swarm-ui: link a gitlab account to an agent
This commit is contained in:
parent
b7d8c0dfc0
commit
3e5b5b54a2
8 changed files with 240 additions and 56 deletions
|
|
@ -11,9 +11,12 @@ from the swarm secret store itself.
|
|||
|
||||
## Linking an account
|
||||
|
||||
The swarm UI has no GitLab dialog yet, so link an account with the controller's
|
||||
API. This is the same route family as the other
|
||||
[linked accounts](../swarm/ui.md#linking-external-accounts):
|
||||
In the swarm UI, open an agent, choose **link account**, then **gitlab
|
||||
account**. Fill in a label, the instance URL (it starts as `https://gitlab.com`)
|
||||
and the token. The form shows the recommended scopes and the controller's
|
||||
answer if it refuses the input. The linked account appears in the agent's list
|
||||
of [linked accounts](../swarm/ui.md#linking-external-accounts) with a delete
|
||||
action. The UI calls the controller's API, which you can also call directly:
|
||||
|
||||
```text
|
||||
PUT /api/hives/{hive}/agents/{agent}/gitlab-accounts/{label}
|
||||
|
|
|
|||
|
|
@ -810,8 +810,7 @@ a link dialog closes.
|
|||
takes a delete, then a new link.
|
||||
- **link a gitlab account** — `PUT /api/hives/{hive}/agents/{agent}/gitlab-accounts/{label}`
|
||||
with a GitLab base URL and an access token, stored at
|
||||
`swarm/agents/<agent>/gitlab/<label>`. The swarm UI has no dialog for it yet;
|
||||
the API is the way to link one. What the token needs and how the agent uses
|
||||
`swarm/agents/<agent>/gitlab/<label>`. Link it from the agent's **link account** menu. What the token needs and how the agent uses
|
||||
it: [GitLab accounts](../integrations/gitlab.md).
|
||||
- **link a github account** — `PUT /api/hives/{hive}/agents/{agent}/github-account`
|
||||
with a personal access token, stored at `swarm/agents/<agent>/github-token`.
|
||||
|
|
|
|||
|
|
@ -8,11 +8,12 @@ import { Badge } from "@hive/shared/badge.js";
|
|||
import { Dropdown, type DropdownOption } from "@hive/shared/dropdown.js";
|
||||
import { LinkIcon } from "@hive/shared/icons.js";
|
||||
|
||||
export type LinkKind = "matrix" | "forge" | "github" | "backend";
|
||||
export type LinkKind = "matrix" | "forge" | "gitlab" | "github" | "backend";
|
||||
|
||||
const OPTIONS: DropdownOption[] = [
|
||||
{ value: "matrix", label: "matrix account" },
|
||||
{ value: "forge", label: "forge account" },
|
||||
{ value: "gitlab", label: "gitlab account" },
|
||||
{ value: "github", label: "github account" },
|
||||
{ value: "backend", label: "API-key environment" },
|
||||
];
|
||||
|
|
@ -41,7 +42,7 @@ export function LinkAccountMenu({
|
|||
disabled={!hasHive}
|
||||
title={
|
||||
hasHive
|
||||
? `link a matrix, forge or github account, or set the API-key environment, for ${agent}`
|
||||
? `link a matrix, forge, gitlab or github account, or set the API-key environment, for ${agent}`
|
||||
: "no hive on record for this agent — nothing to link against"
|
||||
}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ import { Badge } from "@hive/shared/badge.js";
|
|||
import { CheckboxField } from "../../ui/checkbox-field/CheckboxField.js";
|
||||
import { ConfirmDialog } from "../../ui/confirm-dialog/ConfirmDialog.js";
|
||||
import { accountKindLabel } from "./account-kind.js";
|
||||
import { deleteUrl, listUrl } from "./account-api.js";
|
||||
import "./LinkedAccounts.css";
|
||||
|
||||
type AccountKind = "matrix" | "forgejo" | "gitlab" | "github" | "backend";
|
||||
|
|
@ -28,23 +29,6 @@ interface LinkedAccount {
|
|||
reserved: boolean;
|
||||
}
|
||||
|
||||
// The DELETE route for one account, beside the PUT that links it.
|
||||
function accountUrl(hive: string, agent: string, a: LinkedAccount): string {
|
||||
const base = `/api/hives/${encodeURIComponent(hive)}/agents/${encodeURIComponent(agent)}`;
|
||||
switch (a.kind) {
|
||||
case "matrix":
|
||||
return `${base}/matrix-accounts/${encodeURIComponent(a.name)}`;
|
||||
case "forgejo":
|
||||
return `${base}/forge-accounts/${encodeURIComponent(a.name)}`;
|
||||
case "gitlab":
|
||||
return `${base}/gitlab-accounts/${encodeURIComponent(a.name)}`;
|
||||
case "github":
|
||||
return `${base}/github-account`;
|
||||
case "backend":
|
||||
return `${base}/backend-env`;
|
||||
}
|
||||
}
|
||||
|
||||
export function LinkedAccounts({
|
||||
hive,
|
||||
agent,
|
||||
|
|
@ -68,9 +52,7 @@ export function LinkedAccounts({
|
|||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
const r = await fetch(
|
||||
`/api/hives/${encodeURIComponent(hive)}/agents/${encodeURIComponent(agent)}/linked-accounts`,
|
||||
);
|
||||
const r = await fetch(listUrl(hive, agent));
|
||||
if (!r.ok) {
|
||||
if (!cancelled) setError(await readApiError(r));
|
||||
return;
|
||||
|
|
@ -97,7 +79,7 @@ export function LinkedAccounts({
|
|||
if (!deleteTarget) return;
|
||||
setDeleting(true);
|
||||
try {
|
||||
const url = accountUrl(hive, agent, deleteTarget);
|
||||
const url = deleteUrl(hive, agent, deleteTarget);
|
||||
const r = await fetch(
|
||||
deleteTarget.kind === "matrix" && revoke ? `${url}?revoke=true` : url,
|
||||
{ method: "DELETE" },
|
||||
|
|
|
|||
72
frontend/packages/swarm-ui/src/agent/detail/account-api.ts
Normal file
72
frontend/packages/swarm-ui/src/agent/detail/account-api.ts
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
// The routes and request body behind linking, listing and deleting an
|
||||
// external forge account. Pure, so `npm test` runs them without a browser.
|
||||
|
||||
/** The kinds that share the label + URL + token shape. */
|
||||
export type ForgeLinkKind = "forge" | "gitlab";
|
||||
|
||||
/** `hive_types::Ident`, which the controller checks for the label. */
|
||||
export const LABEL_PATTERN = "[a-z0-9\\-]{1,63}";
|
||||
|
||||
function agentBase(hive: string, agent: string): string {
|
||||
return `/api/hives/${encodeURIComponent(hive)}/agents/${encodeURIComponent(agent)}`;
|
||||
}
|
||||
|
||||
/** PUT target for linking `label` under `kind`. */
|
||||
export function linkUrl(
|
||||
hive: string,
|
||||
agent: string,
|
||||
kind: ForgeLinkKind,
|
||||
label: string,
|
||||
): string {
|
||||
const route = kind === "gitlab" ? "gitlab-accounts" : "forge-accounts";
|
||||
return `${agentBase(hive, agent)}/${route}/${encodeURIComponent(label)}`;
|
||||
}
|
||||
|
||||
/** The PUT body; the token is sent as typed, the URL trimmed. */
|
||||
export function linkBody(url: string, token: string): string {
|
||||
return JSON.stringify({ url: url.trim(), token });
|
||||
}
|
||||
|
||||
/** GET target for the agent's linked accounts. */
|
||||
export function listUrl(hive: string, agent: string): string {
|
||||
return `${agentBase(hive, agent)}/linked-accounts`;
|
||||
}
|
||||
|
||||
/** DELETE target for one linked account, as listed by `listUrl`. */
|
||||
export function deleteUrl(
|
||||
hive: string,
|
||||
agent: string,
|
||||
a: { kind: string; name: string },
|
||||
): string {
|
||||
const base = agentBase(hive, agent);
|
||||
const name = encodeURIComponent(a.name);
|
||||
switch (a.kind) {
|
||||
case "matrix":
|
||||
return `${base}/matrix-accounts/${name}`;
|
||||
case "forgejo":
|
||||
return `${base}/forge-accounts/${name}`;
|
||||
case "gitlab":
|
||||
return `${base}/gitlab-accounts/${name}`;
|
||||
case "github":
|
||||
return `${base}/github-account`;
|
||||
default:
|
||||
return `${base}/backend-env`;
|
||||
}
|
||||
}
|
||||
|
||||
/** A message for input the controller would refuse anyway, or null. The
|
||||
* controller's own URL rule stays the gate and its 400 is shown as is. */
|
||||
export function checkLink(
|
||||
label: string,
|
||||
url: string,
|
||||
token: string,
|
||||
): string | null {
|
||||
if (!new RegExp(`^${LABEL_PATTERN}$`).test(label)) {
|
||||
return "label: 1-63 chars, lowercase letters, digits, hyphen";
|
||||
}
|
||||
if (!/^https?:\/\/\S+$/.test(url.trim())) {
|
||||
return "URL must start with http:// or https://";
|
||||
}
|
||||
if (token === "") return "token is required";
|
||||
return null;
|
||||
}
|
||||
|
|
@ -98,6 +98,7 @@ export function useAgentActions(
|
|||
const [customTarget, setCustomTarget] = useState<AgentRow | null>(null);
|
||||
const [matrixTarget, setMatrixTarget] = useState<AgentRow | null>(null);
|
||||
const [forgeTarget, setForgeTarget] = useState<AgentRow | null>(null);
|
||||
const [gitlabTarget, setGitlabTarget] = useState<AgentRow | null>(null);
|
||||
const [githubTarget, setGithubTarget] = useState<AgentRow | null>(null);
|
||||
const [backendTarget, setBackendTarget] = useState<AgentRow | null>(null);
|
||||
const [linkVersion, setLinkVersion] = useState(0);
|
||||
|
|
@ -111,6 +112,10 @@ export function useAgentActions(
|
|||
setForgeTarget(null);
|
||||
linkClosed();
|
||||
};
|
||||
const closeGitlab = () => {
|
||||
setGitlabTarget(null);
|
||||
linkClosed();
|
||||
};
|
||||
const closeGithub = () => {
|
||||
setGithubTarget(null);
|
||||
linkClosed();
|
||||
|
|
@ -233,12 +238,28 @@ export function useAgentActions(
|
|||
>
|
||||
{forgeTarget?.hive ? (
|
||||
<LinkForgeAccountForm
|
||||
kind="forge"
|
||||
hive={forgeTarget.hive}
|
||||
agent={forgeTarget.name}
|
||||
onClose={closeForge}
|
||||
/>
|
||||
) : null}
|
||||
</Dialog>
|
||||
<Dialog
|
||||
open={gitlabTarget !== null}
|
||||
onClose={closeGitlab}
|
||||
label="link a gitlab account"
|
||||
plain
|
||||
>
|
||||
{gitlabTarget?.hive ? (
|
||||
<LinkForgeAccountForm
|
||||
kind="gitlab"
|
||||
hive={gitlabTarget.hive}
|
||||
agent={gitlabTarget.name}
|
||||
onClose={closeGitlab}
|
||||
/>
|
||||
) : null}
|
||||
</Dialog>
|
||||
<Dialog
|
||||
open={githubTarget !== null}
|
||||
onClose={closeGithub}
|
||||
|
|
@ -330,6 +351,7 @@ export function useAgentActions(
|
|||
openLink: (row, kind) => {
|
||||
if (kind === "matrix") setMatrixTarget(row);
|
||||
else if (kind === "forge") setForgeTarget(row);
|
||||
else if (kind === "gitlab") setGitlabTarget(row);
|
||||
else if (kind === "github") setGithubTarget(row);
|
||||
else setBackendTarget(row);
|
||||
},
|
||||
|
|
|
|||
|
|
@ -1,10 +1,11 @@
|
|||
// <LinkForgeAccountForm> — writes an external forge account (base URL +
|
||||
// token) for one agent into the swarm secret store.
|
||||
// PUTs `/api/hives/{hive}/agents/{agent}/forge-accounts/{label}` — 200
|
||||
// token) for one agent into the swarm secret store. `kind` picks the
|
||||
// route and the wording: "forge" (Forgejo) or "gitlab".
|
||||
// PUTs `/api/hives/{hive}/agents/{agent}/{forge,gitlab}-accounts/{label}` — 200
|
||||
// (`{ url }`) on success, 400/409/500 as `problem+json`, shown via
|
||||
// `ApiErrorPanel` like `LinkMatrixAccountForm`.
|
||||
//
|
||||
// The label is what the agent passes to `hive-forge -f <label>`. A blind
|
||||
// A forge label is what the agent passes to `hive-forge -f <label>`. A blind
|
||||
// set: no route hands a token back. Linked labels and URLs show on
|
||||
// the agent panel (`LinkedAccounts`).
|
||||
import { useState } from "preact/hooks";
|
||||
|
|
@ -13,11 +14,17 @@ 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 {
|
||||
checkLink,
|
||||
LABEL_PATTERN,
|
||||
linkBody,
|
||||
linkUrl,
|
||||
type ForgeLinkKind,
|
||||
} from "../agent/detail/account-api.js";
|
||||
import "./LinkMatrixAccountForm.css";
|
||||
|
||||
// `hive_types::Ident`, which the server checks: `hive-forge -f` accepts
|
||||
// nothing wider. A UX hint only; the server is the gate.
|
||||
const LABEL_PATTERN = "[a-z0-9\\-]{1,63}";
|
||||
// Prefilled for gitlab; the field stays editable for a self-hosted instance.
|
||||
const GITLAB_DEFAULT_URL = "https://gitlab.com";
|
||||
|
||||
type SubmitState =
|
||||
| { status: "idle" }
|
||||
|
|
@ -26,31 +33,35 @@ type SubmitState =
|
|||
| { status: "error"; problem: ProblemDetails };
|
||||
|
||||
export function LinkForgeAccountForm({
|
||||
kind,
|
||||
hive,
|
||||
agent,
|
||||
onClose,
|
||||
}: {
|
||||
kind: ForgeLinkKind;
|
||||
hive: string;
|
||||
agent: string;
|
||||
onClose?: () => void;
|
||||
}) {
|
||||
const [label, setLabel] = useState("");
|
||||
const [url, setUrl] = useState("");
|
||||
const [url, setUrl] = useState(kind === "gitlab" ? GITLAB_DEFAULT_URL : "");
|
||||
const [token, setToken] = useState("");
|
||||
const [result, setResult] = useState<SubmitState>({ status: "idle" });
|
||||
|
||||
async function submit(e: Event) {
|
||||
e.preventDefault();
|
||||
const invalid = checkLink(label, url, token);
|
||||
if (invalid) {
|
||||
setResult({ status: "error", problem: { detail: invalid } });
|
||||
return;
|
||||
}
|
||||
setResult({ status: "submitting" });
|
||||
try {
|
||||
const r = await fetch(
|
||||
`/api/hives/${encodeURIComponent(hive)}/agents/${encodeURIComponent(agent)}/forge-accounts/${encodeURIComponent(label)}`,
|
||||
{
|
||||
method: "PUT",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ url: url.trim(), token }),
|
||||
},
|
||||
);
|
||||
const r = await fetch(linkUrl(hive, agent, kind, label), {
|
||||
method: "PUT",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: linkBody(url, token),
|
||||
});
|
||||
if (!r.ok) {
|
||||
setResult({ status: "error", problem: await readApiError(r) });
|
||||
return;
|
||||
|
|
@ -68,38 +79,59 @@ export function LinkForgeAccountForm({
|
|||
|
||||
return (
|
||||
<Panel
|
||||
title={`link a forge account — ${agent}`}
|
||||
title={`link a ${kind === "gitlab" ? "gitlab" : "forge"} account — ${agent}`}
|
||||
icon="🔗"
|
||||
onClose={onClose}
|
||||
>
|
||||
<p>
|
||||
Writes the account to the swarm secret store. The agent fetches it
|
||||
within two minutes and can then use{" "}
|
||||
<code>hive-forge -f {label || "<label>"}</code>. Create the token on the
|
||||
external forge first; nothing is created there.
|
||||
</p>
|
||||
{kind === "gitlab" ? (
|
||||
<>
|
||||
<p>
|
||||
Writes the account to the swarm secret store. The agent fetches it
|
||||
within two minutes and can then use <code>glab</code> and{" "}
|
||||
<code>git push</code> against the instance. Create the token on
|
||||
GitLab first; nothing is created there.
|
||||
</p>
|
||||
<p>
|
||||
Scope the token <code>api</code> and <code>write_repository</code>:
|
||||
the first is for <code>glab</code>, the second for pushing over
|
||||
HTTPS. Use a dedicated bot account; the agent can act within the
|
||||
token's scopes.
|
||||
</p>
|
||||
</>
|
||||
) : (
|
||||
<p>
|
||||
Writes the account to the swarm secret store. The agent fetches it
|
||||
within two minutes and can then use{" "}
|
||||
<code>hive-forge -f {label || "<label>"}</code>. Create the token on
|
||||
the external forge first; nothing is created there.
|
||||
</p>
|
||||
)}
|
||||
<form class="link-matrix-account-form" onSubmit={submit}>
|
||||
<TextField
|
||||
id="forge-account-label"
|
||||
id={`${kind}-account-label`}
|
||||
label="label"
|
||||
value={label}
|
||||
pattern={LABEL_PATTERN}
|
||||
title="1-63 chars: lowercase letters, digits, hyphen"
|
||||
placeholder="e.g. myforge"
|
||||
placeholder={kind === "gitlab" ? "e.g. gitlab-com" : "e.g. myforge"}
|
||||
required
|
||||
onInput={setLabel}
|
||||
/>
|
||||
<TextField
|
||||
id="forge-account-url"
|
||||
label="forge URL"
|
||||
id={`${kind}-account-url`}
|
||||
label={kind === "gitlab" ? "instance URL" : "forge URL"}
|
||||
type="url"
|
||||
value={url}
|
||||
placeholder="https://forge.example.org"
|
||||
placeholder={
|
||||
kind === "gitlab"
|
||||
? "https://gitlab.example.org"
|
||||
: "https://forge.example.org"
|
||||
}
|
||||
required
|
||||
onInput={setUrl}
|
||||
/>
|
||||
<TextField
|
||||
id="forge-account-token"
|
||||
id={`${kind}-account-token`}
|
||||
label="access token"
|
||||
type="password"
|
||||
value={token}
|
||||
|
|
|
|||
73
frontend/packages/swarm-ui/test/account-api.test.mjs
Normal file
73
frontend/packages/swarm-ui/test/account-api.test.mjs
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
// The link / list / delete requests for forge and gitlab accounts, and the
|
||||
// client-side check before a link is sent. Run by `npm test` with Node's
|
||||
// type stripping.
|
||||
import assert from "node:assert/strict";
|
||||
import { test } from "node:test";
|
||||
import {
|
||||
checkLink,
|
||||
deleteUrl,
|
||||
linkBody,
|
||||
linkUrl,
|
||||
listUrl,
|
||||
} from "../src/agent/detail/account-api.ts";
|
||||
|
||||
test("a gitlab link PUTs to gitlab-accounts, a forge link to forge-accounts", () => {
|
||||
assert.equal(
|
||||
linkUrl("h1", "atlas", "gitlab", "gitlab-com"),
|
||||
"/api/hives/h1/agents/atlas/gitlab-accounts/gitlab-com",
|
||||
);
|
||||
assert.equal(
|
||||
linkUrl("h1", "atlas", "forge", "myforge"),
|
||||
"/api/hives/h1/agents/atlas/forge-accounts/myforge",
|
||||
);
|
||||
});
|
||||
|
||||
test("path segments are encoded", () => {
|
||||
assert.equal(
|
||||
linkUrl("a/b", "x y", "gitlab", "l?"),
|
||||
"/api/hives/a%2Fb/agents/x%20y/gitlab-accounts/l%3F",
|
||||
);
|
||||
});
|
||||
|
||||
test("the body carries the trimmed url and the token untouched", () => {
|
||||
assert.deepEqual(JSON.parse(linkBody(" https://gitlab.com ", " tok en ")), {
|
||||
url: "https://gitlab.com",
|
||||
token: " tok en ",
|
||||
});
|
||||
});
|
||||
|
||||
test("list and delete hit the linked-accounts and gitlab routes", () => {
|
||||
assert.equal(
|
||||
listUrl("h1", "atlas"),
|
||||
"/api/hives/h1/agents/atlas/linked-accounts",
|
||||
);
|
||||
assert.equal(
|
||||
deleteUrl("h1", "atlas", { kind: "gitlab", name: "gitlab-com" }),
|
||||
"/api/hives/h1/agents/atlas/gitlab-accounts/gitlab-com",
|
||||
);
|
||||
// Control: the other kinds keep their routes.
|
||||
assert.equal(
|
||||
deleteUrl("h1", "atlas", { kind: "forgejo", name: "f" }),
|
||||
"/api/hives/h1/agents/atlas/forge-accounts/f",
|
||||
);
|
||||
assert.equal(
|
||||
deleteUrl("h1", "atlas", { kind: "github", name: "x" }),
|
||||
"/api/hives/h1/agents/atlas/github-account",
|
||||
);
|
||||
});
|
||||
|
||||
test("checkLink accepts a good input and names what is wrong otherwise", () => {
|
||||
assert.equal(checkLink("gitlab-com", "https://gitlab.com", "t"), null);
|
||||
assert.equal(checkLink("a", " http://gl.lan:8443/x ", "t"), null);
|
||||
assert.match(checkLink("Bad", "https://gitlab.com", "t"), /label/);
|
||||
assert.match(checkLink("", "https://gitlab.com", "t"), /label/);
|
||||
assert.match(checkLink("a".repeat(64), "https://gitlab.com", "t"), /label/);
|
||||
assert.match(checkLink("a", "gitlab.com", "t"), /URL/);
|
||||
assert.match(checkLink("a", "ftp://gitlab.com", "t"), /URL/);
|
||||
assert.match(checkLink("a", "https://gitlab.com", ""), /token/);
|
||||
});
|
||||
|
||||
test("a message never contains the token", () => {
|
||||
const msg = checkLink("Bad", "nope", "s3cr3t-value");
|
||||
assert.ok(!String(msg).includes("s3cr3t-value"));
|
||||
});
|
||||
Loading…
Reference in a new issue