Files
keysat/licensing-service/web/index.html
T
Grant 356d17fdde Multi-currency Phase 2 — admin write path (currency picker)
Backend:
- POST /v1/admin/products accepts both forms:
  - legacy: { price_sats: 50000 }
  - typed:  { price_currency: 'USD', price_value: 4900 }
  Whitelist enforced (SAT|USD|EUR). Mismatched legacy + typed → 400
  to catch half-migrated clients sending stale price_sats alongside
  fresh price_value.
- repo::create_product_with_currency: SAT → dual-write price_sats =
  price_value; USD/EUR → price_sats = 0 until first invoice creation
  triggers a rate lookup (Phase 4 + 5).
- Test admin_create_product_accepts_legacy_and_typed_currency_forms
  pins 6 happy/sad paths.

Frontend (Products page):
- Create-product form has a currency picker (sats / USD / EUR).
  Picker swaps the unit hint + step in place.
- Decimal entry on USD/EUR is converted to cents on the way out.
- Products table renders prices via formatProductPrice(): USD
  products show "$49.00" with optional "≈ 75k sats" hint.

Test count: 34 (was 33).
2026-05-08 12:11:36 -05:00

2694 lines
122 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Keysat Admin</title>
<link href="https://fonts.googleapis.com/css2?family=Manrope:wght@400;500;600;700&family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500;600&display=swap" rel="stylesheet">
<style>
:root {
--navy-950:#0E1F33; --navy-900:#142A47; --navy-800:#1E3A5F; --navy-700:#2A4A75;
--navy-100:#E4EAF1;
--cream-50:#FBF9F2; --cream-100:#F5F1E8; --cream-200:#EDE7D7;
--gold-700:#8A6F3D; --gold-500:#BFA068; --gold-400:#D4B985;
--ink-900:#0E1F33; --ink-700:#2C3E54; --ink-500:#5A6B7F; --ink-400:#7E8C9D;
--success:#2D7A5F; --success-bg:#E3F0EA;
--warning:#B8861F; --warning-bg:#F7EFD7;
--danger:#B23A3A; --danger-bg:#F4E0E0;
--border-1:rgba(14,31,51,0.12);
--border-2:rgba(14,31,51,0.20);
--font-display:'Manrope','Helvetica Neue',Arial,sans-serif;
--font-body:'Inter','Helvetica Neue',Arial,sans-serif;
--font-mono:'JetBrains Mono',ui-monospace,'SF Mono',Menlo,monospace;
--shadow-xs:0 1px 1px rgba(14,31,51,0.04);
--shadow-sm:0 1px 2px rgba(14,31,51,0.06),0 1px 1px rgba(14,31,51,0.03);
}
* { box-sizing:border-box; }
html, body { margin:0; padding:0; }
body {
font-family:var(--font-body); font-size:14px;
color:var(--ink-900); background:var(--cream-100);
background-image:
radial-gradient(rgba(14,31,51,0.022) 1px, transparent 1px),
radial-gradient(rgba(138,111,61,0.020) 1px, transparent 1px);
background-size:3px 3px, 7px 7px;
-webkit-font-smoothing:antialiased;
}
a { color:var(--navy-800); text-decoration:none; }
/* ---------- Layout ---------- */
.app { display:grid; grid-template-columns:240px 1fr; min-height:100vh; }
/* ---------- Sidebar ---------- */
.sidebar {
background:var(--navy-950); color:#F5F1E8;
padding:24px 14px;
display:flex; flex-direction:column;
border-right:1px solid var(--navy-900);
position:sticky; top:0; max-height:100vh; height:100vh; overflow-y:auto;
}
.sidebar .brand {
display:flex; align-items:center; gap:10px;
font-family:var(--font-display); font-weight:500; font-size:14px;
letter-spacing:0.28em; text-transform:uppercase;
color:var(--cream-50);
padding:0 8px 22px;
border-bottom:1px solid rgba(245,241,232,0.10);
margin-bottom:14px;
}
.sidebar .brand img { width:26px; height:26px; }
.sidebar .group-label {
font-size:10px; font-weight:700; letter-spacing:0.18em;
text-transform:uppercase; color:var(--gold-400);
padding:16px 10px 8px;
}
.sidebar a.nav {
display:flex; align-items:center; gap:10px;
padding:9px 10px; border-radius:6px;
font-size:13.5px; color:rgba(245,241,232,0.72);
cursor:pointer; transition:all 120ms;
}
.sidebar a.nav:hover { background:rgba(245,241,232,0.06); color:var(--cream-50); }
.sidebar a.nav.active { background:var(--navy-800); color:var(--cream-50); }
.sidebar a.nav [data-lucide] { width:16px; height:16px; }
.sidebar .footer {
margin-top:auto; padding:14px 10px;
border-top:1px solid rgba(245,241,232,0.10);
font-size:12px; color:rgba(245,241,232,0.55);
display:flex; gap:10px; align-items:center;
}
.sidebar .footer .dot {
width:7px; height:7px; border-radius:50%; background:#2D7A5F;
box-shadow:0 0 0 3px rgba(45,122,95,0.25);
}
.sidebar .footer .dot.warn { background:var(--warning); box-shadow:0 0 0 3px rgba(184,134,31,0.25); }
/* ---------- Main ---------- */
.main { display:flex; flex-direction:column; min-width:0; }
.topbar {
display:flex; align-items:center; gap:16px;
padding:18px 32px; border-bottom:1px solid var(--border-1);
background:rgba(251,249,242,0.92); backdrop-filter:blur(8px);
position:sticky; top:0; z-index:5;
}
.topbar .crumb { font-size:12.5px; color:var(--ink-500); }
.topbar h1 {
font-family:var(--font-display); font-weight:700; font-size:22px;
letter-spacing:-0.015em; margin:2px 0 0; color:var(--navy-950);
}
.topbar .topbar-actions {
margin-left:auto;
display:flex; gap:8px; align-items:center;
}
.topbar .who {
font-family:var(--font-mono); font-size:11.5px; color:var(--ink-500);
padding:5px 9px; border:1px solid var(--border-1); border-radius:6px;
background:var(--cream-50);
}
.content { padding:28px 32px 64px; max-width:1280px; }
/* ---------- Buttons ---------- */
.btn {
display:inline-flex; align-items:center; gap:7px;
font-family:var(--font-body); font-weight:600; font-size:13px;
padding:8px 14px; border-radius:7px; border:1px solid transparent;
cursor:pointer; transition:all 120ms; line-height:1; white-space:nowrap;
}
.btn [data-lucide] { width:14px; height:14px; }
.btn.lg { font-size:14px; padding:11px 18px; }
.btn.sm { font-size:12px; padding:6px 10px; }
.btn.primary { background:var(--navy-800); color:var(--cream-50); border-color:var(--navy-800); }
.btn.primary:hover { background:var(--navy-900); border-color:var(--navy-900); }
.btn.secondary { background:var(--cream-50); color:var(--navy-900); border-color:var(--border-2); }
.btn.secondary:hover { background:var(--cream-200); }
.btn.ghost { background:transparent; color:var(--navy-900); }
.btn.ghost:hover { background:rgba(14,31,51,0.06); }
.btn.danger { color:var(--danger); border-color:rgba(178,58,58,0.3); background:transparent; }
.btn.danger:hover { background:var(--danger-bg); }
.btn:disabled { opacity:0.5; cursor:wait; }
/* ---------- Cards ---------- */
.card {
background:var(--cream-50); border:1px solid var(--border-1);
border-radius:10px; box-shadow:var(--shadow-xs);
margin-bottom:18px;
}
.card .card-head {
padding:14px 18px; border-bottom:1px solid var(--border-1);
display:flex; align-items:center; justify-content:space-between; gap:12px;
}
.card .card-head h3 {
font-family:var(--font-display); font-weight:700; font-size:15px;
margin:0; letter-spacing:-0.01em; color:var(--navy-950);
}
.card .card-head .sub {
font-size:12.5px; color:var(--ink-500); margin-left:auto;
}
.card .card-body { padding:18px; }
.card .card-body > p:first-child { margin-top:0; }
/* ---------- Stats ---------- */
.stats { display:grid; grid-template-columns:repeat(4, 1fr); gap:14px; margin-bottom:20px; }
.stat {
background:var(--cream-50); border:1px solid var(--border-1);
border-radius:10px; padding:18px 18px 16px;
position:relative; overflow:hidden;
}
.stat::before {
content:''; position:absolute; left:0; top:0; bottom:0; width:2px;
background:var(--gold-500); opacity:0;
}
.stat.featured::before { opacity:1; }
.stat .label {
font-size:11px; font-weight:700; letter-spacing:0.14em;
text-transform:uppercase; color:var(--ink-500); margin-bottom:8px;
}
.stat .value {
font-family:var(--font-display); font-weight:500; font-size:30px;
color:var(--navy-950); letter-spacing:-0.022em; line-height:1;
}
.stat .value .unit {
font-family:var(--font-body); font-size:13px; font-weight:600;
color:var(--ink-500); margin-left:6px;
}
.stat .sub { font-size:12px; color:var(--ink-500); margin-top:8px; }
/* ---------- Table ---------- */
table.t {
width:100%; border-collapse:separate; border-spacing:0;
background:var(--cream-50); border:1px solid var(--border-1);
border-radius:10px; overflow:hidden;
}
.card > table.t { border:0; border-radius:0 0 10px 10px; }
table.t thead th {
text-align:left; font-size:11px; font-weight:700;
letter-spacing:0.12em; text-transform:uppercase; color:var(--ink-500);
padding:12px 16px; background:var(--cream-100);
border-bottom:1px solid var(--border-1);
}
table.t tbody td {
padding:14px 16px; border-bottom:1px solid var(--border-1);
font-size:13.5px; color:var(--ink-700); vertical-align:middle;
}
table.t tbody tr:last-child td { border-bottom:0; }
table.t .key, table.t code {
font-family:var(--font-mono); font-size:12.5px;
color:var(--navy-900); font-weight:500;
background:transparent; padding:0;
}
table.t td.muted { color:var(--ink-500); font-size:12.5px; }
/* ---------- Badges ---------- */
.badge {
display:inline-flex; align-items:center; gap:5px;
font-size:11.5px; font-weight:600;
padding:2px 9px; border-radius:999px; line-height:1.5;
border:1px solid transparent;
}
.b-success { background:var(--success-bg); color:#205c47; border-color:rgba(45,122,95,0.25); }
.b-warning { background:var(--warning-bg); color:#7a5814; border-color:rgba(184,134,31,0.3); }
.b-danger { background:var(--danger-bg); color:#8a2828; border-color:rgba(178,58,58,0.25); }
.b-info { background:var(--navy-100); color:var(--navy-800); border-color:rgba(30,58,95,0.20); }
.b-neutral { background:var(--cream-200); color:var(--ink-700); border-color:var(--border-1); }
.b-gold { background:transparent; color:var(--gold-700); border-color:var(--gold-500); }
.dot { width:6px; height:6px; border-radius:50%; display:inline-block; }
.dot.ok { background:var(--success); }
.dot.warn { background:var(--warning); }
.dot.err { background:var(--danger); }
.dot.muted { background:var(--ink-400); }
/* ---------- Forms ---------- */
.field { margin-bottom:14px; }
.field .lbl {
display:block; font-size:12.5px; font-weight:600;
color:var(--ink-700); margin-bottom:6px;
}
.field .lbl .req { color:var(--danger); margin-left:0.15rem; }
.field .hint { font-size:12px; color:var(--ink-500); margin-top:5px; line-height:1.4; }
.input, .select, textarea.input {
width:100%; padding:9px 12px;
font-family:var(--font-body); font-size:13.5px;
border:1px solid var(--border-2); border-radius:7px;
background:#FFFFFF; color:var(--ink-900); transition:all 120ms;
}
.input:focus, .select:focus, textarea.input:focus {
outline:none; border-color:var(--navy-700);
box-shadow:0 0 0 3px rgba(30,58,95,0.18);
}
.input.mono { font-family:var(--font-mono); font-size:13px; }
textarea.input { font-family:var(--font-body); min-height:5rem; resize:vertical; }
.row-2 { display:grid; grid-template-columns:1fr 1fr; gap:14px; }
.toolbar { display:flex; gap:8px; align-items:center; flex-wrap:wrap; margin-bottom:14px; }
.toolbar .input, .toolbar .select { width:auto; min-width:14rem; }
/* ---------- Eyebrow / details ---------- */
.eyebrow {
font-size:10.5px; font-weight:700; letter-spacing:0.18em;
text-transform:uppercase; color:var(--gold-700);
}
details.disclosure {
border:1px solid var(--border-1); border-radius:8px;
padding:0; background:var(--cream-50);
margin-bottom:14px;
}
details.disclosure summary {
cursor:pointer; padding:14px 18px;
font-family:var(--font-body); font-weight:600; font-size:13.5px;
color:var(--navy-900); list-style:none;
display:flex; align-items:center; gap:8px;
}
details.disclosure summary::-webkit-details-marker { display:none; }
details.disclosure summary::before {
content:'+'; color:var(--gold-700); font-family:var(--font-mono); font-weight:700;
width:14px; display:inline-block;
}
details.disclosure[open] summary::before { content:''; }
details.disclosure[open] summary { border-bottom:1px solid var(--border-1); }
details.disclosure .body { padding:18px; }
.empty { padding:32px; text-align:center; color:var(--ink-500); font-size:13px; }
.muted { color:var(--ink-500); }
.err { color:var(--danger); font-size:13px; padding:10px 14px; background:var(--danger-bg); border:1px solid rgba(178,58,58,0.25); border-radius:7px; margin-top:10px; }
.ok { color:var(--success); font-size:13px; padding:10px 14px; background:var(--success-bg); border:1px solid rgba(45,122,95,0.25); border-radius:7px; margin-top:10px; }
.hide { display:none !important; }
.actions-row { display:flex; gap:6px; align-items:center; }
hr.div { border:none; border-top:1px solid var(--border-1); margin:18px 0; }
/* ---------- Login ---------- */
.login-screen {
min-height:100vh; display:flex; align-items:center; justify-content:center;
padding:40px 20px;
}
.login-card {
width:420px; max-width:100%; background:var(--cream-50);
border:1px solid var(--border-1); border-radius:14px;
box-shadow:0 0 0 1px var(--gold-500) inset, 0 2px 4px rgba(14,31,51,0.06), 0 4px 12px rgba(14,31,51,0.06);
padding:36px; position:relative;
}
.login-card::before, .login-card::after {
content:''; position:absolute; left:14px; right:14px;
height:1px; background:var(--gold-500); opacity:0.4;
}
.login-card::before { top:14px; } .login-card::after { bottom:14px; }
.login-card .brand {
display:flex; justify-content:center; margin-bottom:6px;
}
.login-card .brand-mark {
width:56px; height:56px;
}
.login-card h1 {
font-family:var(--font-display); font-weight:500; font-size:26px;
letter-spacing:-0.02em; color:var(--navy-950);
margin:14px 0 4px; text-align:center;
}
.login-card .sub {
text-align:center; font-size:13.5px; color:var(--ink-500);
margin-bottom:24px;
}
.login-card .btn {
width:100%; justify-content:center; padding:12px;
margin-top:14px;
}
.login-card .footnote {
text-align:center; font-size:12px; color:var(--ink-500);
margin-top:22px;
}
@media (max-width: 980px) {
.app { grid-template-columns:1fr; }
.sidebar { position:static; max-height:none; height:auto; }
.stats { grid-template-columns:repeat(2, 1fr); }
.row-2 { grid-template-columns:1fr; }
.content { padding:20px; }
.topbar { padding:14px 20px; }
}
</style>
</head>
<body>
<!-- Login screen (shown until admin API key is validated) -->
<section id="login-view" class="hide login-screen">
<div class="login-card">
<div class="brand">
<!-- Inline keysat-mark, identical to design system asset -->
<svg class="brand-mark" viewBox="0 0 100 100" fill="none" aria-hidden="true">
<ellipse cx="50" cy="22" rx="28" ry="5" fill="#1E3A5F"></ellipse>
<rect x="22" y="22" width="56" height="56" fill="#FBF9F2" stroke="#1E3A5F" stroke-width="3"></rect>
<ellipse cx="50" cy="78" rx="28" ry="5" fill="#1E3A5F"></ellipse>
<line x1="32" y1="36" x2="68" y2="36" stroke="#1E3A5F" stroke-width="1.5" stroke-linecap="round"></line>
<line x1="32" y1="44" x2="62" y2="44" stroke="#1E3A5F" stroke-width="1.5" stroke-linecap="round"></line>
<circle cx="42" cy="60" r="6" fill="none" stroke="#BFA068" stroke-width="2.5"></circle>
<rect x="48" y="58.5" width="14" height="3" fill="#BFA068"></rect>
<rect x="58" y="61.5" width="2" height="4" fill="#BFA068"></rect>
<rect x="62" y="61.5" width="2" height="3" fill="#BFA068"></rect>
</svg>
</div>
<h1>Keysat admin</h1>
<div class="sub" id="login-sub">Sign in with your web UI password.</div>
<!-- Password login (default) -->
<div id="login-pw" class="hide">
<div class="field">
<label class="lbl" for="pw">Password</label>
<input class="input" type="password" id="pw" placeholder="Web UI password" autocomplete="current-password">
<div class="hint">Set or rotate your password from StartOS &rarr; Keysat &rarr; Actions &rarr; <em>Set web UI password</em>.</div>
</div>
<button id="login-pw-btn" class="btn primary">Sign in</button>
</div>
<!-- API-key fallback (shown when no password is configured yet) -->
<div id="login-key" class="hide">
<div class="field">
<label class="lbl" for="api-key">Admin API key</label>
<input class="input mono" type="password" id="api-key" placeholder="64 hex chars" autocomplete="off">
<div class="hint">No web UI password configured yet. Sign in with the API key from StartOS &rarr; Keysat &rarr; Actions &rarr; <em>Show admin API key</em>. Then set a web UI password via the <em>Set web UI password</em> action so you don&rsquo;t need the API key here again.</div>
</div>
<button id="login-btn" class="btn primary">Sign in (with API key)</button>
</div>
<div id="login-err" class="err hide"></div>
</div>
</section>
<!-- Main app shell (shown after login) -->
<section id="app-view" class="hide">
<div class="app">
<aside class="sidebar">
<div class="brand">
<svg viewBox="0 0 100 100" fill="none" aria-hidden="true" style="width:26px; height:26px">
<ellipse cx="50" cy="22" rx="28" ry="5" fill="#1E3A5F"></ellipse>
<rect x="22" y="22" width="56" height="56" fill="#FBF9F2" stroke="#1E3A5F" stroke-width="3"></rect>
<ellipse cx="50" cy="78" rx="28" ry="5" fill="#1E3A5F"></ellipse>
<line x1="32" y1="36" x2="68" y2="36" stroke="#1E3A5F" stroke-width="1.5" stroke-linecap="round"></line>
<line x1="32" y1="44" x2="62" y2="44" stroke="#1E3A5F" stroke-width="1.5" stroke-linecap="round"></line>
<circle cx="42" cy="60" r="6" fill="none" stroke="#BFA068" stroke-width="2.5"></circle>
<rect x="48" y="58.5" width="14" height="3" fill="#BFA068"></rect>
<rect x="58" y="61.5" width="2" height="4" fill="#BFA068"></rect>
<rect x="62" y="61.5" width="2" height="3" fill="#BFA068"></rect>
</svg>
<span>Keysat</span>
</div>
<a class="nav active" data-route="overview"><i data-lucide="layout-dashboard"></i>Overview</a>
<a class="nav" data-route="products"><i data-lucide="package"></i>Products</a>
<a class="nav" data-route="policies"><i data-lucide="file-badge"></i>Policies</a>
<a class="nav" data-route="codes"><i data-lucide="tag"></i>Discount codes</a>
<a class="nav" data-route="licenses"><i data-lucide="key-round"></i>Licenses</a>
<a class="nav" data-route="machines"><i data-lucide="cpu"></i>Machines</a>
<div class="group-label">System</div>
<a class="nav" data-route="webhooks"><i data-lucide="webhook"></i>Webhooks</a>
<a class="nav" data-route="audit"><i data-lucide="scroll-text"></i>Audit log</a>
<!-- Tier banner: persistent. Shows current tier + next-tier CTA. -->
<div id="tier-banner" style="
margin-top:auto; margin-bottom:8px;
padding:12px 12px;
background:rgba(191,160,104,0.10);
border:1px solid rgba(191,160,104,0.30);
border-radius:8px;
font-size:11.5px; line-height:1.45;
color:var(--cream-50);
display:none;
">
<div id="tier-banner-current" style="
font-size:10px; font-weight:700; letter-spacing:0.12em;
text-transform:uppercase; color:var(--gold-400);
margin-bottom:4px;
"></div>
<div id="tier-banner-msg" style="margin-bottom:8px;"></div>
<a id="tier-banner-cta" target="_blank" rel="noopener" style="
display:inline-block; padding:5px 10px;
background:var(--gold-500); color:var(--navy-950);
font-weight:700; font-size:11px;
border-radius:5px; text-decoration:none;
transition:background 120ms;
" onmouseover="this.style.background='var(--gold-400)'"
onmouseout="this.style.background='var(--gold-500)'"></a>
</div>
<div class="footer" id="sidebar-footer">
<span class="dot warn"></span>
<div>
<div style="color:var(--cream-50); font-weight:600">Loading&hellip;</div>
<div>checking BTCPay</div>
</div>
</div>
<a href="https://keysat.xyz/support" target="_blank" rel="noopener" style="
display:flex; align-items:center; gap:8px;
padding:10px 12px; margin-top:6px;
font-size:11.5px; color:rgba(245,241,232,0.55);
border:1px dashed rgba(245,241,232,0.15); border-radius:6px;
text-decoration:none; transition:all 120ms;
" onmouseover="this.style.color='var(--cream-50)'; this.style.borderColor='var(--gold-500)';"
onmouseout="this.style.color='rgba(245,241,232,0.55)'; this.style.borderColor='rgba(245,241,232,0.15)';">
<i data-lucide="heart" style="width:14px; height:14px; color:var(--gold-400)"></i>
<span>Support development</span>
</a>
</aside>
<main class="main">
<header class="topbar">
<div>
<div class="crumb" id="crumb">Workspace</div>
<h1 id="page-title">Overview</h1>
</div>
<div class="topbar-actions">
<span class="who" id="who">&middot;&middot;&middot;</span>
<button class="btn secondary sm" id="logout"><i data-lucide="log-out"></i>Sign out</button>
</div>
</header>
<div class="content" id="route-target"></div>
</main>
</div>
</section>
<script src="https://unpkg.com/lucide@latest"></script>
<script>
(function () {
'use strict'
const LS_KEY = 'keysat-admin-api-key'
// ---------- network helpers ----------
let apiKey = ''
let serviceInfo = null
async function api(path, opts) {
opts = opts || {}
const headers = {}
// Session-cookie path: don't send Authorization; the server-side
// middleware bridges the cookie to the API-key bearer for require_admin.
// API-key fallback path (first-run, before a password is set): send the
// bearer header explicitly.
if (apiKey) headers['Authorization'] = 'Bearer ' + apiKey
if (opts.body) headers['Content-Type'] = 'application/json'
const init = {
method: opts.method || 'GET',
headers,
credentials: 'same-origin', // include keysat_session cookie when set
}
if (opts.body) init.body = JSON.stringify(opts.body)
const resp = await fetch(path, init)
if (!resp.ok) {
let msg = resp.statusText
let body = {}
try { body = await resp.json(); msg = body.message || body.error || msg } catch (_) {}
const err = new Error('HTTP ' + resp.status + ': ' + msg)
err.status = resp.status
err.body = body
throw err
}
if (resp.status === 204) return null
return resp.json()
}
/// Tier-cap-aware error handler. If the error is a 402 from the
/// tier-cap gate, render an actionable modal with a clickable upgrade
/// button instead of a flat alert. Returns true if it handled the
/// error (so callers know whether to fall back to their default).
function handleTierCap(err) {
if (!err || err.status !== 402 || !err.body || !err.body.upgrade_url) return false
const overlay = el('div', {
style: 'position:fixed; inset:0; background:rgba(14,31,51,0.55); z-index:9999; ' +
'display:flex; align-items:center; justify-content:center; padding:20px;',
})
const card = el('div', {
style: 'background:var(--cream-50); border:1px solid var(--border-1); ' +
'border-radius:12px; max-width:440px; width:100%; padding:28px 26px; ' +
'box-shadow:0 0 0 1px var(--gold-500) inset, 0 16px 32px rgba(14,31,51,0.20);',
}, [
el('div', { class: 'eyebrow', style: 'color:var(--gold-700); margin-bottom:8px' }, 'Upgrade required'),
el('h3', { style: 'font-family:var(--font-display); font-weight:600; font-size:22px; margin:0 0 12px; color:var(--navy-950); letter-spacing:-0.01em;' }, 'You\'ve hit a Creator-tier cap'),
el('p', { style: 'font-size:14.5px; color:var(--ink-700); line-height:1.55; margin:0 0 20px;' }, err.body.message || ''),
el('div', { style: 'display:flex; gap:10px;' }, [
el('a', {
href: err.body.upgrade_url,
target: '_blank',
rel: 'noopener',
class: 'btn primary',
style: 'flex:1; text-align:center; text-decoration:none;',
}, [
el('span', null, 'Get Pro license '),
el('span', { style: 'opacity:0.7' }, '→'),
]),
el('button', {
class: 'btn secondary',
onclick: () => overlay.remove(),
}, 'Close'),
]),
])
overlay.appendChild(card)
overlay.addEventListener('click', (e) => { if (e.target === overlay) overlay.remove() })
document.body.appendChild(overlay)
return true
}
/// Convenience wrapper: route 402 tier-cap errors to the modal,
/// fall back to alert() (or a custom fallback) for everything else.
function showApiErr(err, fallback) {
if (handleTierCap(err)) return
if (typeof fallback === 'function') fallback(err)
else alert(err && err.message ? err.message : String(err))
}
/// Generic safe-then-force delete flow.
/// Tries the regular DELETE first; if the server returns 409 (refers to
/// references), shows a modal that lets the operator either cancel or
/// type the slug to confirm a force-delete with cascade.
///
/// `opts`:
/// kind — 'product' | 'policy' (used in the modal copy)
/// slug — what the operator must type to confirm
/// pathBase — '/v1/admin/products/<id>' or '/v1/admin/policies/<id>'
/// onSuccess — called after a successful delete (typically a route reload)
async function safeOrForceDelete(opts) {
const { kind, slug, pathBase, onSuccess } = opts
// Try safe path first.
try {
if (!confirm(`Permanently delete ${kind} "${slug}"? This cannot be undone. \
The request will be refused if there are licenses or invoices tied to it — use force-delete in that case.`)) return
await api(pathBase, { method: 'DELETE' })
onSuccess()
return
} catch (e) {
if (handleTierCap(e)) return
if (e.status !== 409) {
alert(e.message)
return
}
// Fall through to the force-delete modal — server says references exist.
showForceDeleteModal({ kind, slug, message: e.body && e.body.message || e.message, pathBase, onSuccess })
}
}
function showForceDeleteModal({ kind, slug, message, pathBase, onSuccess }) {
const overlay = el('div', {
style: 'position:fixed; inset:0; background:rgba(14,31,51,0.55); z-index:9999; ' +
'display:flex; align-items:center; justify-content:center; padding:20px;',
})
const slugInput = el('input', {
class: 'input mono',
placeholder: slug,
autocomplete: 'off',
style: 'width:100%; margin-top:6px;',
})
const forceBtn = el('button', {
class: 'btn danger',
disabled: true,
style: 'flex:1; opacity:0.5;',
onclick: async function () {
if (slugInput.value.trim() !== slug) return
forceBtn.disabled = true
forceBtn.textContent = 'Deleting…'
try {
const res = await api(pathBase + '?force=true', { method: 'DELETE' })
overlay.remove()
// Surface what got cascaded so the operator sees the blast radius.
const parts = []
if (res.cascaded_licenses) parts.push(res.cascaded_licenses + ' license(s)')
if (res.cascaded_invoices) parts.push(res.cascaded_invoices + ' invoice(s)')
if (res.cascaded_machines) parts.push(res.cascaded_machines + ' machine row(s)')
if (res.cascaded_redemptions) parts.push(res.cascaded_redemptions + ' redemption(s)')
if (res.cascaded_policies) parts.push(res.cascaded_policies + ' polic(y/ies)')
if (res.cascaded_codes) parts.push(res.cascaded_codes + ' code(s)')
const summary = parts.length ? ' — also wiped: ' + parts.join(', ') : ''
// Show a brief toast-style banner instead of alert().
const toast = el('div', {
style: 'position:fixed; top:18px; left:50%; transform:translateX(-50%); ' +
'background:var(--navy-950); color:var(--cream-50); padding:10px 18px; ' +
'border-radius:8px; font-size:13.5px; z-index:10000; ' +
'box-shadow:0 4px 12px rgba(14,31,51,0.30);',
}, `${kind} "${slug}" force-deleted${summary}`)
document.body.appendChild(toast)
setTimeout(() => toast.remove(), 4500)
onSuccess()
} catch (e) {
forceBtn.disabled = false
forceBtn.textContent = 'Force delete (irreversible)'
alert(e.message)
}
},
}, 'Force delete (irreversible)')
slugInput.addEventListener('input', () => {
const ok = slugInput.value.trim() === slug
forceBtn.disabled = !ok
forceBtn.style.opacity = ok ? '1' : '0.5'
})
const card = el('div', {
style: 'background:var(--cream-50); border:2px solid var(--danger); ' +
'border-radius:12px; max-width:480px; width:100%; padding:28px 26px; ' +
'box-shadow:0 16px 32px rgba(178,58,58,0.20);',
}, [
el('div', { class: 'eyebrow', style: 'color:var(--danger); margin-bottom:8px' }, 'Force delete — destructive'),
el('h3', { style: 'font-family:var(--font-display); font-weight:600; font-size:22px; margin:0 0 12px; color:var(--navy-950); letter-spacing:-0.01em;' }, `Wipe ${kind} "${slug}" and everything tied to it?`),
el('p', { style: 'font-size:14px; color:var(--ink-700); line-height:1.55; margin:0 0 12px;' }, message),
el('p', { style: 'font-size:14px; color:var(--ink-700); line-height:1.55; margin:0 0 18px;' },
`Force-delete will permanently remove every license, invoice, redemption, and machine row tied to this ${kind} — along with the ${kind} itself. There is no undo.`),
el('div', null, [
el('label', { style: 'font-size:12.5px; font-weight:600; color:var(--ink-700);' },
`Type the ${kind} slug "${slug}" to confirm:`),
slugInput,
]),
el('div', { style: 'display:flex; gap:10px; margin-top:22px;' }, [
forceBtn,
el('button', {
class: 'btn secondary',
onclick: () => overlay.remove(),
}, 'Cancel'),
]),
])
overlay.appendChild(card)
overlay.addEventListener('click', (e) => { if (e.target === overlay) overlay.remove() })
document.body.appendChild(overlay)
setTimeout(() => slugInput.focus(), 0)
}
function el(tag, attrs, children) {
const e = document.createElement(tag)
if (attrs) for (const k in attrs) {
if (k === 'class') e.className = attrs[k]
else if (k === 'html') e.innerHTML = attrs[k]
else if (k.startsWith('on')) e.addEventListener(k.slice(2).toLowerCase(), attrs[k])
else if (k === 'value') e.value = attrs[k]
else e.setAttribute(k, attrs[k])
}
if (children) for (const c of [].concat(children)) {
if (c == null) continue
e.appendChild(typeof c === 'string' ? document.createTextNode(c) : c)
}
return e
}
function err(msg) { return el('div', { class: 'err' }, msg) }
function ok(msg) { return el('div', { class: 'ok' }, msg) }
function fmtDate(s) {
if (!s) return ''
try { return new Date(s).toLocaleString() } catch (_) { return s }
}
function shortId(s) {
return s ? (s.length > 8 ? s.slice(0, 8) + '…' : s) : ''
}
// ---------- card helpers ----------
function card(title, sub, body) {
const head = el('div', { class: 'card-head' }, [
el('h3', null, title),
sub ? el('span', { class: 'sub' }, sub) : null,
])
const c = el('div', { class: 'card' }, [head])
if (body) c.appendChild(el('div', { class: 'card-body' }, body))
return c
}
function plainCard(body) {
return el('div', { class: 'card' }, el('div', { class: 'card-body' }, body))
}
function tableCard(title, sub, headers, rows, emptyMsg) {
const head = el('div', { class: 'card-head' }, [
el('h3', null, title),
sub ? el('span', { class: 'sub' }, sub) : null,
])
if (rows.length === 0) {
return el('div', { class: 'card' }, [head, el('div', { class: 'empty' }, emptyMsg || 'Nothing yet.')])
}
const t = el('table', { class: 't' })
t.appendChild(el('thead', null, el('tr', null, headers.map((h) => el('th', null, h)))))
const tb = el('tbody')
for (const r of rows) tb.appendChild(r)
t.appendChild(tb)
return el('div', { class: 'card' }, [head, t])
}
function statusBadge(status) {
const map = {
active: { cls: 'b-success', dot: 'ok' },
suspended: { cls: 'b-warning', dot: 'warn' },
revoked: { cls: 'b-danger', dot: 'err' },
expired: { cls: 'b-neutral', dot: 'muted' },
}
const m = map[status] || { cls: 'b-neutral', dot: 'muted' }
return el('span', { class: 'badge ' + m.cls }, [el('span', { class: 'dot ' + m.dot }), status])
}
function activePill(active) {
return active
? el('span', { class: 'badge b-success' }, [el('span', { class: 'dot ok' }), 'active'])
: el('span', { class: 'badge b-neutral' }, 'inactive')
}
// ---------- routes ----------
const routes = {}
const ROUTE_META = {
overview: { title: 'Overview', crumb: 'Workspace' },
products: { title: 'Products', crumb: 'Workspace · Products' },
policies: { title: 'Policies', crumb: 'Workspace · Policies' },
codes: { title: 'Discount codes', crumb: 'Workspace · Discount codes' },
licenses: { title: 'Licenses', crumb: 'Workspace · Licenses' },
machines: { title: 'Machines', crumb: 'Workspace · Machines' },
webhooks: { title: 'Webhooks', crumb: 'System · Webhooks' },
audit: { title: 'Audit log', crumb: 'System · Audit log' },
}
// -------- Overview --------
routes.overview = async function () {
const target = document.getElementById('route-target')
target.innerHTML = ''
// Stats grid (skeleton first; fill in as data arrives)
const stats = el('div', { class: 'stats' })
const sRevenue = stat('Revenue (lifetime)', '', null, true)
const sLicenses = stat('Active licenses', '', null, true)
const sCodes = stat('Discount codes', '')
const sBtc = stat('BTCPay', el('span', { style: 'font-size:18px; font-family:var(--font-body); font-weight:600' }, ''))
stats.appendChild(sRevenue)
stats.appendChild(sLicenses)
stats.appendChild(sCodes)
stats.appendChild(sBtc)
target.appendChild(stats)
// Revenue breakdown card — lifetime total + 30d/7d/24h.
function fmtSatsCard(n) {
const num = Number(n) || 0
return num.toLocaleString('en-US')
}
const revCard = el('div', { class: 'card' }, [
el('div', { class: 'card-body' }, [
el('div', { style: 'display:flex; align-items:baseline; justify-content:space-between; margin-bottom:14px' }, [
el('div', { class: 'eyebrow' }, 'Revenue'),
el('div', { class: 'muted', style: 'font-size:12px' }, 'Sum of settled BTCPay invoices stored locally. Free-license redemptions excluded.'),
]),
el('div', {
id: 'revenue-grid',
style: 'display:grid; grid-template-columns:repeat(4, 1fr); gap:14px;',
}, [
el('div', { id: 'rev-total', style: 'padding:10px 12px; background:var(--cream-100); border:1px solid var(--border-1); border-radius:8px;' },
[el('div', { class: 'muted', style: 'font-size:11px; font-weight:600; letter-spacing:0.06em; text-transform:uppercase' }, 'Lifetime'),
el('div', { style: 'font-family:var(--font-display); font-weight:700; font-size:22px; color:var(--navy-950); margin-top:4px;', class: 'rev-value' }, '')]),
el('div', { id: 'rev-30d', style: 'padding:10px 12px; background:var(--cream-100); border:1px solid var(--border-1); border-radius:8px;' },
[el('div', { class: 'muted', style: 'font-size:11px; font-weight:600; letter-spacing:0.06em; text-transform:uppercase' }, '30d'),
el('div', { style: 'font-family:var(--font-display); font-weight:700; font-size:22px; color:var(--navy-950); margin-top:4px;', class: 'rev-value' }, '')]),
el('div', { id: 'rev-7d', style: 'padding:10px 12px; background:var(--cream-100); border:1px solid var(--border-1); border-radius:8px;' },
[el('div', { class: 'muted', style: 'font-size:11px; font-weight:600; letter-spacing:0.06em; text-transform:uppercase' }, '7d'),
el('div', { style: 'font-family:var(--font-display); font-weight:700; font-size:22px; color:var(--navy-950); margin-top:4px;', class: 'rev-value' }, '')]),
el('div', { id: 'rev-24h', style: 'padding:10px 12px; background:var(--cream-100); border:1px solid var(--border-1); border-radius:8px;' },
[el('div', { class: 'muted', style: 'font-size:11px; font-weight:600; letter-spacing:0.06em; text-transform:uppercase' }, '24h'),
el('div', { style: 'font-family:var(--font-display); font-weight:700; font-size:22px; color:var(--navy-950); margin-top:4px;', class: 'rev-value' }, '')]),
]),
el('div', { class: 'muted', style: 'margin-top:10px; font-size:12px;', id: 'rev-count' }, ''),
]),
])
target.appendChild(revCard)
// Welcome / instructions card
target.appendChild(card('Welcome', null, [
el('p', { class: 'muted' }, [
'This is your Keysat admin dashboard. Use the sidebar to manage products, policies, discount codes, and the licenses you have issued. ',
'Setup actions — setting your operator name, connecting BTCPay, and viewing your admin credentials — live in your StartOS service ',
el('strong', null, 'Actions'), ' tab.',
]),
el('p', { class: 'muted', style: 'margin-bottom:0' }, [
'Service: ',
el('code', { class: 'mono', style: 'font-family:var(--font-mono); font-size:12.5px; color:var(--navy-900)' }, [
serviceInfo ? (serviceInfo.service + ' v' + serviceInfo.version) : '',
]),
' · Operator: ',
el('code', { class: 'mono', style: 'font-family:var(--font-mono); font-size:12.5px; color:var(--navy-900)' },
(serviceInfo && serviceInfo.operator) || '(unset)'),
]),
]))
// Public key tip card (matches the design system 'Embed your public key' tip)
const pubkeyTip = el('div', {
class: 'card',
style: 'background:var(--cream-100); border-style:dashed;'
}, [
el('div', { class: 'card-body' }, [
el('div', { class: 'eyebrow', style: 'margin-bottom:6px' }, 'Tip'),
el('div', {
style: 'font-family:var(--font-display); font-weight:700; font-size:15px; color:var(--navy-950); margin-bottom:4px; letter-spacing:-0.01em;',
}, 'Embed your public key'),
el('p', { style: 'font-size:13px; color:var(--ink-700); margin:0 0 12px; line-height:1.5' },
'Paste this into your apps source so it verifies signatures offline. The key is also available at /v1/issuer/public-key.'),
el('div', {
style: 'background:var(--navy-950); color:var(--cream-50); padding:10px 12px; border-radius:7px; font-family:var(--font-mono); font-size:12px; display:flex; gap:10px; align-items:center; justify-content:space-between;',
}, [
el('span', { id: 'pubkey-preview' }, 'loading…'),
el('button', {
class: 'btn sm',
style: 'background:rgba(245,241,232,0.10); color:var(--cream-50); border:0;',
onclick: copyPubkey,
}, 'Copy'),
]),
]),
])
target.appendChild(pubkeyTip)
// Fill in stat values
try {
const j = await api('/v1/admin/revenue/summary').catch(() => null)
if (j) {
const fmt = (n) => fmtSatsCard(n) + ' sats'
sRevenue.querySelector('.value').textContent = fmt(j.total_sats || 0)
document.querySelector('#rev-total .rev-value').textContent = fmt(j.total_sats || 0)
document.querySelector('#rev-30d .rev-value').textContent = fmt(j.last_30d_sats || 0)
document.querySelector('#rev-7d .rev-value').textContent = fmt(j.last_7d_sats || 0)
document.querySelector('#rev-24h .rev-value').textContent = fmt(j.last_24h_sats || 0)
const c = j.settled_paid_invoice_count || 0
document.getElementById('rev-count').textContent =
c.toLocaleString() + ' settled paid invoice' + (c === 1 ? '' : 's')
} else {
sRevenue.querySelector('.value').textContent = ''
}
} catch {}
try {
const j = await api('/v1/admin/licenses/summary').catch(() => null)
if (j && typeof j.active === 'number') {
sLicenses.querySelector('.value').textContent = j.active.toString()
} else {
sLicenses.querySelector('.value').textContent = ''
}
} catch {}
try {
const j = await api('/v1/admin/discount-codes')
const codes = j.codes || []
sCodes.querySelector('.value').textContent = codes.length.toString()
} catch {}
try {
const j = await api('/v1/admin/webhook-endpoints')
const eps = j.endpoints || j.webhooks || []
sWebhooks.querySelector('.value').textContent = eps.length.toString()
} catch {}
try {
const s = await api('/v1/admin/btcpay/status')
const v = sBtc.querySelector('.value')
v.innerHTML = ''
if (s.connected) {
v.appendChild(el('span', { class: 'badge b-success', style: 'font-size:13px; padding:5px 12px' },
[el('span', { class: 'dot ok' }), 'Connected']))
v.appendChild(el('div', { class: 'sub', style: 'font-family:var(--font-mono); font-size:11px; margin-top:8px' },
'store ' + (s.store_id || '?').slice(0, 14) + '…'))
} else {
v.appendChild(el('span', { class: 'badge b-warning', style: 'font-size:13px; padding:5px 12px' },
[el('span', { class: 'dot warn' }), 'Not connected']))
v.appendChild(el('div', { class: 'sub', style: 'margin-top:8px' },
'Connect via StartOS Actions'))
}
} catch (e) {
sBtc.querySelector('.value').textContent = '?'
}
// Community analytics opt-in card. Off by default; spelled out
// exactly what gets sent so the operator's choice is informed.
const analyticsCard = el('div', { class: 'card' })
target.appendChild(analyticsCard)
renderAnalyticsCard(analyticsCard)
// Public key fetch — pulls PEM from /v1/issuer/public-key (no auth
// required) and displays a short preview. Copy button copies the full
// PEM, including BEGIN/END headers, ready to paste into source.
try {
const j = await fetch('/v1/issuer/public-key').then((r) => r.json()).catch(() => null)
const pem = j && (j.public_key_pem || j.public_key_b64) // accept either shape
if (pem && typeof pem === 'string') {
// Pull the base64 body out of the PEM for the in-card preview
// (BEGIN/END headers are noise on a single 12+12-char preview).
const body = pem
.replace(/-----BEGIN [^-]+-----/g, '')
.replace(/-----END [^-]+-----/g, '')
.replace(/\s+/g, '')
const preview = body.slice(0, 12) + '…' + body.slice(-12)
document.getElementById('pubkey-preview').textContent = preview
document.getElementById('pubkey-preview').dataset.full = pem
} else {
document.getElementById('pubkey-preview').textContent = 'unavailable'
}
} catch {
document.getElementById('pubkey-preview').textContent = 'unavailable'
}
}
function stat(label, value, sub, featured) {
return el('div', { class: 'stat' + (featured ? ' featured' : '') }, [
el('div', { class: 'label' }, label),
el('div', { class: 'value' }, value),
sub ? el('div', { class: 'sub' }, sub) : null,
])
}
// Renders the "Help improve Keysat" card on Overview. Off by default;
// operators see exactly what gets sent before opting in. Toggling on
// requires confirming a collector URL — without one, the daemon
// doesn't beacon even with the toggle on.
async function renderAnalyticsCard(card) {
card.innerHTML = ''
let s
try {
s = await api('/v1/admin/community-analytics')
} catch (e) {
card.appendChild(el('p', { class: 'muted' }, 'Could not load analytics state: ' + e.message))
return
}
const headerLeft = el('div', null, [
el('h3', { style: 'margin:0 0 4px' }, 'Help improve Keysat'),
el('p', { class: 'muted', style: 'margin:0; font-size:14px' },
'Send an anonymous daily heartbeat so we can show real adoption numbers on the public dashboard.'),
])
const toggle = el('label', {
style: 'display:inline-flex; align-items:center; gap:10px; font-weight:600; font-size:14px; cursor:pointer'
}, [
el('input', { type: 'checkbox', checked: s.enabled ? 'checked' : null }),
s.enabled ? 'Enabled' : 'Disabled',
])
const toggleInput = toggle.querySelector('input')
card.appendChild(el('div', {
style: 'display:flex; justify-content:space-between; align-items:flex-start; gap:24px; margin-bottom:16px'
}, [headerLeft, toggle]))
// Collector URL field. Required to actually send anything; the
// toggle being on without a URL is "armed but silent".
const urlInput = el('input', {
class: 'input',
type: 'url',
placeholder: 'https://keysat.xyz/community/v1/heartbeat (or your own collector)',
value: s.collector_url || '',
style: 'width:100%; box-sizing:border-box; margin-bottom:8px',
})
card.appendChild(el('label', { style: 'display:block; font-weight:600; font-size:13px; margin-bottom:4px' }, 'Collector URL'))
card.appendChild(urlInput)
card.appendChild(el('p', { class: 'muted', style: 'margin:4px 0 16px; font-size:12px' },
'Leave blank to opt in but not send (useful while a public collector is being stood up). Once keysat.xyz/community is live, the default URL will populate here on upgrade.'))
// Privacy disclosure — show the exact JSON shape that would be sent.
const disclosure = el('details', { class: 'disclosure' }, [
el('summary', null, 'Show me exactly what gets sent'),
el('div', { class: 'body' }, [
el('p', { class: 'muted', style: 'margin:0 0 12px' },
'Counts are floored to the nearest 5 to prevent fingerprinting an operator by exact license count. ' +
'Uptime is bucketed (<1d / 1-7d / 1-4w / >4w). The install_uuid is a random UUIDv4 generated on first opt-in — ' +
'NOT derived from your operator name, store id, or public URL. You can wipe it any time below.'),
el('pre', { style: 'background:#0e1f33; color:#f6f1e7; padding:12px; border-radius:6px; font-size:12px; overflow-x:auto' },
JSON.stringify(s.preview_heartbeat, null, 2)),
s.install_uuid
? el('p', { class: 'muted', style: 'margin:12px 0 8px; font-size:12px' },
'Your install_uuid: ' + s.install_uuid)
: null,
].filter(Boolean)),
])
card.appendChild(disclosure)
// Save button.
const saveBtn = el('button', { class: 'btn primary', style: 'margin-top:16px; margin-right:8px' }, 'Save')
saveBtn.addEventListener('click', async () => {
saveBtn.disabled = true
try {
await api('/v1/admin/community-analytics', { method: 'POST', body: {
enabled: toggleInput.checked,
collector_url: urlInput.value.trim() || null,
}})
renderAnalyticsCard(card)
} catch (e) {
alert(e.message)
} finally {
saveBtn.disabled = false
}
})
const resetBtn = el('button', { class: 'btn sm secondary', style: 'margin-top:16px' }, 'Reset install_uuid')
resetBtn.addEventListener('click', async () => {
if (!confirm('This wipes your anonymous install_uuid. Future heartbeats (if you re-enable) will use a fresh UUID. Continue?')) return
try {
await api('/v1/admin/community-analytics/reset', { method: 'POST' })
renderAnalyticsCard(card)
} catch (e) { alert(e.message) }
})
card.appendChild(saveBtn)
if (s.install_uuid) card.appendChild(resetBtn)
}
// Render a product's price for table cells. Picks the right
// unit + format based on price_currency. SAT-priced shows
// "50,000 sats"; USD-priced shows "$49.00 ≈ 75k sats" if the
// sat amount has been pinned (after first invoice), or just
// "$49.00" if not yet quoted.
function formatProductPrice(p) {
const currency = (p.price_currency || 'SAT').toUpperCase()
if (currency === 'SAT') {
return (p.price_sats || p.price_value || 0).toLocaleString() + ' sats'
}
const symbol = currency === 'USD' ? '$' : currency === 'EUR' ? '€' : ''
const amount = (p.price_value || 0) / 100 // cents → main unit
const main = symbol + amount.toFixed(2) + (symbol ? '' : ' ' + currency)
if (p.price_sats && p.price_sats > 0) {
// Sat amount has been pinned by a prior invoice; show as a hint.
const sats = p.price_sats >= 1000
? Math.round(p.price_sats / 1000) + 'k'
: String(p.price_sats)
return el('span', null, [main, el('span', { class: 'muted', style: 'font-size:11px; margin-left:6px' }, '≈ ' + sats + ' sats')])
}
return main
}
async function copyPubkey() {
const span = document.getElementById('pubkey-preview')
const k = span.dataset.full
if (!k) return
try {
await navigator.clipboard.writeText(k)
const orig = span.textContent
span.textContent = 'Copied'
setTimeout(() => { span.textContent = orig }, 1200)
} catch {}
}
// -------- Products --------
// Edit-product modal. Opens when the operator clicks Edit on a product
// row. Mutable: name, description, price_sats. Slug is intentionally not
// editable (it's part of the public buy URL — changing breaks bookmarks).
function openEditProduct(p) {
const overlay = el('div', {
style: 'position:fixed; inset:0; background:rgba(14,31,51,0.55); z-index:9999; ' +
'display:flex; align-items:center; justify-content:center; padding:20px;',
})
const nameField = formInput('e_p_name', 'Display name', { value: p.name || '', required: true })
const descField = formInput('e_p_description', 'Description', { textarea: true, value: p.description || '' })
const priceField = formInput('e_p_price', 'Price (sats)', { type: 'number', value: String(p.price_sats || 0), required: true })
const status = el('div', { class: 'muted', style: 'margin-top:6px; font-size:12.5px;' }, '')
const card = el('div', {
style: 'background:var(--cream-50); border:1px solid var(--border-1); ' +
'border-radius:12px; max-width:480px; width:100%; padding:24px; ' +
'box-shadow:0 0 0 1px var(--gold-500) inset, 0 16px 32px rgba(14,31,51,0.20);',
}, [
el('div', { class: 'eyebrow', style: 'margin-bottom:8px' }, 'Edit product'),
el('h3', { style: 'font-family:var(--font-display); font-weight:600; font-size:20px; margin:0 0 6px; color:var(--navy-950);' }, p.slug),
el('p', { class: 'muted', style: 'margin:0 0 16px; font-size:13px' },
'Slug is not editable — it is part of your public /buy/' + p.slug + ' URL. Disable + create a new product if you need to rename.'),
nameField,
descField,
priceField,
status,
el('div', { style: 'display:flex; gap:10px; margin-top:14px;' }, [
el('button', { class: 'btn primary', onclick: async function () {
status.textContent = 'Saving…'
try {
const body = {
name: card.querySelector('[name=e_p_name]').value.trim(),
description: card.querySelector('[name=e_p_description]').value || '',
price_sats: Math.max(0, parseInt(card.querySelector('[name=e_p_price]').value, 10) || 0),
}
await api('/v1/admin/products/' + p.id, { method: 'PATCH', body })
overlay.remove()
routes.products()
} catch (e) {
status.textContent = e.message
status.style.color = 'var(--danger)'
}
} }, 'Save'),
el('button', { class: 'btn secondary', onclick: () => overlay.remove() }, 'Cancel'),
]),
])
overlay.appendChild(card)
overlay.addEventListener('click', (e) => { if (e.target === overlay) overlay.remove() })
document.body.appendChild(overlay)
}
routes.products = async function () {
const target = document.getElementById('route-target')
target.innerHTML = ''
// Create form. Currency picker swaps the price-input units in
// place: SAT → integer sats, USD/EUR → dollar/euro amount which
// we convert to cents on the way out (the backend stores
// smallest-unit-of-currency).
const currencyPicker = el('select', { class: 'input' }, [
el('option', { value: 'SAT' }, 'sats'),
el('option', { value: 'USD' }, 'USD ($)'),
el('option', { value: 'EUR' }, 'EUR (€)'),
])
const priceInput = el('input', {
class: 'input', name: 'price_input', type: 'number',
step: '1', min: '0', value: '50000', required: 'required',
})
const priceHint = el('div', { class: 'muted', style: 'font-size:12px; margin-top:4px' },
'sats — whole numbers only.')
currencyPicker.addEventListener('change', () => {
if (currencyPicker.value === 'SAT') {
priceInput.step = '1'
priceInput.value = '50000'
priceHint.textContent = 'sats — whole numbers only.'
} else {
priceInput.step = '0.01'
priceInput.value = '49.00'
priceHint.textContent =
currencyPicker.value === 'USD'
? 'Dollars — converted to BTC at invoice creation. Buyer pays the locked-in BTC amount.'
: 'Euros — converted to BTC at invoice creation. Buyer pays the locked-in BTC amount.'
}
})
const create = el('details', { class: 'disclosure' }, [
el('summary', null, 'Create a new product'),
el('div', { class: 'body' }, [
formInput('slug', 'Slug', { required: true, hint: 'lowercase, hyphens, e.g. "bitcoin-ticker-pro"' }),
formInput('name', 'Display name', { required: true }),
formInput('description', 'Description', { textarea: true }),
el('label', { style: 'display:block; font-weight:600; font-size:13px; margin:12px 0 4px' }, 'Price'),
el('div', { style: 'display:flex; gap:8px; align-items:flex-start' }, [
priceInput,
currencyPicker,
]),
priceHint,
el('button', { class: 'btn primary', style: 'margin-top:16px' }, 'Create product').addEventListener
? null : null, // dummy; the real button is below for clarity
(() => {
const btn = el('button', { class: 'btn primary', style: 'margin-top:16px' }, 'Create product')
btn.addEventListener('click', async () => {
const status = el('div', { class: 'muted', style: 'margin-top:8px' }, 'Creating…')
create.querySelector('.body').appendChild(status)
try {
const currency = currencyPicker.value
const rawValue = parseFloat(priceInput.value)
if (!Number.isFinite(rawValue) || rawValue <= 0) {
throw new Error('Price must be a positive number.')
}
// SAT/BTC are sat-denominated already; USD/EUR are
// entered as decimal amounts and converted to cents.
const priceValue = currency === 'SAT' ? Math.round(rawValue) : Math.round(rawValue * 100)
await api('/v1/admin/products', { method: 'POST', body: {
slug: create.querySelector('[name=slug]').value.trim(),
name: create.querySelector('[name=name]').value.trim(),
description: create.querySelector('[name=description]').value || '',
price_currency: currency,
price_value: priceValue,
metadata: {},
}})
status.replaceWith(ok('Created. Reloading…'))
setTimeout(routes.products, 600)
} catch (e) {
if (handleTierCap(e)) status.remove()
else status.replaceWith(err(e.message))
}
})
return btn
})(),
].filter(Boolean)),
])
target.appendChild(plainCard([
el('div', { class: 'eyebrow', style: 'margin-bottom:8px' }, 'About'),
el('p', { class: 'muted', style: 'margin:0 0 16px' },
'A product is anything you sell. Each product has a public purchase URL at /buy/<slug> and zero or more policies that determine what kind of license is issued.'),
create,
]))
try {
const [j, counts] = await Promise.all([
api('/v1/products'),
api('/v1/admin/licenses/counts').catch(() => ({ by_product: {}, by_policy: {} })),
])
const products = j.products || j || []
const byProduct = (counts && counts.by_product) || {}
const rows = products.map((p) => el('tr', null, [
el('td', null, el('code', null, p.slug)),
el('td', { style: 'font-weight:600; color:var(--navy-950)' }, p.name),
el('td', null, formatProductPrice(p)),
el('td', null, el('span', { class: 'muted' }, String(byProduct[p.id] || 0))),
el('td', null, activePill(p.active)),
el('td', { class: 'muted' }, fmtDate(p.created_at)),
el('td', null, el('div', { class: 'actions-row' }, [
el('button', {
class: 'btn sm secondary',
onclick: function () { openEditProduct(p) },
}, 'Edit'),
el('button', {
class: 'btn sm danger',
title: 'Delete this product. Safe by default; offers a force-delete with cascade if the product has licenses or invoices.',
onclick: function () {
safeOrForceDelete({
kind: 'product',
slug: p.slug,
pathBase: '/v1/admin/products/' + p.id,
onSuccess: () => routes.products(),
})
},
}, 'Delete'),
])),
]))
target.appendChild(tableCard(
'All products',
products.length + ' total',
['Slug', 'Name', 'Price', 'Licenses', 'Status', 'Created', ''],
rows,
'No products yet. Create one above to start selling.'
))
} catch (e) {
target.appendChild(plainCard([err(e.message)]))
}
}
// Edit-policy modal. Mutable: name, description, price_sats_override,
// duration, grace, max_machines, is_trial, entitlements, highlight.
// Slug + product + tip config are NOT editable here (tip has its own
// dedicated PATCH endpoint with its own validation rules).
function openEditPolicy(pol, prod) {
const overlay = el('div', {
style: 'position:fixed; inset:0; background:rgba(14,31,51,0.55); z-index:9999; ' +
'display:flex; align-items:center; justify-content:center; padding:20px; ' +
'overflow-y:auto;',
})
const DURATION_PRESETS = [
{ value: '0', label: 'Perpetual (no expiry)' },
{ value: '604800', label: '7 days' },
{ value: '2592000', label: '30 days' },
{ value: '7776000', label: '90 days' },
{ value: '15552000', label: '6 months' },
{ value: '31536000', label: '1 year' },
{ value: '63072000', label: '2 years' },
{ value: 'custom', label: 'Custom (in seconds)' },
]
// Map current duration to a preset value if it matches; else 'custom'.
const dur = pol.duration_seconds || 0
const matched = DURATION_PRESETS.find((p) => p.value === String(dur) && p.value !== 'custom')
const initialPreset = matched ? matched.value : 'custom'
const meta = pol.metadata || {}
const description = (typeof meta.description === 'string') ? meta.description : ''
const highlight = !!meta.highlight
const nameField = formInput('e_pol_name', 'Display name', { value: pol.name || '', required: true })
const descField = formInput('e_pol_description', 'Tier description (optional)', {
value: description,
hint: 'Shown on the tier card on /buy/' + (prod.slug || '<product>') + '. One sentence.',
})
const priceField = formInput('e_pol_price', 'Price (sats)', {
type: 'number',
value: String(pol.price_sats_override == null ? prod.price_sats || 0 : pol.price_sats_override),
hint: 'Override for this tier. 0 = free.',
})
const presetSel = formSelect('e_pol_preset', 'Duration', DURATION_PRESETS, { required: true, value: initialPreset })
const customDur = formInput('e_pol_custom', 'Custom (seconds)', { type: 'number', value: String(dur) })
const graceField = formInput('e_pol_grace', 'Grace period (days)', {
type: 'number',
value: String(Math.floor((pol.grace_seconds || 0) / 86400)),
})
const machinesField = formInput('e_pol_machines', 'Max devices (0 = unlimited)', {
type: 'number', value: String(pol.max_machines == null ? 1 : pol.max_machines),
})
const entField = formInput('e_pol_entitlements', 'Entitlements (one per line, or comma-separated)', {
textarea: true,
value: (pol.entitlements || []).join('\n'),
hint: 'Examples: self_host, ai_summaries, export. No quotes or brackets.',
})
const highlightField = formCheckbox('e_pol_highlight', 'Mark as "Most popular" on the tier picker')
if (highlight) setTimeout(() => {
const cb = card.querySelector('[name=e_pol_highlight]')
if (cb) cb.checked = true
}, 0)
const trialField = formCheckbox('e_pol_trial', 'Trial flag')
if (pol.is_trial) setTimeout(() => {
const cb = card.querySelector('[name=e_pol_trial]')
if (cb) cb.checked = true
}, 0)
const status = el('div', { class: 'muted', style: 'margin-top:8px; font-size:12.5px;' }, '')
const card = el('div', {
style: 'background:var(--cream-50); border:1px solid var(--border-1); ' +
'border-radius:12px; max-width:560px; width:100%; padding:24px; ' +
'box-shadow:0 0 0 1px var(--gold-500) inset, 0 16px 32px rgba(14,31,51,0.20); ' +
'max-height:90vh; overflow-y:auto;',
}, [
el('div', { class: 'eyebrow', style: 'margin-bottom:8px' }, 'Edit policy'),
el('h3', { style: 'font-family:var(--font-display); font-weight:600; font-size:20px; margin:0 0 6px; color:var(--navy-950);' },
prod.name + ' — ' + pol.slug),
el('p', { class: 'muted', style: 'margin:0 0 16px; font-size:13px' },
'Slug not editable — disable + create a new policy if you need to rename. To change tip config, use the dedicated tip endpoint.'),
nameField,
descField,
priceField,
el('div', { class: 'row-2' }, [presetSel, customDur]),
el('div', { class: 'row-2' }, [graceField, machinesField]),
entField,
el('div', { class: 'row-2' }, [highlightField, trialField]),
status,
el('div', { style: 'display:flex; gap:10px; margin-top:14px;' }, [
el('button', { class: 'btn primary', onclick: async function () {
status.textContent = 'Saving…'
status.style.color = ''
try {
const presetV = card.querySelector('[name=e_pol_preset]').value
const customV = parseInt(card.querySelector('[name=e_pol_custom]').value, 10) || 0
const duration_seconds = presetV === 'custom' ? customV : parseInt(presetV, 10)
const grace_days = parseInt(card.querySelector('[name=e_pol_grace]').value, 10) || 0
const grace_seconds = grace_days * 86400
const rawEnts = card.querySelector('[name=e_pol_entitlements]').value || ''
const ents = Array.from(new Set(
rawEnts.replace(/[\[\]"'`]/g, '').split(/[\n,]/).map((s) => s.trim()).filter(Boolean)
))
const newDescription = (card.querySelector('[name=e_pol_description]').value || '').trim()
const newHighlight = card.querySelector('[name=e_pol_highlight]').checked
// Preserve any other metadata keys we don't manage in the form.
const newMetadata = Object.assign({}, meta)
if (newDescription) newMetadata.description = newDescription
else delete newMetadata.description
if (newHighlight) newMetadata.highlight = true
else delete newMetadata.highlight
const priceRaw = card.querySelector('[name=e_pol_price]').value.trim()
const price_sats_override = priceRaw === '' ? null : Math.max(0, parseInt(priceRaw, 10) || 0)
const body = {
name: card.querySelector('[name=e_pol_name]').value.trim(),
duration_seconds,
grace_seconds,
max_machines: parseInt(card.querySelector('[name=e_pol_machines]').value, 10) || 0,
is_trial: card.querySelector('[name=e_pol_trial]').checked,
entitlements: ents,
metadata: newMetadata,
price_sats_override,
}
await api('/v1/admin/policies/' + pol.id, { method: 'PATCH', body })
overlay.remove()
routes.policies()
} catch (e) {
status.textContent = e.message
status.style.color = 'var(--danger)'
}
} }, 'Save'),
el('button', { class: 'btn secondary', onclick: () => overlay.remove() }, 'Cancel'),
]),
])
overlay.appendChild(card)
overlay.addEventListener('click', (e) => { if (e.target === overlay) overlay.remove() })
document.body.appendChild(overlay)
}
// -------- Policies --------
routes.policies = async function () {
const target = document.getElementById('route-target')
target.innerHTML = ''
const products = (await api('/v1/products').catch(() => ({ products: [] }))).products || []
if (products.length === 0) {
target.appendChild(plainCard([
el('p', { class: 'muted', style: 'margin:0' },
'Create a product first — a policy is always attached to a product.'),
]))
return
}
// Duration presets — operators rarely think in seconds. Custom drops
// them back to a raw-seconds field so power users still have it.
const DURATION_PRESETS = [
{ value: '0', label: 'Perpetual (no expiry)' },
{ value: '604800', label: '7 days' },
{ value: '2592000', label: '30 days' },
{ value: '7776000', label: '90 days' },
{ value: '15552000', label: '6 months' },
{ value: '31536000', label: '1 year' },
{ value: '63072000', label: '2 years' },
{ value: 'custom', label: 'Custom (in seconds)' },
]
// Price for a given product slug. Used to prefill the override field
// when the operator picks a product from the dropdown.
const PRODUCT_PRICE_BY_SLUG = Object.fromEntries(products.map((p) => [p.slug, p.price_sats]))
const initialProductSlug = products[0] ? products[0].slug : ''
const initialProductPrice = PRODUCT_PRICE_BY_SLUG[initialProductSlug] || 0
const create = el('details', { class: 'disclosure' }, [
el('summary', null, 'Create a new policy'),
el('div', { class: 'body' }, [
formSelect('product_slug', 'Product', products.map((p) => ({ value: p.slug, label: p.name + ' (' + p.slug + ', ' + p.price_sats.toLocaleString() + ' sats)' })), { required: true }),
el('div', { class: 'row-2' }, [
formInput('slug', 'Policy slug', {
required: true,
value: 'default',
hint: 'machine-readable id, lower-case. Examples: default, free, pro, patron. Cannot be changed later.',
}),
formInput('name', 'Display name', {
required: true,
value: 'Standard',
hint: 'shown to buyers on the tier picker on /buy/<product>.',
}),
]),
// Description (maps to metadata.description) — shown on the tier card.
formInput('tier_description', 'Tier description (optional)', {
hint: 'One-sentence blurb shown on the tier card. e.g. "Run Keysat for your own software" or "Unlocks Zaprite + recurring billing".',
}),
// Price override — prefilled with the product's base price; operator
// edits it to set this tier's price. Setting it equal to the product
// price still locks in that price on the policy (predictable across
// future product-price changes).
formInput('price_sats_override', 'Price (sats)', {
type: 'number',
required: true,
value: String(initialProductPrice),
hint: 'Pre-filled with the product\'s base price. Edit to set a different price for this tier (e.g. Free = 0, Pro = 250000, Patron = 500000). Set 0 for free tiers.',
}),
// Duration: preset + optional custom seconds.
el('div', { class: 'row-2' }, [
formSelect('duration_preset', 'Duration', DURATION_PRESETS, { required: true, value: '0' }),
formInput('duration_custom', 'Custom (seconds)', {
type: 'number', value: '0',
hint: 'Used only when the dropdown is "Custom". 86400 = 1 day. 31536000 = 1 year.',
}),
]),
el('div', { class: 'row-2' }, [
formInput('grace_days', 'Grace period after expiry (days)', {
type: 'number', value: '0',
hint: 'Validate calls return ok-with-warning during the grace window. 0 = no grace.',
}),
formInput('max_machines', 'Max devices (0 = unlimited)', {
type: 'number', required: true, value: '1',
hint: '1 = single seat. Set higher for team licenses.',
}),
]),
// Entitlements — textarea, one-per-line OR comma-separated. No JSON brackets, no quotes.
formInput('entitlements', 'Entitlements (one per line, or comma-separated)', {
textarea: true,
hint: 'Plain words. Examples: core, ai_summaries, export, recurring_billing, card_payments. These get baked into the signed license key; your software checks for them with `entitlements.has("ai_summaries")` to decide what to unlock. Don\'t add quotes or brackets — the form does that for you.',
}),
el('div', { class: 'row-2' }, [
formCheckbox('mark_highlight', 'Mark as "Most popular" (gold pill on tier picker)'),
formCheckbox('is_trial', 'Trial flag (sets TRIAL bit on the signed license)'),
]),
// ---------- Tip recipient (optional) ----------
el('div', {
style: 'margin:18px 0 12px; padding-top:14px; border-top:1px dashed var(--border-1)',
}, [
el('div', { class: 'eyebrow', style: 'margin-bottom:6px' }, 'Tip recipient (optional)'),
el('p', { class: 'hint', style: 'margin:0 0 10px' },
'On every successful license issuance under this policy, send a Lightning tip to the recipient as a percentage of what the buyer paid. Operator-controlled — fully optional. Suggestions: keysat@primal.net to support Keysat, opensats@npub.cash for OpenSats (FOSS Bitcoin development), your co-founder, a charity, or any Lightning Address.'),
el('div', { class: 'row-2' }, [
formInput('tip_recipient', 'Lightning Address', {
hint: 'e.g. keysat@primal.net. Leave blank to disable.',
}),
formInput('tip_pct', 'Tip percentage', {
type: 'number', value: '0',
hint: '0 = disabled. Examples: 1 = 1%, 5 = 5%. Capped at 100.',
}),
]),
formInput('tip_label', 'Label (optional)', {
hint: 'Free-form note. Shown in the audit log next to each tip attempt.',
}),
]),
el('button', { class: 'btn primary', onclick: async function () {
const status = el('div', { class: 'muted', style: 'margin-top:8px' }, 'Creating…')
create.querySelector('.body').appendChild(status)
try {
// Entitlements: split on newlines OR commas, trim, dedupe, drop empties.
// Also strip any quotes/brackets a paranoid operator might have typed.
const rawEnts = create.querySelector('[name=entitlements]').value || ''
const ents = Array.from(new Set(
rawEnts
.replace(/[\[\]"'`]/g, '') // strip JSON noise if pasted
.split(/[\n,]/)
.map((s) => s.trim())
.filter(Boolean)
))
// Duration: preset wins unless "custom" selected.
const preset = create.querySelector('[name=duration_preset]').value
const customSecs = parseInt(create.querySelector('[name=duration_custom]').value, 10) || 0
const duration_seconds = preset === 'custom' ? customSecs : parseInt(preset, 10)
// Grace days → seconds.
const grace_days = parseInt(create.querySelector('[name=grace_days]').value, 10) || 0
const grace_seconds = grace_days * 86400
// Metadata: dedicated fields → JSON. Operator never sees the JSON.
const description = (create.querySelector('[name=tier_description]').value || '').trim()
const highlight = create.querySelector('[name=mark_highlight]').checked
const metadata = {}
if (description) metadata.description = description
if (highlight) metadata.highlight = true
const tipRecipient = (create.querySelector('[name=tip_recipient]').value || '').trim()
const tipPctRaw = parseFloat(create.querySelector('[name=tip_pct]').value) || 0
// UI percent → basis points. Cap at 10000 (= 100%).
const tipPctBps = Math.max(0, Math.min(10000, Math.round(tipPctRaw * 100)))
const tipLabel = (create.querySelector('[name=tip_label]').value || '').trim()
// Price override: always send what the operator typed. The form
// pre-filled it to the product price; the value is whatever they
// ended up with (edited or unedited).
const priceRaw = create.querySelector('[name=price_sats_override]').value
const price_sats_override = priceRaw === '' ? null : Math.max(0, parseInt(priceRaw, 10) || 0)
const body = {
product_slug: create.querySelector('[name=product_slug]').value,
slug: create.querySelector('[name=slug]').value,
name: create.querySelector('[name=name]').value,
duration_seconds,
grace_seconds,
max_machines: parseInt(create.querySelector('[name=max_machines]').value, 10),
is_trial: create.querySelector('[name=is_trial]').checked,
entitlements: ents,
metadata,
price_sats_override,
}
if (tipRecipient) {
body.tip_recipient = tipRecipient
body.tip_pct_bps = tipPctBps
if (tipLabel) body.tip_label = tipLabel
}
await api('/v1/admin/policies', { method: 'POST', body })
status.replaceWith(ok('Created. Reloading…'))
setTimeout(routes.policies, 600)
} catch (e) {
// Tier-cap 402 → upgrade modal; everything else → inline status pill.
if (handleTierCap(e)) status.remove()
else status.replaceWith(err(e.message))
}
}}, 'Create policy'),
]),
])
// Toggle the custom-seconds field disabled state based on the preset.
const presetEl = create.querySelector('[name=duration_preset]')
const customEl = create.querySelector('[name=duration_custom]')
function syncDurationCustom() {
if (presetEl.value === 'custom') {
customEl.disabled = false
customEl.style.opacity = '1'
} else {
customEl.disabled = true
customEl.style.opacity = '0.5'
}
}
presetEl.addEventListener('change', syncDurationCustom)
syncDurationCustom()
// When the product changes, prefill the price-override field with that
// product's base price. The operator can still edit afterward; this just
// saves them from looking up the price elsewhere.
const productSelEl = create.querySelector('[name=product_slug]')
const priceFieldEl = create.querySelector('[name=price_sats_override]')
let lastPrefilledPrice = String(initialProductPrice)
productSelEl.addEventListener('change', function () {
const newSlug = productSelEl.value
const newPrice = PRODUCT_PRICE_BY_SLUG[newSlug] || 0
// Only auto-update the price field if the operator hasn't edited it
// away from the previous prefill — so a manual edit isn't clobbered.
if (priceFieldEl.value === lastPrefilledPrice || priceFieldEl.value === '') {
priceFieldEl.value = String(newPrice)
}
lastPrefilledPrice = String(newPrice)
})
target.appendChild(plainCard([
el('p', { class: 'muted', style: 'margin:0 0 16px' },
'Reusable license templates per product. The policy slugged "default" is consumed by the public purchase flow; other slugs are used by manual license issuance.'),
create,
]))
// License-count map (one fetch covers all products / policies on the page).
const counts = await api('/v1/admin/licenses/counts').catch(() => ({ by_policy: {} }))
const byPolicy = (counts && counts.by_policy) || {}
for (const p of products) {
try {
const j = await api('/v1/admin/policies?product_slug=' + encodeURIComponent(p.slug))
const policies = j.policies || []
const rows = policies.map((pol) => el('tr', null, [
el('td', null, el('code', null, pol.slug)),
el('td', { style: 'font-weight:600; color:var(--navy-950)' }, pol.name),
el('td', null, pol.duration_seconds === 0 ? 'perpetual' : pol.duration_seconds + 's'),
el('td', null, pol.grace_seconds + 's'),
el('td', null, pol.max_machines === 0 ? '∞' : String(pol.max_machines)),
el('td', null, pol.is_trial
? el('span', { class: 'badge b-warning' }, 'trial')
: el('span', { class: 'muted' }, '')),
el('td', { class: 'muted' }, (pol.entitlements || []).join(', ') || ''),
el('td', null, el('span', { class: 'muted' }, String(byPolicy[pol.id] || 0))),
el('td', null, activePill(pol.active)),
el('td', null, pol.public
? el('span', { class: 'badge b-gold', title: 'Visible on /buy/' + p.slug + ' tier picker' }, 'public')
: el('span', { class: 'muted', title: 'Hidden from public buy page; admin issuance only' }, 'private')),
el('td', null, el('div', { class: 'actions-row' }, [
el('button', {
class: 'btn sm secondary',
onclick: function () { openEditPolicy(pol, p) },
}, 'Edit'),
el('button', {
class: 'btn sm secondary',
title: pol.public ? 'Hide from /buy/' + p.slug : 'Show on /buy/' + p.slug,
onclick: async function () {
try {
await api('/v1/admin/policies/' + pol.id + '/public', {
method: 'PATCH', body: { public: !pol.public },
})
routes.policies()
} catch (e) { alert(e.message) }
},
}, pol.public ? 'Hide' : 'Show'),
el('button', {
class: 'btn sm danger',
title: 'Delete this policy. Safe by default; offers a force-delete with cascade if the policy has licenses or invoices.',
onclick: function () {
safeOrForceDelete({
kind: 'policy',
slug: pol.slug,
pathBase: '/v1/admin/policies/' + pol.id,
onSuccess: () => routes.policies(),
})
},
}, 'Delete'),
])),
]))
target.appendChild(tableCard(
p.name + ' — ' + p.slug,
policies.length + ' polic' + (policies.length === 1 ? 'y' : 'ies'),
['Slug', 'Name', 'Duration', 'Grace', 'Seats', 'Trial', 'Entitlements', 'Licenses', 'Status', 'On buy page', ''],
rows,
'(no policies yet)'
))
} catch (e) {
target.appendChild(plainCard([err('Failed to load policies for ' + p.slug + ': ' + e.message)]))
}
}
}
// -------- Discount codes --------
routes.codes = async function () {
const target = document.getElementById('route-target')
target.innerHTML = ''
function amountHint(kind) {
if (kind === 'percent') return 'percent off, e.g. 50 = 50%. Range: 1100.'
if (kind === 'fixed_sats') return 'sats subtracted from the base price.'
if (kind === 'set_price') return 'flat price the buyer pays in sats (e.g. 5000 = "buy at 5000 sats regardless of base price"). If higher than base, the code provides no benefit.'
if (kind === 'free_license') return 'amount is ignored — buyer pays nothing.'
return ''
}
const create = el('details', { class: 'disclosure' }, [
el('summary', null, 'Create a new code'),
el('div', { class: 'body' }, [
el('div', { class: 'row-2' }, [
formInput('code', 'Code', { required: true, hint: 'will be uppercased, e.g. FOUNDERS50' }),
formSelect('kind', 'Kind', [
{ value: 'percent', label: 'Percent off' },
{ value: 'fixed_sats', label: 'Fixed sats off' },
{ value: 'set_price', label: 'Set flat price (in sats)' },
{ value: 'free_license', label: 'Free license (no payment)' },
], { required: true, value: 'percent' }),
]),
el('div', { class: 'row-2' }, [
formInput('amount', 'Amount', { type: 'number', value: '50', hint: amountHint('percent') }),
formInput('max_uses', 'Max uses (0 = unlimited)', { type: 'number', value: '0' }),
]),
el('div', { class: 'row-2' }, [
formInput('expires_at', 'Expires at (RFC3339, optional)', { hint: 'e.g. 2026-12-31T23:59:59Z' }),
formInput('product_slug', 'Restrict to product slug (optional)'),
]),
formInput('referrer_label', 'Referrer / campaign label (optional)'),
formInput('description', 'Description (internal note)', { textarea: true }),
el('button', { class: 'btn primary', onclick: async function () {
const status = el('div', { class: 'muted', style: 'margin-top:8px' }, 'Creating…')
create.querySelector('.body').appendChild(status)
try {
const kind = create.querySelector('[name=kind]').value
let amount = parseInt(create.querySelector('[name=amount]').value, 10) || 0
if (kind === 'percent') amount = amount * 100
const body = {
code: create.querySelector('[name=code]').value.trim(),
kind, amount,
description: create.querySelector('[name=description]').value || '',
}
const mu = parseInt(create.querySelector('[name=max_uses]').value, 10) || 0
if (mu > 0) body.max_uses = mu
const exp = create.querySelector('[name=expires_at]').value.trim()
if (exp) body.expires_at = exp
const ps = create.querySelector('[name=product_slug]').value.trim()
if (ps) body.product_slug = ps
const rl = create.querySelector('[name=referrer_label]').value.trim()
if (rl) body.referrer_label = rl
await api('/v1/admin/discount-codes', { method: 'POST', body })
status.replaceWith(ok('Created. Reloading…'))
setTimeout(routes.codes, 600)
} catch (e) {
// Tier-cap 402 → upgrade modal; everything else → inline status pill.
if (handleTierCap(e)) status.remove()
else status.replaceWith(err(e.message))
}
}}, 'Create code'),
]),
])
// Live-update the amount hint as the operator changes the Kind dropdown.
const kindSelEl = create.querySelector('[name=kind]')
if (kindSelEl) {
kindSelEl.addEventListener('change', function () {
const hintEl = create.querySelector('[name=amount]').parentElement.querySelector('.hint')
if (hintEl) hintEl.textContent = amountHint(kindSelEl.value)
})
}
target.appendChild(plainCard([
el('p', { class: 'muted', style: 'margin:0 0 16px' },
'Codes are entered by the buyer on /buy/<product-slug>. Four kinds: percent off, fixed sats off, set flat price (e.g. 5000 sats regardless of base), or free-license (no payment, instant redemption).'),
create,
]))
// Edit panel — hidden until Edit is clicked. Populated with the chosen
// code's current values; saving PATCHes /v1/admin/discount-codes/:id and
// reloads the route.
const editPanel = el('div', { id: 'edit-code-panel', style: 'display:none; margin:16px 0;' })
target.appendChild(editPanel)
function openEdit(c) {
editPanel.innerHTML = ''
editPanel.style.display = 'block'
const amtField = formInput('e_amount', 'Amount', {
type: 'number',
value: c.kind === 'percent' ? String(c.amount / 100) : String(c.amount),
hint: c.kind === 'free_license'
? 'free_license codes have no amount.'
: amountHint(c.kind) + ' (kind: ' + c.kind + ', not editable)',
})
const muField = formInput('e_max_uses', 'Max uses (0 = unlimited)', {
type: 'number',
value: c.max_uses == null ? '0' : String(c.max_uses),
hint: c.used_count > 0 ? 'cannot go below current used_count (' + c.used_count + ').' : null,
})
const expField = formInput('e_expires_at', 'Expires at (RFC3339, blank to clear)', {
value: c.expires_at || '',
})
const refField = formInput('e_referrer_label', 'Referrer / campaign label (blank to clear)', {
value: c.referrer_label || '',
})
const descField = formInput('e_description', 'Description (internal note)', {
textarea: true,
value: c.description || '',
})
const saveBtn = el('button', { class: 'btn primary', onclick: async function () {
const status = el('div', { class: 'muted', style: 'margin-top:8px' }, 'Saving…')
editPanel.appendChild(status)
try {
const body = {}
if (c.kind !== 'free_license') {
const rawAmt = parseInt(editPanel.querySelector('[name=e_amount]').value, 10)
if (Number.isFinite(rawAmt) && rawAmt >= 0) {
body.amount = c.kind === 'percent' ? rawAmt * 100 : rawAmt
}
}
const muRaw = parseInt(editPanel.querySelector('[name=e_max_uses]').value, 10) || 0
body.max_uses = muRaw > 0 ? muRaw : null
const expRaw = editPanel.querySelector('[name=e_expires_at]').value.trim()
body.expires_at = expRaw === '' ? null : expRaw
const refRaw = editPanel.querySelector('[name=e_referrer_label]').value.trim()
body.referrer_label = refRaw === '' ? null : refRaw
body.description = editPanel.querySelector('[name=e_description]').value || ''
await api('/v1/admin/discount-codes/' + c.id, { method: 'PATCH', body })
status.replaceWith(ok('Saved. Reloading…'))
setTimeout(routes.codes, 600)
} catch (e) {
status.replaceWith(err(e.message))
}
} }, 'Save changes')
const cancelBtn = el('button', {
class: 'btn secondary',
style: 'margin-left:8px',
onclick: function () { editPanel.style.display = 'none'; editPanel.innerHTML = '' },
}, 'Cancel')
editPanel.appendChild(plainCard([
el('div', { style: 'display:flex; align-items:baseline; justify-content:space-between; margin-bottom:12px' }, [
el('strong', null, 'Editing code '),
el('code', { style: 'font-size:14px' }, c.code),
]),
el('p', { class: 'muted', style: 'margin:0 0 16px; font-size:13px' },
'Editable: amount, max uses, expiry, referrer label, description. The code string, kind, and product/policy scope cannot be changed — disable + create a new code instead.'),
el('div', { class: 'row-2' }, [amtField, muField]),
el('div', { class: 'row-2' }, [expField, refField]),
descField,
el('div', { style: 'margin-top:8px' }, [saveBtn, cancelBtn]),
]))
editPanel.scrollIntoView({ behavior: 'smooth', block: 'start' })
}
try {
const j = await api('/v1/admin/discount-codes?include_inactive=true')
const codes = j.codes || []
const rows = codes.map((c) => {
let amountStr = ''
if (c.kind === 'percent') amountStr = (c.amount / 100) + '%'
else if (c.kind === 'fixed_sats') amountStr = c.amount.toLocaleString() + ' sats off'
else if (c.kind === 'set_price') amountStr = c.amount.toLocaleString() + ' sats flat'
else amountStr = el('span', { class: 'badge b-gold' }, 'free')
const usage = c.used_count + ' / ' + (c.max_uses == null ? '∞' : c.max_uses)
return el('tr', null, [
el('td', null, el('code', null, c.code)),
el('td', null, c.kind),
el('td', null, amountStr),
el('td', null, usage),
el('td', { class: 'muted' }, c.expires_at ? fmtDate(c.expires_at) : ''),
el('td', null, activePill(c.active)),
el('td', null, el('div', { class: 'actions-row' }, [
el('button', {
class: 'btn sm secondary',
onclick: function () { openEdit(c) },
}, 'Edit'),
el('button', {
class: 'btn sm ' + (c.active ? 'danger' : 'secondary'),
onclick: async function () {
try {
await api('/v1/admin/discount-codes/' + c.id + '/active', { method: 'PATCH', body: { active: !c.active } })
routes.codes()
} catch (e) { alert(e.message) }
},
}, c.active ? 'Disable' : 'Enable'),
el('button', {
class: 'btn sm danger',
onclick: async function () {
const usedNote = c.used_count > 0
? '\n\nThis code has been redeemed ' + c.used_count + ' time(s). Delete will be refused (audit trail). Use Disable instead.'
: ''
if (!confirm('Permanently delete code "' + c.code + '"? This cannot be undone.' + usedNote)) return
try {
await api('/v1/admin/discount-codes/' + c.id, { method: 'DELETE' })
routes.codes()
} catch (e) { alert(e.message) }
},
}, 'Delete'),
])),
])
})
target.appendChild(tableCard(
'All codes',
codes.length + ' total',
['Code', 'Kind', 'Amount', 'Used', 'Expires', 'Status', ''],
rows,
'No codes yet.'
))
} catch (e) {
target.appendChild(plainCard([err(e.message)]))
}
}
// -------- Licenses --------
routes.licenses = async function () {
const target = document.getElementById('route-target')
target.innerHTML = ''
const queryInput = el('input', { class: 'input', type: 'text', placeholder: 'email, npub, or invoice id (leave blank for recent)' })
const fieldSel = el('select', { class: 'select' }, [
el('option', { value: 'email' }, 'Email'),
el('option', { value: 'npub' }, 'Nostr npub'),
el('option', { value: 'invoice' }, 'BTCPay invoice id'),
])
const tableHolder = el('div')
async function loadLicenses() {
const q = queryInput.value.trim()
tableHolder.innerHTML = ''
tableHolder.appendChild(el('div', { class: 'card' }, el('div', { class: 'empty' }, q ? 'Searching…' : 'Loading recent licenses…')))
try {
let url = '/v1/admin/licenses/search'
if (q) {
const params = new URLSearchParams()
params.set(fieldSel.value, q)
url += '?' + params.toString()
}
const j = await api(url)
const lic = j.licenses || []
function entitlementsCell(ents) {
if (!ents || ents.length === 0) {
return el('span', { class: 'muted' }, '')
}
const wrap = el('div', { style: 'display:flex; flex-wrap:wrap; gap:4px;' })
ents.forEach((e) => {
wrap.appendChild(el('span', {
class: 'badge',
style: 'font-size:10.5px; padding:2px 7px; background:var(--cream-200); color:var(--ink-700); font-family:var(--font-mono); font-weight:500;',
title: e,
}, e))
})
return wrap
}
const rows = lic.map((l) => el('tr', null, [
el('td', null, el('code', null, shortId(l.id))),
el('td', null, l.product_slug
? el('code', { title: l.product_id }, l.product_slug)
: shortId(l.product_id)),
el('td', null, l.policy_slug
? el('span', { title: l.policy_name || l.policy_id || '' }, l.policy_slug)
: el('span', { class: 'muted' }, '')),
el('td', null, entitlementsCell(l.entitlements)),
el('td', null, statusBadge(l.status)),
el('td', { class: 'muted' }, fmtDate(l.issued_at)),
el('td', { class: 'muted' }, l.expires_at ? fmtDate(l.expires_at) : el('span', { class: 'badge b-gold' }, 'perpetual')),
el('td', { class: 'muted' }, l.buyer_email || ''),
el('td', null, el('div', { class: 'actions-row' }, [
l.status !== 'revoked' && l.status !== 'suspended'
? el('button', { class: 'btn sm secondary', onclick: () => actLicense(l, 'suspend') }, 'Suspend')
: null,
l.status === 'suspended'
? el('button', { class: 'btn sm secondary', onclick: () => actLicense(l, 'unsuspend') }, 'Unsuspend')
: null,
l.status !== 'revoked'
? el('button', { class: 'btn sm danger', onclick: () => actLicense(l, 'revoke') }, 'Revoke')
: null,
])),
]))
const title = q ? 'Search results' : 'Recent licenses'
const subtitle = lic.length + ' license' + (lic.length === 1 ? '' : 's') +
(q ? '' : (lic.length >= 100 ? ' (most recent 100)' : ''))
tableHolder.innerHTML = ''
tableHolder.appendChild(tableCard(
title,
subtitle,
['ID', 'Product', 'Policy', 'Entitlements', 'Status', 'Issued', 'Expires', 'Buyer', ''],
rows,
q ? 'No matches.' : 'No licenses issued yet — once a buyer purchases or redeems, they appear here.'
))
} catch (e) {
tableHolder.innerHTML = ''
tableHolder.appendChild(plainCard([err(e.message)]))
}
}
async function actLicense(l, op) {
if (op === 'revoke' && !confirm('Revoke this license? This is irreversible.')) return
const reason = prompt('Reason (optional):') || ''
try {
await api('/v1/admin/licenses/' + l.id + '/' + op, { method: 'POST', body: { reason } })
loadLicenses()
} catch (e) { alert(e.message) }
}
queryInput.addEventListener('keypress', (e) => { if (e.key === 'Enter') loadLicenses() })
// ---------- Manual issue (admin comp / promo / paper-licensed) ----------
const issueDisclosure = el('details', { class: 'disclosure' }, [
el('summary', null, 'Manually issue a license'),
el('div', { class: 'body', id: 'issue-body' },
el('div', { class: 'muted', style: 'margin:0' }, 'Loading products + policies…')
),
])
async function buildIssueForm() {
const body = issueDisclosure.querySelector('#issue-body')
body.innerHTML = ''
let products = []
try {
const j = await api('/v1/products')
products = j.products || []
} catch (e) {
body.appendChild(err('Could not load products: ' + e.message))
return
}
if (products.length === 0) {
body.appendChild(el('p', { class: 'muted', style: 'margin:0' },
'Create a product first (Products tab).'))
return
}
const productOptions = products.map((p) => ({ value: p.slug, label: p.name + ' (' + p.slug + ')' }))
const productSel = formSelect('issue_product', 'Product', productOptions, { required: true })
const policySel = formSelect('issue_policy', 'Policy', [{ value: '', label: '— loading —' }], { required: true })
const policyHint = el('div', { class: 'hint', style: 'margin-top:-6px; margin-bottom:12px;' }, 'Pick a tier; the license inherits its entitlements + duration + max_machines.')
const noteField = formInput('issue_note', 'Internal note (optional)', { hint: 'e.g. "comp", "press", "self-issue Pro for dogfood".' })
const emailField = formInput('issue_email', 'Buyer email (optional)', { type: 'email' })
// Populate policy dropdown when product changes.
async function refreshPolicies() {
const slug = productSel.querySelector('select').value
const sel = policySel.querySelector('select')
sel.innerHTML = ''
try {
const j = await api('/v1/admin/policies?product_slug=' + encodeURIComponent(slug))
const policies = (j.policies || []).filter((p) => p.active)
if (policies.length === 0) {
const opt = document.createElement('option')
opt.value = ''; opt.textContent = '(no active policies on this product)'
sel.appendChild(opt)
return
}
policies.forEach((p) => {
const opt = document.createElement('option')
opt.value = p.slug
opt.textContent = p.name + ' (' + p.slug + ')'
sel.appendChild(opt)
})
} catch (e) {
const opt = document.createElement('option')
opt.value = ''; opt.textContent = 'error: ' + e.message
sel.appendChild(opt)
}
}
productSel.querySelector('select').addEventListener('change', refreshPolicies)
const submitBtn = el('button', { class: 'btn primary', onclick: async function () {
const status = el('div', { class: 'muted', style: 'margin-top:8px' }, 'Issuing…')
body.appendChild(status)
try {
const product_slug = productSel.querySelector('select').value
const policy_slug = policySel.querySelector('select').value
if (!policy_slug) throw new Error('Pick a policy.')
const note = (body.querySelector('[name=issue_note]').value || '').trim()
const email = (body.querySelector('[name=issue_email]').value || '').trim()
const reqBody = { product_slug, policy_slug }
if (note) reqBody.note = note
if (email) reqBody.buyer_email = email
const j = await api('/v1/admin/licenses', { method: 'POST', body: reqBody })
status.remove()
showLicenseIssued(j.license_key, j.license_id)
loadLicenses()
} catch (e) {
if (handleTierCap(e)) status.remove()
else status.replaceWith(err(e.message))
}
} }, 'Issue license')
body.appendChild(productSel)
body.appendChild(policySel)
body.appendChild(policyHint)
body.appendChild(emailField)
body.appendChild(noteField)
body.appendChild(submitBtn)
refreshPolicies()
}
/// Modal showing the just-issued signed license_key with a Copy button
/// — the only place this string is ever shown post-issue. Operators
/// must save it before closing.
function showLicenseIssued(key, id) {
const overlay = el('div', {
style: 'position:fixed; inset:0; background:rgba(14,31,51,0.55); z-index:9999; ' +
'display:flex; align-items:center; justify-content:center; padding:20px;',
})
const card = el('div', {
style: 'background:var(--cream-50); border:1px solid var(--border-1); ' +
'border-radius:12px; max-width:560px; width:100%; padding:28px 26px; ' +
'box-shadow:0 0 0 1px var(--gold-500) inset, 0 16px 32px rgba(14,31,51,0.20);',
}, [
el('div', { class: 'eyebrow', style: 'color:var(--gold-700); margin-bottom:8px' }, 'License issued'),
el('h3', { style: 'font-family:var(--font-display); font-weight:600; font-size:22px; margin:0 0 6px; color:var(--navy-950); letter-spacing:-0.01em;' }, 'Save the key now'),
el('p', { style: 'font-size:13.5px; color:var(--ink-700); line-height:1.55; margin:0 0 14px;' },
'This is the only time the signed key is shown. Copy it before closing.'),
el('div', {
id: 'issued-key-box',
style: 'background:var(--navy-950); color:var(--cream-50); padding:14px 16px; ' +
'border-radius:8px; font-family:var(--font-mono); font-size:12.5px; ' +
'word-break:break-all; line-height:1.5; max-height:200px; overflow-y:auto;',
}, key),
el('div', { class: 'muted', style: 'margin-top:10px; font-size:12px;' },
'License id: ' + (id || '?')),
el('div', { style: 'display:flex; gap:10px; margin-top:18px;' }, [
el('button', {
class: 'btn primary',
onclick: async function () {
try {
await navigator.clipboard.writeText(key)
this.textContent = 'Copied'
setTimeout(() => { this.textContent = 'Copy license key' }, 1400)
} catch {}
},
}, 'Copy license key'),
el('button', {
class: 'btn secondary',
onclick: () => overlay.remove(),
}, 'Close'),
]),
])
overlay.appendChild(card)
document.body.appendChild(overlay)
}
target.appendChild(plainCard([
el('p', { class: 'muted', style: 'margin:0 0 16px' },
'Showing the 100 most recently issued licenses by default. Search by buyer email, Nostr npub, or BTCPay invoice id to filter.'),
el('div', { class: 'toolbar' }, [
fieldSel,
queryInput,
el('button', { class: 'btn primary', onclick: loadLicenses }, [
el('i', { 'data-lucide': 'search' }),
'Search',
]),
el('button', {
class: 'btn secondary',
onclick: () => { queryInput.value = ''; loadLicenses() },
title: 'Clear search and show recent',
}, 'Clear'),
]),
issueDisclosure,
]))
target.appendChild(tableHolder)
buildIssueForm()
if (window.lucide) lucide.createIcons()
// Auto-load on tab open — was the bug; tab used to render an empty
// search box and never call the backend, hiding all issued licenses.
loadLicenses()
}
// -------- Machines --------
routes.machines = async function () {
const target = document.getElementById('route-target')
target.innerHTML = ''
const idIn = el('input', { class: 'input mono', type: 'text', placeholder: 'license id (UUID)' })
const out = el('div')
async function load() {
out.innerHTML = ''
out.appendChild(el('div', { class: 'card' }, el('div', { class: 'empty' }, 'Loading…')))
try {
const j = await api('/v1/admin/machines?license_id=' + encodeURIComponent(idIn.value.trim()))
const ms = j.machines || []
const rows = ms.map((m) => el('tr', null, [
el('td', null, el('code', null, shortId(m.id))),
el('td', null, m.hostname || ''),
el('td', null, m.platform || ''),
el('td', { class: 'muted' }, fmtDate(m.last_heartbeat_at)),
el('td', null, (m.active === true || m.active === 1)
? el('span', { class: 'badge b-success' }, [el('span', { class: 'dot ok' }), 'active'])
: el('span', { class: 'badge b-neutral' }, 'inactive')),
el('td', null, (m.active === true || m.active === 1)
? el('button', { class: 'btn sm danger', onclick: async () => {
if (!confirm('Force-deactivate this machine? It will free the seat.')) return
try {
await api('/v1/admin/machines/' + m.id + '/deactivate', { method: 'POST', body: { reason: 'admin' } })
load()
} catch (e) { alert(e.message) }
}}, 'Deactivate')
: null),
]))
out.innerHTML = ''
out.appendChild(tableCard(
'Machines',
ms.length + ' bound',
['ID', 'Hostname', 'Platform', 'Last heartbeat', 'Status', ''],
rows,
'No machines bound to that license.'
))
} catch (e) {
out.innerHTML = ''
out.appendChild(plainCard([err(e.message)]))
}
}
target.appendChild(plainCard([
el('p', { class: 'muted', style: 'margin:0 0 16px' },
'Show or deactivate machines bound to a specific license. Provide the license id (find it via Licenses → search).'),
el('div', { class: 'toolbar' }, [
idIn,
el('button', { class: 'btn primary', onclick: load }, 'Load'),
]),
]))
target.appendChild(out)
}
// -------- Webhooks --------
routes.webhooks = async function () {
const target = document.getElementById('route-target')
target.innerHTML = ''
const create = el('details', { class: 'disclosure' }, [
el('summary', null, 'Register a new webhook endpoint'),
el('div', { class: 'body' }, [
formInput('url', 'URL', { required: true, hint: 'where Keysat will POST event payloads' }),
formInput('description', 'Description (internal)'),
formInput('event_types', 'Event types (comma-separated; * for all)', { value: '*' }),
el('button', { class: 'btn primary', onclick: async function () {
try {
const evts = (create.querySelector('[name=event_types]').value || '*').split(',').map((s) => s.trim()).filter(Boolean)
await api('/v1/admin/webhook-endpoints', { method: 'POST', body: {
url: create.querySelector('[name=url]').value.trim(),
description: create.querySelector('[name=description]').value || '',
event_types: evts,
}})
routes.webhooks()
} catch (e) { alert(e.message) }
}}, 'Register endpoint'),
]),
])
target.appendChild(plainCard([
el('p', { class: 'muted', style: 'margin:0 0 16px' },
'Subscribe an external URL to Keysat events: license.issued, license.revoked, code.redeemed, etc. Each delivery carries an HMAC-SHA256 signature in the X-Keysat-Signature header.'),
create,
]))
try {
const j = await api('/v1/admin/webhook-endpoints')
const eps = j.endpoints || j.webhooks || []
const rows = eps.map((e) => el('tr', null, [
el('td', null, el('code', { style: 'word-break:break-all' }, e.url)),
el('td', { class: 'muted' }, (e.event_types || []).join(', ')),
el('td', null, activePill(e.active)),
el('td', { class: 'muted' }, fmtDate(e.created_at)),
el('td', null, el('div', { class: 'actions-row' }, [
el('button', { class: 'btn sm secondary', onclick: async () => {
try { await api('/v1/admin/webhook-endpoints/' + e.id + '/active', { method: 'PATCH', body: { active: !e.active } }); routes.webhooks() } catch (er) { alert(er.message) }
}}, e.active ? 'Disable' : 'Enable'),
el('button', { class: 'btn sm danger', onclick: async () => {
if (!confirm('Delete this webhook subscription?')) return
try { await api('/v1/admin/webhook-endpoints/' + e.id, { method: 'DELETE' }); routes.webhooks() } catch (er) { alert(er.message) }
}}, 'Delete'),
])),
]))
target.appendChild(tableCard(
'Registered endpoints',
eps.length + ' total',
['URL', 'Events', 'Status', 'Created', ''],
rows,
'No webhooks registered.'
))
} catch (e) {
target.appendChild(plainCard([err(e.message)]))
}
// ----- Delivery history -----
//
// Outbound deliveries get retried with exponential backoff up to
// 10 attempts; after that they're "dead-lettered" — sat in the
// DB unreachable. The new admin endpoint (v0.1.0:43) exposes them
// so operators can investigate and manually re-queue.
const status = el('select', { class: 'input', style: 'min-width:10rem' }, [
el('option', { value: 'all' }, 'All'),
el('option', { value: 'pending' }, 'Pending (in retry queue)'),
el('option', { value: 'delivered' }, 'Delivered'),
el('option', { value: 'failed', selected: 'selected' }, 'Failed (DLQ)'),
])
const reload = el('button', { class: 'btn sm secondary', onclick: () => loadDeliveries() }, 'Reload')
const deliveriesContainer = el('div')
async function loadDeliveries () {
deliveriesContainer.innerHTML = ''
deliveriesContainer.appendChild(el('p', { class: 'muted' }, 'Loading…'))
try {
const params = new URLSearchParams({ status: status.value, limit: '100' })
const j = await api('/v1/admin/webhook-deliveries?' + params.toString())
const ds = j.deliveries || []
deliveriesContainer.innerHTML = ''
if (ds.length === 0) {
const empty = status.value === 'failed'
? 'No failed deliveries — all webhooks are landing or in flight.'
: 'No deliveries match this filter.'
deliveriesContainer.appendChild(el('p', { class: 'muted', style: 'margin:8px 0' }, empty))
return
}
const rows = ds.map((d) => {
// status: delivered (delivered_at set) | failed (no next + attempts > 0) | pending (next set)
let pillCls = 'badge b-warning'
let pillDot = 'warn'
let pillText = 'pending'
if (d.delivered_at) {
pillCls = 'badge b-success'
pillDot = 'ok'
pillText = 'delivered'
} else if (!d.next_attempt_at && d.attempt_count > 0) {
pillCls = 'badge b-danger'
pillDot = 'err'
pillText = 'failed (DLQ)'
}
const pill = el('span', { class: pillCls }, [el('span', { class: 'dot ' + pillDot }), pillText])
const lastErr = d.last_error
? el('div', { class: 'muted', style: 'font-size:0.85em; margin-top:4px; word-break:break-all' }, d.last_error)
: null
return el('tr', null, [
el('td', { class: 'muted' }, fmtDate(d.created_at)),
el('td', null, d.event_type),
el('td', null, [pill, lastErr].filter(Boolean)),
el('td', { class: 'muted' }, String(d.attempt_count)),
el('td', { class: 'muted' }, d.last_status_code ? String(d.last_status_code) : '—'),
el('td', null, d.delivered_at
? null
: el('button', { class: 'btn sm secondary', onclick: async () => {
try {
await api('/v1/admin/webhook-deliveries/' + d.id + '/retry', { method: 'POST' })
loadDeliveries()
} catch (er) { alert(er.message) }
}}, 'Retry')),
])
})
deliveriesContainer.appendChild(el('table', { class: 'data' }, [
el('thead', null, el('tr', null,
['Created', 'Event', 'Status', 'Attempts', 'Last code', '']
.map((h) => el('th', null, h))
)),
el('tbody', null, rows),
]))
} catch (e) {
deliveriesContainer.innerHTML = ''
deliveriesContainer.appendChild(err(e.message))
}
}
status.addEventListener('change', loadDeliveries)
target.appendChild(plainCard([
el('h3', { style: 'margin:0 0 8px' }, 'Delivery history'),
el('p', { class: 'muted', style: 'margin:0 0 12px' },
'Defaults to "Failed" so the DLQ is visible at a glance. Failed deliveries are dead-lettered after 10 retry attempts (~7h backoff window). "Retry" re-queues the delivery for the worker on its next 5s tick.'),
el('div', { style: 'display:flex; gap:8px; align-items:center; margin-bottom:12px' }, [status, reload]),
deliveriesContainer,
]))
loadDeliveries()
}
// -------- Audit log --------
routes.audit = async function () {
const target = document.getElementById('route-target')
target.innerHTML = ''
const filter = el('input', { class: 'input', type: 'text', placeholder: 'filter by action (optional)' })
const limit = el('input', { class: 'input', type: 'number', value: '50', style: 'min-width:6rem; max-width:8rem' })
const out = el('div')
async function load() {
out.innerHTML = ''
out.appendChild(el('div', { class: 'card' }, el('div', { class: 'empty' }, 'Loading…')))
try {
const params = new URLSearchParams()
params.set('limit', limit.value || '50')
if (filter.value.trim()) params.set('action', filter.value.trim())
const j = await api('/v1/admin/audit?' + params.toString())
const entries = j.entries || []
const rows = entries.map((e) => el('tr', null, [
el('td', { class: 'muted' }, fmtDate(e.occurred_at)),
el('td', null, el('code', null, e.action)),
el('td', { class: 'muted' }, e.target_kind ? e.target_kind + ' ' + shortId(e.target_id || '') : ''),
el('td', { class: 'muted' }, e.actor_kind),
]))
out.innerHTML = ''
out.appendChild(tableCard(
'Recent entries',
entries.length + ' shown',
['When', 'Action', 'Target', 'Actor'],
rows,
'No entries.'
))
} catch (e) {
out.innerHTML = ''
out.appendChild(plainCard([err(e.message)]))
}
}
target.appendChild(plainCard([
el('p', { class: 'muted', style: 'margin:0 0 16px' },
'Most recent admin mutations. Filter by action slug if you know what youre looking for.'),
el('div', { class: 'toolbar' }, [
filter, limit,
el('button', { class: 'btn primary', onclick: load }, 'Load'),
]),
]))
target.appendChild(out)
load()
}
// ---------- form helpers ----------
function formInput(name, label, opts) {
opts = opts || {}
const id = 'f_' + name + '_' + Math.random().toString(36).slice(2, 6)
const lbl = el('label', { class: 'lbl', for: id }, [label, opts.required ? el('span', { class: 'req' }, '*') : null])
const inp = opts.textarea
? el('textarea', { class: 'input', id, name, rows: '3' })
: el('input', { class: 'input' + (opts.mono ? ' mono' : ''), id, name, type: opts.type || 'text' })
if (opts.value != null) inp.value = opts.value
const wrap = el('div', { class: 'field' }, [lbl, inp])
if (opts.hint) wrap.appendChild(el('div', { class: 'hint' }, opts.hint))
return wrap
}
function formSelect(name, label, options, opts) {
opts = opts || {}
const id = 'f_' + name + '_' + Math.random().toString(36).slice(2, 6)
const lbl = el('label', { class: 'lbl', for: id }, [label, opts.required ? el('span', { class: 'req' }, '*') : null])
const sel = el('select', { class: 'select', id, name })
for (const o of options) sel.appendChild(el('option', { value: o.value }, o.label))
if (opts.value) sel.value = opts.value
return el('div', { class: 'field' }, [lbl, sel])
}
function formCheckbox(name, label) {
const id = 'f_' + name + '_' + Math.random().toString(36).slice(2, 6)
const cb = el('input', { id, name, type: 'checkbox' })
return el('div', { class: 'field', style: 'display:flex; align-items:center; gap:8px; margin-top:24px' }, [
cb,
el('label', { class: 'lbl', for: id, style: 'margin:0' }, label),
])
}
// ---------- nav + auth ----------
function setRoute(name) {
const links = document.querySelectorAll('.sidebar a.nav')
for (const a of links) a.classList.toggle('active', a.getAttribute('data-route') === name)
const meta = ROUTE_META[name] || ROUTE_META.overview
document.getElementById('page-title').textContent = meta.title
document.getElementById('crumb').textContent = meta.crumb
const fn = routes[name] || routes.overview
fn().catch((e) => {
const t = document.getElementById('route-target')
t.innerHTML = ''
t.appendChild(plainCard([err(e.message || String(e))]))
}).finally(() => {
if (window.lucide) lucide.createIcons()
})
history.replaceState(null, '', '#' + name)
}
document.querySelectorAll('.sidebar a.nav').forEach((a) => {
a.addEventListener('click', (e) => { e.preventDefault(); setRoute(a.getAttribute('data-route')) })
})
async function refreshTierBanner() {
const wrap = document.getElementById('tier-banner')
const current = document.getElementById('tier-banner-current')
const msg = document.getElementById('tier-banner-msg')
const cta = document.getElementById('tier-banner-cta')
if (!wrap) return
try {
const t = await api('/v1/admin/tier')
// Tier label header.
current.textContent = t.tier === 'unlicensed'
? 'Unlicensed'
: (t.tier_name || 'Creator') + ' tier'
// Body copy + CTA based on tier.
if (t.tier === 'patron') {
msg.innerHTML = 'Youre a Patron — thank you for funding development.'
cta.style.display = 'none'
} else if (t.tier === 'pro') {
msg.innerHTML = 'Same features as Pro, plus a Patron badge — voluntary upgrade to fund Keysat development.'
cta.textContent = 'Become a Patron →'
cta.href = t.upgrade_url
cta.style.display = 'inline-block'
} else if (t.tier === 'creator') {
const productCap = (t.caps && t.caps.products) || 5
const productUsed = (t.usage && t.usage.products) || 0
msg.innerHTML = 'Up to ' + productCap + ' products, ' + productCap +
' policies/product, ' + ((t.caps && t.caps.active_codes) || 5) +
' active codes. Currently using ' + productUsed + '/' + productCap + ' products. ' +
'Upgrade for unlimited products, recurring billing, and Zaprite.'
cta.textContent = 'Upgrade to Pro →'
cta.href = t.upgrade_url
cta.style.display = 'inline-block'
} else {
// Unlicensed.
msg.innerHTML = 'Running without a Keysat license. Youre limited to ' +
((t.caps && t.caps.products) || 5) + ' products. ' +
'Get a Creator license (free codes available) or upgrade to Pro for unlimited.'
cta.textContent = 'Get Keysat license →'
cta.href = 'https://licensing.keysat.xyz/buy/keysat'
cta.style.display = 'inline-block'
}
wrap.style.display = 'block'
} catch (e) {
// Hide silently if endpoint not available (older daemon, etc.)
wrap.style.display = 'none'
}
}
async function refreshSidebarFooter() {
refreshTierBanner()
const f = document.getElementById('sidebar-footer')
try {
const s = await api('/v1/admin/btcpay/status')
f.innerHTML = ''
if (s.connected) {
f.appendChild(el('span', { class: 'dot' }))
f.appendChild(el('div', null, [
el('div', { style: 'color:var(--cream-50); font-weight:600' }, 'BTCPay connected'),
el('div', null, 'store ' + (s.store_id || '?').slice(0, 12) + '…'),
]))
} else {
f.appendChild(el('span', { class: 'dot warn' }))
f.appendChild(el('div', null, [
el('div', { style: 'color:var(--cream-50); font-weight:600' }, 'BTCPay not connected'),
el('div', null, 'use StartOS Actions tab'),
]))
}
} catch {
f.innerHTML = ''
f.appendChild(el('span', { class: 'dot warn' }))
f.appendChild(el('div', null, [
el('div', { style: 'color:var(--cream-50); font-weight:600' }, 'BTCPay status'),
el('div', null, 'unavailable'),
]))
}
}
// sessionAuth = true means the browser has a valid keysat_session cookie;
// the server-side middleware handles bridging it to the API-key auth used
// by all admin handlers. apiKey is only set in the legacy fallback path
// (first-time login on a fresh install before a password has been set).
let sessionAuth = false
function whoLabel() {
if (sessionAuth) return 'signed in'
if (apiKey) return apiKey.slice(0, 6) + '…' + apiKey.slice(-4)
return ''
}
function showApp() {
document.getElementById('login-view').classList.add('hide')
document.getElementById('app-view').classList.remove('hide')
document.getElementById('who').textContent = whoLabel()
fetch('/').then((r) => r.json()).then((j) => {
serviceInfo = j
}).catch(() => {}).finally(() => {
const route = (location.hash || '#overview').slice(1)
setRoute(route in routes ? route : 'overview')
if (window.lucide) lucide.createIcons()
})
refreshSidebarFooter()
}
async function showLogin() {
document.getElementById('login-view').classList.remove('hide')
document.getElementById('app-view').classList.add('hide')
const errEl = document.getElementById('login-err')
errEl.classList.add('hide')
// Pick which login mode to show based on whether a password is configured.
let status
try {
const r = await fetch('/admin/login/status', { credentials: 'same-origin' })
status = await r.json()
} catch {
status = { has_password: false, logged_in: false }
}
const sub = document.getElementById('login-sub')
const pwBox = document.getElementById('login-pw')
const keyBox = document.getElementById('login-key')
if (status.has_password) {
pwBox.classList.remove('hide')
keyBox.classList.add('hide')
sub.textContent = 'Sign in with your web UI password.'
setTimeout(() => document.getElementById('pw').focus(), 0)
} else {
pwBox.classList.add('hide')
keyBox.classList.remove('hide')
sub.textContent = 'No web UI password set yet. Sign in with the API key, then set a password via the StartOS action.'
setTimeout(() => document.getElementById('api-key').focus(), 0)
}
}
// ---- Password login (preferred) ----
document.getElementById('login-pw-btn').addEventListener('click', async () => {
const errEl = document.getElementById('login-err')
errEl.classList.add('hide')
const password = document.getElementById('pw').value
if (!password) { errEl.textContent = 'Enter your password.'; errEl.classList.remove('hide'); return }
try {
const r = await fetch('/admin/login', {
method: 'POST',
credentials: 'same-origin',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ password }),
})
if (r.status === 204) {
sessionAuth = true
apiKey = ''
// Probe an admin endpoint to confirm the cookie works end-to-end.
await api('/v1/admin/audit?limit=1')
showApp()
} else if (r.status === 401) {
throw new Error('Wrong password')
} else if (r.status === 429) {
throw new Error('Too many login attempts. Try again in a few minutes.')
} else if (r.status === 503) {
throw new Error('Web UI password not set. Use the StartOS action to set one.')
} else {
let msg = 'HTTP ' + r.status
try { const j = await r.json(); msg = j.message || j.error || msg } catch {}
throw new Error(msg)
}
} catch (e) {
sessionAuth = false
errEl.textContent = e.message
errEl.classList.remove('hide')
}
})
document.getElementById('pw').addEventListener('keypress', (e) => {
if (e.key === 'Enter') document.getElementById('login-pw-btn').click()
})
// ---- API-key fallback (first-run only) ----
document.getElementById('login-btn').addEventListener('click', async () => {
const errEl = document.getElementById('login-err')
errEl.classList.add('hide')
const k = document.getElementById('api-key').value.trim()
if (!k) { errEl.textContent = 'Enter your admin API key.'; errEl.classList.remove('hide'); return }
apiKey = k
sessionAuth = false
try {
await api('/v1/admin/audit?limit=1')
localStorage.setItem(LS_KEY, k)
showApp()
} catch (e) {
apiKey = ''
errEl.textContent = 'Key rejected: ' + e.message
errEl.classList.remove('hide')
}
})
document.getElementById('api-key').addEventListener('keypress', (e) => {
if (e.key === 'Enter') document.getElementById('login-btn').click()
})
document.getElementById('logout').addEventListener('click', async () => {
if (sessionAuth) {
try { await fetch('/admin/logout', { method: 'POST', credentials: 'same-origin' }) } catch {}
}
sessionAuth = false
localStorage.removeItem(LS_KEY)
apiKey = ''
const apiKeyInput = document.getElementById('api-key'); if (apiKeyInput) apiKeyInput.value = ''
const pwInput = document.getElementById('pw'); if (pwInput) pwInput.value = ''
showLogin()
})
// On first load: prefer cookie session if valid, else fall through to
// saved API key, else show the login form.
;(async function bootstrap() {
let status = null
try {
status = await (await fetch('/admin/login/status', { credentials: 'same-origin' })).json()
} catch {}
if (status && status.logged_in) {
sessionAuth = true
try {
await api('/v1/admin/audit?limit=1')
showApp()
return
} catch {
sessionAuth = false
}
}
const saved = localStorage.getItem(LS_KEY)
if (saved) {
apiKey = saved
try {
await api('/v1/admin/audit?limit=1')
showApp()
return
} catch {
apiKey = ''
localStorage.removeItem(LS_KEY)
}
}
showLogin()
})()
if (window.lucide) lucide.createIcons()
})()
</script>
</body>
</html>