"use client"; import { useCallback, useEffect, useRef, useState } from "react"; import { useRouter } from "next/navigation"; import { toast } from "sonner"; import { Clock, RefreshCw, X } from "lucide-react"; import { Button } from "@/components/ui/button"; import { COUNTDOWN_TICK_MS, SESSION_AUTO_CLOSE_MS, SESSION_WARN_THRESHOLD_MS, formatCountdown, sessionExpiryPhase, timeUntilExpiry, type SessionExpiryPhase, } from "@/lib/session/countdown"; import { SESSION_EXTENDED_EVENT } from "@/hooks/useGameTick"; interface SessionExpiryBannerProps { /** Absolute expiry (ms since epoch), server-computed from the JWT `exp`. */ sessionExp: number | null; /** How early (ms) to warn before expiry. Overrides the default for tests. */ warnMs?: number; /** How long before expiry (ms) to auto-close the banner. Overrides default. */ closeMs?: number; } /** * Pre-expiry warning + session-extend surface for logged-in users. * * The `payload-token` cookie is HttpOnly, so the browser can only see the * server-passed `sessionExp`. This component turns that into a live countdown: * it warns `warnMs` before expiry (firing one sonner toast), offers an * "Extend Session" button that calls Payload's built-in refresh endpoint to add * another token window, and auto-closes shortly before the hard cutoff. On * extend it dispatches `SESSION_EXTENDED_EVENT` so minigames un-lock and then * re-validates to pick up the refreshed cookie. */ export function SessionExpiryBanner({ sessionExp, warnMs = SESSION_WARN_THRESHOLD_MS, closeMs = SESSION_AUTO_CLOSE_MS, }: SessionExpiryBannerProps) { const router = useRouter(); const [now, setNow] = useState(() => Date.now()); const [dismissed, setDismissed] = useState(false); const [extending, setExtending] = useState(false); const [error, setError] = useState(null); const toastFiredRef = useRef(false); // Live clock so the countdown ticks every second. useEffect(() => { const timer = window.setInterval(() => setNow(Date.now()), COUNTDOWN_TICK_MS); return () => window.clearInterval(timer); }, []); // Each new expiry window (after an Extend) re-arms the one-shot toast. useEffect(() => { toastFiredRef.current = false; }, [sessionExp]); const remainingMs = timeUntilExpiry(sessionExp, now); const phase: SessionExpiryPhase = remainingMs == null ? "idle" : sessionExpiryPhase(remainingMs, warnMs, closeMs); const visible = phase === "warn" && !dismissed; // Fire the heads-up toast once when we first enter the warn window. const fireToast = useCallback(() => { if (toastFiredRef.current) return; toastFiredRef.current = true; const minutes = Math.round(warnMs / 60_000); toast.info(`Your session expires in about ${minutes} min.`, { action: { label: "Extend", onClick: () => void extendSession(), }, duration: Infinity, }); // eslint-disable-next-line react-hooks/exhaustive-deps }, [warnMs]); useEffect(() => { if (phase === "warn") fireToast(); }, [phase, warnMs, fireToast]); const extendSession = useCallback(async () => { setExtending(true); setError(null); try { const response = await fetch("/api/users/refresh-token", { method: "POST", credentials: "same-origin", }); if (!response.ok) { throw new Error(`Session refresh failed (HTTP ${response.status}).`); } // Unlock minigames that locked when the session neared expiry, then // re-validate so the refreshed cookie is picked up and the countdown // resumes from the new expiry. window.dispatchEvent(new CustomEvent(SESSION_EXTENDED_EVENT)); router.refresh(); } catch (e) { const message = e instanceof Error ? e.message : "Could not extend session."; setError(message); toast.error(message); } finally { setExtending(false); } }, [router]); if (!visible) return null; const minutesLeft = Math.round(warnMs / 60_000); return (
{error &&

{error}

}
); }