feat(auth): handle expiring sessions in frontend
This commit is contained in:
parent
f6d42399d4
commit
c2a16120cc
8 changed files with 250 additions and 11 deletions
|
|
@ -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 ?? []} />
|
||||
|
|
|
|||
|
|
@ -131,6 +131,7 @@ export const Users: CollectionConfig = {
|
|||
group: "Users",
|
||||
},
|
||||
auth: {
|
||||
tokenExpiration: 43200,
|
||||
loginWithUsername: {
|
||||
requireUsername: true,
|
||||
allowEmailLogin: false,
|
||||
|
|
|
|||
|
|
@ -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 &&
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
91
src/components/frontend/session/SessionWatchdog.tsx
Normal file
91
src/components/frontend/session/SessionWatchdog.tsx
Normal 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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
33
src/hooks/useSessionExpired.ts
Normal file
33
src/hooks/useSessionExpired.ts
Normal 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
44
src/lib/session/token.ts
Normal 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;
|
||||
}
|
||||
Loading…
Reference in a new issue