From c2a16120cc819ba7621016093ddb8951f67dc739 Mon Sep 17 00:00:00 2001 From: Z8MB1E Date: Fri, 28 Aug 2026 00:20:35 -0400 Subject: [PATCH] feat(auth): handle expiring sessions in frontend --- src/app/(frontend)/layout.tsx | 6 ++ src/collections/users/Users.ts | 1 + .../frontend/eod/MinesweeperBoard.tsx | 50 ++++++++-- src/components/frontend/flappy/FlappyBird.tsx | 35 ++++++- .../frontend/session/SessionWatchdog.tsx | 91 +++++++++++++++++++ src/hooks/useGameTick.ts | 1 + src/hooks/useSessionExpired.ts | 33 +++++++ src/lib/session/token.ts | 44 +++++++++ 8 files changed, 250 insertions(+), 11 deletions(-) create mode 100644 src/components/frontend/session/SessionWatchdog.tsx create mode 100644 src/hooks/useSessionExpired.ts create mode 100644 src/lib/session/token.ts diff --git a/src/app/(frontend)/layout.tsx b/src/app/(frontend)/layout.tsx index 206f2c9..8b9bf24 100644 --- a/src/app/(frontend)/layout.tsx +++ b/src/app/(frontend)/layout.tsx @@ -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 }) { )} {user && } + {user && } + {user && } {isLogistics && } diff --git a/src/collections/users/Users.ts b/src/collections/users/Users.ts index 2080b50..8a8f9ba 100644 --- a/src/collections/users/Users.ts +++ b/src/collections/users/Users.ts @@ -131,6 +131,7 @@ export const Users: CollectionConfig = { group: "Users", }, auth: { + tokenExpiration: 43200, loginWithUsername: { requireUsername: true, allowEmailLogin: false, diff --git a/src/components/frontend/eod/MinesweeperBoard.tsx b/src/components/frontend/eod/MinesweeperBoard.tsx index c1699a8..f6f64cb 100644 --- a/src/components/frontend/eod/MinesweeperBoard.tsx +++ b/src/components/frontend/eod/MinesweeperBoard.tsx @@ -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("training"); const [customConfig, setCustomConfig] = useState({ 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 (
+ {expired && ( +
+ + Session Ending Soon + + + Your session is about to end. The app will refresh when it expires. + + + + +
+ )}
@@ -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({
)} + + {expired && ( +
+ + Session Ending Soon + + + Your session is about to end. The app will refresh when it expires. + + + + +
+ )}
diff --git a/src/components/frontend/session/SessionWatchdog.tsx b/src/components/frontend/session/SessionWatchdog.tsx new file mode 100644 index 0000000..b0a4902 --- /dev/null +++ b/src/components/frontend/session/SessionWatchdog.tsx @@ -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; +} diff --git a/src/hooks/useGameTick.ts b/src/hooks/useGameTick.ts index e238832..10a50fe 100644 --- a/src/hooks/useGameTick.ts +++ b/src/hooks/useGameTick.ts @@ -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; diff --git a/src/hooks/useSessionExpired.ts b/src/hooks/useSessionExpired.ts new file mode 100644 index 0000000..cce9e37 --- /dev/null +++ b/src/hooks/useSessionExpired.ts @@ -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; +} { + 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 }; +} diff --git a/src/lib/session/token.ts b/src/lib/session/token.ts new file mode 100644 index 0000000..01e284d --- /dev/null +++ b/src/lib/session/token.ts @@ -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; +}