Files
kamilkrzywda 0d2370850f
CI / build + unit + e2e (pull_request) Successful in 3m47s
feat(admin): DB CRUD panel + /api/admin endpoints (#38)
- ADMIN button in the mission-control header opens a table picker + row grid with add/edit/delete (components/admin-panel.tsx), deliberately unauthenticated by explicit request
- /api/admin/tables and /api/admin/tables/{name}/rows driven off a name→table registry built from lib/db/schema.ts (lib/db/admin.ts) — no per-table code; 404 unknown table, 400 malformed payload, 409 constraint violation
- OpenAPI spec updated for all four admin operations + Admin* schemas
- unit: lib/db/admin.test.ts (registry, CRUD round-trips, composite PKs, error mapping); e2e: read-only admin.spec.ts
2026-09-29 21:48:15 +00:00

323 lines
14 KiB
TypeScript

'use client'
import { useCallback, useEffect, useState } from 'react'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Panel, PanelContent, PanelHeader, PanelTitle } from '@/components/ui/panel'
// #38 — direct DB CRUD for the admin. ponytail: deliberately unauthenticated (explicit request);
// add auth before this is reachable beyond the dev host.
interface AdminColumnInfo { name: string; type: string; pk: number }
interface AdminTableInfo { name: string; columns: AdminColumnInfo[]; count: number }
interface AdminRows { rows: Record<string, unknown>[]; total: number }
// Form state is all strings (one input per column); parsed on save against the column type.
function parseValue(raw: string, col: AdminColumnInfo): number | string | null {
const v = raw.trim()
if (v === '' || v.toLowerCase() === 'null') return null
if (/INT|REAL|FLOA|DOUB/.test(col.type)) {
const n = Number(v)
if (!Number.isFinite(n)) throw new Error(`'${col.name}' must be a number, got '${v}'`)
return col.type.includes('INT') ? Math.trunc(n) : n
}
return v
}
function cellText(value: unknown): string {
return value === null || value === undefined ? '—' : String(value)
}
export function AdminPanel({ onClose }: { onClose: () => void }) {
const [tables, setTables] = useState<AdminTableInfo[] | null>(null)
const [selected, setSelected] = useState<string | null>(null)
const [rows, setRows] = useState<AdminRows | null>(null)
// null = form closed; { key: null } = add row; { key: {...} } = edit that row.
const [editing, setEditing] = useState<{ key: Record<string, number> | null } | null>(null)
const [form, setForm] = useState<Record<string, string>>({})
const [error, setError] = useState<string | null>(null)
const table = tables?.find((t) => t.name === selected) ?? null
const refreshTables = useCallback(async () => {
try {
const res = await fetch('/api/admin/tables')
if (!res.ok) throw new Error(`HTTP ${res.status}`)
setTables((await res.json()) as AdminTableInfo[])
} catch (err) {
setError(err instanceof Error ? err.message : String(err))
}
}, [])
const refreshRows = useCallback(async (name: string) => {
try {
const res = await fetch(`/api/admin/tables/${encodeURIComponent(name)}/rows`)
if (!res.ok) throw new Error(`HTTP ${res.status}`)
setRows((await res.json()) as AdminRows)
} catch (err) {
setError(err instanceof Error ? err.message : String(err))
}
}, [])
useEffect(() => {
void refreshTables()
}, [refreshTables])
useEffect(() => {
if (!selected) return
setRows(null)
void refreshRows(selected)
}, [selected, refreshRows])
const pkColumns = (t: AdminTableInfo): AdminColumnInfo[] => t.columns.filter((c) => c.pk > 0).sort((a, b) => a.pk - b.pk)
const rowKey = (t: AdminTableInfo, row: Record<string, unknown>): Record<string, number> =>
Object.fromEntries(pkColumns(t).map((c) => [c.name, Number(row[c.name])]))
const openAdd = () => {
if (!table) return
setEditing({ key: null })
setForm(Object.fromEntries(table.columns.map((c) => [c.name, ''])))
setError(null)
}
const openEdit = (row: Record<string, unknown>) => {
if (!table) return
setEditing({ key: rowKey(table, row) })
setForm(Object.fromEntries(table.columns.map((c) => [c.name, row[c.name] == null ? '' : String(row[c.name])])))
setError(null)
}
const save = async () => {
if (!table || !editing) return
try {
const payload: Record<string, number | string | null> = {}
for (const col of table.columns) payload[col.name] = parseValue(form[col.name] ?? '', col)
const url = `/api/admin/tables/${encodeURIComponent(table.name)}/rows`
const res = await fetch(url, {
method: editing.key === null ? 'POST' : 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(editing.key === null ? payload : { key: editing.key, values: payload }),
})
const body = (await res.json()) as { error?: string } & Record<string, unknown>
if (!res.ok) throw new Error(body.error ?? `HTTP ${res.status}`)
setEditing(null)
setError(null)
await Promise.all([refreshRows(table.name), refreshTables()])
} catch (err) {
setError(err instanceof Error ? err.message : String(err))
}
}
const remove = async (row: Record<string, unknown>) => {
if (!table) return
try {
const res = await fetch(`/api/admin/tables/${encodeURIComponent(table.name)}/rows`, {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ key: rowKey(table, row) }),
})
const body = (await res.json()) as { error?: string }
if (!res.ok) throw new Error(body.error ?? `HTTP ${res.status}`)
await Promise.all([refreshRows(table.name), refreshTables()])
} catch (err) {
setError(err instanceof Error ? err.message : String(err))
}
}
const inputStyle: React.CSSProperties = {
width: '100%',
background: 'var(--background)',
border: '1px solid var(--border)',
color: 'var(--text-secondary)',
fontFamily: 'inherit',
fontSize: '0.68rem',
padding: '0.3rem 0.4rem',
outline: 'none',
}
return (
<div
style={{
position: 'fixed',
inset: 0,
zIndex: 100,
background: 'rgba(0, 0, 0, 0.75)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
padding: '1rem',
}}
>
<Panel notch="md" style={{ width: 'min(980px, 100%)', height: 'min(640px, 100%)', display: 'flex', flexDirection: 'column' }}>
<PanelHeader>
<PanelTitle>ADMIN // DB CRUD</PanelTitle>
<Badge variant="WARNING">NO AUTH</Badge>
<div style={{ marginLeft: 'auto' }}>
<Button size="SM" onClick={onClose}>CLOSE</Button>
</div>
</PanelHeader>
<div style={{ flex: 1, display: 'grid', gridTemplateColumns: '200px 1fr', gap: '1px', background: 'var(--border)', minHeight: 0 }}>
{/* ── TABLE LIST ── */}
<div style={{ background: 'var(--surface)', overflowY: 'auto', padding: '0.5rem' }}>
{!tables ? (
<span style={{ fontSize: '0.62rem', color: 'var(--text-muted)', letterSpacing: '0.12em' }}>LOADING…</span>
) : (
tables.map((t) => (
<button
key={t.name}
onClick={() => setSelected(t.name)}
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
width: '100%',
gap: '0.5rem',
padding: '0.4rem 0.5rem',
marginBottom: '2px',
background: selected === t.name ? 'var(--surface-raised)' : 'transparent',
border: `1px solid ${selected === t.name ? 'var(--color-green)' : 'var(--border)'}`,
color: selected === t.name ? 'var(--color-green)' : 'var(--text-secondary)',
fontFamily: 'inherit',
fontSize: '0.68rem',
letterSpacing: '0.06em',
cursor: 'pointer',
}}
>
<span>{t.name}</span>
<span style={{ color: 'var(--text-muted)' }}>{t.count}</span>
</button>
))
)}
</div>
{/* ── ROWS / EDITOR ── */}
<div style={{ background: 'var(--surface)', display: 'flex', flexDirection: 'column', minHeight: 0 }}>
{!table ? (
<PanelContent>
<span style={{ fontSize: '0.62rem', color: 'var(--text-muted)', letterSpacing: '0.12em' }}>SELECT A TABLE</span>
</PanelContent>
) : (
<>
<div
style={{
display: 'flex',
alignItems: 'center',
gap: '0.5rem',
padding: '0.4rem 0.75rem',
borderBottom: '1px solid var(--border)',
}}
>
<span style={{ fontSize: '0.68rem', color: 'var(--color-green)', letterSpacing: '0.1em' }}>{table.name}</span>
{rows && (
<Badge variant="ACTIVE">
{rows.rows.length} / {rows.total}
</Badge>
)}
<div style={{ marginLeft: 'auto', display: 'flex', gap: '0.4rem' }}>
{!editing && <Button size="SM" variant="EXEC" onClick={openAdd}>ADD ROW</Button>}
{error && (
<span style={{ fontSize: '0.62rem', color: 'var(--color-red)', alignSelf: 'center' }}>{error}</span>
)}
</div>
</div>
{editing && (
<div style={{ padding: '0.5rem 0.75rem', borderBottom: '1px solid var(--border)' }}>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(160px, 1fr))', gap: '0.4rem' }}>
{table.columns.map((c) => (
<label key={c.name} style={{ display: 'flex', flexDirection: 'column', gap: '0.2rem' }}>
<span style={{ fontSize: '0.58rem', color: 'var(--text-muted)', letterSpacing: '0.1em' }}>
{c.name.toUpperCase()}
{c.pk > 0 ? ` (PK${c.pk})` : ''} · {c.type}
</span>
<input
style={inputStyle}
value={form[c.name] ?? ''}
onChange={(e) => setForm((f) => ({ ...f, [c.name]: e.target.value }))}
/>
</label>
))}
</div>
<div style={{ display: 'flex', gap: '0.4rem', marginTop: '0.5rem' }}>
<Button size="SM" variant="EXEC" onClick={() => void save()}>SAVE</Button>
<Button size="SM" onClick={() => setEditing(null)}>CANCEL</Button>
</div>
</div>
)}
<div style={{ flex: 1, overflowY: 'auto' }}>
{!rows ? (
<PanelContent>
<span style={{ fontSize: '0.62rem', color: 'var(--text-muted)', letterSpacing: '0.12em' }}>LOADING…</span>
</PanelContent>
) : rows.rows.length === 0 ? (
<PanelContent>
<span style={{ fontSize: '0.62rem', color: 'var(--text-muted)', letterSpacing: '0.08em' }}>EMPTY TABLE</span>
</PanelContent>
) : (
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: '0.65rem' }}>
<thead>
<tr>
{table.columns.map((c) => (
<th
key={c.name}
style={{
position: 'sticky',
top: 0,
background: 'var(--surface-raised)',
color: 'var(--text-muted)',
textAlign: 'left',
padding: '0.35rem 0.5rem',
borderBottom: '1px solid var(--border)',
letterSpacing: '0.08em',
whiteSpace: 'nowrap',
}}
>
{c.name}
{c.pk > 0 ? ` (PK${c.pk})` : ''}
</th>
))}
<th style={{ position: 'sticky', top: 0, background: 'var(--surface-raised)', padding: '0.35rem 0.5rem', borderBottom: '1px solid var(--border)' }} />
</tr>
</thead>
<tbody>
{rows.rows.map((row) => (
<tr key={JSON.stringify(rowKey(table, row))}>
{table.columns.map((c) => (
<td
key={c.name}
style={{
padding: '0.3rem 0.5rem',
borderBottom: '1px solid var(--border)',
color: row[c.name] === null ? 'var(--text-muted)' : 'var(--text-secondary)',
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
maxWidth: '220px',
}}
>
{cellText(row[c.name])}
</td>
))}
<td style={{ padding: '0.3rem 0.5rem', borderBottom: '1px solid var(--border)', whiteSpace: 'nowrap' }}>
<span style={{ display: 'inline-flex', gap: '0.3rem' }}>
<Button size="SM" onClick={() => openEdit(row)}>EDIT</Button>
<Button size="SM" variant="ABORT" onClick={() => void remove(row)}>DEL</Button>
</span>
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
</>
)}
</div>
</div>
</Panel>
</div>
)
}