diff --git a/src/app/(frontend)/layout.tsx b/src/app/(frontend)/layout.tsx index 3169092..6f025af 100644 --- a/src/app/(frontend)/layout.tsx +++ b/src/app/(frontend)/layout.tsx @@ -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 }) { } > + {user && } {impersonating && } {user && ( Date.now()); + const [dismissed, setDismissed] = useState(false); + const [extending, setExtending] = useState(false); + const [error, setError] = useState(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 ( +
+
+
+
+ + +
+ {error &&

{error}

} +
+ ); +} diff --git a/src/hooks/useGameTick.ts b/src/hooks/useGameTick.ts index 7bdd047..61de5eb 100644 --- a/src/hooks/useGameTick.ts +++ b/src/hooks/useGameTick.ts @@ -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"; diff --git a/src/hooks/useSessionExpired.ts b/src/hooks/useSessionExpired.ts index cce9e37..38f70c1 100644 --- a/src/hooks/useSessionExpired.ts +++ b/src/hooks/useSessionExpired.ts @@ -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); }; }, []); diff --git a/src/lib/session/countdown.ts b/src/lib/session/countdown.ts new file mode 100644 index 0000000..c6af7fe --- /dev/null +++ b/src/lib/session/countdown.ts @@ -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}`; +} diff --git a/tests/e2e/banner_check.spec.mjs b/tests/e2e/banner_check.spec.mjs new file mode 100644 index 0000000..3ed36c7 --- /dev/null +++ b/tests/e2e/banner_check.spec.mjs @@ -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"); +}); diff --git a/tests/int/session-countdown.int.spec.ts b/tests/int/session-countdown.int.spec.ts new file mode 100644 index 0000000..fdc2482 --- /dev/null +++ b/tests/int/session-countdown.int.spec.ts @@ -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"); + }); +});