feat: 添加登录注册条款确认

This commit is contained in:
shaw 2026-05-07 17:35:05 +08:00
parent 6681aee98d
commit e872cbec0b
16 changed files with 1524 additions and 124 deletions

View File

@ -117,6 +117,10 @@ func (h *SettingHandler) GetSettings(c *gin.Context) {
InvitationCodeEnabled: settings.InvitationCodeEnabled,
TotpEnabled: settings.TotpEnabled,
TotpEncryptionKeyConfigured: h.settingService.IsTotpEncryptionKeyConfigured(),
LoginAgreementEnabled: settings.LoginAgreementEnabled,
LoginAgreementMode: settings.LoginAgreementMode,
LoginAgreementUpdatedAt: settings.LoginAgreementUpdatedAt,
LoginAgreementDocuments: loginAgreementDocumentsToDTO(settings.LoginAgreementDocuments),
SMTPHost: settings.SMTPHost,
SMTPPort: settings.SMTPPort,
SMTPUsername: settings.SMTPUsername,
@ -305,17 +309,50 @@ func openaiFastPolicySettingsFromDTO(s *dto.OpenAIFastPolicySettings) *service.O
return &service.OpenAIFastPolicySettings{Rules: rules}
}
func loginAgreementDocumentsToDTO(items []service.LoginAgreementDocument) []dto.LoginAgreementDocument {
result := make([]dto.LoginAgreementDocument, 0, len(items))
for _, item := range items {
result = append(result, dto.LoginAgreementDocument{
ID: item.ID,
Title: item.Title,
ContentMD: item.ContentMD,
})
}
return result
}
func loginAgreementDocumentsToService(items []dto.LoginAgreementDocument) []service.LoginAgreementDocument {
result := make([]service.LoginAgreementDocument, 0, len(items))
for _, item := range items {
title := strings.TrimSpace(item.Title)
content := strings.TrimSpace(item.ContentMD)
if title == "" && content == "" {
continue
}
result = append(result, service.LoginAgreementDocument{
ID: strings.TrimSpace(item.ID),
Title: title,
ContentMD: content,
})
}
return result
}
// UpdateSettingsRequest 更新设置请求
type UpdateSettingsRequest struct {
// 注册设置
RegistrationEnabled bool `json:"registration_enabled"`
EmailVerifyEnabled bool `json:"email_verify_enabled"`
RegistrationEmailSuffixWhitelist []string `json:"registration_email_suffix_whitelist"`
PromoCodeEnabled bool `json:"promo_code_enabled"`
PasswordResetEnabled bool `json:"password_reset_enabled"`
FrontendURL string `json:"frontend_url"`
InvitationCodeEnabled bool `json:"invitation_code_enabled"`
TotpEnabled bool `json:"totp_enabled"` // TOTP 双因素认证
RegistrationEnabled bool `json:"registration_enabled"`
EmailVerifyEnabled bool `json:"email_verify_enabled"`
RegistrationEmailSuffixWhitelist []string `json:"registration_email_suffix_whitelist"`
PromoCodeEnabled bool `json:"promo_code_enabled"`
PasswordResetEnabled bool `json:"password_reset_enabled"`
FrontendURL string `json:"frontend_url"`
InvitationCodeEnabled bool `json:"invitation_code_enabled"`
TotpEnabled bool `json:"totp_enabled"` // TOTP 双因素认证
LoginAgreementEnabled bool `json:"login_agreement_enabled"`
LoginAgreementMode string `json:"login_agreement_mode"`
LoginAgreementUpdatedAt string `json:"login_agreement_updated_at"`
LoginAgreementDocuments []dto.LoginAgreementDocument `json:"login_agreement_documents"`
// 邮件服务设置
SMTPHost string `json:"smtp_host"`
@ -668,6 +705,44 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
return
}
}
loginAgreementMode := strings.ToLower(strings.TrimSpace(req.LoginAgreementMode))
if loginAgreementMode == "" {
loginAgreementMode = strings.ToLower(strings.TrimSpace(previousSettings.LoginAgreementMode))
}
switch loginAgreementMode {
case "", "modal":
loginAgreementMode = "modal"
case "checkbox":
default:
response.BadRequest(c, "Login agreement mode must be modal or checkbox")
return
}
loginAgreementUpdatedAt := strings.TrimSpace(req.LoginAgreementUpdatedAt)
if loginAgreementUpdatedAt == "" {
loginAgreementUpdatedAt = strings.TrimSpace(previousSettings.LoginAgreementUpdatedAt)
}
loginAgreementDocuments := loginAgreementDocumentsToService(req.LoginAgreementDocuments)
if len(loginAgreementDocuments) == 0 {
loginAgreementDocuments = previousSettings.LoginAgreementDocuments
}
for _, doc := range loginAgreementDocuments {
if strings.TrimSpace(doc.Title) == "" {
response.BadRequest(c, "Login agreement document title is required")
return
}
if len(doc.Title) > 80 {
response.BadRequest(c, "Login agreement document title is too long (max 80 characters)")
return
}
if len(doc.ContentMD) > 200*1024 {
response.BadRequest(c, "Login agreement document content is too large (max 200KB)")
return
}
}
if req.LoginAgreementEnabled && len(loginAgreementDocuments) == 0 {
response.BadRequest(c, "Login agreement documents are required when enabled")
return
}
// LinuxDo Connect 参数验证
if req.LinuxDoConnectEnabled {
@ -1193,6 +1268,10 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
FrontendURL: req.FrontendURL,
InvitationCodeEnabled: req.InvitationCodeEnabled,
TotpEnabled: req.TotpEnabled,
LoginAgreementEnabled: req.LoginAgreementEnabled,
LoginAgreementMode: loginAgreementMode,
LoginAgreementUpdatedAt: loginAgreementUpdatedAt,
LoginAgreementDocuments: loginAgreementDocuments,
SMTPHost: req.SMTPHost,
SMTPPort: req.SMTPPort,
SMTPUsername: req.SMTPUsername,
@ -1561,6 +1640,10 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
InvitationCodeEnabled: updatedSettings.InvitationCodeEnabled,
TotpEnabled: updatedSettings.TotpEnabled,
TotpEncryptionKeyConfigured: h.settingService.IsTotpEncryptionKeyConfigured(),
LoginAgreementEnabled: updatedSettings.LoginAgreementEnabled,
LoginAgreementMode: updatedSettings.LoginAgreementMode,
LoginAgreementUpdatedAt: updatedSettings.LoginAgreementUpdatedAt,
LoginAgreementDocuments: loginAgreementDocumentsToDTO(updatedSettings.LoginAgreementDocuments),
SMTPHost: updatedSettings.SMTPHost,
SMTPPort: updatedSettings.SMTPPort,
SMTPUsername: updatedSettings.SMTPUsername,
@ -1772,6 +1855,18 @@ func diffSettings(before *service.SystemSettings, after *service.SystemSettings,
if before.TotpEnabled != after.TotpEnabled {
changed = append(changed, "totp_enabled")
}
if before.LoginAgreementEnabled != after.LoginAgreementEnabled {
changed = append(changed, "login_agreement_enabled")
}
if before.LoginAgreementMode != after.LoginAgreementMode {
changed = append(changed, "login_agreement_mode")
}
if before.LoginAgreementUpdatedAt != after.LoginAgreementUpdatedAt {
changed = append(changed, "login_agreement_updated_at")
}
if !equalLoginAgreementDocuments(before.LoginAgreementDocuments, after.LoginAgreementDocuments) {
changed = append(changed, "login_agreement_documents")
}
if before.SMTPHost != after.SMTPHost {
changed = append(changed, "smtp_host")
}
@ -2272,6 +2367,18 @@ func equalDefaultSubscriptions(a, b []service.DefaultSubscriptionSetting) bool {
return true
}
func equalLoginAgreementDocuments(a, b []service.LoginAgreementDocument) bool {
if len(a) != len(b) {
return false
}
for i := range a {
if a[i].ID != b[i].ID || a[i].Title != b[i].Title || a[i].ContentMD != b[i].ContentMD {
return false
}
}
return true
}
func equalIntSlice(a, b []int) bool {
if len(a) != len(b) {
return false

View File

@ -25,15 +25,19 @@ type CustomEndpoint struct {
// SystemSettings represents the admin settings API response payload.
type SystemSettings struct {
RegistrationEnabled bool `json:"registration_enabled"`
EmailVerifyEnabled bool `json:"email_verify_enabled"`
RegistrationEmailSuffixWhitelist []string `json:"registration_email_suffix_whitelist"`
PromoCodeEnabled bool `json:"promo_code_enabled"`
PasswordResetEnabled bool `json:"password_reset_enabled"`
FrontendURL string `json:"frontend_url"`
InvitationCodeEnabled bool `json:"invitation_code_enabled"`
TotpEnabled bool `json:"totp_enabled"` // TOTP 双因素认证
TotpEncryptionKeyConfigured bool `json:"totp_encryption_key_configured"` // TOTP 加密密钥是否已配置
RegistrationEnabled bool `json:"registration_enabled"`
EmailVerifyEnabled bool `json:"email_verify_enabled"`
RegistrationEmailSuffixWhitelist []string `json:"registration_email_suffix_whitelist"`
PromoCodeEnabled bool `json:"promo_code_enabled"`
PasswordResetEnabled bool `json:"password_reset_enabled"`
FrontendURL string `json:"frontend_url"`
InvitationCodeEnabled bool `json:"invitation_code_enabled"`
TotpEnabled bool `json:"totp_enabled"` // TOTP 双因素认证
TotpEncryptionKeyConfigured bool `json:"totp_encryption_key_configured"` // TOTP 加密密钥是否已配置
LoginAgreementEnabled bool `json:"login_agreement_enabled"`
LoginAgreementMode string `json:"login_agreement_mode"`
LoginAgreementUpdatedAt string `json:"login_agreement_updated_at"`
LoginAgreementDocuments []LoginAgreementDocument `json:"login_agreement_documents"`
SMTPHost string `json:"smtp_host"`
SMTPPort int `json:"smtp_port"`
@ -225,47 +229,52 @@ type DefaultSubscriptionSetting struct {
}
type PublicSettings struct {
RegistrationEnabled bool `json:"registration_enabled"`
EmailVerifyEnabled bool `json:"email_verify_enabled"`
ForceEmailOnThirdPartySignup bool `json:"force_email_on_third_party_signup"`
RegistrationEmailSuffixWhitelist []string `json:"registration_email_suffix_whitelist"`
PromoCodeEnabled bool `json:"promo_code_enabled"`
PasswordResetEnabled bool `json:"password_reset_enabled"`
InvitationCodeEnabled bool `json:"invitation_code_enabled"`
TotpEnabled bool `json:"totp_enabled"` // TOTP 双因素认证
TurnstileEnabled bool `json:"turnstile_enabled"`
TurnstileSiteKey string `json:"turnstile_site_key"`
SiteName string `json:"site_name"`
SiteLogo string `json:"site_logo"`
SiteSubtitle string `json:"site_subtitle"`
APIBaseURL string `json:"api_base_url"`
ContactInfo string `json:"contact_info"`
DocURL string `json:"doc_url"`
HomeContent string `json:"home_content"`
HideCcsImportButton bool `json:"hide_ccs_import_button"`
PurchaseSubscriptionEnabled bool `json:"purchase_subscription_enabled"`
PurchaseSubscriptionURL string `json:"purchase_subscription_url"`
TableDefaultPageSize int `json:"table_default_page_size"`
TablePageSizeOptions []int `json:"table_page_size_options"`
CustomMenuItems []CustomMenuItem `json:"custom_menu_items"`
CustomEndpoints []CustomEndpoint `json:"custom_endpoints"`
LinuxDoOAuthEnabled bool `json:"linuxdo_oauth_enabled"`
WeChatOAuthEnabled bool `json:"wechat_oauth_enabled"`
WeChatOAuthOpenEnabled bool `json:"wechat_oauth_open_enabled"`
WeChatOAuthMPEnabled bool `json:"wechat_oauth_mp_enabled"`
WeChatOAuthMobileEnabled bool `json:"wechat_oauth_mobile_enabled"`
OIDCOAuthEnabled bool `json:"oidc_oauth_enabled"`
OIDCOAuthProviderName string `json:"oidc_oauth_provider_name"`
GitHubOAuthEnabled bool `json:"github_oauth_enabled"`
GoogleOAuthEnabled bool `json:"google_oauth_enabled"`
SoraClientEnabled bool `json:"sora_client_enabled"`
BackendModeEnabled bool `json:"backend_mode_enabled"`
PaymentEnabled bool `json:"payment_enabled"`
Version string `json:"version"`
BalanceLowNotifyEnabled bool `json:"balance_low_notify_enabled"`
AccountQuotaNotifyEnabled bool `json:"account_quota_notify_enabled"`
BalanceLowNotifyThreshold float64 `json:"balance_low_notify_threshold"`
BalanceLowNotifyRechargeURL string `json:"balance_low_notify_recharge_url"`
RegistrationEnabled bool `json:"registration_enabled"`
EmailVerifyEnabled bool `json:"email_verify_enabled"`
ForceEmailOnThirdPartySignup bool `json:"force_email_on_third_party_signup"`
RegistrationEmailSuffixWhitelist []string `json:"registration_email_suffix_whitelist"`
PromoCodeEnabled bool `json:"promo_code_enabled"`
PasswordResetEnabled bool `json:"password_reset_enabled"`
InvitationCodeEnabled bool `json:"invitation_code_enabled"`
TotpEnabled bool `json:"totp_enabled"` // TOTP 双因素认证
LoginAgreementEnabled bool `json:"login_agreement_enabled"`
LoginAgreementMode string `json:"login_agreement_mode"`
LoginAgreementUpdatedAt string `json:"login_agreement_updated_at"`
LoginAgreementRevision string `json:"login_agreement_revision"`
LoginAgreementDocuments []LoginAgreementDocument `json:"login_agreement_documents"`
TurnstileEnabled bool `json:"turnstile_enabled"`
TurnstileSiteKey string `json:"turnstile_site_key"`
SiteName string `json:"site_name"`
SiteLogo string `json:"site_logo"`
SiteSubtitle string `json:"site_subtitle"`
APIBaseURL string `json:"api_base_url"`
ContactInfo string `json:"contact_info"`
DocURL string `json:"doc_url"`
HomeContent string `json:"home_content"`
HideCcsImportButton bool `json:"hide_ccs_import_button"`
PurchaseSubscriptionEnabled bool `json:"purchase_subscription_enabled"`
PurchaseSubscriptionURL string `json:"purchase_subscription_url"`
TableDefaultPageSize int `json:"table_default_page_size"`
TablePageSizeOptions []int `json:"table_page_size_options"`
CustomMenuItems []CustomMenuItem `json:"custom_menu_items"`
CustomEndpoints []CustomEndpoint `json:"custom_endpoints"`
LinuxDoOAuthEnabled bool `json:"linuxdo_oauth_enabled"`
WeChatOAuthEnabled bool `json:"wechat_oauth_enabled"`
WeChatOAuthOpenEnabled bool `json:"wechat_oauth_open_enabled"`
WeChatOAuthMPEnabled bool `json:"wechat_oauth_mp_enabled"`
WeChatOAuthMobileEnabled bool `json:"wechat_oauth_mobile_enabled"`
OIDCOAuthEnabled bool `json:"oidc_oauth_enabled"`
OIDCOAuthProviderName string `json:"oidc_oauth_provider_name"`
GitHubOAuthEnabled bool `json:"github_oauth_enabled"`
GoogleOAuthEnabled bool `json:"google_oauth_enabled"`
SoraClientEnabled bool `json:"sora_client_enabled"`
BackendModeEnabled bool `json:"backend_mode_enabled"`
PaymentEnabled bool `json:"payment_enabled"`
Version string `json:"version"`
BalanceLowNotifyEnabled bool `json:"balance_low_notify_enabled"`
AccountQuotaNotifyEnabled bool `json:"account_quota_notify_enabled"`
BalanceLowNotifyThreshold float64 `json:"balance_low_notify_threshold"`
BalanceLowNotifyRechargeURL string `json:"balance_low_notify_recharge_url"`
ChannelMonitorEnabled bool `json:"channel_monitor_enabled"`
ChannelMonitorDefaultIntervalSeconds int `json:"channel_monitor_default_interval_seconds"`
@ -277,6 +286,12 @@ type PublicSettings struct {
RiskControlEnabled bool `json:"risk_control_enabled"`
}
type LoginAgreementDocument struct {
ID string `json:"id"`
Title string `json:"title"`
ContentMD string `json:"content_md"`
}
// OverloadCooldownSettings 529过载冷却配置 DTO
type OverloadCooldownSettings struct {
Enabled bool `json:"enabled"`

View File

@ -40,6 +40,11 @@ func (h *SettingHandler) GetPublicSettings(c *gin.Context) {
PasswordResetEnabled: settings.PasswordResetEnabled,
InvitationCodeEnabled: settings.InvitationCodeEnabled,
TotpEnabled: settings.TotpEnabled,
LoginAgreementEnabled: settings.LoginAgreementEnabled,
LoginAgreementMode: settings.LoginAgreementMode,
LoginAgreementUpdatedAt: settings.LoginAgreementUpdatedAt,
LoginAgreementRevision: settings.LoginAgreementRevision,
LoginAgreementDocuments: publicLoginAgreementDocumentsToDTO(settings.LoginAgreementDocuments),
TurnstileEnabled: settings.TurnstileEnabled,
TurnstileSiteKey: settings.TurnstileSiteKey,
SiteName: settings.SiteName,
@ -83,3 +88,15 @@ func (h *SettingHandler) GetPublicSettings(c *gin.Context) {
RiskControlEnabled: settings.RiskControlEnabled,
})
}
func publicLoginAgreementDocumentsToDTO(items []service.LoginAgreementDocument) []dto.LoginAgreementDocument {
result := make([]dto.LoginAgreementDocument, 0, len(items))
for _, item := range items {
result = append(result, dto.LoginAgreementDocument{
ID: item.ID,
Title: item.Title,
ContentMD: item.ContentMD,
})
}
return result
}

View File

@ -109,6 +109,10 @@ const (
SettingKeyAffiliateRebatePerInviteeCap = "affiliate_rebate_per_invitee_cap" // 单人返利上限0=无上限)
SettingKeyRiskControlEnabled = "risk_control_enabled" // 是否启用风控中心入口与审计链路
SettingKeyContentModerationConfig = "content_moderation_config" // 内容审计配置JSON
SettingKeyLoginAgreementEnabled = "login_agreement_enabled" // 登录前是否要求同意条款
SettingKeyLoginAgreementMode = "login_agreement_mode" // 条款确认展示模式modal / checkbox
SettingKeyLoginAgreementUpdatedAt = "login_agreement_updated_at" // 条款更新日期(展示用)
SettingKeyLoginAgreementDocuments = "login_agreement_documents" // 条款文档列表JSONMarkdown 内容)
// 邮件服务设置
SettingKeySMTPHost = "smtp_host" // SMTP服务器地址

View File

@ -3,6 +3,7 @@ package service
import (
"context"
"crypto/rand"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
@ -204,8 +205,140 @@ const (
defaultGoogleOAuthUserInfo = "https://openidconnect.googleapis.com/v1/userinfo"
defaultGoogleOAuthScopes = "openid email profile"
defaultGoogleOAuthFrontend = "/auth/oauth/callback"
defaultLoginAgreementMode = "modal"
defaultLoginAgreementDate = "2026-03-31"
)
func normalizeLoginAgreementMode(raw string) string {
switch strings.ToLower(strings.TrimSpace(raw)) {
case "checkbox":
return "checkbox"
default:
return defaultLoginAgreementMode
}
}
func defaultLoginAgreementDocuments() []LoginAgreementDocument {
return []LoginAgreementDocument{
{
ID: "terms",
Title: "服务条款",
ContentMD: "",
},
{
ID: "usage-policy",
Title: "使用政策",
ContentMD: "",
},
{
ID: "supported-regions",
Title: "支持的国家和地区",
ContentMD: "",
},
{
ID: "service-specific-terms",
Title: "服务特定条款",
ContentMD: "",
},
}
}
func normalizeLoginAgreementDocumentID(raw string) string {
raw = strings.ToLower(strings.TrimSpace(raw))
var b strings.Builder
lastSeparator := false
for _, r := range raw {
if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') {
b.WriteRune(r)
lastSeparator = false
continue
}
if r == '-' || r == '_' || r == ' ' || r == '.' || r == '/' {
if !lastSeparator && b.Len() > 0 {
if r == '_' {
b.WriteRune('_')
} else {
b.WriteRune('-')
}
lastSeparator = true
}
}
}
return strings.Trim(b.String(), "-_")
}
func normalizeLoginAgreementDocuments(docs []LoginAgreementDocument) []LoginAgreementDocument {
normalized := make([]LoginAgreementDocument, 0, len(docs))
seen := make(map[string]int, len(docs))
for i, doc := range docs {
title := strings.TrimSpace(doc.Title)
content := strings.TrimSpace(doc.ContentMD)
if title == "" && content == "" {
continue
}
id := normalizeLoginAgreementDocumentID(doc.ID)
if id == "" {
sum := sha256.Sum256([]byte(fmt.Sprintf("%d:%s:%s", i, title, content)))
id = hex.EncodeToString(sum[:])[:12]
}
baseID := id
for suffix := 2; seen[id] > 0; suffix++ {
id = fmt.Sprintf("%s-%d", baseID, suffix)
}
seen[id]++
normalized = append(normalized, LoginAgreementDocument{
ID: id,
Title: title,
ContentMD: content,
})
}
return normalized
}
func parseLoginAgreementDocuments(raw string) []LoginAgreementDocument {
raw = strings.TrimSpace(raw)
if raw == "" {
return defaultLoginAgreementDocuments()
}
var docs []LoginAgreementDocument
if err := json.Unmarshal([]byte(raw), &docs); err != nil {
return defaultLoginAgreementDocuments()
}
docs = normalizeLoginAgreementDocuments(docs)
if len(docs) == 0 {
return defaultLoginAgreementDocuments()
}
return docs
}
func marshalLoginAgreementDocuments(docs []LoginAgreementDocument) (string, error) {
normalized := normalizeLoginAgreementDocuments(docs)
if len(normalized) == 0 {
normalized = defaultLoginAgreementDocuments()
}
b, err := json.Marshal(normalized)
if err != nil {
return "", fmt.Errorf("marshal login agreement documents: %w", err)
}
return string(b), nil
}
func buildLoginAgreementRevision(updatedAt string, docs []LoginAgreementDocument) string {
normalized := normalizeLoginAgreementDocuments(docs)
payload, err := json.Marshal(struct {
UpdatedAt string `json:"updated_at"`
Documents []LoginAgreementDocument `json:"documents"`
}{
UpdatedAt: strings.TrimSpace(updatedAt),
Documents: normalized,
})
if err != nil {
payload = []byte(strings.TrimSpace(updatedAt))
}
sum := sha256.Sum256(payload)
return hex.EncodeToString(sum[:])[:16]
}
func normalizeWeChatConnectModeSetting(raw string) string {
switch strings.ToLower(strings.TrimSpace(raw)) {
case "mp":
@ -438,6 +571,10 @@ func (s *SettingService) GetPublicSettings(ctx context.Context) (*PublicSettings
SettingKeyPasswordResetEnabled,
SettingKeyInvitationCodeEnabled,
SettingKeyTotpEnabled,
SettingKeyLoginAgreementEnabled,
SettingKeyLoginAgreementMode,
SettingKeyLoginAgreementUpdatedAt,
SettingKeyLoginAgreementDocuments,
SettingKeyTurnstileEnabled,
SettingKeyTurnstileSiteKey,
SettingKeySiteName,
@ -530,6 +667,11 @@ func (s *SettingService) GetPublicSettings(ctx context.Context) (*PublicSettings
settings[SettingKeyTableDefaultPageSize],
settings[SettingKeyTablePageSizeOptions],
)
loginAgreementDocuments := parseLoginAgreementDocuments(settings[SettingKeyLoginAgreementDocuments])
loginAgreementUpdatedAt := strings.TrimSpace(settings[SettingKeyLoginAgreementUpdatedAt])
if loginAgreementUpdatedAt == "" {
loginAgreementUpdatedAt = defaultLoginAgreementDate
}
var balanceLowNotifyThreshold float64
if v, err := strconv.ParseFloat(settings[SettingKeyBalanceLowNotifyThreshold], 64); err == nil && v >= 0 {
@ -545,6 +687,11 @@ func (s *SettingService) GetPublicSettings(ctx context.Context) (*PublicSettings
PasswordResetEnabled: passwordResetEnabled,
InvitationCodeEnabled: settings[SettingKeyInvitationCodeEnabled] == "true",
TotpEnabled: settings[SettingKeyTotpEnabled] == "true",
LoginAgreementEnabled: settings[SettingKeyLoginAgreementEnabled] == "true" && len(loginAgreementDocuments) > 0,
LoginAgreementMode: normalizeLoginAgreementMode(settings[SettingKeyLoginAgreementMode]),
LoginAgreementUpdatedAt: loginAgreementUpdatedAt,
LoginAgreementRevision: buildLoginAgreementRevision(loginAgreementUpdatedAt, loginAgreementDocuments),
LoginAgreementDocuments: loginAgreementDocuments,
TurnstileEnabled: settings[SettingKeyTurnstileEnabled] == "true",
TurnstileSiteKey: settings[SettingKeyTurnstileSiteKey],
SiteName: s.getStringOrDefault(settings, SettingKeySiteName, "Sub2API"),
@ -687,45 +834,50 @@ func (s *SettingService) SetVersion(version string) {
// A unit test diffs this struct's JSON keys against dto.PublicSettings to catch
// drift automatically (see setting_service_injection_test.go).
type PublicSettingsInjectionPayload struct {
RegistrationEnabled bool `json:"registration_enabled"`
EmailVerifyEnabled bool `json:"email_verify_enabled"`
RegistrationEmailSuffixWhitelist []string `json:"registration_email_suffix_whitelist"`
PromoCodeEnabled bool `json:"promo_code_enabled"`
PasswordResetEnabled bool `json:"password_reset_enabled"`
InvitationCodeEnabled bool `json:"invitation_code_enabled"`
TotpEnabled bool `json:"totp_enabled"`
TurnstileEnabled bool `json:"turnstile_enabled"`
TurnstileSiteKey string `json:"turnstile_site_key"`
SiteName string `json:"site_name"`
SiteLogo string `json:"site_logo"`
SiteSubtitle string `json:"site_subtitle"`
APIBaseURL string `json:"api_base_url"`
ContactInfo string `json:"contact_info"`
DocURL string `json:"doc_url"`
HomeContent string `json:"home_content"`
HideCcsImportButton bool `json:"hide_ccs_import_button"`
PurchaseSubscriptionEnabled bool `json:"purchase_subscription_enabled"`
PurchaseSubscriptionURL string `json:"purchase_subscription_url"`
TableDefaultPageSize int `json:"table_default_page_size"`
TablePageSizeOptions []int `json:"table_page_size_options"`
CustomMenuItems json.RawMessage `json:"custom_menu_items"`
CustomEndpoints json.RawMessage `json:"custom_endpoints"`
LinuxDoOAuthEnabled bool `json:"linuxdo_oauth_enabled"`
WeChatOAuthEnabled bool `json:"wechat_oauth_enabled"`
WeChatOAuthOpenEnabled bool `json:"wechat_oauth_open_enabled"`
WeChatOAuthMPEnabled bool `json:"wechat_oauth_mp_enabled"`
WeChatOAuthMobileEnabled bool `json:"wechat_oauth_mobile_enabled"`
OIDCOAuthEnabled bool `json:"oidc_oauth_enabled"`
OIDCOAuthProviderName string `json:"oidc_oauth_provider_name"`
GitHubOAuthEnabled bool `json:"github_oauth_enabled"`
GoogleOAuthEnabled bool `json:"google_oauth_enabled"`
BackendModeEnabled bool `json:"backend_mode_enabled"`
PaymentEnabled bool `json:"payment_enabled"`
Version string `json:"version"`
BalanceLowNotifyEnabled bool `json:"balance_low_notify_enabled"`
AccountQuotaNotifyEnabled bool `json:"account_quota_notify_enabled"`
BalanceLowNotifyThreshold float64 `json:"balance_low_notify_threshold"`
BalanceLowNotifyRechargeURL string `json:"balance_low_notify_recharge_url"`
RegistrationEnabled bool `json:"registration_enabled"`
EmailVerifyEnabled bool `json:"email_verify_enabled"`
RegistrationEmailSuffixWhitelist []string `json:"registration_email_suffix_whitelist"`
PromoCodeEnabled bool `json:"promo_code_enabled"`
PasswordResetEnabled bool `json:"password_reset_enabled"`
InvitationCodeEnabled bool `json:"invitation_code_enabled"`
TotpEnabled bool `json:"totp_enabled"`
LoginAgreementEnabled bool `json:"login_agreement_enabled"`
LoginAgreementMode string `json:"login_agreement_mode"`
LoginAgreementUpdatedAt string `json:"login_agreement_updated_at"`
LoginAgreementRevision string `json:"login_agreement_revision"`
LoginAgreementDocuments []LoginAgreementDocument `json:"login_agreement_documents"`
TurnstileEnabled bool `json:"turnstile_enabled"`
TurnstileSiteKey string `json:"turnstile_site_key"`
SiteName string `json:"site_name"`
SiteLogo string `json:"site_logo"`
SiteSubtitle string `json:"site_subtitle"`
APIBaseURL string `json:"api_base_url"`
ContactInfo string `json:"contact_info"`
DocURL string `json:"doc_url"`
HomeContent string `json:"home_content"`
HideCcsImportButton bool `json:"hide_ccs_import_button"`
PurchaseSubscriptionEnabled bool `json:"purchase_subscription_enabled"`
PurchaseSubscriptionURL string `json:"purchase_subscription_url"`
TableDefaultPageSize int `json:"table_default_page_size"`
TablePageSizeOptions []int `json:"table_page_size_options"`
CustomMenuItems json.RawMessage `json:"custom_menu_items"`
CustomEndpoints json.RawMessage `json:"custom_endpoints"`
LinuxDoOAuthEnabled bool `json:"linuxdo_oauth_enabled"`
WeChatOAuthEnabled bool `json:"wechat_oauth_enabled"`
WeChatOAuthOpenEnabled bool `json:"wechat_oauth_open_enabled"`
WeChatOAuthMPEnabled bool `json:"wechat_oauth_mp_enabled"`
WeChatOAuthMobileEnabled bool `json:"wechat_oauth_mobile_enabled"`
OIDCOAuthEnabled bool `json:"oidc_oauth_enabled"`
OIDCOAuthProviderName string `json:"oidc_oauth_provider_name"`
GitHubOAuthEnabled bool `json:"github_oauth_enabled"`
GoogleOAuthEnabled bool `json:"google_oauth_enabled"`
BackendModeEnabled bool `json:"backend_mode_enabled"`
PaymentEnabled bool `json:"payment_enabled"`
Version string `json:"version"`
BalanceLowNotifyEnabled bool `json:"balance_low_notify_enabled"`
AccountQuotaNotifyEnabled bool `json:"account_quota_notify_enabled"`
BalanceLowNotifyThreshold float64 `json:"balance_low_notify_threshold"`
BalanceLowNotifyRechargeURL string `json:"balance_low_notify_recharge_url"`
// Feature flags — MUST match the opt-in/opt-out registry in
// frontend/src/utils/featureFlags.ts. Missing a field here is the bug
@ -753,6 +905,11 @@ func (s *SettingService) GetPublicSettingsForInjection(ctx context.Context) (any
PasswordResetEnabled: settings.PasswordResetEnabled,
InvitationCodeEnabled: settings.InvitationCodeEnabled,
TotpEnabled: settings.TotpEnabled,
LoginAgreementEnabled: settings.LoginAgreementEnabled,
LoginAgreementMode: settings.LoginAgreementMode,
LoginAgreementUpdatedAt: settings.LoginAgreementUpdatedAt,
LoginAgreementRevision: settings.LoginAgreementRevision,
LoginAgreementDocuments: settings.LoginAgreementDocuments,
TurnstileEnabled: settings.TurnstileEnabled,
TurnstileSiteKey: settings.TurnstileSiteKey,
SiteName: settings.SiteName,
@ -1216,6 +1373,19 @@ func (s *SettingService) buildSystemSettingsUpdates(ctx context.Context, setting
updates[SettingKeyFrontendURL] = settings.FrontendURL
updates[SettingKeyInvitationCodeEnabled] = strconv.FormatBool(settings.InvitationCodeEnabled)
updates[SettingKeyTotpEnabled] = strconv.FormatBool(settings.TotpEnabled)
settings.LoginAgreementMode = normalizeLoginAgreementMode(settings.LoginAgreementMode)
settings.LoginAgreementUpdatedAt = strings.TrimSpace(settings.LoginAgreementUpdatedAt)
if settings.LoginAgreementUpdatedAt == "" {
settings.LoginAgreementUpdatedAt = defaultLoginAgreementDate
}
loginAgreementDocumentsJSON, err := marshalLoginAgreementDocuments(settings.LoginAgreementDocuments)
if err != nil {
return nil, err
}
updates[SettingKeyLoginAgreementEnabled] = strconv.FormatBool(settings.LoginAgreementEnabled)
updates[SettingKeyLoginAgreementMode] = settings.LoginAgreementMode
updates[SettingKeyLoginAgreementUpdatedAt] = settings.LoginAgreementUpdatedAt
updates[SettingKeyLoginAgreementDocuments] = loginAgreementDocumentsJSON
// 邮件服务设置(只有非空才更新密码)
updates[SettingKeySMTPHost] = settings.SMTPHost
@ -2040,6 +2210,10 @@ func (s *SettingService) InitializeDefaultSettings(ctx context.Context) error {
oidcValidateIDTokenDefault = s.cfg.OIDC.ValidateIDToken
}
}
loginAgreementDocumentsJSON, err := marshalLoginAgreementDocuments(defaultLoginAgreementDocuments())
if err != nil {
return err
}
// 初始化默认设置
defaults := map[string]string{
@ -2047,6 +2221,10 @@ func (s *SettingService) InitializeDefaultSettings(ctx context.Context) error {
SettingKeyEmailVerifyEnabled: "false",
SettingKeyRegistrationEmailSuffixWhitelist: "[]",
SettingKeyPromoCodeEnabled: "true", // 默认启用优惠码功能
SettingKeyLoginAgreementEnabled: "false",
SettingKeyLoginAgreementMode: defaultLoginAgreementMode,
SettingKeyLoginAgreementUpdatedAt: defaultLoginAgreementDate,
SettingKeyLoginAgreementDocuments: loginAgreementDocumentsJSON,
SettingKeySiteName: "Sub2API",
SettingKeySiteLogo: "",
SettingKeyPurchaseSubscriptionEnabled: "false",
@ -2193,6 +2371,11 @@ func (s *SettingService) InitializeDefaultSettings(ctx context.Context) error {
// parseSettings 解析设置到结构体
func (s *SettingService) parseSettings(settings map[string]string) *SystemSettings {
emailVerifyEnabled := settings[SettingKeyEmailVerifyEnabled] == "true"
loginAgreementDocuments := parseLoginAgreementDocuments(settings[SettingKeyLoginAgreementDocuments])
loginAgreementUpdatedAt := strings.TrimSpace(settings[SettingKeyLoginAgreementUpdatedAt])
if loginAgreementUpdatedAt == "" {
loginAgreementUpdatedAt = defaultLoginAgreementDate
}
result := &SystemSettings{
RegistrationEnabled: settings[SettingKeyRegistrationEnabled] == "true",
EmailVerifyEnabled: emailVerifyEnabled,
@ -2202,6 +2385,10 @@ func (s *SettingService) parseSettings(settings map[string]string) *SystemSettin
FrontendURL: settings[SettingKeyFrontendURL],
InvitationCodeEnabled: settings[SettingKeyInvitationCodeEnabled] == "true",
TotpEnabled: settings[SettingKeyTotpEnabled] == "true",
LoginAgreementEnabled: settings[SettingKeyLoginAgreementEnabled] == "true",
LoginAgreementMode: normalizeLoginAgreementMode(settings[SettingKeyLoginAgreementMode]),
LoginAgreementUpdatedAt: loginAgreementUpdatedAt,
LoginAgreementDocuments: loginAgreementDocuments,
SMTPHost: settings[SettingKeySMTPHost],
SMTPUsername: settings[SettingKeySMTPUsername],
SMTPFrom: settings[SettingKeySMTPFrom],

View File

@ -20,6 +20,10 @@ type SystemSettings struct {
FrontendURL string
InvitationCodeEnabled bool
TotpEnabled bool // TOTP 双因素认证
LoginAgreementEnabled bool
LoginAgreementMode string
LoginAgreementUpdatedAt string
LoginAgreementDocuments []LoginAgreementDocument
SMTPHost string
SMTPPort int
@ -205,6 +209,11 @@ type PublicSettings struct {
PasswordResetEnabled bool
InvitationCodeEnabled bool
TotpEnabled bool // TOTP 双因素认证
LoginAgreementEnabled bool
LoginAgreementMode string
LoginAgreementUpdatedAt string
LoginAgreementRevision string
LoginAgreementDocuments []LoginAgreementDocument
TurnstileEnabled bool
TurnstileSiteKey string
SiteName string
@ -255,6 +264,12 @@ type PublicSettings struct {
RiskControlEnabled bool `json:"risk_control_enabled"`
}
type LoginAgreementDocument struct {
ID string `json:"id"`
Title string `json:"title"`
ContentMD string `json:"content_md"`
}
type WeChatConnectOAuthConfig struct {
Enabled bool
LegacyAppID string

View File

@ -4,7 +4,12 @@
*/
import { apiClient } from "../client";
import type { CustomMenuItem, CustomEndpoint, NotifyEmailEntry } from "@/types";
import type {
CustomEndpoint,
CustomMenuItem,
LoginAgreementDocument,
NotifyEmailEntry,
} from "@/types";
export interface DefaultSubscriptionSetting {
group_id: number;
@ -314,6 +319,10 @@ export interface SystemSettings {
invitation_code_enabled: boolean;
totp_enabled: boolean; // TOTP 双因素认证
totp_encryption_key_configured: boolean; // TOTP 加密密钥是否已配置
login_agreement_enabled: boolean;
login_agreement_mode: "modal" | "checkbox" | string;
login_agreement_updated_at: string;
login_agreement_documents: LoginAgreementDocument[];
// Default settings
default_balance: number;
affiliate_rebate_rate: number;
@ -528,6 +537,10 @@ export interface UpdateSettingsRequest {
frontend_url?: string;
invitation_code_enabled?: boolean;
totp_enabled?: boolean; // TOTP 双因素认证
login_agreement_enabled?: boolean;
login_agreement_mode?: "modal" | "checkbox" | string;
login_agreement_updated_at?: string;
login_agreement_documents?: LoginAgreementDocument[];
default_balance?: number;
affiliate_rebate_rate?: number;
affiliate_rebate_freeze_hours?: number;

View File

@ -0,0 +1,221 @@
<template>
<div
v-if="mode === 'checkbox' && documents.length > 0"
class="px-0.5"
>
<div class="flex items-start gap-2">
<input
id="login-agreement-consent"
type="checkbox"
:checked="accepted"
class="mt-[2px] h-4 w-4 flex-shrink-0 rounded border-gray-300 text-primary-600 focus:ring-primary-500 dark:border-dark-600 dark:bg-dark-900"
@change="handleCheckboxChange"
/>
<div class="min-w-0 flex-1">
<p class="text-[13px] leading-5 text-gray-600 dark:text-dark-300">
<label
for="login-agreement-consent"
class="cursor-pointer text-gray-700 dark:text-dark-200"
>
我已阅读并同意
</label>
<template v-for="(doc, index) in documents" :key="doc.id || doc.title">
<RouterLink
:to="documentRoute(doc)"
target="_blank"
rel="noopener noreferrer"
class="font-medium text-primary-600 underline-offset-4 transition hover:text-primary-700 hover:underline dark:text-primary-300 dark:hover:text-primary-200"
>
{{ doc.title }}
</RouterLink>
<span v-if="index < documents.length - 1"></span>
</template>
</p>
</div>
</div>
</div>
<div
v-else-if="!accepted && documents.length > 0"
class="rounded-lg border border-primary-100 bg-primary-50/70 p-3 text-sm text-primary-900 dark:border-primary-500/20 dark:bg-primary-500/10 dark:text-primary-100"
>
<div class="flex items-start gap-3">
<Icon name="shield" size="sm" class="mt-0.5 flex-shrink-0 text-primary-600 dark:text-primary-300" />
<div class="min-w-0 flex-1">
<p class="font-medium">继续登录前需要先同意最新条款</p>
<p class="mt-1 text-primary-700 dark:text-primary-200/80">
未同意前账号密码输入和快捷登录会保持禁用
</p>
</div>
<button
type="button"
class="flex-shrink-0 rounded-md bg-primary-600 px-3 py-1.5 text-xs font-medium text-white transition hover:bg-primary-700"
@click="emit('open')"
>
查看条款
</button>
</div>
</div>
<Teleport to="body">
<Transition name="agreement-fade">
<div
v-if="dialogVisible"
class="fixed inset-0 z-[140] flex items-center justify-center overflow-y-auto bg-gray-950/60 p-4 backdrop-blur-sm"
>
<div class="w-full max-w-[600px] overflow-hidden rounded-2xl bg-white shadow-2xl ring-1 ring-black/10 dark:bg-dark-900 dark:ring-white/10">
<div class="border-b border-gray-100 bg-white px-6 py-6 dark:border-dark-800 dark:bg-dark-900">
<div class="flex items-start gap-4">
<span class="flex h-12 w-12 flex-shrink-0 items-center justify-center rounded-xl bg-primary-50 text-primary-700 ring-1 ring-primary-100 dark:bg-primary-500/10 dark:text-primary-300 dark:ring-primary-500/20">
<Icon name="shield" size="md" />
</span>
<div class="min-w-0 flex-1">
<div class="flex flex-wrap items-center gap-2">
<h2 class="text-xl font-bold tracking-normal text-gray-950 dark:text-white">
条款更新通知
</h2>
<span
v-if="updatedAt"
class="rounded-full bg-gray-100 px-2.5 py-1 text-xs font-medium text-gray-600 dark:bg-dark-800 dark:text-dark-300"
>
{{ updatedAt }}
</span>
</div>
<p class="mt-2 text-sm leading-6 text-gray-600 dark:text-dark-300">
我们的服务条款已于 {{ updatedAt || '近期' }} 更新在继续使用服务之前请仔细阅读并同意以下条款
</p>
</div>
</div>
</div>
<div class="max-h-[58vh] overflow-y-auto px-6 py-5">
<div class="mb-3 flex items-center justify-between gap-3">
<p class="text-sm font-semibold text-gray-900 dark:text-white">相关文档</p>
</div>
<div class="grid grid-cols-1 gap-3 sm:grid-cols-2">
<RouterLink
v-for="(doc, index) in documents"
:key="doc.id || doc.title"
:to="documentRoute(doc)"
target="_blank"
rel="noopener noreferrer"
class="group flex min-h-[72px] w-full items-center gap-3 rounded-xl border border-gray-200 bg-gray-50/70 px-4 py-3 text-left transition hover:-translate-y-0.5 hover:border-primary-200 hover:bg-white hover:shadow-sm dark:border-dark-700 dark:bg-dark-800/70 dark:hover:border-primary-500/30 dark:hover:bg-dark-800"
>
<span class="flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-lg bg-white text-gray-700 ring-1 ring-gray-200 transition group-hover:bg-primary-50 group-hover:text-primary-700 group-hover:ring-primary-100 dark:bg-dark-900 dark:text-dark-200 dark:ring-dark-700 dark:group-hover:bg-primary-500/10 dark:group-hover:text-primary-200 dark:group-hover:ring-primary-500/20">
<Icon :name="documentIcon(index, doc.title)" size="sm" />
</span>
<span class="min-w-0 flex-1">
<span class="block truncate text-sm font-semibold text-gray-950 dark:text-white">{{ doc.title }}</span>
</span>
<span class="flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-full text-gray-400 transition group-hover:bg-primary-50 group-hover:text-primary-600 dark:group-hover:bg-primary-500/10 dark:group-hover:text-primary-300">
<Icon name="externalLink" size="sm" />
</span>
</RouterLink>
</div>
</div>
<div class="border-t border-gray-100 bg-gray-50/80 px-6 py-4 dark:border-dark-800 dark:bg-dark-950/60">
<div class="grid grid-cols-2 gap-3">
<button
type="button"
class="rounded-xl border border-gray-200 bg-white px-4 py-3 text-sm font-semibold text-gray-700 transition hover:bg-gray-100 dark:border-dark-700 dark:bg-dark-800 dark:text-dark-200 dark:hover:bg-dark-700"
@click="emit('reject')"
>
拒绝
</button>
<button
type="button"
class="rounded-xl bg-primary-600 px-4 py-3 text-sm font-semibold text-white shadow-sm shadow-primary-600/20 transition hover:bg-primary-700"
@click="emit('accept')"
>
同意并继续
</button>
</div>
</div>
</div>
</div>
</Transition>
</Teleport>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import Icon from '@/components/icons/Icon.vue'
import type { LoginAgreementDocument } from '@/types'
const props = withDefaults(defineProps<{
accepted: boolean
documents: LoginAgreementDocument[]
mode: 'modal' | 'checkbox' | string
updatedAt?: string
visible: boolean
}>(), {
updatedAt: ''
})
const emit = defineEmits<{
accept: []
reject: []
open: []
}>()
const dialogVisible = computed(() => props.visible && documents.value.length > 0)
const documents = computed(() => props.documents.filter((doc) => doc.title.trim()))
const updatedAt = computed(() => props.updatedAt || '')
const accepted = computed(() => props.accepted)
const mode = computed(() => props.mode === 'checkbox' ? 'checkbox' : 'modal')
function documentRoute(doc: LoginAgreementDocument) {
return {
name: 'LegalDocument',
params: {
documentId: doc.id || doc.title,
},
}
}
function handleCheckboxChange(event: Event): void {
const checked = (event.target as HTMLInputElement).checked
if (checked) {
emit('accept')
} else {
emit('reject')
}
}
function documentIcon(index: number, title: string): 'document' | 'shield' | 'globe' | 'cog' {
if (title.includes('政策') || title.includes('隐私')) {
return 'shield'
}
if (title.includes('国家') || title.includes('地区')) {
return 'globe'
}
if (index === 3) {
return 'cog'
}
return 'document'
}
</script>
<style scoped>
.agreement-fade-enter-active,
.agreement-fade-leave-active {
transition: opacity 0.18s ease;
}
.agreement-fade-enter-from,
.agreement-fade-leave-to {
opacity: 0;
}
.agreement-fade-enter-active > div,
.agreement-fade-leave-active > div {
transition: transform 0.18s ease, opacity 0.18s ease;
}
.agreement-fade-enter-from > div,
.agreement-fade-leave-to > div {
opacity: 0;
transform: translateY(8px) scale(0.98);
}
</style>

View File

@ -5071,6 +5071,7 @@ export default {
description: 'Manage registration, email verification, default values, and SMTP settings',
tabs: {
general: 'General',
agreement: 'Agreement',
features: 'Feature Switches',
security: 'Security',
users: 'Users',

View File

@ -5234,6 +5234,7 @@ export default {
description: '管理注册、邮箱验证、默认值和 SMTP 设置',
tabs: {
general: '通用设置',
agreement: '登录条款',
features: '功能开关',
security: '安全与认证',
users: '用户默认值',

View File

@ -144,6 +144,15 @@ const routes: RouteRecordRaw[] = [
title: 'Key Usage',
}
},
{
path: '/legal/:documentId',
name: 'LegalDocument',
component: () => import('@/views/public/LegalDocumentView.vue'),
meta: {
requiresAuth: false,
title: 'Legal Document'
}
},
// ==================== User Routes ====================
{
@ -647,7 +656,7 @@ let authInitialized = false
const navigationLoading = useNavigationLoadingState()
// 延迟初始化预加载,传入 router 实例
let routePrefetch: ReturnType<typeof useRoutePrefetch> | null = null
const BACKEND_MODE_ALLOWED_PATHS = ['/login', '/key-usage', '/setup', '/payment/result']
const BACKEND_MODE_ALLOWED_PATHS = ['/login', '/key-usage', '/setup', '/payment/result', '/legal']
const BACKEND_MODE_CALLBACK_PATHS = [
'/auth/callback',
'/auth/linuxdo/callback',

View File

@ -179,6 +179,12 @@ export interface CustomEndpoint {
description: string
}
export interface LoginAgreementDocument {
id: string
title: string
content_md: string
}
export interface PublicSettings {
registration_enabled: boolean
email_verify_enabled: boolean
@ -187,6 +193,11 @@ export interface PublicSettings {
promo_code_enabled: boolean
password_reset_enabled: boolean
invitation_code_enabled: boolean
login_agreement_enabled?: boolean
login_agreement_mode?: 'modal' | 'checkbox' | string
login_agreement_updated_at?: string
login_agreement_revision?: string
login_agreement_documents?: LoginAgreementDocument[]
turnstile_enabled: boolean
turnstile_site_key: string
site_name: string

View File

@ -3881,11 +3881,11 @@
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
{{ t("admin.settings.site.backendModeDescription") }}
</p>
</div>
<Toggle v-model="form.backend_mode_enabled" />
</div>
</div>
<Toggle v-model="form.backend_mode_enabled" />
</div>
<div class="grid grid-cols-1 gap-6 md:grid-cols-2">
<div class="grid grid-cols-1 gap-6 md:grid-cols-2">
<div>
<label
class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300"
@ -4401,10 +4401,212 @@
</button>
</div>
</div>
</div>
<!-- /Tab: General -->
</div>
<!-- /Tab: General -->
<!-- Tab: Features (功能开关) -->
<!-- Tab: Login Agreement -->
<div v-show="activeTab === 'agreement'" class="space-y-6">
<div class="card">
<div class="border-b border-gray-100 px-6 py-4 dark:border-dark-700">
<div class="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
<div>
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">
{{ localText("登录条款确认", "Login agreement") }}
</h2>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
{{
localText(
"控制登录页是否要求用户先阅读并同意服务条款、隐私政策或其他 Markdown 文档。",
"Control whether the login page requires users to accept Markdown policy documents first.",
)
}}
</p>
</div>
<div class="flex items-center gap-3">
<span class="text-sm text-gray-600 dark:text-gray-300">
{{ form.login_agreement_enabled ? localText("已启用", "Enabled") : localText("未启用", "Disabled") }}
</span>
<Toggle v-model="form.login_agreement_enabled" />
</div>
</div>
</div>
<div class="space-y-6 p-6">
<div class="grid grid-cols-1 gap-5 lg:grid-cols-[minmax(0,1fr)_220px]">
<div>
<label class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">
{{ localText("展示形式", "Display mode") }}
</label>
<div class="grid grid-cols-2 gap-2 rounded-lg bg-gray-100 p-1 dark:bg-dark-700">
<button
type="button"
class="inline-flex items-center justify-center gap-2 rounded-md px-3 py-2 text-sm font-medium transition"
:class="
form.login_agreement_mode === 'modal'
? 'bg-white text-primary-700 shadow-sm dark:bg-dark-800 dark:text-primary-300'
: 'text-gray-600 hover:text-gray-900 dark:text-dark-300 dark:hover:text-white'
"
@click="form.login_agreement_mode = 'modal'"
>
<Icon name="shield" size="sm" />
{{ localText("弹窗", "Modal") }}
</button>
<button
type="button"
class="inline-flex items-center justify-center gap-2 rounded-md px-3 py-2 text-sm font-medium transition"
:class="
form.login_agreement_mode === 'checkbox'
? 'bg-white text-primary-700 shadow-sm dark:bg-dark-800 dark:text-primary-300'
: 'text-gray-600 hover:text-gray-900 dark:text-dark-300 dark:hover:text-white'
"
@click="form.login_agreement_mode = 'checkbox'"
>
<Icon name="checkCircle" size="sm" />
{{ localText("复选框", "Checkbox") }}
</button>
</div>
<p class="mt-1.5 text-xs text-gray-500 dark:text-gray-400">
{{
form.login_agreement_mode === "checkbox"
? localText("复选框会显示在登录按钮下方,未勾选前所有登录入口禁用。", "The checkbox appears below the login button and gates all login actions.")
: localText("弹窗会在登录页打开,用户拒绝后所有登录入口保持禁用。", "The modal opens on the login page and gates all login actions until accepted.")
}}
</p>
</div>
<div>
<label class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">
{{ localText("条款更新日期", "Updated date") }}
</label>
<input
v-model="form.login_agreement_updated_at"
type="date"
class="input"
/>
<p class="mt-1.5 text-xs text-gray-500 dark:text-gray-400">
{{ localText("日期或文档内容变化后,用户需要重新同意。", "Changing the date or content requires fresh consent.") }}
</p>
</div>
</div>
<div>
<div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div>
<h3 class="text-sm font-medium text-gray-900 dark:text-white">
{{ localText("协议文档", "Agreement documents") }}
</h3>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
{{
localText(
"文档名称可自定义,内容按 Markdown 保存。可参考:服务条款、使用政策、支持的国家和地区、服务特定条款。",
"Document titles are customizable and content is saved as Markdown.",
)
}}
</p>
</div>
<button
type="button"
class="btn btn-primary btn-sm inline-flex items-center gap-1.5"
@click="addLoginAgreementDocument"
>
<Icon name="plus" size="sm" />
{{ localText("添加文档", "Add document") }}
</button>
</div>
<div class="mt-4 space-y-3">
<div
v-for="(doc, index) in form.login_agreement_documents"
:key="doc.id || index"
class="rounded-lg border border-gray-200 bg-white p-4 dark:border-dark-700 dark:bg-dark-800/60"
>
<div class="mb-3 flex items-center justify-between gap-3">
<div class="flex min-w-0 items-center gap-3">
<span class="flex h-9 w-9 flex-shrink-0 items-center justify-center rounded-md bg-gray-100 text-gray-700 dark:bg-dark-700 dark:text-dark-200">
<Icon
:name="
index === 1
? 'shield'
: index === 2
? 'globe'
: index === 3
? 'cog'
: 'document'
"
size="sm"
/>
</span>
<div class="min-w-0">
<p class="truncate text-sm font-semibold text-gray-900 dark:text-white">
{{ doc.title || localText("未命名文档", "Untitled document") }}
</p>
<p class="truncate text-xs text-gray-500 dark:text-gray-400">
{{ loginAgreementRoutePath(doc, index) }}
</p>
</div>
</div>
<button
type="button"
class="rounded-md p-2 text-red-400 transition hover:bg-red-50 hover:text-red-600 disabled:cursor-not-allowed disabled:opacity-40 dark:hover:bg-red-900/20"
:disabled="
form.login_agreement_enabled &&
form.login_agreement_documents.length <= 1
"
@click="removeLoginAgreementDocument(index)"
>
<Icon name="trash" size="sm" />
</button>
</div>
<div class="grid grid-cols-1 gap-3 lg:grid-cols-2">
<div>
<label class="mb-1 block text-xs font-medium text-gray-600 dark:text-gray-400">
{{ localText("文档名称", "Document title") }}
</label>
<input
v-model="doc.title"
type="text"
class="input text-sm"
:placeholder="localText('例如:服务条款', 'Example: Terms of Service')"
/>
</div>
<div>
<label class="mb-1 block text-xs font-medium text-gray-600 dark:text-gray-400">
{{ localText("路由标识", "Route slug") }}
</label>
<div class="flex overflow-hidden rounded-lg border border-gray-300 bg-white focus-within:border-primary-500 focus-within:ring-1 focus-within:ring-primary-500 dark:border-dark-600 dark:bg-dark-900">
<span class="inline-flex flex-shrink-0 items-center border-r border-gray-200 bg-gray-50 px-3 text-sm text-gray-500 dark:border-dark-700 dark:bg-dark-800 dark:text-dark-400">
/legal/
</span>
<input
v-model="doc.id"
type="text"
class="min-w-0 flex-1 border-0 bg-transparent px-3 py-2 text-sm text-gray-900 outline-none placeholder:text-gray-400 focus:ring-0 dark:text-white dark:placeholder:text-dark-500"
placeholder="usage-policy"
/>
</div>
</div>
</div>
<div class="mt-3">
<label class="mb-1 block text-xs font-medium text-gray-600 dark:text-gray-400">
{{ localText("Markdown 内容", "Markdown content") }}
</label>
<textarea
v-model="doc.content_md"
rows="8"
class="input font-mono text-sm"
:placeholder="localText('在这里填写正式 Markdown 内容。', 'Write the final Markdown content here.')"
></textarea>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- /Tab: Login Agreement -->
<!-- Tab: Features (功能开关) -->
<div v-show="activeTab === 'features'" class="space-y-6">
<div class="card">
@ -5875,7 +6077,12 @@ import type {
WebSearchProviderConfig,
WebSearchTestResult,
} from "@/api/admin/settings";
import type { AdminGroup, Proxy, NotifyEmailEntry } from "@/types";
import type {
AdminGroup,
LoginAgreementDocument,
NotifyEmailEntry,
Proxy,
} from "@/types";
import type { ProviderInstance } from "@/types/payment";
import AppLayout from "@/components/layout/AppLayout.vue";
import Icon from "@/components/icons/Icon.vue";
@ -5925,6 +6132,7 @@ const paymentMethodsHref = computed(() =>
type SettingsTab =
| "general"
| "agreement"
| "features"
| "security"
| "users"
@ -5935,6 +6143,7 @@ type SettingsTab =
const activeTab = ref<SettingsTab>("general");
const settingsTabs = [
{ key: "general" as SettingsTab, icon: "home" as const },
{ key: "agreement" as SettingsTab, icon: "document" as const },
{ key: "features" as SettingsTab, icon: "bolt" as const },
{ key: "security" as SettingsTab, icon: "shield" as const },
{ key: "users" as SettingsTab, icon: "user" as const },
@ -6029,6 +6238,49 @@ const tablePageSizeMin = 5;
const tablePageSizeMax = 1000;
const tablePageSizeDefault = 20;
function defaultLoginAgreementDocuments(): LoginAgreementDocument[] {
return [
{
id: "terms",
title: "服务条款",
content_md: "",
},
{
id: "usage-policy",
title: "使用政策",
content_md: "",
},
{
id: "supported-regions",
title: "支持的国家和地区",
content_md: "",
},
{
id: "service-specific-terms",
title: "服务特定条款",
content_md: "",
},
];
}
function normalizeLoginAgreementDocumentId(raw: string): string {
return raw
.trim()
.toLowerCase()
.replace(/[^a-z0-9_-]+/g, "-")
.replace(/[-_]{2,}/g, "-")
.replace(/^[-_]+|[-_]+$/g, "");
}
function loginAgreementRoutePath(
doc: LoginAgreementDocument,
index: number,
): string {
const id =
normalizeLoginAgreementDocumentId(doc.id || doc.title) || `doc-${index + 1}`;
return `/legal/${id}`;
}
interface DefaultSubscriptionGroupOption {
value: number;
label: string;
@ -6071,6 +6323,10 @@ const form = reactive<SettingsForm>({
password_reset_enabled: false,
totp_enabled: false,
totp_encryption_key_configured: false,
login_agreement_enabled: false,
login_agreement_mode: "modal",
login_agreement_updated_at: "2026-03-31",
login_agreement_documents: defaultLoginAgreementDocuments(),
default_balance: 0,
affiliate_rebate_rate: 20,
affiliate_rebate_freeze_hours: 0,
@ -6753,6 +7009,43 @@ function removeEndpoint(index: number) {
form.custom_endpoints.splice(index, 1);
}
function addLoginAgreementDocument() {
form.login_agreement_documents.push({
id: `custom-${Date.now().toString(36)}`,
title: "",
content_md: "",
});
}
function removeLoginAgreementDocument(index: number) {
form.login_agreement_documents.splice(index, 1);
}
function normalizeLoginAgreementDocumentsForSave(): LoginAgreementDocument[] {
return form.login_agreement_documents
.map((doc, index) => ({
id:
normalizeLoginAgreementDocumentId(doc.id || doc.title) ||
`doc-${index + 1}`,
title: doc.title.trim(),
content_md: doc.content_md.trim(),
}))
.filter((doc) => doc.title || doc.content_md);
}
function findDuplicateLoginAgreementDocumentId(
documents: LoginAgreementDocument[],
): string | null {
const seen = new Set<string>();
for (const doc of documents) {
if (seen.has(doc.id)) {
return doc.id;
}
seen.add(doc.id);
}
return null;
}
function formatTablePageSizeOptions(options: number[]): string {
return options.join(", ");
}
@ -6797,6 +7090,19 @@ async function loadSettings() {
(form as Record<string, unknown>)[key] = value;
}
}
form.login_agreement_mode =
settings.login_agreement_mode === "checkbox" ? "checkbox" : "modal";
form.login_agreement_updated_at =
settings.login_agreement_updated_at || "2026-03-31";
form.login_agreement_documents =
Array.isArray(settings.login_agreement_documents) &&
settings.login_agreement_documents.length > 0
? settings.login_agreement_documents.map((doc) => ({
id: doc.id || "",
title: doc.title || "",
content_md: doc.content_md || "",
}))
: defaultLoginAgreementDocuments();
Object.assign(authSourceDefaults, buildAuthSourceDefaultsState(settings));
form.backend_mode_enabled = settings.backend_mode_enabled;
form.default_subscriptions = normalizeDefaultSubscriptionSettings(
@ -7008,6 +7314,44 @@ async function saveSettings() {
form.table_default_page_size = normalizedTableDefaultPageSize;
form.table_page_size_options = normalizedTablePageSizeOptions;
const normalizedLoginAgreementDocuments =
normalizeLoginAgreementDocumentsForSave();
if (form.login_agreement_enabled && normalizedLoginAgreementDocuments.length === 0) {
appStore.showError(
localText(
"启用登录条款确认时,至少需要保留一份文档。",
"At least one document is required when login agreement is enabled.",
),
);
return;
}
const emptyTitleDocument = normalizedLoginAgreementDocuments.find(
(doc) => !doc.title,
);
if (emptyTitleDocument) {
appStore.showError(
localText(
"登录条款文档名称不能为空。",
"Login agreement document title cannot be empty.",
),
);
return;
}
const duplicateLoginAgreementDocumentId =
findDuplicateLoginAgreementDocumentId(normalizedLoginAgreementDocuments);
if (duplicateLoginAgreementDocumentId) {
appStore.showError(
localText(
`登录条款文档路由不能重复:/legal/${duplicateLoginAgreementDocumentId}`,
`Login agreement document routes cannot be duplicated: /legal/${duplicateLoginAgreementDocumentId}`,
),
);
return;
}
form.login_agreement_mode =
form.login_agreement_mode === "checkbox" ? "checkbox" : "modal";
form.login_agreement_documents = normalizedLoginAgreementDocuments;
const normalizedDefaultSubscriptions = normalizeDefaultSubscriptionSettings(
form.default_subscriptions,
);
@ -7085,6 +7429,10 @@ async function saveSettings() {
invitation_code_enabled: form.invitation_code_enabled,
password_reset_enabled: form.password_reset_enabled,
totp_enabled: form.totp_enabled,
login_agreement_enabled: form.login_agreement_enabled,
login_agreement_mode: form.login_agreement_mode,
login_agreement_updated_at: form.login_agreement_updated_at,
login_agreement_documents: form.login_agreement_documents,
default_balance: form.default_balance,
affiliate_rebate_rate: Math.min(
100,

View File

@ -28,7 +28,7 @@
required
autofocus
autocomplete="email"
:disabled="isLoading"
:disabled="authActionDisabled"
class="input pl-11"
:class="{ 'input-error': errors.email }"
:placeholder="t('auth.emailPlaceholder')"
@ -51,7 +51,7 @@
:type="showPassword ? 'text' : 'password'"
required
autocomplete="current-password"
:disabled="isLoading"
:disabled="authActionDisabled"
class="input pl-11 pr-11"
:class="{ 'input-error': errors.password }"
:placeholder="t('auth.passwordPlaceholder')"
@ -59,6 +59,7 @@
<button
type="button"
@click="showPassword = !showPassword"
:disabled="authActionDisabled"
class="absolute inset-y-0 right-0 flex items-center pr-3.5 text-gray-400 transition-colors hover:text-gray-600 dark:hover:text-dark-300"
>
<Icon v-if="showPassword" name="eyeOff" size="md" />
@ -91,7 +92,7 @@
<!-- Submit Button -->
<button
type="submit"
:disabled="isLoading || (turnstileEnabled && !turnstileToken)"
:disabled="authActionDisabled || (turnstileEnabled && !turnstileToken)"
class="btn btn-primary w-full"
>
<svg
@ -118,6 +119,18 @@
{{ isLoading ? t('auth.signingIn') : t('auth.signIn') }}
</button>
<LoginAgreementPrompt
v-if="loginAgreementEnabled"
:accepted="agreementAccepted"
:documents="loginAgreementDocuments"
:mode="loginAgreementMode"
:updated-at="loginAgreementUpdatedAt"
:visible="showAgreementModal"
@accept="acceptLoginAgreement"
@reject="rejectLoginAgreement"
@open="showAgreementModal = true"
/>
<div v-if="showOAuthLogin" class="space-y-3 pt-1">
<div class="flex items-center gap-3">
<div class="h-px flex-1 bg-gray-200 dark:bg-dark-700"></div>
@ -128,7 +141,7 @@
</div>
<EmailOAuthButtons
:disabled="isLoading"
:disabled="authActionDisabled"
:github-enabled="githubOAuthEnabled"
:google-enabled="googleOAuthEnabled"
:show-divider="false"
@ -136,17 +149,17 @@
<LinuxDoOAuthSection
v-if="linuxdoOAuthEnabled"
:disabled="isLoading"
:disabled="authActionDisabled"
:show-divider="false"
/>
<WechatOAuthSection
v-if="wechatOAuthEnabled"
:disabled="isLoading"
:disabled="authActionDisabled"
:show-divider="false"
/>
<OidcOAuthSection
v-if="oidcOAuthEnabled"
:disabled="isLoading"
:disabled="authActionDisabled"
:provider-name="oidcOAuthProviderName"
:show-divider="false"
/>
@ -188,16 +201,18 @@ import LinuxDoOAuthSection from '@/components/auth/LinuxDoOAuthSection.vue'
import OidcOAuthSection from '@/components/auth/OidcOAuthSection.vue'
import WechatOAuthSection from '@/components/auth/WechatOAuthSection.vue'
import EmailOAuthButtons from '@/components/auth/EmailOAuthButtons.vue'
import LoginAgreementPrompt from '@/components/auth/LoginAgreementPrompt.vue'
import TotpLoginModal from '@/components/auth/TotpLoginModal.vue'
import Icon from '@/components/icons/Icon.vue'
import TurnstileWidget from '@/components/TurnstileWidget.vue'
import { useAuthStore, useAppStore } from '@/stores'
import { getPublicSettings, isTotp2FARequired, isWeChatWebOAuthEnabled } from '@/api/auth'
import type { TotpLoginResponse } from '@/types'
import type { LoginAgreementDocument, TotpLoginResponse } from '@/types'
import { extractI18nErrorMessage } from '@/utils/apiError'
import { clearAllAffiliateReferralCodes } from '@/utils/oauthAffiliate'
const { t } = useI18n()
const LOGIN_AGREEMENT_STORAGE_KEY = 'sub2api_login_agreement_consent'
// ==================== Router & Stores ====================
@ -210,6 +225,7 @@ const appStore = useAppStore()
const isLoading = ref<boolean>(false)
const errorMessage = ref<string>('')
const showPassword = ref<boolean>(false)
const publicSettingsLoaded = ref<boolean>(false)
// Public settings
const turnstileEnabled = ref<boolean>(false)
@ -222,6 +238,13 @@ const oidcOAuthProviderName = ref<string>('OIDC')
const githubOAuthEnabled = ref<boolean>(false)
const googleOAuthEnabled = ref<boolean>(false)
const passwordResetEnabled = ref<boolean>(false)
const loginAgreementEnabled = ref<boolean>(false)
const loginAgreementMode = ref<'modal' | 'checkbox' | string>('modal')
const loginAgreementUpdatedAt = ref<string>('')
const loginAgreementRevision = ref<string>('')
const loginAgreementDocuments = ref<LoginAgreementDocument[]>([])
const agreementAccepted = ref<boolean>(false)
const showAgreementModal = ref<boolean>(false)
// Turnstile
const turnstileRef = ref<InstanceType<typeof TurnstileWidget> | null>(null)
@ -248,6 +271,14 @@ const validationToastMessage = computed(
() => errors.email || errors.password || errors.turnstile || ''
)
const agreementGateActive = computed(
() => loginAgreementEnabled.value && !agreementAccepted.value
)
const authActionDisabled = computed(
() => isLoading.value || !publicSettingsLoaded.value || agreementGateActive.value
)
const showOAuthLogin = computed(
() =>
!backendModeEnabled.value &&
@ -288,11 +319,78 @@ onMounted(async () => {
googleOAuthEnabled.value = settings.google_oauth_enabled
backendModeEnabled.value = settings.backend_mode_enabled
passwordResetEnabled.value = settings.password_reset_enabled
applyLoginAgreementSettings(settings)
} catch (error) {
console.error('Failed to load public settings:', error)
loginAgreementEnabled.value = false
agreementAccepted.value = true
} finally {
publicSettingsLoaded.value = true
}
})
// ==================== Login Agreement ====================
function applyLoginAgreementSettings(settings: {
login_agreement_enabled?: boolean
login_agreement_mode?: string
login_agreement_updated_at?: string
login_agreement_revision?: string
login_agreement_documents?: LoginAgreementDocument[]
}): void {
const documents = Array.isArray(settings.login_agreement_documents)
? settings.login_agreement_documents.filter((doc) => doc.title?.trim())
: []
loginAgreementDocuments.value = documents
loginAgreementEnabled.value = settings.login_agreement_enabled === true && documents.length > 0
loginAgreementMode.value = settings.login_agreement_mode === 'checkbox' ? 'checkbox' : 'modal'
loginAgreementUpdatedAt.value = settings.login_agreement_updated_at || ''
loginAgreementRevision.value =
settings.login_agreement_revision ||
`${loginAgreementUpdatedAt.value}:${documents.map((doc) => `${doc.id}:${doc.title}`).join('|')}`
agreementAccepted.value = !loginAgreementEnabled.value || hasAcceptedLoginAgreement(loginAgreementRevision.value)
showAgreementModal.value =
loginAgreementEnabled.value && !agreementAccepted.value && loginAgreementMode.value !== 'checkbox'
}
function hasAcceptedLoginAgreement(revision: string): boolean {
if (!revision) {
return false
}
try {
const raw = localStorage.getItem(LOGIN_AGREEMENT_STORAGE_KEY)
if (!raw) {
return false
}
const parsed = JSON.parse(raw) as { revision?: string }
return parsed.revision === revision
} catch {
return false
}
}
function acceptLoginAgreement(): void {
if (loginAgreementRevision.value) {
localStorage.setItem(
LOGIN_AGREEMENT_STORAGE_KEY,
JSON.stringify({
revision: loginAgreementRevision.value,
accepted_at: new Date().toISOString()
})
)
}
agreementAccepted.value = true
showAgreementModal.value = false
}
function rejectLoginAgreement(): void {
localStorage.removeItem(LOGIN_AGREEMENT_STORAGE_KEY)
agreementAccepted.value = false
showAgreementModal.value = false
appStore.showWarning('未同意最新条款前,无法输入账号密码或使用快捷登录。')
}
// ==================== Turnstile Handlers ====================
function onTurnstileVerify(token: string): void {
@ -320,6 +418,14 @@ function validateForm(): boolean {
let isValid = true
if (agreementGateActive.value) {
appStore.showWarning('请先阅读并同意最新条款后再登录。')
if (loginAgreementMode.value !== 'checkbox') {
showAgreementModal.value = true
}
return false
}
// Email validation
if (!formData.email.trim()) {
errors.email = t('auth.emailRequired')

View File

@ -44,7 +44,7 @@
required
autofocus
autocomplete="email"
:disabled="isLoading"
:disabled="registrationActionDisabled"
class="input pl-11"
:class="{ 'input-error': errors.email }"
:placeholder="t('auth.emailPlaceholder')"
@ -67,13 +67,14 @@
:type="showPassword ? 'text' : 'password'"
required
autocomplete="new-password"
:disabled="isLoading"
:disabled="registrationActionDisabled"
class="input pl-11 pr-11"
:class="{ 'input-error': errors.password }"
:placeholder="t('auth.createPasswordPlaceholder')"
/>
<button
type="button"
:disabled="registrationActionDisabled"
@click="showPassword = !showPassword"
class="absolute inset-y-0 right-0 flex items-center pr-3.5 text-gray-400 transition-colors hover:text-gray-600 dark:hover:text-dark-300"
>
@ -99,7 +100,7 @@
id="invitation_code"
v-model="formData.invitation_code"
type="text"
:disabled="isLoading"
:disabled="registrationActionDisabled"
class="input pl-11 pr-10"
:class="{
'border-green-500 focus:border-green-500 focus:ring-green-500': invitationValidation.valid,
@ -147,7 +148,7 @@
id="promo_code"
v-model="formData.promo_code"
type="text"
:disabled="isLoading"
:disabled="registrationActionDisabled"
class="input pl-11 pr-10"
:class="{
'border-green-500 focus:border-green-500 focus:ring-green-500': promoValidation.valid,
@ -192,10 +193,22 @@
/>
</div>
<LoginAgreementPrompt
v-if="loginAgreementEnabled"
:accepted="agreementAccepted"
:documents="loginAgreementDocuments"
:mode="loginAgreementMode"
:updated-at="loginAgreementUpdatedAt"
:visible="showAgreementModal"
@accept="acceptLoginAgreement"
@reject="rejectLoginAgreement"
@open="showAgreementModal = true"
/>
<!-- Submit Button -->
<button
type="submit"
:disabled="isLoading || (turnstileEnabled && !turnstileToken)"
:disabled="registrationActionDisabled || (turnstileEnabled && !turnstileToken)"
class="btn btn-primary w-full"
>
<svg
@ -240,7 +253,7 @@
</div>
<EmailOAuthButtons
:disabled="isLoading"
:disabled="registrationActionDisabled"
:aff-code="formData.aff_code"
:github-enabled="githubOAuthEnabled"
:google-enabled="googleOAuthEnabled"
@ -249,19 +262,19 @@
<LinuxDoOAuthSection
v-if="linuxdoOAuthEnabled"
:disabled="isLoading"
:disabled="registrationActionDisabled"
:aff-code="formData.aff_code"
:show-divider="false"
/>
<WechatOAuthSection
v-if="wechatOAuthEnabled"
:disabled="isLoading"
:disabled="registrationActionDisabled"
:aff-code="formData.aff_code"
:show-divider="false"
/>
<OidcOAuthSection
v-if="oidcOAuthEnabled"
:disabled="isLoading"
:disabled="registrationActionDisabled"
:provider-name="oidcOAuthProviderName"
:aff-code="formData.aff_code"
:show-divider="false"
@ -293,6 +306,7 @@ import LinuxDoOAuthSection from '@/components/auth/LinuxDoOAuthSection.vue'
import OidcOAuthSection from '@/components/auth/OidcOAuthSection.vue'
import WechatOAuthSection from '@/components/auth/WechatOAuthSection.vue'
import EmailOAuthButtons from '@/components/auth/EmailOAuthButtons.vue'
import LoginAgreementPrompt from '@/components/auth/LoginAgreementPrompt.vue'
import Icon from '@/components/icons/Icon.vue'
import TurnstileWidget from '@/components/TurnstileWidget.vue'
import { useAuthStore, useAppStore } from '@/stores'
@ -312,8 +326,10 @@ import {
loadAffiliateReferralCode,
resolveAffiliateReferralCode
} from '@/utils/oauthAffiliate'
import type { LoginAgreementDocument } from '@/types'
const { t, locale } = useI18n()
const LOGIN_AGREEMENT_STORAGE_KEY = 'sub2api_login_agreement_consent'
// ==================== Router & Stores ====================
@ -344,6 +360,13 @@ const oidcOAuthProviderName = ref<string>('OIDC')
const githubOAuthEnabled = ref<boolean>(false)
const googleOAuthEnabled = ref<boolean>(false)
const registrationEmailSuffixWhitelist = ref<string[]>([])
const loginAgreementEnabled = ref<boolean>(false)
const loginAgreementMode = ref<'modal' | 'checkbox' | string>('modal')
const loginAgreementUpdatedAt = ref<string>('')
const loginAgreementRevision = ref<string>('')
const loginAgreementDocuments = ref<LoginAgreementDocument[]>([])
const agreementAccepted = ref<boolean>(false)
const showAgreementModal = ref<boolean>(false)
// Turnstile
const turnstileRef = ref<InstanceType<typeof TurnstileWidget> | null>(null)
@ -402,6 +425,14 @@ const showOAuthLogin = computed(
googleOAuthEnabled.value
)
const agreementGateActive = computed(
() => loginAgreementEnabled.value && !agreementAccepted.value
)
const registrationActionDisabled = computed(
() => isLoading.value || !settingsLoaded.value || agreementGateActive.value
)
watch(validationToastMessage, (value, previousValue) => {
if (value && value !== previousValue) {
appStore.showError(value)
@ -439,6 +470,7 @@ onMounted(async () => {
registrationEmailSuffixWhitelist.value = normalizeRegistrationEmailSuffixWhitelist(
settings.registration_email_suffix_whitelist || []
)
applyLoginAgreementSettings(settings)
// Read promo code from URL parameter only if promo code is enabled
if (promoCodeEnabled.value) {
@ -452,6 +484,8 @@ onMounted(async () => {
syncAffiliateReferralCode()
} catch (error) {
console.error('Failed to load public settings:', error)
loginAgreementEnabled.value = false
agreementAccepted.value = true
} finally {
settingsLoaded.value = true
}
@ -473,6 +507,68 @@ onUnmounted(() => {
}
})
// ==================== Login Agreement ====================
function applyLoginAgreementSettings(settings: {
login_agreement_enabled?: boolean
login_agreement_mode?: string
login_agreement_updated_at?: string
login_agreement_revision?: string
login_agreement_documents?: LoginAgreementDocument[]
}): void {
const documents = Array.isArray(settings.login_agreement_documents)
? settings.login_agreement_documents.filter((doc) => doc.title?.trim())
: []
loginAgreementDocuments.value = documents
loginAgreementEnabled.value = settings.login_agreement_enabled === true && documents.length > 0
loginAgreementMode.value = settings.login_agreement_mode === 'checkbox' ? 'checkbox' : 'modal'
loginAgreementUpdatedAt.value = settings.login_agreement_updated_at || ''
loginAgreementRevision.value =
settings.login_agreement_revision ||
`${loginAgreementUpdatedAt.value}:${documents.map((doc) => `${doc.id}:${doc.title}`).join('|')}`
agreementAccepted.value = !loginAgreementEnabled.value || hasAcceptedLoginAgreement(loginAgreementRevision.value)
showAgreementModal.value =
loginAgreementEnabled.value && !agreementAccepted.value && loginAgreementMode.value !== 'checkbox'
}
function hasAcceptedLoginAgreement(revision: string): boolean {
if (!revision) {
return false
}
try {
const raw = localStorage.getItem(LOGIN_AGREEMENT_STORAGE_KEY)
if (!raw) {
return false
}
const parsed = JSON.parse(raw) as { revision?: string }
return parsed.revision === revision
} catch {
return false
}
}
function acceptLoginAgreement(): void {
if (loginAgreementRevision.value) {
localStorage.setItem(
LOGIN_AGREEMENT_STORAGE_KEY,
JSON.stringify({
revision: loginAgreementRevision.value,
accepted_at: new Date().toISOString()
})
)
}
agreementAccepted.value = true
showAgreementModal.value = false
}
function rejectLoginAgreement(): void {
localStorage.removeItem(LOGIN_AGREEMENT_STORAGE_KEY)
agreementAccepted.value = false
showAgreementModal.value = false
appStore.showWarning('未同意最新条款前,无法注册或使用快捷登录。')
}
// ==================== Promo Code Validation ====================
function handlePromoCodeInput(): void {
@ -656,6 +752,14 @@ function validateForm(): boolean {
let isValid = true
if (agreementGateActive.value) {
appStore.showWarning('请先阅读并同意最新条款后再注册。')
if (loginAgreementMode.value !== 'checkbox') {
showAgreementModal.value = true
}
return false
}
// Email validation
if (!formData.email.trim()) {
errors.email = t('auth.emailRequired')

View File

@ -0,0 +1,241 @@
<template>
<div class="min-h-screen bg-gray-50 text-gray-900 dark:bg-dark-950 dark:text-white">
<header class="border-b border-gray-200 bg-white/95 dark:border-dark-800 dark:bg-dark-900/95">
<div class="mx-auto flex max-w-5xl items-center justify-between gap-4 px-4 py-4 sm:px-6">
<RouterLink to="/home" class="flex min-w-0 items-center gap-3">
<span class="flex h-10 w-10 flex-shrink-0 items-center justify-center overflow-hidden rounded-xl bg-white shadow-sm ring-1 ring-gray-200 dark:bg-dark-800 dark:ring-dark-700">
<img :src="siteLogo || '/logo.png'" alt="Logo" class="h-full w-full object-contain" />
</span>
<span class="truncate text-base font-semibold text-gray-950 dark:text-white">
{{ siteName }}
</span>
</RouterLink>
<RouterLink
to="/login"
class="inline-flex flex-shrink-0 items-center justify-center rounded-lg bg-primary-600 px-4 py-2 text-sm font-semibold text-white shadow-sm shadow-primary-600/20 transition hover:bg-primary-700"
>
登录
</RouterLink>
</div>
</header>
<main class="mx-auto max-w-4xl px-4 py-8 sm:px-6 lg:py-10">
<div v-if="loading" class="flex min-h-[320px] items-center justify-center">
<div class="h-8 w-8 animate-spin rounded-full border-b-2 border-primary-600"></div>
</div>
<section
v-else-if="loadError"
class="rounded-lg border border-red-200 bg-red-50 p-6 text-red-700 dark:border-red-500/30 dark:bg-red-500/10 dark:text-red-200"
>
<h1 class="text-lg font-semibold">文档加载失败</h1>
<p class="mt-2 text-sm">请稍后刷新页面重试</p>
</section>
<section
v-else-if="!currentDocument"
class="rounded-lg border border-gray-200 bg-white p-6 dark:border-dark-700 dark:bg-dark-900"
>
<div class="flex items-start gap-3">
<span class="flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-md bg-gray-100 text-gray-600 dark:bg-dark-800 dark:text-dark-300">
<Icon name="document" size="sm" />
</span>
<div>
<h1 class="text-lg font-semibold text-gray-900 dark:text-white">文档不存在</h1>
<p class="mt-2 text-sm leading-6 text-gray-600 dark:text-dark-300">
当前条款文档不存在或已被管理员移除
</p>
</div>
</div>
</section>
<article v-else>
<div class="mb-8 border-b border-gray-200 pb-6 dark:border-dark-700">
<div class="flex items-start gap-4">
<span class="flex h-12 w-12 flex-shrink-0 items-center justify-center rounded-md bg-primary-50 text-primary-700 dark:bg-primary-500/10 dark:text-primary-300">
<Icon :name="documentIcon" size="md" />
</span>
<div class="min-w-0">
<p class="text-sm font-medium text-primary-700 dark:text-primary-300">登录条款</p>
<h1 class="mt-2 break-words text-2xl font-bold tracking-normal text-gray-950 dark:text-white sm:text-3xl">
{{ currentDocument.title }}
</h1>
<p v-if="updatedAt" class="mt-3 text-sm text-gray-500 dark:text-dark-400">
更新日期{{ updatedAt }}
</p>
</div>
</div>
</div>
<div
v-if="hasContent"
class="legal-document-content"
v-html="renderedHtml"
></div>
<div
v-else
class="rounded-lg border border-dashed border-gray-300 bg-white px-6 py-14 text-center text-sm text-gray-500 dark:border-dark-700 dark:bg-dark-900 dark:text-dark-400"
>
暂无正文内容
</div>
</article>
</main>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { useRoute } from 'vue-router'
import { marked } from 'marked'
import DOMPurify from 'dompurify'
import Icon from '@/components/icons/Icon.vue'
import { getPublicSettings } from '@/api/auth'
import { sanitizeUrl } from '@/utils/url'
import type { LoginAgreementDocument, PublicSettings } from '@/types'
type LegalDocumentIcon = 'document' | 'shield' | 'globe' | 'cog'
const route = useRoute()
const settings = ref<PublicSettings | null>(null)
const loading = ref(true)
const loadError = ref(false)
marked.setOptions({
breaks: true,
gfm: true,
})
const documentId = computed(() => String(route.params.documentId || ''))
const documents = computed(() => settings.value?.login_agreement_documents ?? [])
const siteName = computed(() => settings.value?.site_name || 'Sub2API')
const siteLogo = computed(() => sanitizeUrl(settings.value?.site_logo || '', {
allowRelative: true,
allowDataUrl: true,
}))
const updatedAt = computed(() => settings.value?.login_agreement_updated_at || '')
const currentDocument = computed<LoginAgreementDocument | null>(() => {
const id = documentId.value
if (!id) {
return null
}
return documents.value.find((doc) => doc.id === id) ?? null
})
const hasContent = computed(() => Boolean(currentDocument.value?.content_md?.trim()))
const renderedHtml = computed(() => {
const content = currentDocument.value?.content_md?.trim() || ''
if (!content) {
return ''
}
const html = marked.parse(content) as string
return DOMPurify.sanitize(html)
})
const documentIcon = computed<LegalDocumentIcon>(() => {
const title = currentDocument.value?.title || ''
if (title.includes('政策') || title.includes('隐私')) {
return 'shield'
}
if (title.includes('国家') || title.includes('地区')) {
return 'globe'
}
if (title.includes('特定')) {
return 'cog'
}
return 'document'
})
onMounted(async () => {
loading.value = true
loadError.value = false
try {
settings.value = await getPublicSettings()
} catch {
loadError.value = true
} finally {
loading.value = false
}
})
</script>
<style scoped>
.legal-document-content {
line-height: 1.75;
overflow-wrap: anywhere;
color: inherit;
}
.legal-document-content :deep(h1) {
@apply mb-4 mt-8 border-b border-gray-200 pb-3 text-3xl font-bold dark:border-dark-700;
}
.legal-document-content :deep(h2) {
@apply mb-3 mt-7 text-2xl font-bold;
}
.legal-document-content :deep(h3) {
@apply mb-2 mt-6 text-xl font-semibold;
}
.legal-document-content :deep(h4) {
@apply mb-2 mt-5 text-lg font-semibold;
}
.legal-document-content :deep(p) {
@apply mb-4 text-gray-700 dark:text-dark-200;
}
.legal-document-content :deep(a) {
@apply text-primary-600 underline underline-offset-4 hover:text-primary-700 dark:text-primary-300 dark:hover:text-primary-200;
}
.legal-document-content :deep(ul) {
@apply mb-4 list-disc pl-6;
}
.legal-document-content :deep(ol) {
@apply mb-4 list-decimal pl-6;
}
.legal-document-content :deep(li) {
@apply mb-1 text-gray-700 dark:text-dark-200;
}
.legal-document-content :deep(blockquote) {
@apply my-5 border-l-4 border-gray-300 pl-4 text-gray-600 dark:border-dark-600 dark:text-dark-300;
}
.legal-document-content :deep(code) {
@apply rounded bg-gray-100 px-1.5 py-0.5 font-mono text-sm dark:bg-dark-800;
}
.legal-document-content :deep(pre) {
@apply my-5 overflow-x-auto rounded-lg bg-gray-950 p-4 text-gray-100;
}
.legal-document-content :deep(pre code) {
@apply bg-transparent p-0 text-inherit;
}
.legal-document-content :deep(table) {
@apply my-5 block w-full overflow-x-auto border-collapse;
}
.legal-document-content :deep(th) {
@apply border border-gray-300 bg-gray-50 px-3 py-2 text-left font-semibold dark:border-dark-600 dark:bg-dark-800;
}
.legal-document-content :deep(td) {
@apply border border-gray-300 px-3 py-2 dark:border-dark-600;
}
.legal-document-content :deep(img) {
@apply my-5 h-auto max-w-full rounded-lg;
}
.legal-document-content :deep(hr) {
@apply my-7 border-gray-200 dark:border-dark-700;
}
</style>