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