matrix accounts: server-side password login for swarm-controller
Extends PutMatrixAccountRequest with a mode field (token, the existing behavior and default; or password). Password mode has swarm-controller itself perform m.login.password against the caller-given homeserver (mirrors hive-c0re's own /api/matrix-account-login for the hive-local case) and stores the resulting token instead of a caller-supplied one -- the password is used once, over this PUT, and never stored. Also adds the 'main is reserved' guard hive-c0re's login form already has, which swarm-controller had no equivalent of before this. swarm-ui's link-matrix-account form gets a credential-mode toggle wired to the same contract: token mode is unchanged, password mode swaps the token field for user-id + password fields and makes homeserver required (no hive-side fallback to resolve it against, per PutMatrixAccountRequest::homeserver's own doc). Per #4122.
This commit is contained in:
parent
cc8fb0ee44
commit
52446cb273
2 changed files with 263 additions and 45 deletions
|
|
@ -1,11 +1,17 @@
|
|||
// <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.
|
||||
// <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
|
||||
|
|
@ -15,12 +21,6 @@
|
|||
// 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
|
||||
|
|
@ -31,9 +31,12 @@ 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
|
||||
|
|
@ -45,9 +48,18 @@ const ACCOUNT_PATTERN = "[A-Za-z0-9_\\-]{1,63}";
|
|||
type SubmitState =
|
||||
| { status: "idle" }
|
||||
| { status: "submitting" }
|
||||
| { status: "done" }
|
||||
// `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,
|
||||
|
|
@ -56,7 +68,10 @@ export function LinkMatrixAccountForm({
|
|||
agent: string;
|
||||
}) {
|
||||
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" });
|
||||
|
||||
|
|
@ -69,26 +84,43 @@ export function LinkMatrixAccountForm({
|
|||
{
|
||||
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,
|
||||
}),
|
||||
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;
|
||||
}
|
||||
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.
|
||||
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) } });
|
||||
}
|
||||
|
|
@ -118,20 +150,54 @@ export function LinkMatrixAccountForm({
|
|||
required
|
||||
onInput={setAccount}
|
||||
/>
|
||||
<TextField
|
||||
id="matrix-account-token"
|
||||
label="access token"
|
||||
type="password"
|
||||
value={token}
|
||||
required
|
||||
onInput={setToken}
|
||||
<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"
|
||||
label="homeserver (optional)"
|
||||
// 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
|
||||
|
|
@ -145,6 +211,12 @@ export function LinkMatrixAccountForm({
|
|||
{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" && (
|
||||
|
|
|
|||
Loading…
Reference in a new issue