Files
keysat/licensing-service/web/index.html
T
Grant 3d7cf166db v0.2.0:31 — Punchlist clear: cap pre-check, grandfather banner, webhooks empty state, help-icon overhaul
Four outstanding admin-UI items shipped:

- Cap-hit pre-check. Products + Discount Codes pages fetch
  /v1/admin/tier on render and inline a gold-bordered "Approaching
  cap" warning above the submit button when usage is at cap-1.
  Includes a direct upgrade link. The existing 402 modal still
  fires if the operator submits anyway.
- Grandfather banner. When usage > current tier cap (e.g. downgrade
  from Pro to Creator with 8 products under a 5-product cap), the
  relevant page renders a persistent banner explaining the
  grandfather state and that new creates are blocked until upgrade.
  The daemon enforcement was already correct; the UI was silent.
- Webhooks empty state. Replaced the bare "No webhooks registered."
  table with a centered CTA card: eyebrow, headline, 2-sentence
  explainer of what webhooks are good for, and a primary "Add your
  first webhook" button that opens the create disclosure + focuses
  the URL input. Mirrors the Machines empty state.
- Help-icon click-to-toggle. helpIcon() now renders a small
  outlined button that opens a navy popover anchored next to it on
  click. Click outside / Esc / click again closes. Focus + Enter /
  Space opens. Visually less prominent. Replaces the prior native
  title= hover tooltip. Single function used everywhere, so the
  refactor ripples across the whole admin.

Three reusable helpers added: loadTierStatus, capPreCheckCard,
grandfatherBanner.

UI-only. No schema, API, or SDK change.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 16:27:40 -05:00

6626 lines
298 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; }
}
/* Featured (launch special) pill toggle — used on the Discount Codes
create + edit forms. Click anywhere on the pill to flip the
underlying hidden checkbox. Off = muted; on = gold accent. Reads as
"this is a deliberate decision," not a passive checkbox. */
.featured-pill-toggle {
display:inline-flex; align-items:center; gap:10px;
padding:8px 14px;
background:var(--cream-100); color:var(--ink-700);
border:1px solid var(--border-2); border-radius:999px;
font-family:var(--font-body); font-size:13px;
cursor:pointer; transition:all 100ms;
}
.featured-pill-toggle:hover {
background:var(--cream-200); color:var(--navy-900);
}
.featured-pill-toggle > strong {
font-weight:600;
}
.featured-pill-toggle .state {
font-size:11px; font-weight:700; letter-spacing:0.1em;
text-transform:uppercase; padding:2px 8px; border-radius:999px;
background:var(--cream-50); color:var(--ink-500);
border:1px solid var(--border-1);
}
.featured-pill-toggle.on {
background:var(--gold-500); color:var(--navy-950);
border-color:var(--gold-500);
box-shadow:0 2px 6px rgba(191,160,104,0.25);
}
.featured-pill-toggle.on .state {
background:var(--navy-950); color:var(--gold-500);
border-color:var(--navy-950);
}
.featured-pill-toggle.on:hover {
background:var(--gold-400);
}
/* Tier-card drag affordance — cursor signals draggability on hover,
the dragging card visibly lifts, and the drop-target receives a
subtle outline so the operator sees where the card will land. */
.tier-card[draggable="true"]:hover { cursor: grab; }
.tier-card[draggable="true"]:active { cursor: grabbing; }
.tier-card.dragging {
opacity: 0.45;
transform: scale(0.98);
transition: transform 100ms;
}
</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>
<a class="nav" data-route="settings"><i data-lucide="settings"></i>Settings</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, licenseCount } = opts
const isPolicy = kind === 'policy'
const hasRefs = typeof licenseCount === 'number' && licenseCount > 0
let message, bullets
if (hasRefs) {
message = licenseCount + ' license' + (licenseCount === 1 ? '' : 's') +
' reference' + (licenseCount === 1 ? 's' : '') + ' this ' + kind +
'. This action cannot be undone.'
bullets = isPolicy
? [
'Revoked licenses + non-settled invoices are swept away by the safe-delete cascade — they hold no value once youre deleting the tier.',
'Live (non-revoked) licenses, settled invoices, or active subscriptions will block the safe-delete. Use Archive to hide the tier instead, revoke outstanding licenses first, or pick Force delete to wipe everything.',
]
: [
'Every policy, license, and invoice under this product will be affected.',
'The request will be refused at the server if there are live references. Youll get a force-delete option that cascades through them.',
]
} else {
message = 'This cannot be undone.'
bullets = null
}
const ok = await confirmModal({
eyebrow: 'Delete ' + kind,
title: 'Delete ' + kind + ' “' + slug + '”?',
message: message,
bullets: bullets,
confirmLabel: 'Delete',
confirmVariant: 'danger',
})
if (!ok) return
try {
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.
*/
// Click-to-toggle help icon. Replaces the prior native `title=` tooltip
// (hover-only, no keyboard, browser-styled) with a small inline button
// that opens a popover anchored next to itself. Click anywhere outside
// the popover (or the icon again) to dismiss. Keyboard: focus + Enter
// / Space toggles, Esc closes. Less visually prominent than before
// (smaller, outlined, lighter palette).
let _openHelpPopover = null
function closeHelpPopover() {
if (_openHelpPopover) {
_openHelpPopover.remove()
_openHelpPopover = null
}
}
document.addEventListener('click', (e) => {
if (!_openHelpPopover) return
if (_openHelpPopover.contains(e.target)) return
if (e.target.closest && e.target.closest('[data-help-icon]')) return
closeHelpPopover()
})
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape' && _openHelpPopover) closeHelpPopover()
})
function helpIcon(text) {
const btn = el('button', {
type: 'button',
'data-help-icon': '1',
'aria-label': text,
'aria-expanded': 'false',
style:
'display:inline-flex; align-items:center; justify-content:center; ' +
'width:13px; height:13px; border-radius:50%; ' +
'background:transparent; color:var(--ink-500); ' +
'border:1px solid var(--ink-300, var(--border-2)); ' +
'font-size:9px; font-weight:600; font-family:var(--font-body); ' +
'cursor:pointer; margin-left:5px; user-select:none; flex:none; padding:0; ' +
'line-height:1; vertical-align:middle;',
}, '?')
btn.addEventListener('click', (e) => {
e.preventDefault()
e.stopPropagation()
// Toggle: if this icon's popover is the one open, close it.
if (_openHelpPopover && _openHelpPopover._anchor === btn) {
closeHelpPopover()
btn.setAttribute('aria-expanded', 'false')
return
}
closeHelpPopover()
const pop = el('div', {
role: 'tooltip',
style:
'position:absolute; z-index:9000; max-width:280px; ' +
'background:var(--navy-950); color:var(--cream-50); ' +
'padding:8px 12px; border-radius:8px; ' +
'font-family:var(--font-body); font-size:12.5px; font-weight:400; ' +
'line-height:1.45; box-shadow:0 8px 24px rgba(14,31,51,0.20);',
}, text)
pop._anchor = btn
document.body.appendChild(pop)
// Position: anchor near the icon, then clamp to the viewport.
const r = btn.getBoundingClientRect()
const scrollY = window.scrollY || window.pageYOffset
const scrollX = window.scrollX || window.pageXOffset
// Append first so we can measure the popover's rendered size.
const pw = pop.offsetWidth
const ph = pop.offsetHeight
let left = r.left + scrollX + r.width / 2 - pw / 2
let top = r.bottom + scrollY + 6
// Clamp horizontally so the popover never escapes the viewport.
const vw = document.documentElement.clientWidth
left = Math.max(8 + scrollX, Math.min(left, scrollX + vw - pw - 8))
pop.style.left = left + 'px'
pop.style.top = top + 'px'
_openHelpPopover = pop
btn.setAttribute('aria-expanded', 'true')
})
return btn
}
/**
* Wraps a string in a clickable element that copies the FULL value
* to the clipboard on click and shows a brief "Copied" indicator.
* Used in the licenses + subscriptions tables for IDs that get
* truncated for display but are useful to grab in full.
*
* clickToCopy('a1b2c3d4-...', 'a1b2c3d4…')
* → <span title="Click to copy">a1b2c3d4…</span>
*/
function clickToCopy(fullValue, displayLabel) {
const span = el('code', {
class: 'click-to-copy',
title: 'Click to copy: ' + fullValue,
style:
'cursor:pointer; padding:2px 5px; border-radius:4px; ' +
'transition:background 100ms; user-select:none;',
}, displayLabel || fullValue)
span.addEventListener('click', async (e) => {
e.stopPropagation()
try {
await navigator.clipboard.writeText(fullValue)
const orig = span.textContent
span.textContent = '✓ copied'
span.style.background = 'var(--success-bg)'
setTimeout(() => {
span.textContent = orig
span.style.background = ''
}, 1100)
} catch (_) {
// Clipboard API can fail on insecure contexts; fall back to a
// brief style flash so at least click feedback is visible.
span.style.background = 'var(--warning-bg)'
setTimeout(() => { span.style.background = '' }, 600)
}
})
span.addEventListener('mouseenter', () => {
if (!span.textContent.startsWith('✓')) span.style.background = 'var(--cream-200)'
})
span.addEventListener('mouseleave', () => {
if (!span.textContent.startsWith('✓')) span.style.background = ''
})
return span
}
/**
* Render an RFC3339 timestamp as a human-relative string with the
* absolute value as a hover tooltip. "in 3 days" / "12 hours ago" /
* "just now". Falls back to the raw string if parsing fails.
*
* Useful for subscription `next_renewal_at`, license `issued_at` /
* `expires_at`, etc. — operators care about "is this happening
* soon?" more than the wall-clock value.
*/
function relativeDate(rfc3339, opts) {
opts = opts || {}
if (!rfc3339) return el('span', { class: 'muted' }, '')
const t = new Date(rfc3339)
if (isNaN(t.getTime())) return el('span', null, rfc3339)
const now = Date.now()
const diffMs = t.getTime() - now
const future = diffMs > 0
const absMs = Math.abs(diffMs)
const m = 60_000, h = 3_600_000, d = 86_400_000
let label
if (absMs < 30_000) label = 'just now'
else if (absMs < h) label = Math.round(absMs / m) + 'min'
else if (absMs < d) label = Math.round(absMs / h) + 'h'
else if (absMs < 30 * d) label = Math.round(absMs / d) + 'd'
else if (absMs < 365 * d) label = Math.round(absMs / (30 * d)) + 'mo'
else label = Math.round(absMs / (365 * d)) + 'y'
if (label !== 'just now') label = future ? ('in ' + label) : (label + ' ago')
return el('span', {
class: opts.muted === false ? '' : 'muted',
title: t.toLocaleString(),
style: opts.style || '',
}, label)
}
/**
* Inline reason-modal — replaces the jarring browser prompt() for
* cancel/suspend/revoke flows. Shows an overlay card with a
* textarea for the reason + Cancel / Confirm buttons. Returns a
* promise that resolves with the trimmed reason string (or null
* if the operator cancels).
*
* const reason = await reasonModal({
* title: 'Cancel subscription',
* message: 'License stays valid through end of cycle.',
* confirmLabel: 'Cancel subscription',
* confirmVariant: 'danger',
* })
* if (reason !== null) {
* await api(...)
* }
*/
function reasonModal(opts) {
opts = opts || {}
return new Promise((resolve) => {
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 textarea = el('textarea', {
class: 'input', rows: '3',
placeholder: opts.placeholder || 'Optional — recorded on the audit log.',
})
const status = el('div', {
class: 'muted', style: 'margin-top:6px; font-size:12.5px; min-height:16px',
}, '')
let resolved = false
function done(value) {
if (resolved) return
resolved = true
overlay.remove()
resolve(value)
}
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' }, opts.eyebrow || 'Confirm'),
el('h3', {
style: 'font-family:var(--font-display); font-weight:600; font-size:18px; margin:0 0 6px; color:var(--navy-950);',
}, opts.title || 'Are you sure?'),
opts.message ? el('p', { class: 'muted', style: 'margin:0 0 14px; font-size:13.5px' }, opts.message) : null,
opts.warning ? el('div', {
class: 'badge b-warning',
style: 'display:block; padding:8px 12px; margin-bottom:12px; font-size:12px',
}, opts.warning) : null,
el('label', { class: 'lbl', style: 'display:flex; align-items:center; gap:6px; font-size:12.5px; margin:6px 0 6px;' }, [
opts.reasonLabel || 'Reason (optional)',
helpIcon(opts.reasonHelp || 'Free-form note. Stored on the audit log; not user-visible.'),
]),
textarea,
status,
el('div', { style: 'display:flex; gap:10px; margin-top:14px;' }, [
(() => {
const btn = el('button', {
class: 'btn ' + (opts.confirmVariant === 'danger' ? 'danger' : 'primary'),
}, opts.confirmLabel || 'Confirm')
btn.addEventListener('click', () => done(textarea.value.trim()))
return btn
})(),
(() => {
const btn = el('button', { class: 'btn secondary' }, 'Cancel')
btn.addEventListener('click', () => done(null))
return btn
})(),
]),
])
overlay.appendChild(card)
overlay.addEventListener('click', (e) => { if (e.target === overlay) done(null) })
document.body.appendChild(overlay)
setTimeout(() => textarea.focus(), 0)
})
}
/**
* Branded replacement for window.confirm(). Returns a promise that
* resolves true on confirm, false on cancel / overlay-click / Esc.
*
* if (!await confirmModal({
* title: 'Delete policy?',
* message: 'This cannot be undone.',
* confirmLabel: 'Delete',
* confirmVariant: 'danger',
* })) return
*
* `bullets`: optional list of strings rendered as a small bullet list
* under the message — handy for spelling out side-effects.
*/
function confirmModal(opts) {
opts = opts || {}
return new Promise((resolve) => {
let resolved = false
function done(value) {
if (resolved) return
resolved = true
document.removeEventListener('keydown', onKey)
overlay.remove()
resolve(value)
}
function onKey(e) {
if (e.key === 'Escape') done(false)
else if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) done(true)
}
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 confirmBtn = el('button', {
class: 'btn ' + (opts.confirmVariant === 'danger' ? 'danger' : 'primary'),
}, opts.confirmLabel || 'Confirm')
confirmBtn.addEventListener('click', () => done(true))
const cancelBtn = el('button', { class: 'btn secondary' }, opts.cancelLabel || 'Cancel')
cancelBtn.addEventListener('click', () => done(false))
const bulletList = Array.isArray(opts.bullets) && opts.bullets.length
? el('ul', {
class: 'muted',
style: 'margin:0 0 14px 18px; padding:0; font-size:13px; line-height:1.55;',
}, opts.bullets.map((b) => el('li', null, b)))
: null
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' }, opts.eyebrow || 'Confirm'),
el('h3', {
style: 'font-family:var(--font-display); font-weight:600; font-size:18px; margin:0 0 6px; color:var(--navy-950);',
}, opts.title || 'Are you sure?'),
opts.message ? el('p', {
class: 'muted',
style: 'margin:0 0 ' + (bulletList ? '10px' : '14px') + '; font-size:13.5px; line-height:1.55;',
}, opts.message) : null,
bulletList,
opts.warning ? el('div', {
class: 'badge b-warning',
style: 'display:block; padding:8px 12px; margin-bottom:12px; font-size:12px',
}, opts.warning) : null,
el('div', { style: 'display:flex; gap:10px;' }, [confirmBtn, cancelBtn]),
])
overlay.appendChild(card)
overlay.addEventListener('click', (e) => { if (e.target === overlay) done(false) })
document.body.appendChild(overlay)
document.addEventListener('keydown', onKey)
setTimeout(() => confirmBtn.focus(), 0)
})
}
/** 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' },
settings: { title: 'Settings', crumb: 'System · Settings' },
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)
// 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 products source code 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')
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:13.5px; color:var(--ink-700); cursor:pointer; line-height:1.5'
}, [
checkbox,
el('span', null, 'Opt-in to send anonymous usage stats so Keysat can improve service and performance'),
])
const inlineRow = el('div', {
class: 'card',
style: 'background:var(--cream-100); border-style:dashed;'
}, [
el('div', {
class: 'card-body',
style: 'display:flex; justify-content:space-between; align-items:center; flex-wrap:wrap; gap:8px;',
}, [
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', {
class: 'card',
style: 'display:none; background:var(--cream-100); border-style:dashed; margin-top:10px;',
})
const detailsBody = el('div', { class: 'card-body' })
details.appendChild(detailsBody)
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',
})
detailsBody.appendChild(el('label', { style: 'display:block; font-size:11px; font-weight:600; margin-bottom:4px; text-transform:uppercase; letter-spacing:0.05em' }, 'Collector URL'))
detailsBody.appendChild(urlInput)
detailsBody.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.
detailsBody.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.'))
detailsBody.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 (!await confirmModal({
eyebrow: 'Reset analytics ID',
title: 'Wipe anonymous install_uuid?',
message: 'Future heartbeats (if you re-enable) will use a fresh one. The current ID will be unrecoverable.',
confirmLabel: 'Wipe ID',
confirmVariant: 'danger',
})) return
try {
await api('/v1/admin/community-analytics/reset', { method: 'POST' })
renderAnalyticsCard(host)
} catch (er) { alert(er.message) }
})
detailsBody.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:640px; 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 = ''
// Tier status (forced refresh so usage counts reflect any creates /
// deletes from the prior route). Used to render two surfaces:
// - Grandfather banner at top when product usage > cap.
// - Pre-check warning inside the create disclosure when at cap-1.
const tierStatus = await loadTierStatus({ forceRefresh: true })
const gfBanner = grandfatherBanner(tierStatus, 'products', 'products')
if (gfBanner) target.appendChild(gfBanner)
// 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,
]),
// Pre-check warning when the operator is at cap-1 (or already
// over) for products. Renders inline above the submit so they
// know what to expect before clicking.
capPreCheckCard(tierStatus, 'products', 'products'),
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,
licenseCount: byProduct[p.id] || 0,
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 && !await confirmModal({
eyebrow: 'Downgrade',
title: 'Confirm tier downgrade?',
message: 'The buyer loses entitlements immediately. No refund will be issued automatically — handle any refund out of band.',
confirmLabel: 'Apply downgrade',
confirmVariant: 'danger',
})) {
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 marketingBulletsInit = Array.isArray(meta.marketing_bullets)
? meta.marketing_bullets.join('\n')
: ''
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 initialHidden_pol = Array.isArray(meta.hidden_entitlements)
? meta.hidden_entitlements
: []
const entField = (() => {
const host = el('div', { 'data-ent-host': '1' })
if (editCatalog_pol.length > 0) {
const picker = entitlementBubblePicker(editCatalog_pol, pol.entitlements || [], initialHidden_pol)
host.appendChild(picker.element)
host._read = picker.read
host._readHidden = picker.readHidden
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')
const bulletsField = formInput('e_pol_bullets', 'Marketing bullets (optional)', {
textarea: true,
value: marketingBulletsInit,
hint: 'One per line. Renders as ✓ checkmarks on the tier card. Use for things like "Up to 5 products" or "BTCPay integration" that aren\'t real entitlements.',
})
// Marketing bullets position — "above" (default, prior behavior) or
// "below" the entitlement chips on the tier card. Pre-populated
// from existing metadata.marketing_bullets_position.
const bulletsPositionInit = meta.marketing_bullets_position === 'below' ? 'below' : 'above'
const bulletsPositionField = formSelect('e_pol_bullets_position', 'Bullets position on tier card', [
{ value: 'above', label: 'Above entitlements' },
{ value: 'below', label: 'Below entitlements' },
], { value: bulletsPositionInit })
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: 'Set by dragging tier cards in the grid. Override here only if you want a specific rank, or blank to remove this policy from the buyer-facing upgrade ladder entirely.',
})
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,
bulletsField,
bulletsPositionField,
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 newBullets = (card.querySelector('[name=e_pol_bullets]').value || '')
.split('\n').map((s) => s.trim()).filter(Boolean)
if (newBullets.length > 0) newMetadata.marketing_bullets = newBullets
else delete newMetadata.marketing_bullets
// Position: only persist when bullets exist AND the operator
// picked something other than the default ("above"). Keeps
// metadata clean for tiers that use defaults or have no bullets.
const newBulletsPos = card.querySelector('[name=e_pol_bullets_position]').value
if (newBullets.length > 0 && newBulletsPos === 'below') {
newMetadata.marketing_bullets_position = 'below'
} else {
delete newMetadata.marketing_bullets_position
}
// Per-chip "hide on buy page" toggles from the bubble picker.
// Only persisted when non-empty; the buy page + admin grid
// treat an absent field as "show everything".
const entHostNode = card.querySelector('[data-ent-host]')
const hiddenList = (entHostNode && entHostNode._readHidden)
? entHostNode._readHidden().filter((s) => ents.includes(s))
: []
if (hiddenList.length > 0) newMetadata.hidden_entitlements = hiddenList
else delete newMetadata.hidden_entitlements
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
// Marketing bullets — operator-controlled copy that renders as
// ✓ checkmarks above (default) or below the entitlement chips.
// Things like "Up to 5 products" or "BTCPay integration" that
// aren't real entitlement gates but are buyer-relevant.
const marketingBullets = Array.isArray((pol.metadata || {}).marketing_bullets)
? pol.metadata.marketing_bullets
: []
const bulletsBelow = (pol.metadata || {}).marketing_bullets_position === 'below'
// Tighten top-margin when marketing list follows entitlements
// (bulletsBelow=true) — entChips renders first with the normal
// 8px gap, marketingList trails directly under it.
const marketingList = marketingBullets.length === 0
? null
: el('ul', {
style: 'list-style:none; padding:0; margin:' + (bulletsBelow ? '2px' : '8px') + ' 0 0; font-size:12.5px; color:var(--ink-700)',
}, marketingBullets.map((b) => el('li', {
style: 'padding:2px 0 2px 16px; position:relative',
}, [
el('span', {
style: 'position:absolute; left:0; top:2px; color:var(--gold-700); font-weight:700',
}, '✓'),
b,
])))
// Entitlements as small chips with display name + tooltip.
const cat = product.entitlements_catalog || []
// Top-margin: tighten when this list follows another (marketing
// list rendered ABOVE), normal when it leads the section.
const entLeadsSection = !marketingList || bulletsBelow
// Per-chip "hide on buy page" list. The license still grants these,
// but the buy-page tier card renders them filtered out. Surface that
// here as muted strikethrough + a small "(hidden on buy)" hint so
// the operator can spot which chips don't appear to buyers.
const hiddenEnts = Array.isArray((pol.metadata || {}).hidden_entitlements)
? new Set(pol.metadata.hidden_entitlements)
: new Set()
const entChips = (pol.entitlements || []).length === 0
? null
: el('ul', {
style: 'list-style:none; padding:0; margin:' + (entLeadsSection ? '8px' : '2px') + ' 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
const isHidden = hiddenEnts.has(slug)
return el('li', {
title: isHidden
? desc + ' — Hidden from the buy page tier card (license still grants it).'
: desc,
style: 'padding:2px 0 2px 16px; position:relative' +
(isHidden ? '; opacity:0.5' : ''),
}, [
el('span', {
style: 'position:absolute; left:0; top:2px; color:var(--gold-700); font-weight:700',
}, '✓'),
display,
isHidden ? el('span', {
style: 'margin-left:6px; font-size:10.5px; color:var(--ink-500); ' +
'text-decoration:none; font-style:italic',
}, '(hidden on buy)') : null,
])
}))
const isArchived = !!pol.archived_at
// Status pills row (active vs disabled, public vs private, trial,
// archived).
const pillsRow = el('div', {
style: 'display:flex; flex-wrap:wrap; gap:5px; margin:6px 0',
}, [
isArchived
? el('span', {
class: 'badge b-neutral',
style: 'font-size:10.5px; padding:2px 7px; background:var(--ink-700); color:var(--cream-50)',
title: 'Hidden from admin grid + buy page. Existing licenses keep validating.',
}, 'archived')
: (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')),
!isArchived && 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')
: (!isArchived
? el('span', {
class: 'badge b-neutral',
style: 'font-size:10.5px; padding:2px 7px',
title: 'Hidden from public buy page; admin-only',
}, 'private')
: null),
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))
async function setArchived(archived) {
try {
await api('/v1/admin/policies/' + pol.id + '/archived', {
method: 'PATCH', body: { archived: archived },
})
onMutate && onMutate()
} catch (e) { alert(e.message) }
}
// Action row. Archived policies have a slimmed set: Unarchive +
// Delete. Live policies expose Edit / Hide-Show / Archive / Delete.
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)',
}, isArchived
? [
el('button', {
class: 'btn sm secondary',
title: 'Restore this tier — it becomes visible in the admin grid and on /buy/' + product.slug + ' (if public).',
onclick: () => setArchived(false),
}, 'Unarchive'),
el('button', {
class: 'btn sm danger',
onclick: () => safeOrForceDelete({
kind: 'policy',
slug: pol.slug,
pathBase: '/v1/admin/policies/' + pol.id,
licenseCount: pol._license_count || 0,
onSuccess: onMutate,
}),
}, 'Delete'),
]
: [
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 secondary',
title: 'Hide this tier from the admin grid + buy page. Existing licenses keep validating. Reversible.',
onclick: async () => {
if (!await confirmModal({
eyebrow: 'Archive tier',
title: 'Archive “' + pol.slug + '”?',
message: 'Reversible. Hides this tier from the admin grid and from /buy/' + product.slug + '.',
bullets: [
'Existing licenses keep validating — entitlements are signed into the key.',
'New purchases of this tier will be refused.',
'Active recurring subscriptions tied to this tier will stop renewing.',
],
confirmLabel: 'Archive',
confirmVariant: 'primary',
})) return
setArchived(true)
},
}, 'Archive'),
el('button', {
class: 'btn sm danger',
onclick: () => safeOrForceDelete({
kind: 'policy',
slug: pol.slug,
pathBase: '/v1/admin/policies/' + pol.id,
licenseCount: pol._license_count || 0,
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 && !isArchived ? ' box-shadow:0 0 0 3px rgba(191,160,104,0.12);' : '') +
(isArchived ? ' opacity:0.55; background:repeating-linear-gradient(135deg, var(--cream-50) 0 10px, rgba(14,31,51,0.025) 10px 20px);' : ''),
}, [
popularPill,
// Drag-handle affordance — shown only on non-archived (draggable)
// tiers. Subtle muted icon top-right; the cursor still flips to
// grab on hover anywhere on the card, this just makes the
// affordance discoverable without reading any intro text.
!isArchived
? el('div', {
style:
'position:absolute; top:8px; right:8px; ' +
'color:var(--ink-500); opacity:0.5; ' +
'display:flex; align-items:center; pointer-events:none;',
title: 'Drag to reorder tier ladder',
}, [
el('i', { 'data-lucide': 'grip-vertical', style: 'width:16px;height:16px' }),
])
: null,
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,
// Operator-controlled order: marketing bullets above (default)
// or below the entitlements (metadata.marketing_bullets_position).
bulletsBelow ? entChips : marketingList,
bulletsBelow ? marketingList : 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 + custom-days fallback for arbitrary durations.
// Selecting "Custom (days)" reveals a number input; on submit, the
// raw days value is multiplied to seconds before sending. Matches
// the Edit-policy modal which has the same pattern (but in raw
// seconds — days is friendlier for the draft create flow since
// common cadences are day-based).
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' },
{ value: 'custom', label: 'Custom (days)' },
]
const durationSel = el('select', { class: 'select' })
DURATION_PRESETS.forEach((p) => durationSel.appendChild(el('option', { value: p.value }, p.label)))
durationSel.value = '0'
const customDaysInput = el('input', {
class: 'input', type: 'number', min: '1', value: '14',
placeholder: 'days',
})
const customDaysWrap = el('div', {
style: 'display:none; margin-top:6px',
}, [customDaysInput])
durationSel.addEventListener('change', () => {
customDaysWrap.style.display = durationSel.value === 'custom' ? 'block' : 'none'
})
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 = () => []
let entReadHidden = () => []
if (cat.length > 0) {
const picker = entitlementBubblePicker(cat, [], [])
entHost.appendChild(picker.element)
entRead = picker.read
entReadHidden = picker.readHidden
} 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',
})
// "Most popular" highlight + free-form marketing bullets. Both
// write into metadata: metadata.highlight (boolean — drives the
// "Most popular" pill on the buy page tier card) and
// metadata.marketing_bullets (array of strings — extra ✓ bullets
// rendered above the entitlement bullets on the buy page card).
// Marketing bullets aren't enforced anywhere; they're operator-
// controlled copy for things like "5 active products" or "BTCPay
// integration" that don't map to a real entitlement gate.
const highlightCb = el('input', { type: 'checkbox' })
const bulletsTextarea = el('textarea', {
class: 'input', rows: '3',
placeholder: 'One bullet per line — e.g.\nUp to 5 products\nBTCPay integration\nWebhooks + audit log',
style: 'font-family:var(--font-body); font-size:12px; line-height:1.45;',
})
// Position: where the marketing bullets render relative to the
// entitlements list on the tier card. "above" matches the previous
// hardcoded behavior; "below" puts them after entitlements (useful
// when marketing bullets are general value-prop blurbs and the
// entitlements are the technical contract a buyer wants to see
// first).
const bulletsPositionSel = el('select', {
class: 'input',
style: 'font-size:12px; max-width:200px',
}, [
el('option', { value: 'above' }, 'Above entitlements'),
el('option', { value: 'below' }, 'Below entitlements'),
])
// 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. Pick "Custom (days)" to enter an arbitrary number.', el('div', null, [durationSel, customDaysWrap])),
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),
// "Most popular" toggle — drives the gold-pill anchored above
// the tier card on the buy page.
el('div', { style: 'display:flex; align-items:center; gap:6px; margin-top:8px' }, [
highlightCb,
el('label', { class: 'lbl', style: 'margin:0; font-size:11.5px; display:flex; align-items:center' }, [
'Mark as "Most popular"',
helpIcon('Renders a "Most popular" pill above this tier card on the buy page. Pick one tier per product.'),
]),
]),
// Free-form marketing bullets — operator-controlled copy that
// renders as additional ✓ checkmarks alongside the entitlement
// bullets. Position (above vs below entitlements) is operator-
// controlled per tier. Not enforced anywhere; pure marketing.
fieldRow('Marketing bullets',
'One per line. Buyer sees these as ✓ checkmarks on the tier card. Use for things like "Up to 5 products" or "BTCPay integration" that aren\'t real entitlements.',
bulletsTextarea),
fieldRow('Position on tier card',
'Where these bullets render relative to the entitlement chips.',
bulletsPositionSel),
// 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 durationSeconds = durationSel.value === 'custom'
? Math.max(1, parseInt(customDaysInput.value, 10) || 0) * 86400
: parseInt(durationSel.value, 10) || 0
const marketingBullets = bulletsTextarea.value
.split('\n')
.map((s) => s.trim())
.filter(Boolean)
const metadata = {}
if (highlightCb.checked) metadata.highlight = true
if (marketingBullets.length > 0) {
metadata.marketing_bullets = marketingBullets
// Only persist position when bullets exist — keeps
// metadata clean for tiers that don't use bullets.
// Default ("above") matches the previous hardcoded
// behavior, so we only write the field when it differs.
if (bulletsPositionSel.value === 'below') {
metadata.marketing_bullets_position = 'below'
}
}
// Hide-on-buy-page entitlement slugs from the bubble picker.
// Filter against the granted-set so we never persist stale
// hidden entries (de-selecting a chip clears its hidden
// state too, but defensive).
const grantedEnts = entRead()
const hiddenEnts = entReadHidden().filter((s) => grantedEnts.includes(s))
if (hiddenEnts.length > 0) metadata.hidden_entitlements = hiddenEnts
const body = {
product_slug: product.slug,
slug: slugInput.value.trim(),
name: nameInput.value.trim(),
duration_seconds: durationSeconds,
grace_seconds: 0,
max_machines: parseInt(maxMachinesInput.value, 10),
is_trial: false,
entitlements: Array.isArray(entRead()) ? entRead() : entRead(),
metadata: 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
}
const saved = await api('/v1/admin/policies', { method: 'POST', body })
onCommit && onCommit(saved)
} 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;',
})
// Sort by tier_rank ascending so the visual order matches the
// ladder. NULL/missing rank floats to the end — operator can drag
// it into place later, at which point the drop handler assigns
// ranks based on position.
const sorted = [...policies].sort((a, b) => {
const ar = a.tier_rank == null ? Infinity : a.tier_rank
const br = b.tier_rank == null ? Infinity : b.tier_rank
if (ar !== br) return ar - br
// Stable tiebreak: by created_at so the order doesn't jitter
// between renders when multiple tiers share a rank.
return (a.created_at || '').localeCompare(b.created_at || '')
})
sorted.forEach((pol) => {
// Annotate with license count for the card footer.
pol._license_count = byPolicyCounts[pol.id] || 0
const card = renderTierCard(pol, product, onMutate)
// Wire drag-and-drop reordering. Archived tiers are excluded
// — their position in the ladder is moot and dragging them
// would just create noise.
if (!pol.archived_at) {
card.draggable = true
card.dataset.policyId = pol.id
}
grid.appendChild(card)
})
// 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.
//
// On commit of any draft, the saved policy is passed back and we
// replace ONLY that draft's slot with a finalized tier card —
// other drafts (if the operator is authoring multiple in
// parallel) keep their in-progress input state untouched. The
// grid does NOT reload via onMutate() on commit, because a full
// reload would wipe sibling drafts.
function makePlaceholder() {
const placeholder = renderAddTierCard(() => {
const draft = renderDraftTierCard(
product,
(savedPolicy) => {
savedPolicy._license_count = 0
const newCard = renderTierCard(savedPolicy, product, onMutate)
if (!savedPolicy.archived_at) {
newCard.draggable = true
newCard.dataset.policyId = savedPolicy.id
}
grid.replaceChild(newCard, draft)
},
() => grid.replaceChild(makePlaceholder(), draft), // cancel → swap back
)
grid.replaceChild(draft, placeholder)
grid.appendChild(makePlaceholder())
})
return placeholder
}
grid.appendChild(makePlaceholder())
wireTierGridDragAndDrop(grid, onMutate)
return grid
}
/**
* Drag-and-drop reordering for the tier-card grid. Operator drags
* any tier card to a new position; on drop we recompute tier_rank
* based on each card's new index and PATCH the affected policies
* in parallel. Archived tiers aren't draggable (no `draggable`
* attribute) and the "+ Add tier" placeholder is naturally skipped
* by the `.tier-card[data-policy-id]` selector.
*/
function wireTierGridDragAndDrop(grid, onMutate) {
grid.addEventListener('dragstart', (e) => {
const card = e.target.closest('.tier-card[data-policy-id]')
if (!card) return
card.classList.add('dragging')
card.style.opacity = '0.45'
e.dataTransfer.effectAllowed = 'move'
// Some browsers require setData to enable drag.
try { e.dataTransfer.setData('text/plain', card.dataset.policyId) } catch (_) {}
})
grid.addEventListener('dragend', (e) => {
const card = e.target.closest('.tier-card[data-policy-id]')
if (card) {
card.classList.remove('dragging')
card.style.opacity = ''
}
})
grid.addEventListener('dragover', (e) => {
const dragging = grid.querySelector('.tier-card.dragging')
if (!dragging) return
e.preventDefault()
e.dataTransfer.dropEffect = 'move'
// Insertion target: nearest tier card under the cursor.
const target = e.target.closest('.tier-card[data-policy-id]')
if (!target || target === dragging) return
const rect = target.getBoundingClientRect()
// Within a row of cards, compare X to the target's horizontal
// midpoint to decide "before" or "after". For multi-row grids
// this still feels natural because dragging across rows moves
// through cards one-by-one.
const after = e.clientX > rect.left + rect.width / 2
grid.insertBefore(dragging, after ? target.nextSibling : target)
})
grid.addEventListener('drop', async (e) => {
e.preventDefault()
const cards = Array.from(grid.querySelectorAll('.tier-card[data-policy-id]'))
// Reassign ranks 1..N based on new DOM order.
const updates = cards.map((c, i) => ({
id: c.dataset.policyId,
tier_rank: i + 1,
}))
try {
await Promise.all(updates.map((u) =>
api('/v1/admin/policies/' + u.id, {
method: 'PATCH',
body: { tier_rank: u.tier_rank },
})
))
// Reload from server so what's displayed matches what's
// persisted (also resolves any race with concurrent edits).
onMutate && onMutate()
} catch (err) {
alert('Failed to save new tier order: ' + (err.message || err))
onMutate && onMutate()
}
})
}
// -------- 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
}
// "Show archived" persists across navigations so the operator
// doesn't have to re-toggle on every visit.
const showArchived = localStorage.getItem('ks_show_archived_policies') === '1'
// 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.
const archivedToggle = el('input', {
type: 'checkbox',
id: 'showArchivedPolicies',
style: 'margin:0 6px 0 0; vertical-align:middle',
})
archivedToggle.checked = showArchived
archivedToggle.addEventListener('change', () => {
localStorage.setItem('ks_show_archived_policies', archivedToggle.checked ? '1' : '0')
routes.policies()
})
target.appendChild(el('div', {
style: 'display:flex; justify-content:flex-end; margin-bottom:14px',
}, [
el('label', {
for: 'showArchivedPolicies',
style: 'font-size:12.5px; color:var(--ink-700); white-space:nowrap; cursor:pointer',
}, [archivedToggle, 'Show archived']),
]))
// 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 archivedQuery = showArchived ? '&include_archived=true' : ''
const j = await api('/v1/admin/policies?product_slug=' + encodeURIComponent(p.slug) +
'&include_inactive=true' + archivedQuery)
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),
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. ',
helpIcon(
'Cancellation here is non-destructive — the license stays valid through ' +
'the end of the current cycle, the renewal worker just stops creating ' +
'new invoices. Lapses fire automatically when grace expires past a ' +
'past-due cycle.',
),
]),
]))
// Status copy with tooltip help.
const STATUSES = [
{ value: '', label: 'All', help: 'Every subscription regardless of state.' },
{ value: 'active', label: 'Active', help: 'Currently paid through the next renewal date.' },
{ value: 'past_due', label: 'Past due', help: 'Renewal invoice was created but not yet paid. Buyer\'s license is still valid through the grace window.' },
{ value: 'cancelled', label: 'Cancelled', help: 'Operator or buyer cancelled. License stays valid through the current cycle; renewal worker stops.' },
{ value: 'lapsed', label: 'Lapsed', help: 'Past-due window expired. License rejects validation; only re-purchase reactivates.' },
]
let currentStatusFilter = ''
let currentProductFilter = '' // empty = all products
let allSubs = [] // full list, refreshed on load
let products = [] // for product slug → name lookup
const productPillRow = el('div', { style: 'display:flex; gap:8px; flex-wrap:wrap; margin:14px 0 6px' })
const statusPillRow = el('div', { style: 'display:flex; gap:8px; flex-wrap:wrap; margin:6px 0 14px' })
function statusBadge(s) {
const klass = s === 'active' ? 'b-success'
: s === 'past_due' ? 'b-warning'
: s === 'cancelled' ? 'b-neutral'
: s === 'lapsed' ? 'b-danger' : 'b-neutral'
const help = (STATUSES.find((row) => row.value === s) || {}).help
return el('span', { class: 'badge ' + klass, title: help || s }, s)
}
function fmtCadence(periodDays) {
return periodDays === 7 ? 'weekly'
: periodDays === 30 ? 'monthly'
: periodDays === 90 ? 'quarterly'
: periodDays === 180 ? 'semi-annual'
: periodDays === 365 ? 'annual'
: 'every ' + periodDays + 'd'
}
function fmtPrice(s) {
if (s.listed_currency === 'SAT') {
return Number(s.listed_value).toLocaleString() + ' sats'
}
return (s.listed_value / 100).toFixed(2) + ' ' + s.listed_currency
}
function productNameForId(productId) {
const p = products.find((pr) => pr.id === productId)
return p ? p.name : null
}
function productSlugForId(productId) {
const p = products.find((pr) => pr.id === productId)
return p ? p.slug : null
}
function renderProductPills() {
productPillRow.innerHTML = ''
// Compute counts per product after applying the status filter
// (so the product pill counts reflect the active status view).
const statusFiltered = currentStatusFilter
? allSubs.filter((s) => s.status === currentStatusFilter)
: allSubs
const byProduct = {}
statusFiltered.forEach((s) => {
byProduct[s.product_id] = (byProduct[s.product_id] || 0) + 1
})
const allCount = statusFiltered.length
const pills = [{ id: '', label: 'All products', count: allCount }]
products.forEach((p) => {
pills.push({ id: p.id, label: p.name, count: byProduct[p.id] || 0 })
})
pills.forEach((opt) => {
const active = opt.id === currentProductFilter
const pill = el('button', {
class: 'btn sm ' + (active ? 'primary' : 'secondary'),
onclick: () => { currentProductFilter = opt.id; renderProductPills(); renderStatusPills(); render() },
}, [
opt.label,
opt.count > 0
? el('span', { style: 'margin-left:6px; opacity:0.7; font-weight:500' }, '(' + opt.count + ')')
: null,
])
productPillRow.appendChild(pill)
})
}
function renderStatusPills() {
statusPillRow.innerHTML = ''
// Counts per status, filtered to the chosen product.
const productFiltered = currentProductFilter
? allSubs.filter((s) => s.product_id === currentProductFilter)
: allSubs
const byStatus = {}
productFiltered.forEach((s) => { byStatus[s.status] = (byStatus[s.status] || 0) + 1 })
STATUSES.forEach((row) => {
const active = row.value === currentStatusFilter
const count = row.value ? (byStatus[row.value] || 0) : productFiltered.length
const pill = el('button', {
class: 'btn sm ' + (active ? 'primary' : 'secondary'),
title: row.help,
onclick: () => { currentStatusFilter = row.value; renderStatusPills(); renderProductPills(); render() },
}, [
row.label,
el('span', { style: 'margin-left:6px; opacity:0.7; font-weight:500' }, '(' + count + ')'),
])
statusPillRow.appendChild(pill)
})
}
target.appendChild(productPillRow)
target.appendChild(statusPillRow)
const tableHost = el('div')
target.appendChild(tableHost)
function buildTableForSubs(subs) {
if (subs.length === 0) {
return plainCard([
el('p', { class: 'muted', style: 'margin:0' },
currentStatusFilter || currentProductFilter
? '(no subscriptions match these filters)'
: 'No subscriptions yet — once a buyer purchases a recurring policy, they appear here.'),
])
}
const headers = [
'License', 'Product', 'Cadence', 'Listed price', 'Status',
'Next renewal', 'Failures', '',
]
const rows = subs.map((s) => el('tr', null, [
el('td', null, clickToCopy(s.license_id, s.license_id.slice(0, 8) + '…')),
el('td', null, productNameForId(s.product_id)
? el('div', null, [
el('div', { style: 'font-weight:500; color:var(--navy-950)' }, productNameForId(s.product_id)),
el('div', { class: 'muted', style: 'font-size:11px; font-family:var(--font-mono)' },
productSlugForId(s.product_id) || s.product_id.slice(0, 8) + '…'),
])
: el('span', { class: 'muted' }, '')),
el('td', null, fmtCadence(s.period_days)),
el('td', null, fmtPrice(s)),
el('td', null, statusBadge(s.status)),
el('td', null, s.next_renewal_at
? relativeDate(s.next_renewal_at, { muted: false })
: el('span', { class: 'muted' }, '')),
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 = await reasonModal({
eyebrow: 'Cancel subscription',
title: 'Cancel this subscription?',
message:
'The license stays valid through the end of the current cycle. ' +
'No new invoices will be created. The buyer can resubscribe later.',
confirmLabel: 'Cancel subscription',
confirmVariant: 'danger',
})
if (reason === null) return
try {
await api('/v1/admin/subscriptions/' + s.id + '/cancel', {
method: 'POST',
body: { reason: reason || null },
})
loadAll()
} catch (e) { alert(e.message) }
},
}, 'Cancel')
: el('span', { class: 'muted', style: 'font-size:12px' }, '')),
]))
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')
rows.forEach((r) => tb.appendChild(r))
t.appendChild(tb)
return t
}
function render() {
tableHost.innerHTML = ''
// Apply both filters.
let subs = allSubs
if (currentStatusFilter) subs = subs.filter((s) => s.status === currentStatusFilter)
if (currentProductFilter) subs = subs.filter((s) => s.product_id === currentProductFilter)
// If the operator has multiple products and isn't filtering to
// one specifically, group by product. Single-product instances
// get a flat table without the section chrome.
if (products.length <= 1 || currentProductFilter) {
const card = el('div', { class: 'card' }, [
el('div', { class: 'card-head' }, [
el('h3', null, currentProductFilter
? (productNameForId(currentProductFilter) || 'Subscriptions')
: 'Subscriptions'),
el('span', { class: 'sub' }, subs.length + ' subscription' + (subs.length === 1 ? '' : 's')),
]),
buildTableForSubs(subs),
])
tableHost.appendChild(card)
return
}
// Multi-product: group + collapse empty products.
const productsWithSubs = products
.map((p) => ({
product: p,
subs: subs.filter((s) => s.product_id === p.id),
}))
.filter((g) => g.subs.length > 0)
if (productsWithSubs.length === 0) {
tableHost.appendChild(plainCard([
el('p', { class: 'muted', style: 'margin:0' },
'(no subscriptions match these filters)'),
]))
return
}
productsWithSubs.forEach(({ product, subs: subList }) => {
// Per-product status breakdown for the section subtitle.
const breakdown = {}
subList.forEach((s) => { breakdown[s.status] = (breakdown[s.status] || 0) + 1 })
const breakdownTxt = Object.keys(breakdown).sort().map((k) => breakdown[k] + ' ' + k).join(' · ')
const card = el('div', { class: 'card', style: 'margin-bottom:14px' }, [
el('div', { class: 'card-head' }, [
el('h3', null, product.name + ' — ' + product.slug),
el('span', { class: 'sub' }, breakdownTxt),
]),
buildTableForSubs(subList),
])
tableHost.appendChild(card)
})
}
async function loadAll() {
tableHost.innerHTML = ''
tableHost.appendChild(plainCard([el('p', { class: 'muted', style: 'margin:0' }, 'Loading…')]))
try {
// Pull product list (for grouping + name lookup) and subs in parallel.
const [productsResp, subsResp] = await Promise.all([
api('/v1/products').catch(() => ({ products: [] })),
api('/v1/admin/subscriptions'),
])
products = productsResp.products || []
allSubs = subsResp.subscriptions || []
renderProductPills()
renderStatusPills()
render()
} catch (e) {
tableHost.innerHTML = ''
tableHost.appendChild(plainCard([err('Failed to load subscriptions: ' + e.message)]))
}
}
loadAll()
}
// -------- Discount codes --------
routes.codes = async function () {
const target = document.getElementById('route-target')
target.innerHTML = ''
// Tier status (forced refresh — usage may have changed via the
// prior route). `active_codes` is the metric the daemon enforces;
// grandfather banner + pre-check warning hang off it.
const codesTierStatus = await loadTierStatus({ forceRefresh: true })
const codesGfBanner = grandfatherBanner(codesTierStatus, 'active_codes', 'active discount codes')
if (codesGfBanner) target.appendChild(codesGfBanner)
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 ''
}
// Pre-fetch products so the create form's scope-picker dropdowns
// can populate. The codes table render below reuses the same fetch
// (`productsForCreate`) — see the Promise.all in the table block.
const productsForCreate = (await api('/v1/products').catch(() => ({ products: [] }))).products || []
// Product picker: "Any product" + one option per product.
const productOptions = [{ value: '', label: '— Any product —' }]
productsForCreate.forEach((p) => productOptions.push({ value: p.slug, label: p.name }))
const productScopeField = formSelect('product_slug', 'Restrict to product (optional)',
productOptions, { value: '' })
// Policy picker: multi-select pill picker populated on-demand when
// a product is selected. Operator picks zero or more policies:
// - 0 picked = code applies to any policy on the chosen product
// - 1 picked = code is single-policy scoped (mirrors v0.2.0:19)
// - 2+ picked = code applies to any of the picked policies
// Hidden when "Any product" is selected — a policy-scoped code
// requires a product scope per the server contract.
const policyMultiHost = el('div', {
'data-policy-pills': '1',
style: 'display:flex; flex-wrap:wrap; gap:6px; min-height:32px; ' +
'padding:6px 8px; border:1px solid var(--border-1); border-radius:8px; ' +
'background:var(--cream-50);',
}, [
el('span', { class: 'muted', style: 'font-size:12px; align-self:center' },
'Pick a product to choose policies.'),
])
const policyScopeField = el('div', { class: 'field' }, [
el('label', { class: 'lbl' }, 'Restrict to policies (optional)'),
policyMultiHost,
el('p', { class: 'muted', style: 'margin:4px 0 0; font-size:11.5px; line-height:1.4' },
'Click a tier to toggle. Pick 0 for "any policy on this product"; 2+ to scope the code to a specific subset (e.g. Patron AND Pro but not Creator).'),
])
// Stash the current set as a small reactive structure on the host.
// Read by the submit handler via `policyMultiHost._selected`.
policyMultiHost._selected = new Set()
policyMultiHost._available = []
function renderPolicyPills() {
policyMultiHost.innerHTML = ''
if (!policyMultiHost._available.length) {
policyMultiHost.appendChild(el('span', {
class: 'muted', style: 'font-size:12px; align-self:center',
}, 'Pick a product to choose policies.'))
return
}
policyMultiHost._available.forEach((p) => {
const on = policyMultiHost._selected.has(p.slug)
// Navy-filled when selected (matches the admin UI's
// "selected" / "on" convention). Cream-outlined otherwise.
const pill = el('button', {
type: 'button',
'data-policy-slug': p.slug,
style: 'font-size:13px; padding:6px 12px; border-radius:999px; cursor:pointer; ' +
'font-family:var(--font-body); font-weight:500; transition:all 100ms; ' +
(on
? 'background:var(--navy-800); color:var(--cream-50); border:1px solid var(--navy-800);'
: 'background:transparent; color:var(--ink-700); border:1px solid var(--border-2);'),
}, p.name)
pill.addEventListener('click', () => {
if (policyMultiHost._selected.has(p.slug)) policyMultiHost._selected.delete(p.slug)
else policyMultiHost._selected.add(p.slug)
renderPolicyPills()
})
policyMultiHost.appendChild(pill)
})
}
productScopeField.querySelector('select').addEventListener('change', async (e) => {
const slug = e.target.value
policyMultiHost._selected = new Set()
policyMultiHost._available = []
if (!slug) {
renderPolicyPills()
return
}
try {
const r = await api('/v1/admin/policies?product_slug=' + encodeURIComponent(slug) +
'&include_inactive=true')
policyMultiHost._available = (r.policies || []).map((p) => ({ slug: p.slug, name: p.name }))
} catch (_) {
// Silent: operator can still create a product-scoped code
// without picking policies if the policy fetch fails.
}
renderPolicyPills()
})
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' }),
]),
// Amount + currency. Currency is hidden when the kind doesn't
// use it (percent / free_license) — they're currency-agnostic.
// The kind-change listener below toggles `display` on the
// currency field's wrapper.
(() => {
const amountField = formInput('amount', 'Amount',
{ type: 'number', step: '1', value: '50', hint: amountHint('percent', 'SAT') })
const currencyField = formSelect('discount_currency', 'Currency', [
{ value: 'SAT', label: 'sats' },
{ value: 'USD', label: 'USD ($)' },
{ value: 'EUR', label: 'EUR (€)' },
], { value: 'SAT' })
// Stash a marker so the kind-change listener can find this row
// and toggle the currency column visibility.
currencyField.setAttribute('data-currency-field', '1')
return el('div', {
class: 'row-2',
'data-amount-row': '1',
}, [amountField, currencyField])
})(),
// Scope: product picker + dependent policy picker, side-by-side.
// Both default to "Any" — a code with no scope applies to every
// product on this instance.
el('div', { class: 'row-2' }, [productScopeField, policyScopeField]),
// Limits: "Limit total uses" checkbox + number input, paired
// with the expires-at picker. The number input is hidden when
// the checkbox is off (= unlimited). Clearer than the old
// "0 = unlimited" sentinel value.
(() => {
const limitCb = el('input', {
type: 'checkbox',
name: 'max_uses_enabled',
id: 'create_max_uses_cb',
})
const limitNum = el('input', {
class: 'input',
type: 'number',
min: '1',
value: '100',
name: 'max_uses',
style: 'display:none; max-width:120px',
})
limitCb.addEventListener('change', () => {
limitNum.style.display = limitCb.checked ? 'inline-block' : 'none'
if (limitCb.checked) limitNum.focus()
})
const limitWrap = el('div', { class: 'field' }, [
el('label', { class: 'lbl', for: 'create_max_uses_cb' }, 'Use cap'),
el('div', {
style: 'display:flex; align-items:center; gap:10px; flex-wrap:wrap',
}, [
el('label', {
for: 'create_max_uses_cb',
style: 'display:flex; align-items:center; gap:6px; cursor:pointer; font-size:13px',
}, [limitCb, 'Limit total uses']),
limitNum,
]),
])
const expiresField = formInput('expires_at', 'Expires at',
{ type: 'datetime-local', hint: 'Leave blank for no expiry.' })
return el('div', { class: 'row-2' }, [limitWrap, expiresField])
})(),
formInput('referrer_label', 'Referrer / campaign label (optional)'),
formInput('description', 'Description (internal note)', { textarea: true }),
// Featured pill toggle — more prominent than a checkbox so it
// doesn't get missed. Click anywhere on the pill toggles state.
// Hidden state still tracked via a checkbox so existing form
// logic (querySelector by name) keeps working.
(() => {
const hiddenCb = el('input', { type: 'checkbox', name: 'featured', style: 'display:none' })
const pill = el('button', {
type: 'button',
class: 'featured-pill-toggle',
onclick: () => {
hiddenCb.checked = !hiddenCb.checked
pill.classList.toggle('on', hiddenCb.checked)
pill.querySelector('[data-state]').textContent = hiddenCb.checked ? 'On' : 'Off'
},
}, [
el('span', null, '★'),
el('strong', null, 'Featured (launch special)'),
el('span', { 'data-state': '1', class: 'state' }, 'Off'),
])
return el('div', { style: 'margin-top:12px' }, [
pill,
hiddenCb,
el('p', { class: 'muted', style: 'margin:6px 0 0; font-size:12.5px; line-height:1.5' },
'When on: display on the buy page with a diagonal "LAUNCH SPECIAL" ribbon + the original price struck through. Auto-applies to buyers who don\'t type a code. Operator-typed codes still win when a buyer pastes one.'),
])
})(),
// Pre-check warning when the operator is at cap-1 (or already
// over) for active discount codes. Renders inline above the
// submit so they know what to expect before clicking.
capPreCheckCard(codesTierStatus, 'active_codes', 'active discount codes'),
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
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 || '',
}
// Max uses: only honor the number field if the "Limit total
// uses" checkbox is checked. Otherwise leave body.max_uses
// absent (server treats absent = unlimited).
const limitCb = create.querySelector('[name=max_uses_enabled]')
if (limitCb && limitCb.checked) {
const mu = parseInt(create.querySelector('[name=max_uses]').value, 10) || 0
if (mu > 0) body.max_uses = mu
}
// datetime-local returns "2026-12-31T23:59" (local time, no
// seconds, no timezone). Convert to RFC3339 by appending
// ":00Z" — that pins it to UTC. Operators expecting local
// time should use a future picker upgrade; for now this
// matches the server-side parser (chrono RFC3339).
const expRaw = create.querySelector('[name=expires_at]').value.trim()
if (expRaw) {
body.expires_at = expRaw.length === 16 ? (expRaw + ':00Z') : expRaw
}
const ps = create.querySelector('[name=product_slug]').value.trim()
if (ps) body.product_slug = ps
// Multi-policy scope: read from the pill host's _selected set.
// 0 picked = "any policy on the product" (omit field).
// 1 picked = send as singular policy_slug for legacy clarity.
// 2+ picked = send as policy_slugs array (server prefers this).
const pickedPolicySlugs = Array.from(policyMultiHost._selected)
if (pickedPolicySlugs.length === 1) {
body.policy_slug = pickedPolicySlugs[0]
} else if (pickedPolicySlugs.length > 1) {
body.policy_slugs = pickedPolicySlugs
}
const rl = create.querySelector('[name=referrer_label]').value.trim()
if (rl) body.referrer_label = rl
const featured = create.querySelector('[name=featured]').checked
if (featured) body.featured = true
await api('/v1/admin/discount-codes', { method: 'POST', body })
status.replaceWith(ok('Created. Reloading…'))
setTimeout(routes.codes, 600)
} catch (e) {
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. AND hide the
// currency dropdown when the kind doesn't use it (percent is
// currency-agnostic basis points; free_license has no amount).
const kindSelEl = create.querySelector('[name=kind]')
const curSelEl = create.querySelector('[name=discount_currency]')
const amtInputEl = create.querySelector('[name=amount]')
const currencyFieldEl = create.querySelector('[data-currency-field]')
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'
// Show currency only when the kind actually consumes it.
const usesCurrency = kindSelEl.value === 'fixed_sats' || kindSelEl.value === 'set_price'
if (currencyFieldEl) currencyFieldEl.style.display = usesCurrency ? '' : 'none'
}
if (kindSelEl) kindSelEl.addEventListener('change', updateHint)
if (curSelEl) curSelEl.addEventListener('change', updateHint)
// Initial render: kind defaults to 'percent', so hide currency.
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)
async function openEdit(c) {
editPanel.innerHTML = ''
editPanel.style.display = 'block'
// Product scope is read-only (changing product has weird semantics
// for historical redemptions; disable + recreate to re-product).
// Policy scope IS editable (0.2.0:22+) — load all policies on the
// existing product and present a pill multi-picker, pre-selected
// with the code's current allowed-policy set.
let productName = null
let productSlug = null
if (c.applies_to_product_id) {
const prod = productsForCreate.find((p) => p.id === c.applies_to_product_id)
productName = prod ? prod.name : c.applies_to_product_id
productSlug = prod ? prod.slug : null
}
const productLabel = productName
? 'Product: ' + productName + (productSlug ? ' (' + productSlug + ')' : '')
: 'Product: any (global code)'
// Pre-load policies for the product so the picker can render
// immediately. Skip when global (no product → no policies to pick).
let editPolicies = []
if (productSlug) {
try {
const r = await api('/v1/admin/policies?product_slug=' +
encodeURIComponent(productSlug) + '&include_inactive=true')
editPolicies = (r.policies || []).map((p) => ({
id: p.id, slug: p.slug, name: p.name,
}))
} catch (_) {}
}
// Multi-policy scope wins over the legacy singular when non-empty.
const editInitialIds = (() => {
if (Array.isArray(c.applies_to_policy_ids) && c.applies_to_policy_ids.length > 0) {
return new Set(c.applies_to_policy_ids)
}
if (c.applies_to_policy_id) return new Set([c.applies_to_policy_id])
return new Set()
})()
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)',
})
// Max uses — "Limit total uses" checkbox + dependent number
// input. Same UX as the create form. Pre-populated based on
// the code's current state.
const muField = (() => {
const hasLimit = c.max_uses != null
const cb = el('input', {
type: 'checkbox',
name: 'e_max_uses_enabled',
id: 'e_max_uses_cb',
})
if (hasLimit) cb.checked = true
const num = el('input', {
class: 'input',
type: 'number',
min: '1',
value: hasLimit ? String(c.max_uses) : '100',
name: 'e_max_uses',
style: (hasLimit ? '' : 'display:none; ') + 'max-width:120px',
})
cb.addEventListener('change', () => {
num.style.display = cb.checked ? 'inline-block' : 'none'
if (cb.checked) num.focus()
})
const hint = c.used_count > 0
? el('div', { class: 'hint', style: 'margin-top:4px' },
'Cannot go below current used_count (' + c.used_count + ').')
: null
return el('div', { class: 'field' }, [
el('label', { class: 'lbl', for: 'e_max_uses_cb' }, 'Use cap'),
el('div', { style: 'display:flex; align-items:center; gap:10px; flex-wrap:wrap' }, [
el('label', {
for: 'e_max_uses_cb',
style: 'display:flex; align-items:center; gap:6px; cursor:pointer; font-size:13px',
}, [cb, 'Limit total uses']),
num,
]),
hint,
])
})()
// Convert RFC3339 → datetime-local format (YYYY-MM-DDTHH:MM) so
// the native picker can render. On submit we reverse: append
// ":00Z" for UTC. Empty stays empty (= clear the field).
const expValueInit = (() => {
if (!c.expires_at) return ''
try {
const d = new Date(c.expires_at)
if (Number.isNaN(d.getTime())) return ''
return d.toISOString().slice(0, 16) // strip seconds + Z
} catch (_) { return '' }
})()
const expField = formInput('e_expires_at', 'Expires at', {
type: 'datetime-local',
value: expValueInit,
hint: 'Blank to clear.',
})
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 || '',
})
// Featured pill toggle — same shape as in Create. Pre-populated.
const featuredField = (() => {
const hiddenCb = el('input', { type: 'checkbox', name: 'e_featured', style: 'display:none' })
if (c.featured) hiddenCb.checked = true
const pill = el('button', {
type: 'button',
class: 'featured-pill-toggle' + (c.featured ? ' on' : ''),
onclick: () => {
hiddenCb.checked = !hiddenCb.checked
pill.classList.toggle('on', hiddenCb.checked)
pill.querySelector('[data-state]').textContent = hiddenCb.checked ? 'On' : 'Off'
},
}, [
el('span', null, '★'),
el('strong', null, 'Featured (launch special)'),
el('span', { 'data-state': '1', class: 'state' }, c.featured ? 'On' : 'Off'),
])
return el('div', { style: 'margin-top:12px' }, [
pill,
hiddenCb,
el('p', { class: 'muted', style: 'margin:6px 0 0; font-size:12.5px; line-height:1.5' },
'When on: display on the buy page with a diagonal "LAUNCH SPECIAL" ribbon + the original price struck through. Auto-applies to buyers who don\'t type a code. Operator-typed codes still win when a buyer pastes one.'),
])
})()
// Policy scope multi-pill picker. Hidden when the code is global
// (no product) since there are no policies to choose from.
const editPolicyHost = el('div', {
'data-edit-policy-pills': '1',
style: 'display:flex; flex-wrap:wrap; gap:6px; min-height:32px; ' +
'padding:6px 8px; border:1px solid var(--border-1); border-radius:8px; ' +
'background:var(--cream-50);',
})
editPolicyHost._selected = new Set(editInitialIds)
function renderEditPolicyPills() {
editPolicyHost.innerHTML = ''
if (!editPolicies.length) {
editPolicyHost.appendChild(el('span', {
class: 'muted', style: 'font-size:12px; align-self:center',
}, productSlug ? 'No policies on this product yet.' : 'Global code — no policies to pick.'))
return
}
editPolicies.forEach((p) => {
const on = editPolicyHost._selected.has(p.id)
// Navy-filled when selected (matches the admin UI's
// "selected" / "on" convention). Cream-outlined otherwise.
const pill = el('button', {
type: 'button',
'data-policy-id': p.id,
style: 'font-size:13px; padding:6px 12px; border-radius:999px; cursor:pointer; ' +
'font-family:var(--font-body); font-weight:500; transition:all 100ms; ' +
(on
? 'background:var(--navy-800); color:var(--cream-50); border:1px solid var(--navy-800);'
: 'background:transparent; color:var(--ink-700); border:1px solid var(--border-2);'),
}, p.name)
pill.addEventListener('click', () => {
if (editPolicyHost._selected.has(p.id)) editPolicyHost._selected.delete(p.id)
else editPolicyHost._selected.add(p.id)
renderEditPolicyPills()
})
editPolicyHost.appendChild(pill)
})
}
renderEditPolicyPills()
const policyScopeFieldEdit = productSlug ? el('div', { class: 'field' }, [
el('label', { class: 'lbl' }, 'Restrict to policies (optional)'),
editPolicyHost,
el('p', { class: 'muted', style: 'margin:4px 0 0; font-size:11.5px; line-height:1.4' },
'Click a tier to toggle. Pick 0 for "any policy on this product"; 2+ to scope to a subset.'),
]) : null
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
}
}
// Max uses: only honor the number field if the "Limit total
// uses" checkbox is checked. Otherwise send null (unlimited).
const limitCb = editPanel.querySelector('[name=e_max_uses_enabled]')
if (limitCb && limitCb.checked) {
const muRaw = parseInt(editPanel.querySelector('[name=e_max_uses]').value, 10) || 0
body.max_uses = muRaw > 0 ? muRaw : null
} else {
body.max_uses = null
}
// datetime-local → RFC3339 by appending ":00Z" for UTC.
// Empty → null (clear the field).
const expRaw = editPanel.querySelector('[name=e_expires_at]').value.trim()
body.expires_at = expRaw === '' ? null
: (expRaw.length === 16 ? (expRaw + ':00Z') : 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 || ''
body.featured = editPanel.querySelector('[name=e_featured]').checked
// Policy scope (editable since v0.2.0:22). Only sent when the
// code is scoped to a product — global codes have no policy
// picker. Send the selected ids' slugs as an array (server
// accepts 0/1/N and normalizes both singular + multi columns).
if (productSlug) {
const pickedIds = Array.from(editPolicyHost._selected)
const pickedSlugs = pickedIds.map((pid) => {
const found = editPolicies.find((p) => p.id === pid)
return found ? found.slug : null
}).filter(Boolean)
body.policy_slugs = pickedSlugs
}
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:8px' }, [
el('strong', null, 'Editing code '),
el('code', { style: 'font-size:14px' }, c.code),
]),
// Product scope is read-only (changing product has weird semantics
// for historical redemptions). Policy scope is editable via the
// pill picker below.
el('div', {
style:
'padding:8px 12px; margin-bottom:12px; ' +
'background:var(--cream-100); border-radius:6px; ' +
'font-size:12.5px; color:var(--ink-700);',
}, productLabel),
el('p', { class: 'muted', style: 'margin:0 0 16px; font-size:13px' },
'The code string, kind, and product cannot be changed — disable + create a new code instead. Everything else (amount, max uses, expiry, label, description, featured flag, and which policies the code applies to) is editable.'),
el('div', { class: 'row-2' }, [amtField, muField]),
el('div', { class: 'row-2' }, [expField, refField]),
descField,
policyScopeFieldEdit,
featuredField,
el('div', { style: 'margin-top:8px' }, [saveBtn, cancelBtn]),
]))
editPanel.scrollIntoView({ behavior: 'smooth', block: 'start' })
}
try {
// Reuse the products list we fetched above for the create form's
// scope pickers — no need to re-fetch.
const products = productsForCreate
const codesResp = await api('/v1/admin/discount-codes?include_inactive=true')
const codes = codesResp.codes || []
const productById = {}
products.forEach((p) => { productById[p.id] = p })
// Build a row for one code. Same render whether the code is in
// a per-product section or the "Global" section.
function codeRow(c) {
// Currency-aware amount rendering (unchanged).
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),
c.featured ? el('span', {
class: 'badge b-gold',
style: 'margin-left:8px; font-size:10px; padding:2px 6px; letter-spacing:0.05em',
title: 'Public launch-special — auto-applies on the buy page',
}, 'featured') : null,
]),
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 bullets = c.used_count > 0
? ['This code has been redeemed ' + c.used_count + ' time(s). The delete will be refused (audit trail). Use Disable instead.']
: null
if (!await confirmModal({
eyebrow: 'Delete discount code',
title: 'Delete code “' + c.code + '”?',
message: 'This cannot be undone.',
bullets: bullets,
confirmLabel: 'Delete',
confirmVariant: 'danger',
})) return
try {
await api('/v1/admin/discount-codes/' + c.id, { method: 'DELETE' })
routes.codes()
} catch (e) { alert(e.message) }
},
}, 'Delete'),
])),
])
}
// Group codes by product. Codes without a product (applies_to_
// product_id null) land in the "Global" bucket — they apply to
// every product on this instance.
const grouped = new Map() // product_id -> codes[]
const globalCodes = []
codes.forEach((c) => {
if (c.applies_to_product_id && productById[c.applies_to_product_id]) {
if (!grouped.has(c.applies_to_product_id)) {
grouped.set(c.applies_to_product_id, [])
}
grouped.get(c.applies_to_product_id).push(c)
} else {
globalCodes.push(c)
}
})
// Single-product instances: flat table with no grouping noise.
// Multi-product instances OR any global codes: render one card
// per group, matching the Licenses + Subscriptions tab pattern.
const useGrouping = products.length > 1 || globalCodes.length > 0
if (!useGrouping) {
target.appendChild(tableCard(
'All codes',
codes.length + ' total',
['Code', 'Kind', 'Amount', 'Used', 'Expires', 'Status', ''],
codes.map(codeRow),
'No codes yet.'
))
} else {
// Per-product sections, sorted: products with codes first
// (preserving the product list order), Global section last.
products.forEach((p) => {
const list = grouped.get(p.id)
if (!list || list.length === 0) return
const featuredCount = list.filter((c) => c.featured).length
const activeCount = list.filter((c) => c.active).length
const breakdown =
list.length + ' code' + (list.length === 1 ? '' : 's') +
' · ' + activeCount + ' active' +
(featuredCount > 0 ? ' · ' + featuredCount + ' featured' : '')
const card = el('div', { class: 'card', style: 'margin-bottom:14px' }, [
el('div', { class: 'card-head' }, [
el('h3', null, p.name),
el('span', { class: 'sub' }, breakdown),
]),
tableCard('', null,
['Code', 'Kind', 'Amount', 'Used', 'Expires', 'Status', ''],
list.map(codeRow), '(none)'),
])
target.appendChild(card)
})
if (globalCodes.length > 0) {
const featuredCount = globalCodes.filter((c) => c.featured).length
const activeCount = globalCodes.filter((c) => c.active).length
const breakdown =
globalCodes.length + ' code' + (globalCodes.length === 1 ? '' : 's') +
' · ' + activeCount + ' active' +
(featuredCount > 0 ? ' · ' + featuredCount + ' featured' : '')
const card = el('div', { class: 'card', style: 'margin-bottom:14px' }, [
el('div', { class: 'card-head' }, [
el('h3', null, 'All products (global)'),
el('span', { class: 'sub' }, breakdown),
]),
tableCard('', null,
['Code', 'Kind', 'Amount', 'Used', 'Expires', 'Status', ''],
globalCodes.map(codeRow), '(none)'),
])
target.appendChild(card)
}
if (codes.length === 0) {
target.appendChild(plainCard([
el('p', { class: 'muted', style: 'margin:0' },
'No codes yet — use the "Create a new code" form above.'),
]))
}
}
} catch (e) {
target.appendChild(plainCard([err(e.message)]))
}
}
// -------- Licenses --------
routes.licenses = async function () {
const target = document.getElementById('route-target')
target.innerHTML = ''
// ---- Search row ----
const queryInput = el('input', {
class: 'input', type: 'text',
placeholder: 'email or invoice id (leave blank for recent)',
})
// npub search is wired on the backend but the purchase flow doesn't
// capture a buyer npub yet, so there are no npub rows to find. Hide
// the option from the UI until that part of the flow ships.
const fieldSel = el('select', { class: 'select' }, [
el('option', { value: 'email' }, 'Email'),
el('option', { value: 'invoice' }, 'BTCPay invoice id'),
])
// ---- State for filters + grouping ----
let products = [] // for product-name lookup + grouping
let allLicenses = [] // last fetched set
let currentProductFilter = '' // empty = all products
let hideRevoked = false // toggle: hide revoked rows from table (stats unaffected)
let lastQuery = ''
let lastQueryField = 'email'
const productPillRow = el('div', {
style: 'display:flex; gap:8px; flex-wrap:wrap; margin:14px 0 4px',
})
// Status-filter row: a single "Hide revoked" toggle for now. Stats
// cards below still show the revoked count so operators don't lose
// visibility — this only filters which rows render in the table.
const statusFilterRow = el('div', {
style: 'display:flex; gap:8px; flex-wrap:wrap; margin:0 0 14px; ' +
'font-size:12px; color:var(--ink-500);',
})
const hideRevokedBtn = el('button', {
type: 'button',
style: 'font-size:12px; padding:4px 12px; border-radius:999px; cursor:pointer; ' +
'font-family:var(--font-body); font-weight:500; transition:all 100ms; ' +
'background:transparent; color:var(--ink-700); border:1px solid var(--border-2);',
}, 'Hide revoked')
hideRevokedBtn.addEventListener('click', () => {
hideRevoked = !hideRevoked
if (hideRevoked) {
hideRevokedBtn.style.background = 'var(--navy-800)'
hideRevokedBtn.style.color = 'var(--cream-50)'
hideRevokedBtn.style.borderColor = 'var(--navy-800)'
} else {
hideRevokedBtn.style.background = 'transparent'
hideRevokedBtn.style.color = 'var(--ink-700)'
hideRevokedBtn.style.borderColor = 'var(--border-2)'
}
render()
})
statusFilterRow.appendChild(hideRevokedBtn)
const statsRow = el('div', { class: 'stats', style: 'margin:0 0 14px' })
const tableHolder = el('div')
function entitlementsCell(ents, productCatalog) {
if (!ents || ents.length === 0) {
return el('span', { class: 'muted' }, '')
}
const cat = productCatalog || []
const wrap = el('div', { style: 'display:flex; flex-wrap:wrap; gap:4px;' })
ents.forEach((slug) => {
const entry = cat.find((c) => c.slug === slug)
const display = entry && entry.name ? entry.name : slug
const help = entry && entry.description ? entry.description : slug
wrap.appendChild(el('span', {
class: 'badge',
style:
'font-size:10.5px; padding:2px 7px; background:var(--cream-200); ' +
'color:var(--ink-700); font-weight:500;',
title: help,
}, display))
})
return wrap
}
function productById(id) {
return products.find((p) => p.id === id)
}
function productCatalogFor(license) {
const p = productById(license.product_id)
return (p && p.entitlements_catalog) || []
}
async function actLicense(l, op) {
// Suspend / unsuspend / revoke. Revoke is irreversible — the
// confirmation modal makes that obvious + collects an audit
// reason in one step (replaces the older confirm() + prompt()
// double-dialog flow).
const opts = {
suspend: {
eyebrow: 'Suspend license',
title: 'Suspend this license?',
message:
'The buyer\'s app will fail validation immediately ("suspended"). ' +
'Reversible — you can unsuspend later.',
confirmLabel: 'Suspend',
confirmVariant: 'danger',
},
unsuspend: {
eyebrow: 'Unsuspend license',
title: 'Unsuspend this license?',
message: 'License returns to active. Buyer\'s validate calls succeed again.',
confirmLabel: 'Unsuspend',
confirmVariant: 'primary',
},
revoke: {
eyebrow: 'Revoke license',
title: 'Revoke this license?',
message:
'Irreversible. Validate calls return "revoked" forever. ' +
'Use Suspend instead if you want a reversible block.',
warning: 'This action cannot be undone.',
confirmLabel: 'Revoke',
confirmVariant: 'danger',
},
}[op]
const reason = await reasonModal(opts)
if (reason === null) return
try {
await api('/v1/admin/licenses/' + l.id + '/' + op, {
method: 'POST', body: { reason: reason || '' },
})
loadLicenses()
} catch (e) { alert(e.message) }
}
function buildRowsFor(licenses) {
return licenses.map((l) => el('tr', null, [
el('td', null, clickToCopy(l.id, shortId(l.id))),
el('td', null, l.product_slug
? el('code', { title: l.product_id }, l.product_slug)
: el('span', { class: 'muted' }, shortId(l.product_id))),
el('td', null, l.policy_slug
? 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, productCatalogFor(l))),
el('td', null, statusBadge(l.status)),
el('td', null, relativeDate(l.issued_at)),
el('td', null, l.expires_at
? relativeDate(l.expires_at, { muted: false })
: 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,
el('a', {
class: 'btn sm secondary',
href: '#machines?license=' + encodeURIComponent(l.id),
onclick: (e) => {
e.preventDefault()
location.hash = '#machines?license=' + encodeURIComponent(l.id)
routes.machines()
},
title: 'See the machines this license has activated',
style: 'text-decoration:none',
}, 'Machines'),
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,
])),
]))
}
function buildLicensesTable(licenses, title, subtitle, emptyMsg) {
return tableCard(
title,
subtitle,
['ID', 'Product', 'Policy', 'Entitlements', 'Status', 'Issued', 'Expires', 'Buyer', ''],
buildRowsFor(licenses),
emptyMsg,
)
}
function renderProductPills() {
productPillRow.innerHTML = ''
const byProduct = {}
allLicenses.forEach((l) => {
byProduct[l.product_id] = (byProduct[l.product_id] || 0) + 1
})
const pills = [{ id: '', label: 'All products', count: allLicenses.length }]
products.forEach((p) => {
pills.push({ id: p.id, label: p.name, count: byProduct[p.id] || 0 })
})
pills.forEach((opt) => {
const active = opt.id === currentProductFilter
const pill = el('button', {
class: 'btn sm ' + (active ? 'primary' : 'secondary'),
onclick: () => { currentProductFilter = opt.id; renderProductPills(); render() },
}, [
opt.label,
el('span', { style: 'margin-left:6px; opacity:0.7; font-weight:500' }, '(' + opt.count + ')'),
])
productPillRow.appendChild(pill)
})
}
function renderStats() {
statsRow.innerHTML = ''
// Filter by current product first; the row tells the operator
// about THIS scope, not the whole instance.
const scoped = currentProductFilter
? allLicenses.filter((l) => l.product_id === currentProductFilter)
: allLicenses
const total = scoped.length
const active = scoped.filter((l) => l.status === 'active').length
const revoked = scoped.filter((l) => l.status === 'revoked').length
const now = Date.now()
const expiringSoon = scoped.filter((l) => {
if (!l.expires_at) return false
const t = new Date(l.expires_at).getTime()
return !isNaN(t) && t > now && (t - now) < 30 * 86_400_000
}).length
function statBox(label, value, helpText) {
return el('div', { class: 'stat' }, [
el('div', { class: 'stat-label', style: 'display:flex; align-items:center' }, [
label,
helpText ? helpIcon(helpText) : null,
]),
el('div', { class: 'stat-value' }, String(value)),
])
}
statsRow.appendChild(statBox('Licenses', total, 'Total in this scope (all-time, regardless of status).'))
statsRow.appendChild(statBox('Active', active, 'Status = active. Excludes suspended, revoked, expired.'))
statsRow.appendChild(statBox('Revoked', revoked, 'Status = revoked. Irreversible.'))
statsRow.appendChild(statBox('Expiring < 30d', expiringSoon,
'Active or unsuspended licenses with expires_at within the next 30 days. Useful for retention nudges.'))
}
function render() {
tableHolder.innerHTML = ''
let scoped = allLicenses
if (currentProductFilter) scoped = scoped.filter((l) => l.product_id === currentProductFilter)
// Hide-revoked toggle: applied to TABLE rows only. Stats below
// still show the full revoked count from `scoped` (renderStats
// ignores hideRevoked deliberately so the operator never loses
// visibility into how many revoked licenses exist).
if (hideRevoked) scoped = scoped.filter((l) => l.status !== 'revoked')
// Single-product or product-filtered: flat table.
const inSearchMode = lastQuery.length > 0
if (products.length <= 1 || currentProductFilter || inSearchMode) {
const titleProduct = currentProductFilter
? (productById(currentProductFilter) || { name: 'Product' }).name + ' — '
: ''
const title = inSearchMode
? 'Search results'
: (titleProduct + 'Recent licenses')
const subtitle = scoped.length + ' license' + (scoped.length === 1 ? '' : 's') +
(inSearchMode ? '' : (scoped.length >= 100 ? ' (most recent 100)' : ''))
tableHolder.appendChild(buildLicensesTable(
scoped, title, subtitle,
inSearchMode
? 'No matches.'
: 'No licenses yet — once a buyer purchases or you manually issue, they appear here.',
))
renderStats()
return
}
// Multi-product: group + collapse empty products.
const grouped = products
.map((p) => ({ product: p, licenses: scoped.filter((l) => l.product_id === p.id) }))
.filter((g) => g.licenses.length > 0)
if (grouped.length === 0) {
tableHolder.appendChild(plainCard([
el('p', { class: 'muted', style: 'margin:0' }, 'No licenses match this filter.'),
]))
renderStats()
return
}
grouped.forEach(({ product, licenses }) => {
const breakdown = {}
licenses.forEach((l) => { breakdown[l.status] = (breakdown[l.status] || 0) + 1 })
const breakdownTxt = Object.keys(breakdown).sort().map((k) => breakdown[k] + ' ' + k).join(' · ')
const card = el('div', { class: 'card', style: 'margin-bottom:14px' }, [
el('div', { class: 'card-head' }, [
el('h3', null, product.name + ' — ' + product.slug),
el('span', { class: 'sub' }, breakdownTxt),
]),
buildLicensesTable(licenses, '', '', '(none)'),
])
tableHolder.appendChild(card)
})
renderStats()
}
async function loadLicenses() {
const q = queryInput.value.trim()
lastQuery = q
lastQueryField = fieldSel.value
tableHolder.innerHTML = ''
tableHolder.appendChild(plainCard([el('p', { class: 'muted', style: 'margin:0' },
q ? 'Searching…' : 'Loading recent licenses…')]))
try {
const params = q ? '?' + new URLSearchParams({ [lastQueryField]: q }).toString() : ''
const [productsResp, licResp] = await Promise.all([
api('/v1/products').catch(() => ({ products: [] })),
api('/v1/admin/licenses/search' + params),
])
products = productsResp.products || []
allLicenses = licResp.licenses || []
renderProductPills()
render()
} catch (e) {
tableHolder.innerHTML = ''
tableHolder.appendChild(plainCard([err(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,
help: 'Which product the license is for. Determines the buyer\'s available policies (tiers) below.',
})
const policySel = formSelect('issue_policy', 'Policy', [{ value: '', label: '— loading —' }], {
required: true,
help: 'Tier the license inherits from — entitlements, duration, max_machines, trial flag all come from here.',
})
const noteField = formInput('issue_note', 'Internal note', {
help: 'Free-form note attached to the audit log. Examples: "comp", "press", "self-issue Pro for dogfood". Not visible to the buyer.',
placeholder: 'optional',
})
const emailField = formInput('issue_email', 'Buyer email', {
type: 'email',
help: 'Optional. Stored on the license + invoice. Used by buyer self-service recovery if they later lose their key.',
placeholder: 'optional',
})
// 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(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 or BTCPay invoice id to filter. ',
helpIcon(
'Multi-product instances render as per-product sections. Use the product pills above to filter to a single product. ' +
'Search results bypass the per-product grouping (search is global across all products).',
),
]),
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(productPillRow)
target.appendChild(statusFilterRow)
target.appendChild(statsRow)
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 --------
// -------- Machines --------
// Global per-product view: every machine across every license, grouped by
// product when multiple products exist. Matches the Licenses + Subscriptions
// tab format — product filter pills + status filter pills + quick-stats row.
// Drill-into-a-single-license is supported via the ?license= query string.
routes.machines = async function () {
const target = document.getElementById('route-target')
target.innerHTML = ''
// State.
let products = []
let allMachines = []
let currentProductFilter = '' // '' = all
let currentStatusFilter = 'active' // 'active' | 'deactivated' | 'all'
const drillLicenseId = (new URLSearchParams(location.hash.split('?')[1] || '')).get('license') || ''
const productPillRow = el('div', { style: 'display:flex; gap:8px; flex-wrap:wrap; margin:14px 0' })
const statusPillRow = el('div', { style: 'display:flex; gap:8px; flex-wrap:wrap; margin:0 0 14px' })
const statsRow = el('div', { class: 'stats', style: 'margin:0 0 14px' })
const tableHolder = el('div')
function productById(id) {
return products.find((p) => p.id === id)
}
function statusBadge(m) {
if (m.active) {
return el('span', {
class: 'badge b-success',
style: 'font-size:10.5px; padding:2px 7px',
title: 'Heartbeat seen recently; this seat is consuming a slot on its license.',
}, [el('span', { class: 'dot ok' }), 'active'])
}
return el('span', {
class: 'badge b-neutral',
style: 'font-size:10.5px; padding:2px 7px',
title: 'Operator or buyer deactivated this seat. Slot is free.' +
(m.deactivation_reason ? ' Reason: ' + m.deactivation_reason : ''),
}, 'deactivated')
}
function licenseStatusPill(s) {
const map = {
active: { cls: 'b-success', tip: 'License is active.' },
revoked: { cls: 'b-danger', tip: 'License is revoked. Machine seat is moot.' },
suspended: { cls: 'b-warning', tip: 'License is suspended. Validate calls fail; reversible.' },
}
const info = map[s] || { cls: 'b-neutral', tip: s }
return el('span', {
class: 'badge ' + info.cls,
style: 'font-size:10.5px; padding:2px 7px',
title: info.tip,
}, s)
}
function machineRow(m) {
const deactivateBtn = m.active
? el('button', {
class: 'btn sm danger',
onclick: async () => {
if (!await confirmModal({
eyebrow: 'Deactivate machine',
title: 'Force-deactivate this machine?',
message: 'It will free the seat for this license. The machine will be denied on its next validate call.',
confirmLabel: 'Deactivate',
confirmVariant: 'danger',
})) return
try {
await api('/v1/admin/machines/' + m.id + '/deactivate', { method: 'POST', body: { reason: 'admin' } })
load()
} catch (e) { alert(e.message) }
},
}, 'Deactivate')
: null
return el('tr', null, [
el('td', null, [
clickToCopy(m.id),
]),
el('td', null, m.buyer_email || el('span', { class: 'muted' }, '')),
el('td', null, [
el('span', { class: 'mono', style: 'font-size:11.5px' }, shortId(m.license_id)),
' ',
licenseStatusPill(m.license_status),
]),
el('td', null, m.hostname || el('span', { class: 'muted' }, '')),
el('td', null, m.platform || el('span', { class: 'muted' }, '')),
el('td', null, m.ip_last_seen || el('span', { class: 'muted' }, '')),
el('td', null, relativeDate(m.last_heartbeat_at)),
el('td', null, statusBadge(m)),
el('td', null, deactivateBtn),
])
}
function buildTable(machines, title, sub, empty) {
const rows = machines.map(machineRow)
return tableCard(
title, sub,
['Machine ID', 'Buyer', 'License', 'Hostname', 'Platform', 'Last IP', 'Last heartbeat', 'Status', ''],
rows, empty,
)
}
function renderProductPills() {
productPillRow.innerHTML = ''
// "All" pill.
productPillRow.appendChild(el('button', {
class: 'btn sm ' + (currentProductFilter === '' ? 'primary' : 'secondary'),
onclick: () => { currentProductFilter = ''; render() },
}, 'All products (' + allMachines.length + ')'))
// Per-product pills, only for products that have machines in the dataset.
const byProduct = new Map()
allMachines.forEach((m) => {
if (!byProduct.has(m.product_id)) byProduct.set(m.product_id, 0)
byProduct.set(m.product_id, byProduct.get(m.product_id) + 1)
})
products.forEach((p) => {
const n = byProduct.get(p.id) || 0
if (n === 0) return
productPillRow.appendChild(el('button', {
class: 'btn sm ' + (currentProductFilter === p.id ? 'primary' : 'secondary'),
onclick: () => { currentProductFilter = p.id; render() },
}, p.name + ' (' + n + ')'))
})
}
function renderStatusPills() {
statusPillRow.innerHTML = ''
const counts = { active: 0, deactivated: 0 }
allMachines.forEach((m) => { counts[m.active ? 'active' : 'deactivated']++ })
;[['active', 'Active'], ['deactivated', 'Deactivated'], ['all', 'All']].forEach(([key, label]) => {
const count = key === 'all' ? allMachines.length : counts[key]
statusPillRow.appendChild(el('button', {
class: 'btn sm ' + (currentStatusFilter === key ? 'primary' : 'secondary'),
onclick: () => { currentStatusFilter = key; load() }, // reload — server filter
}, label + ' (' + count + ')'))
})
}
function renderStats(scoped) {
statsRow.innerHTML = ''
const total = scoped.length
const active = scoped.filter((m) => m.active).length
const platforms = {}
scoped.forEach((m) => {
const p = m.platform || 'unknown'
platforms[p] = (platforms[p] || 0) + 1
})
const topPlatform = Object.entries(platforms).sort((a, b) => b[1] - a[1])[0]
statsRow.appendChild(stat('Machines', String(total), null))
statsRow.appendChild(stat('Active', String(active),
total > 0 ? Math.round(100 * active / total) + '% of total' : null, true))
statsRow.appendChild(stat('Top platform',
topPlatform ? topPlatform[0] : '',
topPlatform ? topPlatform[1] + ' machine' + (topPlatform[1] === 1 ? '' : 's') : null))
}
function render() {
tableHolder.innerHTML = ''
let scoped = allMachines
if (currentProductFilter) scoped = scoped.filter((m) => m.product_id === currentProductFilter)
renderStats(scoped)
// Single-product or filtered: flat table.
if (products.length <= 1 || currentProductFilter || drillLicenseId) {
const titleProduct = currentProductFilter
? (productById(currentProductFilter) || { name: 'Product' }).name + ' — '
: ''
const title = drillLicenseId
? 'Machines on license ' + shortId(drillLicenseId)
: (titleProduct + 'Machines')
const sub = scoped.length + ' machine' + (scoped.length === 1 ? '' : 's')
tableHolder.appendChild(buildTable(scoped, title, sub,
'No machines yet — once buyers run apps that call /v1/validate with a fingerprint, they appear here.'))
return
}
// Multi-product grouping.
const grouped = products
.map((p) => ({ product: p, machines: scoped.filter((m) => m.product_id === p.id) }))
.filter((g) => g.machines.length > 0)
if (grouped.length === 0) {
tableHolder.appendChild(plainCard([
el('p', { class: 'muted', style: 'margin:0' }, 'No machines match this filter.'),
]))
return
}
grouped.forEach(({ product, machines }) => {
const activeCount = machines.filter((m) => m.active).length
const breakdown = activeCount + ' active' + (machines.length > activeCount
? (' · ' + (machines.length - activeCount) + ' deactivated')
: '')
const card = el('div', { class: 'card', style: 'margin-bottom:14px' }, [
el('div', { class: 'card-head' }, [
el('h3', null, product.name + ' — ' + product.slug),
el('span', { class: 'sub' }, breakdown),
]),
buildTable(machines, '', '', '(none)'),
])
tableHolder.appendChild(card)
})
}
async function load() {
tableHolder.innerHTML = ''
tableHolder.appendChild(plainCard([el('p', { class: 'muted', style: 'margin:0' }, 'Loading machines…')]))
try {
const params = new URLSearchParams()
if (currentStatusFilter !== 'active') params.set('include_inactive', 'true')
if (drillLicenseId) params.set('license_id', drillLicenseId)
const [productsResp, machinesResp] = await Promise.all([
api('/v1/products').catch(() => ({ products: [] })),
api('/v1/admin/machines' + (params.toString() ? '?' + params.toString() : '')),
])
products = productsResp.products || []
let raw = machinesResp.machines || []
// Apply status filter client-side too (server returns active-only by default,
// include_inactive returns everything; client distinguishes deactivated-only).
if (currentStatusFilter === 'deactivated') raw = raw.filter((m) => !m.active)
else if (currentStatusFilter === 'active') raw = raw.filter((m) => m.active)
allMachines = raw
renderProductPills()
renderStatusPills()
render()
} catch (e) {
tableHolder.innerHTML = ''
tableHolder.appendChild(plainCard([err(e.message)]))
}
}
target.appendChild(plainCard([
el('p', { class: 'muted', style: 'margin:0' }, drillLicenseId
? [
'Machines on a single license. ',
el('a', {
href: '#machines',
onclick: (e) => {
e.preventDefault()
location.hash = '#machines'
routes.machines()
},
style: 'color:var(--navy-800); font-weight:500',
}, 'View all machines →'),
]
: 'One row per active install across every license. Rows are created by /v1/validate calls that carry a fingerprint. Use the pills to filter by product or status; click "Deactivate" to force-free a seat.'),
]))
target.appendChild(productPillRow)
target.appendChild(statusPillRow)
target.appendChild(statsRow)
target.appendChild(tableHolder)
load()
}
// -------- 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 || []
// Empty state: show a CTA + "what's a webhook for?" explainer
// instead of a bare empty table. Mirrors the Machines tab
// empty state for visual consistency. Clicking the primary
// CTA opens the create disclosure (the same form below).
if (eps.length === 0) {
const ctaCard = el('div', {
style: 'padding:32px 28px; background:var(--cream-50); ' +
'border:1px solid var(--border-1); border-radius:12px; ' +
'text-align:center; box-shadow:0 0 0 1px var(--gold-500) inset, var(--shadow-md);',
}, [
el('div', { class: 'eyebrow', style: 'color:var(--gold-700); margin-bottom:6px' }, 'No webhooks yet'),
el('h3', { style: 'font-family:var(--font-display); font-weight:600; font-size:20px; margin:0 0 8px; color:var(--navy-950); letter-spacing:-0.01em;' },
'Get notified when something happens'),
el('p', { style: 'font-size:13.5px; color:var(--ink-700); line-height:1.55; margin:0 auto 16px; max-width:520px' },
'A webhook is a URL Keysat POSTs to when an event occurs — license issued, license revoked, code redeemed, invoice settled. Wire one up to sync your own database, post to Slack / Discord, kick off a fulfillment workflow, or trigger a CI run. Every delivery carries an HMAC-SHA256 signature in the X-Keysat-Signature header so you can verify it really came from this daemon.'),
el('button', {
class: 'btn primary',
onclick: () => {
create.open = true
const url = create.querySelector('[name=url]')
if (url) url.focus()
create.scrollIntoView({ behavior: 'smooth', block: 'center' })
},
}, 'Add your first webhook'),
])
target.appendChild(ctaCard)
} else {
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 (!await confirmModal({
eyebrow: 'Delete webhook',
title: 'Delete this webhook subscription?',
message: 'New events will no longer be delivered to this endpoint. Past delivery history is preserved.',
confirmLabel: 'Delete',
confirmVariant: 'danger',
})) 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()
}
// -------- Settings --------
// Three subsections:
// 1. Operator name — the human-readable name on /buy/<slug> + thank-you.
// 2. Payment providers — BTCPay + Zaprite connect / activate. Zaprite
// is gated on the `zaprite_payments` self-tier entitlement (Pro+).
// 3. API keys — scoped tokens for agents / bots. Each key carries a
// role (read-only / license-issuer / support / full-admin).
routes.settings = async function () {
const target = document.getElementById('route-target')
target.innerHTML = ''
const opNameHost = el('div')
target.appendChild(opNameHost)
renderOperatorNameCard(opNameHost)
const paymentHost = el('div', { style: 'margin-top:18px' })
target.appendChild(paymentHost)
renderPaymentProvidersCard(paymentHost)
const apiHost = el('div', { style: 'margin-top:18px' })
target.appendChild(apiHost)
renderApiKeysCard(apiHost)
}
async function renderOperatorNameCard(host) {
host.innerHTML = ''
let stored = '', effective = '', fallbackEnv = ''
try {
const r = await api('/v1/admin/settings/operator-name').catch(() => null)
if (r) {
stored = r.stored || ''
effective = r.effective || ''
fallbackEnv = r.fallback_env || ''
}
} catch {}
const input = el('input', {
class: 'input',
type: 'text',
value: stored,
placeholder: fallbackEnv || 'Your business or display name',
style: 'max-width:380px',
})
const status = el('span', { class: 'muted', style: 'margin-left:10px; font-size:12.5px' }, '')
const saveBtn = el('button', { class: 'btn primary', onclick: async () => {
saveBtn.disabled = true
status.textContent = 'Saving…'
try {
await api('/v1/admin/settings/operator-name', {
method: 'POST',
body: { name: input.value.trim() },
})
status.textContent = 'Saved.'
setTimeout(() => { renderOperatorNameCard(host) }, 600)
} catch (e) {
status.textContent = 'Failed: ' + e.message
saveBtn.disabled = false
}
} }, 'Save')
const noteChildren = [
el('p', { class: 'muted', style: 'margin:0 0 12px' },
'Shown on /buy/<slug> pages and the post-purchase thank-you page. Anything you want buyers to see as the seller.'),
]
if (effective) {
noteChildren.push(el('p', { class: 'muted', style: 'margin:10px 0 0; font-size:12px' }, [
'Buyers currently see: ',
el('code', { style: 'font-family:var(--font-mono); font-size:12px; color:var(--navy-900)' }, effective),
]))
}
host.appendChild(card('Operator name', null, [
...noteChildren.slice(0, 1),
el('div', { style: 'display:flex; align-items:center; gap:10px; flex-wrap:wrap' },
[input, saveBtn, status]),
...noteChildren.slice(1),
]))
}
async function renderPaymentProvidersCard(host) {
host.innerHTML = ''
let btcpayConfigured = false, zapriteConfigured = false, activeProvider = '', tier = null
try {
const r = await api('/v1/admin/payment-provider/status').catch(() => null)
if (r) {
btcpayConfigured = !!r.btcpay_configured
zapriteConfigured = !!r.zaprite_configured
activeProvider = r.active || ''
}
} catch {}
try {
tier = await api('/v1/admin/tier').catch(() => null)
} catch {}
const zapriteAllowed = tier && Array.isArray(tier.entitlements) &&
tier.entitlements.indexOf('zaprite_payments') >= 0
function providerRow(name, label, configured, isActive, lockedReason) {
const statusPill = lockedReason
? el('span', {
class: 'badge b-neutral',
style: 'font-size:10.5px; padding:2px 7px',
title: lockedReason,
}, 'locked')
: isActive
? el('span', { class: 'badge b-success', style: 'font-size:10.5px; padding:2px 7px' }, 'active')
: (configured
? el('span', { class: 'badge b-neutral', style: 'font-size:10.5px; padding:2px 7px' }, 'configured')
: el('span', { class: 'badge b-neutral', style: 'font-size:10.5px; padding:2px 7px' }, 'not connected'))
const buttons = []
if (lockedReason) {
const upgradeBtn = el('a', {
class: 'btn sm primary',
href: 'https://licensing.keysat.xyz/buy/keysat?policy=pro',
target: '_blank',
rel: 'noopener',
style: 'text-decoration:none',
}, 'Upgrade to Pro →')
buttons.push(upgradeBtn)
} else if (configured) {
if (!isActive) {
buttons.push(el('button', {
class: 'btn sm secondary',
onclick: async () => {
try {
await api('/v1/admin/payment-provider/activate', { method: 'POST', body: { provider: name } })
renderPaymentProvidersCard(host)
} catch (e) { alert(e.message) }
},
}, 'Activate'))
}
buttons.push(el('button', {
class: 'btn sm danger',
onclick: async () => {
if (!await confirmModal({
eyebrow: 'Disconnect',
title: 'Disconnect ' + label + '?',
message: 'The stored API key will be wiped. Any active purchases through this provider will stop being processable until you reconnect.',
confirmLabel: 'Disconnect',
confirmVariant: 'danger',
})) return
try {
await api('/v1/admin/' + name + '/disconnect', { method: 'POST' })
renderPaymentProvidersCard(host)
} catch (e) { alert(e.message) }
},
}, 'Disconnect'))
} else {
buttons.push(el('button', {
class: 'btn sm primary',
onclick: () => openConnectModal(name, label, () => renderPaymentProvidersCard(host)),
}, 'Connect'))
}
return el('div', {
style: 'display:flex; align-items:center; gap:12px; padding:14px 0; border-top:1px solid var(--border-1)' +
(lockedReason ? '; opacity:0.65' : ''),
}, [
el('div', { style: 'flex:1' }, [
el('div', { style: 'font-weight:600; color:var(--navy-950); margin-bottom:4px; display:flex; align-items:center; gap:8px' },
[label, statusPill]),
el('div', { class: 'muted', style: 'font-size:12.5px; line-height:1.45' },
name === 'btcpay'
? 'Bitcoin / Lightning via self-hosted BTCPay. Free on every tier.'
: 'Cards, Apple Pay, bank transfers, and Bitcoin via Zaprite\'s hosted gateway. Pro tier required.'),
]),
el('div', { style: 'display:flex; gap:6px; flex-shrink:0' }, buttons),
])
}
const rows = el('div', { style: 'margin-top:6px' }, [
providerRow('btcpay', 'BTCPay Server',
btcpayConfigured,
activeProvider === 'btcpay',
null),
providerRow('zaprite', 'Zaprite payment gateway',
zapriteConfigured,
activeProvider === 'zaprite',
zapriteAllowed ? null : 'Zaprite requires Pro or Patron tier (zaprite_payments entitlement).'),
])
host.appendChild(card('Payment providers', null, [
el('p', { class: 'muted', style: 'margin:0 0 4px' },
'Connect one or both providers, then pick which is active. The active provider is what /buy/<slug> uses for new purchases.'),
rows,
]))
}
// Connect modal for BTCPay / Zaprite.
function openConnectModal(provider, displayLabel, 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; overflow-y:auto;',
})
const fields = el('div', { style: 'display:flex; flex-direction:column; gap:10px; margin-top:10px;' })
const status = el('div', { class: 'muted', style: 'margin-top:8px; font-size:12.5px; min-height:18px' }, '')
const fieldMap = {}
function addField(name, label, opts) {
opts = opts || {}
const inp = el('input', {
class: 'input',
type: opts.type || 'text',
placeholder: opts.placeholder || '',
value: opts.value || '',
})
fieldMap[name] = inp
const labelChildren = [label]
if (opts.help) labelChildren.push(helpIcon(opts.help))
fields.appendChild(el('label', { class: 'lbl', style: 'display:flex; align-items:center; gap:6px; font-size:12.5px' }, labelChildren))
fields.appendChild(inp)
}
if (provider === 'btcpay') {
addField('base_url', 'Base URL', { placeholder: 'https://btcpay.example.com', help: 'Your self-hosted BTCPay server URL. Include https://.' })
addField('api_key', 'API key', { help: 'Generate a Greenfield API key from BTCPay → Account → Manage Account → API Keys with btcpay.store.canmodifyinvoices permission.' })
addField('store_id', 'Store id', { help: 'BTCPay → Stores → your store → General → Store id.' })
addField('webhook_secret', 'Webhook secret (optional)', { help: 'If you set up a webhook in BTCPay pointing at /v1/btcpay/webhook, paste the secret here so Keysat can verify signatures.' })
} else if (provider === 'zaprite') {
addField('api_key', 'API key', { help: 'Generate at app.zaprite.com → Settings → API. Needs the default scopes.' })
}
const connectBtn = el('button', { class: 'btn primary' }, 'Connect')
connectBtn.addEventListener('click', async () => {
const body = {}
for (const k in fieldMap) {
const v = fieldMap[k].value.trim()
if (v) body[k] = v
}
connectBtn.disabled = true
status.textContent = 'Validating…'
try {
await api('/v1/admin/' + provider + '/connect', { method: 'POST', body })
overlay.remove()
onSuccess && onSuccess()
} catch (e) {
status.textContent = e.message
connectBtn.disabled = false
}
})
const cancelBtn = el('button', { class: 'btn secondary' }, 'Cancel')
cancelBtn.addEventListener('click', () => overlay.remove())
const cardEl = el('div', {
style: 'background:var(--cream-50); border:1px solid var(--border-1); border-radius:12px; max-width:540px; 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' }, 'Connect'),
el('h3', { style: 'font-family:var(--font-display); font-weight:600; font-size:18px; margin:0 0 6px; color:var(--navy-950);' },
'Connect ' + displayLabel),
fields,
status,
el('div', { style: 'display:flex; gap:10px; margin-top:14px;' }, [connectBtn, cancelBtn]),
])
overlay.appendChild(cardEl)
overlay.addEventListener('click', (e) => { if (e.target === overlay) overlay.remove() })
document.body.appendChild(overlay)
}
async function renderApiKeysCard(host) {
host.innerHTML = ''
let keys = []
try {
const r = await api('/v1/admin/api-keys').catch(() => ({ api_keys: [] }))
keys = r.api_keys || []
} catch {}
const createBtn = el('button', { class: 'btn sm primary', onclick: () => openCreateApiKeyModal(() => renderApiKeysCard(host)) }, '+ Generate key')
const rows = keys.map((k) => {
const isRevoked = !!k.revoked_at
return el('tr', null, [
el('td', null, [
el('span', { style: 'font-weight:600; color:var(--navy-950)' }, k.label),
isRevoked ? el('span', { class: 'muted', style: 'margin-left:8px; font-size:11.5px' }, '(revoked)') : null,
]),
el('td', null, el('code', { style: 'font-size:11.5px' }, k.role)),
el('td', { class: 'muted' }, fmtDate(k.created_at)),
el('td', { class: 'muted' }, k.last_used_at ? relativeDate(k.last_used_at) : 'never'),
el('td', null, isRevoked
? el('span', { class: 'muted', style: 'font-size:11.5px' }, '—')
: el('button', {
class: 'btn sm danger',
onclick: async () => {
if (!await confirmModal({
eyebrow: 'Revoke API key',
title: 'Revoke "' + k.label + '"?',
message: 'Any agent or script using this key stops working immediately. Irreversible — generate a new key if needed.',
confirmLabel: 'Revoke',
confirmVariant: 'danger',
})) return
try {
await api('/v1/admin/api-keys/' + k.id, { method: 'DELETE' })
renderApiKeysCard(host)
} catch (e) { alert(e.message) }
},
}, 'Revoke')),
])
})
host.appendChild(card('Scoped API keys', null, [
el('p', { class: 'muted', style: 'margin:0 0 8px' }, [
'Additional Bearer tokens with bounded permissions, for agents / bots / partner scripts. The master admin API key (set in your StartOS config) stays full-access. ',
el('a', {
href: 'https://docs.keysat.xyz/agent-guide',
target: '_blank',
rel: 'noopener',
style: 'color:var(--navy-800); font-weight:500',
}, 'Agent integration guide →'),
]),
el('div', { style: 'display:flex; align-items:center; gap:10px; margin-bottom:10px' }, [createBtn]),
keys.length === 0
? el('div', { class: 'empty', style: 'padding:24px; text-align:center; color:var(--ink-500); font-size:13.5px' },
'No scoped API keys yet. Generate one to give an agent or bot bounded access.')
: tableCard('', null,
['Label', 'Role', 'Created', 'Last used', ''],
rows, ''),
]))
}
function openCreateApiKeyModal(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; overflow-y:auto;',
})
const labelInput = el('input', { class: 'input', type: 'text', placeholder: 'e.g. Recap support bot' })
const roleSelect = el('select', { class: 'input' }, [
el('option', { value: 'read-only' }, 'Read-only — list everything; mutate nothing'),
el('option', { value: 'license-issuer' }, 'License issuer — read + issue / revoke / change-tier licenses'),
el('option', { value: 'support' }, 'Support — license issuer + cancel subs + deactivate machines'),
el('option', { value: 'full-admin' }, 'Full admin — every scope (use sparingly)'),
])
const status = el('div', { class: 'muted', style: 'margin-top:8px; font-size:12.5px; min-height:18px' }, '')
const generateBtn = el('button', { class: 'btn primary' }, 'Generate')
const cancelBtn = el('button', { class: 'btn secondary' }, 'Cancel')
cancelBtn.addEventListener('click', () => overlay.remove())
generateBtn.addEventListener('click', async () => {
const label = labelInput.value.trim()
if (!label) { status.textContent = 'Label required.'; return }
generateBtn.disabled = true
status.textContent = 'Generating…'
try {
const r = await api('/v1/admin/api-keys', {
method: 'POST',
body: { label: label, role: roleSelect.value },
})
overlay.remove()
showTokenOnceModal(r.token, r.label, r.role, onSuccess)
} catch (e) {
status.textContent = e.message
generateBtn.disabled = false
}
})
const cardEl = el('div', {
style: 'background:var(--cream-50); border:1px solid var(--border-1); border-radius:12px; max-width:540px; 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' }, 'New API key'),
el('h3', { style: 'font-family:var(--font-display); font-weight:600; font-size:18px; margin:0 0 14px; color:var(--navy-950);' },
'Generate a scoped API key'),
el('label', { class: 'lbl', style: 'font-size:12.5px; margin-bottom:6px; display:block' }, 'Label'),
labelInput,
el('label', { class: 'lbl', style: 'font-size:12.5px; margin:14px 0 6px; display:block' }, 'Role'),
roleSelect,
status,
el('div', { style: 'display:flex; gap:10px; margin-top:14px;' }, [generateBtn, cancelBtn]),
])
overlay.appendChild(cardEl)
overlay.addEventListener('click', (e) => { if (e.target === overlay) overlay.remove() })
document.body.appendChild(overlay)
setTimeout(() => labelInput.focus(), 0)
}
function showTokenOnceModal(token, label, role, onClose) {
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 copyBtn = el('button', {
class: 'btn primary',
onclick: async function () {
try {
await navigator.clipboard.writeText(token)
this.textContent = 'Copied'
setTimeout(() => { this.textContent = 'Copy token' }, 1400)
} catch {}
},
}, 'Copy token')
const closeBtn = el('button', { class: 'btn secondary' }, 'I\'ve saved it — close')
closeBtn.addEventListener('click', () => { overlay.remove(); onClose && onClose() })
const cardEl = el('div', {
style: 'background:var(--cream-50); border:2px solid var(--gold-500); border-radius:12px; max-width:600px; width:100%; padding:28px 26px; ' +
'box-shadow:0 16px 32px rgba(14,31,51,0.20);',
}, [
el('div', { class: 'eyebrow', style: 'color:var(--gold-700); margin-bottom:8px' }, 'Save this token now'),
el('h3', { style: 'font-family:var(--font-display); font-weight:700; font-size:20px; margin:0 0 12px; color:var(--navy-950);' },
'"' + label + '" — ' + role),
el('p', { style: 'font-size:13.5px; line-height:1.55; color:var(--ink-700); margin:0 0 14px' },
'This is the only time the full token will be shown. Copy it now into your password manager or the agent that will use it. If you lose it you can\'t recover it — only revoke and generate a new one.'),
el('div', {
style: 'background:var(--navy-950); color:var(--cream-50); padding:14px 16px; border-radius:8px; ' +
'font-family:var(--font-mono); font-size:13px; word-break:break-all; margin-bottom:14px;',
}, token),
el('div', { style: 'display:flex; gap:10px;' }, [copyBtn, closeBtn]),
])
overlay.appendChild(cardEl)
document.body.appendChild(overlay)
}
// ---------- 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) => {
// Each entitlement is a 2-line block instead of 4 cramped
// columns: slug + display name share the top line (so they fit
// side-by-side at typical lengths), description gets its own
// full-width line below so longer copy reads without truncation.
// The remove button anchors to the top-right of the block.
const slugInput = el('input', {
class: 'input mono', placeholder: 'slug (e.g. unlimited_products)',
value: slug || '',
'data-field': 'slug',
})
const nameInput = el('input', {
class: 'input', placeholder: 'Display name (e.g. Unlimited products)',
value: name || '',
'data-field': 'name',
})
const descInput = el('input', {
class: 'input', placeholder: 'Description — shown as a hover tooltip on the buy page',
value: description || '',
'data-field': 'description',
})
const removeBtn = el('button', {
type: 'button',
class: 'btn sm danger',
title: 'Remove this entitlement',
style: 'padding:6px 10px; flex-shrink:0',
}, '×')
const row = el('div', {
class: 'catalog-row',
style:
'display:flex; flex-direction:column; gap:6px; ' +
'padding:10px; border:1px solid var(--border-1); ' +
'border-radius:8px; background:var(--cream-50);',
}, [
el('div', {
style: 'display:grid; grid-template-columns: 1fr 1.4fr auto; gap:6px; align-items:center',
}, [slugInput, nameInput, removeBtn]),
descInput,
])
removeBtn.addEventListener('click', () => row.remove())
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 allow any free-text entitlement on this product\'s policies.'),
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, initialHidden) {
const selected = new Set(Array.isArray(initialSelection) ? initialSelection : [])
// Per-chip "hidden on buy page" set. An entitlement can be granted
// by the license (in `selected`) without being displayed on the
// public buy-page tier card — useful for "Everything in Creator,
// plus:" marketing where the operator doesn't want to duplicate
// already-implied entitlements visually.
const hidden = new Set(Array.isArray(initialHidden) ? initialHidden : [])
const host = el('div', {
style: 'display:flex; flex-wrap:wrap; gap:6px; margin-top:4px',
})
function paint(pill, slug) {
const isSel = selected.has(slug)
const isHid = hidden.has(slug)
// Navy-filled pill when selected (matches "Selected" tier-select-
// btn + Featured-ON toggle conventions in the admin UI). Cream
// outline when not selected. Hidden state = reduced opacity on
// the whole pill — no strikethrough since the chip is still
// granted by the license.
if (isSel) {
pill.style.background = 'var(--navy-800)'
pill.style.color = 'var(--cream-50)'
pill.style.borderColor = 'var(--navy-800)'
pill.style.opacity = isHid ? '0.5' : '1'
} else {
pill.style.background = 'transparent'
pill.style.color = 'var(--ink-700)'
pill.style.borderColor = 'var(--border-2)'
pill.style.opacity = '1'
}
// Eye toggle: only visible/clickable when entitlement is selected.
const eye = pill.querySelector('[data-eye]')
const nameEl = pill.querySelector('[data-name]')
if (eye) {
eye.style.display = isSel ? 'inline-flex' : 'none'
// "Open eye" = visible on buy; "closed eye" = hidden on buy.
eye.textContent = isHid ? '\u{1F441}\u{200D}\u{1F5E8}' : '\u{1F441}'
eye.title = isHid
? 'Hidden from the buy page tier card. Click to show.'
: 'Shown on the buy page tier card. Click to hide (license still grants it).'
}
// No text strikethrough — opacity on the whole pill is enough to
// signal "muted / hidden on buy" without the misleading "deleted"
// affordance of a strike.
if (nameEl) {
nameEl.style.textDecoration = 'none'
nameEl.style.opacity = '1'
}
}
function renderPill(entry) {
// Container is a flex `<span>` (not a `<button>`) so we can nest
// two clickable buttons inside — the selection button and the eye
// toggle button — without nested-interactive-element issues.
const pill = el('span', {
'data-slug': entry.slug,
style:
'display:inline-flex; align-items:center; gap:6px; ' +
'padding:5px 10px; border-radius:999px; ' +
'font-family:var(--font-body); font-size:13px; font-weight:500; ' +
'border:1px solid var(--border-2); background:transparent; ' +
'transition:background 100ms, color 100ms, border-color 100ms;',
}, [
el('button', {
type: 'button',
'data-name': '1',
title: entry.description || entry.slug,
style: 'background:none; border:none; padding:0; cursor:pointer; ' +
'font:inherit; color:inherit; text-align:left;',
}, entry.name || entry.slug),
el('button', {
type: 'button',
'data-eye': '1',
style: 'background:none; border:none; padding:0 0 0 2px; cursor:pointer; ' +
'font-size:13px; line-height:1; display:none;',
}, ''),
])
const nameBtn = pill.querySelector('[data-name]')
const eyeBtn = pill.querySelector('[data-eye]')
nameBtn.addEventListener('click', () => {
if (selected.has(entry.slug)) {
selected.delete(entry.slug)
// De-selecting also clears any "hidden" state so stale entries
// don't accumulate in metadata.
hidden.delete(entry.slug)
} else {
selected.add(entry.slug)
}
paint(pill, entry.slug)
})
eyeBtn.addEventListener('click', (e) => {
e.stopPropagation()
if (!selected.has(entry.slug)) return
if (hidden.has(entry.slug)) hidden.delete(entry.slug)
else hidden.add(entry.slug)
paint(pill, entry.slug)
})
paint(pill, entry.slug)
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 an entitlement to grant it. The eye toggle on a granted entitlement controls visibility on the buy page tier card (the license still grants it either way).'),
host,
])
return {
element: wrap,
read: () => Array.from(selected),
// Returns slugs that are SELECTED *and* hidden — callers filter
// for that pair so we never persist stale slugs that aren't even
// granted by the policy.
readHidden: () => Array.from(hidden).filter((s) => selected.has(s)),
}
}
// ---------- 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)
// Auto-refresh self-tier from the DB on every route change so the
// sidebar banner reflects admin changes (revoke / tier change /
// unsuspend) without waiting for the hourly background sweep or
// a daemon restart. Fire-and-forget — the route already rendered
// with the previously-known tier; this re-renders the banner once
// the refresh comes back. Errors swallowed; if the refresh fails
// the banner just keeps its existing state until next nav.
selfTierRefreshDebounced()
}
// Per-nav self-tier refresh, lightly debounced (max one in-flight
// request at a time) so a fast-clicking operator doesn't hammer
// /v1/admin/self-license/refresh.
let _selfTierRefreshInflight = false
async function selfTierRefreshDebounced() {
if (_selfTierRefreshInflight) return
_selfTierRefreshInflight = true
try {
await api('/v1/admin/self-license/refresh', { method: 'POST' })
} catch (_) { /* network / endpoint hiccup — banner keeps prior state */ }
finally {
_selfTierRefreshInflight = false
refreshTierBanner()
}
}
document.querySelectorAll('.sidebar a.nav').forEach((a) => {
a.addEventListener('click', (e) => { e.preventDefault(); setRoute(a.getAttribute('data-route')) })
})
// Tier status (label + usage + caps) — cached after first fetch so
// multiple consumers within a single route render don't all re-hit
// /v1/admin/tier. Callers pass `forceRefresh:true` to bust the cache
// (e.g. after a successful create/delete that changes usage).
let _tierStatusCache = null
async function loadTierStatus(opts) {
const forceRefresh = !!(opts && opts.forceRefresh)
if (_tierStatusCache && !forceRefresh) return _tierStatusCache
try {
_tierStatusCache = await api('/v1/admin/tier')
return _tierStatusCache
} catch (_) {
return null
}
}
// Helper: given a tier status, a usage-key (e.g. 'products'), and a
// human label, render the right pre-check warning element OR a
// grandfather banner. Returns null when no warning is needed.
//
// States:
// - usage >= cap → returns 'over' (grandfather banner)
// - usage == cap - 1 → returns 'pre' (approaching cap)
// - usage < cap - 1 → returns 'ok' (no warning)
// - cap is null → returns 'unlim' (unlimited)
function tierCapState(tierStatus, key) {
if (!tierStatus || !tierStatus.caps) return 'unlim'
const cap = tierStatus.caps[key]
if (cap === null || cap === undefined) return 'unlim'
const used = (tierStatus.usage && tierStatus.usage[key]) || 0
if (used >= cap) return 'over'
if (used === cap - 1) return 'pre'
return 'ok'
}
// Render a small inline warning card for a "you're 1-away from the
// cap" pre-check. Used above create-form submit buttons.
function capPreCheckCard(tierStatus, key, label) {
if (!tierStatus) return null
const state = tierCapState(tierStatus, key)
if (state !== 'pre' && state !== 'over') return null
const cap = tierStatus.caps[key]
const used = (tierStatus.usage && tierStatus.usage[key]) || 0
const upgradeUrl = tierStatus.upgrade_url
const nextTier = (tierStatus.next_tier || 'pro').replace(/^[a-z]/, (c) => c.toUpperCase())
const overText = 'You\'re at ' + used + ' active ' + label + ' (cap: ' + cap +
'). Existing ones still work — but new ones are blocked until you upgrade to ' + nextTier + '.'
const preText = 'You\'re at ' + used + '/' + cap + ' ' + label +
'. Creating one more will hit your ' + tierStatus.tier_name + ' tier cap. ' +
'Upgrade to ' + nextTier + ' for unlimited ' + label + '.'
return el('div', {
style: 'margin:8px 0 4px; padding:10px 12px; ' +
'background:rgba(191,160,104,0.10); border:1px solid var(--gold-500); ' +
'border-radius:8px; font-size:12.5px; color:var(--ink-700); line-height:1.5;',
}, [
el('strong', { style: 'color:var(--navy-950); display:block; margin-bottom:3px' },
state === 'over' ? 'Cap reached' : 'Approaching cap'),
el('span', null, state === 'over' ? overText : preText),
upgradeUrl ? el('a', {
href: upgradeUrl, target: '_blank', rel: 'noopener',
style: 'display:inline-block; margin-left:6px; color:var(--gold-700); font-weight:600; text-decoration:none',
}, 'Upgrade →') : null,
].filter(Boolean))
}
// Render a persistent grandfather banner for over-cap scope (usage
// strictly above current tier's cap — i.e. operator downgraded but
// we're letting them keep existing rows). Returns null when not over.
function grandfatherBanner(tierStatus, key, label) {
if (!tierStatus) return null
if (tierCapState(tierStatus, key) !== 'over') return null
const cap = tierStatus.caps[key]
const used = (tierStatus.usage && tierStatus.usage[key]) || 0
const upgradeUrl = tierStatus.upgrade_url
const nextTier = (tierStatus.next_tier || 'pro').replace(/^[a-z]/, (c) => c.toUpperCase())
return el('div', {
style: 'margin:0 0 14px; padding:10px 14px; ' +
'background:rgba(191,160,104,0.10); border:1px solid var(--gold-500); ' +
'border-radius:8px; font-size:13px; color:var(--ink-700); line-height:1.55; ' +
'display:flex; gap:14px; align-items:center; flex-wrap:wrap;',
}, [
el('div', { style: 'flex:1; min-width:260px' }, [
el('strong', { style: 'color:var(--navy-950)' },
'Grandfathered: ' + used + ' ' + label + ' active vs ' + tierStatus.tier_name +
' tier cap of ' + cap + '. '),
el('span', null, 'Existing ' + label + ' keep working. Creating new ones is blocked until you upgrade to ' + nextTier + '.'),
]),
upgradeUrl ? el('a', {
href: upgradeUrl, target: '_blank', rel: 'noopener',
class: 'btn sm primary', style: 'text-decoration:none; flex:none',
}, 'Upgrade to ' + nextTier + ' →') : null,
].filter(Boolean))
}
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_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 {
// Creator tier (default — no self-license, or paid entitlements absent).
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) || 10) +
' active codes. Currently using ' + productUsed + '/' + productCap + ' products. ' +
'Upgrade to Pro for unlimited products + policies + codes, recurring billing, and the Zaprite payment gateway (cards, Apple Pay, bank transfers).'
cta.textContent = 'Upgrade to Pro →'
cta.href = t.upgrade_url
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>