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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user