feat: 增加邀请码注册功能

This commit is contained in:
shuike
2026-01-29 16:29:59 +08:00
parent 0ab68aa9fb
commit 6c86501d11
26 changed files with 475 additions and 83 deletions

View File

@@ -33,7 +33,7 @@ func main() {
}()
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)
defer cancel()

View File

@@ -43,6 +43,7 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
return nil, err
}
userRepository := repository.NewUserRepository(client, db)
redeemCodeRepository := repository.NewRedeemCodeRepository(client)
settingRepository := repository.NewSettingRepository(client)
settingService := service.NewSettingService(settingRepository, 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)
apiKeyAuthCacheInvalidator := service.ProvideAPIKeyAuthCacheInvalidator(apiKeyService)
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)
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)
if err != nil {
return nil, err
}
totpCache := repository.NewTotpCache(redisClient)
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)
apiKeyHandler := handler.NewAPIKeyHandler(apiKeyService)
usageLogRepository := repository.NewUsageLogRepository(client, db)
usageService := service.NewUsageService(usageLogRepository, userRepository, client, apiKeyAuthCacheInvalidator)
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)
subscriptionHandler := handler.NewSubscriptionHandler(subscriptionService)
announcementRepository := repository.NewAnnouncementRepository(client)

View File

@@ -36,6 +36,7 @@ const (
RedeemTypeBalance = "balance"
RedeemTypeConcurrency = "concurrency"
RedeemTypeSubscription = "subscription"
RedeemTypeInvitation = "invitation"
)
// PromoCode status constants

View File

