68dfe7f6fc
Closes the request to make entitlements first-class on products
instead of free-text strings on policies. Operators declare the
closed list of entitlements a product offers — slug + display name
+ optional description — and policies pick from that list with a
click-to-toggle bubble UI. Buy page renders human-readable names
("AI summaries") with descriptions as tooltips, never the raw slug
("ai_summaries").
Schema (migration 0014):
- products.entitlements_catalog_json: nullable JSON column shaped
as [{slug, name, description}, ...]
- Auto-backfill on upgrade: for each existing product, derive a
catalog from the union of its policies' entitlement slugs, with
name = slug.replace('_', ' ') and empty description. Operators
can refine afterward.
- Products with no policy entitlements stay NULL (legacy
free-text mode preserved).
Server:
- Product struct gains entitlements_catalog: Option<Vec<EntitlementDef>>
- repo::set_product_entitlements_catalog (validates lowercase ASCII
slugs, uniqueness, defaults name to slug if empty)
- Product create/update API accept entitlements_catalog;
update uses double-Option PATCH shape so operators can clear
- Closed-list validation: when product has a non-empty catalog,
policy create + update reject any entitlement slug not in the
catalog with a clear error pointing at the right path
- /v1/products/<slug>/policies surfaces entitlements_catalog
in the product object so SDK consumers can render display
names client-side
- Buy page renders entitlement display names + description tooltips
on tier cards (falls back to raw slug for legacy entries that
predate the catalog)
Admin UI:
- New catalogEditor() helper (repeating slug/name/description rows
with add/remove buttons) embedded in product create + edit forms
- New entitlementBubblePicker() helper (click-to-toggle pill chips
showing display name with description tooltip)
- Policy create form: entitlements input swaps based on the chosen
product's catalog — bubble picker when catalog has entries,
legacy textarea otherwise. Rebuilds when operator changes
product.
- Policy edit modal: same bubble-picker-or-textarea swap, scoped
to the policy's product
- Policy list table: entitlement column shows display names
(resolved against the product's catalog) instead of slugs
Migration regression test verifies:
- Backfill correctly unions entitlements across all of a product's
policies, deduplicates, applies name = slug-with-underscores-as-
spaces transformation
- Products with no policy entitlements get NULL (not [])
- Manually-set catalog values round-trip
- Schema is otherwise FK-clean post-migration
Test count: 78 (was 77; +1 for migration_0014_backfills_*).
Phase 2 (SDK updates + integration doc + side-by-side card-grid
policy authoring UI) ships in follow-up commits before v0.2.0:8.
3663 lines
168 KiB
HTML
3663 lines
168 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; }
|
||
}
|
||
</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>
|
||
<!-- 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 } = opts
|
||
// Try safe path first.
|
||
try {
|
||
if (!confirm(`Permanently delete ${kind} "${slug}"? This cannot be undone. \
|
||
The request will be refused if there are licenses or invoices tied to it — use force-delete in that case.`)) return
|
||
await api(pathBase, { method: 'DELETE' })
|
||
onSuccess()
|
||
return
|
||
} catch (e) {
|
||
if (handleTierCap(e)) return
|
||
if (e.status !== 409) {
|
||
alert(e.message)
|
||
return
|
||
}
|
||
// Fall through to the force-delete modal — server says references exist.
|
||
showForceDeleteModal({ kind, slug, message: e.body && e.body.message || e.message, pathBase, onSuccess })
|
||
}
|
||
}
|
||
|
||
function showForceDeleteModal({ kind, slug, message, pathBase, onSuccess }) {
|
||
const overlay = el('div', {
|
||
style: 'position:fixed; inset:0; background:rgba(14,31,51,0.55); z-index:9999; ' +
|
||
'display:flex; align-items:center; justify-content:center; padding:20px;',
|
||
})
|
||
const slugInput = el('input', {
|
||
class: 'input mono',
|
||
placeholder: slug,
|
||
autocomplete: 'off',
|
||
style: 'width:100%; margin-top:6px;',
|
||
})
|
||
const forceBtn = el('button', {
|
||
class: 'btn danger',
|
||
disabled: true,
|
||
style: 'flex:1; opacity:0.5;',
|
||
onclick: async function () {
|
||
if (slugInput.value.trim() !== slug) return
|
||
forceBtn.disabled = true
|
||
forceBtn.textContent = 'Deleting…'
|
||
try {
|
||
const res = await api(pathBase + '?force=true', { method: 'DELETE' })
|
||
overlay.remove()
|
||
// Surface what got cascaded so the operator sees the blast radius.
|
||
const parts = []
|
||
if (res.cascaded_licenses) parts.push(res.cascaded_licenses + ' license(s)')
|
||
if (res.cascaded_invoices) parts.push(res.cascaded_invoices + ' invoice(s)')
|
||
if (res.cascaded_machines) parts.push(res.cascaded_machines + ' machine row(s)')
|
||
if (res.cascaded_redemptions) parts.push(res.cascaded_redemptions + ' redemption(s)')
|
||
if (res.cascaded_policies) parts.push(res.cascaded_policies + ' polic(y/ies)')
|
||
if (res.cascaded_codes) parts.push(res.cascaded_codes + ' code(s)')
|
||
const summary = parts.length ? ' — also wiped: ' + parts.join(', ') : ''
|
||
// Show a brief toast-style banner instead of alert().
|
||
const toast = el('div', {
|
||
style: 'position:fixed; top:18px; left:50%; transform:translateX(-50%); ' +
|
||
'background:var(--navy-950); color:var(--cream-50); padding:10px 18px; ' +
|
||
'border-radius:8px; font-size:13.5px; z-index:10000; ' +
|
||
'box-shadow:0 4px 12px rgba(14,31,51,0.30);',
|
||
}, `${kind} "${slug}" force-deleted${summary}`)
|
||
document.body.appendChild(toast)
|
||
setTimeout(() => toast.remove(), 4500)
|
||
onSuccess()
|
||
} catch (e) {
|
||
forceBtn.disabled = false
|
||
forceBtn.textContent = 'Force delete (irreversible)'
|
||
alert(e.message)
|
||
}
|
||
},
|
||
}, 'Force delete (irreversible)')
|
||
slugInput.addEventListener('input', () => {
|
||
const ok = slugInput.value.trim() === slug
|
||
forceBtn.disabled = !ok
|
||
forceBtn.style.opacity = ok ? '1' : '0.5'
|
||
})
|
||
const card = el('div', {
|
||
style: 'background:var(--cream-50); border:2px solid var(--danger); ' +
|
||
'border-radius:12px; max-width:480px; width:100%; padding:28px 26px; ' +
|
||
'box-shadow:0 16px 32px rgba(178,58,58,0.20);',
|
||
}, [
|
||
el('div', { class: 'eyebrow', style: 'color:var(--danger); margin-bottom:8px' }, 'Force delete — destructive'),
|
||
el('h3', { style: 'font-family:var(--font-display); font-weight:600; font-size:22px; margin:0 0 12px; color:var(--navy-950); letter-spacing:-0.01em;' }, `Wipe ${kind} "${slug}" and everything tied to it?`),
|
||
el('p', { style: 'font-size:14px; color:var(--ink-700); line-height:1.55; margin:0 0 12px;' }, message),
|
||
el('p', { style: 'font-size:14px; color:var(--ink-700); line-height:1.55; margin:0 0 18px;' },
|
||
`Force-delete will permanently remove every license, invoice, redemption, and machine row tied to this ${kind} — along with the ${kind} itself. There is no undo.`),
|
||
el('div', null, [
|
||
el('label', { style: 'font-size:12.5px; font-weight:600; color:var(--ink-700);' },
|
||
`Type the ${kind} slug "${slug}" to confirm:`),
|
||
slugInput,
|
||
]),
|
||
el('div', { style: 'display:flex; gap:10px; margin-top:22px;' }, [
|
||
forceBtn,
|
||
el('button', {
|
||
class: 'btn secondary',
|
||
onclick: () => overlay.remove(),
|
||
}, 'Cancel'),
|
||
]),
|
||
])
|
||
overlay.appendChild(card)
|
||
overlay.addEventListener('click', (e) => { if (e.target === overlay) overlay.remove() })
|
||
document.body.appendChild(overlay)
|
||
setTimeout(() => slugInput.focus(), 0)
|
||
}
|
||
|
||
function el(tag, attrs, children) {
|
||
const e = document.createElement(tag)
|
||
if (attrs) for (const k in attrs) {
|
||
if (k === 'class') e.className = attrs[k]
|
||
else if (k === 'html') e.innerHTML = attrs[k]
|
||
else if (k.startsWith('on')) e.addEventListener(k.slice(2).toLowerCase(), attrs[k])
|
||
else if (k === 'value') e.value = attrs[k]
|
||
else e.setAttribute(k, attrs[k])
|
||
}
|
||
if (children) for (const c of [].concat(children)) {
|
||
if (c == null) continue
|
||
e.appendChild(typeof c === 'string' ? document.createTextNode(c) : c)
|
||
}
|
||
return e
|
||
}
|
||
|
||
function err(msg) { return el('div', { class: 'err' }, msg) }
|
||
function ok(msg) { return el('div', { class: 'ok' }, msg) }
|
||
|
||
function fmtDate(s) {
|
||
if (!s) return ''
|
||
try { return new Date(s).toLocaleString() } catch (_) { return s }
|
||
}
|
||
function shortId(s) {
|
||
return s ? (s.length > 8 ? s.slice(0, 8) + '…' : s) : ''
|
||
}
|
||
|
||
// ---------- card helpers ----------
|
||
function card(title, sub, body) {
|
||
const head = el('div', { class: 'card-head' }, [
|
||
el('h3', null, title),
|
||
sub ? el('span', { class: 'sub' }, sub) : null,
|
||
])
|
||
const c = el('div', { class: 'card' }, [head])
|
||
if (body) c.appendChild(el('div', { class: 'card-body' }, body))
|
||
return c
|
||
}
|
||
function plainCard(body) {
|
||
return el('div', { class: 'card' }, el('div', { class: 'card-body' }, body))
|
||
}
|
||
function tableCard(title, sub, headers, rows, emptyMsg, headerAction) {
|
||
const head = el('div', { class: 'card-head' }, [
|
||
el('h3', null, title),
|
||
sub ? el('span', { class: 'sub' }, sub) : null,
|
||
// Optional right-aligned action element (e.g. "Preview buy page"
|
||
// button on the policies card).
|
||
headerAction ? el('span', { style: 'margin-left:auto' }, headerAction) : null,
|
||
])
|
||
if (rows.length === 0) {
|
||
return el('div', { class: 'card' }, [head, el('div', { class: 'empty' }, emptyMsg || 'Nothing yet.')])
|
||
}
|
||
const t = el('table', { class: 't' })
|
||
t.appendChild(el('thead', null, el('tr', null, headers.map((h) => el('th', null, h)))))
|
||
const tb = el('tbody')
|
||
for (const r of rows) tb.appendChild(r)
|
||
t.appendChild(tb)
|
||
return el('div', { class: 'card' }, [head, t])
|
||
}
|
||
function statusBadge(status) {
|
||
const map = {
|
||
active: { cls: 'b-success', dot: 'ok' },
|
||
suspended: { cls: 'b-warning', dot: 'warn' },
|
||
revoked: { cls: 'b-danger', dot: 'err' },
|
||
expired: { cls: 'b-neutral', dot: 'muted' },
|
||
}
|
||
const m = map[status] || { cls: 'b-neutral', dot: 'muted' }
|
||
return el('span', { class: 'badge ' + m.cls }, [el('span', { class: 'dot ' + m.dot }), status])
|
||
}
|
||
function activePill(active) {
|
||
return active
|
||
? el('span', { class: 'badge b-success' }, [el('span', { class: 'dot ok' }), 'active'])
|
||
: el('span', { class: 'badge b-neutral' }, 'inactive')
|
||
}
|
||
|
||
// ---------- routes ----------
|
||
const routes = {}
|
||
const ROUTE_META = {
|
||
overview: { title: 'Overview', crumb: 'Workspace' },
|
||
products: { title: 'Products', crumb: 'Workspace · Products' },
|
||
policies: { title: 'Policies', crumb: 'Workspace · Policies' },
|
||
subscriptions: { title: 'Subscriptions', crumb: 'Workspace · Subscriptions' },
|
||
codes: { title: 'Discount codes', crumb: 'Workspace · Discount codes' },
|
||
licenses: { title: 'Licenses', crumb: 'Workspace · Licenses' },
|
||
machines: { title: 'Machines', crumb: 'Workspace · Machines' },
|
||
webhooks: { title: 'Webhooks', crumb: 'System · Webhooks' },
|
||
audit: { title: 'Audit log', crumb: 'System · Audit log' },
|
||
}
|
||
|
||
// -------- Overview --------
|
||
routes.overview = async function () {
|
||
const target = document.getElementById('route-target')
|
||
target.innerHTML = ''
|
||
|
||
// Stats grid (skeleton first; fill in as data arrives)
|
||
const stats = el('div', { class: 'stats' })
|
||
const sRevenue = stat('Revenue (lifetime)', '–', null, true)
|
||
const sLicenses = stat('Active licenses', '–', null, true)
|
||
const sCodes = stat('Discount codes', '–')
|
||
const sBtc = stat('BTCPay', el('span', { style: 'font-size:18px; font-family:var(--font-body); font-weight:600' }, '–'))
|
||
stats.appendChild(sRevenue)
|
||
stats.appendChild(sLicenses)
|
||
stats.appendChild(sCodes)
|
||
stats.appendChild(sBtc)
|
||
target.appendChild(stats)
|
||
|
||
// Revenue breakdown card — lifetime total + 30d/7d/24h.
|
||
function fmtSatsCard(n) {
|
||
const num = Number(n) || 0
|
||
return num.toLocaleString('en-US')
|
||
}
|
||
const revCard = el('div', { class: 'card' }, [
|
||
el('div', { class: 'card-body' }, [
|
||
el('div', { style: 'display:flex; align-items:baseline; justify-content:space-between; margin-bottom:14px' }, [
|
||
el('div', { class: 'eyebrow' }, 'Revenue'),
|
||
el('div', { class: 'muted', style: 'font-size:12px' }, 'Sum of settled BTCPay invoices stored locally. Free-license redemptions excluded.'),
|
||
]),
|
||
el('div', {
|
||
id: 'revenue-grid',
|
||
style: 'display:grid; grid-template-columns:repeat(4, 1fr); gap:14px;',
|
||
}, [
|
||
el('div', { id: 'rev-total', style: 'padding:10px 12px; background:var(--cream-100); border:1px solid var(--border-1); border-radius:8px;' },
|
||
[el('div', { class: 'muted', style: 'font-size:11px; font-weight:600; letter-spacing:0.06em; text-transform:uppercase' }, 'Lifetime'),
|
||
el('div', { style: 'font-family:var(--font-display); font-weight:700; font-size:22px; color:var(--navy-950); margin-top:4px;', class: 'rev-value' }, '–')]),
|
||
el('div', { id: 'rev-30d', style: 'padding:10px 12px; background:var(--cream-100); border:1px solid var(--border-1); border-radius:8px;' },
|
||
[el('div', { class: 'muted', style: 'font-size:11px; font-weight:600; letter-spacing:0.06em; text-transform:uppercase' }, '30d'),
|
||
el('div', { style: 'font-family:var(--font-display); font-weight:700; font-size:22px; color:var(--navy-950); margin-top:4px;', class: 'rev-value' }, '–')]),
|
||
el('div', { id: 'rev-7d', style: 'padding:10px 12px; background:var(--cream-100); border:1px solid var(--border-1); border-radius:8px;' },
|
||
[el('div', { class: 'muted', style: 'font-size:11px; font-weight:600; letter-spacing:0.06em; text-transform:uppercase' }, '7d'),
|
||
el('div', { style: 'font-family:var(--font-display); font-weight:700; font-size:22px; color:var(--navy-950); margin-top:4px;', class: 'rev-value' }, '–')]),
|
||
el('div', { id: 'rev-24h', style: 'padding:10px 12px; background:var(--cream-100); border:1px solid var(--border-1); border-radius:8px;' },
|
||
[el('div', { class: 'muted', style: 'font-size:11px; font-weight:600; letter-spacing:0.06em; text-transform:uppercase' }, '24h'),
|
||
el('div', { style: 'font-family:var(--font-display); font-weight:700; font-size:22px; color:var(--navy-950); margin-top:4px;', class: 'rev-value' }, '–')]),
|
||
]),
|
||
el('div', { class: 'muted', style: 'margin-top:10px; font-size:12px;', id: 'rev-count' }, ''),
|
||
]),
|
||
])
|
||
target.appendChild(revCard)
|
||
|
||
// Welcome / instructions card
|
||
target.appendChild(card('Welcome', null, [
|
||
el('p', { class: 'muted' }, [
|
||
'This is your Keysat admin dashboard. Use the sidebar to manage products, policies, discount codes, and the licenses you have issued. ',
|
||
'Setup actions — setting your operator name, connecting BTCPay, and viewing your admin credentials — live in your StartOS service ',
|
||
el('strong', null, 'Actions'), ' tab.',
|
||
]),
|
||
el('p', { class: 'muted', style: 'margin-bottom:0' }, [
|
||
'Service: ',
|
||
el('code', { class: 'mono', style: 'font-family:var(--font-mono); font-size:12.5px; color:var(--navy-900)' }, [
|
||
serviceInfo ? (serviceInfo.service + ' v' + serviceInfo.version) : '–',
|
||
]),
|
||
' · Operator: ',
|
||
el('code', { class: 'mono', style: 'font-family:var(--font-mono); font-size:12.5px; color:var(--navy-900)' },
|
||
(serviceInfo && serviceInfo.operator) || '(unset)'),
|
||
]),
|
||
]))
|
||
|
||
// Public key tip card (matches the design system 'Embed your public key' tip)
|
||
const pubkeyTip = el('div', {
|
||
class: 'card',
|
||
style: 'background:var(--cream-100); border-style:dashed;'
|
||
}, [
|
||
el('div', { class: 'card-body' }, [
|
||
el('div', { class: 'eyebrow', style: 'margin-bottom:6px' }, 'Tip'),
|
||
el('div', {
|
||
style: 'font-family:var(--font-display); font-weight:700; font-size:15px; color:var(--navy-950); margin-bottom:4px; letter-spacing:-0.01em;',
|
||
}, 'Embed your public key'),
|
||
el('p', { style: 'font-size:13px; color:var(--ink-700); margin:0 0 12px; line-height:1.5' },
|
||
'Paste this into your 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', { style: 'margin-top:24px' })
|
||
target.appendChild(analyticsStrip)
|
||
renderAnalyticsCard(analyticsStrip)
|
||
|
||
// Public key fetch — pulls PEM from /v1/issuer/public-key (no auth
|
||
// required) and displays a short preview. Copy button copies the full
|
||
// PEM, including BEGIN/END headers, ready to paste into source.
|
||
try {
|
||
const j = await fetch('/v1/issuer/public-key').then((r) => r.json()).catch(() => null)
|
||
const pem = j && (j.public_key_pem || j.public_key_b64) // accept either shape
|
||
if (pem && typeof pem === 'string') {
|
||
// Pull the base64 body out of the PEM for the in-card preview
|
||
// (BEGIN/END headers are noise on a single 12+12-char preview).
|
||
const body = pem
|
||
.replace(/-----BEGIN [^-]+-----/g, '')
|
||
.replace(/-----END [^-]+-----/g, '')
|
||
.replace(/\s+/g, '')
|
||
const preview = body.slice(0, 12) + '…' + body.slice(-12)
|
||
document.getElementById('pubkey-preview').textContent = preview
|
||
document.getElementById('pubkey-preview').dataset.full = pem
|
||
} else {
|
||
document.getElementById('pubkey-preview').textContent = 'unavailable'
|
||
}
|
||
} catch {
|
||
document.getElementById('pubkey-preview').textContent = 'unavailable'
|
||
}
|
||
}
|
||
|
||
function stat(label, value, sub, featured) {
|
||
return el('div', { class: 'stat' + (featured ? ' featured' : '') }, [
|
||
el('div', { class: 'label' }, label),
|
||
el('div', { class: 'value' }, value),
|
||
sub ? el('div', { class: 'sub' }, sub) : null,
|
||
])
|
||
}
|
||
|
||
// Renders the compact community-analytics opt-in strip on Overview.
|
||
// Off by default. Auto-saves the toggle on click — no separate Save
|
||
// button. Details are tucked into an inline disclosure so the
|
||
// default view stays calm and doesn't compete with the operator's
|
||
// workspace cards.
|
||
async function renderAnalyticsCard(host) {
|
||
host.innerHTML = ''
|
||
let s
|
||
try {
|
||
s = await api('/v1/admin/community-analytics')
|
||
} catch (e) {
|
||
host.appendChild(el('p', { class: 'muted', style: 'font-size:12px' },
|
||
'Could not load analytics state: ' + e.message))
|
||
return
|
||
}
|
||
|
||
// The single line that's visible by default. Native checkbox so
|
||
// the affordance reads as "click to opt in", not as a fancy
|
||
// toggle that needs a Save click after.
|
||
const checkbox = el('input', {
|
||
type: 'checkbox',
|
||
style: 'cursor:pointer',
|
||
})
|
||
if (s.enabled) checkbox.checked = true
|
||
const detailsLink = el('a', {
|
||
href: '#',
|
||
class: 'muted',
|
||
style: 'font-size:12px; margin-left:6px',
|
||
}, 'what gets sent?')
|
||
const oneLine = el('label', {
|
||
style: 'display:inline-flex; align-items:center; gap:8px; font-size:13px; color:var(--ink-500); cursor:pointer'
|
||
}, [
|
||
checkbox,
|
||
el('span', null, 'Send anonymous usage stats so we can show real adoption numbers on the public dashboard.'),
|
||
])
|
||
|
||
const inlineRow = el('div', {
|
||
style: 'display:flex; justify-content:space-between; align-items:center; flex-wrap:wrap; gap:8px; padding:10px 14px; background:#fbf6e9; border:1px dashed #d6cdb8; border-radius:6px'
|
||
}, [
|
||
el('div', null, [oneLine, detailsLink]),
|
||
])
|
||
host.appendChild(inlineRow)
|
||
|
||
// Expanded details (collector URL, JSON preview, reset). Hidden
|
||
// by default; toggled by the "what gets sent?" link.
|
||
const details = el('div', {
|
||
style: 'display:none; margin-top:10px; padding:14px; background:#fbf6e9; border:1px dashed #d6cdb8; border-radius:6px'
|
||
})
|
||
host.appendChild(details)
|
||
detailsLink.addEventListener('click', (e) => {
|
||
e.preventDefault()
|
||
const showing = details.style.display !== 'none'
|
||
details.style.display = showing ? 'none' : 'block'
|
||
detailsLink.textContent = showing ? 'what gets sent?' : 'hide details'
|
||
})
|
||
|
||
// Collector URL — small input, optional.
|
||
const urlInput = el('input', {
|
||
class: 'input',
|
||
type: 'url',
|
||
placeholder: 'https://keysat.xyz/community/v1/heartbeat',
|
||
value: s.collector_url || '',
|
||
style: 'width:100%; box-sizing:border-box; font-size:12px; padding:6px 10px',
|
||
})
|
||
details.appendChild(el('label', { style: 'display:block; font-size:11px; font-weight:600; margin-bottom:4px; text-transform:uppercase; letter-spacing:0.05em' }, 'Collector URL'))
|
||
details.appendChild(urlInput)
|
||
details.appendChild(el('p', { class: 'muted', style: 'margin:4px 0 12px; font-size:11px' },
|
||
'Leave blank to opt in but not send. Once keysat.xyz/community is live, the default URL will populate on upgrade.'))
|
||
|
||
// The exact JSON the daemon would POST. Live preview, not a
|
||
// pretend example — what you see is what would actually be sent.
|
||
details.appendChild(el('p', { class: 'muted', style: 'margin:0 0 6px; font-size:11px' },
|
||
'Counts are floored to the nearest 5 (anti-fingerprinting). Uptime is bucketed. install_uuid is a random UUIDv4 generated on first opt-in — NOT derived from operator name, store id, or public URL.'))
|
||
details.appendChild(el('pre', {
|
||
style: 'background:#0e1f33; color:#f6f1e7; padding:10px; border-radius:4px; font-size:11px; overflow-x:auto; margin:0 0 8px'
|
||
}, JSON.stringify(s.preview_heartbeat, null, 2)))
|
||
|
||
if (s.install_uuid) {
|
||
const resetRow = el('div', { style: 'display:flex; justify-content:space-between; align-items:center; gap:8px; font-size:11px' }, [
|
||
el('span', { class: 'muted' }, 'Your install_uuid: ' + s.install_uuid.slice(0, 8) + '…'),
|
||
el('a', { href: '#', class: 'muted', style: 'font-size:11px' }, 'reset'),
|
||
])
|
||
const resetLink = resetRow.querySelector('a')
|
||
resetLink.addEventListener('click', async (e) => {
|
||
e.preventDefault()
|
||
if (!confirm('Wipe your anonymous install_uuid? Future heartbeats (if you re-enable) will use a fresh one.')) return
|
||
try {
|
||
await api('/v1/admin/community-analytics/reset', { method: 'POST' })
|
||
renderAnalyticsCard(host)
|
||
} catch (er) { alert(er.message) }
|
||
})
|
||
details.appendChild(resetRow)
|
||
}
|
||
|
||
// Auto-save: toggling the checkbox or editing the URL persists
|
||
// immediately. No Save button; the affordance is "click and it's
|
||
// done."
|
||
let saveTimer = null
|
||
async function persist() {
|
||
try {
|
||
await api('/v1/admin/community-analytics', { method: 'POST', body: {
|
||
enabled: checkbox.checked,
|
||
collector_url: urlInput.value.trim() || null,
|
||
}})
|
||
} catch (e) {
|
||
alert(e.message)
|
||
// Revert visual state on failure so what the user sees
|
||
// matches what's persisted.
|
||
checkbox.checked = !checkbox.checked
|
||
}
|
||
}
|
||
checkbox.addEventListener('change', persist)
|
||
urlInput.addEventListener('input', () => {
|
||
clearTimeout(saveTimer)
|
||
saveTimer = setTimeout(persist, 600) // debounce the URL field
|
||
})
|
||
}
|
||
|
||
// Render a product's price for table cells. Picks the right
|
||
// unit + format based on price_currency. SAT-priced shows
|
||
// "50,000 sats"; USD-priced shows "$49.00 ≈ 75k sats" if the
|
||
// sat amount has been pinned (after first invoice), or just
|
||
// "$49.00" if not yet quoted.
|
||
function formatProductPrice(p) {
|
||
const currency = (p.price_currency || 'SAT').toUpperCase()
|
||
if (currency === 'SAT') {
|
||
return (p.price_sats || p.price_value || 0).toLocaleString() + ' sats'
|
||
}
|
||
const symbol = currency === 'USD' ? '$' : currency === 'EUR' ? '€' : ''
|
||
const amount = (p.price_value || 0) / 100 // cents → main unit
|
||
const main = symbol + amount.toFixed(2) + (symbol ? '' : ' ' + currency)
|
||
if (p.price_sats && p.price_sats > 0) {
|
||
// Sat amount has been pinned by a prior invoice; show as a hint.
|
||
const sats = p.price_sats >= 1000
|
||
? Math.round(p.price_sats / 1000) + 'k'
|
||
: String(p.price_sats)
|
||
return el('span', null, [main, el('span', { class: 'muted', style: 'font-size:11px; margin-left:6px' }, '≈ ' + sats + ' sats')])
|
||
}
|
||
return main
|
||
}
|
||
|
||
async function copyPubkey() {
|
||
const span = document.getElementById('pubkey-preview')
|
||
const k = span.dataset.full
|
||
if (!k) return
|
||
try {
|
||
await navigator.clipboard.writeText(k)
|
||
const orig = span.textContent
|
||
span.textContent = 'Copied'
|
||
setTimeout(() => { span.textContent = orig }, 1200)
|
||
} catch {}
|
||
}
|
||
|
||
// -------- Products --------
|
||
// Edit-product modal. Opens when the operator clicks Edit on a product
|
||
// row. Mutable: name, description, price (currency + value). Slug is
|
||
// intentionally not editable (it's part of the public buy URL —
|
||
// changing it would break bookmarks).
|
||
function openEditProduct(p) {
|
||
const overlay = el('div', {
|
||
style: 'position:fixed; inset:0; background:rgba(14,31,51,0.55); z-index:9999; ' +
|
||
'display:flex; align-items:center; justify-content:center; padding:20px;',
|
||
})
|
||
const nameField = formInput('e_p_name', 'Display name', { value: p.name || '', required: true })
|
||
const descField = formInput('e_p_description', 'Description', { textarea: true, value: p.description || '' })
|
||
const editCatalog = catalogEditor(p.entitlements_catalog || null)
|
||
|
||
// Currency-aware price inputs. For SAT-currency products, show
|
||
// the integer sat amount. For USD/EUR, render the cents value
|
||
// back to a decimal main-unit string ($49.00) and accept
|
||
// decimals on save.
|
||
const initialCurrency = (p.price_currency || 'SAT').toUpperCase()
|
||
const initialDisplay = initialCurrency === 'SAT'
|
||
? String(p.price_value || p.price_sats || 0)
|
||
: ((p.price_value || 0) / 100).toFixed(2)
|
||
|
||
const curPicker = el('select', { class: 'input', name: 'e_p_currency' }, [
|
||
el('option', { value: 'SAT' }, 'sats'),
|
||
el('option', { value: 'USD' }, 'USD ($)'),
|
||
el('option', { value: 'EUR' }, 'EUR (€)'),
|
||
])
|
||
curPicker.value = initialCurrency
|
||
const priceInput = el('input', {
|
||
class: 'input', name: 'e_p_price', type: 'number',
|
||
step: initialCurrency === 'SAT' ? '1' : '0.01',
|
||
min: '0', value: initialDisplay, required: 'required',
|
||
})
|
||
curPicker.addEventListener('change', () => {
|
||
priceInput.step = curPicker.value === 'SAT' ? '1' : '0.01'
|
||
// Don't auto-clobber the value — let the operator decide if
|
||
// the displayed number still makes sense in the new unit.
|
||
// Show a hint instead.
|
||
hint.textContent = curPicker.value === 'SAT'
|
||
? 'sats — whole numbers only.'
|
||
: 'Decimal entry, e.g. 49.00. Converted to BTC at each invoice using the daemon\'s rate fetcher.'
|
||
})
|
||
const hint = el('div', { class: 'muted', style: 'font-size:12px; margin-top:4px' },
|
||
initialCurrency === 'SAT'
|
||
? 'sats — whole numbers only.'
|
||
: 'Decimal entry, e.g. 49.00. Converted to BTC at each invoice using the daemon\'s rate fetcher.')
|
||
|
||
const status = el('div', { class: 'muted', style: 'margin-top:6px; font-size:12.5px;' }, '')
|
||
const card = el('div', {
|
||
style: 'background:var(--cream-50); border:1px solid var(--border-1); ' +
|
||
'border-radius:12px; max-width:480px; width:100%; padding:24px; ' +
|
||
'box-shadow:0 0 0 1px var(--gold-500) inset, 0 16px 32px rgba(14,31,51,0.20);',
|
||
}, [
|
||
el('div', { class: 'eyebrow', style: 'margin-bottom:8px' }, 'Edit product'),
|
||
el('h3', { style: 'font-family:var(--font-display); font-weight:600; font-size:20px; margin:0 0 6px; color:var(--navy-950);' }, p.slug),
|
||
el('p', { class: 'muted', style: 'margin:0 0 16px; font-size:13px' },
|
||
'Slug is not editable — it is part of your public /buy/' + p.slug + ' URL. Disable + create a new product if you need to rename.'),
|
||
nameField,
|
||
descField,
|
||
el('label', { style: 'display:block; font-weight:600; font-size:13px; margin:12px 0 4px' }, 'Price'),
|
||
el('div', { style: 'display:flex; gap:8px; align-items:flex-start' }, [priceInput, curPicker]),
|
||
hint,
|
||
// Entitlements catalog — pre-filled from the loaded product.
|
||
// Operator can edit/add/remove rows; submit sends the full
|
||
// current catalog (closed list semantics).
|
||
el('div', { style: 'margin-top:18px; padding-top:14px; border-top:1px dashed var(--border-1)' }, [
|
||
editCatalog.element,
|
||
]),
|
||
status,
|
||
el('div', { style: 'display:flex; gap:10px; margin-top:14px;' }, [
|
||
el('button', { class: 'btn primary', onclick: async function () {
|
||
status.textContent = 'Saving…'
|
||
try {
|
||
const currency = curPicker.value
|
||
const rawValue = parseFloat(priceInput.value) || 0
|
||
const priceValue = currency === 'SAT' ? Math.round(rawValue) : Math.round(rawValue * 100)
|
||
const body = {
|
||
name: card.querySelector('[name=e_p_name]').value.trim(),
|
||
description: card.querySelector('[name=e_p_description]').value || '',
|
||
price_currency: currency,
|
||
price_value: Math.max(0, priceValue),
|
||
}
|
||
// Always send the catalog on edit so the operator can
|
||
// also CLEAR it (empty editor → null → drops back to
|
||
// free-text mode). The double-Option PATCH shape on
|
||
// the server treats null as "set to NULL", absent as
|
||
// "leave alone".
|
||
body.entitlements_catalog = editCatalog.read()
|
||
await api('/v1/admin/products/' + p.id, { method: 'PATCH', body })
|
||
overlay.remove()
|
||
routes.products()
|
||
} catch (e) {
|
||
status.textContent = e.message
|
||
status.style.color = 'var(--danger)'
|
||
}
|
||
} }, 'Save'),
|
||
el('button', { class: 'btn secondary', onclick: () => overlay.remove() }, 'Cancel'),
|
||
]),
|
||
])
|
||
overlay.appendChild(card)
|
||
overlay.addEventListener('click', (e) => { if (e.target === overlay) overlay.remove() })
|
||
document.body.appendChild(overlay)
|
||
}
|
||
|
||
routes.products = async function () {
|
||
const target = document.getElementById('route-target')
|
||
target.innerHTML = ''
|
||
|
||
// Create form. Currency picker swaps the price-input units in
|
||
// place: SAT → integer sats, USD/EUR → dollar/euro amount which
|
||
// we convert to cents on the way out (the backend stores
|
||
// smallest-unit-of-currency).
|
||
const currencyPicker = el('select', { class: 'input' }, [
|
||
el('option', { value: 'SAT' }, 'sats'),
|
||
el('option', { value: 'USD' }, 'USD ($)'),
|
||
el('option', { value: 'EUR' }, 'EUR (€)'),
|
||
])
|
||
const priceInput = el('input', {
|
||
class: 'input', name: 'price_input', type: 'number',
|
||
step: '1', min: '0', value: '50000', required: 'required',
|
||
})
|
||
const priceHint = el('div', { class: 'muted', style: 'font-size:12px; margin-top:4px' },
|
||
'sats — whole numbers only.')
|
||
currencyPicker.addEventListener('change', () => {
|
||
if (currencyPicker.value === 'SAT') {
|
||
priceInput.step = '1'
|
||
priceInput.value = '50000'
|
||
priceHint.textContent = 'sats — whole numbers only.'
|
||
} else {
|
||
priceInput.step = '0.01'
|
||
priceInput.value = '49.00'
|
||
priceHint.textContent =
|
||
currencyPicker.value === 'USD'
|
||
? 'Dollars — converted to BTC at invoice creation. Buyer pays the locked-in BTC amount.'
|
||
: 'Euros — converted to BTC at invoice creation. Buyer pays the locked-in BTC amount.'
|
||
}
|
||
})
|
||
const createCatalog = catalogEditor(null)
|
||
const create = el('details', { class: 'disclosure' }, [
|
||
el('summary', null, 'Create a new product'),
|
||
el('div', { class: 'body' }, [
|
||
formInput('slug', 'Slug', { required: true, hint: 'lowercase, hyphens, e.g. "bitcoin-ticker-pro"' }),
|
||
formInput('name', 'Display name', { required: true }),
|
||
formInput('description', 'Description', { textarea: true }),
|
||
el('label', { style: 'display:block; font-weight:600; font-size:13px; margin:12px 0 4px' }, 'Price'),
|
||
el('div', { style: 'display:flex; gap:8px; align-items:flex-start' }, [
|
||
priceInput,
|
||
currencyPicker,
|
||
]),
|
||
priceHint,
|
||
// 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,
|
||
]))
|
||
|
||
try {
|
||
const [j, counts] = await Promise.all([
|
||
api('/v1/products'),
|
||
api('/v1/admin/licenses/counts').catch(() => ({ by_product: {}, by_policy: {} })),
|
||
])
|
||
const products = j.products || j || []
|
||
const byProduct = (counts && counts.by_product) || {}
|
||
const rows = products.map((p) => el('tr', null, [
|
||
el('td', null, el('code', null, p.slug)),
|
||
el('td', { style: 'font-weight:600; color:var(--navy-950)' }, p.name),
|
||
el('td', null, formatProductPrice(p)),
|
||
el('td', null, el('span', { class: 'muted' }, String(byProduct[p.id] || 0))),
|
||
el('td', null, activePill(p.active)),
|
||
el('td', { class: 'muted' }, fmtDate(p.created_at)),
|
||
el('td', null, el('div', { class: 'actions-row' }, [
|
||
el('button', {
|
||
class: 'btn sm secondary',
|
||
onclick: function () { openEditProduct(p) },
|
||
}, 'Edit'),
|
||
el('button', {
|
||
class: 'btn sm danger',
|
||
title: 'Delete this product. Safe by default; offers a force-delete with cascade if the product has licenses or invoices.',
|
||
onclick: function () {
|
||
safeOrForceDelete({
|
||
kind: 'product',
|
||
slug: p.slug,
|
||
pathBase: '/v1/admin/products/' + p.id,
|
||
onSuccess: () => routes.products(),
|
||
})
|
||
},
|
||
}, 'Delete'),
|
||
])),
|
||
]))
|
||
target.appendChild(tableCard(
|
||
'All products',
|
||
products.length + ' total',
|
||
['Slug', 'Name', 'Price', 'Licenses', 'Status', 'Created', ''],
|
||
rows,
|
||
'No products yet. Create one above to start selling.'
|
||
))
|
||
} catch (e) {
|
||
target.appendChild(plainCard([err(e.message)]))
|
||
}
|
||
}
|
||
|
||
// Change-tier modal: pick target policy, see quote, choose comp
|
||
// (skip_payment=true) vs paid (skip_payment=false → operator gets
|
||
// a checkout URL to forward to the buyer). Auth via admin token —
|
||
// POST /v1/admin/licenses/:id/change-tier.
|
||
function openChangeTier(license) {
|
||
const overlay = el('div', {
|
||
style: 'position:fixed; inset:0; background:rgba(14,31,51,0.55); z-index:9999; ' +
|
||
'display:flex; align-items:center; justify-content:center; padding:20px; overflow-y:auto;',
|
||
})
|
||
const card = el('div', {
|
||
style: 'background:var(--cream-50); border:1px solid var(--border-1); ' +
|
||
'border-radius:12px; max-width:560px; width:100%; padding:24px; ' +
|
||
'box-shadow:0 0 0 1px var(--gold-500) inset, 0 16px 32px rgba(14,31,51,0.20); ' +
|
||
'max-height:90vh; overflow-y:auto;',
|
||
})
|
||
overlay.appendChild(card)
|
||
overlay.addEventListener('click', (e) => { if (e.target === overlay) overlay.remove() })
|
||
document.body.appendChild(overlay)
|
||
|
||
card.appendChild(el('div', { class: 'eyebrow', style: 'margin-bottom:8px' }, 'Change tier'))
|
||
card.appendChild(el('h3', { style: 'font-family:var(--font-display); font-weight:600; font-size:20px; margin:0 0 6px; color:var(--navy-950);' },
|
||
'License ' + (license.id ? license.id.slice(0, 8) : '?') + '…'))
|
||
card.appendChild(el('p', { class: 'muted', style: 'margin:0 0 16px; font-size:13px' },
|
||
'Force-change this license to a different policy under the same product. Bypasses ladder rules — operators can move sideways, downgrade perpetuals, etc. Preview the prorated charge before committing.'))
|
||
|
||
const status = el('div', { class: 'muted', style: 'margin-top:8px; font-size:12.5px;' }, 'Loading product policies…')
|
||
const policiesHolder = el('div')
|
||
const quoteHolder = el('div', { style: 'margin-top:14px' })
|
||
const buttonRow = el('div', { style: 'display:flex; gap:10px; margin-top:14px;' }, [
|
||
el('button', { class: 'btn secondary', onclick: () => overlay.remove() }, 'Cancel'),
|
||
])
|
||
|
||
card.appendChild(policiesHolder)
|
||
card.appendChild(quoteHolder)
|
||
card.appendChild(status)
|
||
card.appendChild(buttonRow)
|
||
|
||
let allPolicies = []
|
||
let currentPolicySlug = null
|
||
let selectedTargetSlug = null
|
||
let lastQuote = null
|
||
|
||
;(async function init() {
|
||
try {
|
||
const j = await api('/v1/admin/policies?product_slug=' + encodeURIComponent(license.product_slug || ''))
|
||
allPolicies = j.policies || []
|
||
if (license.policy_id) {
|
||
const cur = allPolicies.find((p) => p.id === license.policy_id)
|
||
if (cur) currentPolicySlug = cur.slug
|
||
}
|
||
renderPolicyPicker()
|
||
status.textContent = ''
|
||
} catch (e) {
|
||
status.textContent = 'Failed to load policies: ' + e.message
|
||
status.style.color = 'var(--danger)'
|
||
}
|
||
})()
|
||
|
||
function renderPolicyPicker() {
|
||
policiesHolder.innerHTML = ''
|
||
// Show ALL policies but mark the current one as disabled with
|
||
// "(current)" suffix — operator sees what they're starting from
|
||
// but can't pick a no-op. Other policies become the actual
|
||
// change targets.
|
||
const opts = allPolicies.map((p) => {
|
||
const isCurrent = p.slug === currentPolicySlug
|
||
return {
|
||
value: p.slug,
|
||
disabled: isCurrent,
|
||
label: p.name + ' (' + p.slug + ')' +
|
||
(p.tier_rank != null ? ' · rank ' + p.tier_rank : '') +
|
||
(p.is_recurring ? ' · recurring' : '') +
|
||
(p.is_trial ? ' · trial' : '') +
|
||
(isCurrent ? ' · current' : ''),
|
||
}
|
||
})
|
||
const selectableOpts = opts.filter((o) => !o.disabled)
|
||
if (selectableOpts.length === 0) {
|
||
policiesHolder.appendChild(plainCard([
|
||
el('p', { class: 'muted', style: 'margin:0' },
|
||
'No other policies on this product. Create one first under Policies → ' + (license.product_slug || '<product>') + '.'),
|
||
]))
|
||
return
|
||
}
|
||
const sel = formSelect('change_tier_target', 'Target policy', opts, { required: true })
|
||
policiesHolder.appendChild(sel)
|
||
const selEl = sel.querySelector('select')
|
||
selEl.addEventListener('change', () => {
|
||
selectedTargetSlug = selEl.value
|
||
runQuote()
|
||
})
|
||
// Auto-pick first SELECTABLE option (skip the disabled current-tier).
|
||
selectedTargetSlug = selectableOpts[0].value
|
||
selEl.value = selectedTargetSlug
|
||
runQuote()
|
||
}
|
||
|
||
async function runQuote() {
|
||
quoteHolder.innerHTML = ''
|
||
buttonRow.innerHTML = ''
|
||
buttonRow.appendChild(el('button', { class: 'btn secondary', onclick: () => overlay.remove() }, 'Cancel'))
|
||
if (!selectedTargetSlug) return
|
||
try {
|
||
// Reuse the buyer quote endpoint when both ranks are set;
|
||
// for admin-only paths (NULL rank, sideways) the operator
|
||
// path through /v1/admin/.../change-tier validates server-side
|
||
// anyway. We only render the quote preview when buyer-quote
|
||
// returns a clean answer; otherwise show a generic preview.
|
||
// To keep UX simple, we don't currently expose an
|
||
// admin-mode quote endpoint — fall back to letting the
|
||
// operator see the listed price diff via the policy's
|
||
// price_sats_override, surfaced in the picker option label.
|
||
// For ranks that line up, we can pull a real quote via a
|
||
// short-lived test license_key... but that requires the
|
||
// buyer key, which the admin doesn't have. So: we skip
|
||
// the quote preview in the admin UI and rely on the
|
||
// server-side response after submit. Show a placeholder.
|
||
// Detect direction (upgrade vs downgrade) by comparing the
|
||
// current and target policies' price_sats_override (or rank
|
||
// when both ranked). Drives a "downgrade warning" banner so
|
||
// the operator sees what entitlements the buyer is about to
|
||
// lose. Cheap client-side compute; the server still
|
||
// re-validates.
|
||
const targetPol = allPolicies.find((p) => p.slug === selectedTargetSlug)
|
||
const currentPol = allPolicies.find((p) => p.slug === currentPolicySlug)
|
||
let isDowngrade = false
|
||
if (targetPol && currentPol) {
|
||
if (targetPol.tier_rank != null && currentPol.tier_rank != null) {
|
||
isDowngrade = targetPol.tier_rank < currentPol.tier_rank
|
||
} else {
|
||
const a = currentPol.price_sats_override != null ? currentPol.price_sats_override : 0
|
||
const b = targetPol.price_sats_override != null ? targetPol.price_sats_override : 0
|
||
isDowngrade = b < a
|
||
}
|
||
}
|
||
|
||
if (isDowngrade && targetPol && currentPol) {
|
||
// Build a list of entitlements the buyer is about to lose.
|
||
const losing = (currentPol.entitlements || []).filter(
|
||
(e) => !(targetPol.entitlements || []).includes(e),
|
||
)
|
||
const losingLine = losing.length > 0
|
||
? 'Buyer will LOSE these entitlements: ' + losing.join(', ') + '.'
|
||
: 'Buyer keeps the same entitlements.'
|
||
quoteHolder.appendChild(plainCard([
|
||
el('div', { style: 'color:#7a5814; font-weight:600; margin-bottom:6px' },
|
||
'⚠ Downgrade'),
|
||
el('p', { style: 'margin:0 0 8px; font-size:13px' },
|
||
'You\'re moving this license from ' + currentPol.name + ' → ' + targetPol.name + '. ' +
|
||
losingLine + ' The buyer will NOT be refunded automatically — handle any refund out of band.'),
|
||
]))
|
||
} else {
|
||
quoteHolder.appendChild(el('p', { class: 'muted', style: 'margin:0; font-size:13px' },
|
||
'Admin tier changes always apply as comp (no invoice, license flips immediately). ' +
|
||
'For paid upgrades, point the buyer at /buy/<slug> or have their app drive the SDK\'s in-app purchase flow.'))
|
||
}
|
||
|
||
const reasonField = formInput('change_tier_reason', 'Audit reason (optional)', {
|
||
hint: 'Free-form note. Stored on the tier_changes row + audit_log.',
|
||
})
|
||
quoteHolder.appendChild(reasonField)
|
||
|
||
buttonRow.appendChild(el('button', {
|
||
class: 'btn primary',
|
||
onclick: async () => {
|
||
const reason = (card.querySelector('[name=change_tier_reason]').value || '').trim() || null
|
||
// Confirm on downgrades — operator should explicitly OK
|
||
// before the buyer loses entitlements.
|
||
if (isDowngrade && !confirm('Confirm downgrade? Buyer loses entitlements without refund.')) {
|
||
return
|
||
}
|
||
buttonRow.querySelectorAll('button').forEach((b) => b.disabled = true)
|
||
status.textContent = 'Applying…'
|
||
status.style.color = ''
|
||
try {
|
||
// Always apply as comp from the admin UI. Paid admin
|
||
// tier changes are admin-API-only (back-compat) — see
|
||
// KEYSAT_INTEGRATION.md re: SDK-driven buyer upgrades.
|
||
const r = await api('/v1/admin/licenses/' + license.id + '/change-tier', {
|
||
method: 'POST',
|
||
body: {
|
||
to_policy_slug: selectedTargetSlug,
|
||
skip_payment: true,
|
||
reason,
|
||
},
|
||
})
|
||
status.textContent = 'Applied — license is now on ' + r.to_policy_slug + '.'
|
||
setTimeout(() => overlay.remove(), 800)
|
||
} catch (e) {
|
||
status.textContent = e.message
|
||
status.style.color = 'var(--danger)'
|
||
buttonRow.querySelectorAll('button').forEach((b) => b.disabled = false)
|
||
}
|
||
},
|
||
}, 'Apply'))
|
||
} catch (e) {
|
||
status.textContent = 'Quote failed: ' + e.message
|
||
status.style.color = 'var(--danger)'
|
||
}
|
||
}
|
||
}
|
||
|
||
// Edit-policy modal. Mutable: name, description, price_sats_override,
|
||
// duration, grace, max_machines, is_trial, entitlements, highlight.
|
||
// Slug + product + tip config are NOT editable here (tip has its own
|
||
// dedicated PATCH endpoint with its own validation rules).
|
||
function openEditPolicy(pol, prod) {
|
||
const overlay = el('div', {
|
||
style: 'position:fixed; inset:0; background:rgba(14,31,51,0.55); z-index:9999; ' +
|
||
'display:flex; align-items:center; justify-content:center; padding:20px; ' +
|
||
'overflow-y:auto;',
|
||
})
|
||
const DURATION_PRESETS = [
|
||
{ value: '0', label: 'Perpetual (no expiry)' },
|
||
{ value: '604800', label: '7 days' },
|
||
{ value: '2592000', label: '30 days' },
|
||
{ value: '7776000', label: '90 days' },
|
||
{ value: '15552000', label: '6 months' },
|
||
{ value: '31536000', label: '1 year' },
|
||
{ value: '63072000', label: '2 years' },
|
||
{ value: 'custom', label: 'Custom (in seconds)' },
|
||
]
|
||
// Map current duration to a preset value if it matches; else 'custom'.
|
||
const dur = pol.duration_seconds || 0
|
||
const matched = DURATION_PRESETS.find((p) => p.value === String(dur) && p.value !== 'custom')
|
||
const initialPreset = matched ? matched.value : 'custom'
|
||
|
||
const meta = pol.metadata || {}
|
||
const description = (typeof meta.description === 'string') ? meta.description : ''
|
||
const highlight = !!meta.highlight
|
||
|
||
const nameField = formInput('e_pol_name', 'Display name', { value: pol.name || '', required: true })
|
||
const descField = formInput('e_pol_description', 'Tier description (optional)', {
|
||
value: description,
|
||
hint: 'Shown on the tier card on /buy/' + (prod.slug || '<product>') + '. One sentence.',
|
||
})
|
||
const priceField = formInput('e_pol_price', 'Price (sats)', {
|
||
type: 'number',
|
||
value: String(pol.price_sats_override == null ? prod.price_sats || 0 : pol.price_sats_override),
|
||
hint: 'Override for this tier. 0 = free.',
|
||
})
|
||
const presetSel = formSelect('e_pol_preset', 'Duration', DURATION_PRESETS, { required: true, value: initialPreset })
|
||
const customDur = formInput('e_pol_custom', 'Custom (seconds)', { type: 'number', value: String(dur) })
|
||
const graceField = formInput('e_pol_grace', 'Grace period (days)', {
|
||
type: 'number',
|
||
value: String(Math.floor((pol.grace_seconds || 0) / 86400)),
|
||
})
|
||
const machinesField = formInput('e_pol_machines', 'Max devices (0 = unlimited)', {
|
||
type: 'number', value: String(pol.max_machines == null ? 1 : pol.max_machines),
|
||
})
|
||
// Entitlements input: bubble picker against the product's catalog
|
||
// (closed-list mode) when one exists, else legacy free-text
|
||
// textarea. The picker pre-selects the policy's current
|
||
// entitlements; the textarea pre-fills with one slug per line.
|
||
const editCatalog_pol = (prod && prod.entitlements_catalog) || []
|
||
const entField = (() => {
|
||
const host = el('div', { 'data-ent-host': '1' })
|
||
if (editCatalog_pol.length > 0) {
|
||
const picker = entitlementBubblePicker(editCatalog_pol, pol.entitlements || [])
|
||
host.appendChild(picker.element)
|
||
host._read = picker.read
|
||
host._mode = 'bubbles'
|
||
} else {
|
||
const fallback = formInput('e_pol_entitlements', 'Entitlements (one per line, or comma-separated)', {
|
||
textarea: true,
|
||
value: (pol.entitlements || []).join('\n'),
|
||
hint: 'Examples: self_host, ai_summaries, export. No quotes or brackets.',
|
||
})
|
||
host.appendChild(fallback)
|
||
host._mode = 'textarea'
|
||
}
|
||
return host
|
||
})()
|
||
const highlightField = formCheckbox('e_pol_highlight', 'Mark as "Most popular" on the tier picker')
|
||
if (highlight) setTimeout(() => {
|
||
const cb = card.querySelector('[name=e_pol_highlight]')
|
||
if (cb) cb.checked = true
|
||
}, 0)
|
||
const trialField = formCheckbox('e_pol_trial', 'Trial flag')
|
||
if (pol.is_trial) setTimeout(() => {
|
||
const cb = card.querySelector('[name=e_pol_trial]')
|
||
if (cb) cb.checked = true
|
||
}, 0)
|
||
|
||
// -- Recurring subscription (Pro tier) --
|
||
const RENEWAL_PRESETS = [
|
||
{ value: '30', label: 'Monthly (30 days)' },
|
||
{ value: '90', label: 'Quarterly (90 days)' },
|
||
{ value: '180', label: 'Semi-annual (180 days)' },
|
||
{ value: '365', label: 'Annual (365 days)' },
|
||
{ value: 'custom', label: 'Custom (in days)' },
|
||
]
|
||
const isRecurringInit = !!pol.is_recurring
|
||
const renewalDaysInit = pol.renewal_period_days || 30
|
||
const matchedRenewal = RENEWAL_PRESETS.find(
|
||
(p) => p.value === String(renewalDaysInit) && p.value !== 'custom'
|
||
)
|
||
const initialRenewalPreset = matchedRenewal ? matchedRenewal.value : 'custom'
|
||
const recurField = formCheckbox('e_pol_is_recurring', 'This policy is a recurring subscription')
|
||
const renewalPresetField = formSelect('e_pol_renewal_preset', 'Renewal cadence', RENEWAL_PRESETS, { value: initialRenewalPreset })
|
||
const renewalCustomField = formInput('e_pol_renewal_days', 'Custom (days)', {
|
||
type: 'number', value: String(renewalDaysInit),
|
||
})
|
||
const gracePeriodField = formInput('e_pol_grace_period_days', 'Grace period after renewal (days)', {
|
||
type: 'number', value: String(pol.grace_period_days == null ? 7 : pol.grace_period_days),
|
||
})
|
||
const trialDaysField = formInput('e_pol_trial_days', 'Free trial (days)', {
|
||
type: 'number', value: String(pol.trial_days || 0),
|
||
})
|
||
// Tier-ladder rank. Empty input means "not in any ladder" (server
|
||
// stores NULL); operator can blank it to remove a policy from the
|
||
// ladder, or set a number to add it. Range 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: 'Higher = better tier. Leave blank to keep this policy out of the buyer-facing upgrade ladder.',
|
||
})
|
||
if (isRecurringInit) setTimeout(() => {
|
||
const cb = card.querySelector('[name=e_pol_is_recurring]')
|
||
if (cb) cb.checked = true
|
||
syncRecurringEdit()
|
||
}, 0)
|
||
|
||
function syncRecurringEdit() {
|
||
const on = !!card.querySelector('[name=e_pol_is_recurring]').checked
|
||
const presetEl = card.querySelector('[name=e_pol_renewal_preset]')
|
||
const customEl = card.querySelector('[name=e_pol_renewal_days]')
|
||
const graceEl = card.querySelector('[name=e_pol_grace_period_days]')
|
||
const trialEl = card.querySelector('[name=e_pol_trial_days]')
|
||
;[presetEl, graceEl, trialEl].forEach((e) => {
|
||
if (e) { e.disabled = !on; e.style.opacity = on ? '1' : '0.5' }
|
||
})
|
||
if (customEl) {
|
||
const customOn = on && presetEl && presetEl.value === 'custom'
|
||
customEl.disabled = !customOn
|
||
customEl.style.opacity = customOn ? '1' : '0.5'
|
||
}
|
||
}
|
||
|
||
const status = el('div', { class: 'muted', style: 'margin-top:8px; font-size:12.5px;' }, '')
|
||
const card = el('div', {
|
||
style: 'background:var(--cream-50); border:1px solid var(--border-1); ' +
|
||
'border-radius:12px; max-width:560px; width:100%; padding:24px; ' +
|
||
'box-shadow:0 0 0 1px var(--gold-500) inset, 0 16px 32px rgba(14,31,51,0.20); ' +
|
||
'max-height:90vh; overflow-y:auto;',
|
||
}, [
|
||
el('div', { class: 'eyebrow', style: 'margin-bottom:8px' }, 'Edit policy'),
|
||
el('h3', { style: 'font-family:var(--font-display); font-weight:600; font-size:20px; margin:0 0 6px; color:var(--navy-950);' },
|
||
prod.name + ' — ' + pol.slug),
|
||
el('p', { class: 'muted', style: 'margin:0 0 16px; font-size:13px' },
|
||
'Slug not editable — disable + create a new policy if you need to rename. To change tip config, use the dedicated tip endpoint.'),
|
||
nameField,
|
||
descField,
|
||
priceField,
|
||
el('div', { class: 'row-2' }, [presetSel, customDur]),
|
||
el('div', { class: 'row-2' }, [graceField, machinesField]),
|
||
entField,
|
||
el('div', { class: 'row-2' }, [highlightField, trialField]),
|
||
// Tier ladder rank — sits in its own row above the recurring section.
|
||
tierRankField,
|
||
// Recurring subscription block
|
||
el('div', {
|
||
style: 'margin-top:14px; padding-top:12px; border-top:1px dashed var(--border-1)',
|
||
}, [
|
||
el('div', { class: 'eyebrow', style: 'margin-bottom:6px' }, 'Recurring subscription (Pro)'),
|
||
recurField,
|
||
el('div', { class: 'row-2', style: 'margin-top:8px' }, [renewalPresetField, renewalCustomField]),
|
||
el('div', { class: 'row-2' }, [gracePeriodField, trialDaysField]),
|
||
]),
|
||
status,
|
||
el('div', { style: 'display:flex; gap:10px; margin-top:14px;' }, [
|
||
el('button', { class: 'btn primary', onclick: async function () {
|
||
status.textContent = 'Saving…'
|
||
status.style.color = ''
|
||
try {
|
||
const presetV = card.querySelector('[name=e_pol_preset]').value
|
||
const customV = parseInt(card.querySelector('[name=e_pol_custom]').value, 10) || 0
|
||
const duration_seconds = presetV === 'custom' ? customV : parseInt(presetV, 10)
|
||
const grace_days = parseInt(card.querySelector('[name=e_pol_grace]').value, 10) || 0
|
||
const grace_seconds = grace_days * 86400
|
||
// Read from whichever mode the entitlements host is in
|
||
// (bubble picker vs textarea fallback). _read is set by
|
||
// entitlementBubblePicker; absence = textarea.
|
||
const entHost = card.querySelector('[data-ent-host]')
|
||
let ents
|
||
if (entHost && entHost._mode === 'bubbles' && entHost._read) {
|
||
ents = entHost._read()
|
||
} else {
|
||
const rawEnts = card.querySelector('[name=e_pol_entitlements]').value || ''
|
||
ents = Array.from(new Set(
|
||
rawEnts.replace(/[\[\]"'`]/g, '').split(/[\n,]/).map((s) => s.trim()).filter(Boolean)
|
||
))
|
||
}
|
||
const newDescription = (card.querySelector('[name=e_pol_description]').value || '').trim()
|
||
const newHighlight = card.querySelector('[name=e_pol_highlight]').checked
|
||
// Preserve any other metadata keys we don't manage in the form.
|
||
const newMetadata = Object.assign({}, meta)
|
||
if (newDescription) newMetadata.description = newDescription
|
||
else delete newMetadata.description
|
||
if (newHighlight) newMetadata.highlight = true
|
||
else delete newMetadata.highlight
|
||
const priceRaw = card.querySelector('[name=e_pol_price]').value.trim()
|
||
const price_sats_override = priceRaw === '' ? null : Math.max(0, parseInt(priceRaw, 10) || 0)
|
||
// Recurring subscription — send the fields whenever the operator
|
||
// touched any of them so update_policy can validate the post-update
|
||
// shape consistently. Easiest invariant: always send all four.
|
||
const isRecurring = card.querySelector('[name=e_pol_is_recurring]').checked
|
||
const renewalPreset = card.querySelector('[name=e_pol_renewal_preset]').value
|
||
const renewalCustom = parseInt(card.querySelector('[name=e_pol_renewal_days]').value, 10) || 0
|
||
const renewalDays = renewalPreset === 'custom'
|
||
? renewalCustom
|
||
: parseInt(renewalPreset, 10)
|
||
const gracePeriodDays = parseInt(card.querySelector('[name=e_pol_grace_period_days]').value, 10)
|
||
const trialDays = parseInt(card.querySelector('[name=e_pol_trial_days]').value, 10) || 0
|
||
|
||
const body = {
|
||
name: card.querySelector('[name=e_pol_name]').value.trim(),
|
||
duration_seconds,
|
||
grace_seconds,
|
||
max_machines: parseInt(card.querySelector('[name=e_pol_machines]').value, 10) || 0,
|
||
is_trial: card.querySelector('[name=e_pol_trial]').checked,
|
||
entitlements: ents,
|
||
metadata: newMetadata,
|
||
price_sats_override,
|
||
is_recurring: isRecurring,
|
||
renewal_period_days: isRecurring ? renewalDays : (pol.renewal_period_days || 0),
|
||
grace_period_days: isNaN(gracePeriodDays) ? 7 : gracePeriodDays,
|
||
trial_days: trialDays,
|
||
}
|
||
// tier_rank is a nullable patch. Empty input → null
|
||
// (remove from ladder). Number → set. We always send
|
||
// the field on edit so the server's "patch touched
|
||
// field?" logic fires and the operator's intent (clear
|
||
// or set) lands.
|
||
const tierRankRaw = (card.querySelector('[name=e_pol_tier_rank]').value || '').trim()
|
||
body.tier_rank = tierRankRaw === ''
|
||
? null
|
||
: Math.max(0, Math.min(1000, parseInt(tierRankRaw, 10) || 0))
|
||
await api('/v1/admin/policies/' + pol.id, { method: 'PATCH', body })
|
||
overlay.remove()
|
||
routes.policies()
|
||
} catch (e) {
|
||
status.textContent = e.message
|
||
status.style.color = 'var(--danger)'
|
||
}
|
||
} }, 'Save'),
|
||
el('button', { class: 'btn secondary', onclick: () => overlay.remove() }, 'Cancel'),
|
||
]),
|
||
])
|
||
overlay.appendChild(card)
|
||
overlay.addEventListener('click', (e) => { if (e.target === overlay) overlay.remove() })
|
||
document.body.appendChild(overlay)
|
||
|
||
// Wire the recurring section's enable/disable sync now that the card
|
||
// is in the DOM and inputs are queryable.
|
||
const recurEl = card.querySelector('[name=e_pol_is_recurring]')
|
||
const renewalPresetEl = card.querySelector('[name=e_pol_renewal_preset]')
|
||
if (recurEl) recurEl.addEventListener('change', syncRecurringEdit)
|
||
if (renewalPresetEl) renewalPresetEl.addEventListener('change', syncRecurringEdit)
|
||
syncRecurringEdit()
|
||
}
|
||
|
||
// -------- Policies --------
|
||
routes.policies = async function () {
|
||
const target = document.getElementById('route-target')
|
||
target.innerHTML = ''
|
||
|
||
const products = (await api('/v1/products').catch(() => ({ products: [] }))).products || []
|
||
if (products.length === 0) {
|
||
target.appendChild(plainCard([
|
||
el('p', { class: 'muted', style: 'margin:0' },
|
||
'Create a product first — a policy is always attached to a product.'),
|
||
]))
|
||
return
|
||
}
|
||
|
||
// Duration presets — operators rarely think in seconds. Custom drops
|
||
// them back to a raw-seconds field so power users still have it.
|
||
const DURATION_PRESETS = [
|
||
{ value: '0', label: 'Perpetual (no expiry)' },
|
||
{ value: '604800', label: '7 days' },
|
||
{ value: '2592000', label: '30 days' },
|
||
{ value: '7776000', label: '90 days' },
|
||
{ value: '15552000', label: '6 months' },
|
||
{ value: '31536000', label: '1 year' },
|
||
{ value: '63072000', label: '2 years' },
|
||
{ value: 'custom', label: 'Custom (in seconds)' },
|
||
]
|
||
|
||
// Price for a given product slug. Used to prefill the override field
|
||
// when the operator picks a product from the dropdown.
|
||
const PRODUCT_PRICE_BY_SLUG = Object.fromEntries(products.map((p) => [p.slug, p.price_sats]))
|
||
// Each product's entitlements catalog (migration 0014). Drives
|
||
// the closed-list bubble picker on the policy form. Empty / null
|
||
// catalog = legacy free-text textarea fallback.
|
||
const PRODUCT_CATALOG_BY_SLUG = Object.fromEntries(
|
||
products.map((p) => [p.slug, p.entitlements_catalog || []])
|
||
)
|
||
const initialProductSlug = products[0] ? products[0].slug : ''
|
||
const initialProductPrice = PRODUCT_PRICE_BY_SLUG[initialProductSlug] || 0
|
||
|
||
const create = el('details', { class: 'disclosure' }, [
|
||
el('summary', null, 'Create a new policy'),
|
||
el('div', { class: 'body' }, [
|
||
formSelect('product_slug', 'Product', products.map((p) => ({ value: p.slug, label: p.name + ' (' + p.slug + ', ' + p.price_sats.toLocaleString() + ' sats)' })), { required: true }),
|
||
el('div', { class: 'row-2' }, [
|
||
formInput('slug', 'Policy slug', {
|
||
required: true,
|
||
value: 'default',
|
||
hint: 'machine-readable id, lower-case. Examples: default, free, pro, patron. Cannot be changed later.',
|
||
}),
|
||
formInput('name', 'Display name', {
|
||
required: true,
|
||
value: 'Standard',
|
||
hint: 'shown to buyers on the tier picker on /buy/<product>.',
|
||
}),
|
||
]),
|
||
|
||
// Description (maps to metadata.description) — shown on the tier card.
|
||
formInput('tier_description', 'Tier description (optional)', {
|
||
hint: 'One-sentence blurb shown on the tier card. e.g. "Run Keysat for your own software" or "Unlocks Zaprite + recurring billing".',
|
||
}),
|
||
|
||
// Price override — prefilled with the product's base price; operator
|
||
// edits it to set this tier's price. Setting it equal to the product
|
||
// price still locks in that price on the policy (predictable across
|
||
// future product-price changes).
|
||
formInput('price_sats_override', 'Price (sats)', {
|
||
type: 'number',
|
||
required: true,
|
||
value: String(initialProductPrice),
|
||
hint: 'Pre-filled with the product\'s base price. Edit to set a different price for this tier (e.g. Free = 0, Pro = 250000, Patron = 500000). Set 0 for free tiers.',
|
||
}),
|
||
|
||
// Duration: preset + optional custom seconds.
|
||
el('div', { class: 'row-2' }, [
|
||
formSelect('duration_preset', 'Duration', DURATION_PRESETS, { required: true, value: '0' }),
|
||
formInput('duration_custom', 'Custom (seconds)', {
|
||
type: 'number', value: '0',
|
||
hint: 'Used only when the dropdown is "Custom". 86400 = 1 day. 31536000 = 1 year.',
|
||
}),
|
||
]),
|
||
|
||
el('div', { class: 'row-2' }, [
|
||
formInput('grace_days', 'Grace period after expiry (days)', {
|
||
type: 'number', value: '0',
|
||
hint: 'Validate calls return ok-with-warning during the grace window. 0 = no grace.',
|
||
}),
|
||
formInput('max_machines', 'Max devices (0 = unlimited)', {
|
||
type: 'number', required: true, value: '1',
|
||
hint: '1 = single seat. Set higher for team licenses.',
|
||
}),
|
||
]),
|
||
|
||
// Entitlements input — swaps based on product's catalog:
|
||
// - Closed list (catalog has entries): bubble multi-select
|
||
// - Legacy / no catalog: free-text textarea
|
||
// Rebuilt on product-change so the picker reflects the
|
||
// chosen product's catalog.
|
||
(() => {
|
||
const host = el('div', { 'data-ent-host': '1' })
|
||
const initial = PRODUCT_CATALOG_BY_SLUG[initialProductSlug] || []
|
||
if (initial.length > 0) {
|
||
const picker = entitlementBubblePicker(initial, [])
|
||
host.appendChild(picker.element)
|
||
host._read = picker.read
|
||
host._mode = 'bubbles'
|
||
} else {
|
||
const fallback = formInput('entitlements', 'Entitlements (one per line, or comma-separated)', {
|
||
textarea: true,
|
||
hint: 'Plain words. Examples: core, ai_summaries. Define them on the parent product\'s catalog to switch to a click-to-pick picker (recommended).',
|
||
})
|
||
host.appendChild(fallback)
|
||
host._mode = 'textarea'
|
||
}
|
||
return host
|
||
})(),
|
||
|
||
el('div', { class: 'row-2' }, [
|
||
formCheckbox('mark_highlight', 'Mark as "Most popular" (gold pill on tier picker)'),
|
||
formCheckbox('is_trial', 'Trial flag (sets TRIAL bit on the signed license)'),
|
||
]),
|
||
|
||
// ---------- Tier ladder rank ----------
|
||
// Operator-defined ordering for in-place upgrades. Higher
|
||
// rank = better tier. Leave blank to exclude this policy
|
||
// from the buyer-facing upgrade ladder (admin can still
|
||
// force-change to/from any policy via the licenses page).
|
||
formInput('tier_rank', 'Tier ladder rank (optional)', {
|
||
type: 'number',
|
||
hint: 'Position in the upgrade ladder for this product. Higher = better tier. Common pattern: free=0, standard=1, pro=2, patron=3. Leave blank to keep the policy out of the ladder (e.g. one-off promo). Range 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'
|
||
}
|
||
}
|
||
})
|
||
target.appendChild(plainCard([
|
||
el('p', { class: 'muted', style: 'margin:0 0 16px' },
|
||
'Reusable license templates per product. The policy slugged "default" is consumed by the public purchase flow; other slugs are used by manual license issuance.'),
|
||
create,
|
||
]))
|
||
|
||
// License-count map (one fetch covers all products / policies on the page).
|
||
const counts = await api('/v1/admin/licenses/counts').catch(() => ({ by_policy: {} }))
|
||
const byPolicy = (counts && counts.by_policy) || {}
|
||
|
||
// Render a raw seconds value as a human-readable duration. Common
|
||
// cadences map to nice labels (1 day, 1 week, 1 month, 1 year);
|
||
// arbitrary values fall back to the closest unit. 0 = perpetual.
|
||
function fmtDuration(secs) {
|
||
if (!secs || secs === 0) return 'perpetual'
|
||
const days = Math.round(secs / 86400)
|
||
if (secs < 60) return secs + 's'
|
||
if (secs < 3600) return Math.round(secs / 60) + 'min'
|
||
if (secs < 86400) return Math.round(secs / 3600) + 'h'
|
||
if (days === 1) return '1 day'
|
||
if (days === 7) return '1 week'
|
||
if (days === 30) return '1 month'
|
||
if (days === 90) return '3 months'
|
||
if (days === 180) return '6 months'
|
||
if (days === 365) return '1 year'
|
||
if (days === 730) return '2 years'
|
||
if (days % 365 === 0) return (days / 365) + ' years'
|
||
if (days % 30 === 0) return (days / 30) + ' months'
|
||
if (days % 7 === 0) return (days / 7) + ' weeks'
|
||
return days + ' days'
|
||
}
|
||
function fmtGrace(secs) {
|
||
if (!secs || secs === 0) return 'none'
|
||
return fmtDuration(secs)
|
||
}
|
||
|
||
for (const p of products) {
|
||
try {
|
||
const j = await api('/v1/admin/policies?product_slug=' + encodeURIComponent(p.slug))
|
||
const policies = j.policies || []
|
||
const rows = policies.map((pol) => el('tr', null, [
|
||
el('td', null, el('code', null, pol.slug)),
|
||
el('td', { style: 'font-weight:600; color:var(--navy-950)' }, pol.name),
|
||
el('td', null, fmtDuration(pol.duration_seconds)),
|
||
el('td', null, fmtGrace(pol.grace_seconds)),
|
||
el('td', null, pol.max_machines === 0 ? '∞' : String(pol.max_machines)),
|
||
el('td', null,
|
||
// Stack trial + recurring badges in one cell. Both can be set
|
||
// independently (a recurring policy can also have a trial bit).
|
||
pol.is_trial || pol.is_recurring
|
||
? el('span', { style: 'display:inline-flex; gap:4px; flex-wrap:wrap' }, [
|
||
pol.is_trial ? el('span', { class: 'badge b-warning' }, 'trial') : null,
|
||
pol.is_recurring
|
||
? el('span', {
|
||
class: 'badge b-gold',
|
||
title: 'Renews every ' + (pol.renewal_period_days || 0) + ' days',
|
||
}, 'every ' + (pol.renewal_period_days || 0) + 'd')
|
||
: null,
|
||
].filter(Boolean))
|
||
: el('span', { class: 'muted' }, '–')),
|
||
el('td', { class: 'muted' }, (() => {
|
||
// Render entitlement display names from the product's
|
||
// catalog when available, falling back to the slug
|
||
// verbatim. Tooltip shows the slug + description for
|
||
// operator reference.
|
||
const ents = pol.entitlements || []
|
||
if (ents.length === 0) return '–'
|
||
const cat = (p.entitlements_catalog || [])
|
||
return ents
|
||
.map((slug) => {
|
||
const entry = cat.find((c) => c.slug === slug)
|
||
return entry && entry.name ? entry.name : slug
|
||
})
|
||
.join(', ')
|
||
})()),
|
||
el('td', null, el('span', { class: 'muted' }, String(byPolicy[pol.id] || 0))),
|
||
el('td', null, activePill(pol.active)),
|
||
el('td', null, pol.public
|
||
? el('span', { class: 'badge b-gold', title: 'Visible on /buy/' + p.slug + ' tier picker' }, 'public')
|
||
: el('span', { class: 'muted', title: 'Hidden from public buy page; admin issuance only' }, 'private')),
|
||
el('td', null, el('div', { class: 'actions-row' }, [
|
||
el('button', {
|
||
class: 'btn sm secondary',
|
||
onclick: function () { openEditPolicy(pol, p) },
|
||
}, 'Edit'),
|
||
el('button', {
|
||
class: 'btn sm secondary',
|
||
title: pol.public ? 'Hide from /buy/' + p.slug : 'Show on /buy/' + p.slug,
|
||
onclick: async function () {
|
||
try {
|
||
await api('/v1/admin/policies/' + pol.id + '/public', {
|
||
method: 'PATCH', body: { public: !pol.public },
|
||
})
|
||
routes.policies()
|
||
} catch (e) { alert(e.message) }
|
||
},
|
||
}, pol.public ? 'Hide' : 'Show'),
|
||
el('button', {
|
||
class: 'btn sm danger',
|
||
title: 'Delete this policy. Safe by default; offers a force-delete with cascade if the policy has licenses or invoices.',
|
||
onclick: function () {
|
||
safeOrForceDelete({
|
||
kind: 'policy',
|
||
slug: pol.slug,
|
||
pathBase: '/v1/admin/policies/' + pol.id,
|
||
onSuccess: () => routes.policies(),
|
||
})
|
||
},
|
||
}, 'Delete'),
|
||
])),
|
||
]))
|
||
// Per-product header action: open the buy page in a new tab
|
||
// so the operator can preview how their policies render to a
|
||
// buyer without leaving the admin SPA. Only shown when the
|
||
// product has at least one public policy (otherwise the buy
|
||
// page would render empty).
|
||
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
|
||
target.appendChild(tableCard(
|
||
p.name + ' — ' + p.slug,
|
||
policies.length + ' polic' + (policies.length === 1 ? 'y' : 'ies'),
|
||
['Slug', 'Name', 'Duration', 'Grace', 'Seats', 'Trial', 'Entitlements', 'Licenses', 'Status', 'On buy page', ''],
|
||
rows,
|
||
'(no policies yet)',
|
||
previewBtn,
|
||
))
|
||
} catch (e) {
|
||
target.appendChild(plainCard([err('Failed to load policies for ' + p.slug + ': ' + e.message)]))
|
||
}
|
||
}
|
||
}
|
||
|
||
// -------- Subscriptions --------
|
||
routes.subscriptions = async function () {
|
||
const target = document.getElementById('route-target')
|
||
target.innerHTML = ''
|
||
target.appendChild(plainCard([
|
||
el('p', { class: 'muted', style: 'margin:0' },
|
||
'Recurring subscriptions tied to active licenses. Cancellation here ' +
|
||
'is non-destructive: the license stays valid through the end of the ' +
|
||
'current cycle, the renewal worker just stops creating new invoices.'),
|
||
]))
|
||
|
||
// Status filter pills.
|
||
const STATUSES = [
|
||
{ value: '', label: 'All' },
|
||
{ value: 'active', label: 'Active' },
|
||
{ value: 'past_due', label: 'Past due' },
|
||
{ value: 'cancelled', label: 'Cancelled' },
|
||
{ value: 'lapsed', label: 'Lapsed' },
|
||
]
|
||
let currentFilter = ''
|
||
const filterRow = el('div', { style: 'display:flex; gap:8px; flex-wrap:wrap; margin:14px 0' })
|
||
function renderFilterPills() {
|
||
filterRow.innerHTML = ''
|
||
STATUSES.forEach((s) => {
|
||
const active = s.value === currentFilter
|
||
const pill = el('button', {
|
||
class: 'btn sm ' + (active ? 'primary' : 'secondary'),
|
||
onclick: () => { currentFilter = s.value; renderFilterPills(); load() },
|
||
}, s.label)
|
||
filterRow.appendChild(pill)
|
||
})
|
||
}
|
||
renderFilterPills()
|
||
target.appendChild(filterRow)
|
||
|
||
const tableHost = el('div')
|
||
target.appendChild(tableHost)
|
||
|
||
async function load() {
|
||
tableHost.innerHTML = ''
|
||
try {
|
||
const url = '/v1/admin/subscriptions' + (currentFilter ? ('?status=' + currentFilter) : '')
|
||
const j = await api(url)
|
||
const subs = j.subscriptions || []
|
||
if (subs.length === 0) {
|
||
tableHost.appendChild(plainCard([
|
||
el('p', { class: 'muted', style: 'margin:0' }, '(no subscriptions match this filter)'),
|
||
]))
|
||
return
|
||
}
|
||
const table = el('table', { class: 'table' })
|
||
const thead = el('thead', null, el('tr', null, [
|
||
el('th', null, 'License'),
|
||
el('th', null, 'Cadence'),
|
||
el('th', null, 'Listed price'),
|
||
el('th', null, 'Status'),
|
||
el('th', null, 'Next renewal'),
|
||
el('th', null, 'Failures'),
|
||
el('th', null, 'Actions'),
|
||
]))
|
||
const tbody = el('tbody')
|
||
subs.forEach((s) => {
|
||
const statusBadge = (function () {
|
||
const klass = s.status === 'active' ? 'b-success'
|
||
: s.status === 'past_due' ? 'b-warning'
|
||
: s.status === 'cancelled' ? 'b-neutral'
|
||
: s.status === 'lapsed' ? 'b-danger' : 'b-neutral'
|
||
return el('span', { class: 'badge ' + klass }, s.status)
|
||
})()
|
||
const cadence = (s.period_days === 30 ? 'monthly'
|
||
: s.period_days === 90 ? 'quarterly'
|
||
: s.period_days === 180 ? 'semi-annual'
|
||
: s.period_days === 365 ? 'annual'
|
||
: ('every ' + s.period_days + 'd'))
|
||
const priceFmt = s.listed_currency === 'SAT'
|
||
? (Number(s.listed_value).toLocaleString() + ' sats')
|
||
: ((s.listed_value / 100).toFixed(2) + ' ' + s.listed_currency)
|
||
const tr = el('tr', null, [
|
||
el('td', null, el('code', { class: 'small', title: s.license_id }, s.license_id.slice(0, 8) + '…')),
|
||
el('td', null, cadence),
|
||
el('td', null, priceFmt),
|
||
el('td', null, statusBadge),
|
||
el('td', { class: 'muted' }, s.next_renewal_at ? s.next_renewal_at.slice(0, 16).replace('T', ' ') : '–'),
|
||
el('td', null, String(s.consecutive_failures || 0)),
|
||
el('td', null, (s.status === 'active' || s.status === 'past_due')
|
||
? el('button', {
|
||
class: 'btn sm danger',
|
||
onclick: async () => {
|
||
const reason = prompt(
|
||
'Cancel this subscription?\n\nThe license stays valid through the end of the current cycle. ' +
|
||
'No new invoices will be created.\n\nOptional: enter a reason for the audit log:'
|
||
)
|
||
if (reason === null) return // user clicked Cancel
|
||
try {
|
||
await api('/v1/admin/subscriptions/' + s.id + '/cancel', {
|
||
method: 'POST',
|
||
body: { reason: reason || null },
|
||
})
|
||
load()
|
||
} catch (e) { alert(e.message) }
|
||
},
|
||
}, 'Cancel')
|
||
: el('span', { class: 'muted', style: 'font-size:12px' }, '–')),
|
||
])
|
||
tbody.appendChild(tr)
|
||
})
|
||
table.appendChild(thead)
|
||
table.appendChild(tbody)
|
||
tableHost.appendChild(table)
|
||
} catch (e) {
|
||
tableHost.appendChild(plainCard([err('Failed to load subscriptions: ' + e.message)]))
|
||
}
|
||
}
|
||
load()
|
||
}
|
||
|
||
// -------- Discount codes --------
|
||
routes.codes = async function () {
|
||
const target = document.getElementById('route-target')
|
||
target.innerHTML = ''
|
||
|
||
function amountHint(kind, currency) {
|
||
if (kind === 'percent') return 'percent off, e.g. 50 = 50%. Range: 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 usedNote = c.used_count > 0
|
||
? '\n\nThis code has been redeemed ' + c.used_count + ' time(s). Delete will be refused (audit trail). Use Disable instead.'
|
||
: ''
|
||
if (!confirm('Permanently delete code "' + c.code + '"? This cannot be undone.' + usedNote)) return
|
||
try {
|
||
await api('/v1/admin/discount-codes/' + c.id, { method: 'DELETE' })
|
||
routes.codes()
|
||
} catch (e) { alert(e.message) }
|
||
},
|
||
}, 'Delete'),
|
||
])),
|
||
])
|
||
})
|
||
target.appendChild(tableCard(
|
||
'All codes',
|
||
codes.length + ' total',
|
||
['Code', 'Kind', 'Amount', 'Used', 'Expires', 'Status', ''],
|
||
rows,
|
||
'No codes yet.'
|
||
))
|
||
} catch (e) {
|
||
target.appendChild(plainCard([err(e.message)]))
|
||
}
|
||
}
|
||
|
||
// -------- Licenses --------
|
||
routes.licenses = async function () {
|
||
const target = document.getElementById('route-target')
|
||
target.innerHTML = ''
|
||
|
||
const queryInput = el('input', { class: 'input', type: 'text', placeholder: 'email, npub, or invoice id (leave blank for recent)' })
|
||
const fieldSel = el('select', { class: 'select' }, [
|
||
el('option', { value: 'email' }, 'Email'),
|
||
el('option', { value: 'npub' }, 'Nostr npub'),
|
||
el('option', { value: 'invoice' }, 'BTCPay invoice id'),
|
||
])
|
||
const tableHolder = el('div')
|
||
|
||
async function loadLicenses() {
|
||
const q = queryInput.value.trim()
|
||
tableHolder.innerHTML = ''
|
||
tableHolder.appendChild(el('div', { class: 'card' }, el('div', { class: 'empty' }, q ? 'Searching…' : 'Loading recent licenses…')))
|
||
try {
|
||
let url = '/v1/admin/licenses/search'
|
||
if (q) {
|
||
const params = new URLSearchParams()
|
||
params.set(fieldSel.value, q)
|
||
url += '?' + params.toString()
|
||
}
|
||
const j = await api(url)
|
||
const lic = j.licenses || []
|
||
function entitlementsCell(ents) {
|
||
if (!ents || ents.length === 0) {
|
||
return el('span', { class: 'muted' }, '–')
|
||
}
|
||
const wrap = el('div', { style: 'display:flex; flex-wrap:wrap; gap:4px;' })
|
||
ents.forEach((e) => {
|
||
wrap.appendChild(el('span', {
|
||
class: 'badge',
|
||
style: 'font-size:10.5px; padding:2px 7px; background:var(--cream-200); color:var(--ink-700); font-family:var(--font-mono); font-weight:500;',
|
||
title: e,
|
||
}, e))
|
||
})
|
||
return wrap
|
||
}
|
||
const rows = lic.map((l) => el('tr', null, [
|
||
el('td', null, el('code', null, shortId(l.id))),
|
||
el('td', null, l.product_slug
|
||
? el('code', { title: l.product_id }, l.product_slug)
|
||
: shortId(l.product_id)),
|
||
el('td', null, l.policy_slug
|
||
// Display name primary, slug secondary (smaller + muted)
|
||
// so operators see what the buyer sees ("Pro") without
|
||
// losing the technical identifier they need for SDK calls.
|
||
? el('div', null, [
|
||
el('div', { style: 'font-weight:500; color:var(--navy-950)' },
|
||
l.policy_name || l.policy_slug),
|
||
l.policy_name
|
||
? el('div', { class: 'muted', style: 'font-size:11.5px; font-family:var(--font-mono)' }, l.policy_slug)
|
||
: null,
|
||
])
|
||
: el('span', { class: 'muted' }, '–')),
|
||
el('td', null, entitlementsCell(l.entitlements)),
|
||
el('td', null, statusBadge(l.status)),
|
||
el('td', { class: 'muted' }, fmtDate(l.issued_at)),
|
||
el('td', { class: 'muted' }, l.expires_at ? fmtDate(l.expires_at) : el('span', { class: 'badge b-gold' }, 'perpetual')),
|
||
el('td', { class: 'muted' }, l.buyer_email || '–'),
|
||
el('td', null, el('div', { class: 'actions-row' }, [
|
||
l.status !== 'revoked'
|
||
? el('button', {
|
||
class: 'btn sm secondary',
|
||
title: 'Move this license to a different policy/tier',
|
||
onclick: () => openChangeTier(l),
|
||
}, 'Change tier')
|
||
: null,
|
||
l.status !== 'revoked' && l.status !== 'suspended'
|
||
? el('button', { class: 'btn sm secondary', onclick: () => actLicense(l, 'suspend') }, 'Suspend')
|
||
: null,
|
||
l.status === 'suspended'
|
||
? el('button', { class: 'btn sm secondary', onclick: () => actLicense(l, 'unsuspend') }, 'Unsuspend')
|
||
: null,
|
||
l.status !== 'revoked'
|
||
? el('button', { class: 'btn sm danger', onclick: () => actLicense(l, 'revoke') }, 'Revoke')
|
||
: null,
|
||
])),
|
||
]))
|
||
const title = q ? 'Search results' : 'Recent licenses'
|
||
const subtitle = lic.length + ' license' + (lic.length === 1 ? '' : 's') +
|
||
(q ? '' : (lic.length >= 100 ? ' (most recent 100)' : ''))
|
||
tableHolder.innerHTML = ''
|
||
tableHolder.appendChild(tableCard(
|
||
title,
|
||
subtitle,
|
||
['ID', 'Product', 'Policy', 'Entitlements', 'Status', 'Issued', 'Expires', 'Buyer', ''],
|
||
rows,
|
||
q ? 'No matches.' : 'No licenses issued yet — once a buyer purchases or redeems, they appear here.'
|
||
))
|
||
} catch (e) {
|
||
tableHolder.innerHTML = ''
|
||
tableHolder.appendChild(plainCard([err(e.message)]))
|
||
}
|
||
}
|
||
|
||
async function actLicense(l, op) {
|
||
if (op === 'revoke' && !confirm('Revoke this license? This is irreversible.')) return
|
||
const reason = prompt('Reason (optional):') || ''
|
||
try {
|
||
await api('/v1/admin/licenses/' + l.id + '/' + op, { method: 'POST', body: { reason } })
|
||
loadLicenses()
|
||
} catch (e) { alert(e.message) }
|
||
}
|
||
|
||
queryInput.addEventListener('keypress', (e) => { if (e.key === 'Enter') loadLicenses() })
|
||
|
||
// ---------- Manual issue (admin comp / promo / paper-licensed) ----------
|
||
const issueDisclosure = el('details', { class: 'disclosure' }, [
|
||
el('summary', null, 'Manually issue a license'),
|
||
el('div', { class: 'body', id: 'issue-body' },
|
||
el('div', { class: 'muted', style: 'margin:0' }, 'Loading products + policies…')
|
||
),
|
||
])
|
||
|
||
async function buildIssueForm() {
|
||
const body = issueDisclosure.querySelector('#issue-body')
|
||
body.innerHTML = ''
|
||
let products = []
|
||
try {
|
||
const j = await api('/v1/products')
|
||
products = j.products || []
|
||
} catch (e) {
|
||
body.appendChild(err('Could not load products: ' + e.message))
|
||
return
|
||
}
|
||
if (products.length === 0) {
|
||
body.appendChild(el('p', { class: 'muted', style: 'margin:0' },
|
||
'Create a product first (Products tab).'))
|
||
return
|
||
}
|
||
|
||
const productOptions = products.map((p) => ({ value: p.slug, label: p.name + ' (' + p.slug + ')' }))
|
||
const productSel = formSelect('issue_product', 'Product', productOptions, { required: true })
|
||
const policySel = formSelect('issue_policy', 'Policy', [{ value: '', label: '— loading —' }], { required: true })
|
||
const policyHint = el('div', { class: 'hint', style: 'margin-top:-6px; margin-bottom:12px;' }, 'Pick a tier; the license inherits its entitlements + duration + max_machines.')
|
||
const noteField = formInput('issue_note', 'Internal note (optional)', { hint: 'e.g. "comp", "press", "self-issue Pro for dogfood".' })
|
||
const emailField = formInput('issue_email', 'Buyer email (optional)', { type: 'email' })
|
||
|
||
// Populate policy dropdown when product changes.
|
||
async function refreshPolicies() {
|
||
const slug = productSel.querySelector('select').value
|
||
const sel = policySel.querySelector('select')
|
||
sel.innerHTML = ''
|
||
try {
|
||
const j = await api('/v1/admin/policies?product_slug=' + encodeURIComponent(slug))
|
||
const policies = (j.policies || []).filter((p) => p.active)
|
||
if (policies.length === 0) {
|
||
const opt = document.createElement('option')
|
||
opt.value = ''; opt.textContent = '(no active policies on this product)'
|
||
sel.appendChild(opt)
|
||
return
|
||
}
|
||
policies.forEach((p) => {
|
||
const opt = document.createElement('option')
|
||
opt.value = p.slug
|
||
opt.textContent = p.name + ' (' + p.slug + ')'
|
||
sel.appendChild(opt)
|
||
})
|
||
} catch (e) {
|
||
const opt = document.createElement('option')
|
||
opt.value = ''; opt.textContent = 'error: ' + e.message
|
||
sel.appendChild(opt)
|
||
}
|
||
}
|
||
productSel.querySelector('select').addEventListener('change', refreshPolicies)
|
||
|
||
const submitBtn = el('button', { class: 'btn primary', onclick: async function () {
|
||
const status = el('div', { class: 'muted', style: 'margin-top:8px' }, 'Issuing…')
|
||
body.appendChild(status)
|
||
try {
|
||
const product_slug = productSel.querySelector('select').value
|
||
const policy_slug = policySel.querySelector('select').value
|
||
if (!policy_slug) throw new Error('Pick a policy.')
|
||
const note = (body.querySelector('[name=issue_note]').value || '').trim()
|
||
const email = (body.querySelector('[name=issue_email]').value || '').trim()
|
||
const reqBody = { product_slug, policy_slug }
|
||
if (note) reqBody.note = note
|
||
if (email) reqBody.buyer_email = email
|
||
const j = await api('/v1/admin/licenses', { method: 'POST', body: reqBody })
|
||
status.remove()
|
||
showLicenseIssued(j.license_key, j.license_id)
|
||
loadLicenses()
|
||
} catch (e) {
|
||
if (handleTierCap(e)) status.remove()
|
||
else status.replaceWith(err(e.message))
|
||
}
|
||
} }, 'Issue license')
|
||
|
||
body.appendChild(productSel)
|
||
body.appendChild(policySel)
|
||
body.appendChild(policyHint)
|
||
body.appendChild(emailField)
|
||
body.appendChild(noteField)
|
||
body.appendChild(submitBtn)
|
||
refreshPolicies()
|
||
}
|
||
|
||
/// Modal showing the just-issued signed license_key with a Copy button
|
||
/// — the only place this string is ever shown post-issue. Operators
|
||
/// must save it before closing.
|
||
function showLicenseIssued(key, id) {
|
||
const overlay = el('div', {
|
||
style: 'position:fixed; inset:0; background:rgba(14,31,51,0.55); z-index:9999; ' +
|
||
'display:flex; align-items:center; justify-content:center; padding:20px;',
|
||
})
|
||
const card = el('div', {
|
||
style: 'background:var(--cream-50); border:1px solid var(--border-1); ' +
|
||
'border-radius:12px; max-width:560px; width:100%; padding:28px 26px; ' +
|
||
'box-shadow:0 0 0 1px var(--gold-500) inset, 0 16px 32px rgba(14,31,51,0.20);',
|
||
}, [
|
||
el('div', { class: 'eyebrow', style: 'color:var(--gold-700); margin-bottom:8px' }, 'License issued'),
|
||
el('h3', { style: 'font-family:var(--font-display); font-weight:600; font-size:22px; margin:0 0 6px; color:var(--navy-950); letter-spacing:-0.01em;' }, 'Save the key now'),
|
||
el('p', { style: 'font-size:13.5px; color:var(--ink-700); line-height:1.55; margin:0 0 14px;' },
|
||
'This is the only time the signed key is shown. Copy it before closing.'),
|
||
el('div', {
|
||
id: 'issued-key-box',
|
||
style: 'background:var(--navy-950); color:var(--cream-50); padding:14px 16px; ' +
|
||
'border-radius:8px; font-family:var(--font-mono); font-size:12.5px; ' +
|
||
'word-break:break-all; line-height:1.5; max-height:200px; overflow-y:auto;',
|
||
}, key),
|
||
el('div', { class: 'muted', style: 'margin-top:10px; font-size:12px;' },
|
||
'License id: ' + (id || '?')),
|
||
el('div', { style: 'display:flex; gap:10px; margin-top:18px;' }, [
|
||
el('button', {
|
||
class: 'btn primary',
|
||
onclick: async function () {
|
||
try {
|
||
await navigator.clipboard.writeText(key)
|
||
this.textContent = 'Copied'
|
||
setTimeout(() => { this.textContent = 'Copy license key' }, 1400)
|
||
} catch {}
|
||
},
|
||
}, 'Copy license key'),
|
||
el('button', {
|
||
class: 'btn secondary',
|
||
onclick: () => overlay.remove(),
|
||
}, 'Close'),
|
||
]),
|
||
])
|
||
overlay.appendChild(card)
|
||
document.body.appendChild(overlay)
|
||
}
|
||
|
||
target.appendChild(plainCard([
|
||
el('p', { class: 'muted', style: 'margin:0 0 16px' },
|
||
'Showing the 100 most recently issued licenses by default. Search by buyer email, Nostr npub, or BTCPay invoice id to filter.'),
|
||
el('div', { class: 'toolbar' }, [
|
||
fieldSel,
|
||
queryInput,
|
||
el('button', { class: 'btn primary', onclick: loadLicenses }, [
|
||
el('i', { 'data-lucide': 'search' }),
|
||
'Search',
|
||
]),
|
||
el('button', {
|
||
class: 'btn secondary',
|
||
onclick: () => { queryInput.value = ''; loadLicenses() },
|
||
title: 'Clear search and show recent',
|
||
}, 'Clear'),
|
||
]),
|
||
issueDisclosure,
|
||
]))
|
||
target.appendChild(tableHolder)
|
||
buildIssueForm()
|
||
if (window.lucide) lucide.createIcons()
|
||
|
||
// Auto-load on tab open — was the bug; tab used to render an empty
|
||
// search box and never call the backend, hiding all issued licenses.
|
||
loadLicenses()
|
||
}
|
||
|
||
// -------- Machines --------
|
||
routes.machines = async function () {
|
||
const target = document.getElementById('route-target')
|
||
target.innerHTML = ''
|
||
|
||
const idIn = el('input', { class: 'input mono', type: 'text', placeholder: 'license id (UUID)' })
|
||
const out = el('div')
|
||
|
||
async function load() {
|
||
out.innerHTML = ''
|
||
out.appendChild(el('div', { class: 'card' }, el('div', { class: 'empty' }, 'Loading…')))
|
||
try {
|
||
const j = await api('/v1/admin/machines?license_id=' + encodeURIComponent(idIn.value.trim()))
|
||
const ms = j.machines || []
|
||
const rows = ms.map((m) => el('tr', null, [
|
||
el('td', null, el('code', null, shortId(m.id))),
|
||
el('td', null, m.hostname || '–'),
|
||
el('td', null, m.platform || '–'),
|
||
el('td', { class: 'muted' }, fmtDate(m.last_heartbeat_at)),
|
||
el('td', null, (m.active === true || m.active === 1)
|
||
? el('span', { class: 'badge b-success' }, [el('span', { class: 'dot ok' }), 'active'])
|
||
: el('span', { class: 'badge b-neutral' }, 'inactive')),
|
||
el('td', null, (m.active === true || m.active === 1)
|
||
? el('button', { class: 'btn sm danger', onclick: async () => {
|
||
if (!confirm('Force-deactivate this machine? It will free the seat.')) return
|
||
try {
|
||
await api('/v1/admin/machines/' + m.id + '/deactivate', { method: 'POST', body: { reason: 'admin' } })
|
||
load()
|
||
} catch (e) { alert(e.message) }
|
||
}}, 'Deactivate')
|
||
: null),
|
||
]))
|
||
out.innerHTML = ''
|
||
out.appendChild(tableCard(
|
||
'Machines',
|
||
ms.length + ' bound',
|
||
['ID', 'Hostname', 'Platform', 'Last heartbeat', 'Status', ''],
|
||
rows,
|
||
'No machines bound to that license.'
|
||
))
|
||
} catch (e) {
|
||
out.innerHTML = ''
|
||
out.appendChild(plainCard([err(e.message)]))
|
||
}
|
||
}
|
||
|
||
target.appendChild(plainCard([
|
||
el('p', { class: 'muted', style: 'margin:0 0 16px' },
|
||
'Show or deactivate machines bound to a specific license. Provide the license id (find it via Licenses → search).'),
|
||
el('div', { class: 'toolbar' }, [
|
||
idIn,
|
||
el('button', { class: 'btn primary', onclick: load }, 'Load'),
|
||
]),
|
||
]))
|
||
target.appendChild(out)
|
||
}
|
||
|
||
// -------- Webhooks --------
|
||
routes.webhooks = async function () {
|
||
const target = document.getElementById('route-target')
|
||
target.innerHTML = ''
|
||
|
||
const create = el('details', { class: 'disclosure' }, [
|
||
el('summary', null, 'Register a new webhook endpoint'),
|
||
el('div', { class: 'body' }, [
|
||
formInput('url', 'URL', { required: true, hint: 'where Keysat will POST event payloads' }),
|
||
formInput('description', 'Description (internal)'),
|
||
formInput('event_types', 'Event types (comma-separated; * for all)', { value: '*' }),
|
||
el('button', { class: 'btn primary', onclick: async function () {
|
||
try {
|
||
const evts = (create.querySelector('[name=event_types]').value || '*').split(',').map((s) => s.trim()).filter(Boolean)
|
||
await api('/v1/admin/webhook-endpoints', { method: 'POST', body: {
|
||
url: create.querySelector('[name=url]').value.trim(),
|
||
description: create.querySelector('[name=description]').value || '',
|
||
event_types: evts,
|
||
}})
|
||
routes.webhooks()
|
||
} catch (e) { alert(e.message) }
|
||
}}, 'Register endpoint'),
|
||
]),
|
||
])
|
||
target.appendChild(plainCard([
|
||
el('p', { class: 'muted', style: 'margin:0 0 16px' },
|
||
'Subscribe an external URL to Keysat events: license.issued, license.revoked, code.redeemed, etc. Each delivery carries an HMAC-SHA256 signature in the X-Keysat-Signature header.'),
|
||
create,
|
||
]))
|
||
|
||
try {
|
||
const j = await api('/v1/admin/webhook-endpoints')
|
||
const eps = j.endpoints || j.webhooks || []
|
||
const rows = eps.map((e) => el('tr', null, [
|
||
el('td', null, el('code', { style: 'word-break:break-all' }, e.url)),
|
||
el('td', { class: 'muted' }, (e.event_types || []).join(', ')),
|
||
el('td', null, activePill(e.active)),
|
||
el('td', { class: 'muted' }, fmtDate(e.created_at)),
|
||
el('td', null, el('div', { class: 'actions-row' }, [
|
||
el('button', { class: 'btn sm secondary', onclick: async () => {
|
||
try { await api('/v1/admin/webhook-endpoints/' + e.id + '/active', { method: 'PATCH', body: { active: !e.active } }); routes.webhooks() } catch (er) { alert(er.message) }
|
||
}}, e.active ? 'Disable' : 'Enable'),
|
||
el('button', { class: 'btn sm danger', onclick: async () => {
|
||
if (!confirm('Delete this webhook subscription?')) return
|
||
try { await api('/v1/admin/webhook-endpoints/' + e.id, { method: 'DELETE' }); routes.webhooks() } catch (er) { alert(er.message) }
|
||
}}, 'Delete'),
|
||
])),
|
||
]))
|
||
target.appendChild(tableCard(
|
||
'Registered endpoints',
|
||
eps.length + ' total',
|
||
['URL', 'Events', 'Status', 'Created', ''],
|
||
rows,
|
||
'No webhooks registered.'
|
||
))
|
||
} catch (e) {
|
||
target.appendChild(plainCard([err(e.message)]))
|
||
}
|
||
|
||
// ----- Delivery history -----
|
||
//
|
||
// Outbound deliveries get retried with exponential backoff up to
|
||
// 10 attempts; after that they're "dead-lettered" — sat in the
|
||
// DB unreachable. The new admin endpoint (v0.1.0:43) exposes them
|
||
// so operators can investigate and manually re-queue.
|
||
const status = el('select', { class: 'input', style: 'min-width:10rem' }, [
|
||
el('option', { value: 'all' }, 'All'),
|
||
el('option', { value: 'pending' }, 'Pending (in retry queue)'),
|
||
el('option', { value: 'delivered' }, 'Delivered'),
|
||
el('option', { value: 'failed', selected: 'selected' }, 'Failed (DLQ)'),
|
||
])
|
||
const reload = el('button', { class: 'btn sm secondary', onclick: () => loadDeliveries() }, 'Reload')
|
||
const deliveriesContainer = el('div')
|
||
|
||
async function loadDeliveries () {
|
||
deliveriesContainer.innerHTML = ''
|
||
deliveriesContainer.appendChild(el('p', { class: 'muted' }, 'Loading…'))
|
||
try {
|
||
const params = new URLSearchParams({ status: status.value, limit: '100' })
|
||
const j = await api('/v1/admin/webhook-deliveries?' + params.toString())
|
||
const ds = j.deliveries || []
|
||
deliveriesContainer.innerHTML = ''
|
||
|
||
if (ds.length === 0) {
|
||
const empty = status.value === 'failed'
|
||
? 'No failed deliveries — all webhooks are landing or in flight.'
|
||
: 'No deliveries match this filter.'
|
||
deliveriesContainer.appendChild(el('p', { class: 'muted', style: 'margin:8px 0' }, empty))
|
||
return
|
||
}
|
||
|
||
const rows = ds.map((d) => {
|
||
// status: delivered (delivered_at set) | failed (no next + attempts > 0) | pending (next set)
|
||
let pillCls = 'badge b-warning'
|
||
let pillDot = 'warn'
|
||
let pillText = 'pending'
|
||
if (d.delivered_at) {
|
||
pillCls = 'badge b-success'
|
||
pillDot = 'ok'
|
||
pillText = 'delivered'
|
||
} else if (!d.next_attempt_at && d.attempt_count > 0) {
|
||
pillCls = 'badge b-danger'
|
||
pillDot = 'err'
|
||
pillText = 'failed (DLQ)'
|
||
}
|
||
const pill = el('span', { class: pillCls }, [el('span', { class: 'dot ' + pillDot }), pillText])
|
||
const lastErr = d.last_error
|
||
? el('div', { class: 'muted', style: 'font-size:0.85em; margin-top:4px; word-break:break-all' }, d.last_error)
|
||
: null
|
||
return el('tr', null, [
|
||
el('td', { class: 'muted' }, fmtDate(d.created_at)),
|
||
el('td', null, d.event_type),
|
||
el('td', null, [pill, lastErr].filter(Boolean)),
|
||
el('td', { class: 'muted' }, String(d.attempt_count)),
|
||
el('td', { class: 'muted' }, d.last_status_code ? String(d.last_status_code) : '—'),
|
||
el('td', null, d.delivered_at
|
||
? null
|
||
: el('button', { class: 'btn sm secondary', onclick: async () => {
|
||
try {
|
||
await api('/v1/admin/webhook-deliveries/' + d.id + '/retry', { method: 'POST' })
|
||
loadDeliveries()
|
||
} catch (er) { alert(er.message) }
|
||
}}, 'Retry')),
|
||
])
|
||
})
|
||
deliveriesContainer.appendChild(el('table', { class: 'data' }, [
|
||
el('thead', null, el('tr', null,
|
||
['Created', 'Event', 'Status', 'Attempts', 'Last code', '']
|
||
.map((h) => el('th', null, h))
|
||
)),
|
||
el('tbody', null, rows),
|
||
]))
|
||
} catch (e) {
|
||
deliveriesContainer.innerHTML = ''
|
||
deliveriesContainer.appendChild(err(e.message))
|
||
}
|
||
}
|
||
status.addEventListener('change', loadDeliveries)
|
||
|
||
target.appendChild(plainCard([
|
||
el('h3', { style: 'margin:0 0 8px' }, 'Delivery history'),
|
||
el('p', { class: 'muted', style: 'margin:0 0 12px' },
|
||
'Defaults to "Failed" so the DLQ is visible at a glance. Failed deliveries are dead-lettered after 10 retry attempts (~7h backoff window). "Retry" re-queues the delivery for the worker on its next 5s tick.'),
|
||
el('div', { style: 'display:flex; gap:8px; align-items:center; margin-bottom:12px' }, [status, reload]),
|
||
deliveriesContainer,
|
||
]))
|
||
loadDeliveries()
|
||
}
|
||
|
||
// -------- Audit log --------
|
||
routes.audit = async function () {
|
||
const target = document.getElementById('route-target')
|
||
target.innerHTML = ''
|
||
|
||
const filter = el('input', { class: 'input', type: 'text', placeholder: 'filter by action (optional)' })
|
||
const limit = el('input', { class: 'input', type: 'number', value: '50', style: 'min-width:6rem; max-width:8rem' })
|
||
const out = el('div')
|
||
|
||
async function load() {
|
||
out.innerHTML = ''
|
||
out.appendChild(el('div', { class: 'card' }, el('div', { class: 'empty' }, 'Loading…')))
|
||
try {
|
||
const params = new URLSearchParams()
|
||
params.set('limit', limit.value || '50')
|
||
if (filter.value.trim()) params.set('action', filter.value.trim())
|
||
const j = await api('/v1/admin/audit?' + params.toString())
|
||
const entries = j.entries || []
|
||
const rows = entries.map((e) => el('tr', null, [
|
||
el('td', { class: 'muted' }, fmtDate(e.occurred_at)),
|
||
el('td', null, el('code', null, e.action)),
|
||
el('td', { class: 'muted' }, e.target_kind ? e.target_kind + ' ' + shortId(e.target_id || '') : '–'),
|
||
el('td', { class: 'muted' }, e.actor_kind),
|
||
]))
|
||
out.innerHTML = ''
|
||
out.appendChild(tableCard(
|
||
'Recent entries',
|
||
entries.length + ' shown',
|
||
['When', 'Action', 'Target', 'Actor'],
|
||
rows,
|
||
'No entries.'
|
||
))
|
||
} catch (e) {
|
||
out.innerHTML = ''
|
||
out.appendChild(plainCard([err(e.message)]))
|
||
}
|
||
}
|
||
|
||
target.appendChild(plainCard([
|
||
el('p', { class: 'muted', style: 'margin:0 0 16px' },
|
||
'Most recent admin mutations. Filter by action slug if you know what you’re looking for.'),
|
||
el('div', { class: 'toolbar' }, [
|
||
filter, limit,
|
||
el('button', { class: 'btn primary', onclick: load }, 'Load'),
|
||
]),
|
||
]))
|
||
target.appendChild(out)
|
||
load()
|
||
}
|
||
|
||
// ---------- form helpers ----------
|
||
function formInput(name, label, opts) {
|
||
opts = opts || {}
|
||
const id = 'f_' + name + '_' + Math.random().toString(36).slice(2, 6)
|
||
const lbl = el('label', { class: 'lbl', for: id }, [label, opts.required ? el('span', { class: 'req' }, '*') : null])
|
||
const inp = opts.textarea
|
||
? el('textarea', { class: 'input', id, name, rows: '3' })
|
||
: el('input', { class: 'input' + (opts.mono ? ' mono' : ''), id, name, type: opts.type || 'text' })
|
||
if (opts.value != null) inp.value = opts.value
|
||
const wrap = el('div', { class: 'field' }, [lbl, inp])
|
||
if (opts.hint) wrap.appendChild(el('div', { class: 'hint' }, opts.hint))
|
||
return wrap
|
||
}
|
||
function formSelect(name, label, options, opts) {
|
||
opts = opts || {}
|
||
const id = 'f_' + name + '_' + Math.random().toString(36).slice(2, 6)
|
||
const lbl = el('label', { class: 'lbl', for: id }, [label, opts.required ? el('span', { class: 'req' }, '*') : null])
|
||
const sel = el('select', { class: 'select', id, name })
|
||
for (const o of options) {
|
||
// Per-option `disabled: true` lets callers grey-out specific
|
||
// entries — e.g. the Change Tier dropdown marks the current
|
||
// tier as disabled with "(current)" so operators see what
|
||
// they're starting from but can't pick a no-op.
|
||
const attrs = { value: o.value }
|
||
if (o.disabled) attrs.disabled = 'disabled'
|
||
sel.appendChild(el('option', attrs, o.label))
|
||
}
|
||
if (opts.value) sel.value = opts.value
|
||
return el('div', { class: 'field' }, [lbl, sel])
|
||
}
|
||
function formCheckbox(name, label) {
|
||
const id = 'f_' + name + '_' + Math.random().toString(36).slice(2, 6)
|
||
const cb = el('input', { id, name, type: 'checkbox' })
|
||
return el('div', { class: 'field', style: 'display:flex; align-items:center; gap:8px; margin-top:24px' }, [
|
||
cb,
|
||
el('label', { class: 'lbl', for: id, style: 'margin:0' }, label),
|
||
])
|
||
}
|
||
|
||
/**
|
||
* Repeating-row editor for the product entitlements catalog
|
||
* (migration 0014). Operator declares the closed list of
|
||
* {slug, name, description} the product offers, then policies
|
||
* pick from this list rather than free-typing entitlement strings.
|
||
*
|
||
* Usage:
|
||
* const editor = catalogEditor(initialCatalog) // array or null
|
||
* container.appendChild(editor.element)
|
||
* // later, on submit:
|
||
* const catalog = editor.read() // returns array of {slug, name, description}
|
||
* // or null when the operator left it empty
|
||
*
|
||
* Empty editor = null (caller can treat that as "leave field alone"
|
||
* or "clear catalog" depending on context). Whitespace-only slugs
|
||
* are dropped silently. Validation (lowercase, ASCII, unique) is
|
||
* server-side; the UI just shows a hint.
|
||
*/
|
||
function catalogEditor(initial) {
|
||
const rowsHost = el('div', { style: 'display:flex; flex-direction:column; gap:8px' })
|
||
const addRow = (slug, name, description) => {
|
||
const row = el('div', {
|
||
class: 'catalog-row',
|
||
style: 'display:grid; grid-template-columns: 1fr 1fr 1.6fr auto; gap:6px; align-items:flex-start',
|
||
}, [
|
||
el('input', {
|
||
class: 'input', placeholder: 'slug',
|
||
value: slug || '',
|
||
'data-field': 'slug',
|
||
}),
|
||
el('input', {
|
||
class: 'input', placeholder: 'Display name',
|
||
value: name || '',
|
||
'data-field': 'name',
|
||
}),
|
||
el('input', {
|
||
class: 'input', placeholder: 'Description (shown on buy page tooltip)',
|
||
value: description || '',
|
||
'data-field': 'description',
|
||
}),
|
||
(() => {
|
||
const btn = el('button', {
|
||
type: 'button',
|
||
class: 'btn sm danger',
|
||
title: 'Remove this entitlement',
|
||
style: 'padding:6px 10px',
|
||
}, '×')
|
||
btn.addEventListener('click', () => row.remove())
|
||
return btn
|
||
})(),
|
||
])
|
||
rowsHost.appendChild(row)
|
||
}
|
||
if (Array.isArray(initial) && initial.length > 0) {
|
||
initial.forEach((e) => addRow(e.slug, e.name, e.description))
|
||
}
|
||
const addBtn = el('button', {
|
||
type: 'button',
|
||
class: 'btn sm secondary',
|
||
style: 'margin-top:6px; align-self:flex-start',
|
||
}, '+ Add entitlement')
|
||
addBtn.addEventListener('click', () => addRow('', '', ''))
|
||
|
||
const wrap = el('div', { style: 'display:flex; flex-direction:column' }, [
|
||
el('div', { class: 'lbl', style: 'margin-bottom:4px' }, 'Entitlements catalog'),
|
||
el('div', { class: 'muted', style: 'font-size:12px; margin-bottom:8px' },
|
||
'Declare the entitlements this product offers (e.g. "core", "ai_summaries"). ' +
|
||
'Policies will pick from this list — buyers see the display name + description, ' +
|
||
'never the raw slug. Leave empty to use the legacy free-text mode (any string allowed).'),
|
||
rowsHost,
|
||
addBtn,
|
||
])
|
||
return {
|
||
element: wrap,
|
||
read: function () {
|
||
const out = []
|
||
rowsHost.querySelectorAll('.catalog-row').forEach((row) => {
|
||
const slug = row.querySelector('[data-field=slug]').value.trim()
|
||
if (!slug) return
|
||
const name = row.querySelector('[data-field=name]').value.trim()
|
||
const description = row.querySelector('[data-field=description]').value.trim()
|
||
out.push({ slug, name: name || slug, description })
|
||
})
|
||
return out.length > 0 ? out : null
|
||
},
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Bubble multi-select for picking entitlements off a product's
|
||
* catalog. Renders one clickable pill per catalog entry; click to
|
||
* toggle selected state. Hover shows the description.
|
||
*
|
||
* Used in the policy create + edit forms when the parent product
|
||
* has a non-empty catalog (closed-list mode). When the catalog is
|
||
* empty, callers fall back to the legacy free-text textarea.
|
||
*
|
||
* const picker = entitlementBubblePicker(catalog, ['core', 'pro'])
|
||
* container.appendChild(picker.element)
|
||
* const slugs = picker.read() // -> ['core', 'pro']
|
||
*/
|
||
function entitlementBubblePicker(catalog, initialSelection) {
|
||
const selected = new Set(Array.isArray(initialSelection) ? initialSelection : [])
|
||
const host = el('div', {
|
||
style: 'display:flex; flex-wrap:wrap; gap:6px; margin-top:4px',
|
||
})
|
||
const pills = []
|
||
function renderPill(entry) {
|
||
const isSel = selected.has(entry.slug)
|
||
const pill = el('button', {
|
||
type: 'button',
|
||
title: entry.description || entry.slug,
|
||
'data-slug': entry.slug,
|
||
style:
|
||
'padding:6px 12px; border-radius:999px; cursor:pointer; ' +
|
||
'font-family:var(--font-body); font-size:13px; font-weight:500; ' +
|
||
'transition:all 100ms; ' +
|
||
(isSel
|
||
? 'background:var(--gold-500); color:var(--navy-950); border:1px solid var(--gold-500); '
|
||
: 'background:transparent; color:var(--ink-700); border:1px solid var(--border-2); '),
|
||
}, entry.name || entry.slug)
|
||
pill.addEventListener('click', () => {
|
||
if (selected.has(entry.slug)) {
|
||
selected.delete(entry.slug)
|
||
} else {
|
||
selected.add(entry.slug)
|
||
}
|
||
// Re-style only this pill rather than re-rendering the host.
|
||
const nowSel = selected.has(entry.slug)
|
||
pill.style.background = nowSel ? 'var(--gold-500)' : 'transparent'
|
||
pill.style.color = nowSel ? 'var(--navy-950)' : 'var(--ink-700)'
|
||
pill.style.borderColor = nowSel ? 'var(--gold-500)' : 'var(--border-2)'
|
||
})
|
||
pills.push(pill)
|
||
host.appendChild(pill)
|
||
}
|
||
;(catalog || []).forEach(renderPill)
|
||
|
||
const wrap = el('div', { class: 'field' }, [
|
||
el('label', { class: 'lbl' }, 'Entitlements'),
|
||
el('div', { class: 'muted', style: 'font-size:12px; margin-bottom:6px' },
|
||
'Click each entitlement this tier should grant. Defined on the parent product\'s catalog.'),
|
||
host,
|
||
])
|
||
return {
|
||
element: wrap,
|
||
read: () => Array.from(selected),
|
||
}
|
||
}
|
||
|
||
// ---------- nav + auth ----------
|
||
function setRoute(name) {
|
||
const links = document.querySelectorAll('.sidebar a.nav')
|
||
for (const a of links) a.classList.toggle('active', a.getAttribute('data-route') === name)
|
||
const meta = ROUTE_META[name] || ROUTE_META.overview
|
||
document.getElementById('page-title').textContent = meta.title
|
||
document.getElementById('crumb').textContent = meta.crumb
|
||
const fn = routes[name] || routes.overview
|
||
fn().catch((e) => {
|
||
const t = document.getElementById('route-target')
|
||
t.innerHTML = ''
|
||
t.appendChild(plainCard([err(e.message || String(e))]))
|
||
}).finally(() => {
|
||
if (window.lucide) lucide.createIcons()
|
||
})
|
||
history.replaceState(null, '', '#' + name)
|
||
}
|
||
document.querySelectorAll('.sidebar a.nav').forEach((a) => {
|
||
a.addEventListener('click', (e) => { e.preventDefault(); setRoute(a.getAttribute('data-route')) })
|
||
})
|
||
|
||
async function refreshTierBanner() {
|
||
const wrap = document.getElementById('tier-banner')
|
||
const current = document.getElementById('tier-banner-current')
|
||
const msg = document.getElementById('tier-banner-msg')
|
||
const cta = document.getElementById('tier-banner-cta')
|
||
if (!wrap) return
|
||
try {
|
||
const t = await api('/v1/admin/tier')
|
||
// Tier label header.
|
||
current.textContent = t.tier === 'unlicensed'
|
||
? 'Unlicensed'
|
||
: (t.tier_name || 'Creator') + ' tier'
|
||
// Body copy + CTA based on tier.
|
||
if (t.tier === 'patron') {
|
||
msg.innerHTML = '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 if (t.tier === 'creator') {
|
||
const productCap = (t.caps && t.caps.products) || 5
|
||
const productUsed = (t.usage && t.usage.products) || 0
|
||
msg.innerHTML = 'Up to ' + productCap + ' products, ' + productCap +
|
||
' policies/product, ' + ((t.caps && t.caps.active_codes) || 5) +
|
||
' active codes. Currently using ' + productUsed + '/' + productCap + ' products. ' +
|
||
'Upgrade for unlimited products, recurring billing, and Zaprite.'
|
||
cta.textContent = 'Upgrade to Pro →'
|
||
cta.href = t.upgrade_url
|
||
cta.style.display = 'inline-block'
|
||
} else {
|
||
// Unlicensed.
|
||
msg.innerHTML = 'Running without a Keysat license. You’re limited to ' +
|
||
((t.caps && t.caps.products) || 5) + ' products. ' +
|
||
'Get a Creator license (free codes available) or upgrade to Pro for unlimited.'
|
||
cta.textContent = 'Get Keysat license →'
|
||
cta.href = 'https://licensing.keysat.xyz/buy/keysat'
|
||
cta.style.display = 'inline-block'
|
||
}
|
||
wrap.style.display = 'block'
|
||
} catch (e) {
|
||
// Hide silently if endpoint not available (older daemon, etc.)
|
||
wrap.style.display = 'none'
|
||
}
|
||
}
|
||
|
||
async function refreshSidebarFooter() {
|
||
refreshTierBanner()
|
||
const f = document.getElementById('sidebar-footer')
|
||
try {
|
||
const s = await api('/v1/admin/btcpay/status')
|
||
f.innerHTML = ''
|
||
if (s.connected) {
|
||
f.appendChild(el('span', { class: 'dot' }))
|
||
f.appendChild(el('div', null, [
|
||
el('div', { style: 'color:var(--cream-50); font-weight:600' }, 'BTCPay connected'),
|
||
el('div', null, 'store ' + (s.store_id || '?').slice(0, 12) + '…'),
|
||
]))
|
||
} else {
|
||
f.appendChild(el('span', { class: 'dot warn' }))
|
||
f.appendChild(el('div', null, [
|
||
el('div', { style: 'color:var(--cream-50); font-weight:600' }, 'BTCPay not connected'),
|
||
el('div', null, 'use StartOS Actions tab'),
|
||
]))
|
||
}
|
||
} catch {
|
||
f.innerHTML = ''
|
||
f.appendChild(el('span', { class: 'dot warn' }))
|
||
f.appendChild(el('div', null, [
|
||
el('div', { style: 'color:var(--cream-50); font-weight:600' }, 'BTCPay status'),
|
||
el('div', null, 'unavailable'),
|
||
]))
|
||
}
|
||
}
|
||
|
||
// sessionAuth = true means the browser has a valid keysat_session cookie;
|
||
// the server-side middleware handles bridging it to the API-key auth used
|
||
// by all admin handlers. apiKey is only set in the legacy fallback path
|
||
// (first-time login on a fresh install before a password has been set).
|
||
let sessionAuth = false
|
||
|
||
function whoLabel() {
|
||
if (sessionAuth) return 'signed in'
|
||
if (apiKey) return apiKey.slice(0, 6) + '…' + apiKey.slice(-4)
|
||
return ''
|
||
}
|
||
|
||
function showApp() {
|
||
document.getElementById('login-view').classList.add('hide')
|
||
document.getElementById('app-view').classList.remove('hide')
|
||
document.getElementById('who').textContent = whoLabel()
|
||
fetch('/').then((r) => r.json()).then((j) => {
|
||
serviceInfo = j
|
||
}).catch(() => {}).finally(() => {
|
||
const route = (location.hash || '#overview').slice(1)
|
||
setRoute(route in routes ? route : 'overview')
|
||
if (window.lucide) lucide.createIcons()
|
||
})
|
||
refreshSidebarFooter()
|
||
}
|
||
|
||
async function showLogin() {
|
||
document.getElementById('login-view').classList.remove('hide')
|
||
document.getElementById('app-view').classList.add('hide')
|
||
const errEl = document.getElementById('login-err')
|
||
errEl.classList.add('hide')
|
||
// Pick which login mode to show based on whether a password is configured.
|
||
let status
|
||
try {
|
||
const r = await fetch('/admin/login/status', { credentials: 'same-origin' })
|
||
status = await r.json()
|
||
} catch {
|
||
status = { has_password: false, logged_in: false }
|
||
}
|
||
const sub = document.getElementById('login-sub')
|
||
const pwBox = document.getElementById('login-pw')
|
||
const keyBox = document.getElementById('login-key')
|
||
if (status.has_password) {
|
||
pwBox.classList.remove('hide')
|
||
keyBox.classList.add('hide')
|
||
sub.textContent = 'Sign in with your web UI password.'
|
||
setTimeout(() => document.getElementById('pw').focus(), 0)
|
||
} else {
|
||
pwBox.classList.add('hide')
|
||
keyBox.classList.remove('hide')
|
||
sub.textContent = 'No web UI password set yet. Sign in with the API key, then set a password via the StartOS action.'
|
||
setTimeout(() => document.getElementById('api-key').focus(), 0)
|
||
}
|
||
}
|
||
|
||
// ---- Password login (preferred) ----
|
||
document.getElementById('login-pw-btn').addEventListener('click', async () => {
|
||
const errEl = document.getElementById('login-err')
|
||
errEl.classList.add('hide')
|
||
const password = document.getElementById('pw').value
|
||
if (!password) { errEl.textContent = 'Enter your password.'; errEl.classList.remove('hide'); return }
|
||
try {
|
||
const r = await fetch('/admin/login', {
|
||
method: 'POST',
|
||
credentials: 'same-origin',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ password }),
|
||
})
|
||
if (r.status === 204) {
|
||
sessionAuth = true
|
||
apiKey = ''
|
||
// Probe an admin endpoint to confirm the cookie works end-to-end.
|
||
await api('/v1/admin/audit?limit=1')
|
||
showApp()
|
||
} else if (r.status === 401) {
|
||
throw new Error('Wrong password')
|
||
} else if (r.status === 429) {
|
||
throw new Error('Too many login attempts. Try again in a few minutes.')
|
||
} else if (r.status === 503) {
|
||
throw new Error('Web UI password not set. Use the StartOS action to set one.')
|
||
} else {
|
||
let msg = 'HTTP ' + r.status
|
||
try { const j = await r.json(); msg = j.message || j.error || msg } catch {}
|
||
throw new Error(msg)
|
||
}
|
||
} catch (e) {
|
||
sessionAuth = false
|
||
errEl.textContent = e.message
|
||
errEl.classList.remove('hide')
|
||
}
|
||
})
|
||
document.getElementById('pw').addEventListener('keypress', (e) => {
|
||
if (e.key === 'Enter') document.getElementById('login-pw-btn').click()
|
||
})
|
||
|
||
// ---- API-key fallback (first-run only) ----
|
||
document.getElementById('login-btn').addEventListener('click', async () => {
|
||
const errEl = document.getElementById('login-err')
|
||
errEl.classList.add('hide')
|
||
const k = document.getElementById('api-key').value.trim()
|
||
if (!k) { errEl.textContent = 'Enter your admin API key.'; errEl.classList.remove('hide'); return }
|
||
apiKey = k
|
||
sessionAuth = false
|
||
try {
|
||
await api('/v1/admin/audit?limit=1')
|
||
localStorage.setItem(LS_KEY, k)
|
||
showApp()
|
||
} catch (e) {
|
||
apiKey = ''
|
||
errEl.textContent = 'Key rejected: ' + e.message
|
||
errEl.classList.remove('hide')
|
||
}
|
||
})
|
||
document.getElementById('api-key').addEventListener('keypress', (e) => {
|
||
if (e.key === 'Enter') document.getElementById('login-btn').click()
|
||
})
|
||
|
||
document.getElementById('logout').addEventListener('click', async () => {
|
||
if (sessionAuth) {
|
||
try { await fetch('/admin/logout', { method: 'POST', credentials: 'same-origin' }) } catch {}
|
||
}
|
||
sessionAuth = false
|
||
localStorage.removeItem(LS_KEY)
|
||
apiKey = ''
|
||
const apiKeyInput = document.getElementById('api-key'); if (apiKeyInput) apiKeyInput.value = ''
|
||
const pwInput = document.getElementById('pw'); if (pwInput) pwInput.value = ''
|
||
showLogin()
|
||
})
|
||
|
||
// On first load: prefer cookie session if valid, else fall through to
|
||
// saved API key, else show the login form.
|
||
;(async function bootstrap() {
|
||
let status = null
|
||
try {
|
||
status = await (await fetch('/admin/login/status', { credentials: 'same-origin' })).json()
|
||
} catch {}
|
||
if (status && status.logged_in) {
|
||
sessionAuth = true
|
||
try {
|
||
await api('/v1/admin/audit?limit=1')
|
||
showApp()
|
||
return
|
||
} catch {
|
||
sessionAuth = false
|
||
}
|
||
}
|
||
const saved = localStorage.getItem(LS_KEY)
|
||
if (saved) {
|
||
apiKey = saved
|
||
try {
|
||
await api('/v1/admin/audit?limit=1')
|
||
showApp()
|
||
return
|
||
} catch {
|
||
apiKey = ''
|
||
localStorage.removeItem(LS_KEY)
|
||
}
|
||
}
|
||
showLogin()
|
||
})()
|
||
|
||
if (window.lucide) lucide.createIcons()
|
||
})()
|
||
</script>
|
||
</body>
|
||
</html>
|