From 11773a2ad24db4ecbc46565165ca1f2d2b8aea12 Mon Sep 17 00:00:00 2001 From: Randall Stillwell Date: Sun, 19 Apr 2026 10:53:36 -0500 Subject: [PATCH] Self-heal stale email verification banner The banner was wired off session.user.isEmailVerified, which lives on the JWT cookie. If a user verified their email in another tab or was auto-verified by the invite flow after an existing session was issued, the token kept saying unverified until sign-out, so the banner hung around indefinitely. - Add GET /api/auth/verify-email/status, a tiny authenticated endpoint that returns the authoritative emailVerified state straight from the DB. - Banner now fetches that on mount and only renders if the DB agrees the user is unverified. If the DB says verified but the cookie is stale, we call useSession().update() to refresh the JWT so the rest of the app sees the correct state on the next render. Made-with: Cursor --- src/app/api/auth/verify-email/status/route.ts | 21 ++++++++ .../layout/email-verification-banner.tsx | 51 +++++++++++++++++-- 2 files changed, 69 insertions(+), 3 deletions(-) create mode 100644 src/app/api/auth/verify-email/status/route.ts 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; }