v1.1.0: session guard on all game routes + register + sign-in screen (ISS-107)
CI / build + unit + e2e (pull_request) Failing after 26m20s

This commit is contained in:
2026-09-26 14:40:56 +00:00
parent 21dd37f88c
commit b0f1bd8b67
54 changed files with 1002 additions and 91 deletions
+9 -20
View File
@@ -1,13 +1,12 @@
'use client';
import { useCallback, useEffect, useState } from 'react';
import { clearStoredSession, sessionHeaders, storedSession, storeSession } from '../lib/authClient';
// v0.71.0 — content of the Admin modal (opened by the top-bar "Admin" button). Three views:
// login form (no valid session), a "not an admin" notice, or the generic whole-database CRUD
// grid behind /api/admin/*. This is a dev tool surface — plain and functional on purpose.
const SESSION_STORAGE_KEY = 'flight.session';
interface Me {
username: string;
admin: boolean;
@@ -18,15 +17,7 @@ interface RowsData {
rows: Array<Record<string, unknown>>;
}
function readSession(): string | null {
try {
return window.localStorage.getItem(SESSION_STORAGE_KEY);
} catch {
return null;
}
}
export default function AdminPanel() {
export default function AdminPanel({ onLogout }: { onLogout?: () => void }) {
const [session, setSession] = useState<string | null>(null);
const [me, setMe] = useState<Me | null>(null); // null while checking /api/me on mount
const [checking, setChecking] = useState(true);
@@ -45,15 +36,12 @@ export default function AdminPanel() {
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const authHeaders = useCallback(
(s: string) => ({ 'X-Flight-Session': s }),
[],
);
const authHeaders = useCallback((s: string) => sessionHeaders(s), []);
// On mount: a stored session decides between the grid and the login form.
useEffect(() => {
let cancelled = false;
const stored = readSession();
const stored = storedSession();
if (!stored) {
setChecking(false);
return;
@@ -61,7 +49,7 @@ export default function AdminPanel() {
fetch('/api/me', { headers: authHeaders(stored) })
.then(async (res) => {
if (!res.ok || cancelled) {
window.localStorage.removeItem(SESSION_STORAGE_KEY);
clearStoredSession();
setSession(null);
return;
}
@@ -71,7 +59,7 @@ export default function AdminPanel() {
})
.catch(() => {
if (!cancelled) {
window.localStorage.removeItem(SESSION_STORAGE_KEY);
clearStoredSession();
setSession(null);
}
})
@@ -161,7 +149,7 @@ export default function AdminPanel() {
return;
}
const body = (await res.json()) as { sessionId: string; user: Me };
window.localStorage.setItem(SESSION_STORAGE_KEY, body.sessionId);
storeSession(body.sessionId);
setSession(body.sessionId);
setMe(body.user);
setUsername('');
@@ -170,7 +158,8 @@ export default function AdminPanel() {
const logout = async () => {
if (session) await fetch('/api/logout', { method: 'POST', headers: authHeaders(session) }).catch(() => undefined);
window.localStorage.removeItem(SESSION_STORAGE_KEY);
clearStoredSession();
onLogout?.(); // v1.1.0 (ISS-107): session gone → shell drops back to the sign-in screen
setSession(null);
setMe(null);
setTable(null);
+8 -3
View File
@@ -13,6 +13,7 @@ import { weatherFor } from '../lib/weather';
import { approxLocalTime } from '../lib/localTime';
import Modal from './Modal';
import AdminPanel from './AdminPanel';
import { 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,
@@ -69,11 +70,14 @@ interface MapProps {
// ISS-010 (v0.18.0): push an action endpoint's full response snapshot into the shared
// shell state — same callback RightPane receives, used by the airport popup's Relocate row.
applySnapshot: (snapshot: StateSnapshot) => void;
// 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;
}
// 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 }: MapProps) {
export default function FlightMap({ state, hovered, applySnapshot, onLogout }: MapProps) {
const containerRef = useRef<HTMLDivElement>(null);
const mapRef = useRef<maplibregl.Map | null>(null);
// /api/airports is fetched at most once per page life: every styledata event
@@ -295,7 +299,8 @@ export default function FlightMap({ state, hovered, applySnapshot }: MapProps) {
const action = planeStatus === 'available' ? 'fly' : 'queue';
const res = await fetch(`/api/planes/${planeId}/${action}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
// v1.1.0 (ISS-107): fly/queue are session-guarded — echo the stored id.
headers: { 'Content-Type': 'application/json', ...sessionHeaders() },
body: JSON.stringify({ destIcao: p.icao }),
cache: 'no-store',
});
@@ -591,7 +596,7 @@ export default function FlightMap({ state, hovered, applySnapshot }: MapProps) {
</header>
<div ref={containerRef} data-testid="map" className="map-container" />
<Modal open={adminOpen} onClose={() => setAdminOpen(false)} title="Admin">
<AdminPanel />
<AdminPanel onLogout={onLogout} />
</Modal>
</div>
);
+78
View File
@@ -0,0 +1,78 @@
'use client';
import { useState } from 'react';
import { storeSession } from '@/lib/authClient';
// v1.1.0 (ISS-107) — full-screen sign-in / create-account screen shown when no valid session is
// stored. Both forms POST the same shape ({username, password}); login mints a session for an
// existing account, register creates one first and then mints it. On success the sessionId goes
// to localStorage (via storeSession) and onAuthed() flips the shell back in.
interface SignInScreenProps {
onAuthed: () => void;
}
export default function SignInScreen({ onAuthed }: SignInScreenProps) {
const [error, setError] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
async function submit(e: React.FormEvent<HTMLFormElement>, path: '/api/login' | '/api/register') {
e.preventDefault();
if (busy) return;
setBusy(true);
setError(null);
try {
const form = new FormData(e.currentTarget);
const res = await fetch(path, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
username: String(form.get('username') ?? ''),
password: String(form.get('password') ?? ''),
}),
cache: 'no-store',
});
const data = (await res.json()) as { sessionId?: string; error?: string };
if (!res.ok || !data.sessionId) throw new Error(data.error ?? `HTTP ${res.status}`);
storeSession(data.sessionId);
onAuthed();
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
setBusy(false);
}
}
return (
<div className="sign-in">
<h1 data-testid="app-title">Flight Ops</h1>
{error !== null && (
<p data-testid="auth-error" role="alert" className="sign-in-error">
{error}
</p>
)}
<form data-testid="sign-in-form" onSubmit={(e) => void submit(e, '/api/login')}>
<label htmlFor="sign-in-username">Username</label>
<input id="sign-in-username" name="username" autoComplete="username" required />
<label htmlFor="sign-in-password">Password</label>
<input id="sign-in-password" name="password" type="password" autoComplete="current-password" required />
<button type="submit" data-testid="sign-in-submit" className="pane-btn" disabled={busy}>
Sign in
</button>
</form>
<p className="sign-in-divider">or</p>
<form data-testid="create-account-form" onSubmit={(e) => void submit(e, '/api/register')}>
<label htmlFor="register-username">Username (3–20 chars: a-z 0-9 _ -)</label>
<input id="register-username" name="username" autoComplete="off" required />
<label htmlFor="register-password">Password (at least 8 characters)</label>
<input id="register-password" name="password" type="password" autoComplete="new-password" required minLength={8} />
<button type="submit" data-testid="register-submit" className="pane-btn" disabled={busy}>
Create account
</button>
</form>
</div>
);
}