From cde4334946ffa876cd856f5a96f09e9fd9a71aae Mon Sep 17 00:00:00 2001 From: Kamil Krzywda Date: Sun, 27 Sep 2026 22:31:11 +0000 Subject: [PATCH] v1.10.0: top-bar Log out (ISS-157) + Admin button gated to admins (ISS-158) --- CHANGELOG.md | 5 +++++ app/page.tsx | 20 ++++++++++++++------ components/Map.tsx | 27 ++++++++++++++++++++++----- e2e/map.spec.ts | 11 +++++++++++ package.json | 2 +- 5 files changed, 53 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 86b5f66..4cafd0b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,10 @@ # Changelog +## v1.10.0 — 2026-09-27 22:31 +- **Top-bar Log out (ISS-157).** New `Log out` button as the last child of the top bar (`components/Map.tsx`) — flex row puts it top-right, no CSS. Same three steps as the admin panel's in-modal button: best-effort `POST /api/logout` with the session header, clear the stored session id, drop back to the sign-in screen via the existing `onLogout`. The two in-modal Log out buttons are kept (zero e2e churn). +- **Admin button only for admins (ISS-158).** The top-bar `Admin` button now renders only when `/api/me` reports `admin: true` — hidden entirely, not disabled. No server change: the endpoint already returns `{username, admin}`; `app/page.tsx` now parses that body on its mount probe and passes `isAdmin` down AppShell → Map. The probe also re-runs after a form sign-in (bumped `probeKey`) so `isAdmin` tracks the user who just signed in, not whoever was probed before — an admin logging out followed by a non-admin signing in no longer sees the Admin button. +- **E2E.** New test in `e2e/map.spec.ts`: top-bar Log out → sign-in form visible and app header gone (mirrors the existing admin-panel logout assertion). + ## v1.9.0 — 2026-09-27 20:57 - **E2E speedup follow-up (test-only, no game code changed).** `e2e/relocation.spec.ts`'s self-heal helper `flyHomeViaApi` now fast-forwards its cleanup legs at the v1.8.0 `FAST = 10_000` rate instead of a blanket `SCALE = 1000` — every scale-dependent wait in that file is a pure arrival wait (status `'available'` / `locationIcao === HUB`) with no mid-leg observation, so the higher rate is safe and the now-unused `SCALE` constant was dropped. Expected CI wall time for the file drops from ~40 s to ~25 s (~2–3 s off test 1, ~10–11 s each off tests 2 and 3). Test 3's deliberate `setScale(page, 1)` guard is untouched; `map.spec.ts` wheel-zoom (real-time-bound) and `multiplayer.spec.ts`'s N-player clock (measures sim-vs-real elapsed time) stay as-is. diff --git a/app/page.tsx b/app/page.tsx index b2cde0c..f5cc0f5 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -19,6 +19,11 @@ export default function Home() { // the sign-in screen or the game shell. The shell only mounts once authenticated, so its state // feed never polls an unauthenticated /api/state. const [authed, setAuthed] = useState(null); + // v1.10.0 (ISS-158): /api/me's `admin` flag — gates the top-bar Admin button in Map. + const [isAdmin, setIsAdmin] = useState(false); + // Bumped after a form sign-in so the probe below re-runs for the NEW session — isAdmin must + // track whoever just signed in, not the user probed on mount (or before the last logout). + const [probeKey, setProbeKey] = useState(0); useEffect(() => { const sid = storedSession(); @@ -28,9 +33,10 @@ export default function Home() { } let cancelled = false; fetch('/api/me', { headers: sessionHeaders(sid), cache: 'no-store' }) - .then((res) => { + .then(async (res) => { if (cancelled) return; if (!res.ok) clearStoredSession(); // stale/expired id — drop it and show sign-in + else setIsAdmin(Boolean(((await res.json()) as { admin?: boolean }).admin)); // v1.10.0 (ISS-158) setAuthed(res.ok); }) .catch(() => { @@ -39,7 +45,7 @@ export default function Home() { return () => { cancelled = true; }; - }, []); + }, [probeKey]); if (authed === null) { return ( @@ -51,14 +57,16 @@ export default function Home() { ); } if (!authed) { - return setAuthed(true)} />; + // v1.10.0 (ISS-158): re-probe /api/me for the new session instead of blindly flipping authed — + // isAdmin has to come from the user who just signed in. + return setProbeKey((k) => k + 1)} />; } // The admin panel's Log out cleared the session — drop back to sign-in (ISS-107). - return setAuthed(false)} />; + return setAuthed(false)} isAdmin={isAdmin} />; } -function AppShell({ onLogout }: { onLogout: () => void }) { +function AppShell({ onLogout, isAdmin }: { onLogout: () => void; isAdmin: boolean }) { // Single polling source for the whole shell (v0.9.0): one /api/state loop, snapshot passed // down as props so later panes can share it without extra poll loops. applySnapshot (v0.10.0) // lets a pane push an action endpoint's full response snapshot in immediately. @@ -95,7 +103,7 @@ function AppShell({ onLogout }: { onLogout: () => void }) { {/* v0.11.0 — the same polled snapshot drives the map's plane markers + route lines; ISS-010 (v0.18.0) — applySnapshot lets the airport popup's Relocate row push its action response into the shared state, exactly like RightPane does */} - + diff --git a/components/Map.tsx b/components/Map.tsx index 92d8ac3..af33036 100644 --- a/components/Map.tsx +++ b/components/Map.tsx @@ -14,7 +14,7 @@ import { approxLocalTime } from '../lib/localTime'; import Modal from './Modal'; import AdminPanel from './AdminPanel'; import Leaderboard from './Leaderboard'; -import { sessionHeaders } from '../lib/authClient'; +import { clearStoredSession, sessionHeaders } from '../lib/authClient'; // v0.70.0 default view: null island [0,0] at zoom 2 — the world is 2048 px wide there and the // Africa+Europe bounding box (lat −35..+71, lon −18..+52) spans ~800 px vertically in Web Mercator, @@ -74,11 +74,13 @@ interface MapProps { // v1.1.0 (ISS-107): the admin panel's Log out cleared the session — tell the shell to drop back // to the sign-in screen instead of leaving a signed-out user staring at a live dashboard. onLogout?: () => void; + // v1.10.0 (ISS-158): /api/me says this session is an admin → show the top-bar Admin button. + isAdmin?: boolean; } // Named FlightMap (not Map): a component called `Map` would shadow the global // Map constructor inside its own body, breaking `new Map(...)` for the airport index. -export default function FlightMap({ state, hovered, applySnapshot, onLogout }: MapProps) { +export default function FlightMap({ state, hovered, applySnapshot, onLogout, isAdmin = false }: MapProps) { const containerRef = useRef(null); const mapRef = useRef(null); // /api/airports is fetched at most once per page life: every styledata event @@ -569,6 +571,14 @@ export default function FlightMap({ state, hovered, applySnapshot, onLogout }: M void localAssetsAvailable().then((available) => mapRef.current?.setStyle(styleUrlFor(id, available))); }; + // v1.10.0 (ISS-157): top-bar Log out — same three steps as the admin panel's button: best-effort + // server-side session kill, drop the stored id, tell the shell to show sign-in again. + const onTopBarLogout = async () => { + await fetch('/api/logout', { method: 'POST', headers: sessionHeaders() }).catch(() => undefined); + clearStoredSession(); + onLogout?.(); + }; + return (
@@ -596,9 +606,16 @@ export default function FlightMap({ state, hovered, applySnapshot, onLogout }: M - {/* v0.71.0: admin panel entry point — right side of the top bar, opens a modal (no URL). */} - + )} + {/* v1.10.0 (ISS-157): Log out — last child, so the flex row puts it top-right of the bar. */} +
diff --git a/e2e/map.spec.ts b/e2e/map.spec.ts index 6f82a3e..5b11d1b 100644 --- a/e2e/map.spec.ts +++ b/e2e/map.spec.ts @@ -78,6 +78,17 @@ test('header shows the app version sourced from package.json', async ({ page }) ); }); +// v1.10.0 (ISS-157): top-bar Log out — same drop-to-sign-in as the admin panel's button +// (mirrors e2e/admin.spec.ts' logout assertion). +test('top bar Log out drops back to the sign-in screen', async ({ page }) => { + await page.goto(APP_URL); + const header = page.getByTestId('app-header'); + await expect(header).toBeVisible({ timeout: 30_000 }); + await page.getByTestId('logout-btn').click(); + await expect(page.getByTestId('sign-in-form')).toBeVisible({ timeout: 10_000 }); + await expect(header).toHaveCount(0); +}); + test('clicking an airport marker opens a popup with name and ICAO', async ({ page }) => { await page.goto(APP_URL); await waitForAirportCount(page); diff --git a/package.json b/package.json index 72c1ac9..ecbae6b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "flight-ops", - "version": "1.9.0", + "version": "1.10.0", "private": true, "scripts": { "dev": "next dev -p 3000", -- 2.54.0