- 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
155 lines
5.5 KiB
TypeScript
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>
|
|
);
|
|
}
|