Initial import of zappier-edge from zapier monorepo

This commit is contained in:
George Lambert 2026-09-11 13:37:15 -04:00
commit d6b86e4284
120 changed files with 19867 additions and 0 deletions

356
portal/app.js Normal file
View 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 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');
}
/* ---------------- 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();
}

269
portal/index.html Normal file
View file

@ -0,0 +1,269 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Zappier Portal</title>
<style>
:root {
--bg: #f4f5fb;
--panel: #ffffff;
--ink: #171a26;
--muted: #6b7186;
--line: #e5e7f0;
--accent: #4f46e5;
--accent-ink: #ffffff;
--accent-soft: #eef0fe;
--danger: #dc2626;
--ok: #047857;
--radius: 12px;
--shadow: 0 1px 2px rgba(23, 26, 38, 0.05), 0 8px 24px rgba(23, 26, 38, 0.06);
}
* { box-sizing: border-box; }
body {
margin: 0;
font-family: -apple-system, "SF Pro Text", "Segoe UI", "PingFang SC", sans-serif;
background: var(--bg);
color: var(--ink);
font-size: 14px;
}
/* ---------- Auth ---------- */
#auth {
min-height: 100vh;
display: grid;
place-items: center;
background: linear-gradient(160deg, #312e81 0%, #4f46e5 55%, #7c74f0 100%);
}
#auth .card {
width: 380px;
background: var(--panel);
border-radius: 16px;
box-shadow: 0 24px 64px rgba(17, 12, 60, 0.35);
padding: 2rem;
}
#auth h1 { font-size: 1.35rem; margin: 0 0 0.25rem; }
#auth p.sub { color: var(--muted); margin: 0 0 1.25rem; }
#auth label { display: block; font-weight: 600; font-size: 0.8rem; margin: 0.9rem 0 0.3rem; }
#auth input {
width: 100%;
padding: 0.6rem 0.75rem;
border: 1px solid var(--line);
border-radius: 8px;
font-size: 0.95rem;
}
#auth input:focus { outline: 2px solid var(--accent); border-color: transparent; }
#auth button.primary {
width: 100%;
margin-top: 1.4rem;
padding: 0.65rem;
border: 0;
border-radius: 8px;
background: var(--accent);
color: var(--accent-ink);
font-weight: 700;
font-size: 0.95rem;
cursor: pointer;
}
#auth button.primary:hover { filter: brightness(1.08); }
#auth-error { color: var(--danger); font-size: 0.85rem; min-height: 1.2em; margin: 0.6rem 0 0; }
#auth-switch { margin: 1rem 0 0; font-size: 0.85rem; color: var(--muted); text-align: center; }
#auth-switch a { color: var(--accent); cursor: pointer; font-weight: 600; text-decoration: none; }
#totp-group { display: none; }
/* ---------- Shell ---------- */
#shell { display: none; min-height: 100vh; }
#shell.on { display: grid; grid-template-columns: 232px 1fr; }
aside {
background: var(--panel);
border-right: 1px solid var(--line);
padding: 1.25rem 0.9rem;
display: flex;
flex-direction: column;
gap: 0.25rem;
position: sticky;
top: 0;
height: 100vh;
}
.brand { display: flex; align-items: center; gap: 0.6rem; padding: 0.25rem 0.6rem 1.1rem; }
.brand .dot {
width: 30px; height: 30px; border-radius: 9px;
background: linear-gradient(140deg, var(--accent), #8b85f2);
display: grid; place-items: center; color: #fff; font-weight: 800;
}
.brand b { font-size: 1.02rem; }
nav button {
display: flex; align-items: center; gap: 0.55rem;
width: 100%;
border: 0; background: none;
text-align: left;
padding: 0.55rem 0.7rem;
border-radius: 8px;
font-size: 0.92rem;
color: var(--muted);
cursor: pointer;
}
nav button:hover { background: var(--bg); color: var(--ink); }
nav button.active { background: var(--accent-soft); color: var(--accent); font-weight: 700; }
aside .spacer { flex: 1; }
#logout {
border: 1px solid var(--line); background: none; border-radius: 8px;
padding: 0.5rem; color: var(--muted); cursor: pointer; font-size: 0.85rem;
}
#logout:hover { color: var(--danger); border-color: var(--danger); }
main { padding: 1.75rem 2rem 3rem; max-width: 1080px; }
main h2 { margin: 0 0 0.25rem; font-size: 1.3rem; }
main .lede { color: var(--muted); margin: 0 0 1.25rem; }
.card {
background: var(--panel);
border: 1px solid var(--line);
border-radius: var(--radius);
box-shadow: var(--shadow);
padding: 1.1rem 1.25rem;
margin-bottom: 1.25rem;
}
.card h3 { margin: 0 0 0.9rem; font-size: 0.95rem; }
table { border-collapse: collapse; width: 100%; }
th {
text-align: left; font-size: 0.72rem; text-transform: uppercase;
letter-spacing: 0.04em; color: var(--muted);
border-bottom: 1px solid var(--line); padding: 0.45rem 0.6rem;
}
td { border-bottom: 1px solid var(--line); padding: 0.55rem 0.6rem; }
tr:last-child td { border-bottom: 0; }
tbody tr:hover { background: #fafaff; }
td.id, .id { font-family: "SF Mono", Menlo, monospace; font-size: 0.82rem; font-weight: 600; }
input, select {
padding: 0.4rem 0.55rem;
border: 1px solid var(--line);
border-radius: 7px;
font-size: 0.88rem;
background: #fff;
color: var(--ink);
}
input:focus, select:focus { outline: 2px solid var(--accent); border-color: transparent; }
button.btn {
border: 0; border-radius: 7px; padding: 0.42rem 0.85rem;
font-size: 0.85rem; font-weight: 600; cursor: pointer;
background: var(--accent); color: var(--accent-ink);
}
button.btn:hover { filter: brightness(1.08); }
button.btn.ghost { background: none; border: 1px solid var(--line); color: var(--muted); }
button.btn.ghost:hover { color: var(--accent); border-color: var(--accent); }
.row-actions { white-space: nowrap; text-align: right; }
.row-actions button { margin-left: 0.35rem; }
.pill {
display: inline-block; padding: 0.1rem 0.55rem; border-radius: 999px;
font-size: 0.72rem; font-weight: 700;
}
.pill.draft { background: #f1f5f9; color: #475569; }
.pill.issued { background: #fff7ed; color: #c2410c; }
.pill.paid { background: #ecfdf5; color: var(--ok); }
.pill.free { background: #ecfdf5; color: var(--ok); }
.pill.fixed { background: var(--accent-soft); color: var(--accent); }
.pill.variable { background: #fff7ed; color: #c2410c; }
.pill.on { background: #ecfdf5; color: var(--ok); }
.pill.off { background: #f1f5f9; color: #475569; }
.stat-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: 0.9rem; margin-bottom: 1.25rem; }
.stat {
background: var(--panel); border: 1px solid var(--line);
border-radius: var(--radius); box-shadow: var(--shadow);
padding: 0.9rem 1.1rem;
}
.stat .k { font-size: 0.72rem; text-transform: uppercase; letter-spacing: 0.05em; color: var(--muted); }
.stat .v { font-size: 1.35rem; font-weight: 800; margin-top: 0.2rem; font-variant-numeric: tabular-nums; }
.money { font-variant-numeric: tabular-nums; text-align: right; }
.kv { display: grid; grid-template-columns: 170px 1fr; row-gap: 0.5rem; align-items: center; }
.kv dt { color: var(--muted); font-size: 0.82rem; }
.kv dd { margin: 0; font-weight: 600; }
.apikey-row { display: flex; gap: 0.5rem; align-items: center; }
.apikey-row code {
background: var(--bg); border: 1px solid var(--line); border-radius: 7px;
padding: 0.45rem 0.7rem; font-size: 0.85rem; flex: 1;
font-family: "SF Mono", Menlo, monospace;
}
.qr-box { display: flex; gap: 1.25rem; align-items: flex-start; margin: 0.8rem 0; }
.qr-box img { border: 1px solid var(--line); border-radius: 10px; }
.qr-box .secret {
font-family: "SF Mono", Menlo, monospace; font-size: 0.9rem; font-weight: 700;
letter-spacing: 0.06em; background: var(--bg); padding: 0.4rem 0.7rem; border-radius: 7px;
}
.hint { color: var(--muted); font-size: 0.82rem; margin-top: 0.8rem; }
.filterbar { display: flex; flex-wrap: wrap; align-items: end; gap: 0.8rem; }
.filterbar label { display: flex; flex-direction: column; gap: 0.25rem; }
.filterbar label span {
font-size: 0.72rem; text-transform: uppercase; letter-spacing: 0.04em;
color: var(--muted); font-weight: 600;
}
#status {
position: fixed; right: 1.25rem; bottom: 1.25rem;
background: var(--ink); color: #fff;
padding: 0.7rem 1.1rem; border-radius: 10px;
box-shadow: var(--shadow);
font-size: 0.88rem;
opacity: 0; transform: translateY(8px);
transition: opacity 0.2s, transform 0.2s;
max-width: 420px;
pointer-events: none;
}
#status.show { opacity: 1; transform: none; }
#status.error { background: var(--danger); }
</style>
</head>
<body>
<section id="auth">
<form class="card" id="auth-form">
<h1>Zappier Portal</h1>
<p class="sub" id="auth-sub">Sign in to your customer account.</p>
<div id="name-group" style="display:none">
<label for="auth-name">Name</label>
<input id="auth-name" autocomplete="name" />
</div>
<label for="auth-email">Email</label>
<input id="auth-email" type="email" autocomplete="email" required />
<label for="auth-password">Password</label>
<input id="auth-password" type="password" autocomplete="current-password" required />
<div id="totp-group">
<label for="auth-totp">Authenticator code</label>
<input id="auth-totp" inputmode="numeric" placeholder="123456" />
</div>
<p id="auth-error"></p>
<button type="submit" class="primary" id="auth-submit">Sign in</button>
<p id="auth-switch">New here? <a id="auth-toggle">Create an account</a></p>
</form>
</section>
<div id="shell">
<aside>
<div class="brand"><span class="dot">Z</span><b>Zappier</b></div>
<nav>
<button data-tab="dashboard" class="active">&nbsp; Dashboard</button>
<button data-tab="invoices">&nbsp; Invoices</button>
<button data-tab="billing">&nbsp; Billing</button>
<button data-tab="security">&nbsp; Security</button>
<button data-tab="docs">&nbsp; API &amp; pricing</button>
</nav>
<div class="spacer"></div>
<button id="logout">Sign out</button>
</aside>
<main>
<section id="dashboard"></section>
<section id="invoices" hidden></section>
<section id="billing" hidden></section>
<section id="security" hidden></section>
<section id="docs" hidden></section>
</main>
</div>
<p id="status"></p>
<script src="app.js"></script>
</body>
</html>