From 5c2cae41a22e52356ceddf72ee93627ed098a204 Mon Sep 17 00:00:00 2001 From: iris Date: Sat, 11 Jul 2026 12:38:47 +0200 Subject: [PATCH] feat: rename matrix-accounts page to credentials, add github PAT tab Part 2 of hyperhive#1970 (backend/CLI landed in PR #2378). Renames /matrix-accounts.html -> /credentials.html and restructures it with a sub-tab strip (MATRIX / GITHUB), reusing the shared @hive/shared/tabs.js tab strip already used by /logs.html. MATRIX tab carries over the existing account list + login form unchanged. GITHUB tab adds a single-PAT provisioning form: status line (present/absent, read from GET /api/github-account), a security-warning banner (dedicated bot account + minimally-scoped token), a link to generate a PAT at github.com/settings/tokens, and a paste-token form posting to POST /api/github-account. Both tabs share one agent picker. Updated docs/web-ui.md + docs/web-ui/dashboard.md to describe the new page shape, and the H0M3 hub tile (index.html) to point at the renamed page. --- docs/web-ui.md | 2 +- docs/web-ui/dashboard.md | 41 +++- frontend/packages/dashboard/build.mjs | 15 +- .../{matrix-accounts.css => credentials.css} | 54 ++++- .../packages/dashboard/src/credentials.html | 132 ++++++++++++ .../{matrix-accounts.js => credentials.js} | 193 +++++++++++++----- frontend/packages/dashboard/src/index.html | 6 +- .../dashboard/src/matrix-accounts.html | 81 -------- 8 files changed, 376 insertions(+), 148 deletions(-) rename frontend/packages/dashboard/src/{matrix-accounts.css => credentials.css} (65%) create mode 100644 frontend/packages/dashboard/src/credentials.html rename frontend/packages/dashboard/src/{matrix-accounts.js => credentials.js} (62%) delete mode 100644 frontend/packages/dashboard/src/matrix-accounts.html diff --git a/docs/web-ui.md b/docs/web-ui.md index 07ac9e7b..089b5868 100644 --- a/docs/web-ui.md +++ b/docs/web-ui.md @@ -15,7 +15,7 @@ This doc has been split for readability. Pick the section you need: - **[Dashboard layout](web-ui/dashboard.md)** — tab contents (SW4RM, Y3R C4LL, P3RM1SS10NS, SCH3DUL3S) + standalone pages (C0R3 `/core.html`, BU1LDS `/builds.html`, L0GS `/logs.html`, - M4TR1X ACC0UNTS `/matrix-accounts.html`), container row, + CR3D3NTIALS `/credentials.html`), container row, topology tree, selection bar, approval card, browser notifications, dashboard endpoints + event channel. - **[Per-agent page](web-ui/agent.md)** — header, main terminal, diff --git a/docs/web-ui/dashboard.md b/docs/web-ui/dashboard.md index 3bd3301e..f884eaa8 100644 --- a/docs/web-ui/dashboard.md +++ b/docs/web-ui/dashboard.md @@ -254,15 +254,22 @@ badge with a ticking elapsed-time chip; expanding streams output via (suspends on manual scroll-up). Deep-link: `?id=N#buildlogs` opens the entry with that id pre-expanded. -## M4TR1X ACC0UNTS page (`/matrix-accounts.html`) +## CR3D3NTIALS page (`/credentials.html`) -Operator surface to provision / log in a per-agent **external** matrix -account and store its access token, without editing the agent's config -repo. Standalone page reached from the **Matrix accounts** tile on the -H0M3 hub, same minimal chrome as `/core.html` (a `← home` back-link + -title). Its own esbuild bundle (`matrix-accounts.js`); no SSE — it reads -`/api/state` once for the agent picker and otherwise works off two -purpose-built endpoints. +Operator surface to provision per-agent credentials without editing the +agent's config repo. Standalone page reached from the **Credentials** tile +on the H0M3 hub, same minimal chrome as `/logs.html` (a `← home` back-link ++ a sub-tab strip, via the shared `@hive/shared/tabs.js` tab strip) rather +than `/core.html`'s plain title. Its own esbuild bundle +(`credentials.js`); no SSE — it reads `/api/state` once for the (shared) +agent picker and otherwise works off purpose-built endpoints per tab. +Two sub-tabs: + +### MATRIX tab + +Provision / log in a per-agent **external** matrix account and store its +access token (this half is unchanged from the old `/matrix-accounts.html` +page it replaces — only the URL and surrounding chrome moved). An agent picker (populated from `state.containers`, the live roster) drives a list of that agent's accounts — name, homeserver, user id, and a status dot — @@ -288,6 +295,24 @@ The status dot renders these states: !live` "provisioned but offline" case. - **grey** — no token (not provisioned). +### GITHUB tab + +Provision a single per-agent GitHub personal access token (see +[`docs/github.md`](../github.md) for the injection + `gh`/git-push +mechanics). No login flow — the operator pastes an existing PAT for a +dedicated bot account, with a security-warning banner (dedicated account + +minimally-scoped token) and a link to +[github.com/settings/tokens](https://github.com/settings/tokens). + +Status reads `GET /api/github-account?agent=` → +`{ present: bool }` — whether the agent's `github-token` file exists. +There's no live/heartbeat concept for a static PAT, so this is just a +"token stored ✓" / "not set" line, unlike MATRIX's status-dot taxonomy. +Provisioning posts `POST /api/github-account` (form-encoded `agent`, +`token`) → `200 { ok: true }` on success, or the same `error_response` +shape `/api/matrix-account-login` uses on failure. The token is never +echoed back in either direction. + The container-down cross-reference (`/api/state`) takes precedence over the age check. `as_of_unix` is tooltipped ("live as of N ago") throughout so freshness is always legible. When `live` is absent (an older backend diff --git a/frontend/packages/dashboard/build.mjs b/frontend/packages/dashboard/build.mjs index 6dc72e37..3306f8fe 100644 --- a/frontend/packages/dashboard/build.mjs +++ b/frontend/packages/dashboard/build.mjs @@ -10,6 +10,8 @@ // state / container load) // dist/builds.html served at GET /builds.html (BU1LDS: // rebuild queue / meta inputs / build logs) +// dist/credentials.html served at GET /credentials.html +// (CR3D3NTIALS: matrix + github accounts) // dist/static/home.js index.html (H0M3) entry — menu tiles + // matrix-tile gating + identity line // dist/static/tabs.js dashboard.html entry — tab renderers + @@ -20,7 +22,10 @@ // log viewer sub-tabs // dist/static/builds.js /builds.html entry — rebuild queue, // meta inputs, build log history -// dist/static/{home,tabs,flow,logs,builds}.js.map source map siblings +// dist/static/credentials.js /credentials.html entry — matrix + +// github account sub-tabs +// dist/static/{home,tabs,flow,logs,builds,credentials}.js.map source map +// siblings // dist/static/common.css loaded by every page (@hive/shared // imports + shared typography/badges/ // buttons/inbox/side-panel) @@ -29,6 +34,8 @@ // dist/static/logs.css /logs.html only (log viewer sub-tabs) // dist/static/builds.css /builds.html only (build lifecycle hub) // dist/static/home.css index.html (H0M3) only (tile grid) +// dist/static/credentials.css /credentials.html only (matrix + github +// account forms) // // Each JS entry bundles `./common.js` (DOM helpers, Panel singleton, // NOTIF, path linkification) independently — esbuild inlines the shared @@ -59,7 +66,7 @@ mkdirSync(staticDir(''), { recursive: true }); // follow-up once asset sizes warrant it). esbuild writes each entry // to `static/.js` based on the entryPoint basename. await build({ - entryPoints: [src('tabs.js'), src('flow.js'), src('logs.js'), src('home.js'), src('settings.js'), src('stats.js'), src('core.js'), src('builds.js'), src('matrix-accounts.js')], + entryPoints: [src('tabs.js'), src('flow.js'), src('logs.js'), src('home.js'), src('settings.js'), src('stats.js'), src('core.js'), src('builds.js'), src('credentials.js')], outdir: staticDir(''), bundle: true, format: 'esm', @@ -98,7 +105,7 @@ await build({ // so a swap replaces only it) + theme.css (the semantic derivation // layer) + common.css (shared typography, badges, buttons, inbox, side // panel) plus its own page-specific bundle. -for (const entry of ['colors.css', 'theme.css', 'common.css', 'dashboard.css', 'flow.css', 'logs.css', 'home.css', 'settings.css', 'stats.css', 'core.css', 'builds.css', 'matrix-accounts.css']) { +for (const entry of ['colors.css', 'theme.css', 'common.css', 'dashboard.css', 'flow.css', 'logs.css', 'home.css', 'settings.css', 'stats.css', 'core.css', 'builds.css', 'credentials.css']) { await build({ entryPoints: [src(entry)], outfile: staticDir(entry), @@ -108,7 +115,7 @@ for (const entry of ['colors.css', 'theme.css', 'common.css', 'dashboard.css', ' }); } -for (const html of ['index.html', 'dashboard.html', 'flow.html', 'logs.html', 'settings.html', 'stats.html', 'core.html', 'builds.html', 'matrix-accounts.html']) { +for (const html of ['index.html', 'dashboard.html', 'flow.html', 'logs.html', 'settings.html', 'stats.html', 'core.html', 'builds.html', 'credentials.html']) { copyFileSync(src(html), dist(html)); } diff --git a/frontend/packages/dashboard/src/matrix-accounts.css b/frontend/packages/dashboard/src/credentials.css similarity index 65% rename from frontend/packages/dashboard/src/matrix-accounts.css rename to frontend/packages/dashboard/src/credentials.css index 090537ea..fc1a9f13 100644 --- a/frontend/packages/dashboard/src/matrix-accounts.css +++ b/frontend/packages/dashboard/src/credentials.css @@ -1,14 +1,58 @@ -/* M4TR1X ACC0UNTS page (/matrix-accounts.html) only. Page chrome +/* CR3D3NTIALS page (/credentials.html) only. Page chrome (.page-header / .page-back / .page-title) comes from the shared - chrome.css imported by common.css; this file holds the account-list - + provision-form styling specific to this surface. */ + chrome.css imported by common.css; base tab styling lives in + @hive/shared/tabs.css (.hive-tab*) same as /logs.html. This file holds + the account-list + provision-form styling specific to this surface, + carried over from the old /matrix-accounts.html (`.ma-*` classes) plus + the tab-strip layout delta + github-tab additions (`.cred-*`). */ -.ma-main { +body.cred-shell { + margin: 0; + padding: 0; +} + +/* Same layout delta as .logs-tabbar: fill the header row next to the + ← home back-link, and let the flex:1 nav shrink below its intrinsic + width instead of wrapping onto its own row. */ +.cred-tabbar { + flex: 1; + min-width: 0; +} + +.cred-main { max-width: 720px; margin: 0 auto; - padding: 1rem 1.25rem 3rem; + padding: 1.2em 1.25rem 3rem; } +.cred-pane[hidden] { display: none; } + +.cred-warning { + border: 1px solid var(--amber); + border-radius: 4px; + padding: 0.7rem 0.9rem; + margin: 0.6rem 0 1.1rem; + font-size: 0.9rem; + background: color-mix(in srgb, var(--amber) 10%, transparent); +} + +.gh-status { margin: 0.5rem 0 1.2rem; } +.gh-status-line { + display: flex; + align-items: center; + gap: 0.6rem; +} +.gh-dot { + width: 0.6rem; + height: 0.6rem; + border-radius: 50%; + flex: none; +} +.gh-dot.present { background: var(--green); } +.gh-dot.absent { background: var(--muted); } +.gh-status-text.present { color: var(--green); } +.gh-status-text.absent { color: var(--muted); } + .ma-field { display: flex; flex-direction: column; diff --git a/frontend/packages/dashboard/src/credentials.html b/frontend/packages/dashboard/src/credentials.html new file mode 100644 index 00000000..0d0cce9e --- /dev/null +++ b/frontend/packages/dashboard/src/credentials.html @@ -0,0 +1,132 @@ + + + + + + hyperhive // CR3D3NTIALS + + + + + + + + + + + +
+ + +

