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? Sign in' : 'New here? Create an account'; 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) : '—'); const EMPTY_SVG = ''; function maskKey(k) { if (!k || k.length < 8) return '••••••••'; return `${k.slice(0, 4)}••••••••${k.slice(-4)}`; } 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 = `
${me.email} · ${tier ? tier.name : me.tierId} plan · ${me.billingType === 'stripe' ? 'Stripe billing' : 'Purchase-order billing'}
${maskKey(me.apiKey)}
Send it as the x-api-key header. The key stays masked on screen. Copy puts the full key on the clipboard. 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'}.
Your invoice history. “View / print” opens a print-ready page — use the browser’s Print → Save as PDF.
| Invoice | Period | Status | Total | Credit | Amount due | Due date |
|---|
After the first billing period is generated, invoices appear here. Use View / print to save a PDF.
Prepaid balance, customer-service credits, metered usage, and payments. ${st.source === 'nats' ? 'Live from account-balance over NATS.' : 'From this portal’s ledger.'}
| Amount | Reason | Agent | When |
|---|
| Endpoint | Amount | When |
|---|
| Amount | Kind | Reason | When |
|---|
Prepaid balance is drawn down automatically when an invoice is issued.
Minimum $1, maximum $10,000 per reload. In the development environment funds are credited instantly; with Stripe configured you’ll complete payment securely.
Send a copy of each issued invoice to ${me.email}.
Two-factor authentication protects your account with a time-based code from an authenticator app.
To disable 2FA, enter the current code from your authenticator app.
` : `2FA is off. Set it up to require a code at sign-in.
` }Password changes are handled by support for now — sign out everywhere by signing back in (old sessions expire after 7 days).
1. Scan with your authenticator app (or enter the secret manually):
${res.secret}
2. Enter the 6-digit code it shows:
Interactive API reference lives at /docs. That page is stock Swagger UI — not a designed customer surface. Use your API key as x-api-key.
| Endpoint | Kind | List price |
|---|
| Id | Name | Price multiplier | Monthly credit |
|---|
Your plan’s multiplier scales list prices; the monthly credit is free included usage. You’re on ${state.me.tierId}.