Files
keysat/licensing-service/web/index.html
T
Grant fb062d5ca5 Tier upgrades Phase 5 — admin UI: tier_rank input + Change-tier modal
Closes the operator surface for tier upgrades. With this in,
operators have a complete UI for managing the upgrade ladder
without ever needing the curl-the-API path.

Policy editor (create + edit forms):
- New "Tier ladder rank (optional)" number input alongside the
  recurring section. Operators set "0" for free, "1" for
  standard, "2" for pro, etc. Empty input = "not in any ladder"
  (server stores NULL; that policy is excluded from buyer-facing
  upgrade flows but admin can still force-change to/from it).
- Edit-form behavior: empty input clears tier_rank to NULL.
  Filled input sets to that value. The PATCH always sends the
  field (using the nullable-patch shape Some(Option<i64>)) so
  the operator's intent — clear or set — actually lands.
- Range 0–1000 enforced server-side; clipped client-side too.

Licenses page:
- New "Change tier" button on every non-revoked license row,
  to the left of Suspend/Unsuspend/Revoke.
- Opens a modal that:
    * Loads all policies for the license's product
    * Shows them in a dropdown with metadata (rank · cadence ·
      trial flags) so the operator can see the ladder shape
    * Offers a "Apply as comp (skip_payment=true — no invoice,
      flips immediately)" checkbox + an audit-reason field
    * On submit, POSTs to the new admin endpoint:
      - skip_payment=true → "Applied" status, modal closes
      - skip_payment=false → renders the checkout URL the
        operator forwards to the buyer through whatever channel
        they use (the design-doc-spec'd "operator delivers the
        URL" flow)
- The modal deliberately doesn't show a quote preview before
  submit (the buyer-quote endpoint requires the buyer's signed
  license key, which the admin doesn't have). Server-side
  response carries the actual numbers when the operator commits.
  Future polish: a separate admin-mode quote endpoint could
  render the preview pre-submit.

Tests unchanged (77 still passing) — pure UI commit, no Rust
changes. The behavior the UI drives is fully covered by the
api.rs admin_change_tier_* tests added in c5d716a.
2026-05-08 20:15:23 -05:00

3294 lines
152 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="subscriptions"><i data-lucide="repeat"></i>Subscriptions</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' },
subscriptions: { title: 'Subscriptions', crumb: 'Workspace · Subscriptions' },
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. Off by default. Compact strip so it
// doesn't compete with the operator's actual workspace cards. The
// "what's sent" disclosure expands inline; details deliberately
// tucked behind a click so the default view stays calm.
const analyticsStrip = el('div', { style: 'margin-top:24px' })
target.appendChild(analyticsStrip)
renderAnalyticsCard(analyticsStrip)
// 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 compact community-analytics opt-in strip on Overview.
// Off by default. Auto-saves the toggle on click — no separate Save
// button. Details are tucked into an inline disclosure so the
// default view stays calm and doesn't compete with the operator's
// workspace cards.
async function renderAnalyticsCard(host) {
host.innerHTML = ''
let s
try {
s = await api('/v1/admin/community-analytics')
} catch (e) {
host.appendChild(el('p', { class: 'muted', style: 'font-size:12px' },
'Could not load analytics state: ' + e.message))
return
}
// The single line that's visible by default. Native checkbox so
// the affordance reads as "click to opt in", not as a fancy
// toggle that needs a Save click after.
const checkbox = el('input', {
type: 'checkbox',
style: 'cursor:pointer',
})
if (s.enabled) checkbox.checked = true
const detailsLink = el('a', {
href: '#',
class: 'muted',
style: 'font-size:12px; margin-left:6px',
}, 'what gets sent?')
const oneLine = el('label', {
style: 'display:inline-flex; align-items:center; gap:8px; font-size:13px; color:var(--ink-500); cursor:pointer'
}, [
checkbox,
el('span', null, 'Send anonymous usage stats so we can show real adoption numbers on the public dashboard.'),
])
const inlineRow = el('div', {
style: 'display:flex; justify-content:space-between; align-items:center; flex-wrap:wrap; gap:8px; padding:10px 14px; background:#fbf6e9; border:1px dashed #d6cdb8; border-radius:6px'
}, [
el('div', null, [oneLine, detailsLink]),
])
host.appendChild(inlineRow)
// Expanded details (collector URL, JSON preview, reset). Hidden
// by default; toggled by the "what gets sent?" link.
const details = el('div', {
style: 'display:none; margin-top:10px; padding:14px; background:#fbf6e9; border:1px dashed #d6cdb8; border-radius:6px'
})
host.appendChild(details)
detailsLink.addEventListener('click', (e) => {
e.preventDefault()
const showing = details.style.display !== 'none'
details.style.display = showing ? 'none' : 'block'
detailsLink.textContent = showing ? 'what gets sent?' : 'hide details'
})
// Collector URL — small input, optional.
const urlInput = el('input', {
class: 'input',
type: 'url',
placeholder: 'https://keysat.xyz/community/v1/heartbeat',
value: s.collector_url || '',
style: 'width:100%; box-sizing:border-box; font-size:12px; padding:6px 10px',
})
details.appendChild(el('label', { style: 'display:block; font-size:11px; font-weight:600; margin-bottom:4px; text-transform:uppercase; letter-spacing:0.05em' }, 'Collector URL'))
details.appendChild(urlInput)
details.appendChild(el('p', { class: 'muted', style: 'margin:4px 0 12px; font-size:11px' },
'Leave blank to opt in but not send. Once keysat.xyz/community is live, the default URL will populate on upgrade.'))
// The exact JSON the daemon would POST. Live preview, not a
// pretend example — what you see is what would actually be sent.
details.appendChild(el('p', { class: 'muted', style: 'margin:0 0 6px; font-size:11px' },
'Counts are floored to the nearest 5 (anti-fingerprinting). Uptime is bucketed. install_uuid is a random UUIDv4 generated on first opt-in — NOT derived from operator name, store id, or public URL.'))
details.appendChild(el('pre', {
style: 'background:#0e1f33; color:#f6f1e7; padding:10px; border-radius:4px; font-size:11px; overflow-x:auto; margin:0 0 8px'
}, JSON.stringify(s.preview_heartbeat, null, 2)))
if (s.install_uuid) {
const resetRow = el('div', { style: 'display:flex; justify-content:space-between; align-items:center; gap:8px; font-size:11px' }, [
el('span', { class: 'muted' }, 'Your install_uuid: ' + s.install_uuid.slice(0, 8) + '…'),
el('a', { href: '#', class: 'muted', style: 'font-size:11px' }, 'reset'),
])
const resetLink = resetRow.querySelector('a')
resetLink.addEventListener('click', async (e) => {
e.preventDefault()
if (!confirm('Wipe your anonymous install_uuid? Future heartbeats (if you re-enable) will use a fresh one.')) return
try {
await api('/v1/admin/community-analytics/reset', { method: 'POST' })
renderAnalyticsCard(host)
} catch (er) { alert(er.message) }
})
details.appendChild(resetRow)
}
// Auto-save: toggling the checkbox or editing the URL persists
// immediately. No Save button; the affordance is "click and it's
// done."
let saveTimer = null
async function persist() {
try {
await api('/v1/admin/community-analytics', { method: 'POST', body: {
enabled: checkbox.checked,
collector_url: urlInput.value.trim() || null,
}})
} catch (e) {
alert(e.message)
// Revert visual state on failure so what the user sees
// matches what's persisted.
checkbox.checked = !checkbox.checked
}
}
checkbox.addEventListener('change', persist)
urlInput.addEventListener('input', () => {
clearTimeout(saveTimer)
saveTimer = setTimeout(persist, 600) // debounce the URL field
})
}
// 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 (currency + value). Slug is
// intentionally not editable (it's part of the public buy URL —
// changing it would break 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 || '' })
// Currency-aware price inputs. For SAT-currency products, show
// the integer sat amount. For USD/EUR, render the cents value
// back to a decimal main-unit string ($49.00) and accept
// decimals on save.
const initialCurrency = (p.price_currency || 'SAT').toUpperCase()
const initialDisplay = initialCurrency === 'SAT'
? String(p.price_value || p.price_sats || 0)
: ((p.price_value || 0) / 100).toFixed(2)
const curPicker = el('select', { class: 'input', name: 'e_p_currency' }, [
el('option', { value: 'SAT' }, 'sats'),
el('option', { value: 'USD' }, 'USD ($)'),
el('option', { value: 'EUR' }, 'EUR (€)'),
])
curPicker.value = initialCurrency
const priceInput = el('input', {
class: 'input', name: 'e_p_price', type: 'number',
step: initialCurrency === 'SAT' ? '1' : '0.01',
min: '0', value: initialDisplay, required: 'required',
})
curPicker.addEventListener('change', () => {
priceInput.step = curPicker.value === 'SAT' ? '1' : '0.01'
// Don't auto-clobber the value — let the operator decide if
// the displayed number still makes sense in the new unit.
// Show a hint instead.
hint.textContent = curPicker.value === 'SAT'
? 'sats — whole numbers only.'
: 'Decimal entry, e.g. 49.00. Converted to BTC at each invoice using the daemon\'s rate fetcher.'
})
const hint = el('div', { class: 'muted', style: 'font-size:12px; margin-top:4px' },
initialCurrency === 'SAT'
? 'sats — whole numbers only.'
: 'Decimal entry, e.g. 49.00. Converted to BTC at each invoice using the daemon\'s rate fetcher.')
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,
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, curPicker]),
hint,
status,
el('div', { style: 'display:flex; gap:10px; margin-top:14px;' }, [
el('button', { class: 'btn primary', onclick: async function () {
status.textContent = 'Saving…'
try {
const currency = curPicker.value
const rawValue = parseFloat(priceInput.value) || 0
const priceValue = currency === 'SAT' ? Math.round(rawValue) : Math.round(rawValue * 100)
const body = {
name: card.querySelector('[name=e_p_name]').value.trim(),
description: card.querySelector('[name=e_p_description]').value || '',
price_currency: currency,
price_value: Math.max(0, priceValue),
}
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)]))
}
}
// Change-tier modal: pick target policy, see quote, choose comp
// (skip_payment=true) vs paid (skip_payment=false → operator gets
// a checkout URL to forward to the buyer). Auth via admin token —
// POST /v1/admin/licenses/:id/change-tier.
function openChangeTier(license) {
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 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;',
})
overlay.appendChild(card)
overlay.addEventListener('click', (e) => { if (e.target === overlay) overlay.remove() })
document.body.appendChild(overlay)
card.appendChild(el('div', { class: 'eyebrow', style: 'margin-bottom:8px' }, 'Change tier'))
card.appendChild(el('h3', { style: 'font-family:var(--font-display); font-weight:600; font-size:20px; margin:0 0 6px; color:var(--navy-950);' },
'License ' + (license.id ? license.id.slice(0, 8) : '?') + '…'))
card.appendChild(el('p', { class: 'muted', style: 'margin:0 0 16px; font-size:13px' },
'Force-change this license to a different policy under the same product. Bypasses ladder rules — operators can move sideways, downgrade perpetuals, etc. Preview the prorated charge before committing.'))
const status = el('div', { class: 'muted', style: 'margin-top:8px; font-size:12.5px;' }, 'Loading product policies…')
const policiesHolder = el('div')
const quoteHolder = el('div', { style: 'margin-top:14px' })
const buttonRow = el('div', { style: 'display:flex; gap:10px; margin-top:14px;' }, [
el('button', { class: 'btn secondary', onclick: () => overlay.remove() }, 'Cancel'),
])
card.appendChild(policiesHolder)
card.appendChild(quoteHolder)
card.appendChild(status)
card.appendChild(buttonRow)
let allPolicies = []
let currentPolicySlug = null
let selectedTargetSlug = null
let lastQuote = null
;(async function init() {
try {
const j = await api('/v1/admin/policies?product_slug=' + encodeURIComponent(license.product_slug || ''))
allPolicies = j.policies || []
if (license.policy_id) {
const cur = allPolicies.find((p) => p.id === license.policy_id)
if (cur) currentPolicySlug = cur.slug
}
renderPolicyPicker()
status.textContent = ''
} catch (e) {
status.textContent = 'Failed to load policies: ' + e.message
status.style.color = 'var(--danger)'
}
})()
function renderPolicyPicker() {
policiesHolder.innerHTML = ''
const opts = allPolicies
.filter((p) => p.slug !== currentPolicySlug)
.map((p) => ({
value: p.slug,
label: p.name + ' (' + p.slug + ')' +
(p.tier_rank != null ? ' · rank ' + p.tier_rank : '') +
(p.is_recurring ? ' · recurring' : '') +
(p.is_trial ? ' · trial' : ''),
}))
if (opts.length === 0) {
policiesHolder.appendChild(plainCard([
el('p', { class: 'muted', style: 'margin:0' },
'No other policies on this product. Create one first under Policies → ' + (license.product_slug || '<product>') + '.'),
]))
return
}
const sel = formSelect('change_tier_target', 'Target policy', opts, { required: true })
policiesHolder.appendChild(sel)
const selEl = sel.querySelector('select')
selEl.addEventListener('change', () => {
selectedTargetSlug = selEl.value
runQuote()
})
// Auto-pick first option + run quote.
selectedTargetSlug = opts[0].value
selEl.value = selectedTargetSlug
runQuote()
}
async function runQuote() {
quoteHolder.innerHTML = ''
buttonRow.innerHTML = ''
buttonRow.appendChild(el('button', { class: 'btn secondary', onclick: () => overlay.remove() }, 'Cancel'))
if (!selectedTargetSlug) return
try {
// Reuse the buyer quote endpoint when both ranks are set;
// for admin-only paths (NULL rank, sideways) the operator
// path through /v1/admin/.../change-tier validates server-side
// anyway. We only render the quote preview when buyer-quote
// returns a clean answer; otherwise show a generic preview.
// To keep UX simple, we don't currently expose an
// admin-mode quote endpoint — fall back to letting the
// operator see the listed price diff via the policy's
// price_sats_override, surfaced in the picker option label.
// For ranks that line up, we can pull a real quote via a
// short-lived test license_key... but that requires the
// buyer key, which the admin doesn't have. So: we skip
// the quote preview in the admin UI and rely on the
// server-side response after submit. Show a placeholder.
quoteHolder.appendChild(el('p', { class: 'muted', style: 'margin:0; font-size:13px' },
'The server will compute the prorated charge in the listed currency on submit. Toggle "Apply as comp" below to skip the invoice and move the license immediately at no charge.'))
const compToggle = formCheckbox('change_tier_skip_payment', 'Apply as comp (skip_payment=true — no invoice, license flips immediately)')
quoteHolder.appendChild(compToggle)
const reasonField = formInput('change_tier_reason', 'Audit reason (optional)', {
hint: 'Free-form note. Stored on the tier_changes row + audit_log.',
})
quoteHolder.appendChild(reasonField)
buttonRow.appendChild(el('button', {
class: 'btn primary',
onclick: async () => {
const skip = !!card.querySelector('[name=change_tier_skip_payment]').checked
const reason = (card.querySelector('[name=change_tier_reason]').value || '').trim() || null
buttonRow.querySelectorAll('button').forEach((b) => b.disabled = true)
status.textContent = skip ? 'Applying comp change…' : 'Creating invoice…'
status.style.color = ''
try {
const r = await api('/v1/admin/licenses/' + license.id + '/change-tier', {
method: 'POST',
body: {
to_policy_slug: selectedTargetSlug,
skip_payment: skip,
reason,
},
})
if (r.applied) {
status.textContent = 'Applied — license is now on ' + r.to_policy_slug + '.'
} else {
const url = r.checkout_url || ''
status.innerHTML = ''
status.appendChild(el('div', null, 'Invoice created. Forward this URL to the buyer:'))
const link = el('a', { href: url, target: '_blank', style: 'word-break:break-all' }, url)
status.appendChild(link)
}
setTimeout(() => {
if (skip) overlay.remove()
}, 800)
} catch (e) {
status.textContent = e.message
status.style.color = 'var(--danger)'
buttonRow.querySelectorAll('button').forEach((b) => b.disabled = false)
}
},
}, 'Submit'))
} catch (e) {
status.textContent = 'Quote failed: ' + e.message
status.style.color = 'var(--danger)'
}
}
}
// 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)
// -- Recurring subscription (Pro tier) --
const RENEWAL_PRESETS = [
{ value: '30', label: 'Monthly (30 days)' },
{ value: '90', label: 'Quarterly (90 days)' },
{ value: '180', label: 'Semi-annual (180 days)' },
{ value: '365', label: 'Annual (365 days)' },
{ value: 'custom', label: 'Custom (in days)' },
]
const isRecurringInit = !!pol.is_recurring
const renewalDaysInit = pol.renewal_period_days || 30
const matchedRenewal = RENEWAL_PRESETS.find(
(p) => p.value === String(renewalDaysInit) && p.value !== 'custom'
)
const initialRenewalPreset = matchedRenewal ? matchedRenewal.value : 'custom'
const recurField = formCheckbox('e_pol_is_recurring', 'This policy is a recurring subscription')
const renewalPresetField = formSelect('e_pol_renewal_preset', 'Renewal cadence', RENEWAL_PRESETS, { value: initialRenewalPreset })
const renewalCustomField = formInput('e_pol_renewal_days', 'Custom (days)', {
type: 'number', value: String(renewalDaysInit),
})
const gracePeriodField = formInput('e_pol_grace_period_days', 'Grace period after renewal (days)', {
type: 'number', value: String(pol.grace_period_days == null ? 7 : pol.grace_period_days),
})
const trialDaysField = formInput('e_pol_trial_days', 'Free trial (days)', {
type: 'number', value: String(pol.trial_days || 0),
})
// Tier-ladder rank. Empty input means "not in any ladder" (server
// stores NULL); operator can blank it to remove a policy from the
// ladder, or set a number to add it. Range 01000 enforced server-side.
const tierRankField = formInput('e_pol_tier_rank', 'Tier ladder rank (optional)', {
type: 'number',
value: pol.tier_rank == null ? '' : String(pol.tier_rank),
hint: 'Higher = better tier. Leave blank to keep this policy out of the buyer-facing upgrade ladder.',
})
if (isRecurringInit) setTimeout(() => {
const cb = card.querySelector('[name=e_pol_is_recurring]')
if (cb) cb.checked = true
syncRecurringEdit()
}, 0)
function syncRecurringEdit() {
const on = !!card.querySelector('[name=e_pol_is_recurring]').checked
const presetEl = card.querySelector('[name=e_pol_renewal_preset]')
const customEl = card.querySelector('[name=e_pol_renewal_days]')
const graceEl = card.querySelector('[name=e_pol_grace_period_days]')
const trialEl = card.querySelector('[name=e_pol_trial_days]')
;[presetEl, graceEl, trialEl].forEach((e) => {
if (e) { e.disabled = !on; e.style.opacity = on ? '1' : '0.5' }
})
if (customEl) {
const customOn = on && presetEl && presetEl.value === 'custom'
customEl.disabled = !customOn
customEl.style.opacity = customOn ? '1' : '0.5'
}
}
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]),
// Tier ladder rank — sits in its own row above the recurring section.
tierRankField,
// Recurring subscription block
el('div', {
style: 'margin-top:14px; padding-top:12px; border-top:1px dashed var(--border-1)',
}, [
el('div', { class: 'eyebrow', style: 'margin-bottom:6px' }, 'Recurring subscription (Pro)'),
recurField,
el('div', { class: 'row-2', style: 'margin-top:8px' }, [renewalPresetField, renewalCustomField]),
el('div', { class: 'row-2' }, [gracePeriodField, trialDaysField]),
]),
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)
// Recurring subscription — send the fields whenever the operator
// touched any of them so update_policy can validate the post-update
// shape consistently. Easiest invariant: always send all four.
const isRecurring = card.querySelector('[name=e_pol_is_recurring]').checked
const renewalPreset = card.querySelector('[name=e_pol_renewal_preset]').value
const renewalCustom = parseInt(card.querySelector('[name=e_pol_renewal_days]').value, 10) || 0
const renewalDays = renewalPreset === 'custom'
? renewalCustom
: parseInt(renewalPreset, 10)
const gracePeriodDays = parseInt(card.querySelector('[name=e_pol_grace_period_days]').value, 10)
const trialDays = parseInt(card.querySelector('[name=e_pol_trial_days]').value, 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,
is_recurring: isRecurring,
renewal_period_days: isRecurring ? renewalDays : (pol.renewal_period_days || 0),
grace_period_days: isNaN(gracePeriodDays) ? 7 : gracePeriodDays,
trial_days: trialDays,
}
// tier_rank is a nullable patch. Empty input → null
// (remove from ladder). Number → set. We always send
// the field on edit so the server's "patch touched
// field?" logic fires and the operator's intent (clear
// or set) lands.
const tierRankRaw = (card.querySelector('[name=e_pol_tier_rank]').value || '').trim()
body.tier_rank = tierRankRaw === ''
? null
: Math.max(0, Math.min(1000, parseInt(tierRankRaw, 10) || 0))
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)
// Wire the recurring section's enable/disable sync now that the card
// is in the DOM and inputs are queryable.
const recurEl = card.querySelector('[name=e_pol_is_recurring]')
const renewalPresetEl = card.querySelector('[name=e_pol_renewal_preset]')
if (recurEl) recurEl.addEventListener('change', syncRecurringEdit)
if (renewalPresetEl) renewalPresetEl.addEventListener('change', syncRecurringEdit)
syncRecurringEdit()
}
// -------- 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)'),
]),
// ---------- Tier ladder rank ----------
// Operator-defined ordering for in-place upgrades. Higher
// rank = better tier. Leave blank to exclude this policy
// from the buyer-facing upgrade ladder (admin can still
// force-change to/from any policy via the licenses page).
formInput('tier_rank', 'Tier ladder rank (optional)', {
type: 'number',
hint: 'Position in the upgrade ladder for this product. Higher = better tier. Common pattern: free=0, standard=1, pro=2, patron=3. Leave blank to keep the policy out of the ladder (e.g. one-off promo). Range 01000.',
}),
// ---------- Recurring subscription (Pro tier) ----------
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' }, 'Recurring subscription (Pro)'),
el('p', { class: 'hint', style: 'margin:0 0 10px' },
'Bill the buyer on a repeating cycle (monthly, annual, etc.). The renewal worker creates a fresh BTCPay/Zaprite invoice every period; if the buyer doesn\'t pay within the grace window, the license lapses automatically. Pro tier required.'),
formCheckbox('is_recurring', 'This policy is a recurring subscription'),
el('div', { class: 'row-2', style: 'margin-top:10px' }, [
formSelect('renewal_preset', 'Renewal cadence', [
{ value: '30', label: 'Monthly (30 days)' },
{ value: '90', label: 'Quarterly (90 days)' },
{ value: '180', label: 'Semi-annual (180 days)' },
{ value: '365', label: 'Annual (365 days)' },
{ value: 'custom', label: 'Custom (in days)' },
], { value: '30' }),
formInput('renewal_period_days', 'Custom (days)', {
type: 'number', value: '30',
hint: 'Used only when "Custom" is selected. Min 1, max ~1825 (5 years).',
}),
]),
el('div', { class: 'row-2' }, [
formInput('grace_period_days', 'Grace period after renewal (days)', {
type: 'number', value: '7',
hint: 'How long the license stays valid past the renewal date if the buyer hasn\'t paid yet. After this, the subscription transitions to "lapsed". Default 7.',
}),
formInput('trial_days', 'Free trial (days)', {
type: 'number', value: '0',
hint: 'Optional. 0 = no trial. The first invoice is still issued (for $0/1 sat) so buyer email + license flow are consistent; the renewal worker charges the real price after the trial period.',
}),
]),
]),
// ---------- 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,
}
// tier_rank: only attach if the operator typed something.
// Empty input = "leave out of ladder" (server stores NULL).
const rankRaw = (create.querySelector('[name=tier_rank]').value || '').trim()
if (rankRaw !== '') {
const rank = parseInt(rankRaw, 10)
if (!isNaN(rank)) body.tier_rank = rank
}
if (tipRecipient) {
body.tip_recipient = tipRecipient
body.tip_pct_bps = tipPctBps
if (tipLabel) body.tip_label = tipLabel
}
// Recurring subscription — only attach when the operator
// ticked the box, so non-recurring policies stay clean.
const isRecurring = create.querySelector('[name=is_recurring]').checked
if (isRecurring) {
const renewalPresetEl = create.querySelector('[name=renewal_preset]')
const renewalCustomEl = create.querySelector('[name=renewal_period_days]')
const renewalPreset = renewalPresetEl.value
const renewalCustomDays = parseInt(renewalCustomEl.value, 10) || 0
const renewalDays = renewalPreset === 'custom'
? renewalCustomDays
: parseInt(renewalPreset, 10)
const graceDays = parseInt(create.querySelector('[name=grace_period_days]').value, 10)
const trialDays = parseInt(create.querySelector('[name=trial_days]').value, 10) || 0
body.is_recurring = true
body.renewal_period_days = renewalDays
body.grace_period_days = isNaN(graceDays) ? 7 : graceDays
body.trial_days = trialDays
}
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()
// Recurring section: gray everything out unless the box is ticked,
// and gray the custom-days input unless "Custom" is selected. Keeps
// the form visually honest about what will actually be submitted.
const recurEl = create.querySelector('[name=is_recurring]')
const renewalPresetEl = create.querySelector('[name=renewal_preset]')
const renewalCustomEl = create.querySelector('[name=renewal_period_days]')
const graceEl = create.querySelector('[name=grace_period_days]')
const trialEl = create.querySelector('[name=trial_days]')
function syncRecurring() {
const on = recurEl.checked
;[renewalPresetEl, graceEl, trialEl].forEach((e) => {
if (e) { e.disabled = !on; e.style.opacity = on ? '1' : '0.5' }
})
if (renewalCustomEl) {
const customOn = on && renewalPresetEl.value === 'custom'
renewalCustomEl.disabled = !customOn
renewalCustomEl.style.opacity = customOn ? '1' : '0.5'
}
}
recurEl.addEventListener('change', syncRecurring)
renewalPresetEl.addEventListener('change', syncRecurring)
syncRecurring()
// 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,
// Stack trial + recurring badges in one cell. Both can be set
// independently (a recurring policy can also have a trial bit).
pol.is_trial || pol.is_recurring
? el('span', { style: 'display:inline-flex; gap:4px; flex-wrap:wrap' }, [
pol.is_trial ? el('span', { class: 'badge b-warning' }, 'trial') : null,
pol.is_recurring
? el('span', {
class: 'badge b-gold',
title: 'Renews every ' + (pol.renewal_period_days || 0) + ' days',
}, 'every ' + (pol.renewal_period_days || 0) + 'd')
: null,
].filter(Boolean))
: 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)]))
}
}
}
// -------- Subscriptions --------
routes.subscriptions = async function () {
const target = document.getElementById('route-target')
target.innerHTML = ''
target.appendChild(plainCard([
el('p', { class: 'muted', style: 'margin:0' },
'Recurring subscriptions tied to active licenses. Cancellation here ' +
'is non-destructive: the license stays valid through the end of the ' +
'current cycle, the renewal worker just stops creating new invoices.'),
]))
// Status filter pills.
const STATUSES = [
{ value: '', label: 'All' },
{ value: 'active', label: 'Active' },
{ value: 'past_due', label: 'Past due' },
{ value: 'cancelled', label: 'Cancelled' },
{ value: 'lapsed', label: 'Lapsed' },
]
let currentFilter = ''
const filterRow = el('div', { style: 'display:flex; gap:8px; flex-wrap:wrap; margin:14px 0' })
function renderFilterPills() {
filterRow.innerHTML = ''
STATUSES.forEach((s) => {
const active = s.value === currentFilter
const pill = el('button', {
class: 'btn sm ' + (active ? 'primary' : 'secondary'),
onclick: () => { currentFilter = s.value; renderFilterPills(); load() },
}, s.label)
filterRow.appendChild(pill)
})
}
renderFilterPills()
target.appendChild(filterRow)
const tableHost = el('div')
target.appendChild(tableHost)
async function load() {
tableHost.innerHTML = ''
try {
const url = '/v1/admin/subscriptions' + (currentFilter ? ('?status=' + currentFilter) : '')
const j = await api(url)
const subs = j.subscriptions || []
if (subs.length === 0) {
tableHost.appendChild(plainCard([
el('p', { class: 'muted', style: 'margin:0' }, '(no subscriptions match this filter)'),
]))
return
}
const table = el('table', { class: 'table' })
const thead = el('thead', null, el('tr', null, [
el('th', null, 'License'),
el('th', null, 'Cadence'),
el('th', null, 'Listed price'),
el('th', null, 'Status'),
el('th', null, 'Next renewal'),
el('th', null, 'Failures'),
el('th', null, 'Actions'),
]))
const tbody = el('tbody')
subs.forEach((s) => {
const statusBadge = (function () {
const klass = s.status === 'active' ? 'b-success'
: s.status === 'past_due' ? 'b-warning'
: s.status === 'cancelled' ? 'b-neutral'
: s.status === 'lapsed' ? 'b-danger' : 'b-neutral'
return el('span', { class: 'badge ' + klass }, s.status)
})()
const cadence = (s.period_days === 30 ? 'monthly'
: s.period_days === 90 ? 'quarterly'
: s.period_days === 180 ? 'semi-annual'
: s.period_days === 365 ? 'annual'
: ('every ' + s.period_days + 'd'))
const priceFmt = s.listed_currency === 'SAT'
? (Number(s.listed_value).toLocaleString() + ' sats')
: ((s.listed_value / 100).toFixed(2) + ' ' + s.listed_currency)
const tr = el('tr', null, [
el('td', null, el('code', { class: 'small', title: s.license_id }, s.license_id.slice(0, 8) + '…')),
el('td', null, cadence),
el('td', null, priceFmt),
el('td', null, statusBadge),
el('td', { class: 'muted' }, s.next_renewal_at ? s.next_renewal_at.slice(0, 16).replace('T', ' ') : ''),
el('td', null, String(s.consecutive_failures || 0)),
el('td', null, (s.status === 'active' || s.status === 'past_due')
? el('button', {
class: 'btn sm danger',
onclick: async () => {
const reason = prompt(
'Cancel this subscription?\n\nThe license stays valid through the end of the current cycle. ' +
'No new invoices will be created.\n\nOptional: enter a reason for the audit log:'
)
if (reason === null) return // user clicked Cancel
try {
await api('/v1/admin/subscriptions/' + s.id + '/cancel', {
method: 'POST',
body: { reason: reason || null },
})
load()
} catch (e) { alert(e.message) }
},
}, 'Cancel')
: el('span', { class: 'muted', style: 'font-size:12px' }, '')),
])
tbody.appendChild(tr)
})
table.appendChild(thead)
table.appendChild(tbody)
tableHost.appendChild(table)
} catch (e) {
tableHost.appendChild(plainCard([err('Failed to load subscriptions: ' + e.message)]))
}
}
load()
}
// -------- Discount codes --------
routes.codes = async function () {
const target = document.getElementById('route-target')
target.innerHTML = ''
function amountHint(kind, currency) {
if (kind === 'percent') return 'percent off, e.g. 50 = 50%. Range: 1100. (Currency-agnostic.)'
if (kind === 'free_license') return 'amount is ignored — buyer pays nothing.'
const unit = currency === 'SAT' ? 'sats' : currency === 'USD' ? 'USD' : currency === 'EUR' ? 'EUR' : 'units'
const decimals = currency === 'SAT' ? '' : ' (decimals OK, e.g. 9.99)'
if (kind === 'fixed_sats') return `${unit} subtracted from the base price${decimals}.`
if (kind === 'set_price') return `flat price the buyer pays in ${unit}${decimals}. If higher than base, the code provides no benefit.`
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 amount off' },
{ value: 'set_price', label: 'Set flat price' },
{ value: 'free_license', label: 'Free license (no payment)' },
], { required: true, value: 'percent' }),
]),
el('div', { class: 'row-2' }, [
formInput('amount', 'Amount', { type: 'number', step: '1', value: '50', hint: amountHint('percent', 'SAT') }),
formSelect('discount_currency', 'Currency', [
{ value: 'SAT', label: 'sats' },
{ value: 'USD', label: 'USD ($)' },
{ value: 'EUR', label: 'EUR (€)' },
], { value: 'SAT', hint: 'matters for "fixed amount off" and "set flat price" — ignored for percent / free.' }),
]),
el('div', { class: 'row-2' }, [
formInput('max_uses', 'Max uses (0 = unlimited)', { type: 'number', value: '0' }),
el('div'), // spacer to keep the row balanced
]),
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
const currency = create.querySelector('[name=discount_currency]').value
const rawAmount = parseFloat(create.querySelector('[name=amount]').value) || 0
// For percent: stored as basis points (50% → 5000).
// For SAT-currency fixed/set: stored as sats (whole number).
// For USD/EUR fixed/set: stored as cents (1.00 main unit → 100).
// Free license: amount ignored (we send 0).
let amount
if (kind === 'percent') amount = Math.round(rawAmount * 100)
else if (kind === 'free_license') amount = 0
else if (currency === 'SAT') amount = Math.round(rawAmount)
else amount = Math.round(rawAmount * 100)
const body = {
code: create.querySelector('[name=code]').value.trim(),
kind, amount,
discount_currency: currency,
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 Kind or
// Currency. Also swap the input's `step` so SAT-currency codes
// are integer-only and USD/EUR can take decimals.
const kindSelEl = create.querySelector('[name=kind]')
const curSelEl = create.querySelector('[name=discount_currency]')
const amtInputEl = create.querySelector('[name=amount]')
function updateHint() {
const hintEl = amtInputEl.parentElement.querySelector('.hint')
if (hintEl) hintEl.textContent = amountHint(kindSelEl.value, curSelEl.value)
// Toggle decimal entry — sats are integer, fiat goes to cents.
amtInputEl.step = curSelEl.value === 'SAT' ? '1' : '0.01'
}
if (kindSelEl) kindSelEl.addEventListener('change', updateHint)
if (curSelEl) curSelEl.addEventListener('change', updateHint)
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) => {
// Currency-aware rendering. SAT-currency codes show "5,000
// sats off"; fiat codes show "$10.00 off" with cents-to-
// dollars conversion. Backwards-compat for older rows that
// don't carry discount_currency: treat as SAT.
const cur = (c.discount_currency || 'SAT').toUpperCase()
const fmtFiat = (cents, sym) => sym + (cents / 100).toFixed(2)
let amountStr = ''
if (c.kind === 'percent') amountStr = (c.amount / 100) + '%'
else if (c.kind === 'fixed_sats') {
if (cur === 'SAT') amountStr = c.amount.toLocaleString() + ' sats off'
else if (cur === 'USD') amountStr = fmtFiat(c.amount, '$') + ' off'
else if (cur === 'EUR') amountStr = fmtFiat(c.amount, '€') + ' off'
else amountStr = c.amount + ' ' + cur + ' off'
}
else if (c.kind === 'set_price') {
if (cur === 'SAT') amountStr = c.amount.toLocaleString() + ' sats flat'
else if (cur === 'USD') amountStr = fmtFiat(c.amount, '$') + ' flat'
else if (cur === 'EUR') amountStr = fmtFiat(c.amount, '€') + ' flat'
else amountStr = c.amount + ' ' + cur + ' 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'
? el('button', {
class: 'btn sm secondary',
title: 'Move this license to a different policy/tier',
onclick: () => openChangeTier(l),
}, 'Change tier')
: null,
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>