Merge pull request #420 from shuike/feat-invitation-code
feat: 增加邀请码注册功能
This commit is contained in:
@@ -33,7 +33,7 @@ func main() {
|
|||||||
}()
|
}()
|
||||||
|
|
||||||
userRepo := repository.NewUserRepository(client, sqlDB)
|
userRepo := repository.NewUserRepository(client, sqlDB)
|
||||||
authService := service.NewAuthService(userRepo, cfg, nil, nil, nil, nil, nil)
|
authService := service.NewAuthService(userRepo, nil, cfg, nil, nil, nil, nil, nil)
|
||||||
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
userRepository := repository.NewUserRepository(client, db)
|
userRepository := repository.NewUserRepository(client, db)
|
||||||
|
redeemCodeRepository := repository.NewRedeemCodeRepository(client)
|
||||||
settingRepository := repository.NewSettingRepository(client)
|
settingRepository := repository.NewSettingRepository(client)
|
||||||
settingService := service.NewSettingService(settingRepository, configConfig)
|
settingService := service.NewSettingService(settingRepository, configConfig)
|
||||||
redisClient := repository.ProvideRedis(configConfig)
|
redisClient := repository.ProvideRedis(configConfig)
|
||||||
@@ -61,24 +62,23 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
|
|||||||
apiKeyService := service.NewAPIKeyService(apiKeyRepository, userRepository, groupRepository, userSubscriptionRepository, apiKeyCache, configConfig)
|
apiKeyService := service.NewAPIKeyService(apiKeyRepository, userRepository, groupRepository, userSubscriptionRepository, apiKeyCache, configConfig)
|
||||||
apiKeyAuthCacheInvalidator := service.ProvideAPIKeyAuthCacheInvalidator(apiKeyService)
|
apiKeyAuthCacheInvalidator := service.ProvideAPIKeyAuthCacheInvalidator(apiKeyService)
|
||||||
promoService := service.NewPromoService(promoCodeRepository, userRepository, billingCacheService, client, apiKeyAuthCacheInvalidator)
|
promoService := service.NewPromoService(promoCodeRepository, userRepository, billingCacheService, client, apiKeyAuthCacheInvalidator)
|
||||||
authService := service.NewAuthService(userRepository, configConfig, settingService, emailService, turnstileService, emailQueueService, promoService)
|
authService := service.NewAuthService(userRepository, redeemCodeRepository, configConfig, settingService, emailService, turnstileService, emailQueueService, promoService)
|
||||||
userService := service.NewUserService(userRepository, apiKeyAuthCacheInvalidator)
|
userService := service.NewUserService(userRepository, apiKeyAuthCacheInvalidator)
|
||||||
|
subscriptionService := service.NewSubscriptionService(groupRepository, userSubscriptionRepository, billingCacheService)
|
||||||
|
redeemCache := repository.NewRedeemCache(redisClient)
|
||||||
|
redeemService := service.NewRedeemService(redeemCodeRepository, userRepository, subscriptionService, redeemCache, billingCacheService, client, apiKeyAuthCacheInvalidator)
|
||||||
secretEncryptor, err := repository.NewAESEncryptor(configConfig)
|
secretEncryptor, err := repository.NewAESEncryptor(configConfig)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
totpCache := repository.NewTotpCache(redisClient)
|
totpCache := repository.NewTotpCache(redisClient)
|
||||||
totpService := service.NewTotpService(userRepository, secretEncryptor, totpCache, settingService, emailService, emailQueueService)
|
totpService := service.NewTotpService(userRepository, secretEncryptor, totpCache, settingService, emailService, emailQueueService)
|
||||||
authHandler := handler.NewAuthHandler(configConfig, authService, userService, settingService, promoService, totpService)
|
authHandler := handler.NewAuthHandler(configConfig, authService, userService, settingService, promoService, redeemService, totpService)
|
||||||
userHandler := handler.NewUserHandler(userService)
|
userHandler := handler.NewUserHandler(userService)
|
||||||
apiKeyHandler := handler.NewAPIKeyHandler(apiKeyService)
|
apiKeyHandler := handler.NewAPIKeyHandler(apiKeyService)
|
||||||
usageLogRepository := repository.NewUsageLogRepository(client, db)
|
usageLogRepository := repository.NewUsageLogRepository(client, db)
|
||||||
usageService := service.NewUsageService(usageLogRepository, userRepository, client, apiKeyAuthCacheInvalidator)
|
usageService := service.NewUsageService(usageLogRepository, userRepository, client, apiKeyAuthCacheInvalidator)
|
||||||
usageHandler := handler.NewUsageHandler(usageService, apiKeyService)
|
usageHandler := handler.NewUsageHandler(usageService, apiKeyService)
|
||||||
redeemCodeRepository := repository.NewRedeemCodeRepository(client)
|
|
||||||
subscriptionService := service.NewSubscriptionService(groupRepository, userSubscriptionRepository, billingCacheService)
|
|
||||||
redeemCache := repository.NewRedeemCache(redisClient)
|
|
||||||
redeemService := service.NewRedeemService(redeemCodeRepository, userRepository, subscriptionService, redeemCache, billingCacheService, client, apiKeyAuthCacheInvalidator)
|
|
||||||
redeemHandler := handler.NewRedeemHandler(redeemService)
|
redeemHandler := handler.NewRedeemHandler(redeemService)
|
||||||
subscriptionHandler := handler.NewSubscriptionHandler(subscriptionService)
|
subscriptionHandler := handler.NewSubscriptionHandler(subscriptionService)
|
||||||
announcementRepository := repository.NewAnnouncementRepository(client)
|
announcementRepository := repository.NewAnnouncementRepository(client)
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ const (
|
|||||||
RedeemTypeBalance = "balance"
|
RedeemTypeBalance = "balance"
|
||||||
RedeemTypeConcurrency = "concurrency"
|
RedeemTypeConcurrency = "concurrency"
|
||||||
RedeemTypeSubscription = "subscription"
|
RedeemTypeSubscription = "subscription"
|
||||||
|
RedeemTypeInvitation = "invitation"
|
||||||
)
|
)
|
||||||
|
|
||||||
// PromoCode status constants
|
// PromoCode status constants
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ func NewRedeemHandler(adminService service.AdminService) *RedeemHandler {
|
|||||||
// GenerateRedeemCodesRequest represents generate redeem codes request
|
// GenerateRedeemCodesRequest represents generate redeem codes request
|
||||||
type GenerateRedeemCodesRequest struct {
|
type GenerateRedeemCodesRequest struct {
|
||||||
Count int `json:"count" binding:"required,min=1,max=100"`
|
Count int `json:"count" binding:"required,min=1,max=100"`
|
||||||
Type string `json:"type" binding:"required,oneof=balance concurrency subscription"`
|
Type string `json:"type" binding:"required,oneof=balance concurrency subscription invitation"`
|
||||||
Value float64 `json:"value" binding:"min=0"`
|
Value float64 `json:"value" binding:"min=0"`
|
||||||
GroupID *int64 `json:"group_id"` // 订阅类型必填
|
GroupID *int64 `json:"group_id"` // 订阅类型必填
|
||||||
ValidityDays int `json:"validity_days" binding:"omitempty,max=36500"` // 订阅类型使用,默认30天,最大100年
|
ValidityDays int `json:"validity_days" binding:"omitempty,max=36500"` // 订阅类型使用,默认30天,最大100年
|
||||||
|
|||||||
@@ -49,6 +49,7 @@ func (h *SettingHandler) GetSettings(c *gin.Context) {
|
|||||||
EmailVerifyEnabled: settings.EmailVerifyEnabled,
|
EmailVerifyEnabled: settings.EmailVerifyEnabled,
|
||||||
PromoCodeEnabled: settings.PromoCodeEnabled,
|
PromoCodeEnabled: settings.PromoCodeEnabled,
|
||||||
PasswordResetEnabled: settings.PasswordResetEnabled,
|
PasswordResetEnabled: settings.PasswordResetEnabled,
|
||||||
|
InvitationCodeEnabled: settings.InvitationCodeEnabled,
|
||||||
TotpEnabled: settings.TotpEnabled,
|
TotpEnabled: settings.TotpEnabled,
|
||||||
TotpEncryptionKeyConfigured: h.settingService.IsTotpEncryptionKeyConfigured(),
|
TotpEncryptionKeyConfigured: h.settingService.IsTotpEncryptionKeyConfigured(),
|
||||||
SMTPHost: settings.SMTPHost,
|
SMTPHost: settings.SMTPHost,
|
||||||
@@ -94,11 +95,12 @@ func (h *SettingHandler) GetSettings(c *gin.Context) {
|
|||||||
// 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"`
|
||||||
PromoCodeEnabled bool `json:"promo_code_enabled"`
|
PromoCodeEnabled bool `json:"promo_code_enabled"`
|
||||||
PasswordResetEnabled bool `json:"password_reset_enabled"`
|
PasswordResetEnabled bool `json:"password_reset_enabled"`
|
||||||
TotpEnabled bool `json:"totp_enabled"` // TOTP 双因素认证
|
InvitationCodeEnabled bool `json:"invitation_code_enabled"`
|
||||||
|
TotpEnabled bool `json:"totp_enabled"` // TOTP 双因素认证
|
||||||
|
|
||||||
// 邮件服务设置
|
// 邮件服务设置
|
||||||
SMTPHost string `json:"smtp_host"`
|
SMTPHost string `json:"smtp_host"`
|
||||||
@@ -291,6 +293,7 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
|
|||||||
EmailVerifyEnabled: req.EmailVerifyEnabled,
|
EmailVerifyEnabled: req.EmailVerifyEnabled,
|
||||||
PromoCodeEnabled: req.PromoCodeEnabled,
|
PromoCodeEnabled: req.PromoCodeEnabled,
|
||||||
PasswordResetEnabled: req.PasswordResetEnabled,
|
PasswordResetEnabled: req.PasswordResetEnabled,
|
||||||
|
InvitationCodeEnabled: req.InvitationCodeEnabled,
|
||||||
TotpEnabled: req.TotpEnabled,
|
TotpEnabled: req.TotpEnabled,
|
||||||
SMTPHost: req.SMTPHost,
|
SMTPHost: req.SMTPHost,
|
||||||
SMTPPort: req.SMTPPort,
|
SMTPPort: req.SMTPPort,
|
||||||
@@ -370,6 +373,7 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
|
|||||||
EmailVerifyEnabled: updatedSettings.EmailVerifyEnabled,
|
EmailVerifyEnabled: updatedSettings.EmailVerifyEnabled,
|
||||||
PromoCodeEnabled: updatedSettings.PromoCodeEnabled,
|
PromoCodeEnabled: updatedSettings.PromoCodeEnabled,
|
||||||
PasswordResetEnabled: updatedSettings.PasswordResetEnabled,
|
PasswordResetEnabled: updatedSettings.PasswordResetEnabled,
|
||||||
|
InvitationCodeEnabled: updatedSettings.InvitationCodeEnabled,
|
||||||
TotpEnabled: updatedSettings.TotpEnabled,
|
TotpEnabled: updatedSettings.TotpEnabled,
|
||||||
TotpEncryptionKeyConfigured: h.settingService.IsTotpEncryptionKeyConfigured(),
|
TotpEncryptionKeyConfigured: h.settingService.IsTotpEncryptionKeyConfigured(),
|
||||||
SMTPHost: updatedSettings.SMTPHost,
|
SMTPHost: updatedSettings.SMTPHost,
|
||||||
|
|||||||
@@ -15,23 +15,25 @@ import (
|
|||||||
|
|
||||||
// AuthHandler handles authentication-related requests
|
// AuthHandler handles authentication-related requests
|
||||||
type AuthHandler struct {
|
type AuthHandler struct {
|
||||||
cfg *config.Config
|
cfg *config.Config
|
||||||
authService *service.AuthService
|
authService *service.AuthService
|
||||||
userService *service.UserService
|
userService *service.UserService
|
||||||
settingSvc *service.SettingService
|
settingSvc *service.SettingService
|
||||||
promoService *service.PromoService
|
promoService *service.PromoService
|
||||||
totpService *service.TotpService
|
redeemService *service.RedeemService
|
||||||
|
totpService *service.TotpService
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewAuthHandler creates a new AuthHandler
|
// NewAuthHandler creates a new AuthHandler
|
||||||
func NewAuthHandler(cfg *config.Config, authService *service.AuthService, userService *service.UserService, settingService *service.SettingService, promoService *service.PromoService, totpService *service.TotpService) *AuthHandler {
|
func NewAuthHandler(cfg *config.Config, authService *service.AuthService, userService *service.UserService, settingService *service.SettingService, promoService *service.PromoService, redeemService *service.RedeemService, totpService *service.TotpService) *AuthHandler {
|
||||||
return &AuthHandler{
|
return &AuthHandler{
|
||||||
cfg: cfg,
|
cfg: cfg,
|
||||||
authService: authService,
|
authService: authService,
|
||||||
userService: userService,
|
userService: userService,
|
||||||
settingSvc: settingService,
|
settingSvc: settingService,
|
||||||
promoService: promoService,
|
promoService: promoService,
|
||||||
totpService: totpService,
|
redeemService: redeemService,
|
||||||
|
totpService: totpService,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -41,7 +43,8 @@ type RegisterRequest struct {
|
|||||||
Password string `json:"password" binding:"required,min=6"`
|
Password string `json:"password" binding:"required,min=6"`
|
||||||
VerifyCode string `json:"verify_code"`
|
VerifyCode string `json:"verify_code"`
|
||||||
TurnstileToken string `json:"turnstile_token"`
|
TurnstileToken string `json:"turnstile_token"`
|
||||||
PromoCode string `json:"promo_code"` // 注册优惠码
|
PromoCode string `json:"promo_code"` // 注册优惠码
|
||||||
|
InvitationCode string `json:"invitation_code"` // 邀请码
|
||||||
}
|
}
|
||||||
|
|
||||||
// SendVerifyCodeRequest 发送验证码请求
|
// SendVerifyCodeRequest 发送验证码请求
|
||||||
@@ -87,7 +90,7 @@ func (h *AuthHandler) Register(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
token, user, err := h.authService.RegisterWithVerification(c.Request.Context(), req.Email, req.Password, req.VerifyCode, req.PromoCode)
|
token, user, err := h.authService.RegisterWithVerification(c.Request.Context(), req.Email, req.Password, req.VerifyCode, req.PromoCode, req.InvitationCode)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
response.ErrorFrom(c, err)
|
response.ErrorFrom(c, err)
|
||||||
return
|
return
|
||||||
@@ -346,6 +349,67 @@ func (h *AuthHandler) ValidatePromoCode(c *gin.Context) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ValidateInvitationCodeRequest 验证邀请码请求
|
||||||
|
type ValidateInvitationCodeRequest struct {
|
||||||
|
Code string `json:"code" binding:"required"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ValidateInvitationCodeResponse 验证邀请码响应
|
||||||
|
type ValidateInvitationCodeResponse struct {
|
||||||
|
Valid bool `json:"valid"`
|
||||||
|
ErrorCode string `json:"error_code,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ValidateInvitationCode 验证邀请码(公开接口,注册前调用)
|
||||||
|
// POST /api/v1/auth/validate-invitation-code
|
||||||
|
func (h *AuthHandler) ValidateInvitationCode(c *gin.Context) {
|
||||||
|
// 检查邀请码功能是否启用
|
||||||
|
if h.settingSvc == nil || !h.settingSvc.IsInvitationCodeEnabled(c.Request.Context()) {
|
||||||
|
response.Success(c, ValidateInvitationCodeResponse{
|
||||||
|
Valid: false,
|
||||||
|
ErrorCode: "INVITATION_CODE_DISABLED",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req ValidateInvitationCodeRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
response.BadRequest(c, "Invalid request: "+err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证邀请码
|
||||||
|
redeemCode, err := h.redeemService.GetByCode(c.Request.Context(), req.Code)
|
||||||
|
if err != nil {
|
||||||
|
response.Success(c, ValidateInvitationCodeResponse{
|
||||||
|
Valid: false,
|
||||||
|
ErrorCode: "INVITATION_CODE_NOT_FOUND",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查类型和状态
|
||||||
|
if redeemCode.Type != service.RedeemTypeInvitation {
|
||||||
|
response.Success(c, ValidateInvitationCodeResponse{
|
||||||
|
Valid: false,
|
||||||
|
ErrorCode: "INVITATION_CODE_INVALID",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if redeemCode.Status != service.StatusUnused {
|
||||||
|
response.Success(c, ValidateInvitationCodeResponse{
|
||||||
|
Valid: false,
|
||||||
|
ErrorCode: "INVITATION_CODE_USED",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
response.Success(c, ValidateInvitationCodeResponse{
|
||||||
|
Valid: true,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// ForgotPasswordRequest 忘记密码请求
|
// ForgotPasswordRequest 忘记密码请求
|
||||||
type ForgotPasswordRequest struct {
|
type ForgotPasswordRequest struct {
|
||||||
Email string `json:"email" binding:"required,email"`
|
Email string `json:"email" binding:"required,email"`
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ type SystemSettings struct {
|
|||||||
EmailVerifyEnabled bool `json:"email_verify_enabled"`
|
EmailVerifyEnabled bool `json:"email_verify_enabled"`
|
||||||
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"`
|
||||||
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 加密密钥是否已配置
|
||||||
|
|
||||||
@@ -63,6 +64,7 @@ type PublicSettings struct {
|
|||||||
EmailVerifyEnabled bool `json:"email_verify_enabled"`
|
EmailVerifyEnabled bool `json:"email_verify_enabled"`
|
||||||
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"`
|
||||||
TotpEnabled bool `json:"totp_enabled"` // TOTP 双因素认证
|
TotpEnabled bool `json:"totp_enabled"` // TOTP 双因素认证
|
||||||
TurnstileEnabled bool `json:"turnstile_enabled"`
|
TurnstileEnabled bool `json:"turnstile_enabled"`
|
||||||
TurnstileSiteKey string `json:"turnstile_site_key"`
|
TurnstileSiteKey string `json:"turnstile_site_key"`
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ func (h *SettingHandler) GetPublicSettings(c *gin.Context) {
|
|||||||
EmailVerifyEnabled: settings.EmailVerifyEnabled,
|
EmailVerifyEnabled: settings.EmailVerifyEnabled,
|
||||||
PromoCodeEnabled: settings.PromoCodeEnabled,
|
PromoCodeEnabled: settings.PromoCodeEnabled,
|
||||||
PasswordResetEnabled: settings.PasswordResetEnabled,
|
PasswordResetEnabled: settings.PasswordResetEnabled,
|
||||||
|
InvitationCodeEnabled: settings.InvitationCodeEnabled,
|
||||||
TotpEnabled: settings.TotpEnabled,
|
TotpEnabled: settings.TotpEnabled,
|
||||||
TurnstileEnabled: settings.TurnstileEnabled,
|
TurnstileEnabled: settings.TurnstileEnabled,
|
||||||
TurnstileSiteKey: settings.TurnstileSiteKey,
|
TurnstileSiteKey: settings.TurnstileSiteKey,
|
||||||
|
|||||||
@@ -488,6 +488,7 @@ func TestAPIContracts(t *testing.T) {
|
|||||||
"fallback_model_openai": "gpt-4o",
|
"fallback_model_openai": "gpt-4o",
|
||||||
"enable_identity_patch": true,
|
"enable_identity_patch": true,
|
||||||
"identity_patch_prompt": "",
|
"identity_patch_prompt": "",
|
||||||
|
"invitation_code_enabled": false,
|
||||||
"home_content": "",
|
"home_content": "",
|
||||||
"hide_ccs_import_button": false,
|
"hide_ccs_import_button": false,
|
||||||
"purchase_subscription_enabled": false,
|
"purchase_subscription_enabled": false,
|
||||||
@@ -600,7 +601,7 @@ func newContractDeps(t *testing.T) *contractDeps {
|
|||||||
settingService := service.NewSettingService(settingRepo, cfg)
|
settingService := service.NewSettingService(settingRepo, cfg)
|
||||||
|
|
||||||
adminService := service.NewAdminService(userRepo, groupRepo, &accountRepo, proxyRepo, apiKeyRepo, redeemRepo, nil, nil, nil, nil)
|
adminService := service.NewAdminService(userRepo, groupRepo, &accountRepo, proxyRepo, apiKeyRepo, redeemRepo, nil, nil, nil, nil)
|
||||||
authHandler := handler.NewAuthHandler(cfg, nil, userService, settingService, nil, nil)
|
authHandler := handler.NewAuthHandler(cfg, nil, userService, settingService, nil, nil, nil)
|
||||||
apiKeyHandler := handler.NewAPIKeyHandler(apiKeyService)
|
apiKeyHandler := handler.NewAPIKeyHandler(apiKeyService)
|
||||||
usageHandler := handler.NewUsageHandler(usageService, apiKeyService)
|
usageHandler := handler.NewUsageHandler(usageService, apiKeyService)
|
||||||
adminSettingHandler := adminhandler.NewSettingHandler(settingService, nil, nil, nil)
|
adminSettingHandler := adminhandler.NewSettingHandler(settingService, nil, nil, nil)
|
||||||
|
|||||||
@@ -32,6 +32,10 @@ func RegisterAuthRoutes(
|
|||||||
auth.POST("/validate-promo-code", rateLimiter.LimitWithOptions("validate-promo", 10, time.Minute, middleware.RateLimitOptions{
|
auth.POST("/validate-promo-code", rateLimiter.LimitWithOptions("validate-promo", 10, time.Minute, middleware.RateLimitOptions{
|
||||||
FailureMode: middleware.RateLimitFailClose,
|
FailureMode: middleware.RateLimitFailClose,
|
||||||
}), h.Auth.ValidatePromoCode)
|
}), h.Auth.ValidatePromoCode)
|
||||||
|
// 邀请码验证接口添加速率限制:每分钟最多 10 次(Redis 故障时 fail-close)
|
||||||
|
auth.POST("/validate-invitation-code", rateLimiter.LimitWithOptions("validate-invitation", 10, time.Minute, middleware.RateLimitOptions{
|
||||||
|
FailureMode: middleware.RateLimitFailClose,
|
||||||
|
}), h.Auth.ValidateInvitationCode)
|
||||||
// 忘记密码接口添加速率限制:每分钟最多 5 次(Redis 故障时 fail-close)
|
// 忘记密码接口添加速率限制:每分钟最多 5 次(Redis 故障时 fail-close)
|
||||||
auth.POST("/forgot-password", rateLimiter.LimitWithOptions("forgot-password", 5, time.Minute, middleware.RateLimitOptions{
|
auth.POST("/forgot-password", rateLimiter.LimitWithOptions("forgot-password", 5, time.Minute, middleware.RateLimitOptions{
|
||||||
FailureMode: middleware.RateLimitFailClose,
|
FailureMode: middleware.RateLimitFailClose,
|
||||||
|
|||||||
@@ -19,17 +19,19 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
ErrInvalidCredentials = infraerrors.Unauthorized("INVALID_CREDENTIALS", "invalid email or password")
|
ErrInvalidCredentials = infraerrors.Unauthorized("INVALID_CREDENTIALS", "invalid email or password")
|
||||||
ErrUserNotActive = infraerrors.Forbidden("USER_NOT_ACTIVE", "user is not active")
|
ErrUserNotActive = infraerrors.Forbidden("USER_NOT_ACTIVE", "user is not active")
|
||||||
ErrEmailExists = infraerrors.Conflict("EMAIL_EXISTS", "email already exists")
|
ErrEmailExists = infraerrors.Conflict("EMAIL_EXISTS", "email already exists")
|
||||||
ErrEmailReserved = infraerrors.BadRequest("EMAIL_RESERVED", "email is reserved")
|
ErrEmailReserved = infraerrors.BadRequest("EMAIL_RESERVED", "email is reserved")
|
||||||
ErrInvalidToken = infraerrors.Unauthorized("INVALID_TOKEN", "invalid token")
|
ErrInvalidToken = infraerrors.Unauthorized("INVALID_TOKEN", "invalid token")
|
||||||
ErrTokenExpired = infraerrors.Unauthorized("TOKEN_EXPIRED", "token has expired")
|
ErrTokenExpired = infraerrors.Unauthorized("TOKEN_EXPIRED", "token has expired")
|
||||||
ErrTokenTooLarge = infraerrors.BadRequest("TOKEN_TOO_LARGE", "token too large")
|
ErrTokenTooLarge = infraerrors.BadRequest("TOKEN_TOO_LARGE", "token too large")
|
||||||
ErrTokenRevoked = infraerrors.Unauthorized("TOKEN_REVOKED", "token has been revoked")
|
ErrTokenRevoked = infraerrors.Unauthorized("TOKEN_REVOKED", "token has been revoked")
|
||||||
ErrEmailVerifyRequired = infraerrors.BadRequest("EMAIL_VERIFY_REQUIRED", "email verification is required")
|
ErrEmailVerifyRequired = infraerrors.BadRequest("EMAIL_VERIFY_REQUIRED", "email verification is required")
|
||||||
ErrRegDisabled = infraerrors.Forbidden("REGISTRATION_DISABLED", "registration is currently disabled")
|
ErrRegDisabled = infraerrors.Forbidden("REGISTRATION_DISABLED", "registration is currently disabled")
|
||||||
ErrServiceUnavailable = infraerrors.ServiceUnavailable("SERVICE_UNAVAILABLE", "service temporarily unavailable")
|
ErrServiceUnavailable = infraerrors.ServiceUnavailable("SERVICE_UNAVAILABLE", "service temporarily unavailable")
|
||||||
|
ErrInvitationCodeRequired = infraerrors.BadRequest("INVITATION_CODE_REQUIRED", "invitation code is required")
|
||||||
|
ErrInvitationCodeInvalid = infraerrors.BadRequest("INVITATION_CODE_INVALID", "invalid or used invitation code")
|
||||||
)
|
)
|
||||||
|
|
||||||
// maxTokenLength 限制 token 大小,避免超长 header 触发解析时的异常内存分配。
|
// maxTokenLength 限制 token 大小,避免超长 header 触发解析时的异常内存分配。
|
||||||
@@ -47,6 +49,7 @@ type JWTClaims struct {
|
|||||||
// AuthService 认证服务
|
// AuthService 认证服务
|
||||||
type AuthService struct {
|
type AuthService struct {
|
||||||
userRepo UserRepository
|
userRepo UserRepository
|
||||||
|
redeemRepo RedeemCodeRepository
|
||||||
cfg *config.Config
|
cfg *config.Config
|
||||||
settingService *SettingService
|
settingService *SettingService
|
||||||
emailService *EmailService
|
emailService *EmailService
|
||||||
@@ -58,6 +61,7 @@ type AuthService struct {
|
|||||||
// NewAuthService 创建认证服务实例
|
// NewAuthService 创建认证服务实例
|
||||||
func NewAuthService(
|
func NewAuthService(
|
||||||
userRepo UserRepository,
|
userRepo UserRepository,
|
||||||
|
redeemRepo RedeemCodeRepository,
|
||||||
cfg *config.Config,
|
cfg *config.Config,
|
||||||
settingService *SettingService,
|
settingService *SettingService,
|
||||||
emailService *EmailService,
|
emailService *EmailService,
|
||||||
@@ -67,6 +71,7 @@ func NewAuthService(
|
|||||||
) *AuthService {
|
) *AuthService {
|
||||||
return &AuthService{
|
return &AuthService{
|
||||||
userRepo: userRepo,
|
userRepo: userRepo,
|
||||||
|
redeemRepo: redeemRepo,
|
||||||
cfg: cfg,
|
cfg: cfg,
|
||||||
settingService: settingService,
|
settingService: settingService,
|
||||||
emailService: emailService,
|
emailService: emailService,
|
||||||
@@ -78,11 +83,11 @@ func NewAuthService(
|
|||||||
|
|
||||||
// Register 用户注册,返回token和用户
|
// Register 用户注册,返回token和用户
|
||||||
func (s *AuthService) Register(ctx context.Context, email, password string) (string, *User, error) {
|
func (s *AuthService) Register(ctx context.Context, email, password string) (string, *User, error) {
|
||||||
return s.RegisterWithVerification(ctx, email, password, "", "")
|
return s.RegisterWithVerification(ctx, email, password, "", "", "")
|
||||||
}
|
}
|
||||||
|
|
||||||
// RegisterWithVerification 用户注册(支持邮件验证和优惠码),返回token和用户
|
// RegisterWithVerification 用户注册(支持邮件验证、优惠码和邀请码),返回token和用户
|
||||||
func (s *AuthService) RegisterWithVerification(ctx context.Context, email, password, verifyCode, promoCode string) (string, *User, error) {
|
func (s *AuthService) RegisterWithVerification(ctx context.Context, email, password, verifyCode, promoCode, invitationCode string) (string, *User, error) {
|
||||||
// 检查是否开放注册(默认关闭:settingService 未配置时不允许注册)
|
// 检查是否开放注册(默认关闭:settingService 未配置时不允许注册)
|
||||||
if s.settingService == nil || !s.settingService.IsRegistrationEnabled(ctx) {
|
if s.settingService == nil || !s.settingService.IsRegistrationEnabled(ctx) {
|
||||||
return "", nil, ErrRegDisabled
|
return "", nil, ErrRegDisabled
|
||||||
@@ -93,6 +98,26 @@ func (s *AuthService) RegisterWithVerification(ctx context.Context, email, passw
|
|||||||
return "", nil, ErrEmailReserved
|
return "", nil, ErrEmailReserved
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 检查是否需要邀请码
|
||||||
|
var invitationRedeemCode *RedeemCode
|
||||||
|
if s.settingService != nil && s.settingService.IsInvitationCodeEnabled(ctx) {
|
||||||
|
if invitationCode == "" {
|
||||||
|
return "", nil, ErrInvitationCodeRequired
|
||||||
|
}
|
||||||
|
// 验证邀请码
|
||||||
|
redeemCode, err := s.redeemRepo.GetByCode(ctx, invitationCode)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("[Auth] Invalid invitation code: %s, error: %v", invitationCode, err)
|
||||||
|
return "", nil, ErrInvitationCodeInvalid
|
||||||
|
}
|
||||||
|
// 检查类型和状态
|
||||||
|
if redeemCode.Type != RedeemTypeInvitation || redeemCode.Status != StatusUnused {
|
||||||
|
log.Printf("[Auth] Invitation code invalid: type=%s, status=%s", redeemCode.Type, redeemCode.Status)
|
||||||
|
return "", nil, ErrInvitationCodeInvalid
|
||||||
|
}
|
||||||
|
invitationRedeemCode = redeemCode
|
||||||
|
}
|
||||||
|
|
||||||
// 检查是否需要邮件验证
|
// 检查是否需要邮件验证
|
||||||
if s.settingService != nil && s.settingService.IsEmailVerifyEnabled(ctx) {
|
if s.settingService != nil && s.settingService.IsEmailVerifyEnabled(ctx) {
|
||||||
// 如果邮件验证已开启但邮件服务未配置,拒绝注册
|
// 如果邮件验证已开启但邮件服务未配置,拒绝注册
|
||||||
@@ -153,6 +178,14 @@ func (s *AuthService) RegisterWithVerification(ctx context.Context, email, passw
|
|||||||
return "", nil, ErrServiceUnavailable
|
return "", nil, ErrServiceUnavailable
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 标记邀请码为已使用(如果使用了邀请码)
|
||||||
|
if invitationRedeemCode != nil {
|
||||||
|
if err := s.redeemRepo.Use(ctx, invitationRedeemCode.ID, user.ID); err != nil {
|
||||||
|
// 邀请码标记失败不影响注册,只记录日志
|
||||||
|
log.Printf("[Auth] Failed to mark invitation code as used for user %d: %v", user.ID, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 应用优惠码(如果提供且功能已启用)
|
// 应用优惠码(如果提供且功能已启用)
|
||||||
if promoCode != "" && s.promoService != nil && s.settingService != nil && s.settingService.IsPromoCodeEnabled(ctx) {
|
if promoCode != "" && s.promoService != nil && s.settingService != nil && s.settingService.IsPromoCodeEnabled(ctx) {
|
||||||
if err := s.promoService.ApplyPromoCode(ctx, user.ID, promoCode); err != nil {
|
if err := s.promoService.ApplyPromoCode(ctx, user.ID, promoCode); err != nil {
|
||||||
|
|||||||
@@ -115,6 +115,7 @@ func newAuthService(repo *userRepoStub, settings map[string]string, emailCache E
|
|||||||
|
|
||||||
return NewAuthService(
|
return NewAuthService(
|
||||||
repo,
|
repo,
|
||||||
|
nil, // redeemRepo
|
||||||
cfg,
|
cfg,
|
||||||
settingService,
|
settingService,
|
||||||
emailService,
|
emailService,
|
||||||
@@ -152,7 +153,7 @@ func TestAuthService_Register_EmailVerifyEnabledButServiceNotConfigured(t *testi
|
|||||||
}, nil)
|
}, nil)
|
||||||
|
|
||||||
// 应返回服务不可用错误,而不是允许绕过验证
|
// 应返回服务不可用错误,而不是允许绕过验证
|
||||||
_, _, err := service.RegisterWithVerification(context.Background(), "user@test.com", "password", "any-code", "")
|
_, _, err := service.RegisterWithVerification(context.Background(), "user@test.com", "password", "any-code", "", "")
|
||||||
require.ErrorIs(t, err, ErrServiceUnavailable)
|
require.ErrorIs(t, err, ErrServiceUnavailable)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -164,7 +165,7 @@ func TestAuthService_Register_EmailVerifyRequired(t *testing.T) {
|
|||||||
SettingKeyEmailVerifyEnabled: "true",
|
SettingKeyEmailVerifyEnabled: "true",
|
||||||
}, cache)
|
}, cache)
|
||||||
|
|
||||||
_, _, err := service.RegisterWithVerification(context.Background(), "user@test.com", "password", "", "")
|
_, _, err := service.RegisterWithVerification(context.Background(), "user@test.com", "password", "", "", "")
|
||||||
require.ErrorIs(t, err, ErrEmailVerifyRequired)
|
require.ErrorIs(t, err, ErrEmailVerifyRequired)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -178,7 +179,7 @@ func TestAuthService_Register_EmailVerifyInvalid(t *testing.T) {
|
|||||||
SettingKeyEmailVerifyEnabled: "true",
|
SettingKeyEmailVerifyEnabled: "true",
|
||||||
}, cache)
|
}, cache)
|
||||||
|
|
||||||
_, _, err := service.RegisterWithVerification(context.Background(), "user@test.com", "password", "wrong", "")
|
_, _, err := service.RegisterWithVerification(context.Background(), "user@test.com", "password", "wrong", "", "")
|
||||||
require.ErrorIs(t, err, ErrInvalidVerifyCode)
|
require.ErrorIs(t, err, ErrInvalidVerifyCode)
|
||||||
require.ErrorContains(t, err, "verify code")
|
require.ErrorContains(t, err, "verify code")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ const (
|
|||||||
RedeemTypeBalance = domain.RedeemTypeBalance
|
RedeemTypeBalance = domain.RedeemTypeBalance
|
||||||
RedeemTypeConcurrency = domain.RedeemTypeConcurrency
|
RedeemTypeConcurrency = domain.RedeemTypeConcurrency
|
||||||
RedeemTypeSubscription = domain.RedeemTypeSubscription
|
RedeemTypeSubscription = domain.RedeemTypeSubscription
|
||||||
|
RedeemTypeInvitation = domain.RedeemTypeInvitation
|
||||||
)
|
)
|
||||||
|
|
||||||
// PromoCode status constants
|
// PromoCode status constants
|
||||||
@@ -71,10 +72,11 @@ const LinuxDoConnectSyntheticEmailDomain = "@linuxdo-connect.invalid"
|
|||||||
// Setting keys
|
// Setting keys
|
||||||
const (
|
const (
|
||||||
// 注册设置
|
// 注册设置
|
||||||
SettingKeyRegistrationEnabled = "registration_enabled" // 是否开放注册
|
SettingKeyRegistrationEnabled = "registration_enabled" // 是否开放注册
|
||||||
SettingKeyEmailVerifyEnabled = "email_verify_enabled" // 是否开启邮件验证
|
SettingKeyEmailVerifyEnabled = "email_verify_enabled" // 是否开启邮件验证
|
||||||
SettingKeyPromoCodeEnabled = "promo_code_enabled" // 是否启用优惠码功能
|
SettingKeyPromoCodeEnabled = "promo_code_enabled" // 是否启用优惠码功能
|
||||||
SettingKeyPasswordResetEnabled = "password_reset_enabled" // 是否启用忘记密码功能(需要先开启邮件验证)
|
SettingKeyPasswordResetEnabled = "password_reset_enabled" // 是否启用忘记密码功能(需要先开启邮件验证)
|
||||||
|
SettingKeyInvitationCodeEnabled = "invitation_code_enabled" // 是否启用邀请码注册
|
||||||
|
|
||||||
// 邮件服务设置
|
// 邮件服务设置
|
||||||
SettingKeySMTPHost = "smtp_host" // SMTP服务器地址
|
SettingKeySMTPHost = "smtp_host" // SMTP服务器地址
|
||||||
|
|||||||
@@ -126,7 +126,8 @@ func (s *RedeemService) GenerateCodes(ctx context.Context, req GenerateCodesRequ
|
|||||||
return nil, errors.New("count must be greater than 0")
|
return nil, errors.New("count must be greater than 0")
|
||||||
}
|
}
|
||||||
|
|
||||||
if req.Value <= 0 {
|
// 邀请码类型不需要数值,其他类型需要
|
||||||
|
if req.Type != RedeemTypeInvitation && req.Value <= 0 {
|
||||||
return nil, errors.New("value must be greater than 0")
|
return nil, errors.New("value must be greater than 0")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -139,6 +140,12 @@ func (s *RedeemService) GenerateCodes(ctx context.Context, req GenerateCodesRequ
|
|||||||
codeType = RedeemTypeBalance
|
codeType = RedeemTypeBalance
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 邀请码类型的 value 设为 0
|
||||||
|
value := req.Value
|
||||||
|
if codeType == RedeemTypeInvitation {
|
||||||
|
value = 0
|
||||||
|
}
|
||||||
|
|
||||||
codes := make([]RedeemCode, 0, req.Count)
|
codes := make([]RedeemCode, 0, req.Count)
|
||||||
for i := 0; i < req.Count; i++ {
|
for i := 0; i < req.Count; i++ {
|
||||||
code, err := s.GenerateRandomCode()
|
code, err := s.GenerateRandomCode()
|
||||||
@@ -149,7 +156,7 @@ func (s *RedeemService) GenerateCodes(ctx context.Context, req GenerateCodesRequ
|
|||||||
codes = append(codes, RedeemCode{
|
codes = append(codes, RedeemCode{
|
||||||
Code: code,
|
Code: code,
|
||||||
Type: codeType,
|
Type: codeType,
|
||||||
Value: req.Value,
|
Value: value,
|
||||||
Status: StatusUnused,
|
Status: StatusUnused,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -62,6 +62,7 @@ func (s *SettingService) GetPublicSettings(ctx context.Context) (*PublicSettings
|
|||||||
SettingKeyEmailVerifyEnabled,
|
SettingKeyEmailVerifyEnabled,
|
||||||
SettingKeyPromoCodeEnabled,
|
SettingKeyPromoCodeEnabled,
|
||||||
SettingKeyPasswordResetEnabled,
|
SettingKeyPasswordResetEnabled,
|
||||||
|
SettingKeyInvitationCodeEnabled,
|
||||||
SettingKeyTotpEnabled,
|
SettingKeyTotpEnabled,
|
||||||
SettingKeyTurnstileEnabled,
|
SettingKeyTurnstileEnabled,
|
||||||
SettingKeyTurnstileSiteKey,
|
SettingKeyTurnstileSiteKey,
|
||||||
@@ -99,6 +100,7 @@ func (s *SettingService) GetPublicSettings(ctx context.Context) (*PublicSettings
|
|||||||
EmailVerifyEnabled: emailVerifyEnabled,
|
EmailVerifyEnabled: emailVerifyEnabled,
|
||||||
PromoCodeEnabled: settings[SettingKeyPromoCodeEnabled] != "false", // 默认启用
|
PromoCodeEnabled: settings[SettingKeyPromoCodeEnabled] != "false", // 默认启用
|
||||||
PasswordResetEnabled: passwordResetEnabled,
|
PasswordResetEnabled: passwordResetEnabled,
|
||||||
|
InvitationCodeEnabled: settings[SettingKeyInvitationCodeEnabled] == "true",
|
||||||
TotpEnabled: settings[SettingKeyTotpEnabled] == "true",
|
TotpEnabled: settings[SettingKeyTotpEnabled] == "true",
|
||||||
TurnstileEnabled: settings[SettingKeyTurnstileEnabled] == "true",
|
TurnstileEnabled: settings[SettingKeyTurnstileEnabled] == "true",
|
||||||
TurnstileSiteKey: settings[SettingKeyTurnstileSiteKey],
|
TurnstileSiteKey: settings[SettingKeyTurnstileSiteKey],
|
||||||
@@ -141,6 +143,7 @@ func (s *SettingService) GetPublicSettingsForInjection(ctx context.Context) (any
|
|||||||
EmailVerifyEnabled bool `json:"email_verify_enabled"`
|
EmailVerifyEnabled bool `json:"email_verify_enabled"`
|
||||||
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"`
|
||||||
TotpEnabled bool `json:"totp_enabled"`
|
TotpEnabled bool `json:"totp_enabled"`
|
||||||
TurnstileEnabled bool `json:"turnstile_enabled"`
|
TurnstileEnabled bool `json:"turnstile_enabled"`
|
||||||
TurnstileSiteKey string `json:"turnstile_site_key,omitempty"`
|
TurnstileSiteKey string `json:"turnstile_site_key,omitempty"`
|
||||||
@@ -161,6 +164,7 @@ func (s *SettingService) GetPublicSettingsForInjection(ctx context.Context) (any
|
|||||||
EmailVerifyEnabled: settings.EmailVerifyEnabled,
|
EmailVerifyEnabled: settings.EmailVerifyEnabled,
|
||||||
PromoCodeEnabled: settings.PromoCodeEnabled,
|
PromoCodeEnabled: settings.PromoCodeEnabled,
|
||||||
PasswordResetEnabled: settings.PasswordResetEnabled,
|
PasswordResetEnabled: settings.PasswordResetEnabled,
|
||||||
|
InvitationCodeEnabled: settings.InvitationCodeEnabled,
|
||||||
TotpEnabled: settings.TotpEnabled,
|
TotpEnabled: settings.TotpEnabled,
|
||||||
TurnstileEnabled: settings.TurnstileEnabled,
|
TurnstileEnabled: settings.TurnstileEnabled,
|
||||||
TurnstileSiteKey: settings.TurnstileSiteKey,
|
TurnstileSiteKey: settings.TurnstileSiteKey,
|
||||||
@@ -188,6 +192,7 @@ func (s *SettingService) UpdateSettings(ctx context.Context, settings *SystemSet
|
|||||||
updates[SettingKeyEmailVerifyEnabled] = strconv.FormatBool(settings.EmailVerifyEnabled)
|
updates[SettingKeyEmailVerifyEnabled] = strconv.FormatBool(settings.EmailVerifyEnabled)
|
||||||
updates[SettingKeyPromoCodeEnabled] = strconv.FormatBool(settings.PromoCodeEnabled)
|
updates[SettingKeyPromoCodeEnabled] = strconv.FormatBool(settings.PromoCodeEnabled)
|
||||||
updates[SettingKeyPasswordResetEnabled] = strconv.FormatBool(settings.PasswordResetEnabled)
|
updates[SettingKeyPasswordResetEnabled] = strconv.FormatBool(settings.PasswordResetEnabled)
|
||||||
|
updates[SettingKeyInvitationCodeEnabled] = strconv.FormatBool(settings.InvitationCodeEnabled)
|
||||||
updates[SettingKeyTotpEnabled] = strconv.FormatBool(settings.TotpEnabled)
|
updates[SettingKeyTotpEnabled] = strconv.FormatBool(settings.TotpEnabled)
|
||||||
|
|
||||||
// 邮件服务设置(只有非空才更新密码)
|
// 邮件服务设置(只有非空才更新密码)
|
||||||
@@ -286,6 +291,15 @@ func (s *SettingService) IsPromoCodeEnabled(ctx context.Context) bool {
|
|||||||
return value != "false"
|
return value != "false"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// IsInvitationCodeEnabled 检查是否启用邀请码注册功能
|
||||||
|
func (s *SettingService) IsInvitationCodeEnabled(ctx context.Context) bool {
|
||||||
|
value, err := s.settingRepo.GetValue(ctx, SettingKeyInvitationCodeEnabled)
|
||||||
|
if err != nil {
|
||||||
|
return false // 默认关闭
|
||||||
|
}
|
||||||
|
return value == "true"
|
||||||
|
}
|
||||||
|
|
||||||
// IsPasswordResetEnabled 检查是否启用密码重置功能
|
// IsPasswordResetEnabled 检查是否启用密码重置功能
|
||||||
// 要求:必须同时开启邮件验证
|
// 要求:必须同时开启邮件验证
|
||||||
func (s *SettingService) IsPasswordResetEnabled(ctx context.Context) bool {
|
func (s *SettingService) IsPasswordResetEnabled(ctx context.Context) bool {
|
||||||
@@ -401,6 +415,7 @@ func (s *SettingService) parseSettings(settings map[string]string) *SystemSettin
|
|||||||
EmailVerifyEnabled: emailVerifyEnabled,
|
EmailVerifyEnabled: emailVerifyEnabled,
|
||||||
PromoCodeEnabled: settings[SettingKeyPromoCodeEnabled] != "false", // 默认启用
|
PromoCodeEnabled: settings[SettingKeyPromoCodeEnabled] != "false", // 默认启用
|
||||||
PasswordResetEnabled: emailVerifyEnabled && settings[SettingKeyPasswordResetEnabled] == "true",
|
PasswordResetEnabled: emailVerifyEnabled && settings[SettingKeyPasswordResetEnabled] == "true",
|
||||||
|
InvitationCodeEnabled: settings[SettingKeyInvitationCodeEnabled] == "true",
|
||||||
TotpEnabled: settings[SettingKeyTotpEnabled] == "true",
|
TotpEnabled: settings[SettingKeyTotpEnabled] == "true",
|
||||||
SMTPHost: settings[SettingKeySMTPHost],
|
SMTPHost: settings[SettingKeySMTPHost],
|
||||||
SMTPUsername: settings[SettingKeySMTPUsername],
|
SMTPUsername: settings[SettingKeySMTPUsername],
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
package service
|
package service
|
||||||
|
|
||||||
type SystemSettings struct {
|
type SystemSettings struct {
|
||||||
RegistrationEnabled bool
|
RegistrationEnabled bool
|
||||||
EmailVerifyEnabled bool
|
EmailVerifyEnabled bool
|
||||||
PromoCodeEnabled bool
|
PromoCodeEnabled bool
|
||||||
PasswordResetEnabled bool
|
PasswordResetEnabled bool
|
||||||
TotpEnabled bool // TOTP 双因素认证
|
InvitationCodeEnabled bool
|
||||||
|
TotpEnabled bool // TOTP 双因素认证
|
||||||
|
|
||||||
SMTPHost string
|
SMTPHost string
|
||||||
SMTPPort int
|
SMTPPort int
|
||||||
@@ -61,21 +62,22 @@ type SystemSettings struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type PublicSettings struct {
|
type PublicSettings struct {
|
||||||
RegistrationEnabled bool
|
RegistrationEnabled bool
|
||||||
EmailVerifyEnabled bool
|
EmailVerifyEnabled bool
|
||||||
PromoCodeEnabled bool
|
PromoCodeEnabled bool
|
||||||
PasswordResetEnabled bool
|
PasswordResetEnabled bool
|
||||||
TotpEnabled bool // TOTP 双因素认证
|
InvitationCodeEnabled bool
|
||||||
TurnstileEnabled bool
|
TotpEnabled bool // TOTP 双因素认证
|
||||||
TurnstileSiteKey string
|
TurnstileEnabled bool
|
||||||
SiteName string
|
TurnstileSiteKey string
|
||||||
SiteLogo string
|
SiteName string
|
||||||
SiteSubtitle string
|
SiteLogo string
|
||||||
APIBaseURL string
|
SiteSubtitle string
|
||||||
ContactInfo string
|
APIBaseURL string
|
||||||
DocURL string
|
ContactInfo string
|
||||||
HomeContent string
|
DocURL string
|
||||||
HideCcsImportButton bool
|
HomeContent string
|
||||||
|
HideCcsImportButton bool
|
||||||
|
|
||||||
PurchaseSubscriptionEnabled bool
|
PurchaseSubscriptionEnabled bool
|
||||||
PurchaseSubscriptionURL string
|
PurchaseSubscriptionURL string
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ export interface SystemSettings {
|
|||||||
email_verify_enabled: boolean
|
email_verify_enabled: boolean
|
||||||
promo_code_enabled: boolean
|
promo_code_enabled: boolean
|
||||||
password_reset_enabled: boolean
|
password_reset_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 加密密钥是否已配置
|
||||||
// Default settings
|
// Default settings
|
||||||
@@ -72,6 +73,7 @@ export interface UpdateSettingsRequest {
|
|||||||
email_verify_enabled?: boolean
|
email_verify_enabled?: boolean
|
||||||
promo_code_enabled?: boolean
|
promo_code_enabled?: boolean
|
||||||
password_reset_enabled?: boolean
|
password_reset_enabled?: boolean
|
||||||
|
invitation_code_enabled?: boolean
|
||||||
totp_enabled?: boolean // TOTP 双因素认证
|
totp_enabled?: boolean // TOTP 双因素认证
|
||||||
default_balance?: number
|
default_balance?: number
|
||||||
default_concurrency?: number
|
default_concurrency?: number
|
||||||
|
|||||||
@@ -164,6 +164,24 @@ export async function validatePromoCode(code: string): Promise<ValidatePromoCode
|
|||||||
return data
|
return data
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate invitation code response
|
||||||
|
*/
|
||||||
|
export interface ValidateInvitationCodeResponse {
|
||||||
|
valid: boolean
|
||||||
|
error_code?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate invitation code (public endpoint, no auth required)
|
||||||
|
* @param code - Invitation code to validate
|
||||||
|
* @returns Validation result
|
||||||
|
*/
|
||||||
|
export async function validateInvitationCode(code: string): Promise<ValidateInvitationCodeResponse> {
|
||||||
|
const { data } = await apiClient.post<ValidateInvitationCodeResponse>('/auth/validate-invitation-code', { code })
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Forgot password request
|
* Forgot password request
|
||||||
*/
|
*/
|
||||||
@@ -229,6 +247,7 @@ export const authAPI = {
|
|||||||
getPublicSettings,
|
getPublicSettings,
|
||||||
sendVerifyCode,
|
sendVerifyCode,
|
||||||
validatePromoCode,
|
validatePromoCode,
|
||||||
|
validateInvitationCode,
|
||||||
forgotPassword,
|
forgotPassword,
|
||||||
resetPassword
|
resetPassword
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -265,6 +265,13 @@ export default {
|
|||||||
promoCodeAlreadyUsed: 'You have already used this promo code',
|
promoCodeAlreadyUsed: 'You have already used this promo code',
|
||||||
promoCodeValidating: 'Promo code is being validated, please wait',
|
promoCodeValidating: 'Promo code is being validated, please wait',
|
||||||
promoCodeInvalidCannotRegister: 'Invalid promo code. Please check and try again or clear the promo code field',
|
promoCodeInvalidCannotRegister: 'Invalid promo code. Please check and try again or clear the promo code field',
|
||||||
|
invitationCodeLabel: 'Invitation Code',
|
||||||
|
invitationCodePlaceholder: 'Enter invitation code',
|
||||||
|
invitationCodeRequired: 'Invitation code is required',
|
||||||
|
invitationCodeValid: 'Invitation code is valid',
|
||||||
|
invitationCodeInvalid: 'Invalid or used invitation code',
|
||||||
|
invitationCodeValidating: 'Validating invitation code...',
|
||||||
|
invitationCodeInvalidCannotRegister: 'Invalid invitation code. Please check and try again',
|
||||||
linuxdo: {
|
linuxdo: {
|
||||||
signIn: 'Continue with Linux.do',
|
signIn: 'Continue with Linux.do',
|
||||||
orContinue: 'or continue with email',
|
orContinue: 'or continue with email',
|
||||||
@@ -1899,6 +1906,8 @@ export default {
|
|||||||
balance: 'Balance',
|
balance: 'Balance',
|
||||||
concurrency: 'Concurrency',
|
concurrency: 'Concurrency',
|
||||||
subscription: 'Subscription',
|
subscription: 'Subscription',
|
||||||
|
invitation: 'Invitation',
|
||||||
|
invitationHint: 'Invitation codes are used to restrict user registration. They are automatically marked as used after use.',
|
||||||
unused: 'Unused',
|
unused: 'Unused',
|
||||||
used: 'Used',
|
used: 'Used',
|
||||||
columns: {
|
columns: {
|
||||||
@@ -1945,6 +1954,7 @@ export default {
|
|||||||
balance: 'Balance',
|
balance: 'Balance',
|
||||||
concurrency: 'Concurrency',
|
concurrency: 'Concurrency',
|
||||||
subscription: 'Subscription',
|
subscription: 'Subscription',
|
||||||
|
invitation: 'Invitation',
|
||||||
// Admin adjustment types (created when admin modifies user balance/concurrency)
|
// Admin adjustment types (created when admin modifies user balance/concurrency)
|
||||||
admin_balance: 'Balance (Admin)',
|
admin_balance: 'Balance (Admin)',
|
||||||
admin_concurrency: 'Concurrency (Admin)'
|
admin_concurrency: 'Concurrency (Admin)'
|
||||||
@@ -2896,6 +2906,8 @@ export default {
|
|||||||
emailVerificationHint: 'Require email verification for new registrations',
|
emailVerificationHint: 'Require email verification for new registrations',
|
||||||
promoCode: 'Promo Code',
|
promoCode: 'Promo Code',
|
||||||
promoCodeHint: 'Allow users to use promo codes during registration',
|
promoCodeHint: 'Allow users to use promo codes during registration',
|
||||||
|
invitationCode: 'Invitation Code Registration',
|
||||||
|
invitationCodeHint: 'When enabled, users must enter a valid invitation code to register',
|
||||||
passwordReset: 'Password Reset',
|
passwordReset: 'Password Reset',
|
||||||
passwordResetHint: 'Allow users to reset their password via email',
|
passwordResetHint: 'Allow users to reset their password via email',
|
||||||
totp: 'Two-Factor Authentication (2FA)',
|
totp: 'Two-Factor Authentication (2FA)',
|
||||||
|
|||||||
@@ -262,6 +262,13 @@ export default {
|
|||||||
promoCodeAlreadyUsed: '您已使用过此优惠码',
|
promoCodeAlreadyUsed: '您已使用过此优惠码',
|
||||||
promoCodeValidating: '优惠码正在验证中,请稍候',
|
promoCodeValidating: '优惠码正在验证中,请稍候',
|
||||||
promoCodeInvalidCannotRegister: '优惠码无效,请检查后重试或清空优惠码',
|
promoCodeInvalidCannotRegister: '优惠码无效,请检查后重试或清空优惠码',
|
||||||
|
invitationCodeLabel: '邀请码',
|
||||||
|
invitationCodePlaceholder: '请输入邀请码',
|
||||||
|
invitationCodeRequired: '请输入邀请码',
|
||||||
|
invitationCodeValid: '邀请码有效',
|
||||||
|
invitationCodeInvalid: '邀请码无效或已被使用',
|
||||||
|
invitationCodeValidating: '正在验证邀请码...',
|
||||||
|
invitationCodeInvalidCannotRegister: '邀请码无效,请检查后重试',
|
||||||
linuxdo: {
|
linuxdo: {
|
||||||
signIn: '使用 Linux.do 登录',
|
signIn: '使用 Linux.do 登录',
|
||||||
orContinue: '或使用邮箱密码继续',
|
orContinue: '或使用邮箱密码继续',
|
||||||
@@ -2022,6 +2029,7 @@ export default {
|
|||||||
balance: '余额',
|
balance: '余额',
|
||||||
concurrency: '并发数',
|
concurrency: '并发数',
|
||||||
subscription: '订阅',
|
subscription: '订阅',
|
||||||
|
invitation: '邀请码',
|
||||||
// 管理员在用户管理页面调整余额/并发时产生的记录
|
// 管理员在用户管理页面调整余额/并发时产生的记录
|
||||||
admin_balance: '余额(管理员)',
|
admin_balance: '余额(管理员)',
|
||||||
admin_concurrency: '并发数(管理员)'
|
admin_concurrency: '并发数(管理员)'
|
||||||
@@ -2030,6 +2038,8 @@ export default {
|
|||||||
balance: '余额',
|
balance: '余额',
|
||||||
concurrency: '并发数',
|
concurrency: '并发数',
|
||||||
subscription: '订阅',
|
subscription: '订阅',
|
||||||
|
invitation: '邀请码',
|
||||||
|
invitationHint: '邀请码用于限制用户注册,使用后自动标记为已使用。',
|
||||||
allTypes: '全部类型',
|
allTypes: '全部类型',
|
||||||
allStatus: '全部状态',
|
allStatus: '全部状态',
|
||||||
unused: '未使用',
|
unused: '未使用',
|
||||||
@@ -3049,6 +3059,8 @@ export default {
|
|||||||
emailVerificationHint: '新用户注册时需要验证邮箱',
|
emailVerificationHint: '新用户注册时需要验证邮箱',
|
||||||
promoCode: '优惠码',
|
promoCode: '优惠码',
|
||||||
promoCodeHint: '允许用户在注册时使用优惠码',
|
promoCodeHint: '允许用户在注册时使用优惠码',
|
||||||
|
invitationCode: '邀请码注册',
|
||||||
|
invitationCodeHint: '开启后,用户注册时需要填写有效的邀请码',
|
||||||
passwordReset: '忘记密码',
|
passwordReset: '忘记密码',
|
||||||
passwordResetHint: '允许用户通过邮箱重置密码',
|
passwordResetHint: '允许用户通过邮箱重置密码',
|
||||||
totp: '双因素认证 (2FA)',
|
totp: '双因素认证 (2FA)',
|
||||||
|
|||||||
@@ -314,6 +314,7 @@ export const useAppStore = defineStore('app', () => {
|
|||||||
email_verify_enabled: false,
|
email_verify_enabled: false,
|
||||||
promo_code_enabled: true,
|
promo_code_enabled: true,
|
||||||
password_reset_enabled: false,
|
password_reset_enabled: false,
|
||||||
|
invitation_code_enabled: false,
|
||||||
turnstile_enabled: false,
|
turnstile_enabled: false,
|
||||||
turnstile_site_key: '',
|
turnstile_site_key: '',
|
||||||
site_name: siteName.value,
|
site_name: siteName.value,
|
||||||
|
|||||||
@@ -55,6 +55,7 @@ export interface RegisterRequest {
|
|||||||
verify_code?: string
|
verify_code?: string
|
||||||
turnstile_token?: string
|
turnstile_token?: string
|
||||||
promo_code?: string
|
promo_code?: string
|
||||||
|
invitation_code?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SendVerifyCodeRequest {
|
export interface SendVerifyCodeRequest {
|
||||||
@@ -72,6 +73,7 @@ export interface PublicSettings {
|
|||||||
email_verify_enabled: boolean
|
email_verify_enabled: boolean
|
||||||
promo_code_enabled: boolean
|
promo_code_enabled: boolean
|
||||||
password_reset_enabled: boolean
|
password_reset_enabled: boolean
|
||||||
|
invitation_code_enabled: boolean
|
||||||
turnstile_enabled: boolean
|
turnstile_enabled: boolean
|
||||||
turnstile_site_key: string
|
turnstile_site_key: string
|
||||||
site_name: string
|
site_name: string
|
||||||
@@ -701,7 +703,7 @@ export interface UpdateProxyRequest {
|
|||||||
|
|
||||||
// ==================== Usage & Redeem Types ====================
|
// ==================== Usage & Redeem Types ====================
|
||||||
|
|
||||||
export type RedeemCodeType = 'balance' | 'concurrency' | 'subscription'
|
export type RedeemCodeType = 'balance' | 'concurrency' | 'subscription' | 'invitation'
|
||||||
|
|
||||||
export interface UsageLog {
|
export interface UsageLog {
|
||||||
id: number
|
id: number
|
||||||
|
|||||||
@@ -213,7 +213,7 @@
|
|||||||
<Select v-model="generateForm.type" :options="typeOptions" />
|
<Select v-model="generateForm.type" :options="typeOptions" />
|
||||||
</div>
|
</div>
|
||||||
<!-- 余额/并发类型:显示数值输入 -->
|
<!-- 余额/并发类型:显示数值输入 -->
|
||||||
<div v-if="generateForm.type !== 'subscription'">
|
<div v-if="generateForm.type !== 'subscription' && generateForm.type !== 'invitation'">
|
||||||
<label class="input-label">
|
<label class="input-label">
|
||||||
{{
|
{{
|
||||||
generateForm.type === 'balance'
|
generateForm.type === 'balance'
|
||||||
@@ -230,6 +230,12 @@
|
|||||||
class="input"
|
class="input"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<!-- 邀请码类型:显示提示信息 -->
|
||||||
|
<div v-if="generateForm.type === 'invitation'" class="rounded-lg bg-blue-50 p-3 dark:bg-blue-900/20">
|
||||||
|
<p class="text-sm text-blue-700 dark:text-blue-300">
|
||||||
|
{{ t('admin.redeem.invitationHint') }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
<!-- 订阅类型:显示分组选择和有效天数 -->
|
<!-- 订阅类型:显示分组选择和有效天数 -->
|
||||||
<template v-if="generateForm.type === 'subscription'">
|
<template v-if="generateForm.type === 'subscription'">
|
||||||
<div>
|
<div>
|
||||||
@@ -387,7 +393,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, reactive, computed, onMounted, onUnmounted } from 'vue'
|
import { ref, reactive, computed, onMounted, onUnmounted, watch } from 'vue'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
import { useAppStore } from '@/stores/app'
|
import { useAppStore } from '@/stores/app'
|
||||||
import { useClipboard } from '@/composables/useClipboard'
|
import { useClipboard } from '@/composables/useClipboard'
|
||||||
@@ -499,14 +505,16 @@ const columns = computed<Column[]>(() => [
|
|||||||
const typeOptions = computed(() => [
|
const typeOptions = computed(() => [
|
||||||
{ value: 'balance', label: t('admin.redeem.balance') },
|
{ value: 'balance', label: t('admin.redeem.balance') },
|
||||||
{ value: 'concurrency', label: t('admin.redeem.concurrency') },
|
{ value: 'concurrency', label: t('admin.redeem.concurrency') },
|
||||||
{ value: 'subscription', label: t('admin.redeem.subscription') }
|
{ value: 'subscription', label: t('admin.redeem.subscription') },
|
||||||
|
{ value: 'invitation', label: t('admin.redeem.invitation') }
|
||||||
])
|
])
|
||||||
|
|
||||||
const filterTypeOptions = computed(() => [
|
const filterTypeOptions = computed(() => [
|
||||||
{ value: '', label: t('admin.redeem.allTypes') },
|
{ value: '', label: t('admin.redeem.allTypes') },
|
||||||
{ value: 'balance', label: t('admin.redeem.balance') },
|
{ value: 'balance', label: t('admin.redeem.balance') },
|
||||||
{ value: 'concurrency', label: t('admin.redeem.concurrency') },
|
{ value: 'concurrency', label: t('admin.redeem.concurrency') },
|
||||||
{ value: 'subscription', label: t('admin.redeem.subscription') }
|
{ value: 'subscription', label: t('admin.redeem.subscription') },
|
||||||
|
{ value: 'invitation', label: t('admin.redeem.invitation') }
|
||||||
])
|
])
|
||||||
|
|
||||||
const filterStatusOptions = computed(() => [
|
const filterStatusOptions = computed(() => [
|
||||||
@@ -546,6 +554,18 @@ const generateForm = reactive({
|
|||||||
validity_days: 30
|
validity_days: 30
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// 监听类型变化,邀请码类型时自动设置 value 为 0
|
||||||
|
watch(
|
||||||
|
() => generateForm.type,
|
||||||
|
(newType) => {
|
||||||
|
if (newType === 'invitation') {
|
||||||
|
generateForm.value = 0
|
||||||
|
} else if (generateForm.value === 0) {
|
||||||
|
generateForm.value = 10
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
const loadCodes = async () => {
|
const loadCodes = async () => {
|
||||||
if (abortController) {
|
if (abortController) {
|
||||||
abortController.abort()
|
abortController.abort()
|
||||||
|
|||||||
@@ -339,6 +339,21 @@
|
|||||||
<Toggle v-model="form.promo_code_enabled" />
|
<Toggle v-model="form.promo_code_enabled" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Invitation Code -->
|
||||||
|
<div
|
||||||
|
class="flex items-center justify-between border-t border-gray-100 pt-4 dark:border-dark-700"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<label class="font-medium text-gray-900 dark:text-white">{{
|
||||||
|
t('admin.settings.registration.invitationCode')
|
||||||
|
}}</label>
|
||||||
|
<p class="text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
{{ t('admin.settings.registration.invitationCodeHint') }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Toggle v-model="form.invitation_code_enabled" />
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Password Reset - Only show when email verification is enabled -->
|
<!-- Password Reset - Only show when email verification is enabled -->
|
||||||
<div
|
<div
|
||||||
v-if="form.email_verify_enabled"
|
v-if="form.email_verify_enabled"
|
||||||
@@ -1115,6 +1130,7 @@ const form = reactive<SettingsForm>({
|
|||||||
registration_enabled: true,
|
registration_enabled: true,
|
||||||
email_verify_enabled: false,
|
email_verify_enabled: false,
|
||||||
promo_code_enabled: true,
|
promo_code_enabled: true,
|
||||||
|
invitation_code_enabled: false,
|
||||||
password_reset_enabled: false,
|
password_reset_enabled: false,
|
||||||
totp_enabled: false,
|
totp_enabled: false,
|
||||||
totp_encryption_key_configured: false,
|
totp_encryption_key_configured: false,
|
||||||
@@ -1243,6 +1259,7 @@ async function saveSettings() {
|
|||||||
registration_enabled: form.registration_enabled,
|
registration_enabled: form.registration_enabled,
|
||||||
email_verify_enabled: form.email_verify_enabled,
|
email_verify_enabled: form.email_verify_enabled,
|
||||||
promo_code_enabled: form.promo_code_enabled,
|
promo_code_enabled: form.promo_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,
|
||||||
default_balance: form.default_balance,
|
default_balance: form.default_balance,
|
||||||
|
|||||||
@@ -201,6 +201,7 @@ const email = ref<string>('')
|
|||||||
const password = ref<string>('')
|
const password = ref<string>('')
|
||||||
const initialTurnstileToken = ref<string>('')
|
const initialTurnstileToken = ref<string>('')
|
||||||
const promoCode = ref<string>('')
|
const promoCode = ref<string>('')
|
||||||
|
const invitationCode = ref<string>('')
|
||||||
const hasRegisterData = ref<boolean>(false)
|
const hasRegisterData = ref<boolean>(false)
|
||||||
|
|
||||||
// Public settings
|
// Public settings
|
||||||
@@ -230,6 +231,7 @@ onMounted(async () => {
|
|||||||
password.value = registerData.password || ''
|
password.value = registerData.password || ''
|
||||||
initialTurnstileToken.value = registerData.turnstile_token || ''
|
initialTurnstileToken.value = registerData.turnstile_token || ''
|
||||||
promoCode.value = registerData.promo_code || ''
|
promoCode.value = registerData.promo_code || ''
|
||||||
|
invitationCode.value = registerData.invitation_code || ''
|
||||||
hasRegisterData.value = !!(email.value && password.value)
|
hasRegisterData.value = !!(email.value && password.value)
|
||||||
} catch {
|
} catch {
|
||||||
hasRegisterData.value = false
|
hasRegisterData.value = false
|
||||||
@@ -384,7 +386,8 @@ async function handleVerify(): Promise<void> {
|
|||||||
password: password.value,
|
password: password.value,
|
||||||
verify_code: verifyCode.value.trim(),
|
verify_code: verifyCode.value.trim(),
|
||||||
turnstile_token: initialTurnstileToken.value || undefined,
|
turnstile_token: initialTurnstileToken.value || undefined,
|
||||||
promo_code: promoCode.value || undefined
|
promo_code: promoCode.value || undefined,
|
||||||
|
invitation_code: invitationCode.value || undefined
|
||||||
})
|
})
|
||||||
|
|
||||||
// Clear session data
|
// Clear session data
|
||||||
|
|||||||
@@ -95,6 +95,59 @@
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Invitation Code Input (Required when enabled) -->
|
||||||
|
<div v-if="invitationCodeEnabled">
|
||||||
|
<label for="invitation_code" class="input-label">
|
||||||
|
{{ t('auth.invitationCodeLabel') }}
|
||||||
|
</label>
|
||||||
|
<div class="relative">
|
||||||
|
<div class="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3.5">
|
||||||
|
<Icon name="key" size="md" :class="invitationValidation.valid ? 'text-green-500' : 'text-gray-400 dark:text-dark-500'" />
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
id="invitation_code"
|
||||||
|
v-model="formData.invitation_code"
|
||||||
|
type="text"
|
||||||
|
:disabled="isLoading"
|
||||||
|
class="input pl-11 pr-10"
|
||||||
|
:class="{
|
||||||
|
'border-green-500 focus:border-green-500 focus:ring-green-500': invitationValidation.valid,
|
||||||
|
'border-red-500 focus:border-red-500 focus:ring-red-500': invitationValidation.invalid || errors.invitation_code
|
||||||
|
}"
|
||||||
|
:placeholder="t('auth.invitationCodePlaceholder')"
|
||||||
|
@input="handleInvitationCodeInput"
|
||||||
|
/>
|
||||||
|
<!-- Validation indicator -->
|
||||||
|
<div v-if="invitationValidating" class="absolute inset-y-0 right-0 flex items-center pr-3.5">
|
||||||
|
<svg class="h-4 w-4 animate-spin text-gray-400" fill="none" viewBox="0 0 24 24">
|
||||||
|
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||||
|
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div v-else-if="invitationValidation.valid" class="absolute inset-y-0 right-0 flex items-center pr-3.5">
|
||||||
|
<Icon name="checkCircle" size="md" class="text-green-500" />
|
||||||
|
</div>
|
||||||
|
<div v-else-if="invitationValidation.invalid || errors.invitation_code" class="absolute inset-y-0 right-0 flex items-center pr-3.5">
|
||||||
|
<Icon name="exclamationCircle" size="md" class="text-red-500" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- Invitation code validation result -->
|
||||||
|
<transition name="fade">
|
||||||
|
<div v-if="invitationValidation.valid" class="mt-2 flex items-center gap-2 rounded-lg bg-green-50 px-3 py-2 dark:bg-green-900/20">
|
||||||
|
<Icon name="checkCircle" size="sm" class="text-green-600 dark:text-green-400" />
|
||||||
|
<span class="text-sm text-green-700 dark:text-green-400">
|
||||||
|
{{ t('auth.invitationCodeValid') }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p v-else-if="invitationValidation.invalid" class="input-error-text">
|
||||||
|
{{ invitationValidation.message }}
|
||||||
|
</p>
|
||||||
|
<p v-else-if="errors.invitation_code" class="input-error-text">
|
||||||
|
{{ errors.invitation_code }}
|
||||||
|
</p>
|
||||||
|
</transition>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Promo Code Input (Optional) -->
|
<!-- Promo Code Input (Optional) -->
|
||||||
<div v-if="promoCodeEnabled">
|
<div v-if="promoCodeEnabled">
|
||||||
<label for="promo_code" class="input-label">
|
<label for="promo_code" class="input-label">
|
||||||
@@ -239,7 +292,7 @@ import LinuxDoOAuthSection from '@/components/auth/LinuxDoOAuthSection.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, validatePromoCode } from '@/api/auth'
|
import { getPublicSettings, validatePromoCode, validateInvitationCode } from '@/api/auth'
|
||||||
|
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
|
|
||||||
@@ -261,6 +314,7 @@ const showPassword = ref<boolean>(false)
|
|||||||
const registrationEnabled = ref<boolean>(true)
|
const registrationEnabled = ref<boolean>(true)
|
||||||
const emailVerifyEnabled = ref<boolean>(false)
|
const emailVerifyEnabled = ref<boolean>(false)
|
||||||
const promoCodeEnabled = ref<boolean>(true)
|
const promoCodeEnabled = ref<boolean>(true)
|
||||||
|
const invitationCodeEnabled = ref<boolean>(false)
|
||||||
const turnstileEnabled = ref<boolean>(false)
|
const turnstileEnabled = ref<boolean>(false)
|
||||||
const turnstileSiteKey = ref<string>('')
|
const turnstileSiteKey = ref<string>('')
|
||||||
const siteName = ref<string>('Sub2API')
|
const siteName = ref<string>('Sub2API')
|
||||||
@@ -280,16 +334,27 @@ const promoValidation = reactive({
|
|||||||
})
|
})
|
||||||
let promoValidateTimeout: ReturnType<typeof setTimeout> | null = null
|
let promoValidateTimeout: ReturnType<typeof setTimeout> | null = null
|
||||||
|
|
||||||
|
// Invitation code validation
|
||||||
|
const invitationValidating = ref<boolean>(false)
|
||||||
|
const invitationValidation = reactive({
|
||||||
|
valid: false,
|
||||||
|
invalid: false,
|
||||||
|
message: ''
|
||||||
|
})
|
||||||
|
let invitationValidateTimeout: ReturnType<typeof setTimeout> | null = null
|
||||||
|
|
||||||
const formData = reactive({
|
const formData = reactive({
|
||||||
email: '',
|
email: '',
|
||||||
password: '',
|
password: '',
|
||||||
promo_code: ''
|
promo_code: '',
|
||||||
|
invitation_code: ''
|
||||||
})
|
})
|
||||||
|
|
||||||
const errors = reactive({
|
const errors = reactive({
|
||||||
email: '',
|
email: '',
|
||||||
password: '',
|
password: '',
|
||||||
turnstile: ''
|
turnstile: '',
|
||||||
|
invitation_code: ''
|
||||||
})
|
})
|
||||||
|
|
||||||
// ==================== Lifecycle ====================
|
// ==================== Lifecycle ====================
|
||||||
@@ -300,6 +365,7 @@ onMounted(async () => {
|
|||||||
registrationEnabled.value = settings.registration_enabled
|
registrationEnabled.value = settings.registration_enabled
|
||||||
emailVerifyEnabled.value = settings.email_verify_enabled
|
emailVerifyEnabled.value = settings.email_verify_enabled
|
||||||
promoCodeEnabled.value = settings.promo_code_enabled
|
promoCodeEnabled.value = settings.promo_code_enabled
|
||||||
|
invitationCodeEnabled.value = settings.invitation_code_enabled
|
||||||
turnstileEnabled.value = settings.turnstile_enabled
|
turnstileEnabled.value = settings.turnstile_enabled
|
||||||
turnstileSiteKey.value = settings.turnstile_site_key || ''
|
turnstileSiteKey.value = settings.turnstile_site_key || ''
|
||||||
siteName.value = settings.site_name || 'Sub2API'
|
siteName.value = settings.site_name || 'Sub2API'
|
||||||
@@ -325,6 +391,9 @@ onUnmounted(() => {
|
|||||||
if (promoValidateTimeout) {
|
if (promoValidateTimeout) {
|
||||||
clearTimeout(promoValidateTimeout)
|
clearTimeout(promoValidateTimeout)
|
||||||
}
|
}
|
||||||
|
if (invitationValidateTimeout) {
|
||||||
|
clearTimeout(invitationValidateTimeout)
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
// ==================== Promo Code Validation ====================
|
// ==================== Promo Code Validation ====================
|
||||||
@@ -400,6 +469,70 @@ function getPromoErrorMessage(errorCode?: string): string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ==================== Invitation Code Validation ====================
|
||||||
|
|
||||||
|
function handleInvitationCodeInput(): void {
|
||||||
|
const code = formData.invitation_code.trim()
|
||||||
|
|
||||||
|
// Clear previous validation
|
||||||
|
invitationValidation.valid = false
|
||||||
|
invitationValidation.invalid = false
|
||||||
|
invitationValidation.message = ''
|
||||||
|
errors.invitation_code = ''
|
||||||
|
|
||||||
|
if (!code) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Debounce validation
|
||||||
|
if (invitationValidateTimeout) {
|
||||||
|
clearTimeout(invitationValidateTimeout)
|
||||||
|
}
|
||||||
|
|
||||||
|
invitationValidateTimeout = setTimeout(() => {
|
||||||
|
validateInvitationCodeDebounced(code)
|
||||||
|
}, 500)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function validateInvitationCodeDebounced(code: string): Promise<void> {
|
||||||
|
invitationValidating.value = true
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await validateInvitationCode(code)
|
||||||
|
|
||||||
|
if (result.valid) {
|
||||||
|
invitationValidation.valid = true
|
||||||
|
invitationValidation.invalid = false
|
||||||
|
invitationValidation.message = ''
|
||||||
|
} else {
|
||||||
|
invitationValidation.valid = false
|
||||||
|
invitationValidation.invalid = true
|
||||||
|
invitationValidation.message = getInvitationErrorMessage(result.error_code)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
invitationValidation.valid = false
|
||||||
|
invitationValidation.invalid = true
|
||||||
|
invitationValidation.message = t('auth.invitationCodeInvalid')
|
||||||
|
} finally {
|
||||||
|
invitationValidating.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getInvitationErrorMessage(errorCode?: string): string {
|
||||||
|
switch (errorCode) {
|
||||||
|
case 'INVITATION_CODE_NOT_FOUND':
|
||||||
|
return t('auth.invitationCodeInvalid')
|
||||||
|
case 'INVITATION_CODE_INVALID':
|
||||||
|
return t('auth.invitationCodeInvalid')
|
||||||
|
case 'INVITATION_CODE_USED':
|
||||||
|
return t('auth.invitationCodeInvalid')
|
||||||
|
case 'INVITATION_CODE_DISABLED':
|
||||||
|
return t('auth.invitationCodeInvalid')
|
||||||
|
default:
|
||||||
|
return t('auth.invitationCodeInvalid')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ==================== Turnstile Handlers ====================
|
// ==================== Turnstile Handlers ====================
|
||||||
|
|
||||||
function onTurnstileVerify(token: string): void {
|
function onTurnstileVerify(token: string): void {
|
||||||
@@ -429,6 +562,7 @@ function validateForm(): boolean {
|
|||||||
errors.email = ''
|
errors.email = ''
|
||||||
errors.password = ''
|
errors.password = ''
|
||||||
errors.turnstile = ''
|
errors.turnstile = ''
|
||||||
|
errors.invitation_code = ''
|
||||||
|
|
||||||
let isValid = true
|
let isValid = true
|
||||||
|
|
||||||
@@ -450,6 +584,14 @@ function validateForm(): boolean {
|
|||||||
isValid = false
|
isValid = false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Invitation code validation (required when enabled)
|
||||||
|
if (invitationCodeEnabled.value) {
|
||||||
|
if (!formData.invitation_code.trim()) {
|
||||||
|
errors.invitation_code = t('auth.invitationCodeRequired')
|
||||||
|
isValid = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Turnstile validation
|
// Turnstile validation
|
||||||
if (turnstileEnabled.value && !turnstileToken.value) {
|
if (turnstileEnabled.value && !turnstileToken.value) {
|
||||||
errors.turnstile = t('auth.completeVerification')
|
errors.turnstile = t('auth.completeVerification')
|
||||||
@@ -484,6 +626,30 @@ async function handleRegister(): Promise<void> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check invitation code validation status (if enabled and code provided)
|
||||||
|
if (invitationCodeEnabled.value) {
|
||||||
|
// If still validating, wait
|
||||||
|
if (invitationValidating.value) {
|
||||||
|
errorMessage.value = t('auth.invitationCodeValidating')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// If invitation code is invalid, block submission
|
||||||
|
if (invitationValidation.invalid) {
|
||||||
|
errorMessage.value = t('auth.invitationCodeInvalidCannotRegister')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// If invitation code is required but not validated yet
|
||||||
|
if (formData.invitation_code.trim() && !invitationValidation.valid) {
|
||||||
|
errorMessage.value = t('auth.invitationCodeValidating')
|
||||||
|
// Trigger validation
|
||||||
|
await validateInvitationCodeDebounced(formData.invitation_code.trim())
|
||||||
|
if (!invitationValidation.valid) {
|
||||||
|
errorMessage.value = t('auth.invitationCodeInvalidCannotRegister')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
isLoading.value = true
|
isLoading.value = true
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -496,7 +662,8 @@ async function handleRegister(): Promise<void> {
|
|||||||
email: formData.email,
|
email: formData.email,
|
||||||
password: formData.password,
|
password: formData.password,
|
||||||
turnstile_token: turnstileToken.value,
|
turnstile_token: turnstileToken.value,
|
||||||
promo_code: formData.promo_code || undefined
|
promo_code: formData.promo_code || undefined,
|
||||||
|
invitation_code: formData.invitation_code || undefined
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -510,7 +677,8 @@ async function handleRegister(): Promise<void> {
|
|||||||
email: formData.email,
|
email: formData.email,
|
||||||
password: formData.password,
|
password: formData.password,
|
||||||
turnstile_token: turnstileEnabled.value ? turnstileToken.value : undefined,
|
turnstile_token: turnstileEnabled.value ? turnstileToken.value : undefined,
|
||||||
promo_code: formData.promo_code || undefined
|
promo_code: formData.promo_code || undefined,
|
||||||
|
invitation_code: formData.invitation_code || undefined
|
||||||
})
|
})
|
||||||
|
|
||||||
// Show success toast
|
// Show success toast
|
||||||
|
|||||||
Reference in New Issue
Block a user