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.
197 lines
6.3 KiB
TypeScript
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}
|
|
</>
|
|
);
|
|
}
|