Skip to content

Commit e249cd3

Browse files
Merge pull request #35 from lab68dev/fix-magic-link-delivery-ux
Fix magic link delivery errors and resend cooldown UX
2 parents 621cf2c + 3f5fb91 commit e249cd3

4 files changed

Lines changed: 80 additions & 8 deletions

File tree

app/api/auth/magic-link/route.ts

Lines changed: 29 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { NextRequest, NextResponse } from 'next/server'
44
import { loginRateLimit } from '@/lib/utils/rate-limiter'
55

66
export const maxDuration = 45
7+
const MAGIC_LINK_COOLDOWN_SECONDS = 60
78

89
function isValidEmail(email: string) {
910
const atIndex = email.lastIndexOf('@')
@@ -39,20 +40,31 @@ function normalizeSupabaseAuthError(error: { message?: string; status?: number }
3940
) {
4041
return {
4142
status: 429,
42-
message: 'A magic link was requested recently. Please wait 60 seconds before requesting another one.',
43+
message: `A magic link was requested recently. Please wait ${MAGIC_LINK_COOLDOWN_SECONDS} seconds before requesting another one.`,
44+
retryAfter: MAGIC_LINK_COOLDOWN_SECONDS,
4345
}
4446
}
4547

4648
if (lowerMessage.includes('invalid') && lowerMessage.includes('email')) {
4749
return {
4850
status: 400,
4951
message: 'Please enter a valid email address.',
52+
retryAfter: 0,
53+
}
54+
}
55+
56+
if (lowerMessage.includes('redirect')) {
57+
return {
58+
status: 500,
59+
message: 'Magic link redirect is not configured correctly. Please contact support.',
60+
retryAfter: 0,
5061
}
5162
}
5263

5364
return {
54-
status: 502,
55-
message: 'Unable to send magic link right now. Please try again in a moment.',
65+
status: 503,
66+
message: 'Email delivery is temporarily unavailable. Please try again in a moment.',
67+
retryAfter: MAGIC_LINK_COOLDOWN_SECONDS,
5668
}
5769
}
5870

@@ -66,6 +78,10 @@ export async function POST(request: NextRequest) {
6678
return NextResponse.json(
6779
{
6880
error: 'Too many magic link requests. Please try again later.',
81+
retryAfter: Math.max(
82+
MAGIC_LINK_COOLDOWN_SECONDS,
83+
Math.ceil(((rateLimitResult.lockedUntil || rateLimitResult.resetTime) - Date.now()) / 1000)
84+
),
6985
lockedUntil: rateLimitResult.lockedUntil,
7086
resetTime: rateLimitResult.resetTime,
7187
},
@@ -118,8 +134,16 @@ export async function POST(request: NextRequest) {
118134
durationMs: Date.now() - startedAt,
119135
})
120136
return NextResponse.json(
121-
{ error: normalizedError.message },
122-
{ status: normalizedError.status }
137+
{
138+
error: normalizedError.message,
139+
retryAfter: normalizedError.retryAfter,
140+
},
141+
{
142+
status: normalizedError.status,
143+
headers: normalizedError.retryAfter
144+
? { 'Retry-After': String(normalizedError.retryAfter) }
145+
: undefined,
146+
}
123147
)
124148
}
125149

app/login/page.tsx

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ const loginHighlights = [
3232
]
3333

3434
const workspaceItems = ["Project planning", "Collaborator review", "Security settings"]
35+
const MAGIC_LINK_COOLDOWN_SECONDS = 60
3536

