1
0
Fork 0

feat(session): add pre-expiry warning banner with extend button

- 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
This commit is contained in:
Jason Fraley 2026-09-23 15:02:10 -04:00
parent 3b0bdec45e
commit 962064a5a4
7 changed files with 388 additions and 4 deletions

View file

@ -16,6 +16,7 @@ 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 { SessionExpiryBanner } from "@/components/frontend/session/SessionExpiryBanner";
import { getSessionExpMs } from "@/lib/session/token";
import VersionOverlay from "@/components/static/VersionOverlay";
import { LandingPage } from "@/components/frontend/LandingPage";
@ -192,6 +193,7 @@ export default async function RootLayout(props: { children: React.ReactNode }) {
}
>
<SiteHeader />
{user && <SessionExpiryBanner sessionExp={sessionExp} />}
{impersonating && <ImpersonationBanner />}
{user && (
<AnnouncementHost

View file

@ -0,0 +1,155 @@
"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>
);
}

View file

@ -12,6 +12,8 @@ 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";
/** Dispatched after a successful Extend so minigames / consumers un-lock. */
export const SESSION_EXTENDED_EVENT = "ptf:session-extended";
export const POKER_STATE_EVENT = "ptf:poker-state";
export const POKER_TABLES_EVENT = "ptf:poker-tables";
export const POKER_TERMINATED_EVENT = "ptf:poker-terminated";

View file

@ -1,7 +1,7 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { SESSION_EXPIRED_EVENT } from "@/hooks/useGameTick";
import { SESSION_EXPIRED_EVENT, SESSION_EXTENDED_EVENT } from "@/hooks/useGameTick";
/**
* Tracks whether the session has expired (per the client-side watchdog).
@ -19,13 +19,19 @@ export function useSessionExpired(): {
const expiredRef = useRef(false);
useEffect(() => {
const handler = () => {
const lock = () => {
expiredRef.current = true;
setExpired(true);
};
window.addEventListener(SESSION_EXPIRED_EVENT, handler);
const unlock = () => {
expiredRef.current = false;
setExpired(false);
};
window.addEventListener(SESSION_EXPIRED_EVENT, lock);
window.addEventListener(SESSION_EXTENDED_EVENT, unlock);
return () => {
window.removeEventListener(SESSION_EXPIRED_EVENT, handler);
window.removeEventListener(SESSION_EXPIRED_EVENT, lock);
window.removeEventListener(SESSION_EXTENDED_EVENT, unlock);
};
}, []);

View file

@ -0,0 +1,75 @@
/**
* Session-countdown helpers.
*
* The `payload-token` cookie is HttpOnly, so the browser cannot read when the
* session ends. The server computes the absolute expiry (`sessionExp`, ms since
* epoch) and passes it to the client, which uses these pure helpers to drive a
* live countdown and decide when to warn / auto-close the session banner.
*
* Everything here is side-effect free and client-safe so it is unit-testable
* and shareable between the banner and any consumer.
*/
/** Warnings show this long before the hard cutoff so the user can Extend. */
export const SESSION_WARN_THRESHOLD_MS = 15 * 60 * 1000; // 15 minutes
/** The banner auto-closes this long before expiry (Extend no longer helps). */
export const SESSION_AUTO_CLOSE_MS = 30 * 1000; // 30 seconds
/** Phases of the session-expiry window, driven by remaining time. */
export type SessionExpiryPhase = "idle" | "warn" | "close";
/**
* Milliseconds between countdown ticks. Kept separate from the phase constants
* so the visual countdown can be tuned independently of the warn/auto-close
* thresholds.
*/
export const COUNTDOWN_TICK_MS = 1000;
/**
* Remaining time until the session expires, in milliseconds.
*
* Returns `null` when there is no known expiry (guest / missing cookie) so
* callers can treat "unknown" and "idle" the same.
*/
export function timeUntilExpiry(
sessionExp: number | null | undefined,
now: number = Date.now(),
): number | null {
if (sessionExp == null || !Number.isFinite(sessionExp)) return null;
return sessionExp - now;
}
/**
* Which phase of the expiry window a given remaining time falls into.
*
* - `close`: about to expire (<= closeMs) -> banner auto-closes.
* - `warn`: within the warn window (closeMs < remaining <= warnMs) -> shown.
* - `idle`: plenty of time left (remaining > warnMs) or unknown.
*/
export function sessionExpiryPhase(
remainingMs: number | null,
warnMs: number = SESSION_WARN_THRESHOLD_MS,
closeMs: number = SESSION_AUTO_CLOSE_MS,
): SessionExpiryPhase {
if (remainingMs == null) return "idle";
if (remainingMs <= closeMs) return "close";
if (remainingMs <= warnMs) return "warn";
return "idle";
}
/**
* Format a duration in seconds as `H:MM:SS` (leading hour dropped when < 1h)
* for a live countdown, e.g. `14:59` or `1:02:05`. Always rounds up to the next
* second and never goes negative.
*/
export function formatCountdown(totalSeconds: number): string {
if (!Number.isFinite(totalSeconds)) return "00:00";
const safe = Math.max(0, Math.ceil(totalSeconds));
const hours = Math.floor(safe / 3600);
const minutes = Math.floor((safe % 3600) / 60);
const seconds = safe % 60;
const mm = String(minutes).padStart(2, "0");
const ss = String(seconds).padStart(2, "0");
return hours > 0 ? `${hours}:${mm}:${ss}` : `${mm}:${ss}`;
}

