Merge branch 'codex/add-email-verification-with-6-digit-code'

This commit is contained in:
Haitao Pan 2025-10-31 19:22:05 +08:00
commit 6f0e879eee
5 changed files with 806 additions and 188 deletions

View File

@ -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("<p>Hello %s,</p><p>Use the following token to verify your XControl account:</p><p><strong>%s</strong></p><p>This token expires at %s UTC.</p><p>If you did not request this email you can ignore it.</p>", 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("<p>Hello %s,</p><p>Use the following verification code to verify your XControl account:</p><p><strong>%s</strong></p><p>This code expires at %s UTC (in %d minutes).</p><p>If you did not request this email you can ignore it.</p>", 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()
}

View File

@ -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)

View File

@ -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 { AuthLayout, AuthLayoutSocialButton } from '@components/auth/AuthLayout'
@ -136,6 +145,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
@ -233,44 +245,258 @@ export default function RegisterContent() {
const [alert, setAlert] = useState<AlertState | null>(initialAlert)
const [isSubmitting, setIsSubmitting] = useState(false)
const [showOptionalFields, setShowOptionalFields] = useState(false)
const optionalSectionId = useId()
const [codeDigits, setCodeDigits] = useState<string[]>(() => 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<HTMLFormElement | null>(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<HTMLInputElement>) => {
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<HTMLInputElement>) => {
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<HTMLFormElement>) => {
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<string, string> = {
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
}
@ -278,55 +504,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'
@ -336,21 +523,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<string, string> = {
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 })
@ -362,20 +543,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<string, string> = {
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 ? (
<div className="rounded-2xl border border-dashed border-sky-200 bg-sky-50/80 px-4 py-3 text-sm text-sky-700">
{t.uuidNote}
</div>
) : 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 (
<>
<AuthLayout
@ -390,7 +658,13 @@ export default function RegisterContent() {
switchAction={{ text: t.loginPrompt.text, linkLabel: t.loginPrompt.link, href: '/login' }}
bottomNote={t.bottomNote}
>
<form className="space-y-5" method="post" onSubmit={handleSubmit} noValidate>
<form
ref={formRef}
className="space-y-5"
method="post"
onSubmit={handleSubmit}
noValidate
>
<div className="space-y-2">
<label htmlFor="email" className="text-sm font-medium text-slate-600">
{t.form.email}
@ -401,47 +675,50 @@ export default function RegisterContent() {
type="email"
autoComplete="email"
placeholder={t.form.emailPlaceholder}
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"
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 disabled:cursor-not-allowed disabled:bg-slate-100 disabled:text-slate-400"
required
disabled={isVerificationStep}
/>
</div>
<div className="space-y-3 rounded-2xl border border-slate-200/80 bg-slate-50/60 px-4 py-4">
<button
type="button"
onClick={() => setShowOptionalFields(current => !current)}
className="flex w-full items-center justify-between rounded-xl bg-white/90 px-4 py-2.5 text-sm font-medium text-slate-600 shadow-sm ring-1 ring-slate-200 transition hover:text-slate-700 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-sky-500"
aria-expanded={showOptionalFields}
aria-controls={optionalSectionId}
>
<span>{showOptionalFields ? t.form.moreOptionsToggleHide : t.form.moreOptionsToggleShow}</span>
<ChevronDown
aria-hidden
className={`h-4 w-4 transition-transform ${showOptionalFields ? 'rotate-180' : ''}`}
/>
</button>
<div
id={optionalSectionId}
className={`space-y-3 border-t border-slate-100 pt-3 ${showOptionalFields ? 'mt-3' : 'hidden'}`}
>
{t.form.moreOptionsDescription ? (
<p className="text-xs text-slate-500">{t.form.moreOptionsDescription}</p>
) : null}
<div className="space-y-2">
<label htmlFor="full-name" className="text-sm font-medium text-slate-600">
{t.form.fullName}
</label>
<div className="space-y-2">
<div className="flex items-center justify-between">
<label className="text-sm font-medium text-slate-600" htmlFor="verification-code-0">
{t.form.verificationCodeLabel}
</label>
<button
type="button"
onClick={handleResend}
disabled={isResending || resendCooldown > 0}
className="rounded-xl border border-slate-200 bg-white/90 px-3 py-1.5 text-xs font-medium text-slate-600 shadow-sm transition hover:text-slate-700 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-sky-500 disabled:cursor-not-allowed disabled:opacity-60"
>
{resendLabel}
</button>
</div>
{t.form.verificationCodeDescription ? (
<p className="text-xs text-slate-500">{t.form.verificationCodeDescription}</p>
) : null}
<div className="flex gap-2">
{codeDigits.map((digit, index) => (
<input
id="full-name"
name="name"
key={index}
id={`verification-code-${index}`}
ref={(element) => {
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 ? (
<p className="text-xs text-slate-400">{t.form.fullNameHint}</p>
) : null}
</div>
))}
</div>
</div>
<div className="grid gap-5 sm:grid-cols-2">
@ -453,34 +730,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
/>
</div>
<div className="space-y-2">
<label htmlFor="confirm-password" className="text-sm font-medium text-slate-600">
{t.form.confirmPassword}
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 disabled:cursor-not-allowed disabled:bg-slate-100 disabled:text-slate-400"
required={!isVerificationStep}
disabled={isVerificationStep}
/>
</div>
<div className="space-y-2">
<label htmlFor="confirm-password" className="text-sm font-medium text-slate-600">
{t.form.confirmPassword}
</label>
<input
id="confirm-password"
name="confirmPassword"
type="password"
autoComplete="new-password"
placeholder={t.form.confirmPasswordPlaceholder}
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
/>
</div>
</div>
<label className="flex items-start gap-3 text-sm text-slate-600">
<input
type="checkbox"
name="agreement"
required
className="mt-1 h-4 w-4 rounded border-slate-300 text-sky-600 focus:ring-sky-500"
type="password"
autoComplete="new-password"
placeholder={t.form.confirmPasswordPlaceholder}
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 disabled:cursor-not-allowed disabled:bg-slate-100 disabled:text-slate-400"
required={!isVerificationStep}
disabled={isVerificationStep}
/>
</div>
</div>
<label className="flex items-start gap-3 text-sm text-slate-600">
<input
type="checkbox"
name="agreement"
required={!isVerificationStep}
disabled={isVerificationStep}
className="mt-1 h-4 w-4 rounded border-slate-300 text-sky-600 focus:ring-sky-500 disabled:cursor-not-allowed disabled:opacity-60"
/>
<span>
{t.form.agreement}{' '}
<Link href="/docs" className="font-semibold text-sky-600 hover:text-sky-500">
@ -493,7 +773,7 @@ export default function RegisterContent() {
disabled={isSubmitting}
className="w-full rounded-2xl bg-gradient-to-r from-sky-500 to-blue-500 px-4 py-2.5 text-sm font-semibold text-white shadow-lg shadow-sky-500/20 transition hover:from-sky-500 hover:to-blue-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-sky-500 disabled:cursor-not-allowed disabled:opacity-70"
>
{isSubmitting ? t.form.submitting ?? t.form.submit : t.form.submit}
{submitLabel}
</button>
</form>
</AuthLayout>

View File

@ -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',
},
},
)
}

View File

@ -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: {