diff --git a/app/auth/confirm/route.ts b/app/auth/confirm/route.ts new file mode 100644 index 0000000..5007ce4 --- /dev/null +++ b/app/auth/confirm/route.ts @@ -0,0 +1,53 @@ +import { NextResponse, type NextRequest } from 'next/server'; +import type { EmailOtpType } from '@supabase/supabase-js'; +import { createSupabaseServerClient } from '@/lib/supabase/server'; + +export const runtime = 'nodejs'; +export const dynamic = 'force-dynamic'; + +/** + * Whitelist `next` to same-origin relative paths only (open-redirect guard). + * Anything that isn't a single-slash-rooted path falls back to /dashboard. + */ +function safeNext(raw: string | null): string { + if (!raw || !raw.startsWith('/') || raw.startsWith('//')) { + return '/dashboard'; + } + return raw; +} + +/** + * Email confirmation / OTP verification landing route. + * + * Handles both GoTrue link styles defensively: + * - token_hash template: /auth/confirm?token_hash=&type=signup -> verifyOtp + * - PKCE/code redirect: /auth/confirm?code= -> exchangeCodeForSession + * + * On success the server client persists the session cookies and we redirect to + * `next` (default /dashboard). On any failure we send the user to /login with a + * verification_failed flag so they can resend a fresh confirmation email. + */ +export async function GET(request: NextRequest) { + const url = request.nextUrl; + const token_hash = url.searchParams.get('token_hash'); + const type = url.searchParams.get('type') as EmailOtpType | null; + const code = url.searchParams.get('code'); + const next = safeNext(url.searchParams.get('next')); + + const base = process.env.NEXT_PUBLIC_APP_URL ?? url.origin; + const supabase = createSupabaseServerClient(); + + if (token_hash && type) { + const { error } = await supabase.auth.verifyOtp({ type, token_hash }); + if (!error) { + return NextResponse.redirect(new URL(next, base)); + } + } else if (code) { + const { error } = await supabase.auth.exchangeCodeForSession(code); + if (!error) { + return NextResponse.redirect(new URL(next, base)); + } + } + + return NextResponse.redirect(new URL('/login?error=verification_failed', base)); +} diff --git a/app/login/page.tsx b/app/login/page.tsx index 2be5381..2fdffb9 100644 --- a/app/login/page.tsx +++ b/app/login/page.tsx @@ -1,20 +1,47 @@ 'use client'; -import { useState, useTransition } from 'react'; +import { useEffect, useState, useTransition } from 'react'; import { useRouter } from 'next/navigation'; import Link from 'next/link'; +import type { AuthError } from '@supabase/supabase-js'; import { createSupabaseBrowserClient } from '@/lib/supabase/browser'; +const RESEND_COOLDOWN = 45; + +function isEmailNotConfirmed(error: AuthError): boolean { + return error.code === 'email_not_confirmed' || /not confirmed/i.test(error.message); +} + export default function LoginPage() { const router = useRouter(); const [email, setEmail] = useState(''); const [password, setPassword] = useState(''); const [error, setError] = useState(null); + const [needsConfirm, setNeedsConfirm] = useState(false); + const [resendInfo, setResendInfo] = useState(null); + const [cooldown, setCooldown] = useState(0); const [isPending, startTransition] = useTransition(); + const [isResending, startResend] = useTransition(); + + useEffect(() => { + const params = new URLSearchParams(window.location.search); + if (params.get('error') === 'verification_failed') { + setError( + 'Email verification failed or the link expired. Sign in below to resend a confirmation email.', + ); + } + }, []); + + useEffect(() => { + if (cooldown <= 0) return; + const t = setTimeout(() => setCooldown((c) => c - 1), 1000); + return () => clearTimeout(t); + }, [cooldown]); function onSubmit(e: React.FormEvent) { e.preventDefault(); setError(null); + setResendInfo(null); const supabase = createSupabaseBrowserClient(); startTransition(async () => { const { error } = await supabase.auth.signInWithPassword({ @@ -22,7 +49,14 @@ export default function LoginPage() { password, }); if (error) { - setError(error.message); + if (isEmailNotConfirmed(error)) { + setNeedsConfirm(true); + setError( + 'Please confirm your email address before signing in. Check your inbox or resend the confirmation email below.', + ); + } else { + setError(error.message); + } return; } router.push('/dashboard'); @@ -30,6 +64,20 @@ export default function LoginPage() { }); } + function onResend() { + setResendInfo(null); + const supabase = createSupabaseBrowserClient(); + startResend(async () => { + const { error } = await supabase.auth.resend({ type: 'signup', email }); + if (error) { + setError(error.message); + return; + } + setResendInfo('Confirmation email sent. Check your inbox.'); + setCooldown(RESEND_COOLDOWN); + }); + } + return (

Login

@@ -53,6 +101,23 @@ export default function LoginPage() { onChange={(e) => setPassword(e.target.value)} /> {error &&

{error}

} + {resendInfo &&

{resendInfo}

} + {needsConfirm && ( +
+ +
+ )}
+ + Back to login + +
+
+ ); + } + return (

Sign up

@@ -60,7 +129,6 @@ export default function SignupPage() { onChange={(e) => setPassword(e.target.value)} /> {error &&

{error}

} - {info &&

{info}

}