From 9a51b05b3d5b5b572b92267c33058c7aaebb9d82 Mon Sep 17 00:00:00 2001 From: Duong Phu Dong Date: Mon, 1 Jun 2026 23:41:56 +0700 Subject: [PATCH 1/4] fix(auth): normalize magic link delivery errors --- app/api/auth/magic-link/route.ts | 34 +++++++++++++++++++++++++++----- 1 file changed, 29 insertions(+), 5 deletions(-) diff --git a/app/api/auth/magic-link/route.ts b/app/api/auth/magic-link/route.ts index db0ee7f..8f734a5 100644 --- a/app/api/auth/magic-link/route.ts +++ b/app/api/auth/magic-link/route.ts @@ -4,6 +4,7 @@ import { NextRequest, NextResponse } from 'next/server' import { loginRateLimit } from '@/lib/utils/rate-limiter' export const maxDuration = 45 +const MAGIC_LINK_COOLDOWN_SECONDS = 60 function isValidEmail(email: string) { const atIndex = email.lastIndexOf('@') @@ -39,7 +40,8 @@ function normalizeSupabaseAuthError(error: { message?: string; status?: number } ) { return { status: 429, - message: 'A magic link was requested recently. Please wait 60 seconds before requesting another one.', + message: `A magic link was requested recently. Please wait ${MAGIC_LINK_COOLDOWN_SECONDS} seconds before requesting another one.`, + retryAfter: MAGIC_LINK_COOLDOWN_SECONDS, } } @@ -47,12 +49,22 @@ function normalizeSupabaseAuthError(error: { message?: string; status?: number } return { status: 400, message: 'Please enter a valid email address.', + retryAfter: 0, + } + } + + if (lowerMessage.includes('redirect')) { + return { + status: 500, + message: 'Magic link redirect is not configured correctly. Please contact support.', + retryAfter: 0, } } return { - status: 502, - message: 'Unable to send magic link right now. Please try again in a moment.', + status: 503, + message: 'Email delivery is temporarily unavailable. Please try again in a moment.', + retryAfter: MAGIC_LINK_COOLDOWN_SECONDS, } } @@ -66,6 +78,10 @@ export async function POST(request: NextRequest) { return NextResponse.json( { error: 'Too many magic link requests. Please try again later.', + retryAfter: Math.max( + MAGIC_LINK_COOLDOWN_SECONDS, + Math.ceil(((rateLimitResult.lockedUntil || rateLimitResult.resetTime) - Date.now()) / 1000) + ), lockedUntil: rateLimitResult.lockedUntil, resetTime: rateLimitResult.resetTime, }, @@ -118,8 +134,16 @@ export async function POST(request: NextRequest) { durationMs: Date.now() - startedAt, }) return NextResponse.json( - { error: normalizedError.message }, - { status: normalizedError.status } + { + error: normalizedError.message, + retryAfter: normalizedError.retryAfter, + }, + { + status: normalizedError.status, + headers: normalizedError.retryAfter + ? { 'Retry-After': String(normalizedError.retryAfter) } + : undefined, + } ) } From 8b5bcc4c5c47ce375d436535a005c8111182f027 Mon Sep 17 00:00:00 2001 From: Duong Phu Dong Date: Mon, 1 Jun 2026 23:42:07 +0700 Subject: [PATCH 2/4] fix(auth): pass magic link retry metadata --- lib/features/auth/auth-service.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/lib/features/auth/auth-service.ts b/lib/features/auth/auth-service.ts index 8b85476..36e6aa6 100644 --- a/lib/features/auth/auth-service.ts +++ b/lib/features/auth/auth-service.ts @@ -238,7 +238,7 @@ export async function signIn( export async function signInWithOtp( email: string, rememberMe = true, -): Promise<{ success: boolean; error?: string; message?: string }> { +): Promise<{ success: boolean; error?: string; message?: string; retryAfter?: number }> { const controller = new AbortController() const timeoutId = window.setTimeout(() => controller.abort(), 45000) @@ -257,12 +257,14 @@ export async function signInWithOtp( success?: boolean error?: string message?: string + retryAfter?: number } | null if (!response.ok || !result?.success) { return { success: false, error: result?.error || 'Failed to send login link', + retryAfter: typeof result?.retryAfter === 'number' ? result.retryAfter : undefined, } } From 73198257fd246516c764abe305ec9c976078c9f9 Mon Sep 17 00:00:00 2001 From: Duong Phu Dong Date: Mon, 1 Jun 2026 23:42:16 +0700 Subject: [PATCH 3/4] fix(login): add magic link resend cooldown --- app/login/page.tsx | 25 ++++++++++++++++++++++++- 1 file changed, 24 insertions(+), 1 deletion(-) diff --git a/app/login/page.tsx b/app/login/page.tsx index eac75ae..93a7c28 100644 --- a/app/login/page.tsx +++ b/app/login/page.tsx @@ -32,6 +32,7 @@ const loginHighlights = [ ] const workspaceItems = ["Project planning", "Collaborator review", "Security settings"] +const MAGIC_LINK_COOLDOWN_SECONDS = 60 function AuthBrand() { return ( @@ -56,6 +57,7 @@ function LoginPageContent() { const [success, setSuccess] = useState("") const [isLoading, setIsLoading] = useState(false) const [isCheckingAuth, setIsCheckingAuth] = useState(true) + const [cooldownSeconds, setCooldownSeconds] = useState(0) const [t, setT] = useState(getTranslations("en")) const checkAuth = useCallback(async () => { @@ -80,11 +82,26 @@ function LoginPageContent() { void checkAuth() }, [checkAuth]) + useEffect(() => { + if (cooldownSeconds <= 0) return + + const timer = window.setInterval(() => { + setCooldownSeconds((current) => Math.max(0, current - 1)) + }, 1000) + + return () => window.clearInterval(timer) + }, [cooldownSeconds]) + const handleLogin = async (event: React.FormEvent) => { event.preventDefault() setError("") setSuccess("") + if (cooldownSeconds > 0) { + setError(`Please wait ${cooldownSeconds}s before requesting another magic link.`) + return + } + if (!email) { setError(`${t.auth?.email || "Email"} is required`) return @@ -102,8 +119,12 @@ function LoginPageContent() { const result = await signInWithOtp(email, rememberMe) if (result.success) { + setCooldownSeconds(MAGIC_LINK_COOLDOWN_SECONDS) setSuccess(result.message || "Check your email for the magic link to sign in.") } else { + if (result.retryAfter) { + setCooldownSeconds(result.retryAfter) + } setError(result.error || "Login failed") } } catch (err: unknown) { @@ -256,7 +277,7 @@ function LoginPageContent() {