From e872cbec0b6816fe1dd38ba3410ab816c572667a Mon Sep 17 00:00:00 2001 From: shaw Date: Thu, 7 May 2026 17:35:05 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=E7=99=BB=E5=BD=95?= =?UTF-8?q?=E6=B3=A8=E5=86=8C=E6=9D=A1=E6=AC=BE=E7=A1=AE=E8=AE=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../internal/handler/admin/setting_handler.go | 123 +++++- backend/internal/handler/dto/settings.go | 115 +++--- backend/internal/handler/setting_handler.go | 17 + backend/internal/service/domain_constants.go | 4 + backend/internal/service/setting_service.go | 265 +++++++++++-- backend/internal/service/settings_view.go | 15 + frontend/src/api/admin/settings.ts | 15 +- .../components/auth/LoginAgreementPrompt.vue | 221 +++++++++++ frontend/src/i18n/locales/en.ts | 1 + frontend/src/i18n/locales/zh.ts | 1 + frontend/src/router/index.ts | 11 +- frontend/src/types/index.ts | 11 + frontend/src/views/admin/SettingsView.vue | 364 +++++++++++++++++- frontend/src/views/auth/LoginView.vue | 122 +++++- frontend/src/views/auth/RegisterView.vue | 122 +++++- .../src/views/public/LegalDocumentView.vue | 241 ++++++++++++ 16 files changed, 1524 insertions(+), 124 deletions(-) create mode 100644 frontend/src/components/auth/LoginAgreementPrompt.vue create mode 100644 frontend/src/views/public/LegalDocumentView.vue diff --git a/backend/internal/handler/admin/setting_handler.go b/backend/internal/handler/admin/setting_handler.go index e7e3f824..7ad51660 100644 --- a/backend/internal/handler/admin/setting_handler.go +++ b/backend/internal/handler/admin/setting_handler.go @@ -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 diff --git a/backend/internal/handler/dto/settings.go b/backend/internal/handler/dto/settings.go index c9166dfb..2d4cefa1 100644 --- a/backend/internal/handler/dto/settings.go +++ b/backend/internal/handler/dto/settings.go @@ -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"` diff --git a/backend/internal/handler/setting_handler.go b/backend/internal/handler/setting_handler.go index 807bf705..6c389e3d 100644 --- a/backend/internal/handler/setting_handler.go +++ b/backend/internal/handler/setting_handler.go @@ -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 +} diff --git a/backend/internal/service/domain_constants.go b/backend/internal/service/domain_constants.go index a7f92ac9..8eb90a6b 100644 --- a/backend/internal/service/domain_constants.go +++ b/backend/internal/service/domain_constants.go @@ -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" // 条款文档列表(JSON,Markdown 内容) // 邮件服务设置 SettingKeySMTPHost = "smtp_host" // SMTP服务器地址 diff --git a/backend/internal/service/setting_service.go b/backend/internal/service/setting_service.go index 9f3d5245..9dbbaa08 100644 --- a/backend/internal/service/setting_service.go +++ b/backend/internal/service/setting_service.go @@ -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], diff --git a/backend/internal/service/settings_view.go b/backend/internal/service/settings_view.go index 32920066..80b8b32a 100644 --- a/backend/internal/service/settings_view.go +++ b/backend/internal/service/settings_view.go @@ -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 diff --git a/frontend/src/api/admin/settings.ts b/frontend/src/api/admin/settings.ts index 6a4d5e20..01d6969d 100644 --- a/frontend/src/api/admin/settings.ts +++ b/frontend/src/api/admin/settings.ts @@ -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; diff --git a/frontend/src/components/auth/LoginAgreementPrompt.vue b/frontend/src/components/auth/LoginAgreementPrompt.vue new file mode 100644 index 00000000..dd71cbdc --- /dev/null +++ b/frontend/src/components/auth/LoginAgreementPrompt.vue @@ -0,0 +1,221 @@ + + + + + diff --git a/frontend/src/i18n/locales/en.ts b/frontend/src/i18n/locales/en.ts index ef69cefb..90bf23f7 100644 --- a/frontend/src/i18n/locales/en.ts +++ b/frontend/src/i18n/locales/en.ts @@ -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', diff --git a/frontend/src/i18n/locales/zh.ts b/frontend/src/i18n/locales/zh.ts index 7bac08c9..87482f9d 100644 --- a/frontend/src/i18n/locales/zh.ts +++ b/frontend/src/i18n/locales/zh.ts @@ -5234,6 +5234,7 @@ export default { description: '管理注册、邮箱验证、默认值和 SMTP 设置', tabs: { general: '通用设置', + agreement: '登录条款', features: '功能开关', security: '安全与认证', users: '用户默认值', diff --git a/frontend/src/router/index.ts b/frontend/src/router/index.ts index 6c60995a..9e7111a4 100644 --- a/frontend/src/router/index.ts +++ b/frontend/src/router/index.ts @@ -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 | 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', diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index c5e3c0d1..328b7c04 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -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 diff --git a/frontend/src/views/admin/SettingsView.vue b/frontend/src/views/admin/SettingsView.vue index 879f19d9..26a88bc2 100644 --- a/frontend/src/views/admin/SettingsView.vue +++ b/frontend/src/views/admin/SettingsView.vue @@ -3881,11 +3881,11 @@

{{ t("admin.settings.site.backendModeDescription") }}

- - - + + + -
+
-
- + + - + +
+
+
+
+
+

+ {{ localText("登录条款确认", "Login agreement") }} +

+

+ {{ + localText( + "控制登录页是否要求用户先阅读并同意服务条款、隐私政策或其他 Markdown 文档。", + "Control whether the login page requires users to accept Markdown policy documents first.", + ) + }} +

+
+
+ + {{ form.login_agreement_enabled ? localText("已启用", "Enabled") : localText("未启用", "Disabled") }} + + +
+
+
+ +
+
+
+ +
+ + +
+

+ {{ + 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.") + }} +

+
+ +
+ + +

+ {{ localText("日期或文档内容变化后,用户需要重新同意。", "Changing the date or content requires fresh consent.") }} +

+
+
+ +
+
+
+

+ {{ localText("协议文档", "Agreement documents") }} +

+

+ {{ + localText( + "文档名称可自定义,内容按 Markdown 保存。可参考:服务条款、使用政策、支持的国家和地区、服务特定条款。", + "Document titles are customizable and content is saved as Markdown.", + ) + }} +

+
+ +
+ +
+
+
+
+ + + +
+

+ {{ doc.title || localText("未命名文档", "Untitled document") }} +

+

+ {{ loginAgreementRoutePath(doc, index) }} +

+
+
+ +
+ +
+
+ + +
+
+ +
+ + /legal/ + + +
+
+
+
+ + +
+
+
+
+
+
+
+ + +
@@ -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("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({ 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(); + 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)[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, diff --git a/frontend/src/views/auth/LoginView.vue b/frontend/src/views/auth/LoginView.vue index 9b3a6def..3e89b079 100644 --- a/frontend/src/views/auth/LoginView.vue +++ b/frontend/src/views/auth/LoginView.vue @@ -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 @@
+ +