// M4TR1X ACC0UNTS page entry (/matrix-accounts.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 (v1): // GET /api/matrix-accounts?agent= // -> { accounts: [ { name, homeserver, token_present: bool } ] } // POST /matrix-account-login (x-www-form-urlencoded, operator-auth) // fields: agent, account, homeserver, mode=password|token, // user_id?, password?, token? // -> 2xx { ok: true, user_id } on success // -> 4xx { error: "" } on failure // The token is NEVER echoed back in any response, and this page never // re-renders a submitted secret. // // Live up/down (a true green/red dot) needs the daemon's account // registry; until that follow-up lands the dot only reflects whether a // token is STORED, labelled "token stored" rather than "online". The // account list shows what is provisioned (has a stored token), so a // config-declared-but-unprovisioned account appears only once provisioned. import { $, el, esc, renderServerWarnings } from './common.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`); there is no top-level `agents` field, so the // picker stays compatible with both string + object shapes defensively. agents = (s.containers || []) .map((a) => (typeof a === 'string' ? a : a && a.name)) .filter(Boolean) .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)); } 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 || []; 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; ul.append(el('li', { class: 'ma-account' }, el('span', { class: 'ma-dot ' + (present ? 'ok' : 'absent'), title: present ? 'token stored' : 'no token yet', }), el('span', { class: 'ma-name' }, acc.name || '(unnamed)'), el('span', { class: 'ma-hs' }, acc.homeserver || '—'), el('span', { class: 'ma-status ' + (present ? 'ok' : 'absent') }, present ? 'token stored ✓' : 'no token'), )); } 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('/matrix-account-login', { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: new URLSearchParams(fd), }); let body = {}; try { body = await resp.json(); } catch { /* tolerate non-JSON error pages */ } if (resp.ok && 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 = '✗ ' + (body.error || ('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; } } async function init() { await loadState(); renderAgentPicker(); $('ma-agent').addEventListener('change', (e) => loadAccounts(e.target.value)); document.querySelectorAll('input[name="mode"]') .forEach((r) => r.addEventListener('change', toggleModeFields)); toggleModeFields(); $('ma-form').addEventListener('submit', submitLogin); loadAccounts(''); } init();