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 { hasIntelligenceQualification } from "@/utils/access-control/hasIntelligenceQualification";
|
||||||
import { hasLogisticsQualification } from "@/utils/access-control/hasLogisticsQualification";
|
import { hasLogisticsQualification } from "@/utils/access-control/hasLogisticsQualification";
|
||||||
import { ShipmentToasts } from "@/components/frontend/logistics/ShipmentToasts";
|
import { ShipmentToasts } from "@/components/frontend/logistics/ShipmentToasts";
|
||||||
|
import { MissionReminderToasts } from "@/components/frontend/intelligence/MissionReminderToasts";
|
||||||
import { GameTickRealtime } from "@/components/frontend/realtime/GameTickRealtime";
|
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 VersionOverlay from "@/components/static/VersionOverlay";
|
||||||
import { LandingPage } from "@/components/frontend/LandingPage";
|
import { LandingPage } from "@/components/frontend/LandingPage";
|
||||||
import { Toaster } from "@/components/ui/sonner";
|
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 payload = await getPayload({ config });
|
||||||
const { user: authUser } = await payload.auth({ headers, canSetHeaders: false });
|
const { user: authUser } = await payload.auth({ headers, canSetHeaders: false });
|
||||||
const user = isPayloadUser(authUser) ? authUser : null;
|
const user = isPayloadUser(authUser) ? authUser : null;
|
||||||
|
const sessionExp = getSessionExpMs(headers);
|
||||||
const impersonating = (await cookies()).has(IMPERSONATION_ACTIVE_COOKIE);
|
const impersonating = (await cookies()).has(IMPERSONATION_ACTIVE_COOKIE);
|
||||||
|
|
||||||
let xpLevel: ResolvedLevel = {
|
let xpLevel: ResolvedLevel = {
|
||||||
|
|
@ -156,6 +160,8 @@ export default async function RootLayout(props: { children: React.ReactNode }) {
|
||||||
<VersionOverlay canSeeCommit={await isSuperuser(payload, user)} />
|
<VersionOverlay canSeeCommit={await isSuperuser(payload, user)} />
|
||||||
)}
|
)}
|
||||||
{user && <GameTickRealtime />}
|
{user && <GameTickRealtime />}
|
||||||
|
{user && <SessionWatchdog sessionExp={sessionExp} />}
|
||||||
|
{user && <MissionReminderToasts />}
|
||||||
{isLogistics && <ShipmentToasts />}
|
{isLogistics && <ShipmentToasts />}
|
||||||
<Toaster />
|
<Toaster />
|
||||||
<ShimLoader shims={shimEntries} userId={user?.id ?? null} userRoles={user?.roles ?? []} />
|
<ShimLoader shims={shimEntries} userId={user?.id ?? null} userRoles={user?.roles ?? []} />
|
||||||
|
|
|
||||||
|
|
@ -131,6 +131,7 @@ export const Users: CollectionConfig = {
|
||||||
group: "Users",
|
group: "Users",
|
||||||
},
|
},
|
||||||
auth: {
|
auth: {
|
||||||
|
tokenExpiration: 43200,
|
||||||
loginWithUsername: {
|
loginWithUsername: {
|
||||||
requireUsername: true,
|
requireUsername: true,
|
||||||
allowEmailLogin: false,
|
allowEmailLogin: false,
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,8 @@ import { Input } from "@/components/ui/input";
|
||||||
import { Switch } from "@/components/ui/switch";
|
import { Switch } from "@/components/ui/switch";
|
||||||
import { submitEodResult, type EodDifficulty, type EodStats } from "@/app/(frontend)/eod/actions";
|
import { submitEodResult, type EodDifficulty, type EodStats } from "@/app/(frontend)/eod/actions";
|
||||||
import { PROFILE_UPDATE_EVENT } from "@/hooks/useGameTick";
|
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 { cn } from "@/lib/utils";
|
||||||
import { playClear, playExplosion, playFlag, playReveal, playXp } from "./sounds";
|
import { playClear, playExplosion, playFlag, playReveal, playXp } from "./sounds";
|
||||||
|
|
||||||
|
|
@ -182,6 +184,7 @@ export function MinesweeperBoard({
|
||||||
onDifficultyChange: (difficulty: EodDifficulty) => void;
|
onDifficultyChange: (difficulty: EodDifficulty) => void;
|
||||||
}) {
|
}) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
const { expired, expiredRef } = useSessionExpired();
|
||||||
const [difficulty, setDifficulty] = useState<GameMode>("training");
|
const [difficulty, setDifficulty] = useState<GameMode>("training");
|
||||||
const [customConfig, setCustomConfig] = useState<GameConfig>({
|
const [customConfig, setCustomConfig] = useState<GameConfig>({
|
||||||
label: "Custom Minefield",
|
label: "Custom Minefield",
|
||||||
|
|
@ -240,6 +243,7 @@ export function MinesweeperBoard({
|
||||||
const reset = useCallback(
|
const reset = useCallback(
|
||||||
(nextDifficulty: GameMode = difficulty) => {
|
(nextDifficulty: GameMode = difficulty) => {
|
||||||
if (submittingRef.current) return;
|
if (submittingRef.current) return;
|
||||||
|
if (expiredRef.current) return;
|
||||||
const nextConfig = nextDifficulty === "custom" ? customConfig : DIFFICULTIES[nextDifficulty];
|
const nextConfig = nextDifficulty === "custom" ? customConfig : DIFFICULTIES[nextDifficulty];
|
||||||
setDifficulty(nextDifficulty);
|
setDifficulty(nextDifficulty);
|
||||||
if (nextDifficulty !== "custom") onDifficultyChange(nextDifficulty);
|
if (nextDifficulty !== "custom") onDifficultyChange(nextDifficulty);
|
||||||
|
|
@ -249,7 +253,7 @@ export function MinesweeperBoard({
|
||||||
setLastRun(null);
|
setLastRun(null);
|
||||||
setPhaseSafe("ready");
|
setPhaseSafe("ready");
|
||||||
},
|
},
|
||||||
[customConfig, difficulty, onDifficultyChange, setPhaseSafe],
|
[customConfig, difficulty, expiredRef, onDifficultyChange, setPhaseSafe],
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -284,6 +288,17 @@ export function MinesweeperBoard({
|
||||||
const recordResult = useCallback(
|
const recordResult = useCallback(
|
||||||
async (won: boolean, elapsedSeconds: number, flags: number) => {
|
async (won: boolean, elapsedSeconds: number, flags: number) => {
|
||||||
if (submittingRef.current) return;
|
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") {
|
if (difficulty === "custom") {
|
||||||
setLastRun({
|
setLastRun({
|
||||||
won,
|
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(
|
const finishGame = useCallback(
|
||||||
|
|
@ -366,6 +381,7 @@ export function MinesweeperBoard({
|
||||||
const reveal = useCallback(
|
const reveal = useCallback(
|
||||||
(index: number) => {
|
(index: number) => {
|
||||||
if (phase === "over" || submittingRef.current) return;
|
if (phase === "over" || submittingRef.current) return;
|
||||||
|
if (expiredRef.current) return;
|
||||||
const existing = board[index];
|
const existing = board[index];
|
||||||
if (!existing || existing.flagged || existing.revealed) return;
|
if (!existing || existing.flagged || existing.revealed) return;
|
||||||
const workingBoard = generatedRef.current ? board : createBoard(config, index);
|
const workingBoard = generatedRef.current ? board : createBoard(config, index);
|
||||||
|
|
@ -387,12 +403,13 @@ export function MinesweeperBoard({
|
||||||
setBoard(nextBoard);
|
setBoard(nextBoard);
|
||||||
if (nextBoard.every((item) => item.mine || item.revealed)) finishGame(true, 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(
|
const toggleFlag = useCallback(
|
||||||
(index: number) => {
|
(index: number) => {
|
||||||
if (phase === "over" || submittingRef.current) return;
|
if (phase === "over" || submittingRef.current) return;
|
||||||
|
if (expiredRef.current) return;
|
||||||
const cell = board[index];
|
const cell = board[index];
|
||||||
if (!cell || cell.revealed) return;
|
if (!cell || cell.revealed) return;
|
||||||
playFlag();
|
playFlag();
|
||||||
|
|
@ -402,7 +419,7 @@ export function MinesweeperBoard({
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
[board, phase],
|
[board, expiredRef, phase],
|
||||||
);
|
);
|
||||||
|
|
||||||
const clearLongPress = useCallback(() => {
|
const clearLongPress = useCallback(() => {
|
||||||
|
|
@ -450,7 +467,7 @@ export function MinesweeperBoard({
|
||||||
aria-label={cellLabel(cell, row, col)}
|
aria-label={cellLabel(cell, row, col)}
|
||||||
aria-rowindex={row + 1}
|
aria-rowindex={row + 1}
|
||||||
aria-colindex={col + 1}
|
aria-colindex={col + 1}
|
||||||
disabled={phase === "over" || cell.revealed}
|
disabled={phase === "over" || expired || cell.revealed}
|
||||||
className={cn(
|
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",
|
"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 &&
|
cell.revealed &&
|
||||||
|
|
@ -516,6 +533,21 @@ export function MinesweeperBoard({
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex w-full max-w-5xl flex-col gap-4">
|
<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 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-wrap items-start justify-between gap-4">
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1">
|
||||||
|
|
@ -567,7 +599,7 @@ export function MinesweeperBoard({
|
||||||
type="button"
|
type="button"
|
||||||
variant={difficulty === key ? "default" : "outline"}
|
variant={difficulty === key ? "default" : "outline"}
|
||||||
className="h-auto justify-start px-3 py-2 text-left"
|
className="h-auto justify-start px-3 py-2 text-left"
|
||||||
disabled={submitting}
|
disabled={submitting || expired}
|
||||||
aria-pressed={difficulty === key}
|
aria-pressed={difficulty === key}
|
||||||
onClick={() => reset(key)}
|
onClick={() => reset(key)}
|
||||||
>
|
>
|
||||||
|
|
@ -676,7 +708,7 @@ export function MinesweeperBoard({
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant={difficulty === "custom" ? "default" : "outline"}
|
variant={difficulty === "custom" ? "default" : "outline"}
|
||||||
disabled={submitting}
|
disabled={submitting || expired}
|
||||||
aria-pressed={difficulty === "custom"}
|
aria-pressed={difficulty === "custom"}
|
||||||
onClick={() => reset("custom")}
|
onClick={() => reset("custom")}
|
||||||
>
|
>
|
||||||
|
|
@ -731,7 +763,7 @@ export function MinesweeperBoard({
|
||||||
type="button"
|
type="button"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
disabled={submitting}
|
disabled={submitting || expired}
|
||||||
onClick={() => reset()}
|
onClick={() => reset()}
|
||||||
>
|
>
|
||||||
<RotateCcw /> Retry sector
|
<RotateCcw /> Retry sector
|
||||||
|
|
@ -759,7 +791,7 @@ export function MinesweeperBoard({
|
||||||
aria-label={cellLabel(cell, row, col)}
|
aria-label={cellLabel(cell, row, col)}
|
||||||
aria-rowindex={row + 1}
|
aria-rowindex={row + 1}
|
||||||
aria-colindex={col + 1}
|
aria-colindex={col + 1}
|
||||||
disabled={phase === "over" || cell.revealed}
|
disabled={phase === "over" || expired || cell.revealed}
|
||||||
className={cn(
|
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",
|
"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 &&
|
cell.revealed &&
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,9 @@ import { useRouter } from "next/navigation";
|
||||||
|
|
||||||
import { type FlappyStats, submitFlappyScore } from "@/app/(frontend)/flappy/actions";
|
import { type FlappyStats, submitFlappyScore } from "@/app/(frontend)/flappy/actions";
|
||||||
import { PROFILE_UPDATE_EVENT } from "@/hooks/useGameTick";
|
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";
|
import { playFlap, playHit, playXp } from "./sounds";
|
||||||
|
|
||||||
const CANVAS_W = 420;
|
const CANVAS_W = 420;
|
||||||
|
|
@ -333,6 +336,7 @@ export function FlappyBird({
|
||||||
onPhaseChange?: (phase: Phase) => void;
|
onPhaseChange?: (phase: Phase) => void;
|
||||||
}) {
|
}) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
const { expired, expiredRef } = useSessionExpired();
|
||||||
const canvasRef = useRef<HTMLCanvasElement | null>(null);
|
const canvasRef = useRef<HTMLCanvasElement | null>(null);
|
||||||
const wrapperRef = useRef<HTMLDivElement | null>(null);
|
const wrapperRef = useRef<HTMLDivElement | null>(null);
|
||||||
const phaseRef = useRef<Phase>("ready");
|
const phaseRef = useRef<Phase>("ready");
|
||||||
|
|
@ -367,6 +371,7 @@ export function FlappyBird({
|
||||||
}, [onPhaseChange]);
|
}, [onPhaseChange]);
|
||||||
|
|
||||||
const reset = useCallback(() => {
|
const reset = useCallback(() => {
|
||||||
|
if (expiredRef.current) return;
|
||||||
const g = gameRef.current;
|
const g = gameRef.current;
|
||||||
g.birdY = START_Y;
|
g.birdY = START_Y;
|
||||||
g.birdVy = 0;
|
g.birdVy = 0;
|
||||||
|
|
@ -383,6 +388,15 @@ export function FlappyBird({
|
||||||
const handleGameOver = useCallback(
|
const handleGameOver = useCallback(
|
||||||
async (finalScore: number) => {
|
async (finalScore: number) => {
|
||||||
if (submittingRef.current) return;
|
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) {
|
if (finalScore <= 0) {
|
||||||
setLastRun({
|
setLastRun({
|
||||||
score: finalScore,
|
score: finalScore,
|
||||||
|
|
@ -423,7 +437,7 @@ export function FlappyBird({
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[onGameRecorded, router],
|
[expiredRef, onGameRecorded, router],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleGameOverRef = useRef(handleGameOver);
|
const handleGameOverRef = useRef(handleGameOver);
|
||||||
|
|
@ -432,6 +446,7 @@ export function FlappyBird({
|
||||||
}, [handleGameOver]);
|
}, [handleGameOver]);
|
||||||
|
|
||||||
const flap = useCallback(() => {
|
const flap = useCallback(() => {
|
||||||
|
if (expiredRef.current) return;
|
||||||
const p = phaseRef.current;
|
const p = phaseRef.current;
|
||||||
if (p === "ready") {
|
if (p === "ready") {
|
||||||
const g = gameRef.current;
|
const g = gameRef.current;
|
||||||
|
|
@ -445,7 +460,7 @@ export function FlappyBird({
|
||||||
} else if (p === "over") {
|
} else if (p === "over") {
|
||||||
reset();
|
reset();
|
||||||
}
|
}
|
||||||
}, [reset, setPhaseSafe]);
|
}, [expiredRef, reset, setPhaseSafe]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const canvas = canvasRef.current;
|
const canvas = canvasRef.current;
|
||||||
|
|
@ -716,6 +731,22 @@ export function FlappyBird({
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</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>
|
||||||
|
|
||||||
<div className="flex flex-wrap justify-center gap-6 text-sm text-muted-foreground">
|
<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 PROFILE_UPDATE_EVENT = "ptf:profile-update";
|
||||||
export const GAME_EVENT_EVENT = "ptf:game-event";
|
export const GAME_EVENT_EVENT = "ptf:game-event";
|
||||||
export const NOTIFICATION_EVENT = "ptf:notification";
|
export const NOTIFICATION_EVENT = "ptf:notification";
|
||||||
|
export const SESSION_EXPIRED_EVENT = "ptf:session-expired";
|
||||||
|
|
||||||
export interface GameTickDetail {
|
export interface GameTickDetail {
|
||||||
processedAt?: string;
|
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