swarm-ui link-account forms: keep the sent name, drop secrets, one word for forge
The success line of both link forms read the live text field, so editing the name after a link changed the sentence. The result now carries the name that was sent. The matrix form held the typed token and password after a failed submit, and kept them across a switch of credential mode. It now drops them when the request ends and when the mode changes. The linked-accounts list and the delete dialog said forgejo where the link menu and forms say forge; they now say forge.
This commit is contained in:
parent
8f8217100b
commit
ef156d9d23
6 changed files with 52 additions and 18 deletions
|
|
@ -443,6 +443,9 @@ account of that agent as names and hosts and never a credential.
|
||||||
| forgejo | `swarm/agents/<agent>/forge/<label>` | the label | its `url` |
|
| forgejo | `swarm/agents/<agent>/forge/<label>` | the label | its `url` |
|
||||||
| github | `swarm/agents/<agent>/github-token`, if any | `github` | `github.com` |
|
| github | `swarm/agents/<agent>/github-token`, if any | `github` | `github.com` |
|
||||||
|
|
||||||
|
The route names the second kind `forgejo`; the panel shows it as **forge**,
|
||||||
|
the word the link menu uses.
|
||||||
|
|
||||||
The matrix `main` row is the agent's own account, which the swarm mints;
|
The matrix `main` row is the agent's own account, which the swarm mints;
|
||||||
it carries an **own account** badge. The panel requests the list again when
|
it carries an **own account** badge. The panel requests the list again when
|
||||||
a link dialog closes.
|
a link dialog closes.
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,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 } from "@hive/shared/badge.js";
|
import { Badge } from "@hive/shared/badge.js";
|
||||||
import { ConfirmDialog } from "../../ui/confirm-dialog/ConfirmDialog.js";
|
import { ConfirmDialog } from "../../ui/confirm-dialog/ConfirmDialog.js";
|
||||||
|
import { accountKindLabel } from "./account-kind.js";
|
||||||
import "./LinkedAccounts.css";
|
import "./LinkedAccounts.css";
|
||||||
|
|
||||||
type AccountKind = "matrix" | "forgejo" | "github";
|
type AccountKind = "matrix" | "forgejo" | "github";
|
||||||
|
|
@ -123,7 +124,7 @@ export function LinkedAccounts({
|
||||||
{deleteTarget ? (
|
{deleteTarget ? (
|
||||||
<>
|
<>
|
||||||
<p>
|
<p>
|
||||||
Delete the {deleteTarget.kind} account{" "}
|
Delete the {accountKindLabel(deleteTarget.kind)} account{" "}
|
||||||
<strong>{deleteTarget.name}</strong>
|
<strong>{deleteTarget.name}</strong>
|
||||||
{deleteTarget.host ? (
|
{deleteTarget.host ? (
|
||||||
<>
|
<>
|
||||||
|
|
@ -173,7 +174,7 @@ export function LinkedAccounts({
|
||||||
<ul class="ui-linked-accounts">
|
<ul class="ui-linked-accounts">
|
||||||
{accounts.map((a) => (
|
{accounts.map((a) => (
|
||||||
<li key={`${a.kind}/${a.name}`}>
|
<li key={`${a.kind}/${a.name}`}>
|
||||||
<Badge label={a.kind} value={a.name} />
|
<Badge label={accountKindLabel(a.kind)} value={a.name} />
|
||||||
<span class="ui-linked-accounts-host">{a.host ?? "—"}</span>
|
<span class="ui-linked-accounts-host">{a.host ?? "—"}</span>
|
||||||
{a.reserved ? (
|
{a.reserved ? (
|
||||||
<Badge
|
<Badge
|
||||||
|
|
@ -184,7 +185,7 @@ export function LinkedAccounts({
|
||||||
<Badge
|
<Badge
|
||||||
variant="quiet"
|
variant="quiet"
|
||||||
value="delete"
|
value="delete"
|
||||||
title={`delete the ${a.kind} account ${a.name}`}
|
title={`delete the ${accountKindLabel(a.kind)} account ${a.name}`}
|
||||||
onClick={() => openDelete(a)}
|
onClick={() => openDelete(a)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,6 @@
|
||||||
|
// What the operator calls an account kind. The wire value for a forge
|
||||||
|
// account is "forgejo", but the link menu and form say "forge", so the list
|
||||||
|
// and the delete dialog say it too.
|
||||||
|
export function accountKindLabel(kind: string): string {
|
||||||
|
return kind === "forgejo" ? "forge" : kind;
|
||||||
|
}
|
||||||
|
|
@ -22,7 +22,7 @@ const LABEL_PATTERN = "[a-z0-9\\-]{1,63}";
|
||||||
type SubmitState =
|
type SubmitState =
|
||||||
| { status: "idle" }
|
| { status: "idle" }
|
||||||
| { status: "submitting" }
|
| { status: "submitting" }
|
||||||
| { status: "done"; url: string }
|
| { status: "done"; label: string; url: string }
|
||||||
| { status: "error"; problem: ProblemDetails };
|
| { status: "error"; problem: ProblemDetails };
|
||||||
|
|
||||||
export function LinkForgeAccountForm({
|
export function LinkForgeAccountForm({
|
||||||
|
|
@ -56,7 +56,9 @@ export function LinkForgeAccountForm({
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const body = (await r.json().catch(() => ({}))) as { url?: string };
|
const body = (await r.json().catch(() => ({}))) as { url?: string };
|
||||||
setResult({ status: "done", url: body.url ?? url.trim() });
|
// The label is stored with the result, so the success message reads
|
||||||
|
// against what was sent even if the field is edited afterwards.
|
||||||
|
setResult({ status: "done", label, url: body.url ?? url.trim() });
|
||||||
// The store holds the token now; nothing here needs it.
|
// The store holds the token now; nothing here needs it.
|
||||||
setToken("");
|
setToken("");
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|
@ -114,7 +116,7 @@ export function LinkForgeAccountForm({
|
||||||
</form>
|
</form>
|
||||||
{result.status === "done" && (
|
{result.status === "done" && (
|
||||||
<p class="link-matrix-account-result-ok">
|
<p class="link-matrix-account-result-ok">
|
||||||
linked <strong>{label}</strong> ({result.url}) to{" "}
|
linked <strong>{result.label}</strong> ({result.url}) to{" "}
|
||||||
<strong>{agent}</strong>
|
<strong>{agent}</strong>
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -51,7 +51,7 @@ type SubmitState =
|
||||||
// login response) and `undefined` in token mode — see
|
// login response) and `undefined` in token mode — see
|
||||||
// `PutMatrixAccountResponse`'s own doc for why token mode doesn't
|
// `PutMatrixAccountResponse`'s own doc for why token mode doesn't
|
||||||
// spend a round trip resolving one.
|
// spend a round trip resolving one.
|
||||||
| { status: "done"; userId?: string }
|
| { status: "done"; account: string; userId?: string }
|
||||||
| { status: "error"; problem: ProblemDetails };
|
| { status: "error"; problem: ProblemDetails };
|
||||||
|
|
||||||
const MODE_OPTIONS = [
|
const MODE_OPTIONS = [
|
||||||
|
|
@ -79,6 +79,14 @@ export function LinkMatrixAccountForm({
|
||||||
const [homeserver, setHomeserver] = useState("");
|
const [homeserver, setHomeserver] = useState("");
|
||||||
const [result, setResult] = useState<SubmitState>({ status: "idle" });
|
const [result, setResult] = useState<SubmitState>({ status: "idle" });
|
||||||
|
|
||||||
|
// 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("");
|
||||||
|
}
|
||||||
|
|
||||||
async function submit(e: Event) {
|
async function submit(e: Event) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setResult({ status: "submitting" });
|
setResult({ status: "submitting" });
|
||||||
|
|
@ -109,6 +117,7 @@ export function LinkMatrixAccountForm({
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
clearSecrets();
|
||||||
if (!r.ok) {
|
if (!r.ok) {
|
||||||
setResult({ status: "error", problem: await readApiError(r) });
|
setResult({ status: "error", problem: await readApiError(r) });
|
||||||
return;
|
return;
|
||||||
|
|
@ -116,16 +125,11 @@ export function LinkMatrixAccountForm({
|
||||||
const body = (await r.json().catch(() => ({}))) as {
|
const body = (await r.json().catch(() => ({}))) as {
|
||||||
user_id?: string;
|
user_id?: string;
|
||||||
};
|
};
|
||||||
setResult({ status: "done", userId: body.user_id });
|
// The account name is stored with the result, so the success message
|
||||||
// Secrets cleared on success — nothing left needing them in the
|
// reads against what was sent even if the field is edited afterwards.
|
||||||
// form once the store holds the derived credential, and a secret
|
setResult({ status: "done", account, userId: body.user_id });
|
||||||
// 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) {
|
} catch (err) {
|
||||||
|
clearSecrets();
|
||||||
setResult({ status: "error", problem: { detail: String(err) } });
|
setResult({ status: "error", problem: { detail: String(err) } });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -163,7 +167,10 @@ export function LinkMatrixAccountForm({
|
||||||
label="credential"
|
label="credential"
|
||||||
value={mode}
|
value={mode}
|
||||||
options={MODE_OPTIONS}
|
options={MODE_OPTIONS}
|
||||||
onChange={(v) => setMode(v as Mode)}
|
onChange={(v) => {
|
||||||
|
clearSecrets();
|
||||||
|
setMode(v as Mode);
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
{mode === "token" ? (
|
{mode === "token" ? (
|
||||||
<TextField
|
<TextField
|
||||||
|
|
@ -218,7 +225,7 @@ export function LinkMatrixAccountForm({
|
||||||
</form>
|
</form>
|
||||||
{result.status === "done" && (
|
{result.status === "done" && (
|
||||||
<p class="link-matrix-account-result-ok">
|
<p class="link-matrix-account-result-ok">
|
||||||
linked <strong>{account}</strong> to <strong>{agent}</strong>
|
linked <strong>{result.account}</strong> to <strong>{agent}</strong>
|
||||||
{result.userId ? (
|
{result.userId ? (
|
||||||
<>
|
<>
|
||||||
{" "}
|
{" "}
|
||||||
|
|
|
||||||
15
frontend/packages/swarm-ui/test/account-kind.test.mjs
Normal file
15
frontend/packages/swarm-ui/test/account-kind.test.mjs
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
// The word the linked-accounts list uses for an account kind. Run by `npm
|
||||||
|
// test`, which Node runs with its built-in test runner and type stripping,
|
||||||
|
// so the `.ts` module is imported as it is.
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { test } from "node:test";
|
||||||
|
import { accountKindLabel } from "../src/agent/detail/account-kind.ts";
|
||||||
|
|
||||||
|
test("a forgejo account is called a forge account, like the link menu does", () => {
|
||||||
|
assert.equal(accountKindLabel("forgejo"), "forge");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("the other kinds keep their names", () => {
|
||||||
|
assert.equal(accountKindLabel("matrix"), "matrix");
|
||||||
|
assert.equal(accountKindLabel("github"), "github");
|
||||||
|
});
|
||||||
Loading…
Reference in a new issue