@@ -29,7 +29,7 @@ func NewRedeemHandler(adminService service.AdminService) *RedeemHandler {
// GenerateRedeemCodesRequest represents generate redeem codes request
type GenerateRedeemCodesRequest struct {
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"`
GroupID *int64 `json:"group_id"` // 订阅类型必填
ValidityDays int `json:"validity_days" binding:"omitempty,max=36500"` // 订阅类型使用默认30天最大100年

View File

@@ -49,6 +49,7 @@ func (h *SettingHandler) GetSettings(c *gin.Context) {
EmailVerifyEnabled: settings.EmailVerifyEnabled,
PromoCodeEnabled: settings.PromoCodeEnabled,
PasswordResetEnabled: settings.PasswordResetEnabled,
InvitationCodeEnabled: settings.InvitationCodeEnabled,
TotpEnabled: settings.TotpEnabled,
TotpEncryptionKeyConfigured: h.settingService.IsTotpEncryptionKeyConfigured(),
SMTPHost: settings.SMTPHost,
@@ -94,11 +95,12 @@ func (h *SettingHandler) GetSettings(c *gin.Context) {
// UpdateSettingsRequest 更新设置请求
type UpdateSettingsRequest struct {
// 注册设置
RegistrationEnabled bool `json:"registration_enabled"`
EmailVerifyEnabled bool `json:"email_verify_enabled"`
PromoCodeEnabled bool `json:"promo_code_enabled"`
PasswordResetEnabled bool `json:"password_reset_enabled"`
TotpEnabled bool `json:"totp_enabled"` // TOTP 双因素认证
RegistrationEnabled bool `json:"registration_enabled"`
EmailVerifyEnabled bool `json:"email_verify_enabled"`
PromoCodeEnabled bool `json:"promo_code_enabled"`
PasswordResetEnabled bool `json:"password_reset_enabled"`
InvitationCodeEnabled bool `json:"invitation_code_enabled"`
TotpEnabled bool `json:"totp_enabled"` // TOTP 双因素认证
// 邮件服务设置
SMTPHost string `json:"smtp_host"`
@@ -291,6 +293,7 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
EmailVerifyEnabled: req.EmailVerifyEnabled,
PromoCodeEnabled: req.PromoCodeEnabled,
PasswordResetEnabled: req.PasswordResetEnabled,
InvitationCodeEnabled: req.InvitationCodeEnabled,
TotpEnabled: req.TotpEnabled,
SMTPHost: req.SMTPHost,
SMTPPort: req.SMTPPort,
@@ -370,6 +373,7 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
EmailVerifyEnabled: updatedSettings.EmailVerifyEnabled,
PromoCodeEnabled: updatedSettings.PromoCodeEnabled,
PasswordResetEnabled: updatedSettings.PasswordResetEnabled,
InvitationCodeEnabled: updatedSettings.InvitationCodeEnabled,
TotpEnabled: updatedSettings.TotpEnabled,
TotpEncryptionKeyConfigured: h.settingService.IsTotpEncryptionKeyConfigured(),
SMTPHost: updatedSettings.SMTPHost,

View File

@@ -15,23 +15,25 @@ import (
// AuthHandler handles authentication-related requests
type AuthHandler struct {
cfg *config.Config
authService *service.AuthService
userService *service.UserService
settingSvc *service.SettingService
promoService *service.PromoService
totpService *service.TotpService
cfg *config.Config
authService *service.AuthService
userService *service.UserService
settingSvc *service.SettingService
promoService *service.PromoService
redeemService *service.RedeemService
totpService *service.TotpService
}
// 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{
cfg: cfg,
authService: authService,
userService: userService,
settingSvc: settingService,
promoService: promoService,
totpService: totpService,
cfg: cfg,
authService: authService,
userService: userService,
settingSvc: settingService,
promoService: promoService,
redeemService: redeemService,
totpService: totpService,
}
}
@@ -41,7 +43,8 @@ type RegisterRequest struct {
Password string `json:"password" binding:"required,min=6"`
VerifyCode string `json:"verify_code"`
TurnstileToken string `json:"turnstile_token"`
PromoCode string `json:"promo_code"` // 注册优惠码
PromoCode string `json:"promo_code"` // 注册优惠码
InvitationCode string `json:"invitation_code"` // 邀请码
}
// 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 {
response.ErrorFrom(c, err)
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 忘记密码请求
type ForgotPasswordRequest struct {
Email string `json:"email" binding:"required,email"`

View File

@@ -6,6 +6,7 @@ type SystemSettings struct {
EmailVerifyEnabled bool `json:"email_verify_enabled"`
PromoCodeEnabled bool `json:"promo_code_enabled"`
PasswordResetEnabled bool `json:"password_reset_enabled"`
InvitationCodeEnabled bool `json:"invitation_code_enabled"`
TotpEnabled bool `json:"totp_enabled"` // TOTP 双因素认证
TotpEncryptionKeyConfigured bool `json:"totp_encryption_key_configured"` // TOTP 加密密钥是否已配置
@@ -63,6 +64,7 @@ type PublicSettings struct {
EmailVerifyEnabled bool `json:"email_verify_enabled"`
PromoCodeEnabled bool `json:"promo_code_enabled"`
PasswordResetEnabled bool `json:"password_reset_enabled"`
InvitationCodeEnabled bool `json:"invitation_code_enabled"`
TotpEnabled bool `json:"totp_enabled"` // TOTP 双因素认证
TurnstileEnabled bool `json:"turnstile_enabled"`
TurnstileSiteKey string `json:"turnstile_site_key"`

View File

@@ -36,6 +36,7 @@ func (h *SettingHandler) GetPublicSettings(c *gin.Context) {
EmailVerifyEnabled: settings.EmailVerifyEnabled,
PromoCodeEnabled: settings.PromoCodeEnabled,
PasswordResetEnabled: settings.PasswordResetEnabled,
InvitationCodeEnabled: settings.InvitationCodeEnabled,
TotpEnabled: settings.TotpEnabled,
TurnstileEnabled: settings.TurnstileEnabled,
TurnstileSiteKey: settings.TurnstileSiteKey,

View File

@@ -600,7 +600,7 @@ func newContractDeps(t *testing.T) *contractDeps {
settingService := service.NewSettingService(settingRepo, cfg)
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)
usageHandler := handler.NewUsageHandler(usageService, apiKeyService)
adminSettingHandler := adminhandler.NewSettingHandler(settingService, nil, nil, nil)

View File

@@ -32,6 +32,10 @@ func RegisterAuthRoutes(
auth.POST("/validate-promo-code", rateLimiter.LimitWithOptions("validate-promo", 10, time.Minute, middleware.RateLimitOptions{
FailureMode: middleware.RateLimitFailClose,
}), 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
auth.POST("/forgot-password", rateLimiter.LimitWithOptions("forgot-password", 5, time.Minute, middleware.RateLimitOptions{
FailureMode: middleware.RateLimitFailClose,

View File

@@ -19,17 +19,19 @@ import (
)
var (
ErrInvalidCredentials = infraerrors.Unauthorized("INVALID_CREDENTIALS", "invalid email or password")
ErrUserNotActive = infraerrors.Forbidden("USER_NOT_ACTIVE", "user is not active")
ErrEmailExists = infraerrors.Conflict("EMAIL_EXISTS", "email already exists")
ErrEmailReserved = infraerrors.BadRequest("EMAIL_RESERVED", "email is reserved")
ErrInvalidToken = infraerrors.Unauthorized("INVALID_TOKEN", "invalid token")
ErrTokenExpired = infraerrors.Unauthorized("TOKEN_EXPIRED", "token has expired")
ErrTokenTooLarge = infraerrors.BadRequest("TOKEN_TOO_LARGE", "token too large")
ErrTokenRevoked = infraerrors.Unauthorized("TOKEN_REVOKED", "token has been revoked")
ErrEmailVerifyRequired = infraerrors.BadRequest("EMAIL_VERIFY_REQUIRED", "email verification is required")
ErrRegDisabled = infraerrors.Forbidden("REGISTRATION_DISABLED", "registration is currently disabled")
ErrServiceUnavailable = infraerrors.ServiceUnavailable("SERVICE_UNAVAILABLE", "service temporarily unavailable")
ErrInvalidCredentials = infraerrors.Unauthorized("INVALID_CREDENTIALS", "invalid email or password")
ErrUserNotActive = infraerrors.Forbidden("USER_NOT_ACTIVE", "user is not active")
ErrEmailExists = infraerrors.Conflict("EMAIL_EXISTS", "email already exists")
ErrEmailReserved = infraerrors.BadRequest("EMAIL_RESERVED", "email is reserved")
ErrInvalidToken = infraerrors.Unauthorized("INVALID_TOKEN", "invalid token")
ErrTokenExpired = infraerrors.Unauthorized("TOKEN_EXPIRED", "token has expired")
ErrTokenTooLarge = infraerrors.BadRequest("TOKEN_TOO_LARGE", "token too large")
ErrTokenRevoked = infraerrors.Unauthorized("TOKEN_REVOKED", "token has been revoked")
ErrEmailVerifyRequired = infraerrors.BadRequest("EMAIL_VERIFY_REQUIRED", "email verification is required")
ErrRegDisabled = infraerrors.Forbidden("REGISTRATION_DISABLED", "registration is currently disabled")
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 触发解析时的异常内存分配。
@@ -47,6 +49,7 @@ type JWTClaims struct {
// AuthService 认证服务
type AuthService struct {
userRepo UserRepository
redeemRepo RedeemCodeRepository
cfg *config.Config
settingService *SettingService
emailService *EmailService
@@ -58,6 +61,7 @@ type AuthService struct {
// NewAuthService 创建认证服务实例
func NewAuthService(
userRepo UserRepository,
redeemRepo RedeemCodeRepository,
cfg *config.Config,
settingService *SettingService,
emailService *EmailService,
@@ -67,6 +71,7 @@ func NewAuthService(
) *AuthService {
return &AuthService{
userRepo: userRepo,
redeemRepo: redeemRepo,
cfg: cfg,
settingService: settingService,
emailService: emailService,
@@ -78,11 +83,11 @@ func NewAuthService(
// Register 用户注册返回token和用户
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和用户
func (s *AuthService) RegisterWithVerification(ctx context.Context, email, password, verifyCode, promoCode string) (string, *User, error) {
// RegisterWithVerification 用户注册(支持邮件验证优惠码和邀请码返回token和用户
func (s *AuthService) RegisterWithVerification(ctx context.Context, email, password, verifyCode, promoCode, invitationCode string) (string, *User, error) {
// 检查是否开放注册默认关闭settingService 未配置时不允许注册)
if s.settingService == nil || !s.settingService.IsRegistrationEnabled(ctx) {
return "", nil, ErrRegDisabled
@@ -93,6 +98,26 @@ func (s *AuthService) RegisterWithVerification(ctx context.Context, email, passw
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) {
// 如果邮件验证已开启但邮件服务未配置,拒绝注册
@@ -153,6 +178,14 @@ func (s *AuthService) RegisterWithVerification(ctx context.Context, email, passw
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 err := s.promoService.ApplyPromoCode(ctx, user.ID, promoCode); err != nil {

View File

@@ -115,6 +115,7 @@ func newAuthService(repo *userRepoStub, settings map[string]string, emailCache E
return NewAuthService(
repo,
nil, // redeemRepo
cfg,
settingService,
emailService,
@@ -152,7 +153,7 @@ func TestAuthService_Register_EmailVerifyEnabledButServiceNotConfigured(t *testi
}, 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)
}
@@ -164,7 +165,7 @@ func TestAuthService_Register_EmailVerifyRequired(t *testing.T) {
SettingKeyEmailVerifyEnabled: "true",
}, cache)
_, _, err := service.RegisterWithVerification(context.Background(), "user@test.com", "password", "", "")
_, _, err := service.RegisterWithVerification(context.Background(), "user@test.com", "password", "", "", "")
require.ErrorIs(t, err, ErrEmailVerifyRequired)
}
@@ -178,7 +179,7 @@ func TestAuthService_Register_EmailVerifyInvalid(t *testing.T) {
SettingKeyEmailVerifyEnabled: "true",
}, 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.ErrorContains(t, err, "verify code")
}

View File

@@ -38,6 +38,7 @@ const (
RedeemTypeBalance = domain.RedeemTypeBalance
RedeemTypeConcurrency = domain.RedeemTypeConcurrency
RedeemTypeSubscription = domain.RedeemTypeSubscription
RedeemTypeInvitation = domain.RedeemTypeInvitation
)
// PromoCode status constants
@@ -71,10 +72,11 @@ const LinuxDoConnectSyntheticEmailDomain = "@linuxdo-connect.invalid"
// Setting keys
const (
// 注册设置
SettingKeyRegistrationEnabled = "registration_enabled" // 是否开放注册
SettingKeyEmailVerifyEnabled = "email_verify_enabled" // 是否开启邮件验证
SettingKeyPromoCodeEnabled = "promo_code_enabled" // 是否启用优惠码功能
SettingKeyPasswordResetEnabled = "password_reset_enabled" // 是否启用忘记密码功能(需要先开启邮件验证)
SettingKeyRegistrationEnabled = "registration_enabled" // 是否开放注册
SettingKeyEmailVerifyEnabled = "email_verify_enabled" // 是否开启邮件验证
SettingKeyPromoCodeEnabled = "promo_code_enabled" // 是否启用优惠码功能
SettingKeyPasswordResetEnabled = "password_reset_enabled" // 是否启用忘记密码功能(需要先开启邮件验证)
SettingKeyInvitationCodeEnabled = "invitation_code_enabled" // 是否启用邀请码注册
// 邮件服务设置
SettingKeySMTPHost = "smtp_host" // SMTP服务器地址

View File

@@ -126,7 +126,8 @@ func (s *RedeemService) GenerateCodes(ctx context.Context, req GenerateCodesRequ
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")
}
@@ -139,6 +140,12 @@ func (s *RedeemService) GenerateCodes(ctx context.Context, req GenerateCodesRequ
codeType = RedeemTypeBalance
}
// 邀请码类型的 value 设为 0
value := req.Value
if codeType == RedeemTypeInvitation {
value = 0
}
codes := make([]RedeemCode, 0, req.Count)
for i := 0; i < req.Count; i++ {
code, err := s.GenerateRandomCode()
@@ -149,7 +156,7 @@ func (s *RedeemService) GenerateCodes(ctx context.Context, req GenerateCodesRequ
codes = append(codes, RedeemCode{
Code: code,
Type: codeType,
Value: req.Value,
Value: value,
Status: StatusUnused,
})
}

View File

@@ -62,6 +62,7 @@ func (s *SettingService) GetPublicSettings(ctx context.Context) (*PublicSettings
SettingKeyEmailVerifyEnabled,
SettingKeyPromoCodeEnabled,
SettingKeyPasswordResetEnabled,
SettingKeyInvitationCodeEnabled,
SettingKeyTotpEnabled,
SettingKeyTurnstileEnabled,
SettingKeyTurnstileSiteKey,
@@ -99,6 +100,7 @@ func (s *SettingService) GetPublicSettings(ctx context.Context) (*PublicSettings
EmailVerifyEnabled: emailVerifyEnabled,
PromoCodeEnabled: settings[SettingKeyPromoCodeEnabled] != "false", // 默认启用
PasswordResetEnabled: passwordResetEnabled,
InvitationCodeEnabled: settings[SettingKeyInvitationCodeEnabled] == "true",
TotpEnabled: settings[SettingKeyTotpEnabled] == "true",
TurnstileEnabled: settings[SettingKeyTurnstileEnabled] == "true",
TurnstileSiteKey: settings[SettingKeyTurnstileSiteKey],
@@ -141,6 +143,7 @@ func (s *SettingService) GetPublicSettingsForInjection(ctx context.Context) (any
EmailVerifyEnabled bool `json:"email_verify_enabled"`
PromoCodeEnabled bool `json:"promo_code_enabled"`
PasswordResetEnabled bool `json:"password_reset_enabled"`
InvitationCodeEnabled bool `json:"invitation_code_enabled"`
TotpEnabled bool `json:"totp_enabled"`
TurnstileEnabled bool `json:"turnstile_enabled"`
TurnstileSiteKey string `json:"turnstile_site_key,omitempty"`
@@ -161,6 +164,7 @@ func (s *SettingService) GetPublicSettingsForInjection(ctx context.Context) (any
EmailVerifyEnabled: settings.EmailVerifyEnabled,
PromoCodeEnabled: settings.PromoCodeEnabled,
PasswordResetEnabled: settings.PasswordResetEnabled,
InvitationCodeEnabled: settings.InvitationCodeEnabled,
TotpEnabled: settings.TotpEnabled,
TurnstileEnabled: settings.TurnstileEnabled,
TurnstileSiteKey: settings.TurnstileSiteKey,
@@ -188,6 +192,7 @@ func (s *SettingService) UpdateSettings(ctx context.Context, settings *SystemSet
updates[SettingKeyEmailVerifyEnabled] = strconv.FormatBool(settings.EmailVerifyEnabled)
updates[SettingKeyPromoCodeEnabled] = strconv.FormatBool(settings.PromoCodeEnabled)
updates[SettingKeyPasswordResetEnabled] = strconv.FormatBool(settings.PasswordResetEnabled)
updates[SettingKeyInvitationCodeEnabled] = strconv.FormatBool(settings.InvitationCodeEnabled)
updates[SettingKeyTotpEnabled] = strconv.FormatBool(settings.TotpEnabled)
// 邮件服务设置(只有非空才更新密码)
@@ -286,6 +291,15 @@ func (s *SettingService) IsPromoCodeEnabled(ctx context.Context) bool {
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 检查是否启用密码重置功能
// 要求:必须同时开启邮件验证
func (s *SettingService) IsPasswordResetEnabled(ctx context.Context) bool {
@@ -401,6 +415,7 @@ func (s *SettingService) parseSettings(settings map[string]string) *SystemSettin
EmailVerifyEnabled: emailVerifyEnabled,
PromoCodeEnabled: settings[SettingKeyPromoCodeEnabled] != "false", // 默认启用
PasswordResetEnabled: emailVerifyEnabled && settings[SettingKeyPasswordResetEnabled] == "true",
InvitationCodeEnabled: settings[SettingKeyInvitationCodeEnabled] == "true",
TotpEnabled: settings[SettingKeyTotpEnabled] == "true",
SMTPHost: settings[SettingKeySMTPHost],
SMTPUsername: settings[SettingKeySMTPUsername],

View File

@@ -1,11 +1,12 @@
package service
type SystemSettings struct {
RegistrationEnabled bool
EmailVerifyEnabled bool
PromoCodeEnabled bool
PasswordResetEnabled bool
TotpEnabled bool // TOTP 双因素认证
RegistrationEnabled bool
EmailVerifyEnabled bool
PromoCodeEnabled bool
PasswordResetEnabled bool
InvitationCodeEnabled bool
TotpEnabled bool // TOTP 双因素认证
SMTPHost string
SMTPPort int
@@ -61,21 +62,22 @@ type SystemSettings struct {
}
type PublicSettings struct {
RegistrationEnabled bool
EmailVerifyEnabled bool
PromoCodeEnabled bool
PasswordResetEnabled bool
TotpEnabled bool // TOTP 双因素认证
TurnstileEnabled bool
TurnstileSiteKey string
SiteName string
SiteLogo string
SiteSubtitle string
APIBaseURL string
ContactInfo string
DocURL string
HomeContent string
HideCcsImportButton bool
RegistrationEnabled bool
EmailVerifyEnabled bool
PromoCodeEnabled bool
PasswordResetEnabled bool
InvitationCodeEnabled bool
TotpEnabled bool // TOTP 双因素认证
TurnstileEnabled bool
TurnstileSiteKey string
SiteName string
SiteLogo string
SiteSubtitle string
APIBaseURL string
ContactInfo string
DocURL string
HomeContent string
HideCcsImportButton bool
PurchaseSubscriptionEnabled bool
PurchaseSubscriptionURL string

View File

@@ -14,6 +14,7 @@ export interface SystemSettings {
email_verify_enabled: boolean
promo_code_enabled: boolean
password_reset_enabled: boolean
invitation_code_enabled: boolean
totp_enabled: boolean // TOTP 双因素认证
totp_encryption_key_configured: boolean // TOTP 加密密钥是否已配置
// Default settings
@@ -72,6 +73,7 @@ export interface UpdateSettingsRequest {
email_verify_enabled?: boolean
promo_code_enabled?: boolean
password_reset_enabled?: boolean
invitation_code_enabled?: boolean
totp_enabled?: boolean // TOTP 双因素认证
default_balance?: number
default_concurrency?: number

View File

@@ -164,6 +164,24 @@ export async function validatePromoCode(code: string): Promise<ValidatePromoCode
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
*/
@@ -229,6 +247,7 @@ export const authAPI = {
getPublicSettings,
sendVerifyCode,
validatePromoCode,
validateInvitationCode,
forgotPassword,
resetPassword
}

View File

@@ -265,6 +265,13 @@ export default {
promoCodeAlreadyUsed: 'You have already used this promo code',
promoCodeValidating: 'Promo code is being validated, please wait',
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: {
signIn: 'Continue with Linux.do',
orContinue: 'or continue with email',
@@ -1899,6 +1906,8 @@ export default {
balance: 'Balance',
concurrency: 'Concurrency',
subscription: 'Subscription',
invitation: 'Invitation',
invitationHint: 'Invitation codes are used to restrict user registration. They are automatically marked as used after use.',
unused: 'Unused',
used: 'Used',
columns: {
@@ -1945,6 +1954,7 @@ export default {
balance: 'Balance',
concurrency: 'Concurrency',
subscription: 'Subscription',
invitation: 'Invitation',
// Admin adjustment types (created when admin modifies user balance/concurrency)
admin_balance: 'Balance (Admin)',
admin_concurrency: 'Concurrency (Admin)'
@@ -2896,6 +2906,8 @@ export default {
emailVerificationHint: 'Require email verification for new registrations',
promoCode: 'Promo Code',
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',
passwordResetHint: 'Allow users to reset their password via email',
totp: 'Two-Factor Authentication (2FA)',

View File

@@ -262,6 +262,13 @@ export default {
promoCodeAlreadyUsed: '您已使用过此优惠码',
promoCodeValidating: '优惠码正在验证中,请稍候',
promoCodeInvalidCannotRegister: '优惠码无效,请检查后重试或清空优惠码',
invitationCodeLabel: '邀请码',
invitationCodePlaceholder: '请输入邀请码',
invitationCodeRequired: '请输入邀请码',
invitationCodeValid: '邀请码有效',
invitationCodeInvalid: '邀请码无效或已被使用',
invitationCodeValidating: '正在验证邀请码...',
invitationCodeInvalidCannotRegister: '邀请码无效,请检查后重试',
linuxdo: {
signIn: '使用 Linux.do 登录',
orContinue: '或使用邮箱密码继续',
@@ -2022,6 +2029,7 @@ export default {
balance: '余额',
concurrency: '并发数',
subscription: '订阅',
invitation: '邀请码',
// 管理员在用户管理页面调整余额/并发时产生的记录
admin_balance: '余额(管理员)',
admin_concurrency: '并发数(管理员)'
@@ -2030,6 +2038,8 @@ export default {
balance: '余额',
concurrency: '并发数',
subscription: '订阅',
invitation: '邀请码',
invitationHint: '邀请码用于限制用户注册,使用后自动标记为已使用。',
allTypes: '全部类型',
allStatus: '全部状态',
unused: '未使用',
@@ -3049,6 +3059,8 @@ export default {
emailVerificationHint: '新用户注册时需要验证邮箱',
promoCode: '优惠码',
promoCodeHint: '允许用户在注册时使用优惠码',
invitationCode: '邀请码注册',
invitationCodeHint: '开启后,用户注册时需要填写有效的邀请码',
passwordReset: '忘记密码',
passwordResetHint: '允许用户通过邮箱重置密码',
totp: '双因素认证 (2FA)',

View File

@@ -314,6 +314,7 @@ export const useAppStore = defineStore('app', () => {
email_verify_enabled: false,
promo_code_enabled: true,
password_reset_enabled: false,
invitation_code_enabled: false,
turnstile_enabled: false,
turnstile_site_key: '',
site_name: siteName.value,

View File

@@ -55,6 +55,7 @@ export interface RegisterRequest {
verify_code?: string
turnstile_token?: string
promo_code?: string
invitation_code?: string
}
export interface SendVerifyCodeRequest {
@@ -72,6 +73,7 @@ export interface PublicSettings {
email_verify_enabled: boolean
promo_code_enabled: boolean
password_reset_enabled: boolean
invitation_code_enabled: boolean
turnstile_enabled: boolean
turnstile_site_key: string
site_name: string
@@ -701,7 +703,7 @@ export interface UpdateProxyRequest {
// ==================== Usage & Redeem Types ====================
export type RedeemCodeType = 'balance' | 'concurrency' | 'subscription'
export type RedeemCodeType = 'balance' | 'concurrency' | 'subscription' | 'invitation'
export interface UsageLog {
id: number

View File

@@ -213,7 +213,7 @@
<Select v-model="generateForm.type" :options="typeOptions" />
</div>
<!-- 余额/并发类型显示数值输入 -->
<div v-if="generateForm.type !== 'subscription'">
<div v-if="generateForm.type !== 'subscription' && generateForm.type !== 'invitation'">
<label class="input-label">
{{
generateForm.type === 'balance'
@@ -230,6 +230,12 @@
class="input"
/>
</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'">
<div>
@@ -387,7 +393,7 @@
</template>
<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 { useAppStore } from '@/stores/app'
import { useClipboard } from '@/composables/useClipboard'
@@ -499,14 +505,16 @@ const columns = computed<Column[]>(() => [
const typeOptions = computed(() => [
{ value: 'balance', label: t('admin.redeem.balance') },
{ 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(() => [
{ value: '', label: t('admin.redeem.allTypes') },
{ value: 'balance', label: t('admin.redeem.balance') },
{ 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(() => [
@@ -546,6 +554,18 @@ const generateForm = reactive({
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 () => {
if (abortController) {
abortController.abort()

View File

@@ -339,6 +339,21 @@
<Toggle v-model="form.promo_code_enabled" />
</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 -->
<div
v-if="form.email_verify_enabled"
@@ -1115,6 +1130,7 @@ const form = reactive<SettingsForm>({
registration_enabled: true,
email_verify_enabled: false,
promo_code_enabled: true,
invitation_code_enabled: false,
password_reset_enabled: false,
totp_enabled: false,
totp_encryption_key_configured: false,
@@ -1243,6 +1259,7 @@ async function saveSettings() {
registration_enabled: form.registration_enabled,
email_verify_enabled: form.email_verify_enabled,
promo_code_enabled: form.promo_code_enabled,
invitation_code_enabled: form.invitation_code_enabled,
password_reset_enabled: form.password_reset_enabled,
totp_enabled: form.totp_enabled,
default_balance: form.default_balance,

View File

@@ -201,6 +201,7 @@ const email = ref<string>('')
const password = ref<string>('')
const initialTurnstileToken = ref<string>('')
const promoCode = ref<string>('')
const invitationCode = ref<string>('')
const hasRegisterData = ref<boolean>(false)
// Public settings
@@ -230,6 +231,7 @@ onMounted(async () => {
password.value = registerData.password || ''
initialTurnstileToken.value = registerData.turnstile_token || ''
promoCode.value = registerData.promo_code || ''
invitationCode.value = registerData.invitation_code || ''
hasRegisterData.value = !!(email.value && password.value)
} catch {
hasRegisterData.value = false
@@ -384,7 +386,8 @@ async function handleVerify(): Promise<void> {
password: password.value,
verify_code: verifyCode.value.trim(),
turnstile_token: initialTurnstileToken.value || undefined,
promo_code: promoCode.value || undefined
promo_code: promoCode.value || undefined,
invitation_code: invitationCode.value || undefined
})
// Clear session data

View File

@@ -95,6 +95,59 @@
</p>
</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) -->
<div v-if="promoCodeEnabled">
<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 TurnstileWidget from '@/components/TurnstileWidget.vue'
import { useAuthStore, useAppStore } from '@/stores'
import { getPublicSettings, validatePromoCode } from '@/api/auth'
import { getPublicSettings, validatePromoCode, validateInvitationCode } from '@/api/auth'
const { t } = useI18n()
@@ -261,6 +314,7 @@ const showPassword = ref<boolean>(false)
const registrationEnabled = ref<boolean>(true)
const emailVerifyEnabled = ref<boolean>(false)
const promoCodeEnabled = ref<boolean>(true)
const invitationCodeEnabled = ref<boolean>(false)
const turnstileEnabled = ref<boolean>(false)
const turnstileSiteKey = ref<string>('')
const siteName = ref<string>('Sub2API')
@@ -280,16 +334,27 @@ const promoValidation = reactive({
})
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({
email: '',
password: '',
promo_code: ''
promo_code: '',
invitation_code: ''
})
const errors = reactive({
email: '',
password: '',
turnstile: ''
turnstile: '',
invitation_code: ''
})
// ==================== Lifecycle ====================
@@ -300,6 +365,7 @@ onMounted(async () => {
registrationEnabled.value = settings.registration_enabled
emailVerifyEnabled.value = settings.email_verify_enabled
promoCodeEnabled.value = settings.promo_code_enabled
invitationCodeEnabled.value = settings.invitation_code_enabled
turnstileEnabled.value = settings.turnstile_enabled
turnstileSiteKey.value = settings.turnstile_site_key || ''
siteName.value = settings.site_name || 'Sub2API'
@@ -325,6 +391,9 @@ onUnmounted(() => {
if (promoValidateTimeout) {
clearTimeout(promoValidateTimeout)
}
if (invitationValidateTimeout) {
clearTimeout(invitationValidateTimeout)
}
})
// ==================== 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 ====================
function onTurnstileVerify(token: string): void {
@@ -429,6 +562,7 @@ function validateForm(): boolean {
errors.email = ''
errors.password = ''
errors.turnstile = ''
errors.invitation_code = ''
let isValid = true
@@ -450,6 +584,14 @@ function validateForm(): boolean {
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
if (turnstileEnabled.value && !turnstileToken.value) {
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
try {
@@ -496,7 +662,8 @@ async function handleRegister(): Promise<void> {
email: formData.email,
password: formData.password,
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,
password: formData.password,
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