master-zapier-plan-draft/packages/zappier/portal/app.js
George Lambert ac38676645
Some checks are pending
offline / test (push) Waiting to run
Add NATS account-balance SoT and statement review for customers, CS, and sales
Internal billing now uses verae.billing.* request-reply and pubs. zappier-account-balance tracks prepaid, credits, usage, and payments. Portal, admin, CS, and sales all review the same statement. Independent Forgejo repos stay split via push-module-repos.
2026-09-11 15:35:37 -04:00

386 lines
16 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

const state = { me: null, usage: null, invoices: [], pricing: null, statement: null };
const TOKEN_KEY = 'zappier-portal-token';
let authMode = 'login'; // 'login' | 'signup'
/* ---------------- auth ---------------- */
function token() {
return localStorage.getItem(TOKEN_KEY);
}
function showAuth(message = '') {
document.getElementById('shell').classList.remove('on');
document.getElementById('auth').style.display = 'grid';
document.getElementById('auth-error').textContent = message;
}
function showShell() {
document.getElementById('auth').style.display = 'none';
document.getElementById('shell').classList.add('on');
}
function setAuthMode(mode) {
authMode = mode;
const isSignup = mode === 'signup';
document.getElementById('name-group').style.display = isSignup ? 'block' : 'none';
document.getElementById('totp-group').style.display = 'none';
document.getElementById('auth-sub').textContent = isSignup
? 'Create your customer account — you get an API key immediately.'
: 'Sign in to your customer account.';
document.getElementById('auth-submit').textContent = isSignup ? 'Create account' : 'Sign in';
document.getElementById('auth-switch').innerHTML = isSignup
? 'Already have an account? <a id="auth-toggle">Sign in</a>'
: 'New here? <a id="auth-toggle">Create an account</a>';
document.getElementById('auth-error').textContent = '';
document
.getElementById('auth-toggle')
.addEventListener('click', () => setAuthMode(isSignup ? 'login' : 'signup'));
}
document.getElementById('auth-toggle').addEventListener('click', () => setAuthMode('signup'));
document.getElementById('auth-form').addEventListener('submit', async (e) => {
e.preventDefault();
const email = document.getElementById('auth-email').value.trim();
const password = document.getElementById('auth-password').value;
const totpCode = document.getElementById('auth-totp').value.trim();
try {
const path = authMode === 'signup' ? '/portal/api/signup' : '/portal/api/login';
const body =
authMode === 'signup'
? { name: document.getElementById('auth-name').value.trim(), email, password }
: { email, password, ...(totpCode ? { totpCode } : {}) };
const res = await fetch(path, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(body),
});
const data = await res.json();
if (!res.ok) {
if (data.error === 'totp_required') {
document.getElementById('totp-group').style.display = 'block';
document.getElementById('auth-totp').focus();
throw new Error('Enter the 6-digit code from your authenticator app.');
}
throw new Error(data.error || 'Authentication failed');
}
localStorage.setItem(TOKEN_KEY, data.token);
showShell();
load().catch((err) => say(err.message, true));
} catch (err) {
document.getElementById('auth-error').textContent = err.message;
}
});
document.getElementById('logout').addEventListener('click', async () => {
try {
await api('/logout', { method: 'POST', body: '{}' });
} catch {
/* session already gone */
}
localStorage.removeItem(TOKEN_KEY);
location.reload();
});
/* ---------------- api + status ---------------- */
async function api(path, options = {}) {
const res = await fetch(`/portal/api${path}`, {
...options,
headers: { 'content-type': 'application/json', authorization: `Bearer ${token()}` },
});
if (res.status === 401) {
localStorage.removeItem(TOKEN_KEY);
showAuth('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) : '—');
async function load() {
state.me = await api('/me');
state.usage = await api('/usage');
state.invoices = (await api('/invoices')).invoices;
state.pricing = await api('/pricing');
state.statement = await api('/statement');
renderDashboard();
renderInvoices();
renderStatement();
renderBilling();
renderSecurity();
renderDocs();
}
/* ---------------- dashboard ---------------- */
function renderDashboard() {
const u = state.usage;
const me = state.me;
const tier = state.pricing.tiers.find((t) => t.id === me.tierId);
document.getElementById('dashboard').innerHTML = `
<h2>Welcome, ${me.name}</h2>
<p class="lede">${me.email} · ${tier ? tier.name : me.tierId} plan · ${me.billingType === 'stripe' ? 'Stripe billing' : 'Purchase-order billing'}</p>
<div class="stat-grid">
<div class="stat"><div class="k">Usage this month</div><div class="v">${fmt(u.totalCents)}</div></div>
<div class="stat"><div class="k">Included credit</div><div class="v">${fmt(u.includedCents)}</div></div>
<div class="stat"><div class="k">Billable</div><div class="v">${fmt(u.billableCents)}</div></div>
<div class="stat"><div class="k">Prepaid balance</div><div class="v">${fmt(me.balanceCents)}</div></div>
</div>
<div class="card">
<h3>Your API key</h3>
<div class="apikey-row">
<code id="api-key">${me.apiKey}</code>
<button class="btn ghost" onclick="copyKey()">Copy</button>
<button class="btn" onclick="regenerateKey()">Regenerate</button>
</div>
<p class="hint">Send it as the <span class="id">x-api-key</span> header. Regenerating invalidates the old key immediately. Per-endpoint usage: ${Object.entries(u.byEndpoint ?? {}).map(([k, v]) => `${k}: ${v.calls} calls`).join(', ') || 'none yet this month'}.</p>
</div>`;
}
function copyKey() {
navigator.clipboard.writeText(document.getElementById('api-key').textContent);
say('API key copied.');
}
async function regenerateKey() {
const res = await api('/api-key', { method: 'POST', body: '{}' });
state.me.apiKey = res.apiKey;
renderDashboard();
say('New API key issued — the old key no longer works.');
}
/* ---------------- invoices ---------------- */
function renderInvoices() {
const rows = state.invoices
.slice()
.sort((a, b) => b.id.localeCompare(a.id))
.map(
(inv) => `<tr>
<td class="id">${inv.id}</td>
<td>${inv.period}</td>
<td><span class="pill ${inv.status}">${inv.status}</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"><button class="btn ghost" onclick="viewInvoice('${inv.id}')">View / print</button></td>
</tr>`,
)
.join('');
document.getElementById('invoices').innerHTML = `
<h2>Invoices</h2>
<p class="lede">Your invoice history. “View / print” opens a print-ready page — use the browsers Print → Save as PDF.</p>
<div class="card"><table>
<thead><tr><th>Invoice</th><th>Period</th><th>Status</th><th>Total</th><th>Credit</th><th>Amount due</th><th>Due date</th><th></th></tr></thead>
<tbody>${rows || '<tr><td colspan="8" style="color:var(--muted)">No invoices yet.</td></tr>'}</tbody>
</table></div>`;
}
async function viewInvoice(id) {
const res = await fetch(`/portal/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');
}
/* ---------------- statement ---------------- */
function renderStatement() {
const st = state.statement || { prepaidCents: 0, credits: [], usage: [], payments: [] };
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 yet.</td></tr>`;
document.getElementById('statement').innerHTML = `
<h2>Statement</h2>
<p class="lede">Prepaid balance, customer-service credits, metered usage, and payments. ${st.source === 'nats' ? 'Live from account-balance over NATS.' : 'From this portals ledger.'}</p>
<div class="stat-grid">
<div class="stat"><div class="k">Prepaid balance</div><div class="v">${fmt(st.prepaidCents || 0)}</div></div>
<div class="stat"><div class="k">Credits</div><div class="v">${st.credits?.length || 0}</div></div>
<div class="stat"><div class="k">Usage events</div><div class="v">${st.usage?.length || 0}</div></div>
<div class="stat"><div class="k">Payments</div><div class="v">${st.payments?.length || 0}</div></div>
</div>
<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>`;
}
/* ---------------- billing ---------------- */
function renderBilling() {
const me = state.me;
document.getElementById('billing').innerHTML = `
<h2>Billing</h2>
<p class="lede">Prepaid balance is drawn down automatically when an invoice is issued.</p>
<div class="card">
<h3>Reload balance <span class="pill on" style="margin-left:0.4rem">${fmt(me.balanceCents)}</span></h3>
<div class="filterbar">
<label><span>Amount (USD)</span><input id="reload-amount" type="number" min="1" max="10000" step="1" value="25"></label>
<button class="btn" onclick="reload()">Add funds</button>
</div>
<p class="hint">Minimum $1, maximum $10,000 per reload. In the development environment funds are credited instantly; with Stripe configured youll complete payment securely.</p>
</div>
<div class="card">
<h3>Email invoicing</h3>
<p class="hint" style="margin-top:0">Send a copy of each issued invoice to <b>${me.email}</b>.</p>
<label style="display:flex;align-items:center;gap:0.5rem;margin-top:0.5rem">
<input type="checkbox" id="email-invoicing" ${me.emailInvoicing ? 'checked' : ''} onchange="toggleEmailInvoicing(this.checked)">
Email me new invoices
</label>
</div>`;
}
async function reload() {
const dollars = Number(document.getElementById('reload-amount').value);
const amountCents = Math.round(dollars * 100);
const res = await api('/reload', { method: 'POST', body: JSON.stringify({ amountCents }) });
state.me.balanceCents = res.balanceCents;
renderBilling();
if (res.mode === 'dev') say(`Balance reloaded — new balance ${fmt(res.balanceCents)}.`);
else say('Payment started — your balance updates when the payment confirms.');
}
async function toggleEmailInvoicing(enabled) {
await api('/email-invoicing', { method: 'PUT', body: JSON.stringify({ enabled }) });
state.me.emailInvoicing = enabled;
say(enabled ? 'Email invoicing enabled.' : 'Email invoicing disabled.');
}
/* ---------------- security ---------------- */
function renderSecurity() {
const me = state.me;
document.getElementById('security').innerHTML = `
<h2>Security</h2>
<p class="lede">Two-factor authentication protects your account with a time-based code from an authenticator app.</p>
<div class="card">
<h3>Two-factor authentication <span class="pill ${me.totpEnabled ? 'on' : 'off'}" style="margin-left:0.4rem">${me.totpEnabled ? 'enabled' : 'disabled'}</span></h3>
<div id="totp-area">
${
me.totpEnabled
? `<p class="hint" style="margin-top:0">To disable 2FA, enter the current code from your authenticator app.</p>
<div class="filterbar">
<label><span>Authenticator code</span><input id="totp-disable-code" inputmode="numeric" placeholder="123456"></label>
<button class="btn ghost" onclick="disableTotp()">Disable 2FA</button>
</div>`
: `<p class="hint" style="margin-top:0">2FA is off. Set it up to require a code at sign-in.</p>
<button class="btn" onclick="setupTotp()">Set up 2FA</button>
<div id="totp-setup"></div>`
}
</div>
</div>
<div class="card">
<h3>Password</h3>
<p class="hint" style="margin:0">Password changes are handled by support for now — sign out everywhere by signing back in (old sessions expire after 7 days).</p>
</div>`;
}
async function setupTotp() {
const res = await api('/2fa/setup', { method: 'POST', body: '{}' });
document.getElementById('totp-setup').innerHTML = `
<div class="qr-box">
<img src="${res.qr}" alt="2FA QR code" width="160" height="160">
<div>
<p style="margin:0 0 0.5rem"><b>1.</b> Scan with your authenticator app (or enter the secret manually):</p>
<p class="secret">${res.secret}</p>
<p style="margin:0.8rem 0 0.4rem"><b>2.</b> Enter the 6-digit code it shows:</p>
<div class="filterbar">
<label><span>Code</span><input id="totp-enable-code" inputmode="numeric" placeholder="123456"></label>
<button class="btn" onclick="enableTotp()">Enable 2FA</button>
</div>
</div>
</div>`;
}
async function enableTotp() {
const code = document.getElementById('totp-enable-code').value.trim();
await api('/2fa/enable', { method: 'POST', body: JSON.stringify({ code }) });
state.me.totpEnabled = true;
renderSecurity();
say('Two-factor authentication is now on.');
}
async function disableTotp() {
const code = document.getElementById('totp-disable-code').value.trim();
await api('/2fa/disable', { method: 'POST', body: JSON.stringify({ code }) });
state.me.totpEnabled = false;
renderSecurity();
say('Two-factor authentication disabled.');
}
/* ---------------- docs + pricing ---------------- */
function renderDocs() {
const rows = Object.entries(state.pricing.rateCard.endpoints)
.map(([id, rule]) => {
const price =
rule.kind === 'free'
? 'Free'
: rule.kind === 'fixed'
? `${rule.fixedCents}¢ / call`
: `${rule.baseCents}¢ + ${rule.perKbCents}¢/KB + ${rule.perMbCents}¢/MB`;
return `<tr><td class="id">${id}</td><td><span class="pill ${rule.kind}">${rule.kind}</span></td><td>${price}</td></tr>`;
})
.join('');
const tiers = state.pricing.tiers
.map(
(t) =>
`<tr><td class="id">${t.id}</td><td>${t.name}</td><td class="money">${t.multiplier}×</td><td class="money">${fmt(t.monthlyCreditCents)}</td></tr>`,
)
.join('');
document.getElementById('docs').innerHTML = `
<h2>API &amp; pricing</h2>
<p class="lede">Interactive API reference lives at <a href="/docs" target="_blank" style="color:var(--accent)">/docs</a> (Swagger UI) — use your API key as <span class="id">x-api-key</span>.</p>
<div class="card">
<h3>Rate card (list prices)</h3>
<table><thead><tr><th>Endpoint</th><th>Kind</th><th>List price</th></tr></thead><tbody>${rows}</tbody></table>
</div>
<div class="card">
<h3>Plans</h3>
<table><thead><tr><th>Id</th><th>Name</th><th>Price multiplier</th><th>Monthly credit</th></tr></thead><tbody>${tiers}</tbody></table>
<p class="hint">Your plans multiplier scales list prices; the monthly credit is free included usage. Youre on <b>${state.me.tierId}</b>.</p>
</div>`;
}
/* ---------------- 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 {
showAuth();
}