3637
function AuthBrand() {
3738
return (
@@ -56,6 +57,7 @@ function LoginPageContent() {
5657
const [success, setSuccess] = useState("")
5758
const [isLoading, setIsLoading] = useState(false)
5859
const [isCheckingAuth, setIsCheckingAuth] = useState(true)
60+
const [cooldownSeconds, setCooldownSeconds] = useState(0)
5961
const [t, setT] = useState(getTranslations("en"))
6062

6163
const checkAuth = useCallback(async () => {
@@ -80,11 +82,26 @@ function LoginPageContent() {
8082
void checkAuth()
8183
}, [checkAuth])
8284

85+
useEffect(() => {
86+
if (cooldownSeconds <= 0) return
87+
88+
const timer = window.setInterval(() => {
89+
setCooldownSeconds((current) => Math.max(0, current - 1))
90+
}, 1000)
91+
92+
return () => window.clearInterval(timer)
93+
}, [cooldownSeconds])
94+
8395
const handleLogin = async (event: React.FormEvent) => {
8496
event.preventDefault()
8597
setError("")
8698
setSuccess("")
8799

100+
if (cooldownSeconds > 0) {
101+
setError(`Please wait ${cooldownSeconds}s before requesting another magic link.`)
102+
return
103+
}
104+
88105
if (!email) {
89106
setError(`${t.auth?.email || "Email"} is required`)
90107
return
@@ -102,8 +119,12 @@ function LoginPageContent() {
102119
const result = await signInWithOtp(email, rememberMe)
103120

104121
if (result.success) {
122+
setCooldownSeconds(MAGIC_LINK_COOLDOWN_SECONDS)
105123
setSuccess(result.message || "Check your email for the magic link to sign in.")
106124
} else {
125+
if (result.retryAfter) {
126+
setCooldownSeconds(result.retryAfter)
127+
}
107128
setError(result.error || "Login failed")
108129
}
109130
} catch (err: unknown) {
@@ -256,14 +277,16 @@ function LoginPageContent() {
256277

257278
<Button
258279
type="submit"
259-
disabled={isLoading || !!success}
280+
disabled={isLoading || !!success || cooldownSeconds > 0}
260281
className="h-12 w-full rounded-lg text-sm font-semibold"
261282
>
262283
{isLoading ? (
263284
<>
264285
<ArrowPathIcon className="h-5 w-5 animate-spin" />
265286
Sending...
266287
</>
288+
) : cooldownSeconds > 0 ? (
289+
<>Wait {cooldownSeconds}s</>
267290
) : (
268291
<>
269292
Send Magic Link

app/signup/page.tsx

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ const signupHighlights = [
3434
]
3535

3636
const previewItems = ["Plan", "Build", "Review"]
37+
const MAGIC_LINK_COOLDOWN_SECONDS = 60
3738

3839
function AuthBrand() {
3940
return (
@@ -60,6 +61,7 @@ function SignUpPageContent() {
6061
const [isLoading, setIsLoading] = useState(false)
6162
const [isCheckingAuth, setIsCheckingAuth] = useState(true)
6263
const [usePassword, setUsePassword] = useState(false)
64+
const [cooldownSeconds, setCooldownSeconds] = useState(0)
6365
const [t, setT] = useState(getTranslations("en"))
6466

6567
const checkAuth = useCallback(async () => {
@@ -84,11 +86,26 @@ function SignUpPageContent() {
8486
void checkAuth()
8587
}, [checkAuth])
8688

89+
useEffect(() => {
90+
if (cooldownSeconds <= 0) return
91+
92+
const timer = window.setInterval(() => {
93+
setCooldownSeconds((current) => Math.max(0, current - 1))
94+
}, 1000)
95+
96+
return () => window.clearInterval(timer)
97+
}, [cooldownSeconds])
98+
8799
const handleSignUp = async (event: React.FormEvent) => {
88100
event.preventDefault()
89101
setError("")
90102
setSuccess("")
91103

104+
if (!usePassword && cooldownSeconds > 0) {
105+
setError(`Please wait ${cooldownSeconds}s before requesting another magic link.`)
106+
return
107+
}
108+
92109
if (!email) {
93110
setError("Email is required")
94111
return
@@ -130,8 +147,12 @@ function SignUpPageContent() {
130147
const result = await signInWithOtp(email, true)
131148

132149
if (result.success) {
150+
setCooldownSeconds(MAGIC_LINK_COOLDOWN_SECONDS)
133151
setSuccess(result.message || "Check your email for the magic link to complete registration.")
134152
} else {
153+
if (result.retryAfter) {
154+
setCooldownSeconds(result.retryAfter)
155+
}
135156
setError(result.error || "Sign up failed")
136157
}
137158
}
@@ -359,14 +380,16 @@ function SignUpPageContent() {
359380

360381
<Button
361382
type="submit"
362-
disabled={isLoading || !!success}
383+
disabled={isLoading || !!success || (!usePassword && cooldownSeconds > 0)}
363384
className="h-12 w-full rounded-lg text-sm font-semibold"
364385
>
365386
{isLoading ? (
366387
<>
367388
<ArrowPathIcon className="h-5 w-5 animate-spin" />
368389
{usePassword ? "Creating..." : "Sending..."}
369390
</>
391+
) : !usePassword && cooldownSeconds > 0 ? (
392+
<>Wait {cooldownSeconds}s</>
370393
) : (
371394
<>
372395
{usePassword ? "Create Account" : "Send Magic Link"}

lib/features/auth/auth-service.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -238,7 +238,7 @@ export async function signIn(
238238
export async function signInWithOtp(
239239
email: string,
240240
rememberMe = true,
241-
): Promise<{ success: boolean; error?: string; message?: string }> {
241+
): Promise<{ success: boolean; error?: string; message?: string; retryAfter?: number }> {
242242
const controller = new AbortController()
243243
const timeoutId = window.setTimeout(() => controller.abort(), 45000)
244244

@@ -257,12 +257,14 @@ export async function signInWithOtp(
257257
success?: boolean
258258
error?: string
259259
message?: string
260+
retryAfter?: number
260261
} | null
261262

262263
if (!response.ok || !result?.success) {
263264
return {
264265
success: false,
265266
error: result?.error || 'Failed to send login link',
267+
retryAfter: typeof result?.retryAfter === 'number' ? result.retryAfter : undefined,
266268
}
267269
}
268270

0 commit comments

Comments
 (0)