dashboard: matrix-accounts page — provision/login per-agent external matrix accounts
New standalone H0M3 page (/matrix-accounts.html) and tile. An agent
picker drives a list of that agent's configured matrix accounts (name,
homeserver, token-stored status) and a provision form that logs in by
password or stores an existing token.
Frontend half of the per-agent external matrix-account provisioning
work. Built against the v1 backend contract:
GET /matrix-accounts?agent=<name>
-> { accounts: [ { name, homeserver, token_present } ] }
POST /matrix-account-login (x-www-form-urlencoded, operator-auth)
fields: agent, account, homeserver, mode=password|token,
user_id?, password?, token?
-> 2xx { ok, user_id } | 4xx { error }
The token is never echoed back; secret inputs are cleared on submit.
Token-status dot reflects token-stored, not live session (a true
up/down indicator needs the daemon account registry, a follow-up). The
form carries an experimental notice pending per-account failure
isolation on the matrix daemon.
Blocked from merge on the backend endpoints and the daemon
failure-isolation fix; opening for review + to pin the UI/backend wire
contract.
This commit is contained in:
parent
c187366961
commit
9293fe3ac9
6 changed files with 394 additions and 3 deletions
180
frontend/packages/dashboard/src/matrix-accounts.js
Normal file
180
frontend/packages/dashboard/src/matrix-accounts.js
Normal file
|
|
@ -0,0 +1,180 @@
|
|||
// 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 /matrix-accounts?agent=<name>
|
||||
// -> { 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: "<msg>" } 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".
|
||||
//
|
||||
// Hard dependency: per-account failure isolation on the matrix daemon.
|
||||
// Until that lands a bad credential entered here can crash the agent's
|
||||
// whole matrix session, so the form carries an explicit experimental
|
||||
// notice in the markup.
|
||||
|
||||
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);
|
||||
agents = (s.agents || [])
|
||||
.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('/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();
|
||||
Loading…
Reference in a new issue