mirror of
https://github.com/Such-Software/smirk-backend-core.git
synced 2026-10-17 07:21:32 +02:00
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:
-5
File diff suppressed because one or more lines are too long
+5
File diff suppressed because one or more lines are too long
Vendored
+1
-1
@@ -5,7 +5,7 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="robots" content="noindex" />
|
||||
<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">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
+3
-1
@@ -64,7 +64,9 @@ export const api = {
|
||||
logout: () => req('POST', '/admin/auth/logout', {}),
|
||||
me: () => req('GET', '/admin/me'),
|
||||
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) =>
|
||||
req('POST', '/admin/keys', label ? { pubkey, label } : { pubkey }),
|
||||
revokeKey: (id: string) => req('DELETE', `/admin/keys/${id}`),
|
||||
|
||||
+46
-9
@@ -115,8 +115,13 @@ function KeysTab() {
|
||||
};
|
||||
const revoke = async (id: string) => {
|
||||
if (!confirm('Revoke this admin key?')) return;
|
||||
await api.revokeKey(id);
|
||||
reload();
|
||||
setMsg(null);
|
||||
try {
|
||||
await api.revokeKey(id);
|
||||
reload();
|
||||
} catch (e) {
|
||||
setMsg(e instanceof Error ? e.message : String(e));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -163,13 +168,17 @@ function InvitesTab() {
|
||||
const [label, setLabel] = useState('');
|
||||
const [minted, setMinted] = useState<string[] | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [msg, setMsg] = useState<string | null>(null);
|
||||
|
||||
const mint = async () => {
|
||||
setBusy(true);
|
||||
setMsg(null);
|
||||
try {
|
||||
const r = await api.mintInvites(count, label.trim() || undefined);
|
||||
setMinted(r.codes);
|
||||
reload();
|
||||
} catch (e) {
|
||||
setMsg(e instanceof Error ? e.message : String(e));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
@@ -193,10 +202,11 @@ function InvitesTab() {
|
||||
value={label}
|
||||
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
|
||||
</button>
|
||||
</div>
|
||||
{msg && <p style={{ color: 'var(--smirk-danger)' }}>{msg}</p>}
|
||||
{minted && (
|
||||
<div style={{ background: 'var(--smirk-bg-sunken)', padding: 12, borderRadius: 8, marginBottom: 12 }}>
|
||||
<b>New codes — copy now, they are shown once:</b>
|
||||
@@ -236,7 +246,17 @@ function ConfigTab() {
|
||||
const [msg, setMsg] = useState<string | null>(null);
|
||||
|
||||
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]);
|
||||
|
||||
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) => {
|
||||
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 {
|
||||
await api.putConfig(
|
||||
{ [section]: edit[section] },
|
||||
{ [section]: data.versions[section] ?? 0 },
|
||||
);
|
||||
await api.putConfig({ [section]: patch }, { [section]: data.versions[section] ?? 0 });
|
||||
setMsg(`Saved ${section}.`);
|
||||
reload();
|
||||
} catch (e) {
|
||||
@@ -303,7 +333,14 @@ function ConfigTab() {
|
||||
value={String(value ?? '')}
|
||||
onInput={(e) => {
|
||||
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);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user