diff --git a/app/api/auth/magic-link/route.ts b/app/api/auth/magic-link/route.ts new file mode 100644 index 0000000..b8c3d1b --- /dev/null +++ b/app/api/auth/magic-link/route.ts @@ -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 } + ) + } +} diff --git a/lib/features/auth/auth-service.ts b/lib/features/auth/auth-service.ts index e696f9f..71ad611 100644 --- a/lib/features/auth/auth-service.ts +++ b/lib/features/auth/auth-service.ts @@ -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) } }