// CR3D3NTIALS page entry (/credentials.html). // // Operator surface to provision per-agent credentials without editing the // agent's config repo. One sub-tab and an 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. // 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 { 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; } } // ─── init ───────────────────────────────────────────────────────────── async function onAgentChange(agent) { loadGithubStatus(agent); } async function init() { await loadState(); renderAgentPicker(); $("ma-agent").addEventListener("change", (e) => onAgentChange(e.target.value), ); $("gh-form").addEventListener("submit", submitGithub); document.getElementById("cred-tabbar").configure({ tabs: [{ id: "github", label: "GITHUB" }], defaultId: "github", }); onAgentChange(""); } init();