hyperhive/frontend/packages/dashboard/src/credentials.js
atlas 39b95c2ede treefmt: apply prettier
Pure `nix fmt` output from the commit before this one — no hand edits.
203 files: 52 md, 42 tsx, 32 js, 32 css, 21 ts, 13 html, 8 json, 3 mjs.

Reproduce with `nix develop -c nix fmt` on the parent commit; the result
should be byte-identical to this tree.

None of the 13 `.prettierignore` entries appears here — verified by
intersecting the changed-file list against the ignore file, with a
control proving the intersection finds a match when one exists.
2026-09-02 15:25:07 +02:00

615 lines
20 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:
// MATRIX — external matrix account login (carried over verbatim from the
// old /matrix-accounts.html — see matrix_accounts.rs backend doc
// comments for the account/status contract + endpoint shapes).
// GITHUB — single-account PAT paste against /api/github-account
// (GET -> {present}, POST form-encoded {agent, token} ->
// {ok:true}; same error_response shape as matrix-account-login).
// 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, fmtAgeSecs, 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 = [];
// agent name → container running (bool), from /api/state. Cross-referenced by
// the live dot: a `live: true` account whose container is DOWN is definitively
// stale (the daemon can't be up if the container isn't), so we flag it rather
// than show a lying green. `undefined` (agent not in the map) = unknown → we
// don't flag stale.
const containerRunning = new Map();
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();
containerRunning.clear();
for (const c of containers) containerRunning.set(c.name, !!c.running);
} 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.
// ─── MATRIX tab ────────────────────────────────────────────────────────────
// Live status dot — the daemon heartbeats every ~30s (advances as_of_unix),
// so a stalled as_of = daemon dead, not just stale snapshot:
// green live + running + fresh = online
// dim green live but as_of stale > ~90s = heartbeat stopped
// amber live + container DOWN = definitively stale
// amber token_present + !live = provisioned but offline
// grey no token = not provisioned
// Container state takes precedence; as_of_unix is tooltipped for freshness.
// v1 backend (no `live` field) falls back to token-present rendering.
async function loadAccounts(agent) {
const list = $("ma-list");
if (!agent) {
list.replaceChildren(
el("p", { class: "meta" }, "select an agent to see its matrix accounts."),
);
return;
}
list.replaceChildren(el("p", { class: "meta" }, "loading…"));
let data;
try {
const resp = await fetch(
"/api/matrix-accounts?agent=" + encodeURIComponent(agent),
);
if (!resp.ok) throw new Error("HTTP " + resp.status);
data = await resp.json();
} catch (err) {
list.replaceChildren(
el(
"p",
{ class: "err" },
"could not load accounts: " +
esc(String(err)) +
" (the backend endpoint may not be deployed yet).",
),
);
return;
}
const accounts = data.accounts || [];
const asOf = typeof data.as_of_unix === "number" ? data.as_of_unix : null;
// `false` only when the container is explicitly down; `undefined` (unknown,
// e.g. a failed /api/state read) is treated as not-down so we never flag a
// false stale.
const running = containerRunning.get(agent);
// The daemon force-rewrites its snapshot every ~30s, so `as_of_unix` advances
// while it's alive — this is a heartbeat, and a stalled value is meaningful.
const ageSecs =
asOf != null ? Math.max(0, Math.floor(Date.now() / 1000) - asOf) : null;
const asOfText =
asOf != null
? "matrix snapshot · live as of " + fmtAgeSecs(ageSecs) + " ago"
: "no daemon snapshot yet";
// 3 missed ~30s heartbeats. Past this a `live` snapshot whose container is
// NOT down means the daemon stopped publishing (dead/wedged) — dim its dot.
const STALE_AGE_SECS = 90;
const staleByAge = ageSecs != null && ageSecs > STALE_AGE_SECS;
list.replaceChildren();
if (!accounts.length) {
list.append(
el(
"p",
{ class: "meta" },
"no matrix accounts configured for this agent.",
),
);
return;
}
const ul = el("ul", { class: "ma-accounts" });
for (const acc of accounts) {
const present = !!acc.token_present;
// 3-state dot. `live` is absent on the v1 backend (pre BE-4); when
// undefined, fall back to the v1 token-present rendering so the page
// degrades cleanly before the snapshot backend deploys.
let cls;
let statusText;
let dotTitle;
if (acc.live === undefined) {
cls = present ? "ok" : "absent";
statusText = present ? "token stored ✓" : "no token";
dotTitle = present ? "token stored" : "no token yet";
} else if (acc.live && running === false) {
// container down ⟹ daemon down ⟹ a "live" snapshot is stale.
cls = "stale";
statusText = "container stopped";
dotTitle = "container is stopped — live status is stale. " + asOfText;
} else if (acc.live && staleByAge) {
// Snapshot says live, but the heartbeat (snapshot mtime = as_of) hasn't
// advanced in > ~90s while the container is NOT down — the daemon stopped
// publishing, so the "live" is no longer trustworthy. Keep the green
// family but dim it (distinct from the amber container-down 'stale').
cls = "live stale-age";
statusText = "online · no heartbeat";
dotTitle =
"snapshot says live but the daemon heartbeat stalled " +
fmtAgeSecs(ageSecs) +
" ago (publishes every ~30s) — likely dead or wedged. " +
asOfText;
} else if (acc.live) {
cls = "live";
statusText = "online ✓";
dotTitle = asOfText;
} else if (present) {
cls = "offline";
statusText = "token stored · offline";
dotTitle = "provisioned but not live. " + asOfText;
} else {
cls = "absent";
statusText = "no token";
dotTitle = "no token yet";
}
ul.append(
el(
"li",
{ class: "ma-account" },
el("span", { class: "ma-dot " + cls, title: dotTitle }),
el("span", { class: "ma-name" }, acc.name || "(unnamed)"),
acc.user_id ? el("span", { class: "ma-uid" }, acc.user_id) : null,
el("span", { class: "ma-hs" }, acc.homeserver || "—"),
el("span", { class: "ma-status " + cls, title: asOfText }, statusText),
),
);
}
list.append(ul);
}
// Show only the fields for the selected login method, and DISABLE the
// hidden section's inputs so they don't ride along in the FormData (both
// sections carry a `user_id` field, so without this the wrong one — or
// both — would be submitted).
function toggleModeFields() {
const mode = document.querySelector('input[name="mode"]:checked');
const value = mode ? mode.value : "password";
const pw = $("ma-pw-fields");
const tok = $("ma-token-fields");
pw.hidden = value !== "password";
tok.hidden = value !== "token";
pw.querySelectorAll("input").forEach((i) => {
i.disabled = pw.hidden;
});
tok.querySelectorAll("input").forEach((i) => {
i.disabled = tok.hidden;
});
}
function clearSecrets(formEl) {
formEl
.querySelectorAll('input[type="password"], input[name="token"]')
.forEach((i) => {
i.value = "";
});
}
async function submitLogin(e) {
e.preventDefault();
const formEl = e.target;
const out = $("ma-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 = "logging in…";
try {
const resp = await fetch("/api/matrix-account-login", {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams(fd),
});
if (resp.ok) {
// Success is 200 + JSON { ok, user_id }.
let body = {};
try {
body = await resp.json();
} catch {
/* tolerate odd 2xx body */
}
if (body.ok) {
out.className = "ma-result ok";
out.textContent =
"✓ logged in as " +
(body.user_id || "(unknown)") +
" — token stored.";
clearSecrets(formEl);
loadAccounts(agent);
} else {
out.className = "ma-result err";
out.textContent = "✗ login failed (unexpected response).";
clearSecrets(formEl);
}
} else {
const msg = problemMessage(await readApiError(resp));
out.className = "ma-result err";
out.textContent =
"✗ " + (msg || "login 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;
}
}
// ─── 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) {
loadAccounts(agent);
loadGithubStatus(agent);
loadForgeAccounts(agent);
}
async function init() {
await loadState();
renderAgentPicker();
$("ma-agent").addEventListener("change", (e) =>
onAgentChange(e.target.value),
);
document
.querySelectorAll('input[name="mode"]')
.forEach((r) => r.addEventListener("change", toggleModeFields));
toggleModeFields();
$("ma-form").addEventListener("submit", submitLogin);
$("gh-form").addEventListener("submit", submitGithub);
$("ef-form").addEventListener("submit", submitForgeAccount);
document.getElementById("cred-tabbar").configure({
tabs: [
{ id: "matrix", label: "MATRIX" },
{ id: "github", label: "GITHUB" },
{ id: "forges", label: "FORGES" },
],
defaultId: "matrix",
});
onAgentChange("");
}
init();