// 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-