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
This commit is contained in:
Randall Stillwell 2026-04-19 10:53:36 -05:00
parent f2c7d13a3e
commit 11773a2ad2
2 changed files with 69 additions and 3 deletions

View file

@ -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 });
}

View file

@ -1,17 +1,62 @@
"use client"; "use client";
import { useState } from "react"; import { useEffect, useState } from "react";
import { useSession } from "next-auth/react"; import { useSession } from "next-auth/react";
import { Mail, X, Loader2 } from "lucide-react"; import { Mail, X, Loader2 } from "lucide-react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
type Status = "loading" | "verified" | "unverified";
export function EmailVerificationBanner() { export function EmailVerificationBanner() {
const { data: session } = useSession(); const { data: session, status: sessionStatus, update } = useSession();
const [dismissed, setDismissed] = useState(false); const [dismissed, setDismissed] = useState(false);
const [sending, setSending] = useState(false); const [sending, setSending] = useState(false);
const [sent, setSent] = useState(false); const [sent, setSent] = useState(false);
const [status, setStatus] = useState<Status>("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; return null;
} }