From 767f87610a7d0d07ba62ba2363d3a9fb4141c730 Mon Sep 17 00:00:00 2001 From: iris Date: Tue, 8 Sep 2026 13:51:20 +0200 Subject: [PATCH] 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. --- .../swarm-ui/src/pages/AgentsPage.tsx | 47 ++++++ .../src/pages/LinkMatrixAccountForm.css | 21 +++ .../src/pages/LinkMatrixAccountForm.tsx | 152 ++++++++++++++++++ 3 files changed, 220 insertions(+) create mode 100644 frontend/packages/swarm-ui/src/pages/LinkMatrixAccountForm.css create mode 100644 frontend/packages/swarm-ui/src/pages/LinkMatrixAccountForm.tsx diff --git a/frontend/packages/swarm-ui/src/pages/AgentsPage.tsx b/frontend/packages/swarm-ui/src/pages/AgentsPage.tsx index 5a4d23b6..1ad62ed6 100644 --- a/frontend/packages/swarm-ui/src/pages/AgentsPage.tsx +++ b/frontend/packages/swarm-ui/src/pages/AgentsPage.tsx @@ -32,6 +32,7 @@ import { ApiErrorPanel } from "@hive/shared/api-error-panel.js"; import { readApiError, type ProblemDetails } from "@hive/shared/api-error.js"; import { Badge, type BadgeTone } from "@hive/shared/badge.js"; import { Dropdown, type DropdownOption } from "@hive/shared/dropdown.js"; +import { LinkIcon } from "@hive/shared/icons.js"; import { Button } from "../ui/button/Button.js"; import { ConfirmDialog } from "../ui/confirm-dialog/ConfirmDialog.js"; import { Dialog } from "../ui/dialog/Dialog.js"; @@ -44,6 +45,7 @@ import { } from "../ui/refresh-interval/RefreshInterval.js"; import { Table, type TableColumn } from "../ui/table/Table.js"; import { CreateAgentForm } from "./CreateAgentForm.js"; +import { LinkMatrixAccountForm } from "./LinkMatrixAccountForm.js"; interface ConfigPrStatus { pr_number: number; @@ -200,6 +202,12 @@ export function AgentsPage() { // one's JSX below reads standalone. const [destroyTarget, setDestroyTarget] = useState(null); const [stopTarget, setStopTarget] = useState(null); + // The row currently showing the "link a matrix account" dialog — same + // null-means-closed shape as `destroyTarget`/`stopTarget`, own piece of + // state rather than folded into either since this dialog isn't a + // confirmation of a `declareState` call, it's an unrelated action with + // its own form (`LinkMatrixAccountForm`). + const [matrixTarget, setMatrixTarget] = useState(null); async function refresh() { const res = await fetch("/api/agents/status"); @@ -395,6 +403,30 @@ export function AgentsPage() { ); }, }, + { + key: "matrix", + header: "matrix", + // Icon-only trigger, quiet variant (no permanent pill fill) — same + // "chrome, not a status chip" reasoning as `LinksMenu`/`SettingsMenu`'s + // own `Badge` triggers (see `@hive/shared/badge/Badge.tsx`'s + // `BadgeVariant` doc). Disabled with no hive on record, same guard + // `WantedMenu` uses — the endpoint is hive-scoped, there's nothing + // to PUT against without one. + render: (a) => ( + } + value="link account" + onClick={a.hive ? () => setMatrixTarget(a) : undefined} + disabled={!a.hive} + title={ + a.hive + ? `link a matrix account to ${a.name}` + : "no hive on record for this agent — nothing to link against" + } + /> + ), + }, { key: "config-pr", header: "config PR", @@ -459,6 +491,21 @@ export function AgentsPage() { > + setMatrixTarget(null)} + label="link a matrix account" + > + {/* `matrixTarget.hive` is non-null here — the trigger badge above + is disabled without one, so this can only open with a real + hive to PUT against. */} + {matrixTarget?.hive ? ( + + ) : null} + — rendered inside a `Dialog` from `AgentsPage`, + one panel wide (no second info column — this form has no "what this + creates" explanation worth a whole sibling card the way + `CreateAgentForm` does, just the one-line note already in the JSX). + Column layout + `.link-matrix-account-result-ok` mirror + `CreateAgentForm.css`'s `.create-agent-form`/`.create-agent-result-ok` + exactly — same shape, kept as its own file rather than sharing that + one since "form" here means a different component's layout, not a + generic kit primitive (see that file's own top comment on why + `ui/form-field` stays the only truly shared piece). */ +.link-matrix-account-form { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 0.75em; + margin-top: 1em; +} +.link-matrix-account-result-ok { + margin-top: 1em; + color: var(--green); +} diff --git a/frontend/packages/swarm-ui/src/pages/LinkMatrixAccountForm.tsx b/frontend/packages/swarm-ui/src/pages/LinkMatrixAccountForm.tsx new file mode 100644 index 00000000..49dd93b5 --- /dev/null +++ b/frontend/packages/swarm-ui/src/pages/LinkMatrixAccountForm.tsx @@ -0,0 +1,152 @@ +// — 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({ 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 ( + +

+ Writes the credential to the swarm secret store and notifies{" "} + {hive} to deliver it. The agent's own matrix daemon + picks it up next — nothing here restarts anything directly. +

+
+ ); +}