Dialog and Panel both drew their own bordered/backgrounded card in the same --bg-elev, so a Panel-based dialog (create-agent, link-matrix-account) rendered as two concentric cards with a floating close button on the outer one and no purpose to it. Give Dialog a "plain" mode that drops its own card chrome (border, background, padding) and floating close button, and give Panel an optional onClose that renders a close button at the end of its own header row instead. AgentsPage's two Panel-backed dialogs now use plain + Panel's onClose, so the Panel is the dialog's only visible card. ConfirmDialog (no Panel of its own) is unaffected — plain defaults to false, unchanged card + floating close button. Added a ComponentsPage sample demonstrating the plain + onClose pairing. Verified both dialog modes via a real headless-chromium screenshot (plain dialog: single card, close button in the header bar; default dialog: unchanged floating close button).
239 lines
9.1 KiB
TypeScript
239 lines
9.1 KiB
TypeScript
// <LinkMatrixAccountForm> — 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<Mode>("token");
|
|
const [token, setToken] = useState("");
|
|
const [userId, setUserId] = useState("");
|
|
const [password, setPassword] = 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(
|
|
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 (
|
|
<Panel
|
|
title={`link a matrix account — ${agent}`}
|
|
icon="🔗"
|
|
onClose={onClose}
|
|
>
|
|
<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"
|
|
// Not "primary" — that reads as naming the hive-provided default
|
|
// account (`main`, reserved: see `hyperhive.matrixAccounts`'s own
|
|
// module doc), which this route can neither create nor touch.
|
|
// Picking a name with no relationship to that default avoids the
|
|
// misread entirely, rather than requiring a reader to already know
|
|
// the reservation to see through it.
|
|
placeholder="e.g. ops-relay"
|
|
required
|
|
onInput={setAccount}
|
|
/>
|
|
<SelectField
|
|
id="matrix-account-mode"
|
|
label="credential"
|
|
value={mode}
|
|
options={MODE_OPTIONS}
|
|
onChange={(v) => setMode(v as Mode)}
|
|
/>
|
|
{mode === "token" ? (
|
|
<TextField
|
|
id="matrix-account-token"
|
|
label="access token"
|
|
type="password"
|
|
value={token}
|
|
required
|
|
onInput={setToken}
|
|
/>
|
|
) : (
|
|
<>
|
|
<TextField
|
|
id="matrix-account-user-id"
|
|
label="matrix user id"
|
|
placeholder="@name:matrix.example.org"
|
|
value={userId}
|
|
required
|
|
onInput={setUserId}
|
|
/>
|
|
<TextField
|
|
id="matrix-account-password"
|
|
label="password"
|
|
type="password"
|
|
value={password}
|
|
required
|
|
onInput={setPassword}
|
|
/>
|
|
</>
|
|
)}
|
|
<TextField
|
|
id="matrix-account-homeserver"
|
|
// Optional in token mode (the hive falls back to its own
|
|
// default); required in password mode, since swarm-controller
|
|
// logs in against exactly this URL and has no fallback of its
|
|
// own to defer to — see `PutMatrixAccountRequest::homeserver`'s
|
|
// own doc for why the two modes differ here.
|
|
label={mode === "token" ? "homeserver (optional)" : "homeserver"}
|
|
type="url"
|
|
value={homeserver}
|
|
placeholder="https://matrix.example.org"
|
|
required={mode === "password"}
|
|
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>
|
|
{result.userId ? (
|
|
<>
|
|
{" "}
|
|
— logged in as <strong>{result.userId}</strong>
|
|
</>
|
|
) : null}
|
|
</p>
|
|
)}
|
|
{result.status === "error" && (
|
|
<ApiErrorPanel
|
|
context="failed to link the account"
|
|
problem={result.problem}
|
|
/>
|
|
)}
|
|
</Panel>
|
|
);
|
|
}
|