Initial import of zappier-edge from zapier monorepo
This commit is contained in:
commit
d6b86e4284
120 changed files with 19867 additions and 0 deletions
356
portal/app.js
Normal file
356
portal/app.js
Normal file
|
|
@ -0,0 +1,356 @@
|
|||
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? <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');
|
||||
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 = `
|
||||
<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');
|
||||
}
|
||||
|
||||
/* ---------------- 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();
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue