Close the UI-review follow-ups: names typeahead, fleet form, overflow flip.
Some checks are pending
offline / test (push) Waiting to run

Staff pages typeahead customers by name (Ada, not cust_1). Add-machine is
two rows with a wide identity path and filename picker. Overflow menus
flip up near the viewport edge. Customer list no longer leaks password
hashes. UI-REVIEW.pdf remaining list is the three leftover items.
This commit is contained in:
George Lambert 2026-09-11 18:18:37 -04:00
parent 9cc0018708
commit b68fefdea8
41 changed files with 569 additions and 454 deletions

View file

@ -1,44 +1,36 @@
# UI issues still to consider
Second full walkthrough (2026-09-11): 32 screenshots, 0 application console errors on product UIs. Surfaces are visually consistent (indigo, 12px radius, dollars). Primary actions are obvious. Remaining work is listed here for human review. Full screenshot atlas and the same table: [UI-REVIEW.pdf](UI-REVIEW.pdf).
Follow-up to the 2026-09-11 review PDF. Items the review listed as open are now implemented except the three left below.
## Fixed in this pass
## Resolved from the review list
| Item | Result |
|------|--------|
| CS/sales/accounting/access-staff unstyled cents forms | Indigo shell, USD, agent/customer labels, empty illustrations |
| Fleet pause/resume/restart/on/off/pause crowding | One ⋯ menu per replica and per service; Power is status |
| Docs tab bullet dump | Cards matching Fleet |
| Simulator cream/orange brand | Indigo; orange = fault inject only |
| Header LOOPBACK vs 0.0.0.0 bind | `operator LAN · binds 0.0.0.0:3850` |
| Add machine truncated roles field | Chips |
| Admin customers inline-edit table | List + edit drawer |
| Portal API key always visible | Masked + Reveal/Copy/Regenerate |
| Catalog Georgia on indigo | system-ui |
| Trace hop grid clipping names | Wrapping columns |
| Empty “No invoices yet” | Illustrated empty state |
| Fleet on/off looking like toggles | Status pills |
| Department pages a11y | `lang`, skip link, focus-visible |
| Nav hit area/contrast | 44px tabs, `#c7c9ff` on indigo |
| Swagger as customer UI | Labeled stock |
| Severity | Issue | What shipped |
|----------|-------|----------------|
| High | Staff headings showed `cust_1` | Ledger stores names (`customer.put`). Staff UIs typeahead customers from edge (`GET /customers`) and review by name. |
| High | CS/sales/accounting HTML had no login | `verae-staff-session` :3027. Set `STAFF_AUTH=1` and `STAFF_SESSION_URL` on department servers. |
| Med | Catalog was a bibliography | Catalog index is cards (Start here / Architecture / Modules). |
| Med | Fleet identity field truncated | Two-row add-machine form; identity path is a wide field plus a filename picker. |
| Med | Disabled machines looked like faults | Intentionally disabled cards are grey (`disabled`), not degraded yellow. |
| Low | Portal statement empty cells in a table | Empty lists use the same card empty state as invoices. |
| Low | Overflow menus off-screen | Menu flips upward when near the bottom of the viewport. |
| Low | Simulator empty console was a black void | Placeholder: “Run a step to fill this trace.” |
## Still to consider
| Severity | Issue | Suggestion |
|----------|-------|------------|
| High | Staff review headings show `cust_1` because the ledger has no display name | Join statements to zappier-edge customer name, or a customer typeahead |
| High | CS/sales/accounting HTML has no login (LAN trust) | Staff SSO or shared cookie before any non-lab bind |
| Med | Catalog is still a bibliography | Card index like console Docs |
| Med | Swagger remains stock | Keep for integrators; do not skin |
| Med | Fleet add-machine identity path truncates | Two-row form + file picker |
| Med | Intentionally disabled machines use degraded yellow | Neutral grey when disabled on purpose |
| Low | Portal statement empty lists still wrap the illustration in a table row | Same card empty state as invoices |
| Low | Fleet ⋯ can open off-screen at the bottom of a long table | Flip the menu upward |
| Low | access-staff and CS HTML are twins | Share one template |
| Low | Simulator empty console is a black void | Placeholder “Run a step…” |
| Med | Swagger `/docs` remains stock | Keep it for integrators; do not skin. Portal already labels it as stock OpenAPI explorer. |
| Low | access-staff and CS HTML are twins | Share one template file if those packages ever merge. |
| Low | Staff cookie is host-scoped (`127.0.0.1`) | Fine on one operator host. For multiple DNS names, put a reverse proxy in front. |
## Visual revalidation
## How to use each surface
- **Appealing:** staff and console now match portal/admin; simulator no longer looks like a second product.
- **Obvious:** Review account, Apply credit, Edit, Reveal/Copy, Run step, ⋯.
- **Comprehensive:** review, credit, quote, export, fleet power, trace, docs, portal billing, admin customers — covered. Missing: staff identity join and catalog IA (above).
- **Operator** http://127.0.0.1:3850/ — Fleet (replicas, overflow, grey disabled machines), Trace, Docs.
- **Staff CS** :3011 — type a customer name, Review, credit in USD. `STAFF_AUTH=1` redirects to :3027.
- **Sales** :3012 — review / quote by name.
- **Accounting** :3013 — review + QuickBooks IIF / CSV.
- **Staff plane** :3025 — same review/credit after authz.
- **Customers** /portal — masked API key, empty invoice/statement cards.
- **Ops** /admin — customer list + edit drawer.
- **Lab** simulator :3847.
- **Catalog** https://zapier.georgelambert.org/ — card index, system-ui.

File diff suppressed because one or more lines are too long

Binary file not shown.

Before

Width:  |  Height:  |  Size: 391 KiB

After

Width:  |  Height:  |  Size: 396 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 394 KiB

After

Width:  |  Height:  |  Size: 400 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 166 KiB

After

Width:  |  Height:  |  Size: 168 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 165 KiB

After

Width:  |  Height:  |  Size: 155 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 107 KiB

After

Width:  |  Height:  |  Size: 107 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 160 KiB

After

Width:  |  Height:  |  Size: 155 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 89 KiB

After

Width:  |  Height:  |  Size: 89 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 134 KiB

After

Width:  |  Height:  |  Size: 136 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 81 KiB

After

Width:  |  Height:  |  Size: 81 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 138 KiB

After

Width:  |  Height:  |  Size: 128 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 171 KiB

After

Width:  |  Height:  |  Size: 110 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 106 KiB

After

Width:  |  Height:  |  Size: 107 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 166 KiB

After

Width:  |  Height:  |  Size: 110 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 86 KiB

After

Width:  |  Height:  |  Size: 109 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 92 KiB

After

Width:  |  Height:  |  Size: 111 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 95 KiB

After

Width:  |  Height:  |  Size: 108 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 71 KiB

After

Width:  |  Height:  |  Size: 71 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 71 KiB

After

Width:  |  Height:  |  Size: 71 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 948 KiB

After

Width:  |  Height:  |  Size: 1 MiB

Before After
Before After

View file

@ -117,11 +117,11 @@ def main():
story.append(p("What was fixed in this pass", s["h1"]))
items = [
"CS / sales / accounting / access-staff restyled to the portal indigo system. Dollars and names, not cents forms.",
"Fleet replica and service actions collapsed to one overflow menu. Power column is status, not on/off toggles.",
"Add-machine roles are chips. Header copy is operator LAN · binds 0.0.0.0:3850 (NATS stays private).",
"Fleet replica and service actions collapsed to one overflow menu (flips up near the viewport edge). Power is status. Disabled machines are grey.",
"Add-machine is two rows: identity path is wide plus a filename picker; roles are chips. Header: operator LAN · binds 0.0.0.0:3850.",
"Docs tab is cards. Trace hop grid wraps. Skip link and focus rings on staff and console.",
"Simulator indigo; orange only on Inject faults.",
"Portal API key masked with Reveal / Copy / Regenerate. Invoice empty-state illustration.",
"Portal API key masked. Invoice and statement empty states are cards, not table rows. Staff typeahead uses customer names. STAFF_AUTH=1 uses verae-staff-session.",
"Admin customers: list vs edit drawer. Invoice empty state.",
"Catalog body font is system-ui. Swagger /docs labeled stock.",
]
@ -134,16 +134,9 @@ def main():
story.append(p("Issues still to consider", s["h1"]))
remaining = [
["Severity", "Issue", "Suggestion"],
["High", "Staff UIs look up by customer id; ledger often has no display name, so the heading is cust_1 not Ada.", "Join account-balance statements to zappier-edge customer name, or add a typeahead of customers."],
["High", "Staff, portal, and admin have no session login on CS/sales/accounting HTML (LAN trust).", "Put a staff SSO or shared cookie in front of :3011:3013 and :3025 before any non-lab bind."],
["Med", "Catalog is still a long bibliography. Font is fixed; information architecture is not.", "Card index like the console Docs tab: Start here / Architecture / Modules."],
["Med", "Swagger /docs remains stock.", "Keep it for integrators; do not skin it. Link it as 'OpenAPI explorer' only."],
["Med", "Fleet add-machine form is dense; identity file still truncates in a narrow field.", "Two-row form: identity + roles on the second row with a file picker."],
["Med", "lan-134 shows degraded/disabled in yellow while local/ns1 are green — correct, but the disable button looks like a fault.", "When a machine is intentionally disabled, use a neutral grey card, not degraded yellow."],
["Low", "Portal statement empty cells still embed the empty illustration inside a table row.", "Drop the table when the list is empty; use the same card empty state as invoices."],
["Low", "Overflow menus can open off-screen at the bottom of a long fleet table.", "Flip the menu upward when near the viewport edge."],
["Low", "Access-staff and CS are nearly the same page.", "Keep both (plane vs department) but share one HTML template file."],
["Low", "Simulator empty console is a large black void before the first run.", "Placeholder hop: 'Run a step to fill this trace.'"],
["Med", "Swagger /docs remains stock.", "Keep it for integrators; do not skin it. Portal already labels it stock OpenAPI explorer."],
["Low", "access-staff and CS HTML are twins.", "Share one template if those packages ever merge."],
["Low", "Staff cookie is host-scoped (127.0.0.1).", "Fine on one operator host. Multiple DNS names need a reverse proxy."],
]
body = getSampleStyleSheet()["BodyText"]
body.fontSize = 8

View file

@ -1,11 +1,21 @@
{
"capturedAt": "2026-09-11T21:58:47.138Z",
"capturedAt": "2026-09-11T22:17:57.807Z",
"errors": [],
"all": [
{
"page": "catalog",
"page": "staff",
"type": "console",
"text": "Failed to load resource: the server responded with a status of 404 (File not found)"
"text": "Failed to load resource: the server responded with a status of 404 (Not Found)"
},
{
"page": "staff",
"type": "console",
"text": "Failed to load resource: the server responded with a status of 404 (Not Found)"
},
{
"page": "staff",
"type": "console",
"text": "Failed to load resource: the server responded with a status of 404 (Not Found)"
}
]
}

View file

@ -53,8 +53,9 @@
<div class="card">
<div class="row">
<div>
<label for="id">Customer id or name</label>
<input id="id" value="cust_1" autocomplete="off" />
<label for="id">Customer</label>
<input id="id" list="cust-list" placeholder="Type a name or id" autocomplete="off" />
<datalist id="cust-list"></datalist>
</div>
<button type="button" onclick="review()">Review account</button>
</div>
@ -93,6 +94,18 @@
}).join('') + '</tr>').join('');
return `<table><thead><tr>${head}</tr></thead><tbody>${body}</tbody></table>`;
}
async function loadCustomers() {
try {
const { customers } = await (await fetch('/customers')).json();
document.getElementById('cust-list').innerHTML = (customers || [])
.map((c) => `<option value="${c.name}">${c.id}</option>`)
.join('');
if (customers && customers[0] && !document.getElementById('id').value) {
document.getElementById('id').value = customers[0].name;
}
} catch { /* edge optional */ }
}
loadCustomers();
async function review() {
const id = document.getElementById('id').value.trim();
try {

View file

@ -1,4 +1,16 @@
/** Join ledger ids to zappier-edge customer display names. */
export async function listCustomers(edge, key) {
try {
const r = await fetch(`${edge.replace(/\/$/, '')}/admin/api/customers`, {
headers: { 'x-admin-key': key },
});
const body = await r.json();
return (body.customers || []).map(({ passwordHash, apiKey, totpSecret, ...rest }) => rest);
} catch {
return [];
}
}
export async function withCustomerName(st, idOrName, edge, key) {
const out = { ...(st || {}) };
if (out.name && out.customerId) return out;

View file

@ -4,7 +4,7 @@ import fs from 'node:fs';
import http from 'node:http';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { withCustomerName } from './names.js';
import { listCustomers, withCustomerName } from './names.js';
const PUBLIC = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', 'public');
const PORT = Number(process.env.PORT || 3025);
@ -47,6 +47,9 @@ const server = http.createServer(async (req, res) => {
if (req.method === 'GET' && url.pathname === '/health') {
return json(200, { ok: true, role: 'verae-access-staff', plane: PLANE });
}
if (req.method === 'GET' && url.pathname === '/customers') {
return json(200, { customers: await listCustomers(EDGE, KEY) });
}
const review = url.pathname.match(/^\/review\/([^/]+)$/);
if (req.method === 'GET' && review) {
const id = decodeURIComponent(review[1]);

View file

@ -390,9 +390,10 @@ class="sourceCode bash"><code class="sourceCode bash"><span id="cb1-1"><a href="
<tbody>
<tr>
<td><strong>Fleet</strong></td>
<td>Machines (SSH/local), replica floors, green/yellow/red, RTT
min/avg/p50/p90 spark, pause/restart/off with confirm, ssh-check
toast</td>
<td>Machines (SSH/local), replica floors, green/yellow/red, RTT spark.
Replica and service actions live in one <strong></strong> overflow
menu. Power column shows on/paused/off as status, not toggles.
Add-machine roles are chips.</td>
</tr>
<tr>
<td><strong>Trace</strong></td>

View file

@ -67,7 +67,10 @@
tr.down { background:var(--err-bg); }
.inst { font:12px ui-monospace,Menlo,monospace; margin:.2rem 0; display:flex; gap:.4rem; align-items:center; flex-wrap:wrap; }
form.add { display:flex; flex-wrap:wrap; gap:.4rem; margin:.4rem 0 1rem; align-items:end; }
form.add .roles-field { flex: 1 0 100%; }
form.add .row1, form.add .row2 { display:flex; flex-wrap:wrap; gap:.4rem; align-items:end; width:100%; }
form.add .identity-field { flex: 1 1 22rem; }
form.add .identity-field input[name="identityFile"] { min-width:18rem; width:100%; }
form.add .roles-field { flex: 1 1 100%; }
form.add label { font-size:11px; color:var(--muted); display:flex; flex-direction:column; gap:.15rem; }
form.add input, form.add select, aside input, aside select, aside textarea {
padding:.4rem .5rem; border:1px solid var(--line); border-radius:8px; font:13px ui-monospace,Menlo,monospace;
@ -104,6 +107,7 @@
box-shadow:var(--shadow); min-width:11rem; padding:.25rem; z-index:40;
}
.more.open .more-pop { display:block; }
.more.open.up .more-pop { top:auto; bottom:calc(100% + 4px); }
.more-pop button {
display:block; width:100%; text-align:left; border:0; background:none;
padding:.45rem .6rem; border-radius:8px; font:650 12px system-ui; cursor:pointer; color:var(--ink);
@ -134,13 +138,21 @@
<p id="meta" class="muted"></p>
<h2>Machines</h2>
<form class="add" id="addMachine">
<div class="row1">
<label>id <input name="id" required placeholder="ns1-b"/></label>
<label>host <input name="host" required placeholder="70.88.205.138"/></label>
<label>kind <select name="kind"><option value="local">local</option><option value="ssh" selected>ssh</option><option value="agent">agent</option></select></label>
<label>user <input name="user" value="marchon"/></label>
<label>ssh port <input name="sshPort" type="number" value="22"/></label>
<label>identity file <input name="identityFile" value="~/.ssh/id_ed25519"/></label>
<label>capacity <input name="capacity" type="number" min="1" value="8"/></label>
</div>
<div class="row2">
<label class="identity-field">identity file (path on this host)
<span style="display:flex;gap:.35rem;align-items:center">
<input name="identityFile" value="~/.ssh/id_ed25519"/>
<input type="file" id="identityPick" title="Use filename only; SSH keys stay on disk" style="max-width:11rem"/>
</span>
</label>
<label class="roles-field">roles
<div class="roles" id="roleChips">
<label><input type="checkbox" name="role" value="*"/> *</label>
@ -154,6 +166,7 @@
</div>
</label>
<button class="act primary" type="submit">Add machine</button>
</div>
</form>
<div class="cards" id="machines"></div>
<h2>Services</h2>
@ -290,6 +303,11 @@ document.addEventListener('click', (e) => {
const wrap = btn.closest('.more');
document.querySelectorAll('.more.open').forEach((m) => { if (m !== wrap) m.classList.remove('open'); });
wrap.classList.toggle('open');
if (wrap.classList.contains('open')) {
const pop = wrap.querySelector('.more-pop');
const space = window.innerHeight - wrap.getBoundingClientRect().bottom;
wrap.classList.toggle('up', space < (pop?.offsetHeight || 140) + 16);
}
e.stopPropagation();
return;
}
@ -357,6 +375,11 @@ async function drawFleet() {
</tr>`;
}).join('') + '</tbody></table></div>';
}
$('identityPick')?.addEventListener('change', (e) => {
const f = e.target.files && e.target.files[0];
if (!f) return;
document.querySelector('input[name="identityFile"]').value = '~/.ssh/' + f.name;
});
$('addMachine').addEventListener('submit', async (e) => {
e.preventDefault();
const f = e.target;

View file

@ -120,7 +120,7 @@
<h2 style="margin-top:0">Response to Zapier</h2>
<pre id="response">Run a step to see output.</pre>
</section>
<section class="console" id="console" aria-label="trace console"></section>
<section class="console" id="console" aria-label="trace console">Run a step to fill this trace.</section>
<section class="monitors">
<h2 style="margin-top:0">Monitors &amp; suggested modifications</h2>
<div id="monitors"></div>

View file

@ -53,8 +53,9 @@
<div class="card">
<div class="row">
<div>
<label for="id">Customer id or name</label>
<input id="id" value="cust_1" autocomplete="off" />
<label for="id">Customer</label>
<input id="id" list="cust-list" placeholder="Type a name or id" autocomplete="off" />
<datalist id="cust-list"></datalist>
</div>
<button type="button" onclick="review()">Review account</button>
<a class="btn ghost" href="/export/quickbooks.iif">QuickBooks IIF</a>
@ -77,6 +78,18 @@
}).join('') + '</tr>').join('');
return `<table><thead><tr>${head}</tr></thead><tbody>${body}</tbody></table>`;
}
async function loadCustomers() {
try {
const { customers } = await (await fetch('/customers')).json();
document.getElementById('cust-list').innerHTML = (customers || [])
.map((c) => `<option value="${c.name}">${c.id}</option>`)
.join('');
if (customers && customers[0] && !document.getElementById('id').value) {
document.getElementById('id').value = customers[0].name;
}
} catch { /* edge optional */ }
}
loadCustomers();
async function review() {
const id = document.getElementById('id').value.trim();
try {

View file

@ -1,4 +1,16 @@
/** Join ledger ids to zappier-edge customer display names. */
export async function listCustomers(edge, key) {
try {
const r = await fetch(`${edge.replace(/\/$/, '')}/admin/api/customers`, {
headers: { 'x-admin-key': key },
});
const body = await r.json();
return (body.customers || []).map(({ passwordHash, apiKey, totpSecret, ...rest }) => rest);
} catch {
return [];
}
}
export async function withCustomerName(st, idOrName, edge, key) {
const out = { ...(st || {}) };
if (out.name && out.customerId) return out;

View file

@ -5,7 +5,7 @@ import http from 'node:http';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { SUBJECTS, billingRequest } from './nats-billing.js';
import { withCustomerName } from './names.js';
import { listCustomers, withCustomerName } from './names.js';
const PORT = Number(process.env.PORT || 3013);
const EDGE = (process.env.ZAPPIER_ADMIN_URL || 'http://127.0.0.1:3000').replace(/\/$/, '');
@ -41,6 +41,9 @@ const server = http.createServer(async (req, res) => {
if (req.method === 'GET' && url.pathname === '/health') {
return json(200, { ok: true, role: 'zappier-accounting-export' });
}
if (req.method === 'GET' && url.pathname === '/customers') {
return json(200, { customers: await listCustomers(EDGE, KEY) });
}
const review = url.pathname.match(/^\/review\/([^/]+)$/);
if (req.method === 'GET' && review) {
const id = decodeURIComponent(review[1]);

View file

@ -14,7 +14,8 @@ curl -X POST http://127.0.0.1:3011/credits -H 'content-type: application/json' \
| Route | Job |
|-------|-----|
| `GET /` | Staff review UI |
| `GET /` | Staff review UI (typeahead names). `STAFF_AUTH=1` redirects to `verae-staff-session`. |
| `GET /customers` | `{ customers }` from zappier-edge |
| `GET /health` | `{ ok, role }` |
| `POST /credits` | Add/subtract prepaid cents (NATS `balance.adjust`) |
| `GET /credits?customerId=` | Ledger |

View file

@ -58,8 +58,9 @@
<div class="card">
<div class="row">
<div>
<label for="id">Customer id or name</label>
<input id="id" value="cust_1" autocomplete="off" />
<label for="id">Customer</label>
<input id="id" list="cust-list" placeholder="Type a name or id" autocomplete="off" />
<datalist id="cust-list"></datalist>
</div>
<button type="button" onclick="review()">Review account</button>
</div>
@ -112,6 +113,18 @@
<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>`;
}
async function loadCustomers() {
try {
const { customers } = await (await fetch('/customers')).json();
document.getElementById('cust-list').innerHTML = (customers || [])
.map((c) => `<option value="${c.name}">${c.id}</option>`)
.join('');
if (customers && customers[0] && !document.getElementById('id').value) {
document.getElementById('id').value = customers[0].name;
}
} catch { /* edge optional */ }
}
loadCustomers();
async function review() {
const id = document.getElementById('id').value.trim();
try {

View file

@ -1,4 +1,16 @@
/** Join ledger ids to zappier-edge customer display names. */
export async function listCustomers(edge, key) {
try {
const r = await fetch(`${edge.replace(/\/$/, '')}/admin/api/customers`, {
headers: { 'x-admin-key': key },
});
const body = await r.json();
return (body.customers || []).map(({ passwordHash, apiKey, totpSecret, ...rest }) => rest);
} catch {
return [];
}
}
export async function withCustomerName(st, idOrName, edge, key) {
const out = { ...(st || {}) };
if (out.name && out.customerId) return out;

View file

@ -7,7 +7,7 @@ import http from 'node:http';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { SUBJECTS, billingRequest } from './nats-billing.js';
import { withCustomerName } from './names.js';
import { listCustomers, withCustomerName } from './names.js';
const PUBLIC = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', 'public');
@ -50,6 +50,9 @@ const server = http.createServer(async (req, res) => {
if (req.method === 'GET' && url.pathname === '/health') {
return json(200, { ok: true, role: 'zappier-customer-service', nats: Boolean(process.env.NATS_URL) });
}
if (req.method === 'GET' && url.pathname === '/customers') {
return json(200, { customers: await listCustomers(EDGE, KEY) });
}
const review = url.pathname.match(/^\/review\/([^/]+)$/);
if (req.method === 'GET' && review) {
const id = decodeURIComponent(review[1]);

View file

@ -56,8 +56,9 @@
<div class="card">
<div class="row">
<div>
<label for="id">Customer id or name</label>
<input id="id" value="cust_2" autocomplete="off" />
<label for="id">Customer</label>
<input id="id" list="cust-list" placeholder="Type a name or id" autocomplete="off" />
<datalist id="cust-list"></datalist>
</div>
<button type="button" onclick="review()">Review account</button>
<button type="button" class="ghost" onclick="quote()">Show quote</button>
@ -92,6 +93,18 @@
<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>`;
}
async function loadCustomers() {
try {
const { customers } = await (await fetch('/customers')).json();
document.getElementById('cust-list').innerHTML = (customers || [])
.map((c) => `<option value="${c.name}">${c.id}</option>`)
.join('');
if (customers && customers[0] && !document.getElementById('id').value) {
document.getElementById('id').value = customers[0].name;
}
} catch { /* edge optional */ }
}
loadCustomers();
async function review() {
const id = document.getElementById('id').value.trim();
try {

View file

@ -1,4 +1,16 @@
/** Join ledger ids to zappier-edge customer display names. */
export async function listCustomers(edge, key) {
try {
const r = await fetch(`${edge.replace(/\/$/, '')}/admin/api/customers`, {
headers: { 'x-admin-key': key },
});
const body = await r.json();
return (body.customers || []).map(({ passwordHash, apiKey, totpSecret, ...rest }) => rest);
} catch {
return [];
}
}
export async function withCustomerName(st, idOrName, edge, key) {
const out = { ...(st || {}) };
if (out.name && out.customerId) return out;

View file

@ -6,7 +6,7 @@ import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { SUBJECTS, billingRequest } from './nats-billing.js';
import { withCustomerName } from './names.js';
import { listCustomers, withCustomerName } from './names.js';
const PUBLIC = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', 'public');
@ -53,6 +53,9 @@ const server = http.createServer(async (req, res) => {
if (req.method === 'GET' && url.pathname === '/health') {
return json(200, { ok: true, role: 'zappier-sales-pricing' });
}
if (req.method === 'GET' && url.pathname === '/customers') {
return json(200, { customers: await listCustomers(EDGE, KEY) });
}
const review = url.pathname.match(/^\/review\/([^/]+)$/);
if (req.method === 'GET' && review) {
const id = decodeURIComponent(review[1]);

View file

@ -181,7 +181,9 @@ export function adminRouter(
});
router.get('/customers', (req, res) => {
res.json({ customers: customers.list().map((c) => ({ ...c, apiKey: undefined })) });
res.json({
customers: customers.list().map((c) => ({ ...c, apiKey: undefined, passwordHash: undefined, totpSecret: undefined })),
});
});
router.post('/customers', (req, res) => {