v0.71.0: admin panel — users table, login/sessions, generic DB CRUD modal
CI / build + unit + e2e (pull_request) Failing after 13m35s
CI / build + unit + e2e (pull_request) Failing after 13m35s
This commit is contained in:
@@ -0,0 +1,414 @@
|
||||
'use client';
|
||||
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
|
||||
// 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;
|
||||
}
|
||||
|
||||
interface RowsData {
|
||||
columns: string[];
|
||||
rows: Array<Record<string, unknown>>;
|
||||
}
|
||||
|
||||
function readSession(): string | null {
|
||||
try {
|
||||
return window.localStorage.getItem(SESSION_STORAGE_KEY);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export default function AdminPanel() {
|
||||
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);
|
||||
|
||||
// Login form state.
|
||||
const [username, setUsername] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [loginError, setLoginError] = useState<string | null>(null);
|
||||
|
||||
// Admin grid state.
|
||||
const [tables, setTables] = useState<string[]>([]);
|
||||
const [table, setTable] = useState<string | null>(null);
|
||||
const [rowsData, setRowsData] = useState<RowsData | null>(null);
|
||||
const [edits, setEdits] = useState<Record<string, string>>({}); // key `${rowIdx}:${col}`
|
||||
const [newRow, setNewRow] = useState(false);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const authHeaders = useCallback(
|
||||
(s: string) => ({ 'X-Flight-Session': s }),
|
||||
[],
|
||||
);
|
||||
|
||||
// On mount: a stored session decides between the grid and the login form.
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
const stored = readSession();
|
||||
if (!stored) {
|
||||
setChecking(false);
|
||||
return;
|
||||
}
|
||||
fetch('/api/me', { headers: authHeaders(stored) })
|
||||
.then(async (res) => {
|
||||
if (!res.ok || cancelled) {
|
||||
window.localStorage.removeItem(SESSION_STORAGE_KEY);
|
||||
setSession(null);
|
||||
return;
|
||||
}
|
||||
const body = (await res.json()) as Me;
|
||||
setSession(stored);
|
||||
setMe(body);
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) {
|
||||
window.localStorage.removeItem(SESSION_STORAGE_KEY);
|
||||
setSession(null);
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setChecking(false);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [authHeaders]);
|
||||
|
||||
const loadTables = useCallback(async (s: string) => {
|
||||
const res = await fetch('/api/admin/tables', { headers: authHeaders(s) });
|
||||
if (!res.ok) throw new Error((await res.json().catch(() => null))?.error ?? `HTTP ${res.status}`);
|
||||
setTables(((await res.json()) as Array<{ name: string }>).map((t) => t.name));
|
||||
}, [authHeaders]);
|
||||
|
||||
const loadRows = useCallback(
|
||||
async (s: string, name: string) => {
|
||||
const res = await fetch(`/api/admin/tables/${encodeURIComponent(name)}/rows?limit=500`, { headers: authHeaders(s) });
|
||||
if (!res.ok) throw new Error((await res.json().catch(() => null))?.error ?? `HTTP ${res.status}`);
|
||||
setRowsData(await res.json());
|
||||
},
|
||||
[authHeaders],
|
||||
);
|
||||
|
||||
// Enter the grid once /api/me confirms an admin.
|
||||
useEffect(() => {
|
||||
if (!session || !me?.admin) return;
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
try {
|
||||
await loadTables(session);
|
||||
if (!cancelled) setBusy(false);
|
||||
} catch (err) {
|
||||
if (!cancelled) setError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
if (!cancelled) setBusy(false);
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [session, me, loadTables]);
|
||||
|
||||
const openTable = async (name: string) => {
|
||||
if (!session) return;
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
await loadRows(session, name);
|
||||
setTable(name);
|
||||
setEdits({});
|
||||
setNewRow(false);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const refresh = async () => {
|
||||
if (!session || !table) return;
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
await loadRows(session, table);
|
||||
setEdits({});
|
||||
setNewRow(false);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const login = async () => {
|
||||
if (!username || !password) return;
|
||||
setLoginError(null);
|
||||
const res = await fetch('/api/login', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ username, password }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
setLoginError((await res.json().catch(() => null))?.error ?? `HTTP ${res.status}`);
|
||||
return;
|
||||
}
|
||||
const body = (await res.json()) as { sessionId: string; user: Me };
|
||||
window.localStorage.setItem(SESSION_STORAGE_KEY, body.sessionId);
|
||||
setSession(body.sessionId);
|
||||
setMe(body.user);
|
||||
setUsername('');
|
||||
setPassword('');
|
||||
};
|
||||
|
||||
const logout = async () => {
|
||||
if (session) await fetch('/api/logout', { method: 'POST', headers: authHeaders(session) }).catch(() => undefined);
|
||||
window.localStorage.removeItem(SESSION_STORAGE_KEY);
|
||||
setSession(null);
|
||||
setMe(null);
|
||||
setTable(null);
|
||||
setRowsData(null);
|
||||
setTables([]);
|
||||
setError(null);
|
||||
};
|
||||
|
||||
if (checking) return <div className="admin-panel">loading…</div>;
|
||||
|
||||
// No valid session → login form.
|
||||
if (!session || !me) {
|
||||
return (
|
||||
<form
|
||||
className="admin-panel admin-login"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
void login();
|
||||
}}
|
||||
>
|
||||
<label>
|
||||
Username
|
||||
<input data-testid="admin-username" value={username} onChange={(e) => setUsername(e.target.value)} autoComplete="username" />
|
||||
</label>
|
||||
<label>
|
||||
Password
|
||||
<input
|
||||
data-testid="admin-password"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
autoComplete="current-password"
|
||||
/>
|
||||
</label>
|
||||
{loginError && (
|
||||
<div data-testid="admin-error" className="admin-error">
|
||||
{loginError}
|
||||
</div>
|
||||
)}
|
||||
<button type="submit" data-testid="admin-login-submit" className="pane-btn">
|
||||
Log in
|
||||
</button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
// Authenticated but not an admin.
|
||||
if (!me.admin) {
|
||||
return (
|
||||
<div className="admin-panel">
|
||||
<p data-testid="admin-not-admin" className="admin-message">
|
||||
Logged in as <strong>{me.username}</strong> — this account is not an admin.
|
||||
</p>
|
||||
<button type="button" data-testid="admin-logout" className="pane-btn" onClick={() => void logout()}>
|
||||
Log out
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const columns = rowsData?.columns ?? [];
|
||||
// Displayed grid: server rows plus one trailing empty row when "+ Add row" is active.
|
||||
const rowCount = (rowsData?.rows.length ?? 0) + (newRow ? 1 : 0);
|
||||
const cellValue = (rowIdx: number, col: string): string => {
|
||||
if (col === '__rowid') return String(rowsData?.rows[rowIdx]?.[col] ?? '');
|
||||
const key = `${rowIdx}:${col}`;
|
||||
if (key in edits) return edits[key];
|
||||
const raw = rowsData?.rows[rowIdx]?.[col];
|
||||
return raw === null || raw === undefined ? '' : String(raw);
|
||||
};
|
||||
|
||||
const saveRow = async (rowIdx: number) => {
|
||||
if (!session || !table || !rowsData) return;
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
if (newRow && rowIdx === rowsData.rows.length) {
|
||||
// New row → POST the non-empty cells.
|
||||
const values: Record<string, string> = {};
|
||||
for (const col of columns) {
|
||||
if (col === '__rowid') continue;
|
||||
const v = cellValue(rowIdx, col);
|
||||
if (v !== '') values[col] = v;
|
||||
}
|
||||
const res = await fetch(`/api/admin/tables/${encodeURIComponent(table)}/rows`, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json', ...authHeaders(session) },
|
||||
body: JSON.stringify({ values }),
|
||||
});
|
||||
if (!res.ok) throw new Error((await res.json().catch(() => null))?.error ?? `HTTP ${res.status}`);
|
||||
} else {
|
||||
// Existing row → PUT only the changed cells (cleared cell = NULL).
|
||||
const values: Record<string, string | null> = {};
|
||||
for (const col of columns) {
|
||||
if (col === '__rowid') continue;
|
||||
const original = rowsData.rows[rowIdx]?.[col];
|
||||
const origStr = original === null || original === undefined ? '' : String(original);
|
||||
const v = cellValue(rowIdx, col);
|
||||
if (v !== origStr) values[col] = v === '' ? null : v;
|
||||
}
|
||||
if (Object.keys(values).length === 0) return; // nothing changed
|
||||
const res = await fetch(`/api/admin/tables/${encodeURIComponent(table)}/rows`, {
|
||||
method: 'PUT',
|
||||
headers: { 'content-type': 'application/json', ...authHeaders(session) },
|
||||
body: JSON.stringify({ rowid: rowsData.rows[rowIdx]?.__rowid, values }),
|
||||
});
|
||||
if (!res.ok) throw new Error((await res.json().catch(() => null))?.error ?? `HTTP ${res.status}`);
|
||||
}
|
||||
await loadRows(session, table); // re-fetch so the grid shows server state (persistence check)
|
||||
setEdits({});
|
||||
setNewRow(false);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const deleteRow = async (rowIdx: number) => {
|
||||
if (!session || !table || !rowsData?.rows[rowIdx]) return;
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
const rowid = rowsData.rows[rowIdx]?.__rowid;
|
||||
const res = await fetch(
|
||||
`/api/admin/tables/${encodeURIComponent(table)}/rows?rowid=${encodeURIComponent(String(rowid))}`,
|
||||
{ method: 'DELETE', headers: authHeaders(session) },
|
||||
);
|
||||
if (!res.ok) throw new Error((await res.json().catch(() => null))?.error ?? `HTTP ${res.status}`);
|
||||
await loadRows(session, table);
|
||||
setEdits({});
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="admin-panel">
|
||||
<div className="admin-toolbar">
|
||||
<span data-testid="admin-user" className="admin-message">
|
||||
{me.username} (admin)
|
||||
</span>
|
||||
<button type="button" data-testid="admin-logout" className="pane-btn" onClick={() => void logout()}>
|
||||
Log out
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{!table && (
|
||||
<>
|
||||
<p className="admin-message">Pick a table:</p>
|
||||
<div data-testid="admin-tables" className="admin-table-list">
|
||||
{tables.map((t) => (
|
||||
<button key={t} type="button" data-testid={`admin-table-${t}`} className="pane-btn" onClick={() => void openTable(t)}>
|
||||
{t}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{table && rowsData && (
|
||||
<>
|
||||
<div className="admin-toolbar">
|
||||
<strong data-testid="admin-current-table">{table}</strong>
|
||||
<button type="button" data-testid="admin-back" className="pane-btn" onClick={() => setTable(null)}>
|
||||
← tables
|
||||
</button>
|
||||
<button type="button" data-testid="admin-refresh" className="pane-btn" disabled={busy} onClick={() => void refresh()}>
|
||||
Refresh
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
data-testid="admin-add-row"
|
||||
className="pane-btn"
|
||||
disabled={busy || newRow}
|
||||
onClick={() => setNewRow(true)}
|
||||
>
|
||||
+ Add row
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="admin-rows-wrap">
|
||||
<table data-testid="admin-rows" className="admin-rows">
|
||||
<thead>
|
||||
<tr>
|
||||
{columns.map((c) => (
|
||||
<th key={c}>{c}</th>
|
||||
))}
|
||||
<th />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{Array.from({ length: rowCount }, (_, i) => (
|
||||
<tr key={i}>
|
||||
{columns.map((c) => (
|
||||
<td key={c}>
|
||||
<input
|
||||
data-testid={`admin-cell-${i}-${c}`}
|
||||
className="admin-cell"
|
||||
value={cellValue(i, c)}
|
||||
disabled={busy || c === '__rowid'}
|
||||
onChange={(e) => setEdits((prev) => ({ ...prev, [`${i}:${c}`]: e.target.value }))}
|
||||
/>
|
||||
</td>
|
||||
))}
|
||||
<td className="admin-row-actions">
|
||||
<button type="button" data-testid={`admin-save-${i}`} className="pane-btn" disabled={busy} onClick={() => void saveRow(i)}>
|
||||
Save
|
||||
</button>{' '}
|
||||
{!(newRow && i === rowsData.rows.length) && (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
data-testid={`admin-delete-${i}`}
|
||||
className="pane-btn"
|
||||
disabled={busy}
|
||||
onClick={() => void deleteRow(i)}
|
||||
>
|
||||
Delete
|
||||
</button>{' '}
|
||||
</>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div data-testid="admin-error" className="admin-error">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -11,6 +11,8 @@ import type { HoverTarget } from './hoverTypes';
|
||||
import { hoverRouteGeoJson, hoverTraceGeoJson, planesGeoJson, queuedRoutesGeoJson, routesGeoJson, type AirportCoord, type PlaneFeatureProps } from '../lib/mapData';
|
||||
import { weatherFor } from '../lib/weather';
|
||||
import { approxLocalTime } from '../lib/localTime';
|
||||
import Modal from './Modal';
|
||||
import AdminPanel from './AdminPanel';
|
||||
|
||||
// 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,
|
||||
@@ -89,6 +91,9 @@ export default function FlightMap({ state, hovered, applySnapshot }: MapProps) {
|
||||
const airportsIndexRef = useRef<Map<string, AirportCoord> | null>(null);
|
||||
const [airportCount, setAirportCount] = useState<number | null>(null);
|
||||
const [styleId, setStyleId] = useState<string>(DEFAULT_STYLE_ID);
|
||||
// v0.71.0: the Admin modal (top-bar button) — unmounted while closed so it re-checks the
|
||||
// stored session on every open.
|
||||
const [adminOpen, setAdminOpen] = useState(false);
|
||||
|
||||
// Push the latest planes/routes GeoJSON into whatever sources currently exist.
|
||||
// No-op until the map (and for routes: the airport index) is ready — the mount
|
||||
@@ -579,8 +584,15 @@ export default function FlightMap({ state, hovered, applySnapshot }: MapProps) {
|
||||
<span data-testid="airport-count">
|
||||
{airportCount === null ? 'loading airports…' : `${airportCount} world airports`}
|
||||
</span>
|
||||
{/* v0.71.0: admin panel entry point — right side of the top bar, opens a modal (no URL). */}
|
||||
<button type="button" data-testid="admin-btn" className="pane-btn" onClick={() => setAdminOpen(true)}>
|
||||
Admin
|
||||
</button>
|
||||
</header>
|
||||
<div ref={containerRef} data-testid="map" className="map-container" />
|
||||
<Modal open={adminOpen} onClose={() => setAdminOpen(false)} title="Admin">
|
||||
<AdminPanel />
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, type ReactNode } from 'react';
|
||||
|
||||
// v0.71.0 — reusable modal dialog: fixed overlay (z-index 60, above the z-50 toast stack),
|
||||
// centered panel, closes on Escape and backdrop click (inner-panel clicks do not close it).
|
||||
// Foundation for future modal-based features; keep this generic.
|
||||
export default function Modal({ open, onClose, title, children }: { open: boolean; onClose: () => void; title: string; children: ReactNode }) {
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') onClose();
|
||||
};
|
||||
window.addEventListener('keydown', onKey);
|
||||
return () => window.removeEventListener('keydown', onKey);
|
||||
}, [open, onClose]);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
return (
|
||||
<div className="modal-overlay" onClick={onClose}>
|
||||
<div className="modal-panel" role="dialog" aria-modal="true" aria-label={title} onClick={(e) => e.stopPropagation()}>
|
||||
<header className="modal-header">
|
||||
<h2>{title}</h2>
|
||||
<button type="button" data-testid="modal-close" className="pane-btn" onClick={onClose} aria-label="Close dialog">
|
||||
×
|
||||
</button>
|
||||
</header>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user