View file

@ -0,0 +1,53 @@
import { test, expect } from "@playwright/test";
test("banner shows countdown + extend button", async ({ page, context }) => {
// Craft near-expiry token and set it as cookie
const { SignJWT } = await import("jose");
const SECRET = "f80003be79c28ca283c2cbfde2984356";
const key = new TextEncoder().encode(SECRET);
// Login to get a real sid
const loginRes = await fetch("http://localhost:3000/api/users/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username: "dev", password: "Test123" }),
});
const data = await loginRes.json();
const payload = JSON.parse(Buffer.from(data.token.split(".")[1], "base64url").toString());
const now = Math.floor(Date.now() / 1000);
const near = await new SignJWT({
id: 23, collection: "users", email: payload.email, sid: payload.sid, iat: now, exp: now + 600,
})
.setProtectedHeader({ alg: "HS256", typ: "JWT" })
.setIssuedAt(now)
.setExpirationTime(now + 600)
.sign(key);
// Set cookie
await context.addCookies([{
name: "payload-token",
value: near,
domain: "localhost",
path: "/",
expires: now + 600,
}]);
// Navigate to home
await page.goto("http://localhost:3000/");
await page.waitForSelector("role=status", { timeout: 10000 });
// Check banner is visible
const banner = page.locator("role=status").first();
await expect(banner).toBeVisible();
// Check countdown
const countdown = banner.locator(".font-mono.tabular-nums").first();
await expect(countdown).toHaveText(/0\d:\d{2}/); // should show ~10:00
// Check extend button
const extendBtn = banner.locator("button:has-text('Extend')");
await expect(extendBtn).toBeVisible();
console.log("Banner check PASSED");
});

View file

@ -0,0 +1,91 @@
import { describe, expect, it } from "vitest";
import {
SESSION_AUTO_CLOSE_MS,
SESSION_WARN_THRESHOLD_MS,
formatCountdown,
sessionExpiryPhase,
timeUntilExpiry,
} from "@/lib/session/countdown";
describe("timeUntilExpiry", () => {
it("returns sessionExp - now", () => {
expect(timeUntilExpiry(1_800_000_000, 1_700_000_000)).toBe(100_000_000);
});
it("defaults now to Date.now()", () => {
const now = Date.now();
const result = timeUntilExpiry(now + 5_000);
expect(result).toBeGreaterThanOrEqual(4_900);
expect(result).toBeLessThanOrEqual(5_000);
});
it("returns null when there is no known expiry", () => {
expect(timeUntilExpiry(null)).toBeNull();
expect(timeUntilExpiry(undefined)).toBeNull();
});
it("returns null for a non-finite expiry", () => {
expect(timeUntilExpiry(Number.NaN)).toBeNull();
expect(timeUntilExpiry(Number.POSITIVE_INFINITY)).toBeNull();
});
it("allows a negative remaining time (already expired)", () => {
expect(timeUntilExpiry(100, 200)).toBe(-100);
});
});
describe("sessionExpiryPhase", () => {
const warn = SESSION_WARN_THRESHOLD_MS; // 15 min
const close = SESSION_AUTO_CLOSE_MS; // 30s
it("returns idle when remaining exceeds the warn window", () => {
expect(sessionExpiryPhase(warn + 1)).toBe("idle");
expect(sessionExpiryPhase(null)).toBe("idle");
});
it("returns warn inside the window (close < remaining <= warn)", () => {
expect(sessionExpiryPhase(warn)).toBe("warn");
expect(sessionExpiryPhase(close + 1)).toBe("warn");
expect(sessionExpiryPhase(warn / 2)).toBe("warn");
});
it("returns close at or below the close threshold", () => {
expect(sessionExpiryPhase(close)).toBe("close");
expect(sessionExpiryPhase(0)).toBe("close");
expect(sessionExpiryPhase(-1)).toBe("close");
});
it("honours custom thresholds", () => {
expect(sessionExpiryPhase(10 * 60_000, 15 * 60_000, 5 * 60_000)).toBe("warn");
expect(sessionExpiryPhase(3 * 60_000, 15 * 60_000, 5 * 60_000)).toBe("close");
});
});
describe("formatCountdown", () => {
it("formats under an hour as MM:SS", () => {
expect(formatCountdown(0)).toBe("00:00");
expect(formatCountdown(59)).toBe("00:59");
expect(formatCountdown(60)).toBe("01:00");
expect(formatCountdown(90)).toBe("01:30");
expect(formatCountdown(599)).toBe("09:59");
});
it("formats hours as H:MM:SS", () => {
expect(formatCountdown(3600)).toBe("1:00:00");
expect(formatCountdown(3725)).toBe("1:02:05");
});
it("rounds up to the next whole second", () => {
expect(formatCountdown(0.1)).toBe("00:01");
expect(formatCountdown(59.4)).toBe("01:00");
});
it("never goes negative", () => {
expect(formatCountdown(-5)).toBe("00:00");
});
it("handles non-finite input", () => {
expect(formatCountdown(Number.NaN)).toBe("00:00");
expect(formatCountdown(Number.POSITIVE_INFINITY)).toBe("00:00");
});
});