Watch
0
0
Fork
You've already forked hyperhive
0
hyperhive/frontend/packages/swarm-ui/src/pages/agents/LinkedAccounts.tsx
atlas b90be9e65e swarm UI: delete linked accounts — R2 fixes
Addresses argus review comment 90297 on PR #4899:

- swarm-controller/README.md: list the three DELETE routes (including
  matrix's ?revoke=true) beside the PUT/GET ones already documented.
- LinkedAccounts.tsx: a delete answering 404 means the account is
  already gone, so treat it as the delete's end state — re-fetch and
  close the dialog instead of showing an error.
- matrix_account.rs: matrix_logout treats a 401 M_UNKNOWN_TOKEN as the
  token already being revoked and proceeds with the delete; every
  other logout failure still keeps the account. Adds unit tests and
  updates docs/swarm/ui.md to match.
2026-10-03 00:56:39 +02:00

197 lines
6.3 KiB
TypeScript

// <LinkedAccounts> — the accounts linked to one agent, one row each: kind,
// 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, 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";
// Mirrors `linked_accounts::LinkedAccount`.
interface LinkedAccount {
kind: AccountKind;
name: string;
host: string | null;
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,
version,
}: {
hive: string;
agent: string;
version: number;
}) {
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;
(async () => {
const r = await fetch(
`/api/hives/${encodeURIComponent(hive)}/agents/${encodeURIComponent(agent)}/linked-accounts`,
);
if (!r.ok) {
if (!cancelled) setError(await readApiError(r));
return;
}
const data = (await r.json()) as LinkedAccount[];
if (cancelled) return;
setAccounts(data);
setError(null);
})().catch((e: unknown) => {
if (!cancelled) setError({ detail: String(e) });
});
return () => {
cancelled = true;
};
}, [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" },
);
// A 404 means the account the dialog is about is already gone — the
// end state the delete wanted — so it closes the same as a success.
if (!r.ok && r.status !== 404) {
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 (
<Badge
tone="negative"
value="accounts unavailable"
title={error.detail ?? "listing linked accounts failed"}
/>
);
}
if (accounts === null) return <span class="ui-linked-accounts-muted">…</span>;
if (accounts.length === 0) {
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"
/>
) : (
<Badge
variant="quiet"
value="delete"
title={`delete the ${a.kind} account ${a.name}`}
onClick={() => openDelete(a)}
/>
)}
</li>
))}
</ul>
{dialog}
</>
);
}