swarm-ui: link a matrix account to an agent

New per-row action on AgentsPage: a quiet-variant icon badge (LinkIcon,
matching the LinksMenu/SettingsMenu chrome-not-chip convention) opens a
dialog with an account/token/homeserver form, PUTting
/api/hives/{hive}/agents/{agent}/matrix-accounts/{account} per the
contract atlas posted on hyperhive#3726 (issuecomment-72355).

Built against the contract before the backend endpoint exists per
atlas's explicit note that it doesn't change when the implementation
lands -- this 404s until that item merges. No linked-accounts list:
no route exposes one, and a credential store shouldn't hand a secret
back out anyway, so this is a blind set/update action, matching
mara's 1:1-for-now ruling on the issue.

Verified: tsc --noEmit and nix fmt clean, esbuild build clean. Real
DOM-interaction screenshots against a throwaway mock server (deleted
before this commit, never tracked) -- table column render + disabled
state on a hiveless row, dialog open, form filled with the token
masked, and the success path end to end (token field clears, success
message shows) against a mocked 200 response.
This commit is contained in:
iris 2026-09-08 13:51:20 +02:00 • committed by mara
commit 767f87610a
3 changed files with 220 additions and 0 deletions

View file

@ -0,0 +1,152 @@
// <LinkMatrixAccountForm> — writes a matrix account credential (token +
// optional homeserver) for one agent into the swarm secret store.
// PUTs `/api/hives/{hive}/agents/{agent}/matrix-accounts/{account}` per
// the contract atlas posted on the OpenBao swarm-secret-store adoption
// issue: `{ token, homeserver? }` body, 200 on success, 400/503/500 on
// the documented failure arms — all handled generically via
// `readApiError`/`ApiErrorPanel`, same as every other form here, since
// the response is `problem+json` regardless of which arm fired.
//
// No "linked accounts" list to show first: no route exposes one (a
// credential store shouldn't hand a secret back out anyway), so this is
// a blind set/update action, not an edit of something already on
// screen. That matches "make it 1:1 for now, we will split later" and
// "adding them and assigning them should be separate things" — both
// mara's rulings on that issue — there's no assignment step here yet,
// just the write.
//
// Deliberately built against the contract before the endpoint exists
// (atlas: "it does not change when the implementation lands") — until
// item 2 merges this 404s, same as any other page pointed at a route
// that isn't live yet. Nothing here depends on the backend being up to
// be correct.
//
// Rendered inside a `Dialog` from `AgentsPage`, one per row — same
// mount shape as `CreateAgentForm`, own file for the same reason (a
// multi-field form with real submit-state handling is more than
// `WantedMenu`-sized, which is why that one stays inline in
// `AgentsPage.tsx` and this one doesn't).
import { useState } from "preact/hooks";
import { ApiErrorPanel } from "@hive/shared/api-error-panel.js";
import { readApiError, type ProblemDetails } from "@hive/shared/api-error.js";
import { Panel } from "../ui/panel/Panel.js";
import { TextField } from "../ui/text-field/TextField.js";
import { Button } from "../ui/button/Button.js";
import "./LinkMatrixAccountForm.css";
// The store's own key-space, not `hive_types::Ident` — atlas's measured
// gap on the issue: an agent name is an `Ident` (lowercase-only) but an
// account name is a bare attrset key under `matrixAccounts`, so it
// permits e.g. `Ops_Relay9`. `pattern` here is a UX hint only, same
// caveat as `CreateAgentForm`'s `NAME_PATTERN` — the server is the real
// gate, this just avoids a round-trip for an obviously-bad name.
const ACCOUNT_PATTERN = "[A-Za-z0-9_\\-]{1,63}";
type SubmitState =
| { status: "idle" }
| { status: "submitting" }
| { status: "done" }
| { status: "error"; problem: ProblemDetails };
export function LinkMatrixAccountForm({
hive,
agent,
}: {
hive: string;
agent: string;
}) {
const [account, setAccount] = useState("");
const [token, setToken] = useState("");
const [homeserver, setHomeserver] = useState("");
const [result, setResult] = useState<SubmitState>({ status: "idle" });
async function submit(e: Event) {
e.preventDefault();
setResult({ status: "submitting" });
try {
const r = await fetch(
`/api/hives/${encodeURIComponent(hive)}/agents/${encodeURIComponent(agent)}/matrix-accounts/${encodeURIComponent(account)}`,
{
method: "PUT",
headers: { "content-type": "application/json" },
body: JSON.stringify({
token,
// Omitted rather than sent empty — the contract marks it
// optional, and an empty string isn't "no homeserver", it's
// a homeserver named "".
homeserver: homeserver.trim() || undefined,
}),
},
);
if (!r.ok) {
setResult({ status: "error", problem: await readApiError(r) });
return;
}
setResult({ status: "done" });
// Token cleared on success — nothing left needing it in the form
// once the store holds it, and a secret sitting in a field after
// the action that used it is exactly the kind of stale-credential
// risk the whole store exists to avoid. Account name kept, so the
// success message below reads against what actually landed.
setToken("");
} catch (err) {
setResult({ status: "error", problem: { detail: String(err) } });
}
}
return (
<Panel title={`link a matrix account — ${agent}`} icon="🔗">
<p>
Writes the credential to the swarm secret store and notifies{" "}
<strong>{hive}</strong> to deliver it. The agent's own matrix daemon
picks it up next — nothing here restarts anything directly.
</p>
<form class="link-matrix-account-form" onSubmit={submit}>
<TextField
id="matrix-account-name"
label="account"
value={account}
pattern={ACCOUNT_PATTERN}
title="1-63 chars: letters, digits, underscore, hyphen"
placeholder="e.g. primary"
required
onInput={setAccount}
/>
<TextField
id="matrix-account-token"
label="access token"
type="password"
value={token}
required
onInput={setToken}
/>
<TextField
id="matrix-account-homeserver"
label="homeserver (optional)"
type="url"
value={homeserver}
placeholder="https://matrix.example.org"
onInput={setHomeserver}
/>
<Button
variant="primary"
type="submit"
disabled={result.status === "submitting"}
>
{result.status === "submitting" ? "linking…" : "link account"}
</Button>
</form>
{result.status === "done" && (
<p class="link-matrix-account-result-ok">
linked <strong>{account}</strong> to <strong>{agent}</strong>
</p>
)}
{result.status === "error" && (
<ApiErrorPanel
context="failed to link the account"
problem={result.problem}
/>
)}
</Panel>
);
}