swarm UI: delete linked accounts
Each row of an agent's linked accounts, except its own `main` matrix
account, gets a delete action. swarm-controller serves DELETE beside each
PUT (matrix-accounts/{account}, forge-accounts/{label}, github-account),
answers 404 for an account the store does not hold, refuses `main`, and
removes every version through `delete_all_versions`.
The matrix confirmation has a revoke checkbox, off by default: the
controller logs the stored token out at its homeserver first, and keeps
the account when that fails or no homeserver is stored.
The controller's policy gains `delete` on each agent's
`metadata/.../matrix/+`, `forge/+` and `github-token`, pinned in
bao-grants.nix.
Refs #4855
This commit is contained in:
parent
ed9c0f53ed
commit
bbf931207f
7 changed files with 705 additions and 35 deletions
|
|
@ -1,16 +1,19 @@
|
|||
// <LinkedAccounts> — the accounts linked to one agent, one row each: kind,
|
||||
// name, host. Reads `GET /api/hives/{hive}/agents/{agent}/linked-accounts`,
|
||||
// which carries names and hosts only, never a credential.
|
||||
// name, host, and a delete action on every row but the agent's own account.
|
||||
// Reads `GET /api/hives/{hive}/agents/{agent}/linked-accounts`, which carries
|
||||
// names and hosts only, never a credential.
|
||||
//
|
||||
// Fetched on mount and again whenever `version` changes; `AgentsPage` bumps
|
||||
// it when a link dialog closes, so an account linked there shows up without
|
||||
// waiting for a reload.
|
||||
// Fetched on mount, after a delete, and whenever `version` changes;
|
||||
// `AgentsPage` bumps it when a link dialog closes, so an account linked there
|
||||
// shows up without waiting for a reload.
|
||||
//
|
||||
// Its state belongs to one agent: callers key it by hive and agent, so
|
||||
// switching agents remounts it instead of showing the previous agent's rows.
|
||||
import { useEffect, useState } from "preact/hooks";
|
||||
import { ApiErrorPanel } from "@hive/shared/api-error-panel.js";
|
||||
import { readApiError, type ProblemDetails } from "@hive/shared/api-error.js";
|
||||
import { Badge } from "@hive/shared/badge.js";
|
||||
import { ConfirmDialog } from "../../ui/confirm-dialog/ConfirmDialog.js";
|
||||
import "./LinkedAccounts.css";
|
||||
|
||||
type AccountKind = "matrix" | "forgejo" | "github";
|
||||
|
|
@ -23,6 +26,19 @@ interface LinkedAccount {
|
|||
reserved: boolean;
|
||||
}
|
||||
|
||||
// The DELETE route for one account, beside the PUT that links it.
|
||||
function accountUrl(hive: string, agent: string, a: LinkedAccount): string {
|
||||
const base = `/api/hives/${encodeURIComponent(hive)}/agents/${encodeURIComponent(agent)}`;
|
||||
switch (a.kind) {
|
||||
case "matrix":
|
||||
return `${base}/matrix-accounts/${encodeURIComponent(a.name)}`;
|
||||
case "forgejo":
|
||||
return `${base}/forge-accounts/${encodeURIComponent(a.name)}`;
|
||||
case "github":
|
||||
return `${base}/github-account`;
|
||||
}
|
||||
}
|
||||
|
||||
export function LinkedAccounts({
|
||||
hive,
|
||||
agent,
|
||||
|
|
@ -34,6 +50,14 @@ export function LinkedAccounts({
|
|||
}) {
|
||||
const [accounts, setAccounts] = useState<LinkedAccount[] | null>(null);
|
||||
const [error, setError] = useState<ProblemDetails | null>(null);
|
||||
// Bumped after a delete, so the list is fetched again.
|
||||
const [reload, setReload] = useState(0);
|
||||
// The row whose delete confirmation is open; null means closed.
|
||||
const [deleteTarget, setDeleteTarget] = useState<LinkedAccount | null>(null);
|
||||
// Matrix only: log the token out at its homeserver before deleting.
|
||||
const [revoke, setRevoke] = useState(false);
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
const [deleteError, setDeleteError] = useState<ProblemDetails | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
|
@ -55,7 +79,79 @@ export function LinkedAccounts({
|
|||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [hive, agent, version]);
|
||||
}, [hive, agent, version, reload]);
|
||||
|
||||
function openDelete(a: LinkedAccount) {
|
||||
setDeleteTarget(a);
|
||||
setRevoke(false);
|
||||
setDeleteError(null);
|
||||
}
|
||||
|
||||
async function confirmDelete() {
|
||||
if (!deleteTarget) return;
|
||||
setDeleting(true);
|
||||
try {
|
||||
const url = accountUrl(hive, agent, deleteTarget);
|
||||
const r = await fetch(
|
||||
deleteTarget.kind === "matrix" && revoke ? `${url}?revoke=true` : url,
|
||||
{ method: "DELETE" },
|
||||
);
|
||||
if (!r.ok) {
|
||||
setDeleteError(await readApiError(r));
|
||||
return;
|
||||
}
|
||||
setDeleteTarget(null);
|
||||
setReload((n) => n + 1);
|
||||
} catch (e: unknown) {
|
||||
setDeleteError({ detail: String(e) });
|
||||
} finally {
|
||||
setDeleting(false);
|
||||
}
|
||||
}
|
||||
|
||||
const dialog = (
|
||||
<ConfirmDialog
|
||||
open={deleteTarget !== null}
|
||||
label="delete linked account"
|
||||
onCancel={() => setDeleteTarget(null)}
|
||||
onConfirm={() => void confirmDelete()}
|
||||
confirmLabel="delete"
|
||||
confirmDisabled={deleting}
|
||||
>
|
||||
{deleteTarget ? (
|
||||
<>
|
||||
<p>
|
||||
Delete the {deleteTarget.kind} account{" "}
|
||||
<strong>{deleteTarget.name}</strong>
|
||||
{deleteTarget.host ? (
|
||||
<>
|
||||
{" "}
|
||||
on <strong>{deleteTarget.host}</strong>
|
||||
</>
|
||||
) : null}{" "}
|
||||
from <strong>{agent}</strong>? Every stored version of its token is
|
||||
removed from the swarm secret store. The agent is not told.
|
||||
</p>
|
||||
{deleteTarget.kind === "matrix" ? (
|
||||
<label>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={revoke}
|
||||
onChange={(e) =>
|
||||
setRevoke((e.target as HTMLInputElement).checked)
|
||||
}
|
||||
/>{" "}
|
||||
also log the token out at the homeserver; if that fails, the
|
||||
account is kept
|
||||
</label>
|
||||
) : null}
|
||||
{deleteError ? (
|
||||
<ApiErrorPanel context="delete failed" problem={deleteError} />
|
||||
) : null}
|
||||
</>
|
||||
) : null}
|
||||
</ConfirmDialog>
|
||||
);
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
|
|
@ -71,19 +167,29 @@ export function LinkedAccounts({
|
|||
return <span class="ui-linked-accounts-muted">none linked</span>;
|
||||
}
|
||||
return (
|
||||
<ul class="ui-linked-accounts">
|
||||
{accounts.map((a) => (
|
||||
<li key={`${a.kind}/${a.name}`}>
|
||||
<Badge label={a.kind} value={a.name} />
|
||||
<span class="ui-linked-accounts-host">{a.host ?? "—"}</span>
|
||||
{a.reserved ? (
|
||||
<Badge
|
||||
value="own account"
|
||||
title="the agent's own account, which the swarm mints"
|
||||
/>
|
||||
) : null}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<>
|
||||
<ul class="ui-linked-accounts">
|
||||
{accounts.map((a) => (
|
||||
<li key={`${a.kind}/${a.name}`}>
|
||||
<Badge label={a.kind} value={a.name} />
|
||||
<span class="ui-linked-accounts-host">{a.host ?? "—"}</span>
|
||||
{a.reserved ? (
|
||||
<Badge
|
||||
value="own account"
|
||||
title="the agent's own account, which the swarm mints"
|
||||
/>
|
||||
) : (
|
||||
<Badge
|
||||
variant="quiet"
|
||||
value="delete"
|
||||
title={`delete the ${a.kind} account ${a.name}`}
|
||||
onClick={() => openDelete(a)}
|
||||
/>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
{dialog}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue