v1.10.0: top-bar Log out (ISS-157) + Admin button gated to admins (ISS-158)
CI / build + unit + e2e (pull_request) Successful in 6m18s
CI / build + unit + e2e (pull_request) Successful in 6m18s
This commit is contained in:
@@ -1,5 +1,10 @@
|
|||||||
# Changelog
|
# 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
|
## 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.
|
- **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.
|
||||||
|
|
||||||
|
|||||||
+14
-6
@@ -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
|
// the sign-in screen or the game shell. The shell only mounts once authenticated, so its state
|
||||||
// feed never polls an unauthenticated /api/state.
|
// feed never polls an unauthenticated /api/state.
|
||||||
const [authed, setAuthed] = useState<boolean | null>(null);
|
const [authed, setAuthed] = useState<boolean | null>(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(() => {
|
useEffect(() => {
|
||||||
const sid = storedSession();
|
const sid = storedSession();
|
||||||
@@ -28,9 +33,10 @@ export default function Home() {
|
|||||||
}
|
}
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
fetch('/api/me', { headers: sessionHeaders(sid), cache: 'no-store' })
|
fetch('/api/me', { headers: sessionHeaders(sid), cache: 'no-store' })
|
||||||
.then((res) => {
|
.then(async (res) => {
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
if (!res.ok) clearStoredSession(); // stale/expired id — drop it and show sign-in
|
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);
|
setAuthed(res.ok);
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
@@ -39,7 +45,7 @@ export default function Home() {
|
|||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
};
|
};
|
||||||
}, []);
|
}, [probeKey]);
|
||||||
|
|
||||||
if (authed === null) {
|
if (authed === null) {
|
||||||
return (
|
return (
|
||||||
@@ -51,14 +57,16 @@ export default function Home() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (!authed) {
|
if (!authed) {
|
||||||
return <SignInScreen onAuthed={() => 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 <SignInScreen onAuthed={() => setProbeKey((k) => k + 1)} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
// The admin panel's Log out cleared the session — drop back to sign-in (ISS-107).
|
// The admin panel's Log out cleared the session — drop back to sign-in (ISS-107).
|
||||||
return <AppShell onLogout={() => setAuthed(false)} />;
|
return <AppShell onLogout={() => 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
|
// 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)
|
// 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.
|
// 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;
|
{/* 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
|
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 */}
|
action response into the shared state, exactly like RightPane does */}
|
||||||
<Map state={state} hovered={hovered} applySnapshot={applySnapshot} onLogout={onLogout} />
|
<Map state={state} hovered={hovered} applySnapshot={applySnapshot} onLogout={onLogout} isAdmin={isAdmin} />
|
||||||
<RightPane state={state} loading={loading} applySnapshot={applySnapshot} onHover={setHovered} onError={onError} />
|
<RightPane state={state} loading={loading} applySnapshot={applySnapshot} onHover={setHovered} onError={onError} />
|
||||||
<ToastStack toasts={toasts} onDismiss={dismiss} />
|
<ToastStack toasts={toasts} onDismiss={dismiss} />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+20
-3
@@ -14,7 +14,7 @@ import { approxLocalTime } from '../lib/localTime';
|
|||||||
import Modal from './Modal';
|
import Modal from './Modal';
|
||||||
import AdminPanel from './AdminPanel';
|
import AdminPanel from './AdminPanel';
|
||||||
import Leaderboard from './Leaderboard';
|
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
|
// 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,
|
// 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
|
// 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.
|
// to the sign-in screen instead of leaving a signed-out user staring at a live dashboard.
|
||||||
onLogout?: () => void;
|
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
|
// 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.
|
// 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<HTMLDivElement>(null);
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
const mapRef = useRef<maplibregl.Map | null>(null);
|
const mapRef = useRef<maplibregl.Map | null>(null);
|
||||||
// /api/airports is fetched at most once per page life: every styledata event
|
// /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)));
|
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 (
|
return (
|
||||||
<div className="map-wrap">
|
<div className="map-wrap">
|
||||||
<header data-testid="app-header" className="app-header">
|
<header data-testid="app-header" className="app-header">
|
||||||
@@ -596,10 +606,17 @@ export default function FlightMap({ state, hovered, applySnapshot, onLogout }: M
|
|||||||
<button type="button" data-testid="leaderboard-btn" className="pane-btn" onClick={() => setLeaderboardOpen(true)}>
|
<button type="button" data-testid="leaderboard-btn" className="pane-btn" onClick={() => setLeaderboardOpen(true)}>
|
||||||
Leaderboard
|
Leaderboard
|
||||||
</button>
|
</button>
|
||||||
{/* v0.71.0: admin panel entry point — right side of the top bar, opens a modal (no URL). */}
|
{/* v0.71.0: admin panel entry point — right side of the top bar, opens a modal (no URL).
|
||||||
|
v1.10.0 (ISS-158): only for admins (/api/me `admin` flag) — hidden entirely otherwise. */}
|
||||||
|
{isAdmin && (
|
||||||
<button type="button" data-testid="admin-btn" className="pane-btn" onClick={() => setAdminOpen(true)}>
|
<button type="button" data-testid="admin-btn" className="pane-btn" onClick={() => setAdminOpen(true)}>
|
||||||
Admin
|
Admin
|
||||||
</button>
|
</button>
|
||||||
|
)}
|
||||||
|
{/* v1.10.0 (ISS-157): Log out — last child, so the flex row puts it top-right of the bar. */}
|
||||||
|
<button type="button" data-testid="logout-btn" className="pane-btn" onClick={() => void onTopBarLogout()}>
|
||||||
|
Log out
|
||||||
|
</button>
|
||||||
</header>
|
</header>
|
||||||
<div ref={containerRef} data-testid="map" className="map-container" />
|
<div ref={containerRef} data-testid="map" className="map-container" />
|
||||||
<Modal open={adminOpen} onClose={() => setAdminOpen(false)} title="Admin">
|
<Modal open={adminOpen} onClose={() => setAdminOpen(false)} title="Admin">
|
||||||
|
|||||||
@@ -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 }) => {
|
test('clicking an airport marker opens a popup with name and ICAO', async ({ page }) => {
|
||||||
await page.goto(APP_URL);
|
await page.goto(APP_URL);
|
||||||
await waitForAirportCount(page);
|
await waitForAirportCount(page);
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "flight-ops",
|
"name": "flight-ops",
|
||||||
"version": "1.9.0",
|
"version": "1.10.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev -p 3000",
|
"dev": "next dev -p 3000",
|
||||||
|
|||||||
Reference in New Issue
Block a user