The form's credential choice now starts on 'log in with a password', listed first, with a note that the swarm's own login gives the account a device of its own. Choosing 'paste a token' shows a warning: a token from Element or another client belongs to a device that is already logged in, so the agent cannot use its encryption keys and the account will not decrypt encrypted rooms.
251 lines
9.7 KiB
TypeScript
251 lines
9.7 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 stored.
|
|
// PUTs `/api/hives/{hive}/agents/{agent}/matrix-accounts/{account}` —
|
|
// 200 (`{ user_id? }`) on success, 400/409/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.
|
|
//
|
|
// A blind set action: no route hands a token back, so there is
|
|
// nothing to edit. What is already linked shows on the agent panel
|
|
// (`LinkedAccounts`), names and hosts only. 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 { 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 { FormStack } from "../ui/form-stack/FormStack.js";
|
|
import { Hint } from "../ui/hint/Hint.js";
|
|
import { FormResult } from "../ui/submit/FormResult.js";
|
|
import { useSubmit } from "../ui/submit/useSubmit.js";
|
|
|
|
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}";
|
|
|
|
// `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.
|
|
interface Linked {
|
|
account: string;
|
|
userId?: string;
|
|
}
|
|
|
|
const MODE_OPTIONS = [
|
|
{ value: "password", label: "log in with a password" },
|
|
{ value: "token", label: "paste a token" },
|
|
];
|
|
|
|
// What the operator should know before choosing. A login the swarm makes
|
|
// gives the account a device of its own; a token belongs to a device that
|
|
// is already logged in somewhere, whose encryption keys the agent cannot use.
|
|
const PASSWORD_NOTE =
|
|
"The swarm logs in for the agent, so the account gets a device of its own. Link an account only the agent uses: if you also log in to it, you appear to the agent as a second device.";
|
|
const TOKEN_WARNING =
|
|
"A pasted token belongs to a device that is already logged in somewhere. If it came from Element or another client, the agent can't use that device's encryption keys, and the account won't decrypt encrypted rooms. Use a token only if the agent is the first thing to use that device. Prefer a password login.";
|
|
|
|
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>("password");
|
|
const [token, setToken] = useState("");
|
|
const [userId, setUserId] = useState("");
|
|
const [password, setPassword] = useState("");
|
|
const [homeserver, setHomeserver] = useState("");
|
|
const { state, submitting, run } = useSubmit<Linked>();
|
|
|
|
// A secret lives in state only while it is being typed and sent: it is
|
|
// dropped when the request ends, whether it worked or not, and when the
|
|
// credential mode changes.
|
|
function clearSecrets() {
|
|
setToken("");
|
|
setPassword("");
|
|
}
|
|
|
|
function submit(e: Event) {
|
|
e.preventDefault();
|
|
const sent = account;
|
|
void run(
|
|
async () => {
|
|
try {
|
|
return await fetch(
|
|
`/api/hives/${encodeURIComponent(hive)}/agents/${encodeURIComponent(agent)}/matrix-accounts/${encodeURIComponent(sent)}`,
|
|
{
|
|
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(),
|
|
},
|
|
),
|
|
},
|
|
);
|
|
} finally {
|
|
clearSecrets();
|
|
}
|
|
},
|
|
{
|
|
// The account name is kept with the result, so the success message
|
|
// reads against what was sent even if the field is edited afterwards.
|
|
parse: async (r) => {
|
|
const body = (await r.json().catch(() => ({}))) as {
|
|
user_id?: string;
|
|
};
|
|
return { account: sent, userId: body.user_id };
|
|
},
|
|
},
|
|
);
|
|
}
|
|
|
|
return (
|
|
<Panel
|
|
title={`link a matrix account — ${agent}`}
|
|
icon="🔗"
|
|
onClose={onClose}
|
|
>
|
|
<p>
|
|
Writes the credential to the swarm secret store. The agent's own matrix
|
|
daemon reads it from there within two minutes — nothing here restarts
|
|
anything directly.
|
|
</p>
|
|
<FormStack 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 `services.hyperhive.agent.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) => {
|
|
clearSecrets();
|
|
setMode(v as Mode);
|
|
}}
|
|
/>
|
|
<Hint tone={mode === "token" ? "warn" : "muted"}>
|
|
{mode === "token" ? TOKEN_WARNING : PASSWORD_NOTE}
|
|
</Hint>
|
|
{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={submitting}>
|
|
{submitting ? "linking…" : "link account"}
|
|
</Button>
|
|
</FormStack>
|
|
<FormResult
|
|
state={state}
|
|
done={(linked) => (
|
|
<>
|
|
linked <strong>{linked.account}</strong> to <strong>{agent}</strong>
|
|
{linked.userId ? (
|
|
<>
|
|
{" "}
|
|
— logged in as <strong>{linked.userId}</strong>
|
|
</>
|
|
) : null}
|
|
</>
|
|
)}
|
|
failed="failed to link the account"
|
|
/>
|
|
</Panel>
|
|
);
|
|
}
|