Files
keysat/licensing-service/web/index.html
T
Grant 0ea3469899 v0.2.0:9 — side-by-side tier-card policy authoring + form polish
The Policies tab gets the redesign Grant asked for: replace the
table view + verbose disclosure form with a card grid where each
existing policy renders as a buy-page-style tier card sitting next
to a dashed "+ Add tier" placeholder. Click the placeholder, it
morphs into an editable draft tier card with inline form fields;
submit Create on the card and it flips into a read-only preview.
Multiple drafts can coexist for parallel multi-tier authoring with
side-by-side comparison.

New JS helpers:
- helpIcon(text) — small "?" hover tooltip for compact form labels
- slugify(s) — URL-safe slug derivation from display name
- renderTierCard(pol, product, onMutate) — read-only buy-page-style
  preview card with Edit / Hide-Show / Delete actions
- renderAddTierCard(onClick) — dashed placeholder with "+" affordance
- renderDraftTierCard(product, onCommit, onCancel) — inline editable
  card with name + slug + price + duration + entitlement bubble
  picker + recurring/trial toggles
- renderPolicyCardGrid(product, policies, byPolicyCounts, onMutate) —
  ties them together. Submitting "+ Add tier" appends a fresh
  placeholder, so operators can keep clicking to author multiple
  tiers in one session.

formInput() upgraded:
- New `help:` option renders a helpIcon next to the label (replaces
  verbose hint text under the input)
- New `placeholder:` option for cleaner empty-state cues

Auto-slug:
- Product create form's Display name field mirrors a slugified
  version into the Slug field as the operator types — until they
  manually edit the slug, which arms a "userOverridden" guard so
  manual edits stick. Re-arms when the slug field is cleared.

Legacy "Create a new policy" disclosure form unsurfaced from
the Policies route — the card grid replaces it. Advanced fields
(custom grace seconds, tip recipient, tier rank) still live on the
existing Edit modal of an already-committed tier card. Power-user
flow: card grid creates the basics, Edit modal refines.

Test count unchanged (78). UI-only release.
2026-05-10 10:23:07 -05:00

4170 lines
188 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) }
/**
* Inline help affordance — small "?" icon that shows the given
* text in a hover tooltip. Used in form labels to replace the
* verbose hint text that was making create / edit forms feel
* cluttered. Usage:
*
* el('label', null, ['Slug', helpIcon('lowercase, hyphens-not-spaces')])
*
* The tooltip uses the browser's native title attribute — works
* everywhere, no JS, accessible to screen readers.
*/
function helpIcon(text) {
return el('span', {
class: 'help-icon',
title: text,
tabindex: '0',
'aria-label': text,
style:
'display:inline-flex; align-items:center; justify-content:center; ' +
'width:14px; height:14px; border-radius:50%; ' +
'background:var(--ink-500); color:var(--cream-50); ' +
'font-size:10px; font-weight:700; font-family:var(--font-body); ' +
'cursor:help; margin-left:6px; user-select:none; flex:none;',
}, '?')
}
/** Slugify a display name into a URL-safe slug. Used by the
* auto-slug feature on the product create form. */
function slugify(s) {
return (s || '')
.toString()
.toLowerCase()
.trim()
.replace(/['"]/g, '')
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '')
.slice(0, 64)
}
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, headerAction) {
const head = el('div', { class: 'card-head' }, [
el('h3', null, title),
sub ? el('span', { class: 'sub' }, sub) : null,
// Optional right-aligned action element (e.g. "Preview buy page"
// button on the policies card).
headerAction ? el('span', { style: 'margin-left:auto' }, headerAction) : 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 || '' })
const editCatalog = catalogEditor(p.entitlements_catalog || null)
// 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,
// Entitlements catalog — pre-filled from the loaded product.
// Operator can edit/add/remove rows; submit sends the full
// current catalog (closed list semantics).
el('div', { style: 'margin-top:18px; padding-top:14px; border-top:1px dashed var(--border-1)' }, [
editCatalog.element,
]),
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),
}
// Always send the catalog on edit so the operator can
// also CLEAR it (empty editor → null → drops back to
// free-text mode). The double-Option PATCH shape on
// the server treats null as "set to NULL", absent as
// "leave alone".
body.entitlements_catalog = editCatalog.read()
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 createCatalog = catalogEditor(null)
const create = el('details', { class: 'disclosure' }, [
el('summary', null, 'Create a new product'),
el('div', { class: 'body' }, [
// Name first — the slug field auto-derives from this as the
// operator types, so they only fill in one of them in the
// common case. Help icons replace the verbose hint copy.
formInput('name', 'Display name', {
required: true,
help: 'What buyers see on the buy page (e.g. "Bitcoin Ticker Pro").',
}),
formInput('slug', 'Slug', {
required: true,
help: 'Stable URL part — buyers see this in /buy/<slug>. Auto-fills from the display name; edit if needed. Lowercase letters, digits, hyphens.',
}),
formInput('description', 'Description', {
textarea: true,
help: 'One paragraph shown under the product name on the buy page. Optional — leave blank if the tier cards say enough.',
}),
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,
// Entitlements catalog — closed list of slugs the product
// offers. Policies pick from this list. See catalogEditor().
el('div', { style: 'margin-top:18px; padding-top:14px; border-top:1px dashed var(--border-1)' }, [
createCatalog.element,
]),
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)
const catalog = createCatalog.read()
const 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: {},
}
if (catalog) body.entitlements_catalog = catalog
await api('/v1/admin/products', { method: 'POST', body })
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,
]))
// Auto-slug: as the operator types a Display Name, mirror a
// slugified version into the Slug field — UNLESS they've manually
// edited the slug. Tracked via a "userOverridden" flag set on
// the slug input's `input` event. Manual edits stick; clearing
// the slug back to "" re-arms the auto-fill.
const nameInput = create.querySelector('[name=name]')
const slugInput = create.querySelector('[name=slug]')
let slugUserOverridden = false
if (nameInput && slugInput) {
slugInput.addEventListener('input', () => {
slugUserOverridden = slugInput.value.trim().length > 0
})
nameInput.addEventListener('input', () => {
if (!slugUserOverridden) slugInput.value = slugify(nameInput.value)
})
}
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 = ''
// Show ALL policies but mark the current one as disabled with
// "(current)" suffix — operator sees what they're starting from
// but can't pick a no-op. Other policies become the actual
// change targets.
const opts = allPolicies.map((p) => {
const isCurrent = p.slug === currentPolicySlug
return {
value: p.slug,
disabled: isCurrent,
label: p.name + ' (' + p.slug + ')' +
(p.tier_rank != null ? ' · rank ' + p.tier_rank : '') +
(p.is_recurring ? ' · recurring' : '') +
(p.is_trial ? ' · trial' : '') +
(isCurrent ? ' · current' : ''),
}
})
const selectableOpts = opts.filter((o) => !o.disabled)
if (selectableOpts.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 SELECTABLE option (skip the disabled current-tier).
selectedTargetSlug = selectableOpts[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.
// Detect direction (upgrade vs downgrade) by comparing the
// current and target policies' price_sats_override (or rank
// when both ranked). Drives a "downgrade warning" banner so
// the operator sees what entitlements the buyer is about to
// lose. Cheap client-side compute; the server still
// re-validates.
const targetPol = allPolicies.find((p) => p.slug === selectedTargetSlug)
const currentPol = allPolicies.find((p) => p.slug === currentPolicySlug)
let isDowngrade = false
if (targetPol && currentPol) {
if (targetPol.tier_rank != null && currentPol.tier_rank != null) {
isDowngrade = targetPol.tier_rank < currentPol.tier_rank
} else {
const a = currentPol.price_sats_override != null ? currentPol.price_sats_override : 0
const b = targetPol.price_sats_override != null ? targetPol.price_sats_override : 0
isDowngrade = b < a
}
}
if (isDowngrade && targetPol && currentPol) {
// Build a list of entitlements the buyer is about to lose.
const losing = (currentPol.entitlements || []).filter(
(e) => !(targetPol.entitlements || []).includes(e),
)
const losingLine = losing.length > 0
? 'Buyer will LOSE these entitlements: ' + losing.join(', ') + '.'
: 'Buyer keeps the same entitlements.'
quoteHolder.appendChild(plainCard([
el('div', { style: 'color:#7a5814; font-weight:600; margin-bottom:6px' },
'⚠ Downgrade'),
el('p', { style: 'margin:0 0 8px; font-size:13px' },
'You\'re moving this license from ' + currentPol.name + ' → ' + targetPol.name + '. ' +
losingLine + ' The buyer will NOT be refunded automatically — handle any refund out of band.'),
]))
} else {
quoteHolder.appendChild(el('p', { class: 'muted', style: 'margin:0; font-size:13px' },
'Admin tier changes always apply as comp (no invoice, license flips immediately). ' +
'For paid upgrades, point the buyer at /buy/<slug> or have their app drive the SDK\'s in-app purchase flow.'))
}
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 reason = (card.querySelector('[name=change_tier_reason]').value || '').trim() || null
// Confirm on downgrades — operator should explicitly OK
// before the buyer loses entitlements.
if (isDowngrade && !confirm('Confirm downgrade? Buyer loses entitlements without refund.')) {
return
}
buttonRow.querySelectorAll('button').forEach((b) => b.disabled = true)
status.textContent = 'Applying…'
status.style.color = ''
try {
// Always apply as comp from the admin UI. Paid admin
// tier changes are admin-API-only (back-compat) — see
// KEYSAT_INTEGRATION.md re: SDK-driven buyer upgrades.
const r = await api('/v1/admin/licenses/' + license.id + '/change-tier', {
method: 'POST',
body: {
to_policy_slug: selectedTargetSlug,
skip_payment: true,
reason,
},
})
status.textContent = 'Applied — license is now on ' + r.to_policy_slug + '.'
setTimeout(() => overlay.remove(), 800)
} catch (e) {
status.textContent = e.message
status.style.color = 'var(--danger)'
buttonRow.querySelectorAll('button').forEach((b) => b.disabled = false)
}
},
}, 'Apply'))
} 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),
})
// Entitlements input: bubble picker against the product's catalog
// (closed-list mode) when one exists, else legacy free-text
// textarea. The picker pre-selects the policy's current
// entitlements; the textarea pre-fills with one slug per line.
const editCatalog_pol = (prod && prod.entitlements_catalog) || []
const entField = (() => {
const host = el('div', { 'data-ent-host': '1' })
if (editCatalog_pol.length > 0) {
const picker = entitlementBubblePicker(editCatalog_pol, pol.entitlements || [])
host.appendChild(picker.element)
host._read = picker.read
host._mode = 'bubbles'
} else {
const fallback = 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.',
})
host.appendChild(fallback)
host._mode = 'textarea'
}
return host
})()
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
// Read from whichever mode the entitlements host is in
// (bubble picker vs textarea fallback). _read is set by
// entitlementBubblePicker; absence = textarea.
const entHost = card.querySelector('[data-ent-host]')
let ents
if (entHost && entHost._mode === 'bubbles' && entHost._read) {
ents = entHost._read()
} else {
const rawEnts = card.querySelector('[name=e_pol_entitlements]').value || ''
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()
}
// ---------- Policy tier-card grid (Phase 2 of v0.2.0:9) ----------
//
// Replaces the older table-based render of policies with a card
// grid that mirrors the buy page's tier cards. Operators get a
// side-by-side visual comparison of their tiers + an inline
// "+ Add tier" card that morphs into an editable draft card on
// click — multiple drafts can exist simultaneously.
/**
* Format a price for display on a tier card. Returns
* `{ amount, unit }` so the card can render the amount in a
* larger font and the unit beside it.
*/
function fmtTierPrice(pol, product) {
if (product.price_currency === 'SAT' || !product.price_currency) {
const sats = pol.price_sats_override != null ? pol.price_sats_override : product.price_sats
return { amount: Number(sats || 0).toLocaleString('en-US'), unit: 'sats' }
}
const cents = pol.price_sats_override != null ? pol.price_sats_override : product.price_value
return { amount: ((cents || 0) / 100).toFixed(2), unit: product.price_currency }
}
function fmtCadenceSuffix(pol) {
if (!pol.is_recurring) return ''
const d = pol.renewal_period_days || 0
if (d === 7) return ' / wk'
if (d === 30) return ' / mo'
if (d === 90) return ' / qtr'
if (d === 180) return ' / 6mo'
if (d === 365) return ' / yr'
return d > 0 ? (' / ' + d + 'd') : ''
}
/**
* Read-only tier card showing an existing policy. Visual matches
* the buy page's tier cards; bottom action row exposes Edit /
* Hide-Show / Delete and the public-vs-private + active state.
*/
function renderTierCard(pol, product, onMutate) {
const meta = pol.metadata || {}
const highlighted = !!meta.highlight
const description = (typeof meta.description === 'string') ? meta.description : ''
const price = fmtTierPrice(pol, product)
const cadenceSuffix = fmtCadenceSuffix(pol)
const popularPill = highlighted
? el('div', {
style:
'position:absolute; top:-10px; left:50%; transform:translateX(-50%); ' +
'background:var(--gold-500); color:var(--navy-950); ' +
'font-family:var(--font-body); font-size:10px; font-weight:700; ' +
'letter-spacing:0.16em; text-transform:uppercase; ' +
'padding:3px 9px; border-radius:999px; white-space:nowrap;',
}, 'Most popular')
: null
const durationLine = pol.duration_seconds === 0
? 'Perpetual'
: (() => {
const days = Math.floor(pol.duration_seconds / 86400)
if (days >= 1) return days + ' days'
const hours = Math.floor(pol.duration_seconds / 3600)
return Math.max(1, hours) + ' hours'
})()
const recurringMeta = pol.is_recurring
? el('div', { class: 'muted', style: 'font-size:12px' },
(() => {
const d = pol.renewal_period_days || 0
if (d === 7) return 'Renews weekly'
if (d === 30) return 'Renews monthly'
if (d === 90) return 'Renews quarterly'
if (d === 180) return 'Renews semi-annually'
if (d === 365) return 'Renews annually'
return 'Renews every ' + d + ' days'
})())
: null
const trialBanner = (pol.is_recurring && (pol.trial_days || 0) > 0)
? el('div', { style: 'font-size:12px; color:var(--gold-700); font-weight:600' },
pol.trial_days + ' day free trial')
: null
// Entitlements as small chips with display name + tooltip.
const cat = product.entitlements_catalog || []
const entChips = (pol.entitlements || []).length === 0
? null
: el('ul', {
style: 'list-style:none; padding:0; margin:8px 0 0; font-size:12.5px; color:var(--ink-700)',
}, (pol.entitlements || []).map((slug) => {
const entry = cat.find((c) => c.slug === slug)
const display = entry && entry.name ? entry.name : slug
const desc = entry && entry.description ? entry.description : slug
return el('li', {
title: desc,
style: 'padding:2px 0 2px 16px; position:relative',
}, [
el('span', {
style: 'position:absolute; left:0; top:2px; color:var(--gold-700); font-weight:700',
}, '✓'),
display,
])
}))
// Status pills row (active vs disabled, public vs private, trial).
const pillsRow = el('div', {
style: 'display:flex; flex-wrap:wrap; gap:5px; margin:6px 0',
}, [
pol.active
? el('span', { class: 'badge b-success', style: 'font-size:10.5px; padding:2px 7px' }, 'active')
: el('span', { class: 'badge b-neutral', style: 'font-size:10.5px; padding:2px 7px' }, 'disabled'),
pol.public
? el('span', {
class: 'badge b-gold',
style: 'font-size:10.5px; padding:2px 7px',
title: 'Visible on /buy/' + product.slug + ' tier picker',
}, 'public')
: el('span', {
class: 'badge b-neutral',
style: 'font-size:10.5px; padding:2px 7px',
title: 'Hidden from public buy page; admin-only',
}, 'private'),
pol.is_trial
? el('span', { class: 'badge b-warning', style: 'font-size:10.5px; padding:2px 7px' }, 'trial flag')
: null,
pol.tier_rank != null
? el('span', {
class: 'badge b-neutral',
style: 'font-size:10.5px; padding:2px 7px',
title: 'Ladder rank (used by tier-upgrade flow)',
}, 'rank ' + pol.tier_rank)
: null,
].filter(Boolean))
// Action row.
const actions = el('div', {
style: 'display:flex; flex-wrap:wrap; gap:6px; margin-top:auto; padding-top:10px; border-top:1px solid var(--border-1)',
}, [
el('button', {
class: 'btn sm secondary',
onclick: () => openEditPolicy(pol, product),
}, 'Edit'),
el('button', {
class: 'btn sm secondary',
title: pol.public ? 'Hide from /buy/' + product.slug : 'Show on /buy/' + product.slug,
onclick: async () => {
try {
await api('/v1/admin/policies/' + pol.id + '/public', {
method: 'PATCH', body: { public: !pol.public },
})
onMutate && onMutate()
} catch (e) { alert(e.message) }
},
}, pol.public ? 'Hide' : 'Show'),
el('button', {
class: 'btn sm danger',
onclick: () => safeOrForceDelete({
kind: 'policy',
slug: pol.slug,
pathBase: '/v1/admin/policies/' + pol.id,
onSuccess: onMutate,
}),
}, 'Delete'),
])
return el('div', {
class: 'tier-card',
style:
'position:relative; background:var(--cream-50); ' +
'border:' + (highlighted ? '2px solid var(--gold-500)' : '1px solid var(--border-1)') + '; ' +
'border-radius:12px; padding:' + (highlighted ? '21px 19px 16px' : '22px 20px 16px') + '; ' +
'display:flex; flex-direction:column; gap:6px; min-height:280px;' +
(highlighted ? ' box-shadow:0 0 0 3px rgba(191,160,104,0.12);' : ''),
}, [
popularPill,
el('div', {
style: 'font-family:var(--font-display); font-weight:600; font-size:18px; color:var(--navy-950); letter-spacing:-0.01em',
}, pol.name),
el('div', {
class: 'muted',
style: 'font-family:var(--font-mono); font-size:11px',
}, pol.slug),
el('div', {
style: 'font-family:var(--font-display); font-weight:700; font-size:24px; color:var(--navy-950); letter-spacing:-0.02em; line-height:1.1; margin-top:6px',
}, [
price.amount,
el('span', {
style: 'font-family:var(--font-body); font-size:12px; font-weight:500; color:var(--ink-500); margin-left:6px',
}, price.unit + cadenceSuffix),
]),
el('div', { class: 'muted', style: 'font-size:12px' }, durationLine),
recurringMeta,
trialBanner,
pillsRow,
description ? el('p', {
style: 'font-size:13px; line-height:1.45; color:var(--ink-700); margin:6px 0 0',
}, description) : null,
entChips,
el('div', { class: 'muted', style: 'font-size:11px; margin-top:4px' },
(pol._license_count || 0) + ' license' + ((pol._license_count || 0) === 1 ? '' : 's')),
actions,
])
}
/**
* Empty placeholder card with a "+" affordance. On click, the
* caller should swap this card with a draft card via
* renderDraftTierCard.
*/
function renderAddTierCard(onClick) {
const card = el('button', {
type: 'button',
class: 'add-tier-card',
style:
'background:transparent; border:2px dashed var(--border-2); ' +
'border-radius:12px; padding:22px 20px; cursor:pointer; ' +
'min-height:280px; display:flex; flex-direction:column; ' +
'align-items:center; justify-content:center; gap:8px; ' +
'color:var(--ink-500); font-family:var(--font-body); ' +
'transition:all 120ms;',
}, [
el('div', { style: 'font-size:42px; line-height:1; font-weight:300' }, '+'),
el('div', { style: 'font-size:13px; font-weight:600' }, 'Add tier'),
el('div', { style: 'font-size:11px; max-width:160px; text-align:center' },
'Create a new policy on this product'),
])
card.addEventListener('mouseenter', () => {
card.style.borderColor = 'var(--gold-500)'
card.style.color = 'var(--navy-800)'
})
card.addEventListener('mouseleave', () => {
card.style.borderColor = 'var(--border-2)'
card.style.color = 'var(--ink-500)'
})
card.addEventListener('click', onClick)
return card
}
/**
* Inline-editable draft tier card. Same outer dimensions as
* renderTierCard so drafts sit visually side-by-side with
* existing policies. Form fields are compact: name + slug + price,
* a "More options" disclosure for the rest (duration, max
* devices, recurring, trial, tier rank), and the entitlements
* bubble picker against the product's catalog (or fallback
* textarea when no catalog).
*/
function renderDraftTierCard(product, onCommit, onCancel) {
// Compact inputs. Help icons replace per-field hint text to
// keep the card narrow.
const nameInput = el('input', {
class: 'input', placeholder: 'Display name (e.g. Pro)', required: 'required',
})
const slugInput = el('input', {
class: 'input mono', placeholder: 'slug',
})
let slugUserOverridden = false
slugInput.addEventListener('input', () => {
slugUserOverridden = slugInput.value.trim().length > 0
})
nameInput.addEventListener('input', () => {
if (!slugUserOverridden) slugInput.value = slugify(nameInput.value)
})
// Price override: defaults to product base price, displayed in
// the right unit. Operator can edit.
const isSat = (product.price_currency === 'SAT' || !product.price_currency)
const initialPrice = isSat
? String(product.price_sats || 0)
: (((product.price_value || 0) / 100).toFixed(2))
const priceInput = el('input', {
class: 'input', type: 'number',
step: isSat ? '1' : '0.01',
min: '0', value: initialPrice,
style: 'flex:1',
})
const priceUnit = el('span', {
class: 'muted',
style: 'font-size:12px; align-self:center',
}, isSat ? 'sats' : product.price_currency)
// Duration preset
const DURATION_PRESETS = [
{ value: '0', label: 'Perpetual' },
{ value: '604800', label: '7 days' },
{ value: '2592000', label: '30 days' },
{ value: '7776000', label: '90 days' },
{ value: '31536000', label: '1 year' },
]
const durationSel = el('select', { class: 'select' })
DURATION_PRESETS.forEach((p) => durationSel.appendChild(el('option', { value: p.value }, p.label)))
durationSel.value = '0'
const maxMachinesInput = el('input', {
class: 'input', type: 'number', min: '0', value: '1',
style: 'flex:1',
})
// Entitlements: bubble picker (closed list) when product has a
// catalog; legacy textarea otherwise.
const cat = product.entitlements_catalog || []
const entHost = el('div')
let entRead = () => []
if (cat.length > 0) {
const picker = entitlementBubblePicker(cat, [])
entHost.appendChild(picker.element)
entRead = picker.read
} else {
const textarea = el('textarea', {
class: 'input', rows: '2',
placeholder: 'comma-separated entitlement slugs',
})
entHost.appendChild(textarea)
entRead = () => textarea.value
.replace(/[\[\]"'`]/g, '')
.split(/[\n,]/).map((s) => s.trim()).filter(Boolean)
}
// Recurring + trial toggles
const recurringCb = el('input', { type: 'checkbox' })
const trialDaysInput = el('input', {
class: 'input', type: 'number', min: '0', value: '0', style: 'width:60px',
})
const renewalPeriodInput = el('input', {
class: 'input', type: 'number', min: '1', value: '30', style: 'width:60px',
})
// Tip recipient (advanced — collapsed by default to keep the
// card narrow).
const status = el('div', { style: 'font-size:12px; min-height:16px' }, '')
function fieldRow(label, helpText, control) {
return el('div', { style: 'display:flex; flex-direction:column; gap:3px; margin-top:8px' }, [
el('label', { class: 'lbl', style: 'display:flex; align-items:center; font-size:11.5px; margin:0' }, [
label,
helpText ? helpIcon(helpText) : null,
].filter(Boolean)),
control,
])
}
// Build the card body
const body = el('div', { style: 'display:flex; flex-direction:column; gap:0' }, [
el('div', { class: 'eyebrow', style: 'margin-bottom:6px' }, 'New tier'),
fieldRow('Display name', 'What buyers see (e.g. "Pro").', nameInput),
fieldRow('Slug', 'Stable id used by SDK; auto-fills from name. Lowercase, digits, hyphens.', slugInput),
fieldRow('Price', 'Override for this tier. Pre-filled with product base price.',
el('div', { style: 'display:flex; gap:6px' }, [priceInput, priceUnit])),
fieldRow('Duration', 'How long the issued license is valid. Perpetual = no expiry.', durationSel),
fieldRow('Max devices', '1 = single seat; 0 = unlimited; n = n-seat.', maxMachinesInput),
fieldRow('Entitlements', cat.length > 0
? 'Click to toggle. Defined on the product\'s catalog.'
: 'Comma-separated slugs. Define a product catalog for click-to-pick.',
entHost),
// Recurring section — minimal, expanded inline (no nested
// disclosure; cards already imply compactness).
el('div', { style: 'display:flex; align-items:center; gap:6px; margin-top:8px' }, [
recurringCb,
el('label', { class: 'lbl', style: 'margin:0; font-size:11.5px; display:flex; align-items:center' }, [
'Recurring subscription',
helpIcon('Bills the buyer on a repeating cycle. Pro tier required.'),
]),
]),
el('div', {
'data-recurring-detail': '1',
style: 'display:none; padding:8px 0 0 18px; border-left:2px solid var(--border-1); margin-left:6px',
}, [
el('div', { style: 'display:flex; gap:8px; align-items:center; font-size:11.5px' }, [
'Renew every',
renewalPeriodInput,
'days',
]),
el('div', { style: 'display:flex; gap:8px; align-items:center; font-size:11.5px; margin-top:6px' }, [
'Free trial',
trialDaysInput,
'days',
]),
]),
status,
el('div', { style: 'display:flex; gap:6px; margin-top:auto; padding-top:10px; border-top:1px solid var(--border-1)' }, [
(() => {
const btn = el('button', { class: 'btn sm primary' }, 'Create')
btn.addEventListener('click', async () => {
status.textContent = 'Creating…'
status.style.color = 'var(--ink-500)'
btn.disabled = true
try {
const isRecurring = recurringCb.checked
const body = {
product_slug: product.slug,
slug: slugInput.value.trim(),
name: nameInput.value.trim(),
duration_seconds: parseInt(durationSel.value, 10) || 0,
grace_seconds: 0,
max_machines: parseInt(maxMachinesInput.value, 10),
is_trial: false,
entitlements: Array.isArray(entRead()) ? entRead() : entRead(),
metadata: {},
price_sats_override: isSat
? Math.max(0, parseInt(priceInput.value, 10) || 0)
: Math.max(0, Math.round(parseFloat(priceInput.value) * 100) || 0),
}
if (isRecurring) {
body.is_recurring = true
body.renewal_period_days = parseInt(renewalPeriodInput.value, 10) || 30
body.grace_period_days = 7
body.trial_days = parseInt(trialDaysInput.value, 10) || 0
}
await api('/v1/admin/policies', { method: 'POST', body })
onCommit && onCommit()
} catch (e) {
if (handleTierCap(e)) {
status.textContent = ''
} else {
status.textContent = e.message
status.style.color = 'var(--danger)'
btn.disabled = false
}
}
})
return btn
})(),
(() => {
const btn = el('button', { class: 'btn sm secondary' }, 'Cancel')
btn.addEventListener('click', () => onCancel && onCancel())
return btn
})(),
]),
])
// Toggle the recurring detail block
recurringCb.addEventListener('change', () => {
const det = body.querySelector('[data-recurring-detail]')
if (det) det.style.display = recurringCb.checked ? 'block' : 'none'
})
return el('div', {
class: 'tier-card draft',
style:
'position:relative; background:#fff; ' +
'border:2px dashed var(--gold-500); border-radius:12px; ' +
'padding:18px 16px; min-height:280px; ' +
'box-shadow:0 0 0 1px rgba(191,160,104,0.06); ' +
'display:flex; flex-direction:column;',
}, [body])
}
/**
* The card grid for one product. Renders each existing policy
* as a tier card + an "+ Add tier" card on the right. Click to
* add → morphs into a draft card; multiple drafts can coexist.
*/
function renderPolicyCardGrid(product, policies, byPolicyCounts, onMutate) {
const grid = el('div', {
style:
'display:grid; grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); ' +
'gap:14px; margin-top:12px;',
})
policies.forEach((pol) => {
// Annotate with license count for the card footer.
pol._license_count = byPolicyCounts[pol.id] || 0
grid.appendChild(renderTierCard(pol, product, onMutate))
})
// Add-tier card. On click, the placeholder transforms into a
// draft card AND a fresh placeholder is appended so the
// operator can keep clicking "+ Add tier" to author multiple
// policies side-by-side. Named recursive function so each new
// placeholder reuses the same handler.
function makePlaceholder() {
const placeholder = renderAddTierCard(() => {
const draft = renderDraftTierCard(
product,
() => onMutate && onMutate(), // commit → reload (rebuilds grid)
() => grid.replaceChild(makePlaceholder(), draft), // cancel → swap back
)
grid.replaceChild(draft, placeholder)
grid.appendChild(makePlaceholder())
})
return placeholder
}
grid.appendChild(makePlaceholder())
return grid
}
// -------- 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]))
// Each product's entitlements catalog (migration 0014). Drives
// the closed-list bubble picker on the policy form. Empty / null
// catalog = legacy free-text textarea fallback.
const PRODUCT_CATALOG_BY_SLUG = Object.fromEntries(
products.map((p) => [p.slug, p.entitlements_catalog || []])
)
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 input — swaps based on product's catalog:
// - Closed list (catalog has entries): bubble multi-select
// - Legacy / no catalog: free-text textarea
// Rebuilt on product-change so the picker reflects the
// chosen product's catalog.
(() => {
const host = el('div', { 'data-ent-host': '1' })
const initial = PRODUCT_CATALOG_BY_SLUG[initialProductSlug] || []
if (initial.length > 0) {
const picker = entitlementBubblePicker(initial, [])
host.appendChild(picker.element)
host._read = picker.read
host._mode = 'bubbles'
} else {
const fallback = formInput('entitlements', 'Entitlements (one per line, or comma-separated)', {
textarea: true,
hint: 'Plain words. Examples: core, ai_summaries. Define them on the parent product\'s catalog to switch to a click-to-pick picker (recommended).',
})
host.appendChild(fallback)
host._mode = 'textarea'
}
return host
})(),
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: read either from the bubble picker
// (when the product has a catalog) or the legacy
// free-text textarea. _read is set on the host by
// entitlementBubblePicker; absence = textarea mode.
const entHost = create.querySelector('[data-ent-host]')
let ents = []
if (entHost && entHost._mode === 'bubbles' && entHost._read) {
ents = entHost._read()
} else {
const rawEnts = create.querySelector('[name=entitlements]').value || ''
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)
// Rebuild the entitlements picker to reflect the new product's
// catalog (bubbles vs textarea fallback).
const host = create.querySelector('[data-ent-host]')
if (host) {
host.innerHTML = ''
const cat = PRODUCT_CATALOG_BY_SLUG[newSlug] || []
if (cat.length > 0) {
const picker = entitlementBubblePicker(cat, [])
host.appendChild(picker.element)
host._read = picker.read
host._mode = 'bubbles'
} else {
const fallback = formInput('entitlements', 'Entitlements (one per line, or comma-separated)', {
textarea: true,
hint: 'Plain words. Examples: core, ai_summaries. Define them on the parent product\'s catalog to switch to a click-to-pick picker (recommended).',
})
host.appendChild(fallback)
host._mode = 'textarea'
}
}
})
// Intro card. The legacy "Create a new policy" disclosure form
// is no longer surfaced — the per-product card grid below has
// an inline "+ Add tier" affordance that authors policies in
// place, with multiple drafts allowed for side-by-side
// comparison. Advanced fields (tip recipient, custom grace
// seconds, tier rank) live on the Edit modal of an existing
// tier card; create the basics first, then click Edit.
target.appendChild(plainCard([
el('p', { class: 'muted', style: 'margin:0' },
'Reusable license templates per product. The policy slugged "default" is consumed by the public purchase flow; other slugs are used by manual license issuance. Click the dashed "+ Add tier" card under any product to author a new policy in place — multiple drafts can coexist for side-by-side comparison.'),
]))
// Intentionally not used: `create` (legacy disclosure-form
// create-policy flow). Kept around as dead code for one release
// so power users can re-enable by re-introducing the appendChild
// if the card-grid flow turns out to miss something. Removed
// entirely in v0.3.
void 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) || {}
// Render a raw seconds value as a human-readable duration. Common
// cadences map to nice labels (1 day, 1 week, 1 month, 1 year);
// arbitrary values fall back to the closest unit. 0 = perpetual.
function fmtDuration(secs) {
if (!secs || secs === 0) return 'perpetual'
const days = Math.round(secs / 86400)
if (secs < 60) return secs + 's'
if (secs < 3600) return Math.round(secs / 60) + 'min'
if (secs < 86400) return Math.round(secs / 3600) + 'h'
if (days === 1) return '1 day'
if (days === 7) return '1 week'
if (days === 30) return '1 month'
if (days === 90) return '3 months'
if (days === 180) return '6 months'
if (days === 365) return '1 year'
if (days === 730) return '2 years'
if (days % 365 === 0) return (days / 365) + ' years'
if (days % 30 === 0) return (days / 30) + ' months'
if (days % 7 === 0) return (days / 7) + ' weeks'
return days + ' days'
}
function fmtGrace(secs) {
if (!secs || secs === 0) return 'none'
return fmtDuration(secs)
}
for (const p of products) {
try {
const j = await api('/v1/admin/policies?product_slug=' + encodeURIComponent(p.slug))
const policies = j.policies || []
const hasPublicPolicy = policies.some((pol) => pol.public && pol.active)
const previewBtn = hasPublicPolicy
? el('a', {
class: 'btn sm secondary',
href: '/buy/' + encodeURIComponent(p.slug),
target: '_blank',
rel: 'noopener',
title: 'Open this product\'s public buy page in a new tab',
style: 'text-decoration:none',
}, 'Preview buy page')
: null
// Card grid replaces the older table — operators see tier
// cards that mirror the buy page layout, with a side-by-side
// "+ Add tier" affordance that morphs into an inline draft
// card on click. Multiple drafts can coexist for parallel
// multi-tier authoring.
const productCard = el('div', { class: 'card' }, [
el('div', { class: 'card-head' }, [
el('h3', null, p.name + ' — ' + p.slug),
el('span', { class: 'sub' },
policies.length + ' polic' + (policies.length === 1 ? 'y' : 'ies')),
previewBtn ? el('span', { style: 'margin-left:auto' }, previewBtn) : null,
]),
el('div', { class: 'card-body' }, [
renderPolicyCardGrid(p, policies, byPolicy, () => routes.policies()),
]),
])
target.appendChild(productCard)
} 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
// Display name primary, slug secondary (smaller + muted)
// so operators see what the buyer sees ("Pro") without
// losing the technical identifier they need for SDK calls.
? el('div', null, [
el('div', { style: 'font-weight:500; color:var(--navy-950)' },
l.policy_name || l.policy_slug),
l.policy_name
? el('div', { class: 'muted', style: 'font-size:11.5px; font-family:var(--font-mono)' }, l.policy_slug)
: null,
])
: 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)
// Label can carry an optional `help:` hover-tooltip via helpIcon
// (replaces the older verbose `hint:` block-text below the input
// for a more compact form layout). Both can coexist if a caller
// wants both, but help-icon-only is the recommended new pattern.
const labelChildren = [label, opts.required ? el('span', { class: 'req' }, '*') : null]
if (opts.help) labelChildren.push(helpIcon(opts.help))
const lbl = el('label', { class: 'lbl', for: id }, labelChildren)
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.placeholder) inp.setAttribute('placeholder', opts.placeholder)
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) {
// Per-option `disabled: true` lets callers grey-out specific
// entries — e.g. the Change Tier dropdown marks the current
// tier as disabled with "(current)" so operators see what
// they're starting from but can't pick a no-op.
const attrs = { value: o.value }
if (o.disabled) attrs.disabled = 'disabled'
sel.appendChild(el('option', attrs, 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),
])
}
/**
* Repeating-row editor for the product entitlements catalog
* (migration 0014). Operator declares the closed list of
* {slug, name, description} the product offers, then policies
* pick from this list rather than free-typing entitlement strings.
*
* Usage:
* const editor = catalogEditor(initialCatalog) // array or null
* container.appendChild(editor.element)
* // later, on submit:
* const catalog = editor.read() // returns array of {slug, name, description}
* // or null when the operator left it empty
*
* Empty editor = null (caller can treat that as "leave field alone"
* or "clear catalog" depending on context). Whitespace-only slugs
* are dropped silently. Validation (lowercase, ASCII, unique) is
* server-side; the UI just shows a hint.
*/
function catalogEditor(initial) {
const rowsHost = el('div', { style: 'display:flex; flex-direction:column; gap:8px' })
const addRow = (slug, name, description) => {
const row = el('div', {
class: 'catalog-row',
style: 'display:grid; grid-template-columns: 1fr 1fr 1.6fr auto; gap:6px; align-items:flex-start',
}, [
el('input', {
class: 'input', placeholder: 'slug',
value: slug || '',
'data-field': 'slug',
}),
el('input', {
class: 'input', placeholder: 'Display name',
value: name || '',
'data-field': 'name',
}),
el('input', {
class: 'input', placeholder: 'Description (shown on buy page tooltip)',
value: description || '',
'data-field': 'description',
}),
(() => {
const btn = el('button', {
type: 'button',
class: 'btn sm danger',
title: 'Remove this entitlement',
style: 'padding:6px 10px',
}, '×')
btn.addEventListener('click', () => row.remove())
return btn
})(),
])
rowsHost.appendChild(row)
}
if (Array.isArray(initial) && initial.length > 0) {
initial.forEach((e) => addRow(e.slug, e.name, e.description))
}
const addBtn = el('button', {
type: 'button',
class: 'btn sm secondary',
style: 'margin-top:6px; align-self:flex-start',
}, '+ Add entitlement')
addBtn.addEventListener('click', () => addRow('', '', ''))
const wrap = el('div', { style: 'display:flex; flex-direction:column' }, [
el('div', { class: 'lbl', style: 'margin-bottom:4px' }, 'Entitlements catalog'),
el('div', { class: 'muted', style: 'font-size:12px; margin-bottom:8px' },
'Declare the entitlements this product offers (e.g. "core", "ai_summaries"). ' +
'Policies will pick from this list — buyers see the display name + description, ' +
'never the raw slug. Leave empty to use the legacy free-text mode (any string allowed).'),
rowsHost,
addBtn,
])
return {
element: wrap,
read: function () {
const out = []
rowsHost.querySelectorAll('.catalog-row').forEach((row) => {
const slug = row.querySelector('[data-field=slug]').value.trim()
if (!slug) return
const name = row.querySelector('[data-field=name]').value.trim()
const description = row.querySelector('[data-field=description]').value.trim()
out.push({ slug, name: name || slug, description })
})
return out.length > 0 ? out : null
},
}
}
/**
* Bubble multi-select for picking entitlements off a product's
* catalog. Renders one clickable pill per catalog entry; click to
* toggle selected state. Hover shows the description.
*
* Used in the policy create + edit forms when the parent product
* has a non-empty catalog (closed-list mode). When the catalog is
* empty, callers fall back to the legacy free-text textarea.
*
* const picker = entitlementBubblePicker(catalog, ['core', 'pro'])
* container.appendChild(picker.element)
* const slugs = picker.read() // -> ['core', 'pro']
*/
function entitlementBubblePicker(catalog, initialSelection) {
const selected = new Set(Array.isArray(initialSelection) ? initialSelection : [])
const host = el('div', {
style: 'display:flex; flex-wrap:wrap; gap:6px; margin-top:4px',
})
const pills = []
function renderPill(entry) {
const isSel = selected.has(entry.slug)
const pill = el('button', {
type: 'button',
title: entry.description || entry.slug,
'data-slug': entry.slug,
style:
'padding:6px 12px; border-radius:999px; cursor:pointer; ' +
'font-family:var(--font-body); font-size:13px; font-weight:500; ' +
'transition:all 100ms; ' +
(isSel
? 'background:var(--gold-500); color:var(--navy-950); border:1px solid var(--gold-500); '
: 'background:transparent; color:var(--ink-700); border:1px solid var(--border-2); '),
}, entry.name || entry.slug)
pill.addEventListener('click', () => {
if (selected.has(entry.slug)) {
selected.delete(entry.slug)
} else {
selected.add(entry.slug)
}
// Re-style only this pill rather than re-rendering the host.
const nowSel = selected.has(entry.slug)
pill.style.background = nowSel ? 'var(--gold-500)' : 'transparent'
pill.style.color = nowSel ? 'var(--navy-950)' : 'var(--ink-700)'
pill.style.borderColor = nowSel ? 'var(--gold-500)' : 'var(--border-2)'
})
pills.push(pill)
host.appendChild(pill)
}
;(catalog || []).forEach(renderPill)
const wrap = el('div', { class: 'field' }, [
el('label', { class: 'lbl' }, 'Entitlements'),
el('div', { class: 'muted', style: 'font-size:12px; margin-bottom:6px' },
'Click each entitlement this tier should grant. Defined on the parent product\'s catalog.'),
host,
])
return {
element: wrap,
read: () => Array.from(selected),
}
}
// ---------- 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>