'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. interface Me { username: string; admin: boolean; } interface RowsData { columns: string[]; rows: Array>; } export default function AdminPanel({ onLogout }: { onLogout?: () => void }) { const [session, setSession] = useState(null); const [me, setMe] = useState(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(null); // Admin grid state. const [tables, setTables] = useState([]); const [table, setTable] = useState(null); const [rowsData, setRowsData] = useState(null); const [edits, setEdits] = useState>({}); // key `${rowIdx}:${col}` const [newRow, setNewRow] = useState(false); const [busy, setBusy] = useState(false); const [error, setError] = useState(null); 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 = storedSession(); if (!stored) { setChecking(false); return; } fetch('/api/me', { headers: authHeaders(stored) }) .then(async (res) => { if (!res.ok || cancelled) { clearStoredSession(); setSession(null); return; } const body = (await res.json()) as Me; setSession(stored); setMe(body); }) .catch(() => { if (!cancelled) { clearStoredSession(); 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 }; storeSession(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); clearStoredSession(); onLogout?.(); // v1.1.0 (ISS-107): session gone → shell drops back to the sign-in screen setSession(null); setMe(null); setTable(null); setRowsData(null); setTables([]); setError(null); }; if (checking) return
loading…
; // No valid session → login form. if (!session || !me) { return (
{ e.preventDefault(); void login(); }} > {loginError && (
{loginError}
)}
); } // Authenticated but not an admin. if (!me.admin) { return (

Logged in as {me.username} — this account is not an admin.

); } 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 = {}; 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 = {}; 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 (
{me.username} (admin)
{!table && ( <>

Pick a table:

{tables.map((t) => ( ))}
)} {table && rowsData && ( <>
{table}
{columns.map((c) => ( ))} {Array.from({ length: rowCount }, (_, i) => ( {columns.map((c) => ( ))} ))}
{c}
setEdits((prev) => ({ ...prev, [`${i}:${c}`]: e.target.value }))} /> {' '} {!(newRow && i === rowsData.rows.length) && ( <> {' '} )}
)} {error && (
{error}
)}
); }