const state = { me: null, usage: null, invoices: [], pricing: 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) : '—'); async function load() { state.me = await api('/me'); state.usage = await api('/usage'); state.invoices = (await api('/invoices')).invoices; state.pricing = await api('/pricing'); renderDashboard(); renderInvoices(); 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 = `

Welcome, ${me.name}

${me.email} · ${tier ? tier.name : me.tierId} plan · ${me.billingType === 'stripe' ? 'Stripe billing' : 'Purchase-order billing'}

Usage this month
${fmt(u.totalCents)}
Included credit
${fmt(u.includedCents)}
Billable
${fmt(u.billableCents)}
Prepaid balance
${fmt(me.balanceCents)}

Your API key

${me.apiKey}

Send it as the x-api-key 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'}.

`; } 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) => ` ${inv.id} ${inv.period} ${inv.status} ${fmt(inv.totalCents)} ${fmt(inv.creditCents)} ${fmt(inv.billableCents)} ${fmtDate(inv.dueAtMs)} `, ) .join(''); document.getElementById('invoices').innerHTML = `

Invoices

Your invoice history. “View / print” opens a print-ready page — use the browser’s Print → Save as PDF.

${rows || ''}
InvoicePeriodStatusTotalCreditAmount dueDue date
No invoices yet.
`; } 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'); } /* ---------------- billing ---------------- */ function renderBilling() { const me = state.me; document.getElementById('billing').innerHTML = `

Billing

Prepaid balance is drawn down automatically when an invoice is issued.

Reload balance ${fmt(me.balanceCents)}

Minimum $1, maximum $10,000 per reload. In the development environment funds are credited instantly; with Stripe configured you’ll complete payment securely.

Email invoicing

Send a copy of each issued invoice to ${me.email}.

`; } 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 = `

Security

Two-factor authentication protects your account with a time-based code from an authenticator app.

Two-factor authentication ${me.totpEnabled ? 'enabled' : 'disabled'}

${ me.totpEnabled ? `

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

Password changes are handled by support for now — sign out everywhere by signing back in (old sessions expire after 7 days).

`; } async function setupTotp() { const res = await api('/2fa/setup', { method: 'POST', body: '{}' }); document.getElementById('totp-setup').innerHTML = `
2FA QR code

1. Scan with your authenticator app (or enter the secret manually):

${res.secret}

2. Enter the 6-digit code it shows:

`; } 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 `${id}${rule.kind}${price}`; }) .join(''); const tiers = state.pricing.tiers .map( (t) => `${t.id}${t.name}${t.multiplier}×${fmt(t.monthlyCreditCents)}`, ) .join(''); document.getElementById('docs').innerHTML = `

API & pricing

Interactive API reference lives at /docs (Swagger UI) — use your API key as x-api-key.

Rate card (list prices)

${rows}
EndpointKindList price

Plans

${tiers}
IdNamePrice multiplierMonthly credit

Your plan’s multiplier scales list prices; the monthly credit is free included usage. You’re on ${state.me.tierId}.

`; } /* ---------------- 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(); }