Merge branch 'codex/add-email-verification-with-6-digit-code'
This commit is contained in:
commit
6f0e879eee
@ -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()
|
||||
}
|
||||
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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>
|
||||
|
||||
66
dashboard/app/api/auth/verify-email/resend/route.ts
Normal file
66
dashboard/app/api/auth/verify-email/resend/route.ts
Normal 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',
|
||||
},
|
||||
},
|
||||
)
|
||||
}
|
||||
@ -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: {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user