519fa1a8e6
Bug fix:
Product entitlements catalog reads were silently dropping. Every
SELECT against the products table was missing entitlements_catalog_json
from the column list, so the PATCH handler wrote the catalog correctly
but every subsequent read returned null. Admin UI edits appeared to
vanish on save. Fix: added the column to all four product SELECTs
in repo.rs (list_products, get_product_by_slug, get_product_by_id —
one column list, replace_all). Added regression test
product_entitlements_catalog_round_trips_through_list_endpoint that
exercises the full PATCH → list round-trip the admin UI hits.
UX:
Drag-and-drop reordering on the tier-card grid. Operator drags any
tier card to a new position; on drop, parallel PATCH requests set
tier_rank 1..N based on the new visual order. Archived tiers are
excluded (their position in the ladder is moot). Edit-policy modal
retains the tier_rank number field for the two cases drag-and-drop
can't express (precise override + blank-to-remove-from-ladder).
Cursor signals grab/grabbing on hover/drag; dragging card lifts +
fades for visual feedback.
Copy:
Policies-tab section headers now show just the product name
("Keysat") instead of redundant "Keysat — keysat". Entitlements-
catalog row editor description placeholder shortened from
"Description (shown on buy page tooltip)" to "Description (buyer
tooltip)" so it fits the column; full hover hint kept on the
input's title attribute.
Test count: 87.
5610 lines
248 KiB
HTML
5610 lines
248 KiB
HTML
<!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; }
|
||
}
|
||
|
||
/* 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 → Keysat → Actions → <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 → Keysat → Actions → <em>Show admin API key</em>. Then set a web UI password via the <em>Set web UI password</em> action so you don’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…</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">···</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 you’re 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. You’ll 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.
|
||
*/
|
||
function helpIcon(text) {
|
||
return el('span', {
|
||
class: 'help-icon',
|
||
title: text,
|
||
tabindex: '0',
|
||
'aria-label': text,
|
||
style:
|
||
'display:inline-flex; align-items:center; justify-content:center; ' +
|
||
'width:14px; height:14px; border-radius:50%; ' +
|
||
'background:var(--ink-500); color:var(--cream-50); ' +
|
||
'font-size:10px; font-weight:700; font-family:var(--font-body); ' +
|
||
'cursor:help; margin-left:6px; user-select:none; flex:none;',
|
||
}, '?')
|
||
}
|
||
|
||
/**
|
||
* 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 app’s source so it verifies signatures offline. The key is also available at /v1/issuer/public-key.'),
|
||
el('div', {
|
||
style: 'background:var(--navy-950); color:var(--cream-50); padding:10px 12px; border-radius:7px; font-family:var(--font-mono); font-size:12px; display:flex; gap:10px; align-items:center; justify-content:space-between;',
|
||
}, [
|
||
el('span', { id: 'pubkey-preview' }, 'loading…'),
|
||
el('button', {
|
||
class: 'btn sm',
|
||
style: 'background:rgba(245,241,232,0.10); color:var(--cream-50); border:0;',
|
||
onclick: copyPubkey,
|
||
}, 'Copy'),
|
||
]),
|
||
]),
|
||
])
|
||
target.appendChild(pubkeyTip)
|
||
|
||
// Fill in stat values
|
||
try {
|
||
const j = await api('/v1/admin/revenue/summary').catch(() => null)
|
||
if (j) {
|
||
const fmt = (n) => fmtSatsCard(n) + ' sats'
|
||
sRevenue.querySelector('.value').textContent = fmt(j.total_sats || 0)
|
||
document.querySelector('#rev-total .rev-value').textContent = fmt(j.total_sats || 0)
|
||
document.querySelector('#rev-30d .rev-value').textContent = fmt(j.last_30d_sats || 0)
|
||
document.querySelector('#rev-7d .rev-value').textContent = fmt(j.last_7d_sats || 0)
|
||
document.querySelector('#rev-24h .rev-value').textContent = fmt(j.last_24h_sats || 0)
|
||
const c = j.settled_paid_invoice_count || 0
|
||
document.getElementById('rev-count').textContent =
|
||
c.toLocaleString() + ' settled paid invoice' + (c === 1 ? '' : 's')
|
||
} else {
|
||
sRevenue.querySelector('.value').textContent = '–'
|
||
}
|
||
} catch {}
|
||
try {
|
||
const j = await api('/v1/admin/licenses/summary').catch(() => null)
|
||
if (j && typeof j.active === 'number') {
|
||
sLicenses.querySelector('.value').textContent = j.active.toString()
|
||
} else {
|
||
sLicenses.querySelector('.value').textContent = '–'
|
||
}
|
||
} catch {}
|
||
try {
|
||
const j = await api('/v1/admin/discount-codes')
|
||
const codes = j.codes || []
|
||
sCodes.querySelector('.value').textContent = codes.length.toString()
|
||
} catch {}
|
||
try {
|
||
const j = await api('/v1/admin/webhook-endpoints')
|
||
const eps = j.endpoints || j.webhooks || []
|
||
sWebhooks.querySelector('.value').textContent = eps.length.toString()
|
||
} catch {}
|
||
try {
|
||
const s = await api('/v1/admin/btcpay/status')
|
||
const v = sBtc.querySelector('.value')
|
||
v.innerHTML = ''
|
||
if (s.connected) {
|
||
v.appendChild(el('span', { class: 'badge b-success', style: 'font-size:13px; padding:5px 12px' },
|
||
[el('span', { class: 'dot ok' }), 'Connected']))
|
||
v.appendChild(el('div', { class: 'sub', style: 'font-family:var(--font-mono); font-size:11px; margin-top:8px' },
|
||
'store ' + (s.store_id || '?').slice(0, 14) + '…'))
|
||
} else {
|
||
v.appendChild(el('span', { class: 'badge b-warning', style: 'font-size:13px; padding:5px 12px' },
|
||
[el('span', { class: 'dot warn' }), 'Not connected']))
|
||
v.appendChild(el('div', { class: 'sub', style: 'margin-top:8px' },
|
||
'Connect via StartOS Actions'))
|
||
}
|
||
} catch (e) {
|
||
sBtc.querySelector('.value').textContent = '?'
|
||
}
|
||
|
||
// Community analytics opt-in. Off by default. Compact strip so it
|
||
// doesn't compete with the operator's actual workspace cards. The
|
||
// "what's sent" disclosure expands inline; details deliberately
|
||
// tucked behind a click so the default view stays calm.
|
||
const analyticsStrip = el('div')
|
||
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:480px; width:100%; padding:24px; ' +
|
||
'box-shadow:0 0 0 1px var(--gold-500) inset, 0 16px 32px rgba(14,31,51,0.20);',
|
||
}, [
|
||
el('div', { class: 'eyebrow', style: 'margin-bottom:8px' }, 'Edit product'),
|
||
el('h3', { style: 'font-family:var(--font-display); font-weight:600; font-size:20px; margin:0 0 6px; color:var(--navy-950);' }, p.slug),
|
||
el('p', { class: 'muted', style: 'margin:0 0 16px; font-size:13px' },
|
||
'Slug is not editable — it is part of your public /buy/' + p.slug + ' URL. Disable + create a new product if you need to rename.'),
|
||
nameField,
|
||
descField,
|
||
el('label', { style: 'display:block; font-weight:600; font-size:13px; margin:12px 0 4px' }, 'Price'),
|
||
el('div', { style: 'display:flex; gap:8px; align-items:flex-start' }, [priceInput, curPicker]),
|
||
hint,
|
||
// Entitlements catalog — pre-filled from the loaded product.
|
||
// Operator can edit/add/remove rows; submit sends the full
|
||
// current catalog (closed list semantics).
|
||
el('div', { style: 'margin-top:18px; padding-top:14px; border-top:1px dashed var(--border-1)' }, [
|
||
editCatalog.element,
|
||
]),
|
||
status,
|
||
el('div', { style: 'display:flex; gap:10px; margin-top:14px;' }, [
|
||
el('button', { class: 'btn primary', onclick: async function () {
|
||
status.textContent = 'Saving…'
|
||
try {
|
||
const currency = curPicker.value
|
||
const rawValue = parseFloat(priceInput.value) || 0
|
||
const priceValue = currency === 'SAT' ? Math.round(rawValue) : Math.round(rawValue * 100)
|
||
const body = {
|
||
name: card.querySelector('[name=e_p_name]').value.trim(),
|
||
description: card.querySelector('[name=e_p_description]').value || '',
|
||
price_currency: currency,
|
||
price_value: Math.max(0, priceValue),
|
||
}
|
||
// Always send the catalog on edit so the operator can
|
||
// also CLEAR it (empty editor → null → drops back to
|
||
// free-text mode). The double-Option PATCH shape on
|
||
// the server treats null as "set to NULL", absent as
|
||
// "leave alone".
|
||
body.entitlements_catalog = editCatalog.read()
|
||
await api('/v1/admin/products/' + p.id, { method: 'PATCH', body })
|
||
overlay.remove()
|
||
routes.products()
|
||
} catch (e) {
|
||
status.textContent = e.message
|
||
status.style.color = 'var(--danger)'
|
||
}
|
||
} }, 'Save'),
|
||
el('button', { class: 'btn secondary', onclick: () => overlay.remove() }, 'Cancel'),
|
||
]),
|
||
])
|
||
overlay.appendChild(card)
|
||
overlay.addEventListener('click', (e) => { if (e.target === overlay) overlay.remove() })
|
||
document.body.appendChild(overlay)
|
||
}
|
||
|
||
routes.products = async function () {
|
||
const target = document.getElementById('route-target')
|
||
target.innerHTML = ''
|
||
|
||
// Create form. Currency picker swaps the price-input units in
|
||
// place: SAT → integer sats, USD/EUR → dollar/euro amount which
|
||
// we convert to cents on the way out (the backend stores
|
||
// smallest-unit-of-currency).
|
||
const currencyPicker = el('select', { class: 'input' }, [
|
||
el('option', { value: 'SAT' }, 'sats'),
|
||
el('option', { value: 'USD' }, 'USD ($)'),
|
||
el('option', { value: 'EUR' }, 'EUR (€)'),
|
||
])
|
||
const priceInput = el('input', {
|
||
class: 'input', name: 'price_input', type: 'number',
|
||
step: '1', min: '0', value: '50000', required: 'required',
|
||
})
|
||
const priceHint = el('div', { class: 'muted', style: 'font-size:12px; margin-top:4px' },
|
||
'sats — whole numbers only.')
|
||
currencyPicker.addEventListener('change', () => {
|
||
if (currencyPicker.value === 'SAT') {
|
||
priceInput.step = '1'
|
||
priceInput.value = '50000'
|
||
priceHint.textContent = 'sats — whole numbers only.'
|
||
} else {
|
||
priceInput.step = '0.01'
|
||
priceInput.value = '49.00'
|
||
priceHint.textContent =
|
||
currencyPicker.value === 'USD'
|
||
? 'Dollars — converted to BTC at invoice creation. Buyer pays the locked-in BTC amount.'
|
||
: 'Euros — converted to BTC at invoice creation. Buyer pays the locked-in BTC amount.'
|
||
}
|
||
})
|
||
const createCatalog = catalogEditor(null)
|
||
const create = el('details', { class: 'disclosure' }, [
|
||
el('summary', null, 'Create a new product'),
|
||
el('div', { class: 'body' }, [
|
||
// Name first — the slug field auto-derives from this as the
|
||
// operator types, so they only fill in one of them in the
|
||
// common case. Help icons replace the verbose hint copy.
|
||
formInput('name', 'Display name', {
|
||
required: true,
|
||
help: 'What buyers see on the buy page (e.g. "Bitcoin Ticker Pro").',
|
||
}),
|
||
formInput('slug', 'Slug', {
|
||
required: true,
|
||
help: 'Stable URL part — buyers see this in /buy/<slug>. Auto-fills from the display name; edit if needed. Lowercase letters, digits, hyphens.',
|
||
}),
|
||
formInput('description', 'Description', {
|
||
textarea: true,
|
||
help: 'One paragraph shown under the product name on the buy page. Optional — leave blank if the tier cards say enough.',
|
||
}),
|
||
el('label', { style: 'display:block; font-weight:600; font-size:13px; margin:12px 0 4px' }, 'Price'),
|
||
el('div', { style: 'display:flex; gap:8px; align-items:flex-start' }, [
|
||
priceInput,
|
||
currencyPicker,
|
||
]),
|
||
priceHint,
|
||
// Entitlements catalog — closed list of slugs the product
|
||
// offers. Policies pick from this list. See catalogEditor().
|
||
el('div', { style: 'margin-top:18px; padding-top:14px; border-top:1px dashed var(--border-1)' }, [
|
||
createCatalog.element,
|
||
]),
|
||
el('button', { class: 'btn primary', style: 'margin-top:16px' }, 'Create product').addEventListener
|
||
? null : null, // dummy; the real button is below for clarity
|
||
(() => {
|
||
const btn = el('button', { class: 'btn primary', style: 'margin-top:16px' }, 'Create product')
|
||
btn.addEventListener('click', async () => {
|
||
const status = el('div', { class: 'muted', style: 'margin-top:8px' }, 'Creating…')
|
||
create.querySelector('.body').appendChild(status)
|
||
try {
|
||
const currency = currencyPicker.value
|
||
const rawValue = parseFloat(priceInput.value)
|
||
if (!Number.isFinite(rawValue) || rawValue <= 0) {
|
||
throw new Error('Price must be a positive number.')
|
||
}
|
||
// SAT/BTC are sat-denominated already; USD/EUR are
|
||
// entered as decimal amounts and converted to cents.
|
||
const priceValue = currency === 'SAT' ? Math.round(rawValue) : Math.round(rawValue * 100)
|
||
const catalog = createCatalog.read()
|
||
const body = {
|
||
slug: create.querySelector('[name=slug]').value.trim(),
|
||
name: create.querySelector('[name=name]').value.trim(),
|
||
description: create.querySelector('[name=description]').value || '',
|
||
price_currency: currency,
|
||
price_value: priceValue,
|
||
metadata: {},
|
||
}
|
||
if (catalog) body.entitlements_catalog = catalog
|
||
await api('/v1/admin/products', { method: 'POST', body })
|
||
status.replaceWith(ok('Created. Reloading…'))
|
||
setTimeout(routes.products, 600)
|
||
} catch (e) {
|
||
if (handleTierCap(e)) status.remove()
|
||
else status.replaceWith(err(e.message))
|
||
}
|
||
})
|
||
return btn
|
||
})(),
|
||
].filter(Boolean)),
|
||
])
|
||
target.appendChild(plainCard([
|
||
el('div', { class: 'eyebrow', style: 'margin-bottom:8px' }, 'About'),
|
||
el('p', { class: 'muted', style: 'margin:0 0 16px' },
|
||
'A product is anything you sell. Each product has a public purchase URL at /buy/<slug> and zero or more policies that determine what kind of license is issued.'),
|
||
create,
|
||
]))
|
||
|
||
// Auto-slug: as the operator types a Display Name, mirror a
|
||
// slugified version into the Slug field — UNLESS they've manually
|
||
// edited the slug. Tracked via a "userOverridden" flag set on
|
||
// the slug input's `input` event. Manual edits stick; clearing
|
||
// the slug back to "" re-arms the auto-fill.
|
||
const nameInput = create.querySelector('[name=name]')
|
||
const slugInput = create.querySelector('[name=slug]')
|
||
let slugUserOverridden = false
|
||
if (nameInput && slugInput) {
|
||
slugInput.addEventListener('input', () => {
|
||
slugUserOverridden = slugInput.value.trim().length > 0
|
||
})
|
||
nameInput.addEventListener('input', () => {
|
||
if (!slugUserOverridden) slugInput.value = slugify(nameInput.value)
|
||
})
|
||
}
|
||
|
||
try {
|
||
const [j, counts] = await Promise.all([
|
||
api('/v1/products'),
|
||
api('/v1/admin/licenses/counts').catch(() => ({ by_product: {}, by_policy: {} })),
|
||
])
|
||
const products = j.products || j || []
|
||
const byProduct = (counts && counts.by_product) || {}
|
||
const rows = products.map((p) => el('tr', null, [
|
||
el('td', null, el('code', null, p.slug)),
|
||
el('td', { style: 'font-weight:600; color:var(--navy-950)' }, p.name),
|
||
el('td', null, formatProductPrice(p)),
|
||
el('td', null, el('span', { class: 'muted' }, String(byProduct[p.id] || 0))),
|
||
el('td', null, activePill(p.active)),
|
||
el('td', { class: 'muted' }, fmtDate(p.created_at)),
|
||
el('td', null, el('div', { class: 'actions-row' }, [
|
||
el('button', {
|
||
class: 'btn sm secondary',
|
||
onclick: function () { openEditProduct(p) },
|
||
}, 'Edit'),
|
||
el('button', {
|
||
class: 'btn sm danger',
|
||
title: 'Delete this product. Safe by default; offers a force-delete with cascade if the product has licenses or invoices.',
|
||
onclick: function () {
|
||
safeOrForceDelete({
|
||
kind: 'product',
|
||
slug: p.slug,
|
||
pathBase: '/v1/admin/products/' + p.id,
|
||
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 nameField = formInput('e_pol_name', 'Display name', { value: pol.name || '', required: true })
|
||
const descField = formInput('e_pol_description', 'Tier description (optional)', {
|
||
value: description,
|
||
hint: 'Shown on the tier card on /buy/' + (prod.slug || '<product>') + '. One sentence.',
|
||
})
|
||
const priceField = formInput('e_pol_price', 'Price (sats)', {
|
||
type: 'number',
|
||
value: String(pol.price_sats_override == null ? prod.price_sats || 0 : pol.price_sats_override),
|
||
hint: 'Override for this tier. 0 = free.',
|
||
})
|
||
const presetSel = formSelect('e_pol_preset', 'Duration', DURATION_PRESETS, { required: true, value: initialPreset })
|
||
const customDur = formInput('e_pol_custom', 'Custom (seconds)', { type: 'number', value: String(dur) })
|
||
const graceField = formInput('e_pol_grace', 'Grace period (days)', {
|
||
type: 'number',
|
||
value: String(Math.floor((pol.grace_seconds || 0) / 86400)),
|
||
})
|
||
const machinesField = formInput('e_pol_machines', 'Max devices (0 = unlimited)', {
|
||
type: 'number', value: String(pol.max_machines == null ? 1 : pol.max_machines),
|
||
})
|
||
// Entitlements input: bubble picker against the product's catalog
|
||
// (closed-list mode) when one exists, else legacy free-text
|
||
// textarea. The picker pre-selects the policy's current
|
||
// entitlements; the textarea pre-fills with one slug per line.
|
||
const editCatalog_pol = (prod && prod.entitlements_catalog) || []
|
||
const entField = (() => {
|
||
const host = el('div', { 'data-ent-host': '1' })
|
||
if (editCatalog_pol.length > 0) {
|
||
const picker = entitlementBubblePicker(editCatalog_pol, pol.entitlements || [])
|
||
host.appendChild(picker.element)
|
||
host._read = picker.read
|
||
host._mode = 'bubbles'
|
||
} else {
|
||
const fallback = formInput('e_pol_entitlements', 'Entitlements (one per line, or comma-separated)', {
|
||
textarea: true,
|
||
value: (pol.entitlements || []).join('\n'),
|
||
hint: 'Examples: self_host, ai_summaries, export. No quotes or brackets.',
|
||
})
|
||
host.appendChild(fallback)
|
||
host._mode = 'textarea'
|
||
}
|
||
return host
|
||
})()
|
||
const highlightField = formCheckbox('e_pol_highlight', 'Mark as "Most popular" on the tier picker')
|
||
if (highlight) setTimeout(() => {
|
||
const cb = card.querySelector('[name=e_pol_highlight]')
|
||
if (cb) cb.checked = true
|
||
}, 0)
|
||
const trialField = formCheckbox('e_pol_trial', 'Trial flag')
|
||
if (pol.is_trial) setTimeout(() => {
|
||
const cb = card.querySelector('[name=e_pol_trial]')
|
||
if (cb) cb.checked = true
|
||
}, 0)
|
||
|
||
// -- Recurring subscription (Pro tier) --
|
||
const RENEWAL_PRESETS = [
|
||
{ value: '30', label: 'Monthly (30 days)' },
|
||
{ value: '90', label: 'Quarterly (90 days)' },
|
||
{ value: '180', label: 'Semi-annual (180 days)' },
|
||
{ value: '365', label: 'Annual (365 days)' },
|
||
{ value: 'custom', label: 'Custom (in days)' },
|
||
]
|
||
const isRecurringInit = !!pol.is_recurring
|
||
const renewalDaysInit = pol.renewal_period_days || 30
|
||
const matchedRenewal = RENEWAL_PRESETS.find(
|
||
(p) => p.value === String(renewalDaysInit) && p.value !== 'custom'
|
||
)
|
||
const initialRenewalPreset = matchedRenewal ? matchedRenewal.value : 'custom'
|
||
const recurField = formCheckbox('e_pol_is_recurring', 'This policy is a recurring subscription')
|
||
const renewalPresetField = formSelect('e_pol_renewal_preset', 'Renewal cadence', RENEWAL_PRESETS, { value: initialRenewalPreset })
|
||
const renewalCustomField = formInput('e_pol_renewal_days', 'Custom (days)', {
|
||
type: 'number', value: String(renewalDaysInit),
|
||
})
|
||
const gracePeriodField = formInput('e_pol_grace_period_days', 'Grace period after renewal (days)', {
|
||
type: 'number', value: String(pol.grace_period_days == null ? 7 : pol.grace_period_days),
|
||
})
|
||
const trialDaysField = formInput('e_pol_trial_days', 'Free trial (days)', {
|
||
type: 'number', value: String(pol.trial_days || 0),
|
||
})
|
||
// Tier-ladder rank. Empty input means "not in any ladder" (server
|
||
// stores NULL); operator can blank it to remove a policy from the
|
||
// ladder, or set a number to add it. Range 0–1000 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,
|
||
el('div', { class: 'row-2' }, [highlightField, trialField]),
|
||
// Tier ladder rank — sits in its own row above the recurring section.
|
||
tierRankField,
|
||
// Recurring subscription block
|
||
el('div', {
|
||
style: 'margin-top:14px; padding-top:12px; border-top:1px dashed var(--border-1)',
|
||
}, [
|
||
el('div', { class: 'eyebrow', style: 'margin-bottom:6px' }, 'Recurring subscription (Pro)'),
|
||
recurField,
|
||
el('div', { class: 'row-2', style: 'margin-top:8px' }, [renewalPresetField, renewalCustomField]),
|
||
el('div', { class: 'row-2' }, [gracePeriodField, trialDaysField]),
|
||
]),
|
||
status,
|
||
el('div', { style: 'display:flex; gap:10px; margin-top:14px;' }, [
|
||
el('button', { class: 'btn primary', onclick: async function () {
|
||
status.textContent = 'Saving…'
|
||
status.style.color = ''
|
||
try {
|
||
const presetV = card.querySelector('[name=e_pol_preset]').value
|
||
const customV = parseInt(card.querySelector('[name=e_pol_custom]').value, 10) || 0
|
||
const duration_seconds = presetV === 'custom' ? customV : parseInt(presetV, 10)
|
||
const grace_days = parseInt(card.querySelector('[name=e_pol_grace]').value, 10) || 0
|
||
const grace_seconds = grace_days * 86400
|
||
// Read from whichever mode the entitlements host is in
|
||
// (bubble picker vs textarea fallback). _read is set by
|
||
// entitlementBubblePicker; absence = textarea.
|
||
const entHost = card.querySelector('[data-ent-host]')
|
||
let ents
|
||
if (entHost && entHost._mode === 'bubbles' && entHost._read) {
|
||
ents = entHost._read()
|
||
} else {
|
||
const rawEnts = card.querySelector('[name=e_pol_entitlements]').value || ''
|
||
ents = Array.from(new Set(
|
||
rawEnts.replace(/[\[\]"'`]/g, '').split(/[\n,]/).map((s) => s.trim()).filter(Boolean)
|
||
))
|
||
}
|
||
const newDescription = (card.querySelector('[name=e_pol_description]').value || '').trim()
|
||
const newHighlight = card.querySelector('[name=e_pol_highlight]').checked
|
||
// Preserve any other metadata keys we don't manage in the form.
|
||
const newMetadata = Object.assign({}, meta)
|
||
if (newDescription) newMetadata.description = newDescription
|
||
else delete newMetadata.description
|
||
if (newHighlight) newMetadata.highlight = true
|
||
else delete newMetadata.highlight
|
||
const priceRaw = card.querySelector('[name=e_pol_price]').value.trim()
|
||
const price_sats_override = priceRaw === '' ? null : Math.max(0, parseInt(priceRaw, 10) || 0)
|
||
// Recurring subscription — send the fields whenever the operator
|
||
// touched any of them so update_policy can validate the post-update
|
||
// shape consistently. Easiest invariant: always send all four.
|
||
const isRecurring = card.querySelector('[name=e_pol_is_recurring]').checked
|
||
const renewalPreset = card.querySelector('[name=e_pol_renewal_preset]').value
|
||
const renewalCustom = parseInt(card.querySelector('[name=e_pol_renewal_days]').value, 10) || 0
|
||
const renewalDays = renewalPreset === 'custom'
|
||
? renewalCustom
|
||
: parseInt(renewalPreset, 10)
|
||
const gracePeriodDays = parseInt(card.querySelector('[name=e_pol_grace_period_days]').value, 10)
|
||
const trialDays = parseInt(card.querySelector('[name=e_pol_trial_days]').value, 10) || 0
|
||
|
||
const body = {
|
||
name: card.querySelector('[name=e_pol_name]').value.trim(),
|
||
duration_seconds,
|
||
grace_seconds,
|
||
max_machines: parseInt(card.querySelector('[name=e_pol_machines]').value, 10) || 0,
|
||
is_trial: card.querySelector('[name=e_pol_trial]').checked,
|
||
entitlements: ents,
|
||
metadata: newMetadata,
|
||
price_sats_override,
|
||
is_recurring: isRecurring,
|
||
renewal_period_days: isRecurring ? renewalDays : (pol.renewal_period_days || 0),
|
||
grace_period_days: isNaN(gracePeriodDays) ? 7 : gracePeriodDays,
|
||
trial_days: trialDays,
|
||
}
|
||
// tier_rank is a nullable patch. Empty input → null
|
||
// (remove from ladder). Number → set. We always send
|
||
// the field on edit so the server's "patch touched
|
||
// field?" logic fires and the operator's intent (clear
|
||
// or set) lands.
|
||
const tierRankRaw = (card.querySelector('[name=e_pol_tier_rank]').value || '').trim()
|
||
body.tier_rank = tierRankRaw === ''
|
||
? null
|
||
: Math.max(0, Math.min(1000, parseInt(tierRankRaw, 10) || 0))
|
||
await api('/v1/admin/policies/' + pol.id, { method: 'PATCH', body })
|
||
overlay.remove()
|
||
routes.policies()
|
||
} catch (e) {
|
||
status.textContent = e.message
|
||
status.style.color = 'var(--danger)'
|
||
}
|
||
} }, 'Save'),
|
||
el('button', { class: 'btn secondary', onclick: () => overlay.remove() }, 'Cancel'),
|
||
]),
|
||
])
|
||
overlay.appendChild(card)
|
||
overlay.addEventListener('click', (e) => { if (e.target === overlay) overlay.remove() })
|
||
document.body.appendChild(overlay)
|
||
|
||
// Wire the recurring section's enable/disable sync now that the card
|
||
// is in the DOM and inputs are queryable.
|
||
const recurEl = card.querySelector('[name=e_pol_is_recurring]')
|
||
const renewalPresetEl = card.querySelector('[name=e_pol_renewal_preset]')
|
||
if (recurEl) recurEl.addEventListener('change', syncRecurringEdit)
|
||
if (renewalPresetEl) renewalPresetEl.addEventListener('change', syncRecurringEdit)
|
||
syncRecurringEdit()
|
||
}
|
||
|
||
// ---------- Policy tier-card grid (Phase 2 of v0.2.0:9) ----------
|
||
//
|
||
// Replaces the older table-based render of policies with a card
|
||
// grid that mirrors the buy page's tier cards. Operators get a
|
||
// side-by-side visual comparison of their tiers + an inline
|
||
// "+ Add tier" card that morphs into an editable draft card on
|
||
// click — multiple drafts can exist simultaneously.
|
||
|
||
/**
|
||
* Format a price for display on a tier card. Returns
|
||
* `{ amount, unit }` so the card can render the amount in a
|
||
* larger font and the unit beside it.
|
||
*/
|
||
function fmtTierPrice(pol, product) {
|
||
if (product.price_currency === 'SAT' || !product.price_currency) {
|
||
const sats = pol.price_sats_override != null ? pol.price_sats_override : product.price_sats
|
||
return { amount: Number(sats || 0).toLocaleString('en-US'), unit: 'sats' }
|
||
}
|
||
const cents = pol.price_sats_override != null ? pol.price_sats_override : product.price_value
|
||
return { amount: ((cents || 0) / 100).toFixed(2), unit: product.price_currency }
|
||
}
|
||
|
||
function fmtCadenceSuffix(pol) {
|
||
if (!pol.is_recurring) return ''
|
||
const d = pol.renewal_period_days || 0
|
||
if (d === 7) return ' / wk'
|
||
if (d === 30) return ' / mo'
|
||
if (d === 90) return ' / qtr'
|
||
if (d === 180) return ' / 6mo'
|
||
if (d === 365) return ' / yr'
|
||
return d > 0 ? (' / ' + d + 'd') : ''
|
||
}
|
||
|
||
/**
|
||
* Read-only tier card showing an existing policy. Visual matches
|
||
* the buy page's tier cards; bottom action row exposes Edit /
|
||
* Hide-Show / Delete and the public-vs-private + active state.
|
||
*/
|
||
function renderTierCard(pol, product, onMutate) {
|
||
const meta = pol.metadata || {}
|
||
const highlighted = !!meta.highlight
|
||
const description = (typeof meta.description === 'string') ? meta.description : ''
|
||
const price = fmtTierPrice(pol, product)
|
||
const cadenceSuffix = fmtCadenceSuffix(pol)
|
||
|
||
const popularPill = highlighted
|
||
? el('div', {
|
||
style:
|
||
'position:absolute; top:-10px; left:50%; transform:translateX(-50%); ' +
|
||
'background:var(--gold-500); color:var(--navy-950); ' +
|
||
'font-family:var(--font-body); font-size:10px; font-weight:700; ' +
|
||
'letter-spacing:0.16em; text-transform:uppercase; ' +
|
||
'padding:3px 9px; border-radius:999px; white-space:nowrap;',
|
||
}, 'Most popular')
|
||
: null
|
||
|
||
const durationLine = pol.duration_seconds === 0
|
||
? 'Perpetual'
|
||
: (() => {
|
||
const days = Math.floor(pol.duration_seconds / 86400)
|
||
if (days >= 1) return days + ' days'
|
||
const hours = Math.floor(pol.duration_seconds / 3600)
|
||
return Math.max(1, hours) + ' hours'
|
||
})()
|
||
|
||
const recurringMeta = pol.is_recurring
|
||
? el('div', { class: 'muted', style: 'font-size:12px' },
|
||
(() => {
|
||
const d = pol.renewal_period_days || 0
|
||
if (d === 7) return 'Renews weekly'
|
||
if (d === 30) return 'Renews monthly'
|
||
if (d === 90) return 'Renews quarterly'
|
||
if (d === 180) return 'Renews semi-annually'
|
||
if (d === 365) return 'Renews annually'
|
||
return 'Renews every ' + d + ' days'
|
||
})())
|
||
: null
|
||
|
||
const trialBanner = (pol.is_recurring && (pol.trial_days || 0) > 0)
|
||
? el('div', { style: 'font-size:12px; color:var(--gold-700); font-weight:600' },
|
||
pol.trial_days + ' day free trial')
|
||
: null
|
||
|
||
// Entitlements as small chips with display name + tooltip.
|
||
const cat = product.entitlements_catalog || []
|
||
const entChips = (pol.entitlements || []).length === 0
|
||
? null
|
||
: el('ul', {
|
||
style: 'list-style:none; padding:0; margin:8px 0 0; font-size:12.5px; color:var(--ink-700)',
|
||
}, (pol.entitlements || []).map((slug) => {
|
||
const entry = cat.find((c) => c.slug === slug)
|
||
const display = entry && entry.name ? entry.name : slug
|
||
const desc = entry && entry.description ? entry.description : slug
|
||
return el('li', {
|
||
title: desc,
|
||
style: 'padding:2px 0 2px 16px; position:relative',
|
||
}, [
|
||
el('span', {
|
||
style: 'position:absolute; left:0; top:2px; color:var(--gold-700); font-weight:700',
|
||
}, '✓'),
|
||
display,
|
||
])
|
||
}))
|
||
|
||
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,
|
||
el('div', {
|
||
style: 'font-family:var(--font-display); font-weight:600; font-size:18px; color:var(--navy-950); letter-spacing:-0.01em',
|
||
}, pol.name),
|
||
el('div', {
|
||
class: 'muted',
|
||
style: 'font-family:var(--font-mono); font-size:11px',
|
||
}, pol.slug),
|
||
el('div', {
|
||
style: 'font-family:var(--font-display); font-weight:700; font-size:24px; color:var(--navy-950); letter-spacing:-0.02em; line-height:1.1; margin-top:6px',
|
||
}, [
|
||
price.amount,
|
||
el('span', {
|
||
style: 'font-family:var(--font-body); font-size:12px; font-weight:500; color:var(--ink-500); margin-left:6px',
|
||
}, price.unit + cadenceSuffix),
|
||
]),
|
||
el('div', { class: 'muted', style: 'font-size:12px' }, durationLine),
|
||
recurringMeta,
|
||
trialBanner,
|
||
pillsRow,
|
||
description ? el('p', {
|
||
style: 'font-size:13px; line-height:1.45; color:var(--ink-700); margin:6px 0 0',
|
||
}, description) : null,
|
||
entChips,
|
||
el('div', { class: 'muted', style: 'font-size:11px; margin-top:4px' },
|
||
(pol._license_count || 0) + ' license' + ((pol._license_count || 0) === 1 ? '' : 's')),
|
||
actions,
|
||
])
|
||
}
|
||
|
||
/**
|
||
* Empty placeholder card with a "+" affordance. On click, the
|
||
* caller should swap this card with a draft card via
|
||
* renderDraftTierCard.
|
||
*/
|
||
function renderAddTierCard(onClick) {
|
||
const card = el('button', {
|
||
type: 'button',
|
||
class: 'add-tier-card',
|
||
style:
|
||
'background:transparent; border:2px dashed var(--border-2); ' +
|
||
'border-radius:12px; padding:22px 20px; cursor:pointer; ' +
|
||
'min-height:280px; display:flex; flex-direction:column; ' +
|
||
'align-items:center; justify-content:center; gap:8px; ' +
|
||
'color:var(--ink-500); font-family:var(--font-body); ' +
|
||
'transition:all 120ms;',
|
||
}, [
|
||
el('div', { style: 'font-size:42px; line-height:1; font-weight:300' }, '+'),
|
||
el('div', { style: 'font-size:13px; font-weight:600' }, 'Add tier'),
|
||
el('div', { style: 'font-size:11px; max-width:160px; text-align:center' },
|
||
'Create a new policy on this product'),
|
||
])
|
||
card.addEventListener('mouseenter', () => {
|
||
card.style.borderColor = 'var(--gold-500)'
|
||
card.style.color = 'var(--navy-800)'
|
||
})
|
||
card.addEventListener('mouseleave', () => {
|
||
card.style.borderColor = 'var(--border-2)'
|
||
card.style.color = 'var(--ink-500)'
|
||
})
|
||
card.addEventListener('click', onClick)
|
||
return card
|
||
}
|
||
|
||
/**
|
||
* Inline-editable draft tier card. Same outer dimensions as
|
||
* renderTierCard so drafts sit visually side-by-side with
|
||
* existing policies. Form fields are compact: name + slug + price,
|
||
* a "More options" disclosure for the rest (duration, max
|
||
* devices, recurring, trial, tier rank), and the entitlements
|
||
* bubble picker against the product's catalog (or fallback
|
||
* textarea when no catalog).
|
||
*/
|
||
function renderDraftTierCard(product, onCommit, onCancel) {
|
||
// Compact inputs. Help icons replace per-field hint text to
|
||
// keep the card narrow.
|
||
const nameInput = el('input', {
|
||
class: 'input', placeholder: 'Display name (e.g. Pro)', required: 'required',
|
||
})
|
||
const slugInput = el('input', {
|
||
class: 'input mono', placeholder: 'slug',
|
||
})
|
||
let slugUserOverridden = false
|
||
slugInput.addEventListener('input', () => {
|
||
slugUserOverridden = slugInput.value.trim().length > 0
|
||
})
|
||
nameInput.addEventListener('input', () => {
|
||
if (!slugUserOverridden) slugInput.value = slugify(nameInput.value)
|
||
})
|
||
|
||
// Price override: defaults to product base price, displayed in
|
||
// the right unit. Operator can edit.
|
||
const isSat = (product.price_currency === 'SAT' || !product.price_currency)
|
||
const initialPrice = isSat
|
||
? String(product.price_sats || 0)
|
||
: (((product.price_value || 0) / 100).toFixed(2))
|
||
const priceInput = el('input', {
|
||
class: 'input', type: 'number',
|
||
step: isSat ? '1' : '0.01',
|
||
min: '0', value: initialPrice,
|
||
style: 'flex:1',
|
||
})
|
||
const priceUnit = el('span', {
|
||
class: 'muted',
|
||
style: 'font-size:12px; align-self:center',
|
||
}, isSat ? 'sats' : product.price_currency)
|
||
|
||
// Duration preset
|
||
const DURATION_PRESETS = [
|
||
{ value: '0', label: 'Perpetual' },
|
||
{ value: '604800', label: '7 days' },
|
||
{ value: '2592000', label: '30 days' },
|
||
{ value: '7776000', label: '90 days' },
|
||
{ value: '31536000', label: '1 year' },
|
||
]
|
||
const durationSel = el('select', { class: 'select' })
|
||
DURATION_PRESETS.forEach((p) => durationSel.appendChild(el('option', { value: p.value }, p.label)))
|
||
durationSel.value = '0'
|
||
|
||
const maxMachinesInput = el('input', {
|
||
class: 'input', type: 'number', min: '0', value: '1',
|
||
style: 'flex:1',
|
||
})
|
||
|
||
// Entitlements: bubble picker (closed list) when product has a
|
||
// catalog; legacy textarea otherwise.
|
||
const cat = product.entitlements_catalog || []
|
||
const entHost = el('div')
|
||
let entRead = () => []
|
||
if (cat.length > 0) {
|
||
const picker = entitlementBubblePicker(cat, [])
|
||
entHost.appendChild(picker.element)
|
||
entRead = picker.read
|
||
} else {
|
||
const textarea = el('textarea', {
|
||
class: 'input', rows: '2',
|
||
placeholder: 'comma-separated entitlement slugs',
|
||
})
|
||
entHost.appendChild(textarea)
|
||
entRead = () => textarea.value
|
||
.replace(/[\[\]"'`]/g, '')
|
||
.split(/[\n,]/).map((s) => s.trim()).filter(Boolean)
|
||
}
|
||
|
||
// Recurring + trial toggles
|
||
const recurringCb = el('input', { type: 'checkbox' })
|
||
const trialDaysInput = el('input', {
|
||
class: 'input', type: 'number', min: '0', value: '0', style: 'width:60px',
|
||
})
|
||
const renewalPeriodInput = el('input', {
|
||
class: 'input', type: 'number', min: '1', value: '30', style: 'width:60px',
|
||
})
|
||
|
||
// Tip recipient (advanced — collapsed by default to keep the
|
||
// card narrow).
|
||
const status = el('div', { style: 'font-size:12px; min-height:16px' }, '')
|
||
|
||
function fieldRow(label, helpText, control) {
|
||
return el('div', { style: 'display:flex; flex-direction:column; gap:3px; margin-top:8px' }, [
|
||
el('label', { class: 'lbl', style: 'display:flex; align-items:center; font-size:11.5px; margin:0' }, [
|
||
label,
|
||
helpText ? helpIcon(helpText) : null,
|
||
].filter(Boolean)),
|
||
control,
|
||
])
|
||
}
|
||
|
||
// Build the card body
|
||
const body = el('div', { style: 'display:flex; flex-direction:column; gap:0' }, [
|
||
el('div', { class: 'eyebrow', style: 'margin-bottom:6px' }, 'New tier'),
|
||
fieldRow('Display name', 'What buyers see (e.g. "Pro").', nameInput),
|
||
fieldRow('Slug', 'Stable id used by SDK; auto-fills from name. Lowercase, digits, hyphens.', slugInput),
|
||
fieldRow('Price', 'Override for this tier. Pre-filled with product base price.',
|
||
el('div', { style: 'display:flex; gap:6px' }, [priceInput, priceUnit])),
|
||
fieldRow('Duration', 'How long the issued license is valid. Perpetual = no expiry.', durationSel),
|
||
fieldRow('Max devices', '1 = single seat; 0 = unlimited; n = n-seat.', maxMachinesInput),
|
||
fieldRow('Entitlements', cat.length > 0
|
||
? 'Click to toggle. Defined on the product\'s catalog.'
|
||
: 'Comma-separated slugs. Define a product catalog for click-to-pick.',
|
||
entHost),
|
||
// Recurring section — minimal, expanded inline (no nested
|
||
// disclosure; cards already imply compactness).
|
||
el('div', { style: 'display:flex; align-items:center; gap:6px; margin-top:8px' }, [
|
||
recurringCb,
|
||
el('label', { class: 'lbl', style: 'margin:0; font-size:11.5px; display:flex; align-items:center' }, [
|
||
'Recurring subscription',
|
||
helpIcon('Bills the buyer on a repeating cycle. Pro tier required.'),
|
||
]),
|
||
]),
|
||
el('div', {
|
||
'data-recurring-detail': '1',
|
||
style: 'display:none; padding:8px 0 0 18px; border-left:2px solid var(--border-1); margin-left:6px',
|
||
}, [
|
||
el('div', { style: 'display:flex; gap:8px; align-items:center; font-size:11.5px' }, [
|
||
'Renew every',
|
||
renewalPeriodInput,
|
||
'days',
|
||
]),
|
||
el('div', { style: 'display:flex; gap:8px; align-items:center; font-size:11.5px; margin-top:6px' }, [
|
||
'Free trial',
|
||
trialDaysInput,
|
||
'days',
|
||
]),
|
||
]),
|
||
status,
|
||
el('div', { style: 'display:flex; gap:6px; margin-top:auto; padding-top:10px; border-top:1px solid var(--border-1)' }, [
|
||
(() => {
|
||
const btn = el('button', { class: 'btn sm primary' }, 'Create')
|
||
btn.addEventListener('click', async () => {
|
||
status.textContent = 'Creating…'
|
||
status.style.color = 'var(--ink-500)'
|
||
btn.disabled = true
|
||
try {
|
||
const isRecurring = recurringCb.checked
|
||
const body = {
|
||
product_slug: product.slug,
|
||
slug: slugInput.value.trim(),
|
||
name: nameInput.value.trim(),
|
||
duration_seconds: parseInt(durationSel.value, 10) || 0,
|
||
grace_seconds: 0,
|
||
max_machines: parseInt(maxMachinesInput.value, 10),
|
||
is_trial: false,
|
||
entitlements: Array.isArray(entRead()) ? entRead() : entRead(),
|
||
metadata: {},
|
||
price_sats_override: isSat
|
||
? Math.max(0, parseInt(priceInput.value, 10) || 0)
|
||
: Math.max(0, Math.round(parseFloat(priceInput.value) * 100) || 0),
|
||
}
|
||
if (isRecurring) {
|
||
body.is_recurring = true
|
||
body.renewal_period_days = parseInt(renewalPeriodInput.value, 10) || 30
|
||
body.grace_period_days = 7
|
||
body.trial_days = parseInt(trialDaysInput.value, 10) || 0
|
||
}
|
||
await api('/v1/admin/policies', { method: 'POST', body })
|
||
onCommit && onCommit()
|
||
} catch (e) {
|
||
if (handleTierCap(e)) {
|
||
status.textContent = ''
|
||
} else {
|
||
status.textContent = e.message
|
||
status.style.color = 'var(--danger)'
|
||
btn.disabled = false
|
||
}
|
||
}
|
||
})
|
||
return btn
|
||
})(),
|
||
(() => {
|
||
const btn = el('button', { class: 'btn sm secondary' }, 'Cancel')
|
||
btn.addEventListener('click', () => onCancel && onCancel())
|
||
return btn
|
||
})(),
|
||
]),
|
||
])
|
||
|
||
// Toggle the recurring detail block
|
||
recurringCb.addEventListener('change', () => {
|
||
const det = body.querySelector('[data-recurring-detail]')
|
||
if (det) det.style.display = recurringCb.checked ? 'block' : 'none'
|
||
})
|
||
|
||
return el('div', {
|
||
class: 'tier-card draft',
|
||
style:
|
||
'position:relative; background:#fff; ' +
|
||
'border:2px dashed var(--gold-500); border-radius:12px; ' +
|
||
'padding:18px 16px; min-height:280px; ' +
|
||
'box-shadow:0 0 0 1px rgba(191,160,104,0.06); ' +
|
||
'display:flex; flex-direction:column;',
|
||
}, [body])
|
||
}
|
||
|
||
/**
|
||
* The card grid for one product. Renders each existing policy
|
||
* as a tier card + an "+ Add tier" card on the right. Click to
|
||
* add → morphs into a draft card; multiple drafts can coexist.
|
||
*/
|
||
function renderPolicyCardGrid(product, policies, byPolicyCounts, onMutate) {
|
||
const grid = el('div', {
|
||
style:
|
||
'display:grid; grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); ' +
|
||
'gap:14px; margin-top:12px;',
|
||
})
|
||
|
||
// 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.
|
||
function makePlaceholder() {
|
||
const placeholder = renderAddTierCard(() => {
|
||
const draft = renderDraftTierCard(
|
||
product,
|
||
() => onMutate && onMutate(), // commit → reload (rebuilds grid)
|
||
() => grid.replaceChild(makePlaceholder(), draft), // cancel → swap back
|
||
)
|
||
grid.replaceChild(draft, placeholder)
|
||
grid.appendChild(makePlaceholder())
|
||
})
|
||
return placeholder
|
||
}
|
||
grid.appendChild(makePlaceholder())
|
||
|
||
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 0–1000.',
|
||
}),
|
||
|
||
// ---------- 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(plainCard([
|
||
el('div', { style: 'display:flex; align-items:center; gap:14px; flex-wrap:wrap' }, [
|
||
el('p', { class: 'muted', style: 'margin:0; flex:1; min-width:280px' },
|
||
'Reusable license templates per product. The policy slugged "default" is consumed by the public purchase flow; other slugs are used by manual license issuance. Click the dashed "+ Add tier" card to author a new policy. Drag tier cards left/right to reorder — the ladder rank used by tier-upgrade flow follows the visual order.'),
|
||
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 = ''
|
||
|
||
function amountHint(kind, currency) {
|
||
if (kind === 'percent') return 'percent off, e.g. 50 = 50%. Range: 1–100. (Currency-agnostic.)'
|
||
if (kind === 'free_license') return 'amount is ignored — buyer pays nothing.'
|
||
const unit = currency === 'SAT' ? 'sats' : currency === 'USD' ? 'USD' : currency === 'EUR' ? 'EUR' : 'units'
|
||
const decimals = currency === 'SAT' ? '' : ' (decimals OK, e.g. 9.99)'
|
||
if (kind === 'fixed_sats') return `${unit} subtracted from the base price${decimals}.`
|
||
if (kind === 'set_price') return `flat price the buyer pays in ${unit}${decimals}. If higher than base, the code provides no benefit.`
|
||
return ''
|
||
}
|
||
|
||
const create = el('details', { class: 'disclosure' }, [
|
||
el('summary', null, 'Create a new code'),
|
||
el('div', { class: 'body' }, [
|
||
el('div', { class: 'row-2' }, [
|
||
formInput('code', 'Code', { required: true, hint: 'will be uppercased, e.g. FOUNDERS50' }),
|
||
formSelect('kind', 'Kind', [
|
||
{ value: 'percent', label: 'Percent off' },
|
||
{ value: 'fixed_sats', label: 'Fixed amount off' },
|
||
{ value: 'set_price', label: 'Set flat price' },
|
||
{ value: 'free_license', label: 'Free license (no payment)' },
|
||
], { required: true, value: 'percent' }),
|
||
]),
|
||
el('div', { class: 'row-2' }, [
|
||
formInput('amount', 'Amount', { type: 'number', step: '1', value: '50', hint: amountHint('percent', 'SAT') }),
|
||
formSelect('discount_currency', 'Currency', [
|
||
{ value: 'SAT', label: 'sats' },
|
||
{ value: 'USD', label: 'USD ($)' },
|
||
{ value: 'EUR', label: 'EUR (€)' },
|
||
], { value: 'SAT', hint: 'matters for "fixed amount off" and "set flat price" — ignored for percent / free.' }),
|
||
]),
|
||
el('div', { class: 'row-2' }, [
|
||
formInput('max_uses', 'Max uses (0 = unlimited)', { type: 'number', value: '0' }),
|
||
el('div'), // spacer to keep the row balanced
|
||
]),
|
||
el('div', { class: 'row-2' }, [
|
||
formInput('expires_at', 'Expires at (RFC3339, optional)', { hint: 'e.g. 2026-12-31T23:59:59Z' }),
|
||
formInput('product_slug', 'Restrict to product slug (optional)'),
|
||
]),
|
||
formInput('referrer_label', 'Referrer / campaign label (optional)'),
|
||
formInput('description', 'Description (internal note)', { textarea: true }),
|
||
el('button', { class: 'btn primary', onclick: async function () {
|
||
const status = el('div', { class: 'muted', style: 'margin-top:8px' }, 'Creating…')
|
||
create.querySelector('.body').appendChild(status)
|
||
try {
|
||
const kind = create.querySelector('[name=kind]').value
|
||
const currency = create.querySelector('[name=discount_currency]').value
|
||
const rawAmount = parseFloat(create.querySelector('[name=amount]').value) || 0
|
||
// For percent: stored as basis points (50% → 5000).
|
||
// For SAT-currency fixed/set: stored as sats (whole number).
|
||
// For USD/EUR fixed/set: stored as cents (1.00 main unit → 100).
|
||
// Free license: amount ignored (we send 0).
|
||
let amount
|
||
if (kind === 'percent') amount = Math.round(rawAmount * 100)
|
||
else if (kind === 'free_license') amount = 0
|
||
else if (currency === 'SAT') amount = Math.round(rawAmount)
|
||
else amount = Math.round(rawAmount * 100)
|
||
const body = {
|
||
code: create.querySelector('[name=code]').value.trim(),
|
||
kind, amount,
|
||
discount_currency: currency,
|
||
description: create.querySelector('[name=description]').value || '',
|
||
}
|
||
const mu = parseInt(create.querySelector('[name=max_uses]').value, 10) || 0
|
||
if (mu > 0) body.max_uses = mu
|
||
const exp = create.querySelector('[name=expires_at]').value.trim()
|
||
if (exp) body.expires_at = exp
|
||
const ps = create.querySelector('[name=product_slug]').value.trim()
|
||
if (ps) body.product_slug = ps
|
||
const rl = create.querySelector('[name=referrer_label]').value.trim()
|
||
if (rl) body.referrer_label = rl
|
||
await api('/v1/admin/discount-codes', { method: 'POST', body })
|
||
status.replaceWith(ok('Created. Reloading…'))
|
||
setTimeout(routes.codes, 600)
|
||
} catch (e) {
|
||
// Tier-cap 402 → upgrade modal; everything else → inline status pill.
|
||
if (handleTierCap(e)) status.remove()
|
||
else status.replaceWith(err(e.message))
|
||
}
|
||
}}, 'Create code'),
|
||
]),
|
||
])
|
||
|
||
// Live-update the amount hint as the operator changes Kind or
|
||
// Currency. Also swap the input's `step` so SAT-currency codes
|
||
// are integer-only and USD/EUR can take decimals.
|
||
const kindSelEl = create.querySelector('[name=kind]')
|
||
const curSelEl = create.querySelector('[name=discount_currency]')
|
||
const amtInputEl = create.querySelector('[name=amount]')
|
||
function updateHint() {
|
||
const hintEl = amtInputEl.parentElement.querySelector('.hint')
|
||
if (hintEl) hintEl.textContent = amountHint(kindSelEl.value, curSelEl.value)
|
||
// Toggle decimal entry — sats are integer, fiat goes to cents.
|
||
amtInputEl.step = curSelEl.value === 'SAT' ? '1' : '0.01'
|
||
}
|
||
if (kindSelEl) kindSelEl.addEventListener('change', updateHint)
|
||
if (curSelEl) curSelEl.addEventListener('change', updateHint)
|
||
|
||
target.appendChild(plainCard([
|
||
el('p', { class: 'muted', style: 'margin:0 0 16px' },
|
||
'Codes are entered by the buyer on /buy/<product-slug>. Four kinds: percent off, fixed sats off, set flat price (e.g. 5000 sats regardless of base), or free-license (no payment, instant redemption).'),
|
||
create,
|
||
]))
|
||
|
||
// Edit panel — hidden until Edit is clicked. Populated with the chosen
|
||
// code's current values; saving PATCHes /v1/admin/discount-codes/:id and
|
||
// reloads the route.
|
||
const editPanel = el('div', { id: 'edit-code-panel', style: 'display:none; margin:16px 0;' })
|
||
target.appendChild(editPanel)
|
||
|
||
function openEdit(c) {
|
||
editPanel.innerHTML = ''
|
||
editPanel.style.display = 'block'
|
||
const amtField = formInput('e_amount', 'Amount', {
|
||
type: 'number',
|
||
value: c.kind === 'percent' ? String(c.amount / 100) : String(c.amount),
|
||
hint: c.kind === 'free_license'
|
||
? 'free_license codes have no amount.'
|
||
: amountHint(c.kind) + ' (kind: ' + c.kind + ', not editable)',
|
||
})
|
||
const muField = formInput('e_max_uses', 'Max uses (0 = unlimited)', {
|
||
type: 'number',
|
||
value: c.max_uses == null ? '0' : String(c.max_uses),
|
||
hint: c.used_count > 0 ? 'cannot go below current used_count (' + c.used_count + ').' : null,
|
||
})
|
||
const expField = formInput('e_expires_at', 'Expires at (RFC3339, blank to clear)', {
|
||
value: c.expires_at || '',
|
||
})
|
||
const refField = formInput('e_referrer_label', 'Referrer / campaign label (blank to clear)', {
|
||
value: c.referrer_label || '',
|
||
})
|
||
const descField = formInput('e_description', 'Description (internal note)', {
|
||
textarea: true,
|
||
value: c.description || '',
|
||
})
|
||
const saveBtn = el('button', { class: 'btn primary', onclick: async function () {
|
||
const status = el('div', { class: 'muted', style: 'margin-top:8px' }, 'Saving…')
|
||
editPanel.appendChild(status)
|
||
try {
|
||
const body = {}
|
||
if (c.kind !== 'free_license') {
|
||
const rawAmt = parseInt(editPanel.querySelector('[name=e_amount]').value, 10)
|
||
if (Number.isFinite(rawAmt) && rawAmt >= 0) {
|
||
body.amount = c.kind === 'percent' ? rawAmt * 100 : rawAmt
|
||
}
|
||
}
|
||
const muRaw = parseInt(editPanel.querySelector('[name=e_max_uses]').value, 10) || 0
|
||
body.max_uses = muRaw > 0 ? muRaw : null
|
||
const expRaw = editPanel.querySelector('[name=e_expires_at]').value.trim()
|
||
body.expires_at = expRaw === '' ? null : expRaw
|
||
const refRaw = editPanel.querySelector('[name=e_referrer_label]').value.trim()
|
||
body.referrer_label = refRaw === '' ? null : refRaw
|
||
body.description = editPanel.querySelector('[name=e_description]').value || ''
|
||
await api('/v1/admin/discount-codes/' + c.id, { method: 'PATCH', body })
|
||
status.replaceWith(ok('Saved. Reloading…'))
|
||
setTimeout(routes.codes, 600)
|
||
} catch (e) {
|
||
status.replaceWith(err(e.message))
|
||
}
|
||
} }, 'Save changes')
|
||
const cancelBtn = el('button', {
|
||
class: 'btn secondary',
|
||
style: 'margin-left:8px',
|
||
onclick: function () { editPanel.style.display = 'none'; editPanel.innerHTML = '' },
|
||
}, 'Cancel')
|
||
|
||
editPanel.appendChild(plainCard([
|
||
el('div', { style: 'display:flex; align-items:baseline; justify-content:space-between; margin-bottom:12px' }, [
|
||
el('strong', null, 'Editing code '),
|
||
el('code', { style: 'font-size:14px' }, c.code),
|
||
]),
|
||
el('p', { class: 'muted', style: 'margin:0 0 16px; font-size:13px' },
|
||
'Editable: amount, max uses, expiry, referrer label, description. The code string, kind, and product/policy scope cannot be changed — disable + create a new code instead.'),
|
||
el('div', { class: 'row-2' }, [amtField, muField]),
|
||
el('div', { class: 'row-2' }, [expField, refField]),
|
||
descField,
|
||
el('div', { style: 'margin-top:8px' }, [saveBtn, cancelBtn]),
|
||
]))
|
||
editPanel.scrollIntoView({ behavior: 'smooth', block: 'start' })
|
||
}
|
||
|
||
try {
|
||
const j = await api('/v1/admin/discount-codes?include_inactive=true')
|
||
const codes = j.codes || []
|
||
const rows = codes.map((c) => {
|
||
// Currency-aware rendering. SAT-currency codes show "5,000
|
||
// sats off"; fiat codes show "$10.00 off" with cents-to-
|
||
// dollars conversion. Backwards-compat for older rows that
|
||
// don't carry discount_currency: treat as SAT.
|
||
const cur = (c.discount_currency || 'SAT').toUpperCase()
|
||
const fmtFiat = (cents, sym) => sym + (cents / 100).toFixed(2)
|
||
let amountStr = ''
|
||
if (c.kind === 'percent') amountStr = (c.amount / 100) + '%'
|
||
else if (c.kind === 'fixed_sats') {
|
||
if (cur === 'SAT') amountStr = c.amount.toLocaleString() + ' sats off'
|
||
else if (cur === 'USD') amountStr = fmtFiat(c.amount, '$') + ' off'
|
||
else if (cur === 'EUR') amountStr = fmtFiat(c.amount, '€') + ' off'
|
||
else amountStr = c.amount + ' ' + cur + ' off'
|
||
}
|
||
else if (c.kind === 'set_price') {
|
||
if (cur === 'SAT') amountStr = c.amount.toLocaleString() + ' sats flat'
|
||
else if (cur === 'USD') amountStr = fmtFiat(c.amount, '$') + ' flat'
|
||
else if (cur === 'EUR') amountStr = fmtFiat(c.amount, '€') + ' flat'
|
||
else amountStr = c.amount + ' ' + cur + ' flat'
|
||
}
|
||
else amountStr = el('span', { class: 'badge b-gold' }, 'free')
|
||
const usage = c.used_count + ' / ' + (c.max_uses == null ? '∞' : c.max_uses)
|
||
return el('tr', null, [
|
||
el('td', null, el('code', null, c.code)),
|
||
el('td', null, c.kind),
|
||
el('td', null, amountStr),
|
||
el('td', null, usage),
|
||
el('td', { class: 'muted' }, c.expires_at ? fmtDate(c.expires_at) : '–'),
|
||
el('td', null, activePill(c.active)),
|
||
el('td', null, el('div', { class: 'actions-row' }, [
|
||
el('button', {
|
||
class: 'btn sm secondary',
|
||
onclick: function () { openEdit(c) },
|
||
}, 'Edit'),
|
||
el('button', {
|
||
class: 'btn sm ' + (c.active ? 'danger' : 'secondary'),
|
||
onclick: async function () {
|
||
try {
|
||
await api('/v1/admin/discount-codes/' + c.id + '/active', { method: 'PATCH', body: { active: !c.active } })
|
||
routes.codes()
|
||
} catch (e) { alert(e.message) }
|
||
},
|
||
}, c.active ? 'Disable' : 'Enable'),
|
||
el('button', {
|
||
class: 'btn sm danger',
|
||
onclick: async function () {
|
||
const 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'),
|
||
])),
|
||
])
|
||
})
|
||
target.appendChild(tableCard(
|
||
'All codes',
|
||
codes.length + ' total',
|
||
['Code', 'Kind', 'Amount', 'Used', 'Expires', 'Status', ''],
|
||
rows,
|
||
'No codes yet.'
|
||
))
|
||
} catch (e) {
|
||
target.appendChild(plainCard([err(e.message)]))
|
||
}
|
||
}
|
||
|
||
// -------- Licenses --------
|
||
routes.licenses = async function () {
|
||
const target = document.getElementById('route-target')
|
||
target.innerHTML = ''
|
||
|
||
// ---- Search row ----
|
||
const queryInput = el('input', {
|
||
class: 'input', type: 'text',
|
||
placeholder: 'email, npub, or invoice id (leave blank for recent)',
|
||
})
|
||
const fieldSel = el('select', { class: 'select' }, [
|
||
el('option', { value: 'email' }, 'Email'),
|
||
el('option', { value: 'npub' }, 'Nostr npub'),
|
||
el('option', { value: 'invoice' }, 'BTCPay invoice id'),
|
||
])
|
||
|
||
// ---- State for filters + grouping ----
|
||
let products = [] // for product-name lookup + grouping
|
||
let allLicenses = [] // last fetched set
|
||
let currentProductFilter = '' // empty = all products
|
||
let lastQuery = ''
|
||
let lastQueryField = 'email'
|
||
|
||
const productPillRow = el('div', {
|
||
style: 'display:flex; gap:8px; flex-wrap:wrap; margin:14px 0',
|
||
})
|
||
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)
|
||
|
||
// 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, Nostr npub, 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(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 || []
|
||
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 you’re 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 = ''
|
||
target.appendChild(plainCard([
|
||
el('p', { class: 'muted', style: 'margin:0' },
|
||
'Operator-facing configuration. Display name, payment provider connections, and scoped API keys for agent / automation access.'),
|
||
]))
|
||
|
||
const opNameHost = el('div', { style: 'margin-top:18px' })
|
||
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/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/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) => {
|
||
const row = el('div', {
|
||
class: 'catalog-row',
|
||
style: 'display:grid; grid-template-columns: 1fr 1fr 1.6fr auto; gap:6px; align-items:flex-start',
|
||
}, [
|
||
el('input', {
|
||
class: 'input', placeholder: 'slug',
|
||
value: slug || '',
|
||
'data-field': 'slug',
|
||
}),
|
||
el('input', {
|
||
class: 'input', placeholder: 'Display name',
|
||
value: name || '',
|
||
'data-field': 'name',
|
||
}),
|
||
el('input', {
|
||
class: 'input', placeholder: 'Description (buyer tooltip)',
|
||
value: description || '',
|
||
'data-field': 'description',
|
||
title: 'Description shown as a hover tooltip on the buy page',
|
||
}),
|
||
(() => {
|
||
const btn = el('button', {
|
||
type: 'button',
|
||
class: 'btn sm danger',
|
||
title: 'Remove this entitlement',
|
||
style: 'padding:6px 10px',
|
||
}, '×')
|
||
btn.addEventListener('click', () => row.remove())
|
||
return btn
|
||
})(),
|
||
])
|
||
rowsHost.appendChild(row)
|
||
}
|
||
if (Array.isArray(initial) && initial.length > 0) {
|
||
initial.forEach((e) => addRow(e.slug, e.name, e.description))
|
||
}
|
||
const addBtn = el('button', {
|
||
type: 'button',
|
||
class: 'btn sm secondary',
|
||
style: 'margin-top:6px; align-self:flex-start',
|
||
}, '+ Add entitlement')
|
||
addBtn.addEventListener('click', () => addRow('', '', ''))
|
||
|
||
const wrap = el('div', { style: 'display:flex; flex-direction:column' }, [
|
||
el('div', { class: 'lbl', style: 'margin-bottom:4px' }, 'Entitlements catalog'),
|
||
el('div', { class: 'muted', style: 'font-size:12px; margin-bottom:8px' },
|
||
'Declare the entitlements this product offers (e.g. "core", "ai_summaries"). ' +
|
||
'Policies will pick from this list — buyers see the display name + description, ' +
|
||
'never the raw slug. Leave empty to 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) {
|
||
const selected = new Set(Array.isArray(initialSelection) ? initialSelection : [])
|
||
const host = el('div', {
|
||
style: 'display:flex; flex-wrap:wrap; gap:6px; margin-top:4px',
|
||
})
|
||
const pills = []
|
||
function renderPill(entry) {
|
||
const isSel = selected.has(entry.slug)
|
||
const pill = el('button', {
|
||
type: 'button',
|
||
title: entry.description || entry.slug,
|
||
'data-slug': entry.slug,
|
||
style:
|
||
'padding:6px 12px; border-radius:999px; cursor:pointer; ' +
|
||
'font-family:var(--font-body); font-size:13px; font-weight:500; ' +
|
||
'transition:all 100ms; ' +
|
||
(isSel
|
||
? 'background:var(--gold-500); color:var(--navy-950); border:1px solid var(--gold-500); '
|
||
: 'background:transparent; color:var(--ink-700); border:1px solid var(--border-2); '),
|
||
}, entry.name || entry.slug)
|
||
pill.addEventListener('click', () => {
|
||
if (selected.has(entry.slug)) {
|
||
selected.delete(entry.slug)
|
||
} else {
|
||
selected.add(entry.slug)
|
||
}
|
||
// Re-style only this pill rather than re-rendering the host.
|
||
const nowSel = selected.has(entry.slug)
|
||
pill.style.background = nowSel ? 'var(--gold-500)' : 'transparent'
|
||
pill.style.color = nowSel ? 'var(--navy-950)' : 'var(--ink-700)'
|
||
pill.style.borderColor = nowSel ? 'var(--gold-500)' : 'var(--border-2)'
|
||
})
|
||
pills.push(pill)
|
||
host.appendChild(pill)
|
||
}
|
||
;(catalog || []).forEach(renderPill)
|
||
|
||
const wrap = el('div', { class: 'field' }, [
|
||
el('label', { class: 'lbl' }, 'Entitlements'),
|
||
el('div', { class: 'muted', style: 'font-size:12px; margin-bottom:6px' },
|
||
'Click each entitlement this tier should grant. Defined on the parent product\'s catalog.'),
|
||
host,
|
||
])
|
||
return {
|
||
element: wrap,
|
||
read: () => Array.from(selected),
|
||
}
|
||
}
|
||
|
||
// ---------- nav + auth ----------
|
||
function setRoute(name) {
|
||
const links = document.querySelectorAll('.sidebar a.nav')
|
||
for (const a of links) a.classList.toggle('active', a.getAttribute('data-route') === name)
|
||
const meta = ROUTE_META[name] || ROUTE_META.overview
|
||
document.getElementById('page-title').textContent = meta.title
|
||
document.getElementById('crumb').textContent = meta.crumb
|
||
const fn = routes[name] || routes.overview
|
||
fn().catch((e) => {
|
||
const t = document.getElementById('route-target')
|
||
t.innerHTML = ''
|
||
t.appendChild(plainCard([err(e.message || String(e))]))
|
||
}).finally(() => {
|
||
if (window.lucide) lucide.createIcons()
|
||
})
|
||
history.replaceState(null, '', '#' + name)
|
||
// 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')) })
|
||
})
|
||
|
||
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 = 'You’re 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>
|