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:
parent
0d88ca5e7f
commit
767f87610a
3 changed files with 220 additions and 0 deletions
|
|
@ -32,6 +32,7 @@ import { ApiErrorPanel } from "@hive/shared/api-error-panel.js";
|
||||||
import { readApiError, type ProblemDetails } from "@hive/shared/api-error.js";
|
import { readApiError, type ProblemDetails } from "@hive/shared/api-error.js";
|
||||||
import { Badge, type BadgeTone } from "@hive/shared/badge.js";
|
import { Badge, type BadgeTone } from "@hive/shared/badge.js";
|
||||||
import { Dropdown, type DropdownOption } from "@hive/shared/dropdown.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 { Button } from "../ui/button/Button.js";
|
||||||
import { ConfirmDialog } from "../ui/confirm-dialog/ConfirmDialog.js";
|
import { ConfirmDialog } from "../ui/confirm-dialog/ConfirmDialog.js";
|
||||||
import { Dialog } from "../ui/dialog/Dialog.js";
|
import { Dialog } from "../ui/dialog/Dialog.js";
|
||||||
|
|
@ -44,6 +45,7 @@ import {
|
||||||
} from "../ui/refresh-interval/RefreshInterval.js";
|
} from "../ui/refresh-interval/RefreshInterval.js";
|
||||||
import { Table, type TableColumn } from "../ui/table/Table.js";
|
import { Table, type TableColumn } from "../ui/table/Table.js";
|
||||||
import { CreateAgentForm } from "./CreateAgentForm.js";
|
import { CreateAgentForm } from "./CreateAgentForm.js";
|
||||||
|
import { LinkMatrixAccountForm } from "./LinkMatrixAccountForm.js";
|
||||||
|
|
||||||
interface ConfigPrStatus {
|
interface ConfigPrStatus {
|
||||||
pr_number: number;
|
pr_number: number;
|
||||||
|
|
@ -200,6 +202,12 @@ export function AgentsPage() {
|
||||||
// one's JSX below reads standalone.
|
// one's JSX below reads standalone.
|
||||||
const [destroyTarget, setDestroyTarget] = useState<AgentRow | null>(null);
|
const [destroyTarget, setDestroyTarget] = useState<AgentRow | null>(null);
|
||||||
const [stopTarget, setStopTarget] = useState<AgentRow | null>(null);
|
const [stopTarget, setStopTarget] = useState<AgentRow | null>(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<AgentRow | null>(null);
|
||||||
|
|
||||||
async function refresh() {
|
async function refresh() {
|
||||||
const res = await fetch("/api/agents/status");
|
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) => (
|
||||||
|
<Badge
|
||||||
|
variant="quiet"
|
||||||
|
icon={<LinkIcon />}
|
||||||
|
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",
|
key: "config-pr",
|
||||||
header: "config PR",
|
header: "config PR",
|
||||||
|
|
@ -459,6 +491,21 @@ export function AgentsPage() {
|
||||||
>
|
>
|
||||||
<CreateAgentForm />
|
<CreateAgentForm />
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
<Dialog
|
||||||
|
open={matrixTarget !== null}
|
||||||
|
onClose={() => 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 ? (
|
||||||
|
<LinkMatrixAccountForm
|
||||||
|
hive={matrixTarget.hive}
|
||||||
|
agent={matrixTarget.name}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</Dialog>
|
||||||
<ConfirmDialog
|
<ConfirmDialog
|
||||||
open={stopTarget !== null}
|
open={stopTarget !== null}
|
||||||
label="stop agent"
|
label="stop agent"
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,21 @@
|
||||||
|
/* <LinkMatrixAccountForm> — 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);
|
||||||
|
}
|
||||||
152
frontend/packages/swarm-ui/src/pages/LinkMatrixAccountForm.tsx
Normal file
152
frontend/packages/swarm-ui/src/pages/LinkMatrixAccountForm.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue