Some checks are pending
offline / test (push) Waiting to run
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.
386 lines
16 KiB
JavaScript
386 lines
16 KiB
JavaScript
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 browser’s 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 portal’s 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 you’ll 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 & 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 plan’s multiplier scales list prices; the monthly credit is free included usage. You’re 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();
|
||
}
|