admin ui: reserved usernames use the standard dry-run/confirm flow

Both reserve and unreserve go through ActionButton now (reason -> dry-run ->
confirm, journalled) like every other admin action. The reserve modal keeps
just the username field and hands off; it autofocuses and echoes @<name> live
so the field being filled is unambiguous.
This commit is contained in:
Astra 2026-09-09 21:23:25 +01:00
parent 37a3462a22
commit 90af881867
3 changed files with 30 additions and 74 deletions

View file

@ -23,7 +23,7 @@
})();
</script>
<script type="module" crossorigin src="/assets/index-v1d-K3Z6.js"></script>
<script type="module" crossorigin src="/assets/index-fn4QJaPB.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-CQKJMNpu.css">
</head>
<body>

View file

@ -2,6 +2,7 @@ import { Loader2, Plus, RefreshCw, Search, Trash2, X } from "lucide-react";
import { useEffect, useState } from "react";
import { createPortal } from "react-dom";
import { api, errorMessage } from "../api";
import { ActionButton } from "../components/ActionButton";
import { Alert, EmptyRow, Metric, PageFrame, QueryPanel } from "../components/ui";
import { formatUnix } from "../lib/format";
import type { ReservedUsernameRow } from "../types";
@ -61,7 +62,7 @@ export function ReservedUsernamesPage() {
className="toolbar"
onSubmit={(event) => {
event.preventDefault();
load();
void load();
}}
>
<label className="searchbox">
@ -92,7 +93,17 @@ export function ReservedUsernamesPage() {
<td>{row.reason || "-"}</td>
<td>{row.actor || "-"}</td>
<td>{formatUnix(row.created_at) || "-"}</td>
<td><UnreserveButton username={row.username} onDone={() => load()} /></td>
<td>
<ActionButton
compact
label={"Unreserve"}
icon={<Trash2 size={13} />}
tone="danger"
path="/api/actions/unreserve-username"
payload={() => ({ username: row.username })}
onDone={() => void load()}
/>
</td>
</tr>
))}
{rows.length === 0 && <EmptyRow colSpan={5} />}
@ -105,7 +116,7 @@ export function ReservedUsernamesPage() {
onClose={() => setReserveOpen(false)}
onDone={() => {
setReserveOpen(false);
load();
void load();
}}
/>
)}
@ -113,63 +124,12 @@ export function ReservedUsernamesPage() {
);
}
function UnreserveButton({ username, onDone }: { username: string; onDone: () => void }) {
const [busy, setBusy] = useState(false);
return (
<button
className="btn danger compact-btn icon-text"
type="button"
disabled={busy}
onClick={async () => {
if (!window.confirm(`Unreserve @${username}?`)) return;
setBusy(true);
try {
await api.action("/api/actions/unreserve-username", {
username,
reason: "unreserved from admin panel",
confirm: true,
});
onDone();
} catch (err) {
window.alert(errorMessage(err));
} finally {
setBusy(false);
}
}}
>
{busy ? <Loader2 size={13} className="spin" /> : <Trash2 size={13} />} {"Unreserve"}
</button>
);
}
// ReserveUsernameModal collects the name, then hands off to ActionButton for the
// standard reason / dry-run / confirm flow - the same as every other admin
// action. The name is read fresh from state on each ActionButton render.
function ReserveUsernameModal({ onClose, onDone }: { onClose: () => void; onDone: () => void }) {
const [username, setUsername] = useState("");
const [reason, setReason] = useState("");
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
const clean = username.trim().replace(/^@/, "");
const canSubmit = clean.length >= 5 && reason.trim().length > 0 && !busy;
async function submit() {
setBusy(true);
setError("");
try {
const result = await api.action("/api/actions/reserve-username", {
username: clean,
reason: reason.trim(),
confirm: true,
});
if (result.error) {
setError(result.error);
return;
}
onDone();
} catch (err) {
setError(errorMessage(err));
} finally {
setBusy(false);
}
}
return createPortal(
<div className="modal-backdrop" role="presentation">
@ -193,25 +153,21 @@ function ReserveUsernameModal({ onClose, onDone }: { onClose: () => void; onDone
autoFocus
/>
</label>
<label className="form-field">
<span>{"Reason"}</span>
<textarea
value={reason}
onChange={(event) => setReason(event.target.value)}
rows={2}
placeholder={"Why this name is off limits"}
/>
</label>
<p className="bot-create-note">
{"No peer will be able to take @"}{clean || "…"}{" until it is unreserved. Nothing is shown to users."}
{`No peer will be able to take @${clean || "…"} until it is unreserved. Nothing is shown to users.`}
</p>
{error && <Alert>{error}</Alert>}
</div>
<div className="modal-actions">
<button className="btn" type="button" onClick={onClose}>{"Close"}</button>
<button className="btn primary icon-text" type="button" disabled={!canSubmit} onClick={() => void submit()}>
{busy ? <Loader2 size={15} className="spin" /> : <Plus size={15} />} {"Reserve username"}
</button>
<ActionButton
disabled={clean.length < 5}
label={"Reserve username"}
icon={<Plus size={15} />}
tone="neutral"
path="/api/actions/reserve-username"
payload={() => ({ username: clean })}
onDone={onDone}
/>
</div>
</section>
</div>,