diff --git a/src/app/api/auth/verify-email/status/route.ts b/src/app/api/auth/verify-email/status/route.ts new file mode 100644 index 0000000..4980cf2 --- /dev/null +++ b/src/app/api/auth/verify-email/status/route.ts @@ -0,0 +1,21 @@ +import { NextResponse } from "next/server"; +import { auth } from "@/auth"; +import { prisma } from "@/lib/db"; + +export async function GET() { + const session = await auth(); + if (!session?.user?.id) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const user = await prisma.user.findUnique({ + where: { id: session.user.id }, + select: { emailVerified: true }, + }); + + if (!user) { + return NextResponse.json({ error: "User not found" }, { status: 404 }); + } + + return NextResponse.json({ verified: !!user.emailVerified }); +} diff --git a/src/components/layout/email-verification-banner.tsx b/src/components/layout/email-verification-banner.tsx index fddba07..8dc68af 100644 --- a/src/components/layout/email-verification-banner.tsx +++ b/src/components/layout/email-verification-banner.tsx @@ -1,17 +1,62 @@ "use client"; -import { useState } from "react"; +import { useEffect, useState } from "react"; import { useSession } from "next-auth/react"; import { Mail, X, Loader2 } from "lucide-react"; import { Button } from "@/components/ui/button"; +type Status = "loading" | "verified" | "unverified"; + export function EmailVerificationBanner() { - const { data: session } = useSession(); + const { data: session, status: sessionStatus, update } = useSession(); const [dismissed, setDismissed] = useState(false); const [sending, setSending] = useState(false); const [sent, setSent] = useState(false); + const [status, setStatus] = useState("loading"); - if (!session?.user || session.user.isEmailVerified || dismissed) { + // Authoritative check against the DB. The JWT cookie can be stale (e.g. + // user verified their email in another tab, or was auto-verified by the + // invite flow on an existing session), so we confirm with the server + // before ever showing the banner. If the DB disagrees with the session, + // we also trigger a session update so the banner hides immediately on + // subsequent renders without requiring a full sign-out. + useEffect(() => { + if (sessionStatus !== "authenticated") return; + + let cancelled = false; + (async () => { + try { + const res = await fetch("/api/auth/verify-email/status", { + cache: "no-store", + }); + if (!res.ok) { + if (!cancelled) setStatus("unverified"); + return; + } + const data = (await res.json()) as { verified: boolean }; + if (cancelled) return; + setStatus(data.verified ? "verified" : "unverified"); + if (data.verified && session?.user && !session.user.isEmailVerified) { + void update(); + } + } catch { + if (!cancelled) setStatus("unverified"); + } + })(); + + return () => { + cancelled = true; + }; + }, [sessionStatus, session?.user, update]); + + if ( + sessionStatus !== "authenticated" || + !session?.user || + session.user.isEmailVerified || + status === "loading" || + status === "verified" || + dismissed + ) { return null; }