◇ agent

+ + + +
+

provision or log in an external matrix account for an agent and store its access token. the token is written to the agent's matrixAccounts.<account>.tokenFile by the host coordinator — it is never displayed back on this page.

+ +

◇ provisioned accounts

+

accounts that have a stored token (provision one below to add it here); a config-declared account that hasn't been provisioned yet won't appear until it has a token. status reflects whether a token is stored, not a live session — a true online/offline indicator is a follow-up that needs the daemon's account registry.

+

select an agent to see its matrix accounts.

+ +

◇ provision / log in

+
+ + + +
+ login method + + +
+ +
+ + +
+ + + + +

+
+
+ + + + +
+ + + + diff --git a/frontend/packages/dashboard/src/matrix-accounts.js b/frontend/packages/dashboard/src/credentials.js similarity index 62% rename from frontend/packages/dashboard/src/matrix-accounts.js rename to frontend/packages/dashboard/src/credentials.js index 1985967b..41197083 100644 --- a/frontend/packages/dashboard/src/matrix-accounts.js +++ b/frontend/packages/dashboard/src/credentials.js @@ -1,31 +1,19 @@ -// M4TR1X ACC0UNTS page entry (/matrix-accounts.html). +// CR3D3NTIALS page entry (/credentials.html). // -// Operator surface to provision / log in a per-agent EXTERNAL matrix -// account and store its access token, without editing the agent's config -// repo. Companion to the multi-account harness support. -// -// Backend contract: -// GET /api/matrix-accounts?agent= -// -> { accounts: [{name, homeserver, token_present, live, user_id}], -// as_of_unix: int|null } -// POST /api/matrix-account-login (x-www-form-urlencoded) -// fields: agent, account, homeserver, mode=password|token, -// user_id?, password?, token? -// -> 200 { ok: true, user_id }. Error body shape-agnostic: plain text -// today, migrating to RFC 9457 problem+json { detail, … }. -// Token is never echoed; page never re-renders a submitted secret. -// -// 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. +// 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. +// Per-tab detail comments live next to their section below. import { $, el, esc, fmtAgeSecs, renderServerWarnings } from './common.js'; +import { createTabStrip } from '@hive/shared/tabs.js'; let agents = []; // agent name → container running (bool), from /api/state. Cross-referenced by @@ -68,6 +56,40 @@ function renderAgentPicker() { for (const a of agents) sel.append(el('option', { value: a }, a)); } +// ─── shape-agnostic error-body parsing (shared by both tabs' submit +// handlers) ─────────────────────────────────────────────────────────────── +// The BE error-body shape is in transition: today hive-c0re's +// error_response sends a bare plain-text body; the RFC 9457 rework moves it +// to application/problem+json ({ type, title, detail, … }). Read shape- +// agnostically: pull the body once as text, and if it parses as JSON +// surface `detail` (problem+json) → `error`/`title` fallback, else use the +// raw text. A bare HTTP code is the last resort. +async function readErrorBody(resp) { + try { + const raw = (await resp.text()).trim(); + if (raw && (raw[0] === '{' || raw[0] === '[')) { + try { + const body = JSON.parse(raw); + return body.detail || body.error || body.title || raw; + } catch { /* not JSON after all — keep the raw text */ } + } + return raw; + } catch { + return ''; + } +} + +// ─── 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) { @@ -222,25 +244,7 @@ async function submitLogin(e) { clearSecrets(formEl); } } else { - // The BE error-body shape is in transition: today hive-c0re's - // error_response sends a bare plain-text body (e.g. "matrix-account- - // login: password mode needs user_id + password"); the RFC 9457 rework - // moves it to application/problem+json ({ type, title, detail, … }). - // Read shape-agnostically so the FE handles both with no merge-order - // coupling: pull the body once as text, and if it parses as JSON - // surface `detail` (problem+json) → `error`/`title` fallback, else use - // the raw text. A bare HTTP code is the last resort. - let msg = ''; - try { - const raw = (await resp.text()).trim(); - msg = raw; - if (raw && (raw[0] === '{' || raw[0] === '[')) { - try { - const body = JSON.parse(raw); - msg = body.detail || body.error || body.title || raw; - } catch { /* not JSON after all — keep the raw text */ } - } - } catch { /* fall back to the status code below */ } + const msg = await readErrorBody(resp); out.className = 'ma-result err'; out.textContent = '✗ ' + (msg || ('login failed (HTTP ' + resp.status + ')')); clearSecrets(formEl); @@ -254,15 +258,112 @@ async function submitLogin(e) { } } +// ─── 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 = await readErrorBody(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); +} + async function init() { await loadState(); renderAgentPicker(); - $('ma-agent').addEventListener('change', (e) => loadAccounts(e.target.value)); + $('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); - loadAccounts(''); + $('gh-form').addEventListener('submit', submitGithub); + + createTabStrip(document.getElementById('cred-tabbar'), { + defaultId: 'matrix', + }); + + onAgentChange(''); } init(); diff --git a/frontend/packages/dashboard/src/index.html b/frontend/packages/dashboard/src/index.html index 7dd95f3a..c6b98a8a 100644 --- a/frontend/packages/dashboard/src/index.html +++ b/frontend/packages/dashboard/src/index.html @@ -91,12 +91,12 @@ kept state · container load - + - Matrix accounts + Credentials - provision · log in · store per-agent matrix tokens + provision per-agent matrix + github accounts - - -
-

provision or log in an external matrix account for an agent and store its access token. the token is written to the agent's matrixAccounts.<account>.tokenFile by the host coordinator — it is never displayed back on this page.

- -

◇ agent

- - -

◇ provisioned accounts

-

accounts that have a stored token (provision one below to add it here); a config-declared account that hasn't been provisioned yet won't appear until it has a token. status reflects whether a token is stored, not a live session — a true online/offline indicator is a follow-up that needs the daemon's account registry.

-

select an agent to see its matrix accounts.

- -

◇ provision / log in

-
- - - -
- login method - - -
- -
- - -
- - - - -

-
-
- - - -