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, + } ) } 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() {