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
|
## Linking an account
|
||||||
|
|
||||||
The swarm UI has no GitLab dialog yet, so link an account with the controller's
|
In the swarm UI, open an agent, choose **link account**, then **gitlab
|
||||||
API. This is the same route family as the other
|
account**. Fill in a label, the instance URL (it starts as `https://gitlab.com`)
|
||||||
[linked accounts](../swarm/ui.md#linking-external-accounts):
|
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
|
```text
|
||||||
PUT /api/hives/{hive}/agents/{agent}/gitlab-accounts/{label}
|
PUT /api/hives/{hive}/agents/{agent}/gitlab-accounts/{label}
|
||||||
|
|
|
||||||
|
|
@ -810,8 +810,7 @@ a link dialog closes.
|
||||||
takes a delete, then a new link.
|
takes a delete, then a new link.
|
||||||
- **link a gitlab account** — `PUT /api/hives/{hive}/agents/{agent}/gitlab-accounts/{label}`
|
- **link a gitlab account** — `PUT /api/hives/{hive}/agents/{agent}/gitlab-accounts/{label}`
|
||||||
with a GitLab base URL and an access token, stored at
|
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;
|
`swarm/agents/<agent>/gitlab/<label>`. Link it from the agent's **link account** menu. What the token needs and how the agent uses
|
||||||
the API is the way to link one. What the token needs and how the agent uses
|
|
||||||
it: [GitLab accounts](../integrations/gitlab.md).
|
it: [GitLab accounts](../integrations/gitlab.md).
|
||||||
- **link a github account** — `PUT /api/hives/{hive}/agents/{agent}/github-account`
|
- **link a github account** — `PUT /api/hives/{hive}/agents/{agent}/github-account`
|
||||||
with a personal access token, stored at `swarm/agents/<agent>/github-token`.
|
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 { Dropdown, type DropdownOption } from "@hive/shared/dropdown.js";
|
||||||
import { LinkIcon } from "@hive/shared/icons.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[] = [
|
const OPTIONS: DropdownOption[] = [
|
||||||
{ value: "matrix", label: "matrix account" },
|
{ value: "matrix", label: "matrix account" },
|
||||||
{ value: "forge", label: "forge account" },
|
{ value: "forge", label: "forge account" },
|
||||||
|
{ value: "gitlab", label: "gitlab account" },
|
||||||
{ value: "github", label: "github account" },
|
{ value: "github", label: "github account" },
|
||||||
{ value: "backend", label: "API-key environment" },
|
{ value: "backend", label: "API-key environment" },
|
||||||
];
|
];
|
||||||
|
|
@ -41,7 +42,7 @@ export function LinkAccountMenu({
|
||||||
disabled={!hasHive}
|
disabled={!hasHive}
|
||||||
title={
|
title={
|
||||||
hasHive
|
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"
|
: "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 { CheckboxField } from "../../ui/checkbox-field/CheckboxField.js";
|
||||||
import { ConfirmDialog } from "../../ui/confirm-dialog/ConfirmDialog.js";
|
import { ConfirmDialog } from "../../ui/confirm-dialog/ConfirmDialog.js";
|
||||||
import { accountKindLabel } from "./account-kind.js";
|
import { accountKindLabel } from "./account-kind.js";
|
||||||
|
import { deleteUrl, listUrl } from "./account-api.js";
|
||||||
import "./LinkedAccounts.css";
|
import "./LinkedAccounts.css";
|
||||||
|
|
||||||
type AccountKind = "matrix" | "forgejo" | "gitlab" | "github" | "backend";
|
type AccountKind = "matrix" | "forgejo" | "gitlab" | "github" | "backend";
|
||||||
|
|
@ -28,23 +29,6 @@ interface LinkedAccount {
|
||||||
reserved: boolean;
|
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({
|
export function LinkedAccounts({
|
||||||
hive,
|
hive,
|
||||||
agent,
|
agent,
|
||||||
|
|
@ -68,9 +52,7 @@ export function LinkedAccounts({
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
(async () => {
|
(async () => {
|
||||||
const r = await fetch(
|
const r = await fetch(listUrl(hive, agent));
|
||||||
`/api/hives/${encodeURIComponent(hive)}/agents/${encodeURIComponent(agent)}/linked-accounts`,
|
|
||||||
);
|
|
||||||
if (!r.ok) {
|
if (!r.ok) {
|
||||||
if (!cancelled) setError(await readApiError(r));
|
if (!cancelled) setError(await readApiError(r));
|
||||||
return;
|
return;
|
||||||
|
|
@ -97,7 +79,7 @@ export function LinkedAccounts({
|
||||||
if (!deleteTarget) return;
|
if (!deleteTarget) return;
|
||||||
setDeleting(true);
|
setDeleting(true);
|
||||||
try {
|
try {
|
||||||
const url = accountUrl(hive, agent, deleteTarget);
|
const url = deleteUrl(hive, agent, deleteTarget);
|
||||||
const r = await fetch(
|
const r = await fetch(
|
||||||
deleteTarget.kind === "matrix" && revoke ? `${url}?revoke=true` : url,
|
deleteTarget.kind === "matrix" && revoke ? `${url}?revoke=true` : url,
|
||||||
{ method: "DELETE" },
|
{ 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 [customTarget, setCustomTarget] = useState<AgentRow | null>(null);
|
||||||
const [matrixTarget, setMatrixTarget] = useState<AgentRow | null>(null);
|
const [matrixTarget, setMatrixTarget] = useState<AgentRow | null>(null);
|
||||||
const [forgeTarget, setForgeTarget] = 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 [githubTarget, setGithubTarget] = useState<AgentRow | null>(null);
|
||||||
const [backendTarget, setBackendTarget] = useState<AgentRow | null>(null);
|
const [backendTarget, setBackendTarget] = useState<AgentRow | null>(null);
|
||||||
const [linkVersion, setLinkVersion] = useState(0);
|
const [linkVersion, setLinkVersion] = useState(0);
|
||||||
|
|
@ -111,6 +112,10 @@ export function useAgentActions(
|
||||||
setForgeTarget(null);
|
setForgeTarget(null);
|
||||||
linkClosed();
|
linkClosed();
|
||||||
};
|
};
|
||||||
|
const closeGitlab = () => {
|
||||||
|
setGitlabTarget(null);
|
||||||
|
linkClosed();
|
||||||
|
};
|
||||||
const closeGithub = () => {
|
const closeGithub = () => {
|
||||||
setGithubTarget(null);
|
setGithubTarget(null);
|
||||||
linkClosed();
|
linkClosed();
|
||||||
|
|
@ -233,12 +238,28 @@ export function useAgentActions(
|
||||||
>
|
>
|
||||||
{forgeTarget?.hive ? (
|
{forgeTarget?.hive ? (
|
||||||
<LinkForgeAccountForm
|
<LinkForgeAccountForm
|
||||||
|
kind="forge"
|
||||||
hive={forgeTarget.hive}
|
hive={forgeTarget.hive}
|
||||||
agent={forgeTarget.name}
|
agent={forgeTarget.name}
|
||||||
onClose={closeForge}
|
onClose={closeForge}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
</Dialog>
|
</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
|
<Dialog
|
||||||
open={githubTarget !== null}
|
open={githubTarget !== null}
|
||||||
onClose={closeGithub}
|
onClose={closeGithub}
|
||||||
|
|
@ -330,6 +351,7 @@ export function useAgentActions(
|
||||||
openLink: (row, kind) => {
|
openLink: (row, kind) => {
|
||||||
if (kind === "matrix") setMatrixTarget(row);
|
if (kind === "matrix") setMatrixTarget(row);
|
||||||
else if (kind === "forge") setForgeTarget(row);
|
else if (kind === "forge") setForgeTarget(row);
|
||||||
|
else if (kind === "gitlab") setGitlabTarget(row);
|
||||||
else if (kind === "github") setGithubTarget(row);
|
else if (kind === "github") setGithubTarget(row);
|
||||||
else setBackendTarget(row);
|
else setBackendTarget(row);
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,11 @@
|
||||||
// <LinkForgeAccountForm> — writes an external forge account (base URL +
|
// <LinkForgeAccountForm> — writes an external forge account (base URL +
|
||||||
// token) for one agent into the swarm secret store.
|
// token) for one agent into the swarm secret store. `kind` picks the
|
||||||
// PUTs `/api/hives/{hive}/agents/{agent}/forge-accounts/{label}` — 200
|
// 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
|
// (`{ url }`) on success, 400/409/500 as `problem+json`, shown via
|
||||||
// `ApiErrorPanel` like `LinkMatrixAccountForm`.
|
// `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
|
// set: no route hands a token back. Linked labels and URLs show on
|
||||||
// the agent panel (`LinkedAccounts`).
|
// the agent panel (`LinkedAccounts`).
|
||||||
import { useState } from "preact/hooks";
|
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 { Panel } from "../ui/panel/Panel.js";
|
||||||
import { TextField } from "../ui/text-field/TextField.js";
|
import { TextField } from "../ui/text-field/TextField.js";
|
||||||
import { Button } from "../ui/button/Button.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";
|
import "./LinkMatrixAccountForm.css";
|
||||||
|
|
||||||
// `hive_types::Ident`, which the server checks: `hive-forge -f` accepts
|
// Prefilled for gitlab; the field stays editable for a self-hosted instance.
|
||||||
// nothing wider. A UX hint only; the server is the gate.
|
const GITLAB_DEFAULT_URL = "https://gitlab.com";
|
||||||
const LABEL_PATTERN = "[a-z0-9\\-]{1,63}";
|
|
||||||
|
|
||||||
type SubmitState =
|
type SubmitState =
|
||||||
| { status: "idle" }
|
| { status: "idle" }
|
||||||
|
|
@ -26,31 +33,35 @@ type SubmitState =
|
||||||
| { status: "error"; problem: ProblemDetails };
|
| { status: "error"; problem: ProblemDetails };
|
||||||
|
|
||||||
export function LinkForgeAccountForm({
|
export function LinkForgeAccountForm({
|
||||||
|
kind,
|
||||||
hive,
|
hive,
|
||||||
agent,
|
agent,
|
||||||
onClose,
|
onClose,
|
||||||
}: {
|
}: {
|
||||||
|
kind: ForgeLinkKind;
|
||||||
hive: string;
|
hive: string;
|
||||||
agent: string;
|
agent: string;
|
||||||
onClose?: () => void;
|
onClose?: () => void;
|
||||||
}) {
|
}) {
|
||||||
const [label, setLabel] = useState("");
|
const [label, setLabel] = useState("");
|
||||||
const [url, setUrl] = useState("");
|
const [url, setUrl] = useState(kind === "gitlab" ? GITLAB_DEFAULT_URL : "");
|
||||||
const [token, setToken] = useState("");
|
const [token, setToken] = useState("");
|
||||||
const [result, setResult] = useState<SubmitState>({ status: "idle" });
|
const [result, setResult] = useState<SubmitState>({ status: "idle" });
|
||||||
|
|
||||||
async function submit(e: Event) {
|
async function submit(e: Event) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
const invalid = checkLink(label, url, token);
|
||||||
|
if (invalid) {
|
||||||
|
setResult({ status: "error", problem: { detail: invalid } });
|
||||||
|
return;
|
||||||
|
}
|
||||||
setResult({ status: "submitting" });
|
setResult({ status: "submitting" });
|
||||||
try {
|
try {
|
||||||
const r = await fetch(
|
const r = await fetch(linkUrl(hive, agent, kind, label), {
|
||||||
`/api/hives/${encodeURIComponent(hive)}/agents/${encodeURIComponent(agent)}/forge-accounts/${encodeURIComponent(label)}`,
|
method: "PUT",
|
||||||
{
|
headers: { "content-type": "application/json" },
|
||||||
method: "PUT",
|
body: linkBody(url, token),
|
||||||
headers: { "content-type": "application/json" },
|
});
|
||||||
body: JSON.stringify({ url: url.trim(), token }),
|
|
||||||
},
|
|
||||||
);
|
|
||||||
if (!r.ok) {
|
if (!r.ok) {
|
||||||
setResult({ status: "error", problem: await readApiError(r) });
|
setResult({ status: "error", problem: await readApiError(r) });
|
||||||
return;
|
return;
|
||||||
|
|
@ -68,38 +79,59 @@ export function LinkForgeAccountForm({
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Panel
|
<Panel
|
||||||
title={`link a forge account — ${agent}`}
|
title={`link a ${kind === "gitlab" ? "gitlab" : "forge"} account — ${agent}`}
|
||||||
icon="🔗"
|
icon="🔗"
|
||||||
onClose={onClose}
|
onClose={onClose}
|
||||||
>
|
>
|
||||||
<p>
|
{kind === "gitlab" ? (
|
||||||
Writes the account to the swarm secret store. The agent fetches it
|
<>
|
||||||
within two minutes and can then use{" "}
|
<p>
|
||||||
<code>hive-forge -f {label || "<label>"}</code>. Create the token on the
|
Writes the account to the swarm secret store. The agent fetches it
|
||||||
external forge first; nothing is created there.
|
within two minutes and can then use <code>glab</code> and{" "}
|
||||||
</p>
|
<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}>
|
<form class="link-matrix-account-form" onSubmit={submit}>
|
||||||
<TextField
|
<TextField
|
||||||
id="forge-account-label"
|
id={`${kind}-account-label`}
|
||||||
label="label"
|
label="label"
|
||||||
value={label}
|
value={label}
|
||||||
pattern={LABEL_PATTERN}
|
pattern={LABEL_PATTERN}
|
||||||
title="1-63 chars: lowercase letters, digits, hyphen"
|
title="1-63 chars: lowercase letters, digits, hyphen"
|
||||||
placeholder="e.g. myforge"
|
placeholder={kind === "gitlab" ? "e.g. gitlab-com" : "e.g. myforge"}
|
||||||
required
|
required
|
||||||
onInput={setLabel}
|
onInput={setLabel}
|
||||||
/>
|
/>
|
||||||
<TextField
|
<TextField
|
||||||
id="forge-account-url"
|
id={`${kind}-account-url`}
|
||||||
label="forge URL"
|
label={kind === "gitlab" ? "instance URL" : "forge URL"}
|
||||||
type="url"
|
type="url"
|
||||||
value={url}
|
value={url}
|
||||||
placeholder="https://forge.example.org"
|
placeholder={
|
||||||
|
kind === "gitlab"
|
||||||
|
? "https://gitlab.example.org"
|
||||||
|
: "https://forge.example.org"
|
||||||
|
}
|
||||||
required
|
required
|
||||||
onInput={setUrl}
|
onInput={setUrl}
|
||||||
/>
|
/>
|
||||||
<TextField
|
<TextField
|
||||||
id="forge-account-token"
|
id={`${kind}-account-token`}
|
||||||
label="access token"
|
label="access token"
|
||||||
type="password"
|
type="password"
|
||||||
value={token}
|
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