From a34efd7b41c4881d3438768766533d84033dc70a Mon Sep 17 00:00:00 2001 From: shenlan Date: Fri, 31 Oct 2025 19:17:03 +0800 Subject: [PATCH] Add verification code resend flow to registration --- account/api/api.go | 147 ++++- account/api/api_test.go | 191 +++++- .../app/api/auth/verify-email/resend/route.ts | 66 +++ dashboard/app/register/RegisterContent.tsx | 542 +++++++++++++----- dashboard/i18n/translations.ts | 48 +- 5 files changed, 806 insertions(+), 188 deletions(-) create mode 100644 dashboard/app/api/auth/verify-email/resend/route.ts diff --git a/account/api/api.go b/account/api/api.go index fcfffdb..f8fc7d1 100644 --- a/account/api/api.go +++ b/account/api/api.go @@ -10,6 +10,7 @@ import ( "fmt" "html" "log/slog" + "math/big" "net/http" "strings" "sync" @@ -27,7 +28,7 @@ import ( const defaultSessionTTL = 24 * time.Hour const defaultMFAChallengeTTL = 10 * time.Minute const defaultTOTPIssuer = "XControl Account" -const defaultEmailVerificationTTL = 24 * time.Hour +const defaultEmailVerificationTTL = 10 * time.Minute const defaultPasswordResetTTL = 30 * time.Minute const maxMFAVerificationAttempts = 5 const defaultMFALockoutDuration = 5 * time.Minute @@ -74,6 +75,7 @@ type mfaChallenge struct { type emailVerification struct { userID string email string + code string expiresAt time.Time } @@ -185,6 +187,7 @@ func RegisterRoutes(r *gin.Engine, opts ...Option) { auth.POST("/register", h.register) auth.POST("/register/verify", h.verifyEmail) + auth.POST("/register/resend", h.resendEmailVerification) auth.POST("/login", h.login) @@ -221,8 +224,13 @@ type loginRequest struct { TOTPCode string `json:"totpCode"` } -type tokenRequest struct { - Token string `json:"token"` +type verificationCodeRequest struct { + Email string `json:"email"` + Code string `json:"code"` +} + +type verificationResendRequest struct { + Email string `json:"email"` } type passwordResetRequestBody struct { @@ -339,26 +347,45 @@ func (h *handler) register(c *gin.Context) { } func (h *handler) verifyEmail(c *gin.Context) { - if hasQueryParameter(c, "token") { - respondError(c, http.StatusBadRequest, "token_in_query", "verification token must be sent in the request body") + if hasQueryParameter(c, "token", "code") { + respondError(c, http.StatusBadRequest, "token_in_query", "verification code must be sent in the request body") return } - var req tokenRequest + var req verificationCodeRequest if err := c.ShouldBindJSON(&req); err != nil { respondError(c, http.StatusBadRequest, "invalid_request", "invalid request payload") return } - token := strings.TrimSpace(req.Token) - if token == "" { - respondError(c, http.StatusBadRequest, "invalid_token", "verification token is required") + email := strings.ToLower(strings.TrimSpace(req.Email)) + code := strings.TrimSpace(req.Code) + + if email == "" || code == "" { + respondError(c, http.StatusBadRequest, "invalid_request", "email and verification code are required") return } - verification, ok := h.lookupEmailVerification(token) + if len(code) != 6 { + respondError(c, http.StatusBadRequest, "invalid_code", "verification code must be 6 digits") + return + } + + for _, r := range code { + if r < '0' || r > '9' { + respondError(c, http.StatusBadRequest, "invalid_code", "verification code must be 6 digits") + return + } + } + + verification, ok := h.lookupEmailVerification(email) if !ok { - respondError(c, http.StatusBadRequest, "invalid_token", "verification token is invalid or expired") + respondError(c, http.StatusBadRequest, "invalid_code", "verification code is invalid or expired") + return + } + + if verification.code != code { + respondError(c, http.StatusBadRequest, "invalid_code", "verification code is invalid or expired") return } @@ -370,8 +397,8 @@ func (h *handler) verifyEmail(c *gin.Context) { } if !strings.EqualFold(strings.TrimSpace(user.Email), verification.email) { - h.removeEmailVerification(token) - respondError(c, http.StatusBadRequest, "invalid_token", "verification token is invalid or expired") + h.removeEmailVerification(email) + respondError(c, http.StatusBadRequest, "invalid_code", "verification code is invalid or expired") return } @@ -384,7 +411,7 @@ func (h *handler) verifyEmail(c *gin.Context) { } } - h.removeEmailVerification(token) + h.removeEmailVerification(email) sessionToken, expiresAt, err := h.createSession(user.ID) if err != nil { @@ -402,6 +429,53 @@ func (h *handler) verifyEmail(c *gin.Context) { }) } +func (h *handler) resendEmailVerification(c *gin.Context) { + if hasQueryParameter(c, "email") { + respondError(c, http.StatusBadRequest, "email_in_query", "email must be sent in the request body") + return + } + + var req verificationResendRequest + if err := c.ShouldBindJSON(&req); err != nil { + respondError(c, http.StatusBadRequest, "invalid_request", "invalid request payload") + return + } + + email := strings.ToLower(strings.TrimSpace(req.Email)) + if email == "" { + respondError(c, http.StatusBadRequest, "invalid_email", "email must be a valid address") + return + } + + user, err := h.store.GetUserByEmail(c.Request.Context(), email) + if err != nil { + if errors.Is(err, store.ErrUserNotFound) { + respondError(c, http.StatusNotFound, "verification_failed", "verification email could not be sent") + return + } + respondError(c, http.StatusInternalServerError, "verification_failed", "verification email could not be sent") + return + } + + if strings.TrimSpace(user.Email) == "" { + respondError(c, http.StatusBadRequest, "invalid_email", "email must be a valid address") + return + } + + if user.EmailVerified { + respondError(c, http.StatusBadRequest, "already_verified", "email is already verified") + return + } + + if err := h.enqueueEmailVerification(c.Request.Context(), user); err != nil { + slog.Error("failed to resend verification email", "err", err, "email", user.Email) + respondError(c, http.StatusInternalServerError, "verification_failed", "verification email could not be sent") + return + } + + c.JSON(http.StatusOK, gin.H{"message": "verification email resent"}) +} + func (h *handler) requestPasswordReset(c *gin.Context) { if hasQueryParameter(c, "email") { respondError(c, http.StatusBadRequest, "email_in_query", "email must be sent in the request body") @@ -867,6 +941,15 @@ func (h *handler) newRandomToken() (string, error) { return hex.EncodeToString(buffer), nil } +func (h *handler) newVerificationCode() (string, error) { + max := big.NewInt(1000000) + n, err := rand.Int(rand.Reader, max) + if err != nil { + return "", err + } + return fmt.Sprintf("%06d", n.Int64()), nil +} + func (h *handler) effectiveMFAChallengeTTL() time.Duration { ttl := h.mfaChallengeTTL if ttl <= 0 { @@ -945,25 +1028,27 @@ func (h *handler) enqueueEmailVerification(ctx context.Context, user *store.User return errors.New("user email is empty") } - token, err := h.newRandomToken() - if err != nil { - return err - } - ttl := h.verificationTTL if ttl <= 0 { ttl = defaultEmailVerificationTTL } expiresAt := time.Now().Add(ttl) + code, err := h.newVerificationCode() + if err != nil { + return err + } + + normalizedEmail := strings.ToLower(email) verification := emailVerification{ userID: user.ID, - email: strings.ToLower(email), + email: normalizedEmail, + code: code, expiresAt: expiresAt, } h.verificationMu.Lock() - h.verifications[token] = verification + h.verifications[normalizedEmail] = verification h.verificationMu.Unlock() name := strings.TrimSpace(user.Name) @@ -972,8 +1057,8 @@ func (h *handler) enqueueEmailVerification(ctx context.Context, user *store.User } subject := "Verify your XControl account" - plainBody := fmt.Sprintf("Hello %s,\n\nUse the following token to verify your XControl account: %s\n\nThis token expires at %s UTC.\nIf you did not request this email you can ignore it.\n", name, token, expiresAt.UTC().Format(time.RFC3339)) - htmlBody := fmt.Sprintf("

Hello %s,

Use the following token to verify your XControl account:

%s

This token expires at %s UTC.

If you did not request this email you can ignore it.

", html.EscapeString(name), token, expiresAt.UTC().Format(time.RFC3339)) + plainBody := fmt.Sprintf("Hello %s,\n\nUse the following verification code to verify your XControl account: %s\n\nThis code expires at %s UTC (in %d minutes).\nIf you did not request this email you can ignore it.\n", name, code, expiresAt.UTC().Format(time.RFC3339), int(ttl.Minutes())) + htmlBody := fmt.Sprintf("

Hello %s,

Use the following verification code to verify your XControl account:

%s

This code expires at %s UTC (in %d minutes).

If you did not request this email you can ignore it.

", html.EscapeString(name), code, expiresAt.UTC().Format(time.RFC3339), int(ttl.Minutes())) msg := EmailMessage{ To: []string{email}, @@ -983,37 +1068,37 @@ func (h *handler) enqueueEmailVerification(ctx context.Context, user *store.User } if err := h.emailSender.Send(ctx, msg); err != nil { - h.removeEmailVerification(token) + h.removeEmailVerification(normalizedEmail) return err } return nil } -func (h *handler) lookupEmailVerification(token string) (emailVerification, bool) { - token = strings.TrimSpace(token) - if token == "" { +func (h *handler) lookupEmailVerification(email string) (emailVerification, bool) { + email = strings.ToLower(strings.TrimSpace(email)) + if email == "" { return emailVerification{}, false } h.verificationMu.RLock() - verification, ok := h.verifications[token] + verification, ok := h.verifications[email] h.verificationMu.RUnlock() if !ok { return emailVerification{}, false } if time.Now().After(verification.expiresAt) { - h.removeEmailVerification(token) + h.removeEmailVerification(email) return emailVerification{}, false } return verification, true } -func (h *handler) removeEmailVerification(token string) { +func (h *handler) removeEmailVerification(email string) { h.verificationMu.Lock() - delete(h.verifications, strings.TrimSpace(token)) + delete(h.verifications, strings.ToLower(strings.TrimSpace(email))) h.verificationMu.Unlock() } diff --git a/account/api/api_test.go b/account/api/api_test.go index ce46518..081e8b5 100644 --- a/account/api/api_test.go +++ b/account/api/api_test.go @@ -99,6 +99,19 @@ func extractTokenFromMessage(t *testing.T, msg capturedEmail) string { return "" } +func extractVerificationCodeFromMessage(t *testing.T, msg capturedEmail) string { + t.Helper() + re := regexp.MustCompile(`\b[0-9]{6}\b`) + if match := re.FindString(msg.PlainBody); match != "" { + return match + } + if match := re.FindString(msg.HTMLBody); match != "" { + return match + } + t.Fatalf("failed to extract verification code from email body: %q", msg.PlainBody) + return "" +} + func decodeResponse(t *testing.T, rr *httptest.ResponseRecorder) apiResponse { t.Helper() var resp apiResponse @@ -211,8 +224,11 @@ func TestRegisterEndpoint(t *testing.T) { t.Fatalf("expected verification subject, got %q", msg.Subject) } - token := extractTokenFromMessage(t, msg) - verifyPayload := map[string]string{"token": token} + code := extractVerificationCodeFromMessage(t, msg) + verifyPayload := map[string]string{ + "email": payload["email"], + "code": code, + } verifyBody, err := json.Marshal(verifyPayload) if err != nil { t.Fatalf("failed to marshal verification payload: %v", err) @@ -236,6 +252,159 @@ func TestRegisterEndpoint(t *testing.T) { } } +func TestResendVerificationEndpoint(t *testing.T) { + gin.SetMode(gin.TestMode) + + router := gin.New() + mailer := &testEmailSender{} + RegisterRoutes(router, WithEmailSender(mailer)) + + payload := map[string]string{ + "name": "Resend User", + "email": "resend@example.com", + "password": "supersecure", + } + + body, err := json.Marshal(payload) + if err != nil { + t.Fatalf("failed to marshal payload: %v", err) + } + + req := httptest.NewRequest(http.MethodPost, "/api/auth/register", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + rr := httptest.NewRecorder() + router.ServeHTTP(rr, req) + if rr.Code != http.StatusCreated { + t.Fatalf("expected registration success, got %d: %s", rr.Code, rr.Body.String()) + } + + initialMsg, ok := mailer.last() + if !ok { + t.Fatalf("expected initial verification email") + } + initialCode := extractVerificationCodeFromMessage(t, initialMsg) + + resendPayload := map[string]string{"email": payload["email"]} + resendBody, err := json.Marshal(resendPayload) + if err != nil { + t.Fatalf("failed to marshal resend payload: %v", err) + } + + req = httptest.NewRequest(http.MethodPost, "/api/auth/register/resend", bytes.NewReader(resendBody)) + req.Header.Set("Content-Type", "application/json") + rr = httptest.NewRecorder() + router.ServeHTTP(rr, req) + if rr.Code != http.StatusOK { + t.Fatalf("expected resend success, got %d: %s", rr.Code, rr.Body.String()) + } + + resentMsg, ok := mailer.last() + if !ok { + t.Fatalf("expected verification email after resend") + } + resentCode := extractVerificationCodeFromMessage(t, resentMsg) + if strings.TrimSpace(resentCode) == "" { + t.Fatalf("expected verification code in resent email") + } + if strings.TrimSpace(initialCode) == strings.TrimSpace(resentCode) { + t.Logf("verification code repeated across resend; continuing to verify") + } + + verifyPayload := map[string]string{ + "email": payload["email"], + "code": resentCode, + } + verifyBody, err := json.Marshal(verifyPayload) + if err != nil { + t.Fatalf("failed to marshal verify payload: %v", err) + } + + req = httptest.NewRequest(http.MethodPost, "/api/auth/register/verify", bytes.NewReader(verifyBody)) + req.Header.Set("Content-Type", "application/json") + rr = httptest.NewRecorder() + router.ServeHTTP(rr, req) + if rr.Code != http.StatusOK { + t.Fatalf("expected verification success after resend, got %d: %s", rr.Code, rr.Body.String()) + } +} + +func TestResendVerificationEndpointErrors(t *testing.T) { + gin.SetMode(gin.TestMode) + + router := gin.New() + mailer := &testEmailSender{} + RegisterRoutes(router, WithEmailSender(mailer)) + + payload := map[string]string{ + "name": "Verified User", + "email": "verified@example.com", + "password": "supersecure", + } + + body, err := json.Marshal(payload) + if err != nil { + t.Fatalf("failed to marshal payload: %v", err) + } + + req := httptest.NewRequest(http.MethodPost, "/api/auth/register", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + rr := httptest.NewRecorder() + router.ServeHTTP(rr, req) + if rr.Code != http.StatusCreated { + t.Fatalf("expected registration success, got %d: %s", rr.Code, rr.Body.String()) + } + + msg, ok := mailer.last() + if !ok { + t.Fatalf("expected verification email after registration") + } + code := extractVerificationCodeFromMessage(t, msg) + verifyPayload := map[string]string{ + "email": payload["email"], + "code": code, + } + verifyBody, err := json.Marshal(verifyPayload) + if err != nil { + t.Fatalf("failed to marshal verify payload: %v", err) + } + + req = httptest.NewRequest(http.MethodPost, "/api/auth/register/verify", bytes.NewReader(verifyBody)) + req.Header.Set("Content-Type", "application/json") + rr = httptest.NewRecorder() + router.ServeHTTP(rr, req) + if rr.Code != http.StatusOK { + t.Fatalf("expected verification success, got %d: %s", rr.Code, rr.Body.String()) + } + + resendPayload := map[string]string{"email": payload["email"]} + resendBody, err := json.Marshal(resendPayload) + if err != nil { + t.Fatalf("failed to marshal resend payload: %v", err) + } + + req = httptest.NewRequest(http.MethodPost, "/api/auth/register/resend", bytes.NewReader(resendBody)) + req.Header.Set("Content-Type", "application/json") + rr = httptest.NewRecorder() + router.ServeHTTP(rr, req) + if rr.Code != http.StatusBadRequest { + t.Fatalf("expected resend to fail for verified email, got %d: %s", rr.Code, rr.Body.String()) + } + + unknownPayload := map[string]string{"email": "missing@example.com"} + unknownBody, err := json.Marshal(unknownPayload) + if err != nil { + t.Fatalf("failed to marshal unknown payload: %v", err) + } + + req = httptest.NewRequest(http.MethodPost, "/api/auth/register/resend", bytes.NewReader(unknownBody)) + req.Header.Set("Content-Type", "application/json") + rr = httptest.NewRecorder() + router.ServeHTTP(rr, req) + if rr.Code != http.StatusNotFound { + t.Fatalf("expected resend to fail for unknown email, got %d: %s", rr.Code, rr.Body.String()) + } +} + func TestRegisterEndpointWithoutEmailVerification(t *testing.T) { gin.SetMode(gin.TestMode) @@ -384,8 +553,11 @@ func TestMFATOTPFlow(t *testing.T) { if !ok { t.Fatalf("expected verification email during registration") } - token := extractTokenFromMessage(t, msg) - verifyPayload := map[string]string{"token": token} + code := extractVerificationCodeFromMessage(t, msg) + verifyPayload := map[string]string{ + "email": registerPayload["email"], + "code": code, + } verifyBody, err := json.Marshal(verifyPayload) if err != nil { t.Fatalf("failed to marshal verify payload: %v", err) @@ -482,11 +654,11 @@ func TestMFATOTPFlow(t *testing.T) { } waitForStableTOTPWindow(t) - code := generateCode(-30 * time.Second) + mfaCode := generateCode(-30 * time.Second) totpVerifyPayload := map[string]string{ "token": resp.MFAToken, - "code": code, + "code": mfaCode, } totpVerifyBody, err := json.Marshal(totpVerifyPayload) if err != nil { @@ -826,8 +998,11 @@ func TestPasswordResetFlow(t *testing.T) { if !ok { t.Fatalf("expected verification email during registration") } - verifyToken := extractTokenFromMessage(t, msg) - verifyPayload := map[string]string{"token": verifyToken} + verificationCode := extractVerificationCodeFromMessage(t, msg) + verifyPayload := map[string]string{ + "email": registerPayload["email"], + "code": verificationCode, + } verifyBody, err := json.Marshal(verifyPayload) if err != nil { t.Fatalf("failed to marshal verification payload: %v", err) diff --git a/dashboard/app/api/auth/verify-email/resend/route.ts b/dashboard/app/api/auth/verify-email/resend/route.ts new file mode 100644 index 0000000..89ab3bd --- /dev/null +++ b/dashboard/app/api/auth/verify-email/resend/route.ts @@ -0,0 +1,66 @@ +import { NextRequest, NextResponse } from 'next/server' + +import { getAccountServiceBaseUrl } from '@lib/serviceConfig' + +const ACCOUNT_SERVICE_URL = getAccountServiceBaseUrl() +const ACCOUNT_API_BASE = `${ACCOUNT_SERVICE_URL}/api/auth` + +type ResendPayload = { + email?: string +} + +function normalizeEmail(value: unknown) { + return typeof value === 'string' ? value.trim().toLowerCase() : '' +} + +export async function POST(request: NextRequest) { + let payload: ResendPayload + try { + payload = (await request.json()) as ResendPayload + } catch (error) { + console.error('Failed to decode verification resend payload', error) + return NextResponse.json({ success: false, error: 'invalid_request', needMfa: false }, { status: 400 }) + } + + const email = normalizeEmail(payload?.email) + if (!email) { + return NextResponse.json({ success: false, error: 'invalid_email', needMfa: false }, { status: 400 }) + } + + try { + const response = await fetch(`${ACCOUNT_API_BASE}/register/resend`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ email }), + cache: 'no-store', + }) + + const data = await response.json().catch(() => ({})) + if (!response.ok) { + const errorCode = typeof (data as { error?: string })?.error === 'string' ? data.error : 'verification_failed' + return NextResponse.json({ success: false, error: errorCode, needMfa: false }, { status: response.status || 400 }) + } + + return NextResponse.json({ success: true, error: null, needMfa: false }) + } catch (error) { + console.error('Account service verification resend proxy failed', error) + return NextResponse.json( + { success: false, error: 'account_service_unreachable', needMfa: false }, + { status: 502 }, + ) + } +} + +export function GET() { + return NextResponse.json( + { success: false, error: 'method_not_allowed', needMfa: false }, + { + status: 405, + headers: { + Allow: 'POST', + }, + }, + ) +} diff --git a/dashboard/app/register/RegisterContent.tsx b/dashboard/app/register/RegisterContent.tsx index 94b51df..9b94893 100644 --- a/dashboard/app/register/RegisterContent.tsx +++ b/dashboard/app/register/RegisterContent.tsx @@ -1,8 +1,17 @@ 'use client' import Link from 'next/link' -import { ChevronDown, Github } from 'lucide-react' -import { FormEvent, useCallback, useEffect, useId, useMemo, useRef, useState } from 'react' +import { Github } from 'lucide-react' +import { + ClipboardEvent, + FormEvent, + KeyboardEvent, + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from 'react' import { useRouter, useSearchParams } from 'next/navigation' import { AskAIButton } from '@components/AskAIButton' @@ -137,6 +146,9 @@ function deriveSameOriginFallback(url: string): string | undefined { return undefined } +const VERIFICATION_CODE_LENGTH = 6 +const RESEND_COOLDOWN_SECONDS = 60 + export default function RegisterContent() { const { language } = useLanguage() const t = translations[language].auth.register @@ -234,44 +246,258 @@ export default function RegisterContent() { const [alert, setAlert] = useState(initialAlert) const [isSubmitting, setIsSubmitting] = useState(false) - const [showOptionalFields, setShowOptionalFields] = useState(false) - const optionalSectionId = useId() + const [codeDigits, setCodeDigits] = useState(() => Array(VERIFICATION_CODE_LENGTH).fill('')) + const [hasRequestedCode, setHasRequestedCode] = useState(false) + const [pendingEmail, setPendingEmail] = useState('') + const [isResending, setIsResending] = useState(false) + const [resendCooldown, setResendCooldown] = useState(0) + const formRef = useRef(null) + const codeInputRefs = useRef<(HTMLInputElement | null)[]>([]) useEffect(() => { setAlert(initialAlert) }, [initialAlert]) + useEffect(() => { + if (resendCooldown <= 0) { + return + } + + const timer = window.setInterval(() => { + setResendCooldown((current) => (current > 0 ? current - 1 : 0)) + }, 1000) + + return () => window.clearInterval(timer) + }, [resendCooldown]) + + const focusCodeInput = useCallback((index: number) => { + const input = codeInputRefs.current[index] + if (input) { + input.focus() + input.select() + } + }, []) + + const resetCodeDigits = useCallback(() => { + setCodeDigits(Array(VERIFICATION_CODE_LENGTH).fill('')) + }, []) + + const handleCodeChange = useCallback( + (index: number, value: string) => { + const sanitized = value.replace(/\D/g, '') + setCodeDigits((previous) => { + const next = [...previous] + next[index] = sanitized ? sanitized[sanitized.length - 1] ?? '' : '' + return next + }) + + if (sanitized && index < VERIFICATION_CODE_LENGTH - 1) { + focusCodeInput(index + 1) + } + }, + [focusCodeInput], + ) + + const handleCodeKeyDown = useCallback( + (index: number, event: KeyboardEvent) => { + if (event.key === 'Backspace' && !codeDigits[index] && index > 0) { + event.preventDefault() + setCodeDigits((previous) => { + const next = [...previous] + next[index - 1] = '' + return next + }) + focusCodeInput(index - 1) + return + } + + if (event.key === 'ArrowLeft' && index > 0) { + event.preventDefault() + focusCodeInput(index - 1) + return + } + + if (event.key === 'ArrowRight' && index < VERIFICATION_CODE_LENGTH - 1) { + event.preventDefault() + focusCodeInput(index + 1) + } + }, + [codeDigits, focusCodeInput], + ) + + const handleCodePaste = useCallback( + (index: number, event: ClipboardEvent) => { + event.preventDefault() + const clipboardValue = event.clipboardData.getData('text').replace(/\D/g, '') + if (!clipboardValue) { + return + } + + const digits = clipboardValue.slice(0, VERIFICATION_CODE_LENGTH - index).split('') + setCodeDigits((previous) => { + const next = [...previous] + digits.forEach((digit, offset) => { + const targetIndex = index + offset + if (targetIndex < VERIFICATION_CODE_LENGTH) { + next[targetIndex] = digit + } + }) + return next + }) + + const lastFilledIndex = Math.min(index + digits.length - 1, VERIFICATION_CODE_LENGTH - 1) + focusCodeInput(lastFilledIndex) + }, + [focusCodeInput], + ) + const handleSubmit = useCallback( async (event: FormEvent) => { event.preventDefault() + if (isSubmitting) { return } + formRef.current = event.currentTarget + const formData = new FormData(event.currentTarget) const name = String(formData.get('name') ?? '').trim() const email = String(formData.get('email') ?? '').trim() const password = String(formData.get('password') ?? '') const confirmPassword = String(formData.get('confirmPassword') ?? '') const agreementAccepted = formData.get('agreement') === 'on' + const verificationCode = codeDigits.join('') - if (!email || !password || !confirmPassword) { - setAlert({ type: 'error', message: alerts.missingFields }) + if (!hasRequestedCode) { + if (!email || !password || !confirmPassword) { + setAlert({ type: 'error', message: alerts.missingFields }) + return + } + + if (!agreementAccepted) { + setAlert({ type: 'error', message: alerts.agreementRequired ?? alerts.missingFields }) + return + } + + if (password !== confirmPassword) { + setAlert({ type: 'error', message: alerts.passwordMismatch }) + return + } + + if (password.length < 8) { + setAlert({ type: 'error', message: alerts.weakPassword }) + return + } + + setIsSubmitting(true) + setAlert(null) + + try { + const fallbackNameFromEmail = email.includes('@') ? email.split('@')[0] : email + const normalizedName = (name || fallbackNameFromEmail || 'svc_user').trim() || 'svc_user' + + const requestPayload = { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + name: normalizedName, + email, + password, + }), + } as const + + let response: Response + let usedUrl = registerUrlRef.current + + try { + response = await fetch(usedUrl, requestPayload) + } catch (primaryError) { + const sameOriginFallback = deriveSameOriginFallback(usedUrl) + if (sameOriginFallback && sameOriginFallback !== usedUrl) { + try { + response = await fetch(sameOriginFallback, requestPayload) + registerUrlRef.current = sameOriginFallback + usedUrl = sameOriginFallback + } catch (fallbackError) { + console.error('Primary register request failed, same-origin fallback also failed', fallbackError) + throw fallbackError + } + } else { + const httpsPattern = /^https:/i + if (httpsPattern.test(usedUrl)) { + const insecureUrl = usedUrl.replace(httpsPattern, 'http:') + + try { + response = await fetch(insecureUrl, requestPayload) + registerUrlRef.current = insecureUrl + usedUrl = insecureUrl + } catch (fallbackError) { + console.error('Primary register request failed, insecure fallback also failed', fallbackError) + throw fallbackError + } + } else { + throw primaryError + } + } + } + + if (!response.ok) { + let errorCode = 'generic_error' + try { + const data = await response.json() + if (typeof data?.error === 'string') { + errorCode = data.error + } + } catch (error) { + console.error('Failed to parse register response', error) + } + + const errorMap: Record = { + invalid_request: alerts.genericError, + missing_credentials: alerts.missingFields, + invalid_email: alerts.invalidEmail, + password_too_short: alerts.weakPassword, + email_already_exists: alerts.userExists, + name_already_exists: alerts.usernameExists ?? alerts.userExists, + invalid_name: alerts.invalidName ?? alerts.genericError, + name_required: alerts.invalidName ?? alerts.genericError, + hash_failure: alerts.genericError, + user_creation_failed: alerts.genericError, + credentials_in_query: alerts.genericError, + verification_email_failed: alerts.genericError, + } + + setAlert({ type: 'error', message: errorMap[normalize(errorCode)] ?? alerts.genericError }) + setIsSubmitting(false) + return + } + + setPendingEmail(email) + setHasRequestedCode(true) + resetCodeDigits() + focusCodeInput(0) + setResendCooldown(RESEND_COOLDOWN_SECONDS) + setAlert({ type: 'success', message: alerts.verificationSent ?? alerts.success }) + setIsSubmitting(false) + } catch (error) { + console.error('Failed to register user', error) + setAlert({ type: 'error', message: alerts.genericError }) + setIsSubmitting(false) + } return } - if (!agreementAccepted) { - setAlert({ type: 'error', message: alerts.agreementRequired ?? alerts.missingFields }) + const emailForVerification = pendingEmail || email + if (!emailForVerification) { + setAlert({ type: 'error', message: alerts.invalidEmail }) return } - if (password !== confirmPassword) { - setAlert({ type: 'error', message: alerts.passwordMismatch }) - return - } - - if (password.length < 8) { - setAlert({ type: 'error', message: alerts.weakPassword }) + if (verificationCode.length !== VERIFICATION_CODE_LENGTH) { + setAlert({ type: 'error', message: alerts.codeRequired ?? alerts.missingFields }) return } @@ -279,55 +505,16 @@ export default function RegisterContent() { setAlert(null) try { - const fallbackNameFromEmail = email.includes('@') ? email.split('@')[0] : email - const normalizedName = (name || fallbackNameFromEmail || 'svc_user').trim() || 'svc_user' - - const requestPayload = { + const response = await fetch('/api/auth/verify-email', { method: 'POST', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify({ - name: normalizedName, - email, - password, + email: emailForVerification, + code: verificationCode, }), - } as const - - let response: Response - let usedUrl = registerUrlRef.current - - try { - response = await fetch(usedUrl, requestPayload) - } catch (primaryError) { - const sameOriginFallback = deriveSameOriginFallback(usedUrl) - if (sameOriginFallback && sameOriginFallback !== usedUrl) { - try { - response = await fetch(sameOriginFallback, requestPayload) - registerUrlRef.current = sameOriginFallback - usedUrl = sameOriginFallback - } catch (fallbackError) { - console.error('Primary register request failed, same-origin fallback also failed', fallbackError) - throw fallbackError - } - } else { - const httpsPattern = /^https:/i - if (httpsPattern.test(usedUrl)) { - const insecureUrl = usedUrl.replace(httpsPattern, 'http:') - - try { - response = await fetch(insecureUrl, requestPayload) - registerUrlRef.current = insecureUrl - usedUrl = insecureUrl - } catch (fallbackError) { - console.error('Primary register request failed, insecure fallback also failed', fallbackError) - throw fallbackError - } - } else { - throw primaryError - } - } - } + }) if (!response.ok) { let errorCode = 'generic_error' @@ -337,21 +524,15 @@ export default function RegisterContent() { errorCode = data.error } } catch (error) { - console.error('Failed to parse register response', error) + console.error('Failed to parse verification response', error) } const errorMap: Record = { invalid_request: alerts.genericError, - missing_credentials: alerts.missingFields, - invalid_email: alerts.invalidEmail, - password_too_short: alerts.weakPassword, - email_already_exists: alerts.userExists, - name_already_exists: alerts.usernameExists ?? alerts.userExists, - invalid_name: alerts.invalidName ?? alerts.genericError, - name_required: alerts.invalidName ?? alerts.genericError, - hash_failure: alerts.genericError, - user_creation_failed: alerts.genericError, - credentials_in_query: alerts.genericError, + missing_verification: alerts.codeRequired ?? alerts.missingFields, + invalid_code: alerts.invalidCode ?? alerts.genericError, + verification_failed: alerts.verificationFailed ?? alerts.genericError, + account_service_unreachable: alerts.genericError, } setAlert({ type: 'error', message: errorMap[normalize(errorCode)] ?? alerts.genericError }) @@ -363,20 +544,107 @@ export default function RegisterContent() { setIsSubmitting(false) router.push('/login?registered=1&setupMfa=1') } catch (error) { - console.error('Failed to register user', error) + console.error('Failed to verify email', error) setAlert({ type: 'error', message: alerts.genericError }) setIsSubmitting(false) } }, - [alerts, isSubmitting, normalize, router], + [ + alerts, + codeDigits, + focusCodeInput, + hasRequestedCode, + isSubmitting, + normalize, + pendingEmail, + resetCodeDigits, + router, + ], ) + const handleResend = useCallback(async () => { + if (isResending || resendCooldown > 0) { + return + } + + const emailFromForm = + pendingEmail || + (formRef.current ? String(new FormData(formRef.current).get('email') ?? '').trim() : '') + + if (!emailFromForm) { + setAlert({ type: 'error', message: alerts.invalidEmail }) + return + } + + setIsResending(true) + + try { + const response = await fetch('/api/auth/verify-email/resend', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ email: emailFromForm }), + }) + + if (!response.ok) { + let errorCode = 'generic_error' + try { + const data = await response.json() + if (typeof data?.error === 'string') { + errorCode = data.error + } + } catch (error) { + console.error('Failed to parse resend response', error) + } + + const errorMap: Record = { + invalid_request: alerts.genericError, + invalid_email: alerts.invalidEmail, + verification_failed: alerts.verificationFailed ?? alerts.genericError, + already_verified: alerts.verificationFailed ?? alerts.genericError, + account_service_unreachable: alerts.genericError, + } + + setAlert({ type: 'error', message: errorMap[normalize(errorCode)] ?? alerts.genericError }) + setIsResending(false) + return + } + + setPendingEmail(emailFromForm) + setHasRequestedCode(true) + resetCodeDigits() + focusCodeInput(0) + setResendCooldown(RESEND_COOLDOWN_SECONDS) + setAlert({ type: 'success', message: alerts.verificationSent ?? alerts.success }) + setIsResending(false) + } catch (error) { + console.error('Failed to resend verification code', error) + setAlert({ type: 'error', message: alerts.genericError }) + setIsResending(false) + } + }, [alerts, focusCodeInput, isResending, normalize, pendingEmail, resetCodeDigits, resendCooldown]) + const aboveForm = t.uuidNote ? (
{t.uuidNote}
) : null + const isVerificationStep = hasRequestedCode + const submitLabel = isVerificationStep + ? isSubmitting + ? t.form.verifying ?? t.form.verifySubmit ?? t.form.submit + : t.form.verifySubmit ?? t.form.submit + : isSubmitting + ? t.form.submitting ?? t.form.submit + : t.form.submit + const resendLabel = isResending + ? t.form.verificationCodeResending ?? t.form.verificationCodeResend + : resendCooldown > 0 + ? `${t.form.verificationCodeResend} (${resendCooldown}s)` + : t.form.verificationCodeResend + return ( <> -
+
-
- -
- {t.form.moreOptionsDescription ? ( -

{t.form.moreOptionsDescription}

- ) : null} -
- +
+
+ + +
+ {t.form.verificationCodeDescription ? ( +

{t.form.verificationCodeDescription}

+ ) : null} +
+ {codeDigits.map((digit, index) => ( { + codeInputRefs.current[index] = element + }} type="text" - autoComplete="name" - placeholder={t.form.fullNamePlaceholder} - className="w-full rounded-2xl border border-slate-200 bg-white px-4 py-2.5 text-slate-900 shadow-sm transition focus:border-sky-500 focus:outline-none focus:ring-2 focus:ring-sky-200" + inputMode="numeric" + autoComplete={index === 0 ? 'one-time-code' : undefined} + pattern="[0-9]*" + maxLength={1} + value={digit} + onChange={(event) => handleCodeChange(index, event.target.value)} + onKeyDown={(event) => handleCodeKeyDown(index, event)} + onPaste={(event) => handleCodePaste(index, event)} + disabled={!isVerificationStep} + className="h-12 w-12 rounded-2xl border border-slate-200 bg-white/90 text-center text-lg font-semibold text-slate-900 shadow-sm transition focus:border-sky-500 focus:outline-none focus:ring-2 focus:ring-sky-200 disabled:cursor-not-allowed disabled:bg-slate-100 disabled:text-slate-400" + aria-label={`${t.form.verificationCodeLabel} ${index + 1}`} /> - {t.form.fullNameHint ? ( -

{t.form.fullNameHint}

- ) : null} -
+ ))}
@@ -454,34 +731,37 @@ export default function RegisterContent() { id="password" name="password" type="password" - autoComplete="new-password" - placeholder={t.form.passwordPlaceholder} - className="w-full rounded-2xl border border-slate-200 bg-white/90 px-4 py-2.5 text-slate-900 shadow-sm transition focus:border-sky-500 focus:outline-none focus:ring-2 focus:ring-sky-200" - required - /> -
-
-
+
+ -
-
-
+ +
diff --git a/dashboard/i18n/translations.ts b/dashboard/i18n/translations.ts index 9af23e7..b2888c6 100644 --- a/dashboard/i18n/translations.ts +++ b/dashboard/i18n/translations.ts @@ -176,6 +176,10 @@ type AuthRegisterAlerts = { invalidEmail: string weakPassword: string genericError: string + verificationSent?: string + verificationFailed?: string + invalidCode?: string + codeRequired?: string } type AuthLoginAlerts = { @@ -204,9 +208,6 @@ type AuthRegisterTranslation = { form: { title: string subtitle: string - fullName: string - fullNamePlaceholder: string - fullNameHint?: string email: string emailPlaceholder: string password: string @@ -217,9 +218,12 @@ type AuthRegisterTranslation = { terms: string submit: string submitting?: string - moreOptionsToggleShow: string - moreOptionsToggleHide: string - moreOptionsDescription?: string + verifySubmit?: string + verifying?: string + verificationCodeLabel: string + verificationCodeDescription?: string + verificationCodeResend: string + verificationCodeResending?: string } social: { title: string @@ -662,9 +666,6 @@ export const translations: Record<'en' | 'zh', Translation> = { form: { title: 'Create your account', subtitle: 'Share a few details or continue with a social login.', - fullName: 'Display name (optional)', - fullNamePlaceholder: 'Ada Lovelace', - fullNameHint: 'Shown in notifications and workspace invites. You can change this later.', email: 'Work email', emailPlaceholder: 'name@example.com', password: 'Password', @@ -675,9 +676,12 @@ export const translations: Record<'en' | 'zh', Translation> = { terms: 'terms & privacy policy', submit: 'Create account', submitting: 'Creating account…', - moreOptionsToggleShow: 'More options', - moreOptionsToggleHide: 'Hide options', - moreOptionsDescription: 'Add optional profile details to personalize your workspace.', + verifySubmit: 'Verify & complete', + verifying: 'Verifying…', + verificationCodeLabel: 'Email verification code', + verificationCodeDescription: 'Enter the 6-digit code sent to your email. It expires in 10 minutes.', + verificationCodeResend: 'Resend', + verificationCodeResending: 'Resending…', }, social: { title: 'Or continue with', @@ -699,6 +703,10 @@ export const translations: Record<'en' | 'zh', Translation> = { invalidEmail: 'Enter a valid email address.', weakPassword: 'Your password must be at least 8 characters long.', genericError: 'We could not complete your registration. Please try again.', + verificationSent: 'Verification code sent. Check your email.', + verificationFailed: 'Verification failed. Request a new code and try again.', + invalidCode: 'Enter the 6-digit verification code sent to your email.', + codeRequired: 'Enter the 6-digit verification code to continue.', }, }, login: { @@ -1267,9 +1275,6 @@ export const translations: Record<'en' | 'zh', Translation> = { form: { title: '创建账号', subtitle: '填写基础信息,或选择社交账号直接注册。', - fullName: '展示名称(可选)', - fullNamePlaceholder: '王小云', - fullNameHint: '用于通知、团队邀请等场景,后续可以随时修改。', email: '邮箱', emailPlaceholder: 'name@example.com', password: '密码', @@ -1280,9 +1285,12 @@ export const translations: Record<'en' | 'zh', Translation> = { terms: '服务条款与隐私政策', submit: '立即注册', submitting: '注册中…', - moreOptionsToggleShow: '更多选项', - moreOptionsToggleHide: '收起选项', - moreOptionsDescription: '补充可选信息,帮助我们为你个性化工作区体验。', + verifySubmit: '验证并完成', + verifying: '验证中…', + verificationCodeLabel: '动态验证码', + verificationCodeDescription: '请输入发送到注册邮箱的 6 位数字验证码,10 分钟内有效。', + verificationCodeResend: '重发', + verificationCodeResending: '重发中…', }, social: { title: '或选择以下方式', @@ -1304,6 +1312,10 @@ export const translations: Record<'en' | 'zh', Translation> = { invalidEmail: '请输入有效的邮箱地址。', weakPassword: '密码长度至少需要 8 个字符。', genericError: '注册失败,请稍后重试。', + verificationSent: '验证码已发送,请查收邮箱。', + verificationFailed: '验证码验证失败,请重新获取验证码再试。', + invalidCode: '请输入邮箱收到的 6 位数字验证码。', + codeRequired: '请输入验证码后再继续。', }, }, login: {