Watch
0
0
Fork
You've already forked hyperhive
0

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:
atlas 2026-10-02 23:31:39 +02:00
commit bbf931207f
7 changed files with 705 additions and 35 deletions

View file

@ -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}
</>
);
}