Restyle staff and console UIs; add UI-Docs walkthrough and review PDF.
Some checks are pending
offline / test (push) Waiting to run

Match CS/sales/accounting/access-staff to the portal indigo system with
dollar amounts, skip links, and empty states. Fleet replica actions move
into overflow menus, roles become chips, Docs become cards, and the
header copy reflects the 0.0.0.0 bind. Simulator uses the same shell
(orange only for faults). Portal API keys are masked; admin customers
edit in a drawer. Catalog uses system-ui. New UI-Docs repo holds
screenshots, usage notes, and UI-REVIEW.pdf.
This commit is contained in:
George Lambert 2026-09-11 18:01:33 -04:00
parent ddf772454b
commit cb07f5b321
72 changed files with 3284 additions and 242 deletions

View file

@ -2,6 +2,8 @@
Staff access plane for CS / sales / accounting. Credits and statement review after `verae.access.authz.check` with plane `staff`.
HTTP UI at **http://0.0.0.0:3025/** (indigo shell, dollars and names). JSON still at `/health`, `/review/:id`, `POST /credits`.
**Forgejo:** https://git.georgelambert.org/marchon/verae-access-staff
Port `:3025`.

View file

@ -0,0 +1,134 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Staff access — account review</title>
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Crect width='32' height='32' rx='8' fill='%234f46e5'/%3E%3C/svg%3E"/>
<style>
:root {
--bg:#f4f5fb; --panel:#fff; --ink:#171a26; --muted:#6b7186; --line:#e5e7f0;
--accent:#4f46e5; --accent-ink:#fff; --soft:#eef0fe; --danger:#dc2626; --ok:#047857;
--radius:12px; --shadow:0 1px 2px rgba(23,26,38,.05), 0 8px 24px rgba(23,26,38,.06);
}
* { box-sizing:border-box; }
body { margin:0; font:14px/1.45 -apple-system,"SF Pro Text","Segoe UI",sans-serif; background:var(--bg); color:var(--ink); }
.skip { position:absolute; left:-999px; }
.skip:focus { left:1rem; top:1rem; z-index:20; background:#fff; color:var(--accent); padding:.5rem .9rem; border-radius:8px; }
header.app { background:linear-gradient(160deg,#312e81 0%,#4f46e5 60%,#7c74f0 100%); color:#eef0fe; padding:1.15rem 1.5rem 1.25rem; }
.kicker { letter-spacing:.12em; text-transform:uppercase; font:700 10px system-ui; opacity:.75; }
header.app h1 { margin:.2rem 0 .25rem; font-size:1.25rem; }
header.app p { margin:0; font-size:13px; opacity:.88; max-width:42rem; }
main { max-width:1080px; margin:0 auto; padding:1.5rem 1.25rem 3rem; }
.card { background:var(--panel); border:1px solid var(--line); border-radius:var(--radius); box-shadow:var(--shadow); padding:1.1rem 1.25rem; margin-bottom:1rem; }
.card h3 { margin:0 0 .75rem; font-size:.95rem; }
label { display:block; font-size:.72rem; font-weight:700; letter-spacing:.04em; text-transform:uppercase; color:var(--muted); margin:.7rem 0 .25rem; }
input { width:100%; max-width:22rem; padding:.5rem .65rem; border:1px solid var(--line); border-radius:8px; font-size:.95rem; }
input:focus-visible, button:focus-visible, a:focus-visible { outline:2px solid var(--accent); outline-offset:2px; }
.row { display:flex; flex-wrap:wrap; gap:.6rem; align-items:end; }
.row > div { min-width:10rem; }
button { border:0; border-radius:8px; padding:.5rem .9rem; font:650 13px system-ui; cursor:pointer; background:var(--accent); color:#fff; }
table { border-collapse:collapse; width:100%; }
th { text-align:left; font-size:.72rem; text-transform:uppercase; letter-spacing:.04em; color:var(--muted); border-bottom:1px solid var(--line); padding:.45rem .55rem; }
td { border-bottom:1px solid var(--line); padding:.5rem .55rem; }
.money { font-variant-numeric:tabular-nums; }
.muted { color:var(--muted); }
.stat-grid { display:grid; grid-template-columns:repeat(auto-fit,minmax(150px,1fr)); gap:.8rem; margin-bottom:1rem; }
.stat { background:var(--panel); border:1px solid var(--line); border-radius:var(--radius); box-shadow:var(--shadow); padding:.9rem 1.1rem; }
.stat .k { font-size:.72rem; text-transform:uppercase; letter-spacing:.05em; color:var(--muted); }
.stat .v { font-size:1.35rem; font-weight:800; margin-top:.2rem; font-variant-numeric:tabular-nums; }
.empty { text-align:center; padding:1.5rem 1rem; color:var(--muted); }
.empty svg { display:block; margin:0 auto .6rem; }
.err { color:var(--danger); }
</style>
</head>
<body>
<a class="skip" href="#main">Skip to content</a>
<header class="app">
<div class="kicker">staff plane · after authz</div>
<h1>Staff access</h1>
<p>CS / sales / accounting door. Review and credit go through <code>verae.access.authz.check</code>, then account-balance. Amounts are dollars; the customer name is shown when the ledger has it.</p>
</header>
<main id="main">
<div class="card">
<div class="row">
<div>
<label for="id">Customer id or name</label>
<input id="id" value="cust_1" autocomplete="off" />
</div>
<button type="button" onclick="review()">Review account</button>
</div>
</div>
<div class="card">
<h3>Apply credit</h3>
<div class="row">
<div>
<label for="dollars">Amount (USD)</label>
<input id="dollars" type="number" min="0.01" step="0.01" value="5.00" />
</div>
<div>
<label for="reason">Reason</label>
<input id="reason" value="goodwill" />
</div>
<div>
<label for="agent">Agent name</label>
<input id="agent" value="cs" />
</div>
<button type="button" onclick="credit()">Apply credit</button>
</div>
</div>
<div id="out"></div>
</main>
<script>
const fmt = (c) => (c < 0 ? '-$' : '$') + (Math.abs(c || 0) / 100).toFixed(2);
const when = (v) => (v ? new Date(v).toLocaleString() : '—');
const empty = (title, hint) =>
`<div class="empty"><svg width="80" height="64" viewBox="0 0 80 64" fill="none" aria-hidden="true"><rect x="16" y="8" width="48" height="48" rx="8" fill="#eef0fe"/><rect x="24" y="20" width="32" height="4" rx="2" fill="#4f46e5" opacity=".35"/><rect x="24" y="30" width="24" height="4" rx="2" fill="#4f46e5" opacity=".2"/><rect x="24" y="40" width="28" height="4" rx="2" fill="#4f46e5" opacity=".2"/></svg><div>${title}</div><p>${hint}</p></div>`;
function moneyTable(list, cols, emptyTitle) {
if (!list || !list.length) return empty(emptyTitle, 'Nothing recorded for this customer yet.');
const head = cols.map((c) => `<th>${c.label}</th>`).join('');
const body = list.map((r) => '<tr>' + cols.map((c) => {
const v = c.key === 'cents' ? fmt(r.cents) : c.key === 'at' ? when(r.at) : (r[c.key] ?? '—');
return `<td class="${c.key === 'cents' ? 'money' : ''}">${v}</td>`;
}).join('') + '</tr>').join('');
return `<table><thead><tr>${head}</tr></thead><tbody>${body}</tbody></table>`;
}
async function review() {
const id = document.getElementById('id').value.trim();
try {
const r = await fetch('/review/' + encodeURIComponent(id));
const st = await r.json();
if (!r.ok) throw new Error(st.error || ('HTTP ' + r.status));
const name = st.name || st.customerName || id;
document.getElementById('out').innerHTML = `
<div class="stat-grid">
<div class="stat"><div class="k">Customer</div><div class="v" style="font-size:1.05rem">${name}</div></div>
<div class="stat"><div class="k">Prepaid balance</div><div class="v">${fmt(st.prepaidCents)}</div></div>
<div class="stat"><div class="k">Credits</div><div class="v">${(st.credits||[]).length}</div></div>
<div class="stat"><div class="k">Source</div><div class="v" style="font-size:1rem">${st.source || 'unknown'}</div></div>
</div>
<div class="card"><h3>Credits</h3>${moneyTable(st.credits, [{key:'cents',label:'Amount'},{key:'reason',label:'Reason'},{key:'agent',label:'Agent'},{key:'at',label:'When'}], 'No credits yet')}</div>
<div class="card"><h3>Usage</h3>${moneyTable(st.usage, [{key:'endpointId',label:'Endpoint'},{key:'cents',label:'Amount'},{key:'at',label:'When'}], 'No usage yet')}</div>
<div class="card"><h3>Payments</h3>${moneyTable(st.payments, [{key:'cents',label:'Amount'},{key:'kind',label:'Kind'},{key:'reason',label:'Reason'},{key:'at',label:'When'}], 'No payments yet')}</div>`;
} catch (err) {
document.getElementById('out').innerHTML = `<div class="card err">${err.message}</div>`;
}
}
async function credit() {
const body = {
customerId: document.getElementById('id').value.trim(),
cents: Math.round(Number(document.getElementById('dollars').value) * 100),
reason: document.getElementById('reason').value,
agent: document.getElementById('agent').value,
};
const r = await fetch('/credits', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) });
if (!r.ok) {
const st = await r.json().catch(() => ({}));
document.getElementById('out').innerHTML = `<div class="card err">${st.error || r.status}</div>`;
return;
}
await review();
}
</script>
</body>
</html>

View file

@ -1,7 +1,11 @@
#!/usr/bin/env node
/** Staff access plane (CS / sales / accounting). Not Zapier, not customer API. */
import fs from 'node:fs';
import http from 'node:http';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const PUBLIC = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', 'public');
const PORT = Number(process.env.PORT || 3025);
const AUTHZ = (process.env.AUTHZ_URL || 'http://127.0.0.1:3020').replace(/\/$/, '');
const BOOKS = (process.env.ACCOUNT_BALANCE_URL || 'http://127.0.0.1:3010').replace(/\/$/, '');
@ -23,6 +27,11 @@ const server = http.createServer(async (req, res) => {
res.end(JSON.stringify(obj));
};
try {
if (req.method === 'GET' && (url.pathname === '/' || url.pathname === '/index.html')) {
res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' });
res.end(fs.readFileSync(path.join(PUBLIC, 'index.html')));
return;
}
if (req.method === 'GET' && url.pathname === '/health') {
return json(200, { ok: true, role: 'verae-access-staff', plane: PLANE });
}

View file

@ -36,6 +36,11 @@ test('staff plane can credit after authz; zapier cannot', async () => {
try {
const h = await (await fetch(`http://127.0.0.1:${staffPort}/health`)).json();
assert.equal(h.plane, 'staff');
const page = await fetch(`http://127.0.0.1:${staffPort}/`);
assert.match(page.headers.get('content-type') || '', /text\/html/);
const html = await page.text();
assert.match(html, /Staff access/);
assert.match(html, /Amount \(USD\)/);
const add = await fetch(`http://127.0.0.1:${staffPort}/credits`, {
method: 'POST',
headers: { 'content-type': 'application/json' },