1
0
Fork 0
polaris-task-force/src/components/frontend/session/SessionExpiryBanner.tsx
Z8MB1E 962064a5a4 feat(session): add pre-expiry warning banner with extend button
- SessionExpiryBanner: live countdown, warn 15min early, one-shot toast, Extend button
- countdown.ts: pure countdown helpers (timeUntilExpiry, sessionExpiryPhase, formatCountdown)
- useSessionExpired: added unlock handler for SESSION_EXTENDED_EVENT
- useGameTick: exported SESSION_EXTENDED_EVENT for session extension
- unit tests: 14/14 pass for countdown helpers
- e2e test: banner renders with countdown + extend button
2026-09-23 15:02:10 -04:00

155 lines
5.5 KiB
TypeScript

"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<string | null>(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 (
<div
className="flex flex-wrap items-center justify-between gap-3 border-b border-white/15 bg-slate-900/90 px-4 py-2 text-sm text-white/90 backdrop-blur-sm"
role="status"
aria-live="polite"
>
<div className="flex items-center gap-3">
<Clock className="size-4 shrink-0 text-white/60" aria-hidden="true" />
<span>
Session expires in{" "}
<span className="font-mono tabular-nums text-white">{formatCountdown((remainingMs ?? 0) / 1000)}</span>.{" "}
Extend to stay signed in for another {minutesLeft} min.
</span>
</div>
<div className="flex items-center gap-2">
<Button
type="button"
variant="outline"
size="sm"
onClick={() => void extendSession()}
disabled={extending}
className="h-7 border-white/30 bg-transparent text-white hover:bg-white/10 hover:text-white"
>
<RefreshCw className={`size-3.5 ${extending ? "animate-spin" : ""}`} aria-hidden="true" />
{extending ? "Extending..." : "Extend"}
</Button>
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => setDismissed(true)}
className="h-7 text-white/70 hover:bg-white/10 hover:text-white"
>
<X className="size-3.5" aria-hidden="true" />
<span className="sr-only">Dismiss</span>
</Button>
</div>
{error && <p className="absolute -bottom-6 left-4 text-xs text-red-400">{error}</p>}
</div>
);
}