Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
96 changes: 96 additions & 0 deletions app/api/auth/magic-link/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
import 'server-only'
import { createClient } from '@supabase/supabase-js'
import { NextRequest, NextResponse } from 'next/server'
import { loginRateLimit } from '@/lib/utils/rate-limiter'

function isValidEmail(email: string) {
const atIndex = email.lastIndexOf('@')

return (
atIndex > 0 &&
atIndex < email.length - 3 &&
!email.includes(' ') &&
email.slice(atIndex + 1).includes('.')
)
}

function getRequestOrigin(request: NextRequest) {
const forwardedHost = request.headers.get('x-forwarded-host')
const forwardedProto = request.headers.get('x-forwarded-proto')

if (forwardedHost) {
return `${forwardedProto || 'https'}://${forwardedHost}`
}

return new URL(request.url).origin
}

export async function POST(request: NextRequest) {
try {
const rateLimitResult = await loginRateLimit(request)

if (!rateLimitResult.allowed) {
return NextResponse.json(
{
error: 'Too many magic link requests. Please try again later.',
lockedUntil: rateLimitResult.lockedUntil,
resetTime: rateLimitResult.resetTime,
},
{ status: 429 }
)
}

const body = await request.json()
const email = typeof body?.email === 'string' ? body.email.trim().toLowerCase() : ''

if (!email) {
return NextResponse.json({ error: 'Email is required' }, { status: 400 })
}

if (!isValidEmail(email)) {
return NextResponse.json({ error: 'Please enter a valid email address' }, { status: 400 })
}

const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL
const supabaseAnonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY

if (!supabaseUrl || !supabaseAnonKey) {
console.error('Missing Supabase public configuration for magic link auth')
return NextResponse.json(
{ error: 'Authentication is not configured. Please contact support.' },
{ status: 500 }
)
}

const supabase = createClient(supabaseUrl, supabaseAnonKey, {
auth: {
autoRefreshToken: false,
persistSession: false,
},
})

const origin = getRequestOrigin(request)
const { error } = await supabase.auth.signInWithOtp({
email,
options: {
emailRedirectTo: `${origin}/auth/callback`,
},
})

if (error) {
console.error('Magic link auth error:', error.message)
return NextResponse.json({ error: error.message }, { status: 400 })
}

return NextResponse.json({
success: true,
message: 'Check your email for the magic link.',
})
} catch (error) {
console.error('Magic link route error:', error)
return NextResponse.json(
{ error: 'Unable to send magic link. Please try again.' },
{ status: 500 }
)
}
}
43 changes: 33 additions & 10 deletions lib/features/auth/auth-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -239,32 +239,55 @@ export async function signInWithOtp(
email: string,
rememberMe = true,
): Promise<{ success: boolean; error?: string; message?: string }> {
try {
const supabase = createClient()
const controller = new AbortController()
const timeoutId = window.setTimeout(() => controller.abort(), 15000)

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

if (error) {
return { success: false, error: error.message }
const result = await response.json().catch(() => null) as {
success?: boolean
error?: string
message?: string
} | null

if (!response.ok || !result?.success) {
return {
success: false,
error: result?.error || 'Failed to send login link',
}
}

// Store remember me preference for after OTP verification
if (rememberMe) {
localStorage.setItem("lab68_remember", "true")
} else {
localStorage.removeItem("lab68_remember")
}

return {
success: true,
message: 'Check your email for the magic link to sign in.'
message: result.message || 'Check your email for the magic link to sign in.'
}
} catch (error: any) {
if (error?.name === 'AbortError') {
return {
success: false,
error: 'The request took too long. The email may still arrive, but please try again if it does not.',
}
}

return { success: false, error: error.message || 'Failed to send login link' }
} finally {
window.clearTimeout(timeoutId)
}
}

Expand Down
Loading