The CR3D3NTIALS page's MATRIX tab was the only caller of `POST /api/matrix-account-login` (provision/log in an external matrix account through the hive) and `GET /api/matrix-accounts` (its account list). External matrix accounts are linked from the swarm UI now (`LinkMatrixAccountForm` -> swarm-controller), so the hive-side UI and both routes go. `priv_client::restart_matrix_daemon` had no other caller and goes with them. Already-provisioned credentials keep working: the `matrix-token-<name>` files and `matrix-account-<name>.json` sidecars the old route wrote are still discovered by hive-matrix-mcp (`accounts::configured` -> `discover_token_accounts`), the `matrix-token*` path unit still re-fires the daemon, and `WriteAgentMatrixToken` stays for the swarm credential worker. Removing that usage waits on moving the existing creds to swarm level. The GITHUB tab is the credentials page's default tab now. Refs #4348
383 lines
12 KiB
JavaScript
383 lines
12 KiB
JavaScript
// CR3D3NTIALS page entry (/credentials.html).
|
|
//
|
|
// Operator surface to provision per-agent credentials without editing the
|
|
// agent's config repo. Two sub-tabs, sharing one agent picker:
|
|
// GITHUB — single-account PAT paste against /api/github-account
|
|
// (GET -> {present}, POST form-encoded {agent, token} ->
|
|
// {ok:true}; error_response shape on failure).
|
|
// No account name / homeserver / login mode, and no
|
|
// live/heartbeat concept for a static PAT — just present/absent.
|
|
// FORGES — external forge accounts, entirely dashboard-provisioned (no
|
|
// host-side config): GET /api/extra-forges?agent= lists the
|
|
// agent's stored {label, base_url} pairs, POST
|
|
// /api/extra-forge-account (form agent/label/base_url/token/
|
|
// action=add|remove) stores or removes one. No remote account
|
|
// creation — the operator makes the token on the external forge
|
|
// themselves and pastes it in, same trust model as GITHUB.
|
|
// Per-tab detail comments live next to their section below.
|
|
|
|
import { $, esc, renderServerWarnings } from "./common.js";
|
|
import { el } from "@hive/shared/dom.js";
|
|
import "@hive/shared/hive-tab-strip.js";
|
|
import { themedConfirm, themedToast } from "@hive/shared/modal.js";
|
|
import { readApiError, problemMessage } from "@hive/shared/api-error.js";
|
|
|
|
let agents = [];
|
|
|
|
async function loadState() {
|
|
try {
|
|
const resp = await fetch("/api/state");
|
|
if (!resp.ok) return;
|
|
const s = await resp.json();
|
|
renderServerWarnings(s.server_warnings);
|
|
// `/api/state` exposes the live roster under `containers` (each entry an
|
|
// object carrying `.name` + `.running`); there is no top-level `agents`
|
|
// field, so the picker stays compatible with both string + object shapes.
|
|
const containers = (s.containers || [])
|
|
.map((a) => (typeof a === "string" ? { name: a } : a))
|
|
.filter((c) => c && c.name);
|
|
agents = containers.map((c) => c.name).sort();
|
|
} catch {
|
|
// best-effort: on a failed state read the picker renders empty
|
|
// ("— no agents —") and the submit guard blocks until an agent is
|
|
// selected, rather than guessing a roster.
|
|
}
|
|
}
|
|
|
|
function renderAgentPicker() {
|
|
const sel = $("ma-agent");
|
|
sel.replaceChildren();
|
|
if (!agents.length) {
|
|
sel.append(el("option", { value: "" }, "— no agents —"));
|
|
return;
|
|
}
|
|
sel.append(el("option", { value: "" }, "— select agent —"));
|
|
for (const a of agents) sel.append(el("option", { value: a }, a));
|
|
}
|
|
|
|
// Shape-agnostic error-body parsing (shared by both tabs' submit handlers)
|
|
// lives in `@hive/shared/api-error.js` now — `readApiError` +
|
|
// `problemMessage` (this page only needs the one-line message, not the
|
|
// full `ApiErrorPanel`; its result lines are single-line `aria-live`
|
|
// regions, not a swap-in-a-panel context). Was a local function here
|
|
// originally; promoted so swarm-ui shares the same
|
|
// shape-agnostic reader instead of each side maintaining its own copy.
|
|
|
|
function clearSecrets(formEl) {
|
|
formEl
|
|
.querySelectorAll('input[type="password"], input[name="token"]')
|
|
.forEach((i) => {
|
|
i.value = "";
|
|
});
|
|
}
|
|
|
|
// ─── GITHUB tab ─────────────────────────────────────────────────────────
|
|
|
|
async function loadGithubStatus(agent) {
|
|
const status = $("gh-status");
|
|
if (!agent) {
|
|
status.replaceChildren(
|
|
el(
|
|
"p",
|
|
{ class: "meta" },
|
|
"select an agent to see its github credential status.",
|
|
),
|
|
);
|
|
return;
|
|
}
|
|
status.replaceChildren(el("p", { class: "meta" }, "loading…"));
|
|
let data;
|
|
try {
|
|
const resp = await fetch(
|
|
"/api/github-account?agent=" + encodeURIComponent(agent),
|
|
);
|
|
if (!resp.ok) throw new Error("HTTP " + resp.status);
|
|
data = await resp.json();
|
|
} catch (err) {
|
|
status.replaceChildren(
|
|
el(
|
|
"p",
|
|
{ class: "err" },
|
|
"could not load status: " +
|
|
esc(String(err)) +
|
|
" (the backend endpoint may not be deployed yet).",
|
|
),
|
|
);
|
|
return;
|
|
}
|
|
const present = !!data.present;
|
|
status.replaceChildren(
|
|
el(
|
|
"div",
|
|
{ class: "gh-status-line" },
|
|
el("span", { class: "gh-dot " + (present ? "present" : "absent") }),
|
|
el(
|
|
"span",
|
|
{ class: "gh-status-text " + (present ? "present" : "absent") },
|
|
present ? "token stored ✓" : "not set",
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
async function submitGithub(e) {
|
|
e.preventDefault();
|
|
const formEl = e.target;
|
|
const out = $("gh-result");
|
|
out.className = "ma-result";
|
|
out.textContent = "";
|
|
|
|
const agent = $("ma-agent").value;
|
|
if (!agent) {
|
|
out.className = "ma-result err";
|
|
out.textContent = "select an agent first.";
|
|
return;
|
|
}
|
|
|
|
const fd = new FormData(formEl);
|
|
fd.set("agent", agent);
|
|
|
|
const btn = formEl.querySelector('button[type="submit"]');
|
|
const orig = btn.textContent;
|
|
btn.disabled = true;
|
|
btn.textContent = "storing…";
|
|
|
|
try {
|
|
const resp = await fetch("/api/github-account", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
body: new URLSearchParams(fd),
|
|
});
|
|
|
|
if (resp.ok) {
|
|
let body = {};
|
|
try {
|
|
body = await resp.json();
|
|
} catch {
|
|
/* tolerate odd 2xx body */
|
|
}
|
|
if (body.ok) {
|
|
out.className = "ma-result ok";
|
|
out.textContent = "✓ token stored.";
|
|
clearSecrets(formEl);
|
|
loadGithubStatus(agent);
|
|
} else {
|
|
out.className = "ma-result err";
|
|
out.textContent = "✗ store failed (unexpected response).";
|
|
clearSecrets(formEl);
|
|
}
|
|
} else {
|
|
const msg = problemMessage(await readApiError(resp));
|
|
out.className = "ma-result err";
|
|
out.textContent =
|
|
"✗ " + (msg || "store failed (HTTP " + resp.status + ")");
|
|
clearSecrets(formEl);
|
|
}
|
|
} catch (err) {
|
|
out.className = "ma-result err";
|
|
out.textContent =
|
|
"✗ request failed: " +
|
|
String(err) +
|
|
" (the backend endpoint may not be deployed yet).";
|
|
} finally {
|
|
btn.disabled = false;
|
|
btn.textContent = orig;
|
|
}
|
|
}
|
|
|
|
// ─── FORGES tab ─────────────────────────────────────────────────────────
|
|
// Entirely dashboard-provisioned, no host-side nix config: per-agent list
|
|
// (GET /api/extra-forges?agent=, derived from the agent's own
|
|
// forge-<label>-token files) + an add form (POST /api/extra-forge-account,
|
|
// form label/base_url/token, action=add) and a remove button per row
|
|
// (same POST, action=remove). No remote account creation — purely local
|
|
// bookkeeping for a token the operator already created on the external
|
|
// forge themselves.
|
|
|
|
async function loadForgeAccounts(agent) {
|
|
const list = $("ef-list");
|
|
if (!agent) {
|
|
list.replaceChildren(
|
|
el("p", { class: "meta" }, "select an agent to see its forge accounts."),
|
|
);
|
|
return;
|
|
}
|
|
list.replaceChildren(el("p", { class: "meta" }, "loading…"));
|
|
let forges;
|
|
try {
|
|
const resp = await fetch(
|
|
"/api/extra-forges?agent=" + encodeURIComponent(agent),
|
|
);
|
|
if (!resp.ok) throw new Error("HTTP " + resp.status);
|
|
forges = (await resp.json()).forges || [];
|
|
} catch (err) {
|
|
list.replaceChildren(
|
|
el(
|
|
"p",
|
|
{ class: "err" },
|
|
"could not load forge accounts: " +
|
|
esc(String(err)) +
|
|
" (the backend endpoint may not be deployed yet).",
|
|
),
|
|
);
|
|
return;
|
|
}
|
|
|
|
list.replaceChildren();
|
|
if (!forges.length) {
|
|
list.replaceChildren(
|
|
el("p", { class: "meta" }, "no forge accounts stored for this agent."),
|
|
);
|
|
return;
|
|
}
|
|
const ul = el("ul", { class: "ma-accounts" });
|
|
for (const forge of forges) {
|
|
const btn = el("button", { class: "btn", type: "button" }, "remove");
|
|
btn.addEventListener("click", () => onForgeRemoveClick(agent, forge, btn));
|
|
ul.append(
|
|
el(
|
|
"li",
|
|
{ class: "ma-account" },
|
|
el("span", { class: "ma-dot ok" }),
|
|
el("span", { class: "ma-name" }, forge.label),
|
|
el("span", { class: "ma-hs" }, forge.base_url || "—"),
|
|
el("span", { class: "ma-status ok" }, "token stored ✓"),
|
|
btn,
|
|
),
|
|
);
|
|
}
|
|
list.append(ul);
|
|
}
|
|
|
|
async function onForgeRemoveClick(agent, forge, btn) {
|
|
const r = await themedConfirm({
|
|
message: `remove ${agent}'s stored token for ${forge.label}? this only deletes the local copy — nothing changes on the remote forge.`,
|
|
danger: true,
|
|
confirmLabel: "⊘ remove",
|
|
});
|
|
if (!r) return;
|
|
|
|
btn.disabled = true;
|
|
const orig = btn.textContent;
|
|
btn.textContent = "removing…";
|
|
try {
|
|
const resp = await fetch("/api/extra-forge-account", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
body: new URLSearchParams({
|
|
agent,
|
|
label: forge.label,
|
|
action: "remove",
|
|
}),
|
|
});
|
|
if (resp.ok) {
|
|
loadForgeAccounts(agent);
|
|
return;
|
|
}
|
|
const msg = problemMessage(await readApiError(resp));
|
|
btn.textContent = orig;
|
|
btn.disabled = false;
|
|
themedToast("✗ " + (msg || "remove failed (HTTP " + resp.status + ")"), {
|
|
type: "error",
|
|
});
|
|
} catch (err) {
|
|
btn.textContent = orig;
|
|
btn.disabled = false;
|
|
themedToast("✗ request failed: " + String(err), { type: "error" });
|
|
}
|
|
}
|
|
|
|
async function submitForgeAccount(e) {
|
|
e.preventDefault();
|
|
const formEl = e.target;
|
|
const out = $("ef-result");
|
|
out.className = "ma-result";
|
|
out.textContent = "";
|
|
|
|
const agent = $("ma-agent").value;
|
|
if (!agent) {
|
|
out.className = "ma-result err";
|
|
out.textContent = "select an agent first.";
|
|
return;
|
|
}
|
|
|
|
const fd = new FormData(formEl);
|
|
fd.set("agent", agent);
|
|
fd.set("action", "add");
|
|
|
|
const btn = formEl.querySelector('button[type="submit"]');
|
|
const orig = btn.textContent;
|
|
btn.disabled = true;
|
|
btn.textContent = "storing…";
|
|
|
|
try {
|
|
const resp = await fetch("/api/extra-forge-account", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
body: new URLSearchParams(fd),
|
|
});
|
|
|
|
if (resp.ok) {
|
|
let body = {};
|
|
try {
|
|
body = await resp.json();
|
|
} catch {
|
|
/* tolerate odd 2xx body */
|
|
}
|
|
if (body.ok) {
|
|
out.className = "ma-result ok";
|
|
out.textContent = "✓ forge account stored.";
|
|
clearSecrets(formEl);
|
|
loadForgeAccounts(agent);
|
|
} else {
|
|
out.className = "ma-result err";
|
|
out.textContent = "✗ store failed (unexpected response).";
|
|
clearSecrets(formEl);
|
|
}
|
|
} else {
|
|
const msg = problemMessage(await readApiError(resp));
|
|
out.className = "ma-result err";
|
|
out.textContent =
|
|
"✗ " + (msg || "store failed (HTTP " + resp.status + ")");
|
|
clearSecrets(formEl);
|
|
}
|
|
} catch (err) {
|
|
out.className = "ma-result err";
|
|
out.textContent =
|
|
"✗ request failed: " +
|
|
String(err) +
|
|
" (the backend endpoint may not be deployed yet).";
|
|
} finally {
|
|
btn.disabled = false;
|
|
btn.textContent = orig;
|
|
}
|
|
}
|
|
|
|
// ─── init ─────────────────────────────────────────────────────────────
|
|
|
|
async function onAgentChange(agent) {
|
|
loadGithubStatus(agent);
|
|
loadForgeAccounts(agent);
|
|
}
|
|
|
|
async function init() {
|
|
await loadState();
|
|
renderAgentPicker();
|
|
$("ma-agent").addEventListener("change", (e) =>
|
|
onAgentChange(e.target.value),
|
|
);
|
|
$("gh-form").addEventListener("submit", submitGithub);
|
|
$("ef-form").addEventListener("submit", submitForgeAccount);
|
|
|
|
document.getElementById("cred-tabbar").configure({
|
|
tabs: [
|
|
{ id: "github", label: "GITHUB" },
|
|
{ id: "forges", label: "FORGES" },
|
|
],
|
|
defaultId: "github",
|
|
});
|
|
|
|
onAgentChange("");
|
|
}
|
|
|
|
init();
|