Initial import of zappier-edge from zapier monorepo

This commit is contained in:
George Lambert 2026-09-11 13:37:15 -04:00
commit d6b86e4284
120 changed files with 19867 additions and 0 deletions

672
admin/app.js Normal file
View file

@ -0,0 +1,672 @@
const state = { pricing: null, customers: [], invoices: [], report: null, trend: null, system: null, users: [] };
const TOKEN_KEY = 'zappier-admin-token';
/* ---------------- auth ---------------- */
function token() {
return localStorage.getItem(TOKEN_KEY);
}
function showLogin(message = '') {
document.getElementById('shell').classList.remove('on');
document.getElementById('login').style.display = 'grid';
document.getElementById('login-error').textContent = message;
}
function showShell() {
document.getElementById('login').style.display = 'none';
document.getElementById('shell').classList.add('on');
}
document.getElementById('login-form').addEventListener('submit', async (e) => {
e.preventDefault();
const username = document.getElementById('login-username').value.trim();
const password = document.getElementById('login-password').value;
try {
const res = await fetch('/admin/api/login', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ username, password }),
});
if (!res.ok) throw new Error((await res.json()).error || 'Login failed');
const { token: t } = await res.json();
localStorage.setItem(TOKEN_KEY, t);
showShell();
load().catch((err) => say(err.message, true));
} catch (err) {
showLogin(err.message);
}
});
document.getElementById('logout').addEventListener('click', () => {
localStorage.removeItem(TOKEN_KEY);
location.reload();
});
/* ---------------- api + status ---------------- */
async function api(path, options = {}) {
const res = await fetch(`/admin/api${path}`, {
...options,
headers: { 'content-type': 'application/json', authorization: `Bearer ${token()}` },
});
if (res.status === 403) {
localStorage.removeItem(TOKEN_KEY);
showLogin('Session expired — sign in again.');
throw new Error('Session expired.');
}
if (!res.ok) throw new Error(`${res.status}: ${(await res.json()).error}`);
return res.json();
}
let statusTimer;
function say(msg, isError = false) {
const el = document.getElementById('status');
el.textContent = msg;
el.classList.toggle('error', isError);
el.classList.add('show');
clearTimeout(statusTimer);
statusTimer = setTimeout(() => el.classList.remove('show'), 4000);
}
/* ---------------- shared helpers ---------------- */
const fmt = (cents) =>
(cents < 0 ? '-$' : '$') + (Math.abs(cents) / 100).toFixed(2);
const fmtDate = (ms) => (ms ? new Date(ms).toISOString().slice(0, 10) : '—');
const customerName = (id) => state.customers.find((c) => c.id === id)?.name ?? id;
function customerOptions(selected, includeAll = false) {
const all = includeAll ? `<option value="">All customers</option>` : '';
return (
all +
state.customers
.map((c) => `<option value="${c.id}" ${c.id === selected ? 'selected' : ''}>${c.name} (${c.id})</option>`)
.join('')
);
}
function currentPeriod() {
return new Date().toISOString().slice(0, 7);
}
async function load() {
state.pricing = await api('/pricing');
state.customers = (await api('/customers')).customers;
state.invoices = (await api('/invoices')).invoices;
state.users = (await api('/users')).users;
renderEndpoints();
renderTiers();
renderCustomers();
renderInvoices();
renderReports();
renderSystem();
renderUsers();
}
/* ---------------- rate card ---------------- */
function ruleInputs(id, rule) {
const fields =
rule.kind === 'fixed'
? { fixedCents: rule.fixedCents }
: rule.kind === 'variable'
? { baseCents: rule.baseCents, perKbCents: rule.perKbCents, perMbCents: rule.perMbCents }
: {};
return Object.entries(fields)
.map(
([k, v]) =>
`<label class="field"><span>${k}</span><input data-endpoint="${id}" data-field="${k}" type="number" step="any" value="${v}" size="6"></label>`,
)
.join('');
}
function renderEndpoints() {
const rows = Object.entries(state.pricing.rateCard.endpoints)
.map(
([id, rule]) => `<tr>
<td class="id">${id}</td>
<td><span class="pill ${rule.kind}">${rule.kind}</span></td>
<td><select data-endpoint-kind="${id}">
${['free', 'fixed', 'variable'].map((k) => `<option ${k === rule.kind ? 'selected' : ''}>${k}</option>`).join('')}
</select></td>
<td>${ruleInputs(id, rule)}</td>
<td class="row-actions">
<button class="btn" onclick="saveEndpoint('${id}')">Save</button>
<button class="btn ghost" onclick="deleteEndpoint('${id}')">Delete</button>
</td>
</tr>`,
)
.join('');
document.getElementById('endpoints').innerHTML = `
<h2>Rate card</h2>
<p class="lede">Per-endpoint list prices, in cents. Changes apply to the next API call no restart.</p>
<div class="card"><table>
<thead><tr><th>Endpoint (operationId)</th><th>Kind</th><th>Set kind</th><th>Prices (cents)</th><th></th></tr></thead>
<tbody>${rows}</tbody>
</table></div>
<div class="card">
<h3>Add endpoint</h3>
<input id="new-endpoint-id" placeholder="operationId">
<select id="new-endpoint-kind"><option>free</option><option selected>fixed</option><option>variable</option></select>
<button class="btn" onclick="addEndpoint()">Add</button>
<p class="hint">The operationId must match an operation in openapi.yaml.</p>
</div>`;
}
async function saveEndpoint(id) {
const kind = document.querySelector(`[data-endpoint-kind="${id}"]`).value;
const rule = { kind };
document.querySelectorAll(`input[data-endpoint="${id}"]`).forEach((el) => {
rule[el.dataset.field] = Number(el.value);
});
if (kind === 'fixed' && rule.fixedCents === undefined) rule.fixedCents = 0;
if (kind === 'variable') {
rule.baseCents = rule.baseCents ?? 0;
rule.perKbCents = rule.perKbCents ?? 0;
rule.perMbCents = rule.perMbCents ?? 0;
}
await api(`/endpoints/${id}`, { method: 'PUT', body: JSON.stringify(rule) });
say(`Saved ${id}.`);
await load();
}
async function deleteEndpoint(id) {
await api(`/endpoints/${id}`, { method: 'DELETE' });
say(`Deleted ${id} — calls to it now get 403 unless a tier has a default rule.`);
await load();
}
async function addEndpoint() {
const id = document.getElementById('new-endpoint-id').value.trim();
const kind = document.getElementById('new-endpoint-kind').value;
if (!id) return say('Endpoint id required.', true);
const rule =
kind === 'free'
? { kind }
: kind === 'fixed'
? { kind, fixedCents: 0 }
: { kind, baseCents: 0, perKbCents: 0, perMbCents: 0 };
await api(`/endpoints/${id}`, { method: 'PUT', body: JSON.stringify(rule) });
say(`Added ${id}.`);
await load();
}
/* ---------------- customer types ---------------- */
function renderTiers() {
const rows = state.pricing.tiers
.map(
(t) => `<tr>
<td class="id">${t.id}</td>
<td><input data-tier="${t.id}" data-field="name" value="${t.name}"></td>
<td><input data-tier="${t.id}" data-field="multiplier" type="number" step="any" value="${t.multiplier}" size="5"></td>
<td><input data-tier="${t.id}" data-field="monthlyCreditCents" type="number" value="${t.monthlyCreditCents}" size="8"></td>
<td class="row-actions">
<button class="btn" onclick="saveTier('${t.id}')">Save</button>
<button class="btn ghost" onclick="deleteTier('${t.id}')">Delete</button>
</td>
</tr>`,
)
.join('');
document.getElementById('tiers').innerHTML = `
<h2>Customer types</h2>
<p class="lede">Multiplier scales every list price (0.5 = 50%). Monthly credit is free included usage, in cents.</p>
<div class="card"><table>
<thead><tr><th>Id</th><th>Name</th><th>Multiplier</th><th>Monthly credit (cents)</th><th></th></tr></thead>
<tbody>${rows}</tbody>
</table></div>
<div class="card">
<h3>Add customer type</h3>
<input id="new-tier-id" placeholder="id">
<input id="new-tier-name" placeholder="name">
<input id="new-tier-multiplier" type="number" step="any" value="1" size="5"> multiplier
<button class="btn" onclick="addTier()">Add</button>
<p class="hint">New types start with 0 monthly credit edit after adding.</p>
</div>`;
}
async function saveTier(id) {
const body = { id };
document.querySelectorAll(`[data-tier="${id}"]`).forEach((el) => {
body[el.dataset.field] = el.type === 'number' ? Number(el.value) : el.value;
});
const existing = state.pricing.tiers.find((t) => t.id === id);
if (existing?.defaultRule) body.defaultRule = existing.defaultRule;
await api(`/tiers/${id}`, { method: 'PUT', body: JSON.stringify(body) });
say(`Saved tier ${id}.`);
await load();
}
async function deleteTier(id) {
await api(`/tiers/${id}`, { method: 'DELETE' });
say(`Deleted tier ${id}.`);
await load();
}
async function addTier() {
const id = document.getElementById('new-tier-id').value.trim();
const name = document.getElementById('new-tier-name').value.trim();
const multiplier = Number(document.getElementById('new-tier-multiplier').value);
if (!id || !name) return say('Tier id and name required.', true);
await api(`/tiers/${id}`, {
method: 'PUT',
body: JSON.stringify({ id, name, multiplier, monthlyCreditCents: 0 }),
});
say(`Added tier ${id}.`);
await load();
}
/* ---------------- customers ---------------- */
function renderCustomers() {
const tierOptions = (selected) =>
state.pricing.tiers
.map((t) => `<option ${t.id === selected ? 'selected' : ''}>${t.id}</option>`)
.join('');
const btOptions = (selected) =>
['stripe', 'purchase_order']
.map((b) => `<option value="${b}" ${b === (selected ?? 'stripe') ? 'selected' : ''}>${b === 'stripe' ? 'Stripe' : 'Purchase order'}</option>`)
.join('');
const rows = state.customers
.map(
(c) => `<tr>
<td class="id">${c.id}</td>
<td>${c.name}</td>
<td><input data-customer="${c.id}" data-field="email" type="email" size="18" value="${c.email ?? ''}" placeholder="—"></td>
<td><select data-customer="${c.id}" data-field="tierId">${tierOptions(c.tierId)}</select></td>
<td><input data-customer="${c.id}" data-field="multiplierOverride" type="number" step="any" size="5" value="${c.multiplierOverride ?? ''}" placeholder="—"></td>
<td><select data-customer="${c.id}" data-field="billingType">${btOptions(c.billingType)}</select></td>
<td class="row-actions"><button class="btn" onclick="saveCustomer('${c.id}')">Save</button></td>
</tr>`,
)
.join('');
document.getElementById('customers').innerHTML = `
<h2>Customers</h2>
<p class="lede">Assign types, billing method, and per-customer deals. A multiplier override replaces the type multiplier for that customer.</p>
<div class="card"><table>
<thead><tr><th>Id</th><th>Name</th><th>Email</th><th>Type</th><th>Multiplier override</th><th>Billing</th><th></th></tr></thead>
<tbody>${rows}</tbody>
</table></div>
<div class="card">
<h3>Add customer</h3>
<input id="new-customer-name" placeholder="name">
<select id="new-customer-tier">${tierOptions(state.pricing.tiers[0]?.id)}</select>
<button class="btn" onclick="addCustomer()">Create</button>
<p class="hint">The new customer's API key is shown once in the notification copy it immediately. Set email and billing method after creating.</p>
</div>`;
}
async function saveCustomer(id) {
const body = {};
document.querySelectorAll(`[data-customer="${id}"]`).forEach((el) => {
if (el.value === '') return;
body[el.dataset.field] = el.type === 'number' ? Number(el.value) : el.value;
});
await api(`/customers/${id}`, { method: 'PUT', body: JSON.stringify(body) });
say(`Saved customer ${id}.`);
await load();
}
async function addCustomer() {
const name = document.getElementById('new-customer-name').value.trim();
const tierId = document.getElementById('new-customer-tier').value;
if (!name) return say('Customer name required.', true);
const created = await api('/customers', {
method: 'POST',
body: JSON.stringify({ name, tierId }),
});
say(`Created ${created.id} — API key: ${created.apiKey}`);
await load();
}
/* ---------------- invoices ---------------- */
function renderInvoices() {
const rows = state.invoices
.slice()
.sort((a, b) => b.id.localeCompare(a.id))
.map((inv) => {
const actions = [];
actions.push(`<button class="btn ghost" onclick="viewInvoice('${inv.id}')">View</button>`);
if (inv.status === 'draft')
actions.push(`<button class="btn" onclick="invoiceAction('${inv.id}','issue')">Issue</button>`);
if (inv.status === 'issued')
actions.push(`<button class="btn" onclick="invoiceAction('${inv.id}','paid')">Mark paid</button>`);
return `<tr>
<td class="id">${inv.id}</td>
<td>${customerName(inv.customerId)}</td>
<td>${inv.period}</td>
<td><span class="pill ${inv.status}">${inv.status}</span></td>
<td><span class="pill ${inv.billingType}">${inv.billingType === 'stripe' ? 'Stripe' : 'PO'}</span>${inv.poNumber ? ` <span class="id">${inv.poNumber}</span>` : ''}</td>
<td class="money">${fmt(inv.totalCents)}</td>
<td class="money">${fmt(inv.creditCents)}</td>
<td class="money"><b>${fmt(inv.billableCents)}</b></td>
<td>${fmtDate(inv.dueAtMs)}</td>
<td class="row-actions">${actions.join('')}</td>
</tr>`;
})
.join('');
document.getElementById('invoices').innerHTML = `
<h2>Invoices</h2>
<p class="lede">Generate monthly invoices from metered usage, then issue and collect. Regenerating a period replaces drafts and skips issued/paid invoices.</p>
<div class="card">
<h3>Generate invoices</h3>
<div class="filterbar">
<label><span>Period</span><input id="gen-period" type="month" value="${currentPeriod()}"></label>
<label><span>Customer</span><select id="gen-customer">${customerOptions('', true)}</select></label>
<label><span>PO number (optional)</span><input id="gen-po" placeholder="PO-1234" size="12"></label>
<button class="btn" onclick="generateInvoices()">Generate</button>
</div>
<div id="gen-result"></div>
</div>
<div class="card">
<div class="filterbar">
<label><span>Customer</span><select id="inv-filter-customer" onchange="refreshInvoices()">${customerOptions('', true)}</select></label>
<label><span>Period</span><input id="inv-filter-period" type="month" onchange="refreshInvoices()"></label>
<label><span>Status</span><select id="inv-filter-status" onchange="refreshInvoices()">
<option value="">Any</option><option>draft</option><option>issued</option><option>paid</option>
</select></label>
<button class="btn ghost" onclick="refreshInvoices()">Refresh</button>
</div>
<table id="inv-table">
<thead><tr><th>Invoice</th><th>Customer</th><th>Period</th><th>Status</th><th>Billing</th><th>Total</th><th>Credit</th><th>Due amount</th><th>Due date</th><th></th></tr></thead>
<tbody>${rows || '<tr><td colspan="10" style="color:var(--muted)">No invoices yet — generate a period above.</td></tr>'}</tbody>
</table>
</div>`;
}
async function generateInvoices() {
const period = document.getElementById('gen-period').value;
const customerId = document.getElementById('gen-customer').value;
const poNumber = document.getElementById('gen-po').value.trim();
if (!period) return say('Pick a period first.', true);
const result = await api('/invoices/generate', {
method: 'POST',
body: JSON.stringify({
period,
...(customerId ? { customerId } : {}),
...(poNumber ? { poNumber } : {}),
}),
});
const skips = result.skipped
.map((s) => `<li>${customerName(s.customerId)}: ${s.reason}</li>`)
.join('');
document.getElementById('gen-result').innerHTML =
`<p class="hint">Generated ${result.generated.length}: ${result.generated.join(', ') || '—'}</p>` +
(skips ? `<ul class="skip-list">${skips}</ul>` : '');
say(`Generated ${result.generated.length} invoice(s), skipped ${result.skipped.length}.`);
await load();
}
async function refreshInvoices() {
const params = new URLSearchParams();
const customerId = document.getElementById('inv-filter-customer').value;
const period = document.getElementById('inv-filter-period').value;
const status = document.getElementById('inv-filter-status').value;
if (customerId) params.set('customerId', customerId);
if (period) params.set('period', period);
if (status) params.set('status', status);
state.invoices = (await api(`/invoices?${params}`)).invoices;
renderInvoices();
}
async function invoiceAction(id, action) {
await api(`/invoices/${id}/${action}`, { method: 'POST', body: '{}' });
say(action === 'issue' ? `Issued ${id}.` : `Marked ${id} paid.`);
await load();
}
async function viewInvoice(id) {
const res = await fetch(`/admin/api/invoices/${id}?format=html`, {
headers: { authorization: `Bearer ${token()}` },
});
if (!res.ok) return say(`Could not load ${id}.`, true);
const blob = await res.blob();
window.open(URL.createObjectURL(blob), '_blank');
}
/* ---------------- reports ---------------- */
function renderReports() {
const periodStart = `${currentPeriod()}-01`;
document.getElementById('reports').innerHTML = `
<h2>Reports</h2>
<p class="lede">Billing and usage analytics across customers. All amounts in USD, converted from integer cents.</p>
<div class="card">
<h3>Billing report</h3>
<div class="filterbar">
<label><span>From</span><input id="rep-from" type="date" value="${periodStart}"></label>
<label><span>To</span><input id="rep-to" type="date"></label>
<label><span>Customer</span><select id="rep-customer">${customerOptions('', true)}</select></label>
<label><span>Billing type</span><select id="rep-billing-type">
<option value="">Any</option><option value="stripe">Stripe</option><option value="purchase_order">Purchase order</option>
</select></label>
<button class="btn" onclick="runReport()">Run</button>
<button class="btn ghost" onclick="downloadCsv()">Download CSV</button>
</div>
<div id="rep-summary"></div>
<table id="rep-table"></table>
</div>
<div class="card">
<h3>Usage trend</h3>
<div class="filterbar">
<label><span>Bucket</span><select id="trend-bucket" onchange="runTrend()">
<option value="day">Daily</option><option value="week">Weekly</option>
</select></label>
</div>
<div id="trend-chart"></div>
</div>`;
runReport().catch((err) => say(err.message, true));
runTrend().catch((err) => say(err.message, true));
}
function reportQuery() {
const params = new URLSearchParams();
const from = document.getElementById('rep-from').value;
const to = document.getElementById('rep-to').value;
const customerId = document.getElementById('rep-customer').value;
const billingType = document.getElementById('rep-billing-type').value;
if (from) params.set('from', from);
if (to) params.set('to', to);
if (customerId) params.set('customerId', customerId);
if (billingType) params.set('billingType', billingType);
return params;
}
async function runReport() {
const { rows } = await api(`/reports/billing?${reportQuery()}`);
state.report = rows;
const totals = rows.reduce(
(acc, r) => ({
calls: acc.calls + r.calls,
totalCents: acc.totalCents + r.totalCents,
creditCents: acc.creditCents + r.creditCents,
billableCents: acc.billableCents + r.billableCents,
}),
{ calls: 0, totalCents: 0, creditCents: 0, billableCents: 0 },
);
document.getElementById('rep-summary').innerHTML = `
<div class="stat-grid">
<div class="stat"><div class="k">Calls</div><div class="v">${totals.calls.toLocaleString()}</div></div>
<div class="stat"><div class="k">Gross usage</div><div class="v">${fmt(totals.totalCents)}</div></div>
<div class="stat"><div class="k">Credits applied</div><div class="v">${fmt(totals.creditCents)}</div></div>
<div class="stat"><div class="k">Billable</div><div class="v">${fmt(totals.billableCents)}</div></div>
</div>`;
document.getElementById('rep-table').innerHTML = `
<thead><tr><th>Customer</th><th>Billing</th><th>Calls</th><th>Gross</th><th>Credit</th><th>Billable</th></tr></thead>
<tbody>${
rows
.map(
(r) => `<tr>
<td>${r.name} <span class="id">${r.customerId}</span></td>
<td><span class="pill ${r.billingType}">${r.billingType === 'stripe' ? 'Stripe' : 'PO'}</span></td>
<td class="money">${r.calls.toLocaleString()}</td>
<td class="money">${fmt(r.totalCents)}</td>
<td class="money">${fmt(r.creditCents)}</td>
<td class="money"><b>${fmt(r.billableCents)}</b></td>
</tr>`,
)
.join('') || '<tr><td colspan="6" style="color:var(--muted)">No usage in range.</td></tr>'
}</tbody>`;
}
async function downloadCsv() {
const params = reportQuery();
params.set('format', 'csv');
const res = await fetch(`/admin/api/reports/billing?${params}`, {
headers: { authorization: `Bearer ${token()}` },
});
if (!res.ok) return say('CSV download failed.', true);
const url = URL.createObjectURL(await res.blob());
const a = document.createElement('a');
a.href = url;
a.download = 'billing-report.csv';
a.click();
URL.revokeObjectURL(url);
say('CSV downloaded.');
}
async function runTrend() {
const bucket = document.getElementById('trend-bucket').value;
const params = reportQuery();
params.delete('billingType');
params.set('bucket', bucket);
const { points } = await api(`/reports/usage-trend?${params}`);
state.trend = points;
document.getElementById('trend-chart').innerHTML = points.length
? trendChart(points)
: '<p class="hint">No usage in range.</p>';
}
function trendChart(points) {
const W = 920;
const H = 220;
const padL = 8;
const padB = 34;
const padT = 10;
const max = Math.max(...points.map((p) => p.cents), 1);
const band = (W - padL) / points.length;
const barW = Math.max(4, Math.min(48, band * 0.62));
const bars = points
.map((p, i) => {
const h = ((H - padB - padT) * p.cents) / max;
const x = padL + i * band + (band - barW) / 2;
const y = H - padB - h;
const label =
points.length <= 31 || i % Math.ceil(points.length / 31) === 0
? `<text x="${x + barW / 2}" y="${H - padB + 13}" text-anchor="middle">${p.bucket.slice(5)}</text>`
: '';
return `<rect class="bar" x="${x.toFixed(1)}" y="${y.toFixed(1)}" width="${barW.toFixed(1)}" height="${Math.max(h, p.cents > 0 ? 2 : 0).toFixed(1)}"><title>${p.bucket}: ${p.calls} calls, ${fmt(p.cents)}</title></rect>${label}`;
})
.join('');
return `<svg class="chart" viewBox="0 0 ${W} ${H}" role="img" aria-label="Usage trend">${bars}</svg>
<p class="hint">Hover a bar for exact calls and amount. Peak: ${fmt(max)}.</p>`;
}
/* ---------------- system ---------------- */
function renderSystem() {
document.getElementById('system').innerHTML = `
<h2>System</h2>
<p class="lede">Integration health and current-period billing snapshot.</p>
<div class="card"><h3>Zapier integration</h3><div id="sys-zapier"><p class="hint">Loading</p></div></div>
<div class="card"><h3>Current period (${currentPeriod()})</h3><div id="sys-period"><p class="hint">Loading</p></div></div>`;
loadSystem().catch((err) => say(err.message, true));
}
async function loadSystem() {
const status = await api('/zapier/status');
state.system = status;
document.getElementById('sys-zapier').innerHTML = `
<dl class="kv">
<dt>App directory</dt><dd>${status.appDirPresent ? '✓ zapier-app/ found' : ' not found'}</dd>
<dt>Version</dt><dd>${status.version ?? ''}</dd>
<dt>Triggers</dt><dd>${status.triggers.length ? status.triggers.join(', ') : ''}</dd>
<dt>Creates</dt><dd>${status.creates.length ? status.creates.join(', ') : ''}</dd>
</dl>`;
const params = new URLSearchParams({ from: `${currentPeriod()}-01` });
const { rows } = await api(`/reports/billing?${params}`);
const totals = rows.reduce(
(acc, r) => ({ calls: acc.calls + r.calls, billableCents: acc.billableCents + r.billableCents }),
{ calls: 0, billableCents: 0 },
);
const unpaid = state.invoices.filter((i) => i.status === 'issued');
document.getElementById('sys-period').innerHTML = `
<div class="stat-grid">
<div class="stat"><div class="k">Calls this period</div><div class="v">${totals.calls.toLocaleString()}</div></div>
<div class="stat"><div class="k">Billable this period</div><div class="v">${fmt(totals.billableCents)}</div></div>
<div class="stat"><div class="k">Open invoices</div><div class="v">${unpaid.length}</div></div>
<div class="stat"><div class="k">Open amount</div><div class="v">${fmt(unpaid.reduce((s, i) => s + i.billableCents, 0))}</div></div>
</div>`;
}
/* ---------------- admin users ---------------- */
function renderUsers() {
const rows = state.users
.slice()
.sort((a, b) => a.username.localeCompare(b.username))
.map(
(u) => `<tr>
<td class="id">${u.username}</td>
<td><span class="pill ${u.active ? 'paid' : 'draft'}">${u.active ? 'active' : 'inactive'}</span></td>
<td>${fmtDate(u.createdMs)}</td>
<td class="row-actions">
<button class="btn ${u.active ? 'ghost' : ''}" onclick="toggleUser('${u.username}', ${!u.active})">${u.active ? 'Deactivate' : 'Activate'}</button>
</td>
</tr>`,
)
.join('');
document.getElementById('users').innerHTML = `
<h2>Admin users</h2>
<p class="lede">Accounts that can sign in to this console. Passwords are stored as scrypt hashes never in plain text. The last active admin cannot be deactivated.</p>
<div class="card"><table>
<thead><tr><th>Username</th><th>Status</th><th>Created</th><th></th></tr></thead>
<tbody>${rows}</tbody>
</table></div>
<div class="card">
<h3>Add admin user</h3>
<input id="new-user-name" placeholder="username" autocomplete="off">
<input id="new-user-password" type="password" placeholder="password (min 8 chars)" autocomplete="new-password">
<button class="btn" onclick="addUser()">Create</button>
<p class="hint">Usernames may contain letters, digits, dots, dashes, and underscores. Deactivated users are blocked from signing in immediately.</p>
</div>`;
}
async function addUser() {
const username = document.getElementById('new-user-name').value.trim();
const password = document.getElementById('new-user-password').value;
if (!username || !password) return say('Username and password required.', true);
await api('/users', { method: 'POST', body: JSON.stringify({ username, password }) });
say(`Created admin user ${username}.`);
await load();
}
async function toggleUser(username, activate) {
await api(`/users/${username}/${activate ? 'activate' : 'deactivate'}`, {
method: 'POST',
body: '{}',
});
say(`${activate ? 'Activated' : 'Deactivated'} ${username}.`);
await load();
}
/* ---------------- tabs + boot ---------------- */
document.querySelectorAll('nav button').forEach((btn) =>
btn.addEventListener('click', () => {
document.querySelectorAll('nav button').forEach((b) => b.classList.remove('active'));
btn.classList.add('active');
document.querySelectorAll('main section').forEach((s) => (s.hidden = true));
document.getElementById(btn.dataset.tab).hidden = false;
}),
);
if (token()) {
showShell();
load().catch((err) => say(err.message, true));
} else {
showLogin();
}