Initial import of zappier-edge from zapier monorepo

This commit is contained in:
George Lambert 2026-09-11 17:15:28 -04:00
commit b9dca6cd0f
130 changed files with 20724 additions and 0 deletions

715
admin/app.js Normal file
View file

@ -0,0 +1,715 @@
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();
renderStatement();
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 ghost" onclick="reviewCustomer('${c.id}')">Statement</button>
<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();
}
function renderStatement() {
const options = customerOptions(state.customers[0]?.id || '');
document.getElementById('statement').innerHTML = `
<h2>Customer statement</h2>
<p class="lede">Credits, prepaid balance, usage, and payments. Prefers NATS account-balance.</p>
<div class="card">
<div class="filterbar">
<label><span>Customer</span><select id="stmt-customer">${options}</select></label>
<button class="btn" onclick="reviewCustomer(document.getElementById('stmt-customer').value)">Load</button>
</div>
<div id="stmt-out"></div>
</div>`;
}
async function reviewCustomer(id) {
if (!id) return;
const st = await api(`/statement/${id}`);
const row = (list, cols) =>
(list || [])
.map((r) => `<tr>${cols.map((c) => `<td>${r[c] ?? ''}</td>`).join('')}</tr>`)
.join('') || `<tr><td colspan="${cols.length}" style="color:var(--muted)">None.</td></tr>`;
const html = `
<p class="lede">${st.name || id} · prepaid ${fmt(st.prepaidCents || 0)} · source ${st.source || 'local'}</p>
<div class="card"><h3>Credits</h3><table><thead><tr><th>Cents</th><th>Reason</th><th>Agent</th><th>When</th></tr></thead>
<tbody>${row(st.credits, ['cents', 'reason', 'agent', 'at'])}</tbody></table></div>
<div class="card"><h3>Usage</h3><table><thead><tr><th>Endpoint</th><th>Cents</th><th>When</th></tr></thead>
<tbody>${row(st.usage, ['endpointId', 'cents', 'at'])}</tbody></table></div>
<div class="card"><h3>Payments</h3><table><thead><tr><th>Cents</th><th>Kind</th><th>Reason</th><th>When</th></tr></thead>
<tbody>${row(st.payments, ['cents', 'kind', 'reason', 'at'])}</tbody></table></div>`;
const out = document.getElementById('stmt-out');
if (out) out.innerHTML = html;
else {
document.getElementById('statement').innerHTML = `<h2>Customer statement</h2>${html}`;
document.querySelectorAll('nav button').forEach((b) => b.classList.toggle('active', b.dataset.tab === 'statement'));
document.querySelectorAll('main section').forEach((s) => (s.hidden = s.id !== 'statement'));
}
const sel = document.getElementById('stmt-customer');
if (sel) sel.value = id;
say(`Loaded statement for ${id}.`);
}
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();
}

262
admin/index.html Normal file
View file

@ -0,0 +1,262 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Zappier Admin</title>
<style>
:root {
--bg: #f4f5fb;
--panel: #ffffff;
--ink: #171a26;
--muted: #6b7186;
--line: #e5e7f0;
--accent: #4f46e5;
--accent-ink: #ffffff;
--accent-soft: #eef0fe;
--danger: #dc2626;
--ok: #047857;
--radius: 12px;
--shadow: 0 1px 2px rgba(23, 26, 38, 0.05), 0 8px 24px rgba(23, 26, 38, 0.06);
}
* { box-sizing: border-box; }
body {
margin: 0;
font-family: -apple-system, "SF Pro Text", "Segoe UI", "PingFang SC", sans-serif;
background: var(--bg);
color: var(--ink);
font-size: 14px;
}
/* ---------- Login ---------- */
#login {
min-height: 100vh;
display: grid;
place-items: center;
background: linear-gradient(160deg, #312e81 0%, #4f46e5 55%, #7c74f0 100%);
}
#login .card {
width: 360px;
background: var(--panel);
border-radius: 16px;
box-shadow: 0 24px 64px rgba(17, 12, 60, 0.35);
padding: 2rem;
}
#login h1 { font-size: 1.35rem; margin: 0 0 0.25rem; }
#login p.sub { color: var(--muted); margin: 0 0 1.5rem; }
#login label { display: block; font-weight: 600; font-size: 0.8rem; margin: 0.9rem 0 0.3rem; }
#login input {
width: 100%;
padding: 0.6rem 0.75rem;
border: 1px solid var(--line);
border-radius: 8px;
font-size: 0.95rem;
}
#login input:focus { outline: 2px solid var(--accent); border-color: transparent; }
#login button {
width: 100%;
margin-top: 1.4rem;
padding: 0.65rem;
border: 0;
border-radius: 8px;
background: var(--accent);
color: var(--accent-ink);
font-weight: 700;
font-size: 0.95rem;
cursor: pointer;
}
#login button:hover { filter: brightness(1.08); }
#login-error { color: var(--danger); font-size: 0.85rem; min-height: 1.2em; margin: 0.6rem 0 0; }
/* ---------- Shell ---------- */
#shell { display: none; min-height: 100vh; }
#shell.on { display: grid; grid-template-columns: 232px 1fr; }
aside {
background: var(--panel);
border-right: 1px solid var(--line);
padding: 1.25rem 0.9rem;
display: flex;
flex-direction: column;
gap: 0.25rem;
position: sticky;
top: 0;
height: 100vh;
}
.brand { display: flex; align-items: center; gap: 0.6rem; padding: 0.25rem 0.6rem 1.1rem; }
.brand .dot {
width: 30px; height: 30px; border-radius: 9px;
background: linear-gradient(140deg, var(--accent), #8b85f2);
display: grid; place-items: center; color: #fff; font-weight: 800;
}
.brand b { font-size: 1.02rem; }
nav button {
display: flex; align-items: center; gap: 0.55rem;
width: 100%;
border: 0; background: none;
text-align: left;
padding: 0.55rem 0.7rem;
border-radius: 8px;
font-size: 0.92rem;
color: var(--muted);
cursor: pointer;
}
nav button:hover { background: var(--bg); color: var(--ink); }
nav button.active { background: var(--accent-soft); color: var(--accent); font-weight: 700; }
aside .spacer { flex: 1; }
#logout {
border: 1px solid var(--line); background: none; border-radius: 8px;
padding: 0.5rem; color: var(--muted); cursor: pointer; font-size: 0.85rem;
}
#logout:hover { color: var(--danger); border-color: var(--danger); }
main { padding: 1.75rem 2rem 3rem; max-width: 1080px; }
main h2 { margin: 0 0 0.25rem; font-size: 1.3rem; }
main .lede { color: var(--muted); margin: 0 0 1.25rem; }
.card {
background: var(--panel);
border: 1px solid var(--line);
border-radius: var(--radius);
box-shadow: var(--shadow);
padding: 1.1rem 1.25rem;
margin-bottom: 1.25rem;
}
.card h3 { margin: 0 0 0.9rem; font-size: 0.95rem; }
table { border-collapse: collapse; width: 100%; }
th {
text-align: left; font-size: 0.72rem; text-transform: uppercase;
letter-spacing: 0.04em; color: var(--muted);
border-bottom: 1px solid var(--line); padding: 0.45rem 0.6rem;
}
td { border-bottom: 1px solid var(--line); padding: 0.55rem 0.6rem; }
tr:last-child td { border-bottom: 0; }
tbody tr:hover { background: #fafaff; }
td.id { font-family: "SF Mono", Menlo, monospace; font-size: 0.82rem; font-weight: 600; }
input, select {
padding: 0.4rem 0.55rem;
border: 1px solid var(--line);
border-radius: 7px;
font-size: 0.88rem;
background: #fff;
color: var(--ink);
}
input:focus, select:focus { outline: 2px solid var(--accent); border-color: transparent; }
input[type="number"] { width: 90px; }
.field { display: inline-flex; align-items: center; gap: 0.35rem; margin-right: 0.7rem; }
.field span { color: var(--muted); font-size: 0.78rem; font-family: "SF Mono", Menlo, monospace; }
button.btn {
border: 0; border-radius: 7px; padding: 0.42rem 0.85rem;
font-size: 0.85rem; font-weight: 600; cursor: pointer;
background: var(--accent); color: var(--accent-ink);
}
button.btn:hover { filter: brightness(1.08); }
button.btn.ghost { background: none; border: 1px solid var(--line); color: var(--muted); }
button.btn.ghost:hover { color: var(--danger); border-color: var(--danger); }
.row-actions { white-space: nowrap; text-align: right; }
.row-actions button { margin-left: 0.35rem; }
.pill {
display: inline-block; padding: 0.1rem 0.55rem; border-radius: 999px;
font-size: 0.72rem; font-weight: 700;
}
.pill.free { background: #ecfdf5; color: var(--ok); }
.pill.fixed { background: var(--accent-soft); color: var(--accent); }
.pill.variable { background: #fff7ed; color: #c2410c; }
.pill.draft { background: #f1f5f9; color: #475569; }
.pill.issued { background: #fff7ed; color: #c2410c; }
.pill.paid { background: #ecfdf5; color: var(--ok); }
.pill.stripe { background: var(--accent-soft); color: var(--accent); }
.pill.purchase_order { background: #fdf4ff; color: #a21caf; }
/* ---------- Accounting tabs ---------- */
.filterbar {
display: flex; flex-wrap: wrap; align-items: end; gap: 0.8rem;
margin-bottom: 1rem;
}
.filterbar label { display: flex; flex-direction: column; gap: 0.25rem; }
.filterbar label span {
font-size: 0.72rem; text-transform: uppercase; letter-spacing: 0.04em;
color: var(--muted); font-weight: 600;
}
.money { font-variant-numeric: tabular-nums; text-align: right; }
.stat-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: 0.9rem; margin-bottom: 1.25rem; }
.stat {
background: var(--panel); border: 1px solid var(--line);
border-radius: var(--radius); box-shadow: var(--shadow);
padding: 0.9rem 1.1rem;
}
.stat .k { font-size: 0.72rem; text-transform: uppercase; letter-spacing: 0.05em; color: var(--muted); }
.stat .v { font-size: 1.35rem; font-weight: 800; margin-top: 0.2rem; font-variant-numeric: tabular-nums; }
.chart { width: 100%; height: auto; display: block; }
.chart .bar { fill: var(--accent); }
.chart .bar:hover { fill: #3730a3; }
.chart text { fill: var(--muted); font-size: 10px; font-family: inherit; }
.skip-list { margin: 0.6rem 0 0; padding-left: 1.1rem; color: var(--muted); font-size: 0.85rem; }
.kv { display: grid; grid-template-columns: 160px 1fr; row-gap: 0.45rem; }
.kv dt { color: var(--muted); font-size: 0.82rem; }
.kv dd { margin: 0; font-weight: 600; }
#status {
position: fixed; right: 1.25rem; bottom: 1.25rem;
background: var(--ink); color: #fff;
padding: 0.7rem 1.1rem; border-radius: 10px;
box-shadow: var(--shadow);
font-size: 0.88rem;
opacity: 0; transform: translateY(8px);
transition: opacity 0.2s, transform 0.2s;
max-width: 420px;
pointer-events: none;
}
#status.show { opacity: 1; transform: none; }
#status.error { background: var(--danger); }
.hint { color: var(--muted); font-size: 0.82rem; margin-top: 0.8rem; }
</style>
</head>
<body>
<section id="login">
<form class="card" id="login-form">
<h1>Zappier Admin</h1>
<p class="sub">Sign in to manage pricing, customer types, and customers.</p>
<label for="login-username">Username</label>
<input id="login-username" autocomplete="username" required />
<label for="login-password">Password</label>
<input id="login-password" type="password" autocomplete="current-password" required />
<p id="login-error"></p>
<button type="submit">Sign in</button>
</form>
</section>
<div id="shell">
<aside>
<div class="brand"><span class="dot">Z</span><b>Zappier</b></div>
<nav>
<button data-tab="endpoints" class="active">&nbsp; Rate card</button>
<button data-tab="tiers">&nbsp; Customer types</button>
<button data-tab="customers">&nbsp; Customers</button>
<button data-tab="statement">&nbsp; Statement</button>
<button data-tab="invoices">&nbsp; Invoices</button>
<button data-tab="reports">&nbsp; Reports</button>
<button data-tab="system">&nbsp; System</button>
<button data-tab="users">&nbsp; Users</button>
</nav>
<div class="spacer"></div>
<button id="logout">Sign out</button>
</aside>
<main>
<section id="endpoints"></section>
<section id="tiers" hidden></section>
<section id="customers" hidden></section>
<section id="statement" hidden></section>
<section id="invoices" hidden></section>
<section id="reports" hidden></section>
<section id="system" hidden></section>
<section id="users" hidden></section>
</main>
</div>
<p id="status"></p>
<script src="app.js"></script>
</body>
</html>