1
0
Fork 0

feat(auth): handle expiring sessions in frontend

This commit is contained in:
Jason Fraley 2026-08-28 00:20:35 -04:00
parent f6d42399d4
commit c2a16120cc
8 changed files with 250 additions and 11 deletions

View file

@ -12,7 +12,10 @@ import { type ResolvedLevel, resolveLevel } from "@/utils/xp/resolveLevel";
import { hasIntelligenceQualification } from "@/utils/access-control/hasIntelligenceQualification";
import { hasLogisticsQualification } from "@/utils/access-control/hasLogisticsQualification";
import { ShipmentToasts } from "@/components/frontend/logistics/ShipmentToasts";
import { MissionReminderToasts } from "@/components/frontend/intelligence/MissionReminderToasts";
import { GameTickRealtime } from "@/components/frontend/realtime/GameTickRealtime";
import { SessionWatchdog } from "@/components/frontend/session/SessionWatchdog";
import { getSessionExpMs } from "@/lib/session/token";
import VersionOverlay from "@/components/static/VersionOverlay";
import { LandingPage } from "@/components/frontend/LandingPage";
import { Toaster } from "@/components/ui/sonner";
@ -54,6 +57,7 @@ export default async function RootLayout(props: { children: React.ReactNode }) {
const payload = await getPayload({ config });
const { user: authUser } = await payload.auth({ headers, canSetHeaders: false });
const user = isPayloadUser(authUser) ? authUser : null;
const sessionExp = getSessionExpMs(headers);
const impersonating = (await cookies()).has(IMPERSONATION_ACTIVE_COOKIE);
let xpLevel: ResolvedLevel = {
@ -156,6 +160,8 @@ export default async function RootLayout(props: { children: React.ReactNode }) {
<VersionOverlay canSeeCommit={await isSuperuser(payload, user)} />
)}
{user && <GameTickRealtime />}
{user && <SessionWatchdog sessionExp={sessionExp} />}
{user && <MissionReminderToasts />}
{isLogistics && <ShipmentToasts />}
<Toaster />
<ShimLoader shims={shimEntries} userId={user?.id ?? null} userRoles={user?.roles ?? []} />

View file

@ -131,6 +131,7 @@ export const Users: CollectionConfig = {
group: "Users",
},
auth: {
tokenExpiration: 43200,
loginWithUsername: {
requireUsername: true,
allowEmailLogin: false,

View file

@ -17,6 +17,8 @@ import { Input } from "@/components/ui/input";
import { Switch } from "@/components/ui/switch";
import { submitEodResult, type EodDifficulty, type EodStats } from "@/app/(frontend)/eod/actions";
import { PROFILE_UPDATE_EVENT } from "@/hooks/useGameTick";
import { useSessionExpired } from "@/hooks/useSessionExpired";
import { LoginLink } from "@/components/frontend/auth/LoginLink";
import { cn } from "@/lib/utils";
import { playClear, playExplosion, playFlag, playReveal, playXp } from "./sounds";
@ -182,6 +184,7 @@ export function MinesweeperBoard({
onDifficultyChange: (difficulty: EodDifficulty) => void;
}) {
const router = useRouter();
const { expired, expiredRef } = useSessionExpired();
const [difficulty, setDifficulty] = useState<GameMode>("training");
const [customConfig, setCustomConfig] = useState<GameConfig>({
label: "Custom Minefield",
@ -240,6 +243,7 @@ export function MinesweeperBoard({
const reset = useCallback(
(nextDifficulty: GameMode = difficulty) => {
if (submittingRef.current) return;
if (expiredRef.current) return;
const nextConfig = nextDifficulty === "custom" ? customConfig : DIFFICULTIES[nextDifficulty];
setDifficulty(nextDifficulty);
if (nextDifficulty !== "custom") onDifficultyChange(nextDifficulty);
@ -249,7 +253,7 @@ export function MinesweeperBoard({
setLastRun(null);
setPhaseSafe("ready");
},
[customConfig, difficulty, onDifficultyChange, setPhaseSafe],
[customConfig, difficulty, expiredRef, onDifficultyChange, setPhaseSafe],
);
useEffect(() => {
@ -284,6 +288,17 @@ export function MinesweeperBoard({
const recordResult = useCallback(
async (won: boolean, elapsedSeconds: number, flags: number) => {
if (submittingRef.current) return;
if (expiredRef.current) {
setLastRun({
won,
seconds: elapsedSeconds,
score: 0,
xpEarned: 0,
best: 0,
error: "Your session expired. Result not recorded.",
});
return;
}
if (difficulty === "custom") {
setLastRun({
won,
@ -337,7 +352,7 @@ export function MinesweeperBoard({
});
}
},
[config.baseScore, config.maxSeconds, config.parSeconds, difficulty, onGameRecorded, router],
[config.baseScore, config.maxSeconds, config.parSeconds, difficulty, expiredRef, onGameRecorded, router],
);
const finishGame = useCallback(
@ -366,6 +381,7 @@ export function MinesweeperBoard({
const reveal = useCallback(
(index: number) => {
if (phase === "over" || submittingRef.current) return;
if (expiredRef.current) return;
const existing = board[index];
if (!existing || existing.flagged || existing.revealed) return;
const workingBoard = generatedRef.current ? board : createBoard(config, index);
@ -387,12 +403,13 @@ export function MinesweeperBoard({
setBoard(nextBoard);
if (nextBoard.every((item) => item.mine || item.revealed)) finishGame(true, nextBoard);
},
[board, config, finishGame, phase, setPhaseSafe],
[board, config, expiredRef, finishGame, phase, setPhaseSafe],
);
const toggleFlag = useCallback(
(index: number) => {
if (phase === "over" || submittingRef.current) return;
if (expiredRef.current) return;
const cell = board[index];
if (!cell || cell.revealed) return;
playFlag();
@ -402,7 +419,7 @@ export function MinesweeperBoard({
),
);
},
[board, phase],
[board, expiredRef, phase],
);
const clearLongPress = useCallback(() => {
@ -450,7 +467,7 @@ export function MinesweeperBoard({
aria-label={cellLabel(cell, row, col)}
aria-rowindex={row + 1}
aria-colindex={col + 1}
disabled={phase === "over" || cell.revealed}
disabled={phase === "over" || expired || cell.revealed}
className={cn(
"flex size-7 items-center justify-center rounded-sm border text-xs font-bold transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
cell.revealed &&
@ -516,6 +533,21 @@ export function MinesweeperBoard({
return (
<div className="flex w-full max-w-5xl flex-col gap-4">
{expired && (
<div className="flex flex-col items-center justify-center gap-3 rounded-xl border border-amber-500/40 bg-amber-500/10 p-6 text-center">
<span className="text-xl font-black uppercase tracking-widest text-amber-400">
Session Ending Soon
</span>
<span className="max-w-md text-sm text-white/80">
Your session is about to end. The app will refresh when it expires.
</span>
<LoginLink>
<Button type="button" size="sm">
Log in
</Button>
</LoginLink>
</div>
)}
<div className="flex select-none flex-col gap-4 rounded-xl border border-border bg-card p-4 shadow">
<div className="flex flex-wrap items-start justify-between gap-4">
<div className="flex flex-col gap-1">
@ -567,7 +599,7 @@ export function MinesweeperBoard({
type="button"
variant={difficulty === key ? "default" : "outline"}
className="h-auto justify-start px-3 py-2 text-left"
disabled={submitting}
disabled={submitting || expired}
aria-pressed={difficulty === key}
onClick={() => reset(key)}
>
@ -676,7 +708,7 @@ export function MinesweeperBoard({
<Button
type="button"
variant={difficulty === "custom" ? "default" : "outline"}
disabled={submitting}
disabled={submitting || expired}
aria-pressed={difficulty === "custom"}
onClick={() => reset("custom")}
>
@ -731,7 +763,7 @@ export function MinesweeperBoard({
type="button"
variant="outline"
size="sm"
disabled={submitting}
disabled={submitting || expired}
onClick={() => reset()}
>
<RotateCcw /> Retry sector
@ -759,7 +791,7 @@ export function MinesweeperBoard({
aria-label={cellLabel(cell, row, col)}
aria-rowindex={row + 1}
aria-colindex={col + 1}
disabled={phase === "over" || cell.revealed}
disabled={phase === "over" || expired || cell.revealed}
className={cn(
"flex size-7 items-center justify-center rounded-sm border text-xs font-bold transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
cell.revealed &&

View file

@ -5,6 +5,9 @@ import { useRouter } from "next/navigation";
import { type FlappyStats, submitFlappyScore } from "@/app/(frontend)/flappy/actions";
import { PROFILE_UPDATE_EVENT } from "@/hooks/useGameTick";
import { useSessionExpired } from "@/hooks/useSessionExpired";
import { LoginLink } from "@/components/frontend/auth/LoginLink";
import { Button } from "@/components/ui/button";
import { playFlap, playHit, playXp } from "./sounds";
const CANVAS_W = 420;
@ -333,6 +336,7 @@ export function FlappyBird({
onPhaseChange?: (phase: Phase) => void;
}) {
const router = useRouter();
const { expired, expiredRef } = useSessionExpired();
const canvasRef = useRef<HTMLCanvasElement | null>(null);
const wrapperRef = useRef<HTMLDivElement | null>(null);
const phaseRef = useRef<Phase>("ready");
@ -367,6 +371,7 @@ export function FlappyBird({
}, [onPhaseChange]);
const reset = useCallback(() => {
if (expiredRef.current) return;
const g = gameRef.current;
g.birdY = START_Y;
g.birdVy = 0;
@ -383,6 +388,15 @@ export function FlappyBird({
const handleGameOver = useCallback(
async (finalScore: number) => {
if (submittingRef.current) return;
if (expiredRef.current) {
setLastRun({
score: finalScore,
xpEarned: 0,
best: statsRef.current.bestScore,
error: "Your session expired. Score not recorded.",
});
return;
}
if (finalScore <= 0) {
setLastRun({
score: finalScore,
@ -423,7 +437,7 @@ export function FlappyBird({
});
}
},
[onGameRecorded, router],
[expiredRef, onGameRecorded, router],
);
const handleGameOverRef = useRef(handleGameOver);
@ -432,6 +446,7 @@ export function FlappyBird({
}, [handleGameOver]);
const flap = useCallback(() => {
if (expiredRef.current) return;
const p = phaseRef.current;
if (p === "ready") {
const g = gameRef.current;
@ -445,7 +460,7 @@ export function FlappyBird({
} else if (p === "over") {
reset();
}
}, [reset, setPhaseSafe]);
}, [expiredRef, reset, setPhaseSafe]);
useEffect(() => {
const canvas = canvasRef.current;
@ -716,6 +731,22 @@ export function FlappyBird({
</span>
</div>
)}
{expired && (
<div className="absolute inset-0 flex flex-col items-center justify-center gap-3 text-center bg-black/70 rounded-xl">
<span className="text-2xl font-black uppercase tracking-widest text-amber-400 drop-shadow-lg">
Session Ending Soon
</span>
<span className="max-w-[260px] text-xs text-white/80">
Your session is about to end. The app will refresh when it expires.
</span>
<LoginLink className="mt-1">
<Button type="button" size="sm">
Log in
</Button>
</LoginLink>
</div>
)}
</div>
<div className="flex flex-wrap justify-center gap-6 text-sm text-muted-foreground">

View file

@ -0,0 +1,91 @@
"use client";
import { useEffect, useRef } from "react";
import { useRouter } from "next/navigation";
import { SESSION_EXPIRED_EVENT } from "@/hooks/useGameTick";
/**
* How early (ms) before the JWT `exp` we lock the client (dispatch the
* `SESSION_EXPIRED_EVENT`). The server is authoritative; this buffer only
* makes the client-side UX hint fire a little ahead of the hard cutoff so the
* user isn't mid-action when the token lapses.
*/
const NEAR_EXPIRY_BUFFER_MS = 30_000;
/**
* How often (ms) the safety check runs. It compares the wall clock against the
* server-provided `sessionExp` — it makes no server requests and reads no
* cookies. It exists to catch a throttled timer (e.g. a backgrounded tab) that
* delayed the one-shot timeout past the real expiry.
*/
const SAFETY_CHECK_INTERVAL_MS = 5 * 60_000;
/**
* Mounted only in the authenticated branch of `(frontend)/layout.tsx`.
*
* The server reads the HttpOnly `payload-token` cookie and passes only the JWT
* `exp` (ms) as `sessionExp`. This component schedules a one-shot lock timer
* just before expiry (dispatches `SESSION_EXPIRED_EVENT` so minigames and other
* consumers can lock themselves) and a refresh timer at the actual expiry
* (`router.refresh()` — the server then re-renders the layout, sees the expired
* token, and swaps the shell for the guest landing page). A lightweight safety
* interval catches throttled timers. Guards prevent duplicate lock/refresh.
*/
export function SessionWatchdog({ sessionExp }: { sessionExp: number | null }) {
const router = useRouter();
const lockFiredRef = useRef(false);
const refreshFiredRef = useRef(false);
useEffect(() => {
if (sessionExp === null) return;
const lock = () => {
if (lockFiredRef.current) return;
lockFiredRef.current = true;
window.dispatchEvent(new CustomEvent(SESSION_EXPIRED_EVENT));
};
const refresh = () => {
if (refreshFiredRef.current) return;
refreshFiredRef.current = true;
router.refresh();
};
const now = Date.now();
const lockDelay = sessionExp - NEAR_EXPIRY_BUFFER_MS - now;
const refreshDelay = sessionExp - now;
let lockTimer: number | null = null;
let refreshTimer: number | null = null;
if (refreshDelay <= 0) {
// Already past expiry — lock and refresh immediately.
lock();
refresh();
} else {
if (lockDelay <= 0) {
lock();
} else {
lockTimer = window.setTimeout(lock, lockDelay);
}
refreshTimer = window.setTimeout(refresh, refreshDelay);
}
// Safety net for throttled timers in background tabs.
const safety = window.setInterval(() => {
if (Date.now() >= sessionExp) {
lock();
refresh();
window.clearInterval(safety);
}
}, SAFETY_CHECK_INTERVAL_MS);
return () => {
if (lockTimer !== null) window.clearTimeout(lockTimer);
if (refreshTimer !== null) window.clearTimeout(refreshTimer);
window.clearInterval(safety);
};
}, [router, sessionExp]);
return null;
}

View file

@ -6,6 +6,7 @@ export const GAME_TICK_EVENT = "ptf:game-tick";
export const PROFILE_UPDATE_EVENT = "ptf:profile-update";
export const GAME_EVENT_EVENT = "ptf:game-event";
export const NOTIFICATION_EVENT = "ptf:notification";
export const SESSION_EXPIRED_EVENT = "ptf:session-expired";
export interface GameTickDetail {
processedAt?: string;

View file

@ -0,0 +1,33 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { SESSION_EXPIRED_EVENT } from "@/hooks/useGameTick";
/**
* Tracks whether the session has expired (per the client-side watchdog).
*
* Returns both a reactive `expired` boolean (for rendering) and an
* `expiredRef` (for use inside callbacks / the game loop without stale
* closures). Consumers should gate start/retry/input and skip server saves
* on `expiredRef.current`.
*/
export function useSessionExpired(): {
expired: boolean;
expiredRef: React.RefObject<boolean>;
} {
const [expired, setExpired] = useState(false);
const expiredRef = useRef(false);
useEffect(() => {
const handler = () => {
expiredRef.current = true;
setExpired(true);
};
window.addEventListener(SESSION_EXPIRED_EVENT, handler);
return () => {
window.removeEventListener(SESSION_EXPIRED_EVENT, handler);
};
}, []);
return { expired, expiredRef };
}

44
src/lib/session/token.ts Normal file
View file

@ -0,0 +1,44 @@
/**
* Session-token helpers.
*
* The `payload-token` cookie is HttpOnly (set by Payload), so it is invisible
* to client-side JavaScript. The server reads it from the request headers and
* extracts only the JWT `exp` claim, which is passed to the client watchdog as
* a plain number. The server remains the authority on whether a session is
* actually valid — the `exp` is used purely as a client-side UX hint.
*/
/**
* Decode the `exp` claim (seconds since epoch) from a JWT string, or null when
* the token is malformed or carries no numeric `exp`. Pure and side-effect
* free so it is trivially unit-testable on both server and client.
*/
export function decodeTokenExp(token: string): number | null {
const payload = token.split(".")[1];
if (!payload) return null;
let json: unknown;
try {
const base64 = payload.replace(/-/g, "+").replace(/_/g, "/");
json = JSON.parse(atob(base64));
} catch {
return null;
}
if (typeof json !== "object" || json === null) return null;
const exp = (json as { exp?: unknown }).exp;
return typeof exp === "number" && Number.isFinite(exp) ? exp : null;
}
/**
* Read the `payload-token` cookie from the request headers and return the JWT
* `exp` in milliseconds since epoch, or null when the cookie is absent or the
* token is malformed. Server-only in practice (needs the request headers), but
* takes the headers as a parameter so it stays pure and testable.
*/
export function getSessionExpMs(headers: { get(name: string): string | null }): number | null {
const cookieHeader = headers.get("cookie");
if (!cookieHeader) return null;
const match = cookieHeader.match(/(?:^|;\s*)payload-token=([^;]+)/);
if (!match) return null;
const exp = decodeTokenExp(match[1]);
return exp === null ? null : exp * 1000;
}