Watch
0
0
Fork
You've already forked hyperhive
0
hyperhive/frontend/packages/swarm-ui/src/agent/detail/LinkedAccounts.tsx

201 lines
6.6 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 { CheckboxField } from "../../ui/checkbox-field/CheckboxField.js";
import { ConfirmDialog } from "../../ui/confirm-dialog/ConfirmDialog.js";
import { accountKindLabel } from "./account-kind.js";
import "./LinkedAccounts.css";
type AccountKind = "matrix" | "forgejo" | "github" | "backend";
// 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`;
case "backend":
return `${base}/backend-env`;
}
}
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 {accountKindLabel(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" ? (
<CheckboxField
id="linked-accounts-revoke"
label="also log the token out at the homeserver; if that fails, the account is kept"
checked={revoke}
onChange={setRevoke}
/>
) : null}
{deleteError ? (
<ApiErrorPanel context="delete failed" problem={deleteError} />
) : null}
</>
) : null}
</ConfirmDialog>
);
// Shown as text, not a hover title. A list already loaded stays under a
// failed refetch, so a link or delete that worked is not hidden by a read
// that did not.
const errorPanel = error ? (
<ApiErrorPanel context="listing linked accounts failed" problem={error} />
) : null;
if (accounts === null) {
return errorPanel ?? <span class="ui-linked-accounts-muted">…</span>;
}
if (accounts.length === 0) {
return (
<>
{errorPanel}
<span class="ui-linked-accounts-muted">none linked</span>
</>
);
}
return (
<>
{errorPanel}
<ul class="ui-linked-accounts">
{accounts.map((a) => (
<li key={`${a.kind}/${a.name}`}>
<Badge label={accountKindLabel(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 ${accountKindLabel(a.kind)} account ${a.name}`}
onClick={() => openDelete(a)}
/>
)}
</li>
))}
</ul>
{dialog}
</>
);
}