415 lines
14 KiB
TypeScript
415 lines
14 KiB
TypeScript
'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>
|
|
);
|
|
}
|