// — 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(null); const [error, setError] = useState(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(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(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 = ( setDeleteTarget(null)} onConfirm={() => void confirmDelete()} confirmLabel="delete" confirmDisabled={deleting} > {deleteTarget ? ( <>

Delete the {deleteTarget.kind} account{" "} {deleteTarget.name} {deleteTarget.host ? ( <> {" "} on {deleteTarget.host} ) : null}{" "} from {agent}? Every stored version of its token is removed from the swarm secret store. The agent is not told.

{deleteTarget.kind === "matrix" ? ( ) : null} {deleteError ? ( ) : null} ) : null}
); if (error) { return ( ); } if (accounts === null) return …; if (accounts.length === 0) { return none linked; } return ( <>
    {accounts.map((a) => (
  • {a.host ?? "—"} {a.reserved ? ( ) : ( openDelete(a)} /> )}
  • ))}
{dialog} ); }