// — writes a matrix account credential for one // agent into the swarm secret store, in one of two modes (mirrors // swarm-controller's `PutMatrixAccountRequest`): // token — paste an already-obtained bearer token, optional homeserver. // password — user id + password; swarm-controller logs into the // (required, here) homeserver itself and stores the token // that comes back. The password is sent once, over this // PUT, straight to swarm-controller — never held here past // the request, never sent to the hive. // PUTs `/api/hives/{hive}/agents/{agent}/matrix-accounts/{account}` — // 200 (`{ user_id? }`) 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. // // 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 { SelectField } from "../ui/select-field/SelectField.js"; import { Button } from "../ui/button/Button.js"; import "./LinkMatrixAccountForm.css"; type Mode = "token" | "password"; // 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" } // `userId` is set in password mode (the server resolves it from the // login response) and `undefined` in token mode — see // `PutMatrixAccountResponse`'s own doc for why token mode doesn't // spend a round trip resolving one. | { status: "done"; userId?: string } | { status: "error"; problem: ProblemDetails }; const MODE_OPTIONS = [ { value: "token", label: "paste a token" }, { value: "password", label: "log in with a password" }, ]; export function LinkMatrixAccountForm({ hive, agent, onClose, }: { hive: string; agent: string; /** Rendered as the panel header's own close button — this form always * mounts inside a `plain` `Dialog` (see `AgentsPage`), which no longer * floats its own. */ onClose?: () => void; }) { const [account, setAccount] = useState(""); const [mode, setMode] = useState("token"); const [token, setToken] = useState(""); const [userId, setUserId] = useState(""); const [password, setPassword] = 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( mode === "token" ? { mode, token, // Omitted rather than sent empty — the contract marks // it optional in token mode, and an empty string // isn't "no homeserver", it's a homeserver named "". homeserver: homeserver.trim() || undefined, } : { mode, user_id: userId, password, // Required in password mode — the field below enforces // that before this branch is ever reachable. homeserver: homeserver.trim(), }, ), }, ); if (!r.ok) { setResult({ status: "error", problem: await readApiError(r) }); return; } const body = (await r.json().catch(() => ({}))) as { user_id?: string; }; setResult({ status: "done", userId: body.user_id }); // Secrets cleared on success — nothing left needing them in the // form once the store holds the derived credential, 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(""); setPassword(""); } 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.

); }