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:
parent
3b0bdec45e
commit
962064a5a4
7 changed files with 388 additions and 4 deletions
|
|
@ -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
|
||||
|
|
|
|||
155
src/components/frontend/session/SessionExpiryBanner.tsx
Normal file
155
src/components/frontend/session/SessionExpiryBanner.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
};
|
||||
}, []);
|
||||
|
||||
|
|
|
|||
75
src/lib/session/countdown.ts
Normal file
75
src/lib/session/countdown.ts
Normal 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}`;
|
||||
}
|
||||
53
tests/e2e/banner_check.spec.mjs
Normal file
53
tests/e2e/banner_check.spec.mjs
Normal 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");
|
||||
});
|
||||
91
tests/int/session-countdown.int.spec.ts
Normal file
91
tests/int/session-countdown.int.spec.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
Loading…
Reference in a new issue