feat: 添加登录注册条款确认
This commit is contained in:
parent
6681aee98d
commit
e872cbec0b
@ -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
|
||||
|
||||
@ -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"`
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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服务器地址
|
||||
|
||||
@ -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],
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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;
|
||||
|
||||
221
frontend/src/components/auth/LoginAgreementPrompt.vue
Normal file
221
frontend/src/components/auth/LoginAgreementPrompt.vue
Normal 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>
|
||||
@ -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',
|
||||
|
||||
@ -5234,6 +5234,7 @@ export default {
|
||||
description: '管理注册、邮箱验证、默认值和 SMTP 设置',
|
||||
tabs: {
|
||||
general: '通用设置',
|
||||
agreement: '登录条款',
|
||||
features: '功能开关',
|
||||
security: '安全与认证',
|
||||
users: '用户默认值',
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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')
|
||||
|
||||
@ -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')
|
||||
|
||||
241
frontend/src/views/public/LegalDocumentView.vue
Normal file
241
frontend/src/views/public/LegalDocumentView.vue
Normal 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>
|
||||
Loading…
x
Reference in New Issue
Block a user