feat: 添加登录注册条款确认
This commit is contained in:
@@ -117,6 +117,10 @@ func (h *SettingHandler) GetSettings(c *gin.Context) {
|
|||||||
InvitationCodeEnabled: settings.InvitationCodeEnabled,
|
InvitationCodeEnabled: settings.InvitationCodeEnabled,
|
||||||
TotpEnabled: settings.TotpEnabled,
|
TotpEnabled: settings.TotpEnabled,
|
||||||
TotpEncryptionKeyConfigured: h.settingService.IsTotpEncryptionKeyConfigured(),
|
TotpEncryptionKeyConfigured: h.settingService.IsTotpEncryptionKeyConfigured(),
|
||||||
|
LoginAgreementEnabled: settings.LoginAgreementEnabled,
|
||||||
|
LoginAgreementMode: settings.LoginAgreementMode,
|
||||||
|
LoginAgreementUpdatedAt: settings.LoginAgreementUpdatedAt,
|
||||||
|
LoginAgreementDocuments: loginAgreementDocumentsToDTO(settings.LoginAgreementDocuments),
|
||||||
SMTPHost: settings.SMTPHost,
|
SMTPHost: settings.SMTPHost,
|
||||||
SMTPPort: settings.SMTPPort,
|
SMTPPort: settings.SMTPPort,
|
||||||
SMTPUsername: settings.SMTPUsername,
|
SMTPUsername: settings.SMTPUsername,
|
||||||
@@ -305,17 +309,50 @@ func openaiFastPolicySettingsFromDTO(s *dto.OpenAIFastPolicySettings) *service.O
|
|||||||
return &service.OpenAIFastPolicySettings{Rules: rules}
|
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 更新设置请求
|
// UpdateSettingsRequest 更新设置请求
|
||||||
type UpdateSettingsRequest struct {
|
type UpdateSettingsRequest struct {
|
||||||
// 注册设置
|
// 注册设置
|
||||||
RegistrationEnabled bool `json:"registration_enabled"`
|
RegistrationEnabled bool `json:"registration_enabled"`
|
||||||
EmailVerifyEnabled bool `json:"email_verify_enabled"`
|
EmailVerifyEnabled bool `json:"email_verify_enabled"`
|
||||||
RegistrationEmailSuffixWhitelist []string `json:"registration_email_suffix_whitelist"`
|
RegistrationEmailSuffixWhitelist []string `json:"registration_email_suffix_whitelist"`
|
||||||
PromoCodeEnabled bool `json:"promo_code_enabled"`
|
PromoCodeEnabled bool `json:"promo_code_enabled"`
|
||||||
PasswordResetEnabled bool `json:"password_reset_enabled"`
|
PasswordResetEnabled bool `json:"password_reset_enabled"`
|
||||||
FrontendURL string `json:"frontend_url"`
|
FrontendURL string `json:"frontend_url"`
|
||||||
InvitationCodeEnabled bool `json:"invitation_code_enabled"`
|
InvitationCodeEnabled bool `json:"invitation_code_enabled"`
|
||||||
TotpEnabled bool `json:"totp_enabled"` // TOTP 双因素认证
|
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"`
|
SMTPHost string `json:"smtp_host"`
|
||||||
@@ -668,6 +705,44 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
|
|||||||
return
|
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 参数验证
|
// LinuxDo Connect 参数验证
|
||||||
if req.LinuxDoConnectEnabled {
|
if req.LinuxDoConnectEnabled {
|
||||||
@@ -1193,6 +1268,10 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
|
|||||||
FrontendURL: req.FrontendURL,
|
FrontendURL: req.FrontendURL,
|
||||||
InvitationCodeEnabled: req.InvitationCodeEnabled,
|
InvitationCodeEnabled: req.InvitationCodeEnabled,
|
||||||
TotpEnabled: req.TotpEnabled,
|
TotpEnabled: req.TotpEnabled,
|
||||||
|
LoginAgreementEnabled: req.LoginAgreementEnabled,
|
||||||
|
LoginAgreementMode: loginAgreementMode,
|
||||||
|
LoginAgreementUpdatedAt: loginAgreementUpdatedAt,
|
||||||
|
LoginAgreementDocuments: loginAgreementDocuments,
|
||||||
SMTPHost: req.SMTPHost,
|
SMTPHost: req.SMTPHost,
|
||||||
SMTPPort: req.SMTPPort,
|
SMTPPort: req.SMTPPort,
|
||||||
SMTPUsername: req.SMTPUsername,
|
SMTPUsername: req.SMTPUsername,
|
||||||
@@ -1561,6 +1640,10 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
|
|||||||
InvitationCodeEnabled: updatedSettings.InvitationCodeEnabled,
|
InvitationCodeEnabled: updatedSettings.InvitationCodeEnabled,
|
||||||
TotpEnabled: updatedSettings.TotpEnabled,
|
TotpEnabled: updatedSettings.TotpEnabled,
|
||||||
TotpEncryptionKeyConfigured: h.settingService.IsTotpEncryptionKeyConfigured(),
|
TotpEncryptionKeyConfigured: h.settingService.IsTotpEncryptionKeyConfigured(),
|
||||||
|
LoginAgreementEnabled: updatedSettings.LoginAgreementEnabled,
|
||||||
|
LoginAgreementMode: updatedSettings.LoginAgreementMode,
|
||||||
|
LoginAgreementUpdatedAt: updatedSettings.LoginAgreementUpdatedAt,
|
||||||
|
LoginAgreementDocuments: loginAgreementDocumentsToDTO(updatedSettings.LoginAgreementDocuments),
|
||||||
SMTPHost: updatedSettings.SMTPHost,
|
SMTPHost: updatedSettings.SMTPHost,
|
||||||
SMTPPort: updatedSettings.SMTPPort,
|
SMTPPort: updatedSettings.SMTPPort,
|
||||||
SMTPUsername: updatedSettings.SMTPUsername,
|
SMTPUsername: updatedSettings.SMTPUsername,
|
||||||
@@ -1772,6 +1855,18 @@ func diffSettings(before *service.SystemSettings, after *service.SystemSettings,
|
|||||||
if before.TotpEnabled != after.TotpEnabled {
|
if before.TotpEnabled != after.TotpEnabled {
|
||||||
changed = append(changed, "totp_enabled")
|
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 {
|
if before.SMTPHost != after.SMTPHost {
|
||||||
changed = append(changed, "smtp_host")
|
changed = append(changed, "smtp_host")
|
||||||
}
|
}
|
||||||
@@ -2272,6 +2367,18 @@ func equalDefaultSubscriptions(a, b []service.DefaultSubscriptionSetting) bool {
|
|||||||
return true
|
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 {
|
func equalIntSlice(a, b []int) bool {
|
||||||
if len(a) != len(b) {
|
if len(a) != len(b) {
|
||||||
return false
|
return false
|
||||||
|
|||||||
@@ -25,15 +25,19 @@ type CustomEndpoint struct {
|
|||||||
|
|
||||||
// SystemSettings represents the admin settings API response payload.
|
// SystemSettings represents the admin settings API response payload.
|
||||||
type SystemSettings struct {
|
type SystemSettings struct {
|
||||||
RegistrationEnabled bool `json:"registration_enabled"`
|
RegistrationEnabled bool `json:"registration_enabled"`
|
||||||
EmailVerifyEnabled bool `json:"email_verify_enabled"`
|
EmailVerifyEnabled bool `json:"email_verify_enabled"`
|
||||||
RegistrationEmailSuffixWhitelist []string `json:"registration_email_suffix_whitelist"`
|
RegistrationEmailSuffixWhitelist []string `json:"registration_email_suffix_whitelist"`
|
||||||
PromoCodeEnabled bool `json:"promo_code_enabled"`
|
PromoCodeEnabled bool `json:"promo_code_enabled"`
|
||||||
PasswordResetEnabled bool `json:"password_reset_enabled"`
|
PasswordResetEnabled bool `json:"password_reset_enabled"`
|
||||||
FrontendURL string `json:"frontend_url"`
|
FrontendURL string `json:"frontend_url"`
|
||||||
InvitationCodeEnabled bool `json:"invitation_code_enabled"`
|
InvitationCodeEnabled bool `json:"invitation_code_enabled"`
|
||||||
TotpEnabled bool `json:"totp_enabled"` // TOTP 双因素认证
|
TotpEnabled bool `json:"totp_enabled"` // TOTP 双因素认证
|
||||||
TotpEncryptionKeyConfigured bool `json:"totp_encryption_key_configured"` // 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"`
|
SMTPHost string `json:"smtp_host"`
|
||||||
SMTPPort int `json:"smtp_port"`
|
SMTPPort int `json:"smtp_port"`
|
||||||
@@ -225,47 +229,52 @@ type DefaultSubscriptionSetting struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type PublicSettings struct {
|
type PublicSettings struct {
|
||||||
RegistrationEnabled bool `json:"registration_enabled"`
|
RegistrationEnabled bool `json:"registration_enabled"`
|
||||||
EmailVerifyEnabled bool `json:"email_verify_enabled"`
|
EmailVerifyEnabled bool `json:"email_verify_enabled"`
|
||||||
ForceEmailOnThirdPartySignup bool `json:"force_email_on_third_party_signup"`
|
ForceEmailOnThirdPartySignup bool `json:"force_email_on_third_party_signup"`
|
||||||
RegistrationEmailSuffixWhitelist []string `json:"registration_email_suffix_whitelist"`
|
RegistrationEmailSuffixWhitelist []string `json:"registration_email_suffix_whitelist"`
|
||||||
PromoCodeEnabled bool `json:"promo_code_enabled"`
|
PromoCodeEnabled bool `json:"promo_code_enabled"`
|
||||||
PasswordResetEnabled bool `json:"password_reset_enabled"`
|
PasswordResetEnabled bool `json:"password_reset_enabled"`
|
||||||
InvitationCodeEnabled bool `json:"invitation_code_enabled"`
|
InvitationCodeEnabled bool `json:"invitation_code_enabled"`
|
||||||
TotpEnabled bool `json:"totp_enabled"` // TOTP 双因素认证
|
TotpEnabled bool `json:"totp_enabled"` // TOTP 双因素认证
|
||||||
TurnstileEnabled bool `json:"turnstile_enabled"`
|
LoginAgreementEnabled bool `json:"login_agreement_enabled"`
|
||||||
TurnstileSiteKey string `json:"turnstile_site_key"`
|
LoginAgreementMode string `json:"login_agreement_mode"`
|
||||||
SiteName string `json:"site_name"`
|
LoginAgreementUpdatedAt string `json:"login_agreement_updated_at"`
|
||||||
SiteLogo string `json:"site_logo"`
|
LoginAgreementRevision string `json:"login_agreement_revision"`
|
||||||
SiteSubtitle string `json:"site_subtitle"`
|
LoginAgreementDocuments []LoginAgreementDocument `json:"login_agreement_documents"`
|
||||||
APIBaseURL string `json:"api_base_url"`
|
TurnstileEnabled bool `json:"turnstile_enabled"`
|
||||||
ContactInfo string `json:"contact_info"`
|
TurnstileSiteKey string `json:"turnstile_site_key"`
|
||||||
DocURL string `json:"doc_url"`
|
SiteName string `json:"site_name"`
|
||||||
HomeContent string `json:"home_content"`
|
SiteLogo string `json:"site_logo"`
|
||||||
HideCcsImportButton bool `json:"hide_ccs_import_button"`
|
SiteSubtitle string `json:"site_subtitle"`
|
||||||
PurchaseSubscriptionEnabled bool `json:"purchase_subscription_enabled"`
|
APIBaseURL string `json:"api_base_url"`
|
||||||
PurchaseSubscriptionURL string `json:"purchase_subscription_url"`
|
ContactInfo string `json:"contact_info"`
|
||||||
TableDefaultPageSize int `json:"table_default_page_size"`
|
DocURL string `json:"doc_url"`
|
||||||
TablePageSizeOptions []int `json:"table_page_size_options"`
|
HomeContent string `json:"home_content"`
|
||||||
CustomMenuItems []CustomMenuItem `json:"custom_menu_items"`
|
HideCcsImportButton bool `json:"hide_ccs_import_button"`
|
||||||
CustomEndpoints []CustomEndpoint `json:"custom_endpoints"`
|
PurchaseSubscriptionEnabled bool `json:"purchase_subscription_enabled"`
|
||||||
LinuxDoOAuthEnabled bool `json:"linuxdo_oauth_enabled"`
|
PurchaseSubscriptionURL string `json:"purchase_subscription_url"`
|
||||||
WeChatOAuthEnabled bool `json:"wechat_oauth_enabled"`
|
TableDefaultPageSize int `json:"table_default_page_size"`
|
||||||
WeChatOAuthOpenEnabled bool `json:"wechat_oauth_open_enabled"`
|
TablePageSizeOptions []int `json:"table_page_size_options"`
|
||||||
WeChatOAuthMPEnabled bool `json:"wechat_oauth_mp_enabled"`
|
CustomMenuItems []CustomMenuItem `json:"custom_menu_items"`
|
||||||
WeChatOAuthMobileEnabled bool `json:"wechat_oauth_mobile_enabled"`
|
CustomEndpoints []CustomEndpoint `json:"custom_endpoints"`
|
||||||
OIDCOAuthEnabled bool `json:"oidc_oauth_enabled"`
|
LinuxDoOAuthEnabled bool `json:"linuxdo_oauth_enabled"`
|
||||||
OIDCOAuthProviderName string `json:"oidc_oauth_provider_name"`
|
WeChatOAuthEnabled bool `json:"wechat_oauth_enabled"`
|
||||||
GitHubOAuthEnabled bool `json:"github_oauth_enabled"`
|
WeChatOAuthOpenEnabled bool `json:"wechat_oauth_open_enabled"`
|
||||||
GoogleOAuthEnabled bool `json:"google_oauth_enabled"`
|
WeChatOAuthMPEnabled bool `json:"wechat_oauth_mp_enabled"`
|
||||||
SoraClientEnabled bool `json:"sora_client_enabled"`
|
WeChatOAuthMobileEnabled bool `json:"wechat_oauth_mobile_enabled"`
|
||||||
BackendModeEnabled bool `json:"backend_mode_enabled"`
|
OIDCOAuthEnabled bool `json:"oidc_oauth_enabled"`
|
||||||
PaymentEnabled bool `json:"payment_enabled"`
|
OIDCOAuthProviderName string `json:"oidc_oauth_provider_name"`
|
||||||
Version string `json:"version"`
|
GitHubOAuthEnabled bool `json:"github_oauth_enabled"`
|
||||||
BalanceLowNotifyEnabled bool `json:"balance_low_notify_enabled"`
|
GoogleOAuthEnabled bool `json:"google_oauth_enabled"`
|
||||||
AccountQuotaNotifyEnabled bool `json:"account_quota_notify_enabled"`
|
SoraClientEnabled bool `json:"sora_client_enabled"`
|
||||||
BalanceLowNotifyThreshold float64 `json:"balance_low_notify_threshold"`
|
BackendModeEnabled bool `json:"backend_mode_enabled"`
|
||||||
BalanceLowNotifyRechargeURL string `json:"balance_low_notify_recharge_url"`
|
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"`
|
ChannelMonitorEnabled bool `json:"channel_monitor_enabled"`
|
||||||
ChannelMonitorDefaultIntervalSeconds int `json:"channel_monitor_default_interval_seconds"`
|
ChannelMonitorDefaultIntervalSeconds int `json:"channel_monitor_default_interval_seconds"`
|
||||||
@@ -277,6 +286,12 @@ type PublicSettings struct {
|
|||||||
RiskControlEnabled bool `json:"risk_control_enabled"`
|
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
|
// OverloadCooldownSettings 529过载冷却配置 DTO
|
||||||
type OverloadCooldownSettings struct {
|
type OverloadCooldownSettings struct {
|
||||||
Enabled bool `json:"enabled"`
|
Enabled bool `json:"enabled"`
|
||||||
|
|||||||
@@ -40,6 +40,11 @@ func (h *SettingHandler) GetPublicSettings(c *gin.Context) {
|
|||||||
PasswordResetEnabled: settings.PasswordResetEnabled,
|
PasswordResetEnabled: settings.PasswordResetEnabled,
|
||||||
InvitationCodeEnabled: settings.InvitationCodeEnabled,
|
InvitationCodeEnabled: settings.InvitationCodeEnabled,
|
||||||
TotpEnabled: settings.TotpEnabled,
|
TotpEnabled: settings.TotpEnabled,
|
||||||
|
LoginAgreementEnabled: settings.LoginAgreementEnabled,
|
||||||
|
LoginAgreementMode: settings.LoginAgreementMode,
|
||||||
|
LoginAgreementUpdatedAt: settings.LoginAgreementUpdatedAt,
|
||||||
|
LoginAgreementRevision: settings.LoginAgreementRevision,
|
||||||
|
LoginAgreementDocuments: publicLoginAgreementDocumentsToDTO(settings.LoginAgreementDocuments),
|
||||||
TurnstileEnabled: settings.TurnstileEnabled,
|
TurnstileEnabled: settings.TurnstileEnabled,
|
||||||
TurnstileSiteKey: settings.TurnstileSiteKey,
|
TurnstileSiteKey: settings.TurnstileSiteKey,
|
||||||
SiteName: settings.SiteName,
|
SiteName: settings.SiteName,
|
||||||
@@ -83,3 +88,15 @@ func (h *SettingHandler) GetPublicSettings(c *gin.Context) {
|
|||||||
RiskControlEnabled: settings.RiskControlEnabled,
|
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=无上限)
|
SettingKeyAffiliateRebatePerInviteeCap = "affiliate_rebate_per_invitee_cap" // 单人返利上限(0=无上限)
|
||||||
SettingKeyRiskControlEnabled = "risk_control_enabled" // 是否启用风控中心入口与审计链路
|
SettingKeyRiskControlEnabled = "risk_control_enabled" // 是否启用风控中心入口与审计链路
|
||||||
SettingKeyContentModerationConfig = "content_moderation_config" // 内容审计配置(JSON)
|
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服务器地址
|
SettingKeySMTPHost = "smtp_host" // SMTP服务器地址
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package service
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"crypto/rand"
|
"crypto/rand"
|
||||||
|
"crypto/sha256"
|
||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
@@ -204,8 +205,140 @@ const (
|
|||||||
defaultGoogleOAuthUserInfo = "https://openidconnect.googleapis.com/v1/userinfo"
|
defaultGoogleOAuthUserInfo = "https://openidconnect.googleapis.com/v1/userinfo"
|
||||||
defaultGoogleOAuthScopes = "openid email profile"
|
defaultGoogleOAuthScopes = "openid email profile"
|
||||||
defaultGoogleOAuthFrontend = "/auth/oauth/callback"
|
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 {
|
func normalizeWeChatConnectModeSetting(raw string) string {
|
||||||
switch strings.ToLower(strings.TrimSpace(raw)) {
|
switch strings.ToLower(strings.TrimSpace(raw)) {
|
||||||
case "mp":
|
case "mp":
|
||||||
@@ -438,6 +571,10 @@ func (s *SettingService) GetPublicSettings(ctx context.Context) (*PublicSettings
|
|||||||
SettingKeyPasswordResetEnabled,
|
SettingKeyPasswordResetEnabled,
|
||||||
SettingKeyInvitationCodeEnabled,
|
SettingKeyInvitationCodeEnabled,
|
||||||
SettingKeyTotpEnabled,
|
SettingKeyTotpEnabled,
|
||||||
|
SettingKeyLoginAgreementEnabled,
|
||||||
|
SettingKeyLoginAgreementMode,
|
||||||
|
SettingKeyLoginAgreementUpdatedAt,
|
||||||
|
SettingKeyLoginAgreementDocuments,
|
||||||
SettingKeyTurnstileEnabled,
|
SettingKeyTurnstileEnabled,
|
||||||
SettingKeyTurnstileSiteKey,
|
SettingKeyTurnstileSiteKey,
|
||||||
SettingKeySiteName,
|
SettingKeySiteName,
|
||||||
@@ -530,6 +667,11 @@ func (s *SettingService) GetPublicSettings(ctx context.Context) (*PublicSettings
|
|||||||
settings[SettingKeyTableDefaultPageSize],
|
settings[SettingKeyTableDefaultPageSize],
|
||||||
settings[SettingKeyTablePageSizeOptions],
|
settings[SettingKeyTablePageSizeOptions],
|
||||||
)
|
)
|
||||||
|
loginAgreementDocuments := parseLoginAgreementDocuments(settings[SettingKeyLoginAgreementDocuments])
|
||||||
|
loginAgreementUpdatedAt := strings.TrimSpace(settings[SettingKeyLoginAgreementUpdatedAt])
|
||||||
|
if loginAgreementUpdatedAt == "" {
|
||||||
|
loginAgreementUpdatedAt = defaultLoginAgreementDate
|
||||||
|
}
|
||||||
|
|
||||||
var balanceLowNotifyThreshold float64
|
var balanceLowNotifyThreshold float64
|
||||||
if v, err := strconv.ParseFloat(settings[SettingKeyBalanceLowNotifyThreshold], 64); err == nil && v >= 0 {
|
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,
|
PasswordResetEnabled: passwordResetEnabled,
|
||||||
InvitationCodeEnabled: settings[SettingKeyInvitationCodeEnabled] == "true",
|
InvitationCodeEnabled: settings[SettingKeyInvitationCodeEnabled] == "true",
|
||||||
TotpEnabled: settings[SettingKeyTotpEnabled] == "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",
|
TurnstileEnabled: settings[SettingKeyTurnstileEnabled] == "true",
|
||||||
TurnstileSiteKey: settings[SettingKeyTurnstileSiteKey],
|
TurnstileSiteKey: settings[SettingKeyTurnstileSiteKey],
|
||||||
SiteName: s.getStringOrDefault(settings, SettingKeySiteName, "Sub2API"),
|
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
|
// A unit test diffs this struct's JSON keys against dto.PublicSettings to catch
|
||||||
// drift automatically (see setting_service_injection_test.go).
|
// drift automatically (see setting_service_injection_test.go).
|
||||||
type PublicSettingsInjectionPayload struct {
|
type PublicSettingsInjectionPayload struct {
|
||||||
RegistrationEnabled bool `json:"registration_enabled"`
|
RegistrationEnabled bool `json:"registration_enabled"`
|
||||||
EmailVerifyEnabled bool `json:"email_verify_enabled"`
|
EmailVerifyEnabled bool `json:"email_verify_enabled"`
|
||||||
RegistrationEmailSuffixWhitelist []string `json:"registration_email_suffix_whitelist"`
|
RegistrationEmailSuffixWhitelist []string `json:"registration_email_suffix_whitelist"`
|
||||||
PromoCodeEnabled bool `json:"promo_code_enabled"`
|
PromoCodeEnabled bool `json:"promo_code_enabled"`
|
||||||
PasswordResetEnabled bool `json:"password_reset_enabled"`
|
PasswordResetEnabled bool `json:"password_reset_enabled"`
|
||||||
InvitationCodeEnabled bool `json:"invitation_code_enabled"`
|
InvitationCodeEnabled bool `json:"invitation_code_enabled"`
|
||||||
TotpEnabled bool `json:"totp_enabled"`
|
TotpEnabled bool `json:"totp_enabled"`
|
||||||
TurnstileEnabled bool `json:"turnstile_enabled"`
|
LoginAgreementEnabled bool `json:"login_agreement_enabled"`
|
||||||
TurnstileSiteKey string `json:"turnstile_site_key"`
|
LoginAgreementMode string `json:"login_agreement_mode"`
|
||||||
SiteName string `json:"site_name"`
|
LoginAgreementUpdatedAt string `json:"login_agreement_updated_at"`
|
||||||
SiteLogo string `json:"site_logo"`
|
LoginAgreementRevision string `json:"login_agreement_revision"`
|
||||||
SiteSubtitle string `json:"site_subtitle"`
|
LoginAgreementDocuments []LoginAgreementDocument `json:"login_agreement_documents"`
|
||||||
APIBaseURL string `json:"api_base_url"`
|
TurnstileEnabled bool `json:"turnstile_enabled"`
|
||||||
ContactInfo string `json:"contact_info"`
|
TurnstileSiteKey string `json:"turnstile_site_key"`
|
||||||
DocURL string `json:"doc_url"`
|
SiteName string `json:"site_name"`
|
||||||
HomeContent string `json:"home_content"`
|
SiteLogo string `json:"site_logo"`
|
||||||
HideCcsImportButton bool `json:"hide_ccs_import_button"`
|
SiteSubtitle string `json:"site_subtitle"`
|
||||||
PurchaseSubscriptionEnabled bool `json:"purchase_subscription_enabled"`
|
APIBaseURL string `json:"api_base_url"`
|
||||||
PurchaseSubscriptionURL string `json:"purchase_subscription_url"`
|
ContactInfo string `json:"contact_info"`
|
||||||
TableDefaultPageSize int `json:"table_default_page_size"`
|
DocURL string `json:"doc_url"`
|
||||||
TablePageSizeOptions []int `json:"table_page_size_options"`
|
HomeContent string `json:"home_content"`
|
||||||
CustomMenuItems json.RawMessage `json:"custom_menu_items"`
|
HideCcsImportButton bool `json:"hide_ccs_import_button"`
|
||||||
CustomEndpoints json.RawMessage `json:"custom_endpoints"`
|
PurchaseSubscriptionEnabled bool `json:"purchase_subscription_enabled"`
|
||||||
LinuxDoOAuthEnabled bool `json:"linuxdo_oauth_enabled"`
|
PurchaseSubscriptionURL string `json:"purchase_subscription_url"`
|
||||||
WeChatOAuthEnabled bool `json:"wechat_oauth_enabled"`
|
TableDefaultPageSize int `json:"table_default_page_size"`
|
||||||
WeChatOAuthOpenEnabled bool `json:"wechat_oauth_open_enabled"`
|
TablePageSizeOptions []int `json:"table_page_size_options"`
|
||||||
WeChatOAuthMPEnabled bool `json:"wechat_oauth_mp_enabled"`
|
CustomMenuItems json.RawMessage `json:"custom_menu_items"`
|
||||||
WeChatOAuthMobileEnabled bool `json:"wechat_oauth_mobile_enabled"`
|
CustomEndpoints json.RawMessage `json:"custom_endpoints"`
|
||||||
OIDCOAuthEnabled bool `json:"oidc_oauth_enabled"`
|
LinuxDoOAuthEnabled bool `json:"linuxdo_oauth_enabled"`
|
||||||
OIDCOAuthProviderName string `json:"oidc_oauth_provider_name"`
|
WeChatOAuthEnabled bool `json:"wechat_oauth_enabled"`
|
||||||
GitHubOAuthEnabled bool `json:"github_oauth_enabled"`
|
WeChatOAuthOpenEnabled bool `json:"wechat_oauth_open_enabled"`
|
||||||
GoogleOAuthEnabled bool `json:"google_oauth_enabled"`
|
WeChatOAuthMPEnabled bool `json:"wechat_oauth_mp_enabled"`
|
||||||
BackendModeEnabled bool `json:"backend_mode_enabled"`
|
WeChatOAuthMobileEnabled bool `json:"wechat_oauth_mobile_enabled"`
|
||||||
PaymentEnabled bool `json:"payment_enabled"`
|
OIDCOAuthEnabled bool `json:"oidc_oauth_enabled"`
|
||||||
Version string `json:"version"`
|
OIDCOAuthProviderName string `json:"oidc_oauth_provider_name"`
|
||||||
BalanceLowNotifyEnabled bool `json:"balance_low_notify_enabled"`
|
GitHubOAuthEnabled bool `json:"github_oauth_enabled"`
|
||||||
AccountQuotaNotifyEnabled bool `json:"account_quota_notify_enabled"`
|
GoogleOAuthEnabled bool `json:"google_oauth_enabled"`
|
||||||
BalanceLowNotifyThreshold float64 `json:"balance_low_notify_threshold"`
|
BackendModeEnabled bool `json:"backend_mode_enabled"`
|
||||||
BalanceLowNotifyRechargeURL string `json:"balance_low_notify_recharge_url"`
|
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
|
// Feature flags — MUST match the opt-in/opt-out registry in
|
||||||
// frontend/src/utils/featureFlags.ts. Missing a field here is the bug
|
// 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,
|
PasswordResetEnabled: settings.PasswordResetEnabled,
|
||||||
InvitationCodeEnabled: settings.InvitationCodeEnabled,
|
InvitationCodeEnabled: settings.InvitationCodeEnabled,
|
||||||
TotpEnabled: settings.TotpEnabled,
|
TotpEnabled: settings.TotpEnabled,
|
||||||
|
LoginAgreementEnabled: settings.LoginAgreementEnabled,
|
||||||
|
LoginAgreementMode: settings.LoginAgreementMode,
|
||||||
|
LoginAgreementUpdatedAt: settings.LoginAgreementUpdatedAt,
|
||||||
|
LoginAgreementRevision: settings.LoginAgreementRevision,
|
||||||
|
LoginAgreementDocuments: settings.LoginAgreementDocuments,
|
||||||
TurnstileEnabled: settings.TurnstileEnabled,
|
TurnstileEnabled: settings.TurnstileEnabled,
|
||||||
TurnstileSiteKey: settings.TurnstileSiteKey,
|
TurnstileSiteKey: settings.TurnstileSiteKey,
|
||||||
SiteName: settings.SiteName,
|
SiteName: settings.SiteName,
|
||||||
@@ -1216,6 +1373,19 @@ func (s *SettingService) buildSystemSettingsUpdates(ctx context.Context, setting
|
|||||||
updates[SettingKeyFrontendURL] = settings.FrontendURL
|
updates[SettingKeyFrontendURL] = settings.FrontendURL
|
||||||
updates[SettingKeyInvitationCodeEnabled] = strconv.FormatBool(settings.InvitationCodeEnabled)
|
updates[SettingKeyInvitationCodeEnabled] = strconv.FormatBool(settings.InvitationCodeEnabled)
|
||||||
updates[SettingKeyTotpEnabled] = strconv.FormatBool(settings.TotpEnabled)
|
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
|
updates[SettingKeySMTPHost] = settings.SMTPHost
|
||||||
@@ -2040,6 +2210,10 @@ func (s *SettingService) InitializeDefaultSettings(ctx context.Context) error {
|
|||||||
oidcValidateIDTokenDefault = s.cfg.OIDC.ValidateIDToken
|
oidcValidateIDTokenDefault = s.cfg.OIDC.ValidateIDToken
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
loginAgreementDocumentsJSON, err := marshalLoginAgreementDocuments(defaultLoginAgreementDocuments())
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
// 初始化默认设置
|
// 初始化默认设置
|
||||||
defaults := map[string]string{
|
defaults := map[string]string{
|
||||||
@@ -2047,6 +2221,10 @@ func (s *SettingService) InitializeDefaultSettings(ctx context.Context) error {
|
|||||||
SettingKeyEmailVerifyEnabled: "false",
|
SettingKeyEmailVerifyEnabled: "false",
|
||||||
SettingKeyRegistrationEmailSuffixWhitelist: "[]",
|
SettingKeyRegistrationEmailSuffixWhitelist: "[]",
|
||||||
SettingKeyPromoCodeEnabled: "true", // 默认启用优惠码功能
|
SettingKeyPromoCodeEnabled: "true", // 默认启用优惠码功能
|
||||||
|
SettingKeyLoginAgreementEnabled: "false",
|
||||||
|
SettingKeyLoginAgreementMode: defaultLoginAgreementMode,
|
||||||
|
SettingKeyLoginAgreementUpdatedAt: defaultLoginAgreementDate,
|
||||||
|
SettingKeyLoginAgreementDocuments: loginAgreementDocumentsJSON,
|
||||||
SettingKeySiteName: "Sub2API",
|
SettingKeySiteName: "Sub2API",
|
||||||
SettingKeySiteLogo: "",
|
SettingKeySiteLogo: "",
|
||||||
SettingKeyPurchaseSubscriptionEnabled: "false",
|
SettingKeyPurchaseSubscriptionEnabled: "false",
|
||||||
@@ -2193,6 +2371,11 @@ func (s *SettingService) InitializeDefaultSettings(ctx context.Context) error {
|
|||||||
// parseSettings 解析设置到结构体
|
// parseSettings 解析设置到结构体
|
||||||
func (s *SettingService) parseSettings(settings map[string]string) *SystemSettings {
|
func (s *SettingService) parseSettings(settings map[string]string) *SystemSettings {
|
||||||
emailVerifyEnabled := settings[SettingKeyEmailVerifyEnabled] == "true"
|
emailVerifyEnabled := settings[SettingKeyEmailVerifyEnabled] == "true"
|
||||||
|
loginAgreementDocuments := parseLoginAgreementDocuments(settings[SettingKeyLoginAgreementDocuments])
|
||||||
|
loginAgreementUpdatedAt := strings.TrimSpace(settings[SettingKeyLoginAgreementUpdatedAt])
|
||||||
|
if loginAgreementUpdatedAt == "" {
|
||||||
|
loginAgreementUpdatedAt = defaultLoginAgreementDate
|
||||||
|
}
|
||||||
result := &SystemSettings{
|
result := &SystemSettings{
|
||||||
RegistrationEnabled: settings[SettingKeyRegistrationEnabled] == "true",
|
RegistrationEnabled: settings[SettingKeyRegistrationEnabled] == "true",
|
||||||
EmailVerifyEnabled: emailVerifyEnabled,
|
EmailVerifyEnabled: emailVerifyEnabled,
|
||||||
@@ -2202,6 +2385,10 @@ func (s *SettingService) parseSettings(settings map[string]string) *SystemSettin
|
|||||||
FrontendURL: settings[SettingKeyFrontendURL],
|
FrontendURL: settings[SettingKeyFrontendURL],
|
||||||
InvitationCodeEnabled: settings[SettingKeyInvitationCodeEnabled] == "true",
|
InvitationCodeEnabled: settings[SettingKeyInvitationCodeEnabled] == "true",
|
||||||
TotpEnabled: settings[SettingKeyTotpEnabled] == "true",
|
TotpEnabled: settings[SettingKeyTotpEnabled] == "true",
|
||||||
|
LoginAgreementEnabled: settings[SettingKeyLoginAgreementEnabled] == "true",
|
||||||
|
LoginAgreementMode: normalizeLoginAgreementMode(settings[SettingKeyLoginAgreementMode]),
|
||||||
|
LoginAgreementUpdatedAt: loginAgreementUpdatedAt,
|
||||||
|
LoginAgreementDocuments: loginAgreementDocuments,
|
||||||
SMTPHost: settings[SettingKeySMTPHost],
|
SMTPHost: settings[SettingKeySMTPHost],
|
||||||
SMTPUsername: settings[SettingKeySMTPUsername],
|
SMTPUsername: settings[SettingKeySMTPUsername],
|
||||||
SMTPFrom: settings[SettingKeySMTPFrom],
|
SMTPFrom: settings[SettingKeySMTPFrom],
|
||||||
|
|||||||
@@ -20,6 +20,10 @@ type SystemSettings struct {
|
|||||||
FrontendURL string
|
FrontendURL string
|
||||||
InvitationCodeEnabled bool
|
InvitationCodeEnabled bool
|
||||||
TotpEnabled bool // TOTP 双因素认证
|
TotpEnabled bool // TOTP 双因素认证
|
||||||
|
LoginAgreementEnabled bool
|
||||||
|
LoginAgreementMode string
|
||||||
|
LoginAgreementUpdatedAt string
|
||||||
|
LoginAgreementDocuments []LoginAgreementDocument
|
||||||
|
|
||||||
SMTPHost string
|
SMTPHost string
|
||||||
SMTPPort int
|
SMTPPort int
|
||||||
@@ -205,6 +209,11 @@ type PublicSettings struct {
|
|||||||
PasswordResetEnabled bool
|
PasswordResetEnabled bool
|
||||||
InvitationCodeEnabled bool
|
InvitationCodeEnabled bool
|
||||||
TotpEnabled bool // TOTP 双因素认证
|
TotpEnabled bool // TOTP 双因素认证
|
||||||
|
LoginAgreementEnabled bool
|
||||||
|
LoginAgreementMode string
|
||||||
|
LoginAgreementUpdatedAt string
|
||||||
|
LoginAgreementRevision string
|
||||||
|
LoginAgreementDocuments []LoginAgreementDocument
|
||||||
TurnstileEnabled bool
|
TurnstileEnabled bool
|
||||||
TurnstileSiteKey string
|
TurnstileSiteKey string
|
||||||
SiteName string
|
SiteName string
|
||||||
@@ -255,6 +264,12 @@ type PublicSettings struct {
|
|||||||
RiskControlEnabled bool `json:"risk_control_enabled"`
|
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 {
|
type WeChatConnectOAuthConfig struct {
|
||||||
Enabled bool
|
Enabled bool
|
||||||
LegacyAppID string
|
LegacyAppID string
|
||||||
|
|||||||
@@ -4,7 +4,12 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { apiClient } from "../client";
|
import { apiClient } from "../client";
|
||||||
import type { CustomMenuItem, CustomEndpoint, NotifyEmailEntry } from "@/types";
|
import type {
|
||||||
|
CustomEndpoint,
|
||||||
|
CustomMenuItem,
|
||||||
|
LoginAgreementDocument,
|
||||||
|
NotifyEmailEntry,
|
||||||
|
} from "@/types";
|
||||||
|
|
||||||
export interface DefaultSubscriptionSetting {
|
export interface DefaultSubscriptionSetting {
|
||||||
group_id: number;
|
group_id: number;
|
||||||
@@ -314,6 +319,10 @@ export interface SystemSettings {
|
|||||||
invitation_code_enabled: boolean;
|
invitation_code_enabled: boolean;
|
||||||
totp_enabled: boolean; // TOTP 双因素认证
|
totp_enabled: boolean; // TOTP 双因素认证
|
||||||
totp_encryption_key_configured: 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 settings
|
||||||
default_balance: number;
|
default_balance: number;
|
||||||
affiliate_rebate_rate: number;
|
affiliate_rebate_rate: number;
|
||||||
@@ -528,6 +537,10 @@ export interface UpdateSettingsRequest {
|
|||||||
frontend_url?: string;
|
frontend_url?: string;
|
||||||
invitation_code_enabled?: boolean;
|
invitation_code_enabled?: boolean;
|
||||||
totp_enabled?: boolean; // TOTP 双因素认证
|
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;
|
default_balance?: number;
|
||||||
affiliate_rebate_rate?: number;
|
affiliate_rebate_rate?: number;
|
||||||
affiliate_rebate_freeze_hours?: number;
|
affiliate_rebate_freeze_hours?: number;
|
||||||
|
|||||||
@@ -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',
|
description: 'Manage registration, email verification, default values, and SMTP settings',
|
||||||
tabs: {
|
tabs: {
|
||||||
general: 'General',
|
general: 'General',
|
||||||
|
agreement: 'Agreement',
|
||||||
features: 'Feature Switches',
|
features: 'Feature Switches',
|
||||||
security: 'Security',
|
security: 'Security',
|
||||||
users: 'Users',
|
users: 'Users',
|
||||||
|
|||||||
@@ -5234,6 +5234,7 @@ export default {
|
|||||||
description: '管理注册、邮箱验证、默认值和 SMTP 设置',
|
description: '管理注册、邮箱验证、默认值和 SMTP 设置',
|
||||||
tabs: {
|
tabs: {
|
||||||
general: '通用设置',
|
general: '通用设置',
|
||||||
|
agreement: '登录条款',
|
||||||
features: '功能开关',
|
features: '功能开关',
|
||||||
security: '安全与认证',
|
security: '安全与认证',
|
||||||
users: '用户默认值',
|
users: '用户默认值',
|
||||||
|
|||||||
@@ -144,6 +144,15 @@ const routes: RouteRecordRaw[] = [
|
|||||||
title: 'Key Usage',
|
title: 'Key Usage',
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: '/legal/:documentId',
|
||||||
|
name: 'LegalDocument',
|
||||||
|
component: () => import('@/views/public/LegalDocumentView.vue'),
|
||||||
|
meta: {
|
||||||
|
requiresAuth: false,
|
||||||
|
title: 'Legal Document'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
// ==================== User Routes ====================
|
// ==================== User Routes ====================
|
||||||
{
|
{
|
||||||
@@ -647,7 +656,7 @@ let authInitialized = false
|
|||||||
const navigationLoading = useNavigationLoadingState()
|
const navigationLoading = useNavigationLoadingState()
|
||||||
// 延迟初始化预加载,传入 router 实例
|
// 延迟初始化预加载,传入 router 实例
|
||||||
let routePrefetch: ReturnType<typeof useRoutePrefetch> | null = null
|
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 = [
|
const BACKEND_MODE_CALLBACK_PATHS = [
|
||||||
'/auth/callback',
|
'/auth/callback',
|
||||||
'/auth/linuxdo/callback',
|
'/auth/linuxdo/callback',
|
||||||
|
|||||||
@@ -179,6 +179,12 @@ export interface CustomEndpoint {
|
|||||||
description: string
|
description: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface LoginAgreementDocument {
|
||||||
|
id: string
|
||||||
|
title: string
|
||||||
|
content_md: string
|
||||||
|
}
|
||||||
|
|
||||||
export interface PublicSettings {
|
export interface PublicSettings {
|
||||||
registration_enabled: boolean
|
registration_enabled: boolean
|
||||||
email_verify_enabled: boolean
|
email_verify_enabled: boolean
|
||||||
@@ -187,6 +193,11 @@ export interface PublicSettings {
|
|||||||
promo_code_enabled: boolean
|
promo_code_enabled: boolean
|
||||||
password_reset_enabled: boolean
|
password_reset_enabled: boolean
|
||||||
invitation_code_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_enabled: boolean
|
||||||
turnstile_site_key: string
|
turnstile_site_key: string
|
||||||
site_name: string
|
site_name: string
|
||||||
|
|||||||
@@ -3881,11 +3881,11 @@
|
|||||||
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||||
{{ t("admin.settings.site.backendModeDescription") }}
|
{{ t("admin.settings.site.backendModeDescription") }}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Toggle v-model="form.backend_mode_enabled" />
|
<Toggle v-model="form.backend_mode_enabled" />
|
||||||
</div>
|
</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>
|
<div>
|
||||||
<label
|
<label
|
||||||
class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300"
|
class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300"
|
||||||
@@ -4401,10 +4401,212 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<!-- /Tab: General -->
|
<!-- /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 v-show="activeTab === 'features'" class="space-y-6">
|
||||||
|
|
||||||
<div class="card">
|
<div class="card">
|
||||||
@@ -5875,7 +6077,12 @@ import type {
|
|||||||
WebSearchProviderConfig,
|
WebSearchProviderConfig,
|
||||||
WebSearchTestResult,
|
WebSearchTestResult,
|
||||||
} from "@/api/admin/settings";
|
} 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 type { ProviderInstance } from "@/types/payment";
|
||||||
import AppLayout from "@/components/layout/AppLayout.vue";
|
import AppLayout from "@/components/layout/AppLayout.vue";
|
||||||
import Icon from "@/components/icons/Icon.vue";
|
import Icon from "@/components/icons/Icon.vue";
|
||||||
@@ -5925,6 +6132,7 @@ const paymentMethodsHref = computed(() =>
|
|||||||
|
|
||||||
type SettingsTab =
|
type SettingsTab =
|
||||||
| "general"
|
| "general"
|
||||||
|
| "agreement"
|
||||||
| "features"
|
| "features"
|
||||||
| "security"
|
| "security"
|
||||||
| "users"
|
| "users"
|
||||||
@@ -5935,6 +6143,7 @@ type SettingsTab =
|
|||||||
const activeTab = ref<SettingsTab>("general");
|
const activeTab = ref<SettingsTab>("general");
|
||||||
const settingsTabs = [
|
const settingsTabs = [
|
||||||
{ key: "general" as SettingsTab, icon: "home" as const },
|
{ 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: "features" as SettingsTab, icon: "bolt" as const },
|
||||||
{ key: "security" as SettingsTab, icon: "shield" as const },
|
{ key: "security" as SettingsTab, icon: "shield" as const },
|
||||||
{ key: "users" as SettingsTab, icon: "user" as const },
|
{ key: "users" as SettingsTab, icon: "user" as const },
|
||||||
@@ -6029,6 +6238,49 @@ const tablePageSizeMin = 5;
|
|||||||
const tablePageSizeMax = 1000;
|
const tablePageSizeMax = 1000;
|
||||||
const tablePageSizeDefault = 20;
|
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 {
|
interface DefaultSubscriptionGroupOption {
|
||||||
value: number;
|
value: number;
|
||||||
label: string;
|
label: string;
|
||||||
@@ -6071,6 +6323,10 @@ const form = reactive<SettingsForm>({
|
|||||||
password_reset_enabled: false,
|
password_reset_enabled: false,
|
||||||
totp_enabled: false,
|
totp_enabled: false,
|
||||||
totp_encryption_key_configured: 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,
|
default_balance: 0,
|
||||||
affiliate_rebate_rate: 20,
|
affiliate_rebate_rate: 20,
|
||||||
affiliate_rebate_freeze_hours: 0,
|
affiliate_rebate_freeze_hours: 0,
|
||||||
@@ -6753,6 +7009,43 @@ function removeEndpoint(index: number) {
|
|||||||
form.custom_endpoints.splice(index, 1);
|
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 {
|
function formatTablePageSizeOptions(options: number[]): string {
|
||||||
return options.join(", ");
|
return options.join(", ");
|
||||||
}
|
}
|
||||||
@@ -6797,6 +7090,19 @@ async function loadSettings() {
|
|||||||
(form as Record<string, unknown>)[key] = value;
|
(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));
|
Object.assign(authSourceDefaults, buildAuthSourceDefaultsState(settings));
|
||||||
form.backend_mode_enabled = settings.backend_mode_enabled;
|
form.backend_mode_enabled = settings.backend_mode_enabled;
|
||||||
form.default_subscriptions = normalizeDefaultSubscriptionSettings(
|
form.default_subscriptions = normalizeDefaultSubscriptionSettings(
|
||||||
@@ -7008,6 +7314,44 @@ async function saveSettings() {
|
|||||||
form.table_default_page_size = normalizedTableDefaultPageSize;
|
form.table_default_page_size = normalizedTableDefaultPageSize;
|
||||||
form.table_page_size_options = normalizedTablePageSizeOptions;
|
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(
|
const normalizedDefaultSubscriptions = normalizeDefaultSubscriptionSettings(
|
||||||
form.default_subscriptions,
|
form.default_subscriptions,
|
||||||
);
|
);
|
||||||
@@ -7085,6 +7429,10 @@ async function saveSettings() {
|
|||||||
invitation_code_enabled: form.invitation_code_enabled,
|
invitation_code_enabled: form.invitation_code_enabled,
|
||||||
password_reset_enabled: form.password_reset_enabled,
|
password_reset_enabled: form.password_reset_enabled,
|
||||||
totp_enabled: form.totp_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,
|
default_balance: form.default_balance,
|
||||||
affiliate_rebate_rate: Math.min(
|
affiliate_rebate_rate: Math.min(
|
||||||
100,
|
100,
|
||||||
|
|||||||
@@ -28,7 +28,7 @@
|
|||||||
required
|
required
|
||||||
autofocus
|
autofocus
|
||||||
autocomplete="email"
|
autocomplete="email"
|
||||||
:disabled="isLoading"
|
:disabled="authActionDisabled"
|
||||||
class="input pl-11"
|
class="input pl-11"
|
||||||
:class="{ 'input-error': errors.email }"
|
:class="{ 'input-error': errors.email }"
|
||||||
:placeholder="t('auth.emailPlaceholder')"
|
:placeholder="t('auth.emailPlaceholder')"
|
||||||
@@ -51,7 +51,7 @@
|
|||||||
:type="showPassword ? 'text' : 'password'"
|
:type="showPassword ? 'text' : 'password'"
|
||||||
required
|
required
|
||||||
autocomplete="current-password"
|
autocomplete="current-password"
|
||||||
:disabled="isLoading"
|
:disabled="authActionDisabled"
|
||||||
class="input pl-11 pr-11"
|
class="input pl-11 pr-11"
|
||||||
:class="{ 'input-error': errors.password }"
|
:class="{ 'input-error': errors.password }"
|
||||||
:placeholder="t('auth.passwordPlaceholder')"
|
:placeholder="t('auth.passwordPlaceholder')"
|
||||||
@@ -59,6 +59,7 @@
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@click="showPassword = !showPassword"
|
@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"
|
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" />
|
<Icon v-if="showPassword" name="eyeOff" size="md" />
|
||||||
@@ -91,7 +92,7 @@
|
|||||||
<!-- Submit Button -->
|
<!-- Submit Button -->
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
:disabled="isLoading || (turnstileEnabled && !turnstileToken)"
|
:disabled="authActionDisabled || (turnstileEnabled && !turnstileToken)"
|
||||||
class="btn btn-primary w-full"
|
class="btn btn-primary w-full"
|
||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
@@ -118,6 +119,18 @@
|
|||||||
{{ isLoading ? t('auth.signingIn') : t('auth.signIn') }}
|
{{ isLoading ? t('auth.signingIn') : t('auth.signIn') }}
|
||||||
</button>
|
</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 v-if="showOAuthLogin" class="space-y-3 pt-1">
|
||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center gap-3">
|
||||||
<div class="h-px flex-1 bg-gray-200 dark:bg-dark-700"></div>
|
<div class="h-px flex-1 bg-gray-200 dark:bg-dark-700"></div>
|
||||||
@@ -128,7 +141,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<EmailOAuthButtons
|
<EmailOAuthButtons
|
||||||
:disabled="isLoading"
|
:disabled="authActionDisabled"
|
||||||
:github-enabled="githubOAuthEnabled"
|
:github-enabled="githubOAuthEnabled"
|
||||||
:google-enabled="googleOAuthEnabled"
|
:google-enabled="googleOAuthEnabled"
|
||||||
:show-divider="false"
|
:show-divider="false"
|
||||||
@@ -136,17 +149,17 @@
|
|||||||
|
|
||||||
<LinuxDoOAuthSection
|
<LinuxDoOAuthSection
|
||||||
v-if="linuxdoOAuthEnabled"
|
v-if="linuxdoOAuthEnabled"
|
||||||
:disabled="isLoading"
|
:disabled="authActionDisabled"
|
||||||
:show-divider="false"
|
:show-divider="false"
|
||||||
/>
|
/>
|
||||||
<WechatOAuthSection
|
<WechatOAuthSection
|
||||||
v-if="wechatOAuthEnabled"
|
v-if="wechatOAuthEnabled"
|
||||||
:disabled="isLoading"
|
:disabled="authActionDisabled"
|
||||||
:show-divider="false"
|
:show-divider="false"
|
||||||
/>
|
/>
|
||||||
<OidcOAuthSection
|
<OidcOAuthSection
|
||||||
v-if="oidcOAuthEnabled"
|
v-if="oidcOAuthEnabled"
|
||||||
:disabled="isLoading"
|
:disabled="authActionDisabled"
|
||||||
:provider-name="oidcOAuthProviderName"
|
:provider-name="oidcOAuthProviderName"
|
||||||
:show-divider="false"
|
:show-divider="false"
|
||||||
/>
|
/>
|
||||||
@@ -188,16 +201,18 @@ import LinuxDoOAuthSection from '@/components/auth/LinuxDoOAuthSection.vue'
|
|||||||
import OidcOAuthSection from '@/components/auth/OidcOAuthSection.vue'
|
import OidcOAuthSection from '@/components/auth/OidcOAuthSection.vue'
|
||||||
import WechatOAuthSection from '@/components/auth/WechatOAuthSection.vue'
|
import WechatOAuthSection from '@/components/auth/WechatOAuthSection.vue'
|
||||||
import EmailOAuthButtons from '@/components/auth/EmailOAuthButtons.vue'
|
import EmailOAuthButtons from '@/components/auth/EmailOAuthButtons.vue'
|
||||||
|
import LoginAgreementPrompt from '@/components/auth/LoginAgreementPrompt.vue'
|
||||||
import TotpLoginModal from '@/components/auth/TotpLoginModal.vue'
|
import TotpLoginModal from '@/components/auth/TotpLoginModal.vue'
|
||||||
import Icon from '@/components/icons/Icon.vue'
|
import Icon from '@/components/icons/Icon.vue'
|
||||||
import TurnstileWidget from '@/components/TurnstileWidget.vue'
|
import TurnstileWidget from '@/components/TurnstileWidget.vue'
|
||||||
import { useAuthStore, useAppStore } from '@/stores'
|
import { useAuthStore, useAppStore } from '@/stores'
|
||||||
import { getPublicSettings, isTotp2FARequired, isWeChatWebOAuthEnabled } from '@/api/auth'
|
import { getPublicSettings, isTotp2FARequired, isWeChatWebOAuthEnabled } from '@/api/auth'
|
||||||
import type { TotpLoginResponse } from '@/types'
|
import type { LoginAgreementDocument, TotpLoginResponse } from '@/types'
|
||||||
import { extractI18nErrorMessage } from '@/utils/apiError'
|
import { extractI18nErrorMessage } from '@/utils/apiError'
|
||||||
import { clearAllAffiliateReferralCodes } from '@/utils/oauthAffiliate'
|
import { clearAllAffiliateReferralCodes } from '@/utils/oauthAffiliate'
|
||||||
|
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
|
const LOGIN_AGREEMENT_STORAGE_KEY = 'sub2api_login_agreement_consent'
|
||||||
|
|
||||||
// ==================== Router & Stores ====================
|
// ==================== Router & Stores ====================
|
||||||
|
|
||||||
@@ -210,6 +225,7 @@ const appStore = useAppStore()
|
|||||||
const isLoading = ref<boolean>(false)
|
const isLoading = ref<boolean>(false)
|
||||||
const errorMessage = ref<string>('')
|
const errorMessage = ref<string>('')
|
||||||
const showPassword = ref<boolean>(false)
|
const showPassword = ref<boolean>(false)
|
||||||
|
const publicSettingsLoaded = ref<boolean>(false)
|
||||||
|
|
||||||
// Public settings
|
// Public settings
|
||||||
const turnstileEnabled = ref<boolean>(false)
|
const turnstileEnabled = ref<boolean>(false)
|
||||||
@@ -222,6 +238,13 @@ const oidcOAuthProviderName = ref<string>('OIDC')
|
|||||||
const githubOAuthEnabled = ref<boolean>(false)
|
const githubOAuthEnabled = ref<boolean>(false)
|
||||||
const googleOAuthEnabled = ref<boolean>(false)
|
const googleOAuthEnabled = ref<boolean>(false)
|
||||||
const passwordResetEnabled = 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
|
// Turnstile
|
||||||
const turnstileRef = ref<InstanceType<typeof TurnstileWidget> | null>(null)
|
const turnstileRef = ref<InstanceType<typeof TurnstileWidget> | null>(null)
|
||||||
@@ -248,6 +271,14 @@ const validationToastMessage = computed(
|
|||||||
() => errors.email || errors.password || errors.turnstile || ''
|
() => 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(
|
const showOAuthLogin = computed(
|
||||||
() =>
|
() =>
|
||||||
!backendModeEnabled.value &&
|
!backendModeEnabled.value &&
|
||||||
@@ -288,11 +319,78 @@ onMounted(async () => {
|
|||||||
googleOAuthEnabled.value = settings.google_oauth_enabled
|
googleOAuthEnabled.value = settings.google_oauth_enabled
|
||||||
backendModeEnabled.value = settings.backend_mode_enabled
|
backendModeEnabled.value = settings.backend_mode_enabled
|
||||||
passwordResetEnabled.value = settings.password_reset_enabled
|
passwordResetEnabled.value = settings.password_reset_enabled
|
||||||
|
applyLoginAgreementSettings(settings)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to load public settings:', 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 ====================
|
// ==================== Turnstile Handlers ====================
|
||||||
|
|
||||||
function onTurnstileVerify(token: string): void {
|
function onTurnstileVerify(token: string): void {
|
||||||
@@ -320,6 +418,14 @@ function validateForm(): boolean {
|
|||||||
|
|
||||||
let isValid = true
|
let isValid = true
|
||||||
|
|
||||||
|
if (agreementGateActive.value) {
|
||||||
|
appStore.showWarning('请先阅读并同意最新条款后再登录。')
|
||||||
|
if (loginAgreementMode.value !== 'checkbox') {
|
||||||
|
showAgreementModal.value = true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
// Email validation
|
// Email validation
|
||||||
if (!formData.email.trim()) {
|
if (!formData.email.trim()) {
|
||||||
errors.email = t('auth.emailRequired')
|
errors.email = t('auth.emailRequired')
|
||||||
|
|||||||
@@ -44,7 +44,7 @@
|
|||||||
required
|
required
|
||||||
autofocus
|
autofocus
|
||||||
autocomplete="email"
|
autocomplete="email"
|
||||||
:disabled="isLoading"
|
:disabled="registrationActionDisabled"
|
||||||
class="input pl-11"
|
class="input pl-11"
|
||||||
:class="{ 'input-error': errors.email }"
|
:class="{ 'input-error': errors.email }"
|
||||||
:placeholder="t('auth.emailPlaceholder')"
|
:placeholder="t('auth.emailPlaceholder')"
|
||||||
@@ -67,13 +67,14 @@
|
|||||||
:type="showPassword ? 'text' : 'password'"
|
:type="showPassword ? 'text' : 'password'"
|
||||||
required
|
required
|
||||||
autocomplete="new-password"
|
autocomplete="new-password"
|
||||||
:disabled="isLoading"
|
:disabled="registrationActionDisabled"
|
||||||
class="input pl-11 pr-11"
|
class="input pl-11 pr-11"
|
||||||
:class="{ 'input-error': errors.password }"
|
:class="{ 'input-error': errors.password }"
|
||||||
:placeholder="t('auth.createPasswordPlaceholder')"
|
:placeholder="t('auth.createPasswordPlaceholder')"
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
:disabled="registrationActionDisabled"
|
||||||
@click="showPassword = !showPassword"
|
@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"
|
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"
|
id="invitation_code"
|
||||||
v-model="formData.invitation_code"
|
v-model="formData.invitation_code"
|
||||||
type="text"
|
type="text"
|
||||||
:disabled="isLoading"
|
:disabled="registrationActionDisabled"
|
||||||
class="input pl-11 pr-10"
|
class="input pl-11 pr-10"
|
||||||
:class="{
|
:class="{
|
||||||
'border-green-500 focus:border-green-500 focus:ring-green-500': invitationValidation.valid,
|
'border-green-500 focus:border-green-500 focus:ring-green-500': invitationValidation.valid,
|
||||||
@@ -147,7 +148,7 @@
|
|||||||
id="promo_code"
|
id="promo_code"
|
||||||
v-model="formData.promo_code"
|
v-model="formData.promo_code"
|
||||||
type="text"
|
type="text"
|
||||||
:disabled="isLoading"
|
:disabled="registrationActionDisabled"
|
||||||
class="input pl-11 pr-10"
|
class="input pl-11 pr-10"
|
||||||
:class="{
|
:class="{
|
||||||
'border-green-500 focus:border-green-500 focus:ring-green-500': promoValidation.valid,
|
'border-green-500 focus:border-green-500 focus:ring-green-500': promoValidation.valid,
|
||||||
@@ -192,10 +193,22 @@
|
|||||||
/>
|
/>
|
||||||
</div>
|
</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 -->
|
<!-- Submit Button -->
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
:disabled="isLoading || (turnstileEnabled && !turnstileToken)"
|
:disabled="registrationActionDisabled || (turnstileEnabled && !turnstileToken)"
|
||||||
class="btn btn-primary w-full"
|
class="btn btn-primary w-full"
|
||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
@@ -240,7 +253,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<EmailOAuthButtons
|
<EmailOAuthButtons
|
||||||
:disabled="isLoading"
|
:disabled="registrationActionDisabled"
|
||||||
:aff-code="formData.aff_code"
|
:aff-code="formData.aff_code"
|
||||||
:github-enabled="githubOAuthEnabled"
|
:github-enabled="githubOAuthEnabled"
|
||||||
:google-enabled="googleOAuthEnabled"
|
:google-enabled="googleOAuthEnabled"
|
||||||
@@ -249,19 +262,19 @@
|
|||||||
|
|
||||||
<LinuxDoOAuthSection
|
<LinuxDoOAuthSection
|
||||||
v-if="linuxdoOAuthEnabled"
|
v-if="linuxdoOAuthEnabled"
|
||||||
:disabled="isLoading"
|
:disabled="registrationActionDisabled"
|
||||||
:aff-code="formData.aff_code"
|
:aff-code="formData.aff_code"
|
||||||
:show-divider="false"
|
:show-divider="false"
|
||||||
/>
|
/>
|
||||||
<WechatOAuthSection
|
<WechatOAuthSection
|
||||||
v-if="wechatOAuthEnabled"
|
v-if="wechatOAuthEnabled"
|
||||||
:disabled="isLoading"
|
:disabled="registrationActionDisabled"
|
||||||
:aff-code="formData.aff_code"
|
:aff-code="formData.aff_code"
|
||||||
:show-divider="false"
|
:show-divider="false"
|
||||||
/>
|
/>
|
||||||
<OidcOAuthSection
|
<OidcOAuthSection
|
||||||
v-if="oidcOAuthEnabled"
|
v-if="oidcOAuthEnabled"
|
||||||
:disabled="isLoading"
|
:disabled="registrationActionDisabled"
|
||||||
:provider-name="oidcOAuthProviderName"
|
:provider-name="oidcOAuthProviderName"
|
||||||
:aff-code="formData.aff_code"
|
:aff-code="formData.aff_code"
|
||||||
:show-divider="false"
|
:show-divider="false"
|
||||||
@@ -293,6 +306,7 @@ import LinuxDoOAuthSection from '@/components/auth/LinuxDoOAuthSection.vue'
|
|||||||
import OidcOAuthSection from '@/components/auth/OidcOAuthSection.vue'
|
import OidcOAuthSection from '@/components/auth/OidcOAuthSection.vue'
|
||||||
import WechatOAuthSection from '@/components/auth/WechatOAuthSection.vue'
|
import WechatOAuthSection from '@/components/auth/WechatOAuthSection.vue'
|
||||||
import EmailOAuthButtons from '@/components/auth/EmailOAuthButtons.vue'
|
import EmailOAuthButtons from '@/components/auth/EmailOAuthButtons.vue'
|
||||||
|
import LoginAgreementPrompt from '@/components/auth/LoginAgreementPrompt.vue'
|
||||||
import Icon from '@/components/icons/Icon.vue'
|
import Icon from '@/components/icons/Icon.vue'
|
||||||
import TurnstileWidget from '@/components/TurnstileWidget.vue'
|
import TurnstileWidget from '@/components/TurnstileWidget.vue'
|
||||||
import { useAuthStore, useAppStore } from '@/stores'
|
import { useAuthStore, useAppStore } from '@/stores'
|
||||||
@@ -312,8 +326,10 @@ import {
|
|||||||
loadAffiliateReferralCode,
|
loadAffiliateReferralCode,
|
||||||
resolveAffiliateReferralCode
|
resolveAffiliateReferralCode
|
||||||
} from '@/utils/oauthAffiliate'
|
} from '@/utils/oauthAffiliate'
|
||||||
|
import type { LoginAgreementDocument } from '@/types'
|
||||||
|
|
||||||
const { t, locale } = useI18n()
|
const { t, locale } = useI18n()
|
||||||
|
const LOGIN_AGREEMENT_STORAGE_KEY = 'sub2api_login_agreement_consent'
|
||||||
|
|
||||||
// ==================== Router & Stores ====================
|
// ==================== Router & Stores ====================
|
||||||
|
|
||||||
@@ -344,6 +360,13 @@ const oidcOAuthProviderName = ref<string>('OIDC')
|
|||||||
const githubOAuthEnabled = ref<boolean>(false)
|
const githubOAuthEnabled = ref<boolean>(false)
|
||||||
const googleOAuthEnabled = ref<boolean>(false)
|
const googleOAuthEnabled = ref<boolean>(false)
|
||||||
const registrationEmailSuffixWhitelist = ref<string[]>([])
|
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
|
// Turnstile
|
||||||
const turnstileRef = ref<InstanceType<typeof TurnstileWidget> | null>(null)
|
const turnstileRef = ref<InstanceType<typeof TurnstileWidget> | null>(null)
|
||||||
@@ -402,6 +425,14 @@ const showOAuthLogin = computed(
|
|||||||
googleOAuthEnabled.value
|
googleOAuthEnabled.value
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const agreementGateActive = computed(
|
||||||
|
() => loginAgreementEnabled.value && !agreementAccepted.value
|
||||||
|
)
|
||||||
|
|
||||||
|
const registrationActionDisabled = computed(
|
||||||
|
() => isLoading.value || !settingsLoaded.value || agreementGateActive.value
|
||||||
|
)
|
||||||
|
|
||||||
watch(validationToastMessage, (value, previousValue) => {
|
watch(validationToastMessage, (value, previousValue) => {
|
||||||
if (value && value !== previousValue) {
|
if (value && value !== previousValue) {
|
||||||
appStore.showError(value)
|
appStore.showError(value)
|
||||||
@@ -439,6 +470,7 @@ onMounted(async () => {
|
|||||||
registrationEmailSuffixWhitelist.value = normalizeRegistrationEmailSuffixWhitelist(
|
registrationEmailSuffixWhitelist.value = normalizeRegistrationEmailSuffixWhitelist(
|
||||||
settings.registration_email_suffix_whitelist || []
|
settings.registration_email_suffix_whitelist || []
|
||||||
)
|
)
|
||||||
|
applyLoginAgreementSettings(settings)
|
||||||
|
|
||||||
// Read promo code from URL parameter only if promo code is enabled
|
// Read promo code from URL parameter only if promo code is enabled
|
||||||
if (promoCodeEnabled.value) {
|
if (promoCodeEnabled.value) {
|
||||||
@@ -452,6 +484,8 @@ onMounted(async () => {
|
|||||||
syncAffiliateReferralCode()
|
syncAffiliateReferralCode()
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to load public settings:', error)
|
console.error('Failed to load public settings:', error)
|
||||||
|
loginAgreementEnabled.value = false
|
||||||
|
agreementAccepted.value = true
|
||||||
} finally {
|
} finally {
|
||||||
settingsLoaded.value = true
|
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 ====================
|
// ==================== Promo Code Validation ====================
|
||||||
|
|
||||||
function handlePromoCodeInput(): void {
|
function handlePromoCodeInput(): void {
|
||||||
@@ -656,6 +752,14 @@ function validateForm(): boolean {
|
|||||||
|
|
||||||
let isValid = true
|
let isValid = true
|
||||||
|
|
||||||
|
if (agreementGateActive.value) {
|
||||||
|
appStore.showWarning('请先阅读并同意最新条款后再注册。')
|
||||||
|
if (loginAgreementMode.value !== 'checkbox') {
|
||||||
|
showAgreementModal.value = true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
// Email validation
|
// Email validation
|
||||||
if (!formData.email.trim()) {
|
if (!formData.email.trim()) {
|
||||||
errors.email = t('auth.emailRequired')
|
errors.email = t('auth.emailRequired')
|
||||||
|
|||||||
@@ -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>
|
||||||
Reference in New Issue
Block a user