Skip to content

Commit 215f566

Browse files
Merge pull request #31 from lab68dev/fix-magic-link-auth-flow
Fix magic link submission getting stuck in production
2 parents 86d82ba + d64a674 commit 215f566

2 files changed

Lines changed: 129 additions & 10 deletions

File tree

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

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
import 'server-only'
2+
import { createClient } from '@supabase/supabase-js'
3+
import { NextRequest, NextResponse } from 'next/server'
4+
import { loginRateLimit } from '@/lib/utils/rate-limiter'
5+
6+
function isValidEmail(email: string) {
7+
const atIndex = email.lastIndexOf('@')
8+
9+
return (
10+
atIndex > 0 &&
11+
atIndex < email.length - 3 &&
12+
!email.includes(' ') &&
13+
email.slice(atIndex + 1).includes('.')
14+
)
15+
}
16+
17+
function getRequestOrigin(request: NextRequest) {
18+
const forwardedHost = request.headers.get('x-forwarded-host')
19+
const forwardedProto = request.headers.get('x-forwarded-proto')
20+
21+
if (forwardedHost) {
22+
return `${forwardedProto || 'https'}://${forwardedHost}`
23+
}
24+
25+
return new URL(request.url).origin
26+
}
27+
28+
export async function POST(request: NextRequest) {
29+
try {
30+
const rateLimitResult = await loginRateLimit(request)
31+
32+
if (!rateLimitResult.allowed) {
33+
return NextResponse.json(
34+
{
35+
error: 'Too many magic link requests. Please try again later.',
36+
lockedUntil: rateLimitResult.lockedUntil,
37+
resetTime: rateLimitResult.resetTime,
38+
},
39+
{ status: 429 }
40+
)
41+
}
42+
43+
const body = await request.json()
44+
const email = typeof body?.email === 'string' ? body.email.trim().toLowerCase() : ''
45+
46+
if (!email) {
47+
return NextResponse.json({ error: 'Email is required' }, { status: 400 })
48+
}
49+
50+
if (!isValidEmail(email)) {
51+
return NextResponse.json({ error: 'Please enter a valid email address' }, { status: 400 })
52+
}
53+
54+
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL
55+
const supabaseAnonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY
56+
57+
if (!supabaseUrl || !supabaseAnonKey) {
58+
console.error('Missing Supabase public configuration for magic link auth')
59+
return NextResponse.json(
60+
{ error: 'Authentication is not configured. Please contact support.' },
61+
{ status: 500 }
62+
)
63+
}
64+
65+
const supabase = createClient(supabaseUrl, supabaseAnonKey, {
66+
auth: {
67+
autoRefreshToken: false,
68+
persistSession: false,
69+
},
70+
})
71+
72+
const origin = getRequestOrigin(request)
73+
const { error } = await supabase.auth.signInWithOtp({
74+
email,
75+
options: {
76+
emailRedirectTo: `${origin}/auth/callback`,
77+
},
78+
})
79+
80+
if (error) {
81+
console.error('Magic link auth error:', error.message)
82+
return NextResponse.json({ error: error.message }, { status: 400 })
83+
}
84+
85+
return NextResponse.json({
86+
success: true,
87+
message: 'Check your email for the magic link.',
88+
})
89+
} catch (error) {
90+
console.error('Magic link route error:', error)
91+
return NextResponse.json(
92+
{ error: 'Unable to send magic link. Please try again.' },
93+
{ status: 500 }
94+
)
95+
}
96+
}

lib/features/auth/auth-service.ts

Lines changed: 33 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -239,32 +239,55 @@ export async function signInWithOtp(
239239
email: string,
240240
rememberMe = true,
241241
): Promise<{ success: boolean; error?: string; message?: string }> {
242-
try {
243-
const supabase = createClient()
242+
const controller = new AbortController()
243+
const timeoutId = window.setTimeout(() => controller.abort(), 15000)
244244

245-
// Send OTP to user's email
246-
const { error } = await supabase.auth.signInWithOtp({
247-
email,
248-
options: {
249-
emailRedirectTo: `${window.location.origin}/auth/callback`,
245+
try {
246+
const response = await fetch('/api/auth/magic-link', {
247+
method: 'POST',
248+
headers: {
249+
'Content-Type': 'application/json',
250250
},
251+
body: JSON.stringify({ email }),
252+
cache: 'no-store',
253+
signal: controller.signal,
251254
})
252255

253-
if (error) {
254-
return { success: false, error: error.message }
256+
const result = await response.json().catch(() => null) as {
257+
success?: boolean
258+
error?: string
259+
message?: string
260+
} | null
261+
262+
if (!response.ok || !result?.success) {
263+
return {
264+
success: false,
265+
error: result?.error || 'Failed to send login link',
266+
}
255267
}
256268

257269
// Store remember me preference for after OTP verification
258270
if (rememberMe) {
259271
localStorage.setItem("lab68_remember", "true")
272+
} else {
273+
localStorage.removeItem("lab68_remember")
260274
}
261275

262276
return {
263277
success: true,
264-
message: 'Check your email for the magic link to sign in.'
278+
message: result.message || 'Check your email for the magic link to sign in.'
265279
}
266280
} catch (error: any) {
281+
if (error?.name === 'AbortError') {
282+
return {
283+
success: false,
284+
error: 'The request took too long. The email may still arrive, but please try again if it does not.',
285+
}
286+
}
287+
267288
return { success: false, error: error.message || 'Failed to send login link' }
289+
} finally {
290+
window.clearTimeout(timeoutId)
268291
}
269292
}
270293

0 commit comments

Comments
 (0)