fix(admin-ui): repair Keys tab + console UX

- Keys tab crashed on render: api.keys() expected an array but the
  backend returns {keys:[...]}; unwrap it (KeysTab maps over the array).
- Config save now sends only CHANGED fields, not the whole section, so
  the backend stops spuriously flagging restarts and pinning env-sourced
  values into the DB overlay.
- Config edit state survives a reload (saving one section no longer
  discards unsaved edits in others); numeric inputs ignore empty/NaN
  instead of silently coercing to 0.
- Mint and Revoke now surface errors instead of swallowing them.
Rebuilt admin-ui/dist.
This commit is contained in:
jwinterm
2026-07-19 05:30:33 -04:00
parent ea2230164e
commit fe2fc91cdd
5 changed files with 55 additions and 16 deletions
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -5,7 +5,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="robots" content="noindex" /> <meta name="robots" content="noindex" />
<title>Smirk Operator Console</title> <title>Smirk Operator Console</title>
<script type="module" crossorigin src="/admin/assets/index-4xN7Sa3G.js"></script> <script type="module" crossorigin src="/admin/assets/index-7a1tzG-S.js"></script>
<link rel="stylesheet" crossorigin href="/admin/assets/index-uKG4VNAS.css"> <link rel="stylesheet" crossorigin href="/admin/assets/index-uKG4VNAS.css">
</head> </head>
<body> <body>
+3 -1
View File
@@ -64,7 +64,9 @@ export const api = {
logout: () => req('POST', '/admin/auth/logout', {}), logout: () => req('POST', '/admin/auth/logout', {}),
me: () => req('GET', '/admin/me'), me: () => req('GET', '/admin/me'),
features: () => req('GET', '/admin/features'), features: () => req('GET', '/admin/features'),
keys: () => req('GET', '/admin/keys') as Promise<KeyRow[]>, // The backend wraps the list as {keys:[...]} (KeysListResponse); unwrap here so
// callers get the array they expect (KeysTab maps over it directly).
keys: () => req('GET', '/admin/keys').then((r) => (r as { keys: KeyRow[] }).keys),
addKey: (pubkey: string, label?: string) => addKey: (pubkey: string, label?: string) =>
req('POST', '/admin/keys', label ? { pubkey, label } : { pubkey }), req('POST', '/admin/keys', label ? { pubkey, label } : { pubkey }),
revokeKey: (id: string) => req('DELETE', `/admin/keys/${id}`), revokeKey: (id: string) => req('DELETE', `/admin/keys/${id}`),
+46 -9
View File
@@ -115,8 +115,13 @@ function KeysTab() {
}; };
const revoke = async (id: string) => { const revoke = async (id: string) => {
if (!confirm('Revoke this admin key?')) return; if (!confirm('Revoke this admin key?')) return;
await api.revokeKey(id); setMsg(null);
reload(); try {
await api.revokeKey(id);
reload();
} catch (e) {
setMsg(e instanceof Error ? e.message : String(e));
}
}; };
return ( return (
@@ -163,13 +168,17 @@ function InvitesTab() {
const [label, setLabel] = useState(''); const [label, setLabel] = useState('');
const [minted, setMinted] = useState<string[] | null>(null); const [minted, setMinted] = useState<string[] | null>(null);
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
const [msg, setMsg] = useState<string | null>(null);
const mint = async () => { const mint = async () => {
setBusy(true); setBusy(true);
setMsg(null);
try { try {
const r = await api.mintInvites(count, label.trim() || undefined); const r = await api.mintInvites(count, label.trim() || undefined);
setMinted(r.codes); setMinted(r.codes);
reload(); reload();
} catch (e) {
setMsg(e instanceof Error ? e.message : String(e));
} finally { } finally {
setBusy(false); setBusy(false);
} }
@@ -193,10 +202,11 @@ function InvitesTab() {
value={label} value={label}
onInput={(e) => setLabel((e.target as HTMLInputElement).value)} onInput={(e) => setLabel((e.target as HTMLInputElement).value)}
/> />
<button class="primary" disabled={busy} onClick={mint}> <button class="primary" disabled={busy || count < 1} onClick={mint}>
Mint Mint
</button> </button>
</div> </div>
{msg && <p style={{ color: 'var(--smirk-danger)' }}>{msg}</p>}
{minted && ( {minted && (
<div style={{ background: 'var(--smirk-bg-sunken)', padding: 12, borderRadius: 8, marginBottom: 12 }}> <div style={{ background: 'var(--smirk-bg-sunken)', padding: 12, borderRadius: 8, marginBottom: 12 }}>
<b>New codes — copy now, they are shown once:</b> <b>New codes — copy now, they are shown once:</b>
@@ -236,7 +246,17 @@ function ConfigTab() {
const [msg, setMsg] = useState<string | null>(null); const [msg, setMsg] = useState<string | null>(null);
useEffect(() => { useEffect(() => {
if (data) setEdit(structuredClone(data.effective)); if (!data) return;
// Seed edit state for sections we are NOT already editing, and preserve
// in-progress edits — a reload after saving one section must not discard
// unsaved edits in the others.
setEdit((prev) => {
const next = { ...prev };
for (const section of Object.keys(data.effective)) {
if (!(section in next)) next[section] = structuredClone(data.effective[section]);
}
return next;
});
}, [data]); }, [data]);
if (err) return <Section title="Config">{<p style={{ color: 'var(--smirk-danger)' }}>{err}</p>}</Section>; if (err) return <Section title="Config">{<p style={{ color: 'var(--smirk-danger)' }}>{err}</p>}</Section>;
@@ -247,11 +267,21 @@ function ConfigTab() {
const save = async (section: string) => { const save = async (section: string) => {
setMsg(null); setMsg(null);
// Send ONLY the fields that actually changed. Sending the whole section makes the
// backend count every populated field as "touched" — spuriously flagging a restart
// and pinning env-sourced values into the DB overlay.
const orig = (data.effective[section] ?? {}) as Record<string, unknown>;
const cur = (edit[section] ?? {}) as Record<string, unknown>;
const patch: Record<string, unknown> = {};
for (const k of Object.keys(cur)) {
if (JSON.stringify(cur[k]) !== JSON.stringify(orig[k])) patch[k] = cur[k];
}
if (Object.keys(patch).length === 0) {
setMsg(`No changes in ${section}.`);
return;
}
try { try {
await api.putConfig( await api.putConfig({ [section]: patch }, { [section]: data.versions[section] ?? 0 });
{ [section]: edit[section] },
{ [section]: data.versions[section] ?? 0 },
);
setMsg(`Saved ${section}.`); setMsg(`Saved ${section}.`);
reload(); reload();
} catch (e) { } catch (e) {
@@ -303,7 +333,14 @@ function ConfigTab() {
value={String(value ?? '')} value={String(value ?? '')}
onInput={(e) => { onInput={(e) => {
const v = (e.target as HTMLInputElement).value; const v = (e.target as HTMLInputElement).value;
setField(section, field, typeof value === 'number' ? Number(v) : v); if (typeof value === 'number') {
// Ignore empty / non-numeric input instead of coercing to 0.
if (v === '') return;
const n = Number(v);
if (!Number.isNaN(n)) setField(section, field, n);
} else {
setField(section, field, v);
}
}} }}
/> />
)} )}