v0.2.0:10 — Licenses + Subscriptions tabs reorganized by product

Both tabs now group by product (matching the per-product card
sections in Products + Policies), with product-filter pills + per-
product counts at the top. Multi-product instances see one section
per product with a status breakdown subtitle ("3 active · 1
revoked · 2 expired"); single-product instances continue to see a
flat table with no chrome overhead. Search results bypass grouping
(search is global across all products).

Three new shared helpers added at the top of the script:

- clickToCopy(fullValue, displayLabel) — clickable code element
  that copies the full ID to clipboard with a "✓ copied"
  indicator. Replaces the older hover-to-see-full-id UX for
  license / subscription IDs.

- relativeDate(rfc3339, opts) — renders an RFC3339 timestamp as
  a human-relative string ("in 3 days" / "12 hours ago") with
  the absolute timestamp in a hover tooltip. Applied to license
  issued/expires + subscription next_renewal.

- reasonModal({title, message, warning, confirmLabel,
  confirmVariant}) — inline overlay-card replacement for the
  native prompt() / confirm() dialogs. Used by:
  * Subscription cancellation flow
  * License suspend / unsuspend / revoke flows
  Same UX language as the Change Tier modal.

Subscriptions tab specifics:
- Product filter pills with per-product counts (filtered by
  active status filter so the counts reflect what the operator
  is currently viewing).
- Status filter pills gain counts (Active (3), Past due (0), etc.)
- New Product column shows display name + slug.
- Status badges have hover tooltips explaining each state's meaning.
- Cancel button uses reasonModal instead of prompt().

Licenses tab specifics:
- Quick-stats row: Licenses / Active / Revoked / Expiring < 30d.
  Scope follows the active product filter; hover "?" icons
  define each stat. Mirrors the Overview dashboard style.
- Search affordance preserved; search results render as a single
  flat table titled "Search results" (not grouped by product).
- Manual-issue form's hint blocks replaced with help icons on
  every field. Compact-form treatment to match Products + Policies.
- Suspend / unsuspend / revoke buttons use reasonModal with
  per-action context (irreversible warning on revoke, etc.)
  instead of confirm() + prompt() double-dialog.
- Entitlements rendered with display name primary + description
  tooltip (resolves against the product's catalog from
  /v1/products's response).

Pure UI release. 78/78 tests still pass. No schema, SDK, or
behavior change.
This commit is contained in:
Grant
2026-05-10 12:07:06 -05:00
parent 0ea3469899
commit 20b5293c81
2 changed files with 712 additions and 185 deletions
+636 -127
View File
@@ -706,6 +706,166 @@ The request will be refused if there are licenses or invoices tied to it — use
}, '?') }, '?')
} }
/**
* Wraps a string in a clickable element that copies the FULL value
* to the clipboard on click and shows a brief "Copied" indicator.
* Used in the licenses + subscriptions tables for IDs that get
* truncated for display but are useful to grab in full.
*
* clickToCopy('a1b2c3d4-...', 'a1b2c3d4…')
* → <span title="Click to copy">a1b2c3d4…</span>
*/
function clickToCopy(fullValue, displayLabel) {
const span = el('code', {
class: 'click-to-copy',
title: 'Click to copy: ' + fullValue,
style:
'cursor:pointer; padding:2px 5px; border-radius:4px; ' +
'transition:background 100ms; user-select:none;',
}, displayLabel || fullValue)
span.addEventListener('click', async (e) => {
e.stopPropagation()
try {
await navigator.clipboard.writeText(fullValue)
const orig = span.textContent
span.textContent = '✓ copied'
span.style.background = 'var(--success-bg)'
setTimeout(() => {
span.textContent = orig
span.style.background = ''
}, 1100)
} catch (_) {
// Clipboard API can fail on insecure contexts; fall back to a
// brief style flash so at least click feedback is visible.
span.style.background = 'var(--warning-bg)'
setTimeout(() => { span.style.background = '' }, 600)
}
})
span.addEventListener('mouseenter', () => {
if (!span.textContent.startsWith('✓')) span.style.background = 'var(--cream-200)'
})
span.addEventListener('mouseleave', () => {
if (!span.textContent.startsWith('✓')) span.style.background = ''
})
return span
}
/**
* Render an RFC3339 timestamp as a human-relative string with the
* absolute value as a hover tooltip. "in 3 days" / "12 hours ago" /
* "just now". Falls back to the raw string if parsing fails.
*
* Useful for subscription `next_renewal_at`, license `issued_at` /
* `expires_at`, etc. — operators care about "is this happening
* soon?" more than the wall-clock value.
*/
function relativeDate(rfc3339, opts) {
opts = opts || {}
if (!rfc3339) return el('span', { class: 'muted' }, '')
const t = new Date(rfc3339)
if (isNaN(t.getTime())) return el('span', null, rfc3339)
const now = Date.now()
const diffMs = t.getTime() - now
const future = diffMs > 0
const absMs = Math.abs(diffMs)
const m = 60_000, h = 3_600_000, d = 86_400_000
let label
if (absMs < 30_000) label = 'just now'
else if (absMs < h) label = Math.round(absMs / m) + 'min'
else if (absMs < d) label = Math.round(absMs / h) + 'h'
else if (absMs < 30 * d) label = Math.round(absMs / d) + 'd'
else if (absMs < 365 * d) label = Math.round(absMs / (30 * d)) + 'mo'
else label = Math.round(absMs / (365 * d)) + 'y'
if (label !== 'just now') label = future ? ('in ' + label) : (label + ' ago')
return el('span', {
class: opts.muted === false ? '' : 'muted',
title: t.toLocaleString(),
style: opts.style || '',
}, label)
}
/**
* Inline reason-modal — replaces the jarring browser prompt() for
* cancel/suspend/revoke flows. Shows an overlay card with a
* textarea for the reason + Cancel / Confirm buttons. Returns a
* promise that resolves with the trimmed reason string (or null
* if the operator cancels).
*
* const reason = await reasonModal({
* title: 'Cancel subscription',
* message: 'License stays valid through end of cycle.',
* confirmLabel: 'Cancel subscription',
* confirmVariant: 'danger',
* })
* if (reason !== null) {
* await api(...)
* }
*/
function reasonModal(opts) {
opts = opts || {}
return new Promise((resolve) => {
const overlay = el('div', {
style:
'position:fixed; inset:0; background:rgba(14,31,51,0.55); z-index:9999; ' +
'display:flex; align-items:center; justify-content:center; padding:20px; overflow-y:auto;',
})
const textarea = el('textarea', {
class: 'input', rows: '3',
placeholder: opts.placeholder || 'Optional — recorded on the audit log.',
})
const status = el('div', {
class: 'muted', style: 'margin-top:6px; font-size:12.5px; min-height:16px',
}, '')
let resolved = false
function done(value) {
if (resolved) return
resolved = true
overlay.remove()
resolve(value)
}
const card = el('div', {
style:
'background:var(--cream-50); border:1px solid var(--border-1); ' +
'border-radius:12px; max-width:480px; width:100%; padding:24px; ' +
'box-shadow:0 0 0 1px var(--gold-500) inset, 0 16px 32px rgba(14,31,51,0.20);',
}, [
el('div', { class: 'eyebrow', style: 'margin-bottom:8px' }, opts.eyebrow || 'Confirm'),
el('h3', {
style: 'font-family:var(--font-display); font-weight:600; font-size:18px; margin:0 0 6px; color:var(--navy-950);',
}, opts.title || 'Are you sure?'),
opts.message ? el('p', { class: 'muted', style: 'margin:0 0 14px; font-size:13.5px' }, opts.message) : null,
opts.warning ? el('div', {
class: 'badge b-warning',
style: 'display:block; padding:8px 12px; margin-bottom:12px; font-size:12px',
}, opts.warning) : null,
el('label', { class: 'lbl', style: 'display:flex; align-items:center; font-size:12.5px' }, [
opts.reasonLabel || 'Reason (optional)',
helpIcon(opts.reasonHelp || 'Free-form note. Stored on the audit log; not user-visible.'),
]),
textarea,
status,
el('div', { style: 'display:flex; gap:10px; margin-top:14px;' }, [
(() => {
const btn = el('button', {
class: 'btn ' + (opts.confirmVariant === 'danger' ? 'danger' : 'primary'),
}, opts.confirmLabel || 'Confirm')
btn.addEventListener('click', () => done(textarea.value.trim()))
return btn
})(),
(() => {
const btn = el('button', { class: 'btn secondary' }, 'Cancel')
btn.addEventListener('click', () => done(null))
return btn
})(),
]),
])
overlay.appendChild(card)
overlay.addEventListener('click', (e) => { if (e.target === overlay) done(null) })
document.body.appendChild(overlay)
setTimeout(() => textarea.focus(), 0)
})
}
/** Slugify a display name into a URL-safe slug. Used by the /** Slugify a display name into a URL-safe slug. Used by the
* auto-slug feature on the product create form. */ * auto-slug feature on the product create form. */
function slugify(s) { function slugify(s) {
@@ -2808,115 +2968,267 @@ The request will be refused if there are licenses or invoices tied to it — use
const target = document.getElementById('route-target') const target = document.getElementById('route-target')
target.innerHTML = '' target.innerHTML = ''
target.appendChild(plainCard([ target.appendChild(plainCard([
el('p', { class: 'muted', style: 'margin:0' }, el('p', { class: 'muted', style: 'margin:0' }, [
'Recurring subscriptions tied to active licenses. Cancellation here ' + 'Recurring subscriptions tied to active licenses. ',
'is non-destructive: the license stays valid through the end of the ' + helpIcon(
'current cycle, the renewal worker just stops creating new invoices.'), 'Cancellation here is non-destructive — the license stays valid through ' +
'the end of the current cycle, the renewal worker just stops creating ' +
'new invoices. Lapses fire automatically when grace expires past a ' +
'past-due cycle.',
),
]),
])) ]))
// Status filter pills. // Status copy with tooltip help.
const STATUSES = [ const STATUSES = [
{ value: '', label: 'All' }, { value: '', label: 'All', help: 'Every subscription regardless of state.' },
{ value: 'active', label: 'Active' }, { value: 'active', label: 'Active', help: 'Currently paid through the next renewal date.' },
{ value: 'past_due', label: 'Past due' }, { value: 'past_due', label: 'Past due', help: 'Renewal invoice was created but not yet paid. Buyer\'s license is still valid through the grace window.' },
{ value: 'cancelled', label: 'Cancelled' }, { value: 'cancelled', label: 'Cancelled', help: 'Operator or buyer cancelled. License stays valid through the current cycle; renewal worker stops.' },
{ value: 'lapsed', label: 'Lapsed' }, { value: 'lapsed', label: 'Lapsed', help: 'Past-due window expired. License rejects validation; only re-purchase reactivates.' },
] ]
let currentFilter = '' let currentStatusFilter = ''
const filterRow = el('div', { style: 'display:flex; gap:8px; flex-wrap:wrap; margin:14px 0' }) let currentProductFilter = '' // empty = all products
function renderFilterPills() { let allSubs = [] // full list, refreshed on load
filterRow.innerHTML = '' let products = [] // for product slug → name lookup
STATUSES.forEach((s) => {
const active = s.value === currentFilter const productPillRow = el('div', { style: 'display:flex; gap:8px; flex-wrap:wrap; margin:14px 0 6px' })
const statusPillRow = el('div', { style: 'display:flex; gap:8px; flex-wrap:wrap; margin:6px 0 14px' })
function statusBadge(s) {
const klass = s === 'active' ? 'b-success'
: s === 'past_due' ? 'b-warning'
: s === 'cancelled' ? 'b-neutral'
: s === 'lapsed' ? 'b-danger' : 'b-neutral'
const help = (STATUSES.find((row) => row.value === s) || {}).help
return el('span', { class: 'badge ' + klass, title: help || s }, s)
}
function fmtCadence(periodDays) {
return periodDays === 7 ? 'weekly'
: periodDays === 30 ? 'monthly'
: periodDays === 90 ? 'quarterly'
: periodDays === 180 ? 'semi-annual'
: periodDays === 365 ? 'annual'
: 'every ' + periodDays + 'd'
}
function fmtPrice(s) {
if (s.listed_currency === 'SAT') {
return Number(s.listed_value).toLocaleString() + ' sats'
}
return (s.listed_value / 100).toFixed(2) + ' ' + s.listed_currency
}
function productNameForId(productId) {
const p = products.find((pr) => pr.id === productId)
return p ? p.name : null
}
function productSlugForId(productId) {
const p = products.find((pr) => pr.id === productId)
return p ? p.slug : null
}
function renderProductPills() {
productPillRow.innerHTML = ''
// Compute counts per product after applying the status filter
// (so the product pill counts reflect the active status view).
const statusFiltered = currentStatusFilter
? allSubs.filter((s) => s.status === currentStatusFilter)
: allSubs
const byProduct = {}
statusFiltered.forEach((s) => {
byProduct[s.product_id] = (byProduct[s.product_id] || 0) + 1
})
const allCount = statusFiltered.length
const pills = [{ id: '', label: 'All products', count: allCount }]
products.forEach((p) => {
pills.push({ id: p.id, label: p.name, count: byProduct[p.id] || 0 })
})
pills.forEach((opt) => {
const active = opt.id === currentProductFilter
const pill = el('button', { const pill = el('button', {
class: 'btn sm ' + (active ? 'primary' : 'secondary'), class: 'btn sm ' + (active ? 'primary' : 'secondary'),
onclick: () => { currentFilter = s.value; renderFilterPills(); load() }, onclick: () => { currentProductFilter = opt.id; renderProductPills(); renderStatusPills(); render() },
}, s.label) }, [
filterRow.appendChild(pill) opt.label,
opt.count > 0
? el('span', { style: 'margin-left:6px; opacity:0.7; font-weight:500' }, '(' + opt.count + ')')
: null,
])
productPillRow.appendChild(pill)
}) })
} }
renderFilterPills()
target.appendChild(filterRow) function renderStatusPills() {
statusPillRow.innerHTML = ''
// Counts per status, filtered to the chosen product.
const productFiltered = currentProductFilter
? allSubs.filter((s) => s.product_id === currentProductFilter)
: allSubs
const byStatus = {}
productFiltered.forEach((s) => { byStatus[s.status] = (byStatus[s.status] || 0) + 1 })
STATUSES.forEach((row) => {
const active = row.value === currentStatusFilter
const count = row.value ? (byStatus[row.value] || 0) : productFiltered.length
const pill = el('button', {
class: 'btn sm ' + (active ? 'primary' : 'secondary'),
title: row.help,
onclick: () => { currentStatusFilter = row.value; renderStatusPills(); renderProductPills(); render() },
}, [
row.label,
el('span', { style: 'margin-left:6px; opacity:0.7; font-weight:500' }, '(' + count + ')'),
])
statusPillRow.appendChild(pill)
})
}
target.appendChild(productPillRow)
target.appendChild(statusPillRow)
const tableHost = el('div') const tableHost = el('div')
target.appendChild(tableHost) target.appendChild(tableHost)
async function load() { function buildTableForSubs(subs) {
tableHost.innerHTML = ''
try {
const url = '/v1/admin/subscriptions' + (currentFilter ? ('?status=' + currentFilter) : '')
const j = await api(url)
const subs = j.subscriptions || []
if (subs.length === 0) { if (subs.length === 0) {
tableHost.appendChild(plainCard([ return plainCard([
el('p', { class: 'muted', style: 'margin:0' }, '(no subscriptions match this filter)'), el('p', { class: 'muted', style: 'margin:0' },
])) currentStatusFilter || currentProductFilter
return ? '(no subscriptions match these filters)'
: 'No subscriptions yet — once a buyer purchases a recurring policy, they appear here.'),
])
} }
const table = el('table', { class: 'table' }) const headers = [
const thead = el('thead', null, el('tr', null, [ 'License', 'Product', 'Cadence', 'Listed price', 'Status',
el('th', null, 'License'), 'Next renewal', 'Failures', '',
el('th', null, 'Cadence'), ]
el('th', null, 'Listed price'), const rows = subs.map((s) => el('tr', null, [
el('th', null, 'Status'), el('td', null, clickToCopy(s.license_id, s.license_id.slice(0, 8) + '…')),
el('th', null, 'Next renewal'), el('td', null, productNameForId(s.product_id)
el('th', null, 'Failures'), ? el('div', null, [
el('th', null, 'Actions'), el('div', { style: 'font-weight:500; color:var(--navy-950)' }, productNameForId(s.product_id)),
])) el('div', { class: 'muted', style: 'font-size:11px; font-family:var(--font-mono)' },
const tbody = el('tbody') productSlugForId(s.product_id) || s.product_id.slice(0, 8) + '…'),
subs.forEach((s) => { ])
const statusBadge = (function () { : el('span', { class: 'muted' }, '')),
const klass = s.status === 'active' ? 'b-success' el('td', null, fmtCadence(s.period_days)),
: s.status === 'past_due' ? 'b-warning' el('td', null, fmtPrice(s)),
: s.status === 'cancelled' ? 'b-neutral' el('td', null, statusBadge(s.status)),
: s.status === 'lapsed' ? 'b-danger' : 'b-neutral' el('td', null, s.next_renewal_at
return el('span', { class: 'badge ' + klass }, s.status) ? relativeDate(s.next_renewal_at, { muted: false })
})() : el('span', { class: 'muted' }, '')),
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, String(s.consecutive_failures || 0)),
el('td', null, (s.status === 'active' || s.status === 'past_due') el('td', null, (s.status === 'active' || s.status === 'past_due')
? el('button', { ? el('button', {
class: 'btn sm danger', class: 'btn sm danger',
onclick: async () => { onclick: async () => {
const reason = prompt( const reason = await reasonModal({
'Cancel this subscription?\n\nThe license stays valid through the end of the current cycle. ' + eyebrow: 'Cancel subscription',
'No new invoices will be created.\n\nOptional: enter a reason for the audit log:' title: 'Cancel this subscription?',
) message:
if (reason === null) return // user clicked Cancel 'The license stays valid through the end of the current cycle. ' +
'No new invoices will be created. The buyer can resubscribe later.',
confirmLabel: 'Cancel subscription',
confirmVariant: 'danger',
})
if (reason === null) return
try { try {
await api('/v1/admin/subscriptions/' + s.id + '/cancel', { await api('/v1/admin/subscriptions/' + s.id + '/cancel', {
method: 'POST', method: 'POST',
body: { reason: reason || null }, body: { reason: reason || null },
}) })
load() loadAll()
} catch (e) { alert(e.message) } } catch (e) { alert(e.message) }
}, },
}, 'Cancel') }, 'Cancel')
: el('span', { class: 'muted', style: 'font-size:12px' }, '')), : el('span', { class: 'muted', style: 'font-size:12px' }, '')),
]))
const t = el('table', { class: 't' })
t.appendChild(el('thead', null, el('tr', null, headers.map((h) => el('th', null, h)))))
const tb = el('tbody')
rows.forEach((r) => tb.appendChild(r))
t.appendChild(tb)
return t
}
function render() {
tableHost.innerHTML = ''
// Apply both filters.
let subs = allSubs
if (currentStatusFilter) subs = subs.filter((s) => s.status === currentStatusFilter)
if (currentProductFilter) subs = subs.filter((s) => s.product_id === currentProductFilter)
// If the operator has multiple products and isn't filtering to
// one specifically, group by product. Single-product instances
// get a flat table without the section chrome.
if (products.length <= 1 || currentProductFilter) {
const card = el('div', { class: 'card' }, [
el('div', { class: 'card-head' }, [
el('h3', null, currentProductFilter
? (productNameForId(currentProductFilter) || 'Subscriptions')
: 'Subscriptions'),
el('span', { class: 'sub' }, subs.length + ' subscription' + (subs.length === 1 ? '' : 's')),
]),
buildTableForSubs(subs),
]) ])
tbody.appendChild(tr) tableHost.appendChild(card)
return
}
// Multi-product: group + collapse empty products.
const productsWithSubs = products
.map((p) => ({
product: p,
subs: subs.filter((s) => s.product_id === p.id),
}))
.filter((g) => g.subs.length > 0)
if (productsWithSubs.length === 0) {
tableHost.appendChild(plainCard([
el('p', { class: 'muted', style: 'margin:0' },
'(no subscriptions match these filters)'),
]))
return
}
productsWithSubs.forEach(({ product, subs: subList }) => {
// Per-product status breakdown for the section subtitle.
const breakdown = {}
subList.forEach((s) => { breakdown[s.status] = (breakdown[s.status] || 0) + 1 })
const breakdownTxt = Object.keys(breakdown).sort().map((k) => breakdown[k] + ' ' + k).join(' · ')
const card = el('div', { class: 'card', style: 'margin-bottom:14px' }, [
el('div', { class: 'card-head' }, [
el('h3', null, product.name + ' — ' + product.slug),
el('span', { class: 'sub' }, breakdownTxt),
]),
buildTableForSubs(subList),
])
tableHost.appendChild(card)
}) })
table.appendChild(thead) }
table.appendChild(tbody)
tableHost.appendChild(table) async function loadAll() {
tableHost.innerHTML = ''
tableHost.appendChild(plainCard([el('p', { class: 'muted', style: 'margin:0' }, 'Loading…')]))
try {
// Pull product list (for grouping + name lookup) and subs in parallel.
const [productsResp, subsResp] = await Promise.all([
api('/v1/products').catch(() => ({ products: [] })),
api('/v1/admin/subscriptions'),
])
products = productsResp.products || []
allSubs = subsResp.subscriptions || []
renderProductPills()
renderStatusPills()
render()
} catch (e) { } catch (e) {
tableHost.innerHTML = ''
tableHost.appendChild(plainCard([err('Failed to load subscriptions: ' + e.message)])) tableHost.appendChild(plainCard([err('Failed to load subscriptions: ' + e.message)]))
} }
} }
load()
loadAll()
} }
// -------- Discount codes -------- // -------- Discount codes --------
@@ -3184,50 +3496,109 @@ The request will be refused if there are licenses or invoices tied to it — use
const target = document.getElementById('route-target') const target = document.getElementById('route-target')
target.innerHTML = '' target.innerHTML = ''
const queryInput = el('input', { class: 'input', type: 'text', placeholder: 'email, npub, or invoice id (leave blank for recent)' }) // ---- Search row ----
const queryInput = el('input', {
class: 'input', type: 'text',
placeholder: 'email, npub, or invoice id (leave blank for recent)',
})
const fieldSel = el('select', { class: 'select' }, [ const fieldSel = el('select', { class: 'select' }, [
el('option', { value: 'email' }, 'Email'), el('option', { value: 'email' }, 'Email'),
el('option', { value: 'npub' }, 'Nostr npub'), el('option', { value: 'npub' }, 'Nostr npub'),
el('option', { value: 'invoice' }, 'BTCPay invoice id'), el('option', { value: 'invoice' }, 'BTCPay invoice id'),
]) ])
// ---- State for filters + grouping ----
let products = [] // for product-name lookup + grouping
let allLicenses = [] // last fetched set
let currentProductFilter = '' // empty = all products
let lastQuery = ''
let lastQueryField = 'email'
const productPillRow = el('div', {
style: 'display:flex; gap:8px; flex-wrap:wrap; margin:14px 0',
})
const statsRow = el('div', { class: 'stats', style: 'margin:0 0 14px' })
const tableHolder = el('div') const tableHolder = el('div')
async function loadLicenses() { function entitlementsCell(ents, productCatalog) {
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) { if (!ents || ents.length === 0) {
return el('span', { class: 'muted' }, '') return el('span', { class: 'muted' }, '')
} }
const cat = productCatalog || []
const wrap = el('div', { style: 'display:flex; flex-wrap:wrap; gap:4px;' }) const wrap = el('div', { style: 'display:flex; flex-wrap:wrap; gap:4px;' })
ents.forEach((e) => { ents.forEach((slug) => {
const entry = cat.find((c) => c.slug === slug)
const display = entry && entry.name ? entry.name : slug
const help = entry && entry.description ? entry.description : slug
wrap.appendChild(el('span', { wrap.appendChild(el('span', {
class: 'badge', 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;', style:
title: e, 'font-size:10.5px; padding:2px 7px; background:var(--cream-200); ' +
}, e)) 'color:var(--ink-700); font-weight:500;',
title: help,
}, display))
}) })
return wrap return wrap
} }
const rows = lic.map((l) => el('tr', null, [
el('td', null, el('code', null, shortId(l.id))), function productById(id) {
return products.find((p) => p.id === id)
}
function productCatalogFor(license) {
const p = productById(license.product_id)
return (p && p.entitlements_catalog) || []
}
async function actLicense(l, op) {
// Suspend / unsuspend / revoke. Revoke is irreversible — the
// confirmation modal makes that obvious + collects an audit
// reason in one step (replaces the older confirm() + prompt()
// double-dialog flow).
const opts = {
suspend: {
eyebrow: 'Suspend license',
title: 'Suspend this license?',
message:
'The buyer\'s app will fail validation immediately ("suspended"). ' +
'Reversible — you can unsuspend later.',
confirmLabel: 'Suspend',
confirmVariant: 'danger',
},
unsuspend: {
eyebrow: 'Unsuspend license',
title: 'Unsuspend this license?',
message: 'License returns to active. Buyer\'s validate calls succeed again.',
confirmLabel: 'Unsuspend',
confirmVariant: 'primary',
},
revoke: {
eyebrow: 'Revoke license',
title: 'Revoke this license?',
message:
'Irreversible. Validate calls return "revoked" forever. ' +
'Use Suspend instead if you want a reversible block.',
warning: 'This action cannot be undone.',
confirmLabel: 'Revoke',
confirmVariant: 'danger',
},
}[op]
const reason = await reasonModal(opts)
if (reason === null) return
try {
await api('/v1/admin/licenses/' + l.id + '/' + op, {
method: 'POST', body: { reason: reason || '' },
})
loadLicenses()
} catch (e) { alert(e.message) }
}
function buildRowsFor(licenses) {
return licenses.map((l) => el('tr', null, [
el('td', null, clickToCopy(l.id, shortId(l.id))),
el('td', null, l.product_slug el('td', null, l.product_slug
? el('code', { title: l.product_id }, l.product_slug) ? el('code', { title: l.product_id }, l.product_slug)
: shortId(l.product_id)), : el('span', { class: 'muted' }, shortId(l.product_id))),
el('td', null, l.policy_slug 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', null, [
el('div', { style: 'font-weight:500; color:var(--navy-950)' }, el('div', { style: 'font-weight:500; color:var(--navy-950)' },
l.policy_name || l.policy_slug), l.policy_name || l.policy_slug),
@@ -3236,10 +3607,12 @@ The request will be refused if there are licenses or invoices tied to it — use
: null, : null,
]) ])
: el('span', { class: 'muted' }, '')), : el('span', { class: 'muted' }, '')),
el('td', null, entitlementsCell(l.entitlements)), el('td', null, entitlementsCell(l.entitlements, productCatalogFor(l))),
el('td', null, statusBadge(l.status)), el('td', null, statusBadge(l.status)),
el('td', { class: 'muted' }, fmtDate(l.issued_at)), el('td', null, relativeDate(l.issued_at)),
el('td', { class: 'muted' }, l.expires_at ? fmtDate(l.expires_at) : el('span', { class: 'badge b-gold' }, 'perpetual')), el('td', null, l.expires_at
? relativeDate(l.expires_at, { muted: false })
: el('span', { class: 'badge b-gold' }, 'perpetual')),
el('td', { class: 'muted' }, l.buyer_email || ''), el('td', { class: 'muted' }, l.buyer_email || ''),
el('td', null, el('div', { class: 'actions-row' }, [ el('td', null, el('div', { class: 'actions-row' }, [
l.status !== 'revoked' l.status !== 'revoked'
@@ -3260,32 +3633,150 @@ The request will be refused if there are licenses or invoices tied to it — use
: null, : null,
])), ])),
])) ]))
const title = q ? 'Search results' : 'Recent licenses' }
const subtitle = lic.length + ' license' + (lic.length === 1 ? '' : 's') +
(q ? '' : (lic.length >= 100 ? ' (most recent 100)' : '')) function buildLicensesTable(licenses, title, subtitle, emptyMsg) {
tableHolder.innerHTML = '' return tableCard(
tableHolder.appendChild(tableCard(
title, title,
subtitle, subtitle,
['ID', 'Product', 'Policy', 'Entitlements', 'Status', 'Issued', 'Expires', 'Buyer', ''], ['ID', 'Product', 'Policy', 'Entitlements', 'Status', 'Issued', 'Expires', 'Buyer', ''],
rows, buildRowsFor(licenses),
q ? 'No matches.' : 'No licenses issued yet — once a buyer purchases or redeems, they appear here.' emptyMsg,
)
}
function renderProductPills() {
productPillRow.innerHTML = ''
const byProduct = {}
allLicenses.forEach((l) => {
byProduct[l.product_id] = (byProduct[l.product_id] || 0) + 1
})
const pills = [{ id: '', label: 'All products', count: allLicenses.length }]
products.forEach((p) => {
pills.push({ id: p.id, label: p.name, count: byProduct[p.id] || 0 })
})
pills.forEach((opt) => {
const active = opt.id === currentProductFilter
const pill = el('button', {
class: 'btn sm ' + (active ? 'primary' : 'secondary'),
onclick: () => { currentProductFilter = opt.id; renderProductPills(); render() },
}, [
opt.label,
el('span', { style: 'margin-left:6px; opacity:0.7; font-weight:500' }, '(' + opt.count + ')'),
])
productPillRow.appendChild(pill)
})
}
function renderStats() {
statsRow.innerHTML = ''
// Filter by current product first; the row tells the operator
// about THIS scope, not the whole instance.
const scoped = currentProductFilter
? allLicenses.filter((l) => l.product_id === currentProductFilter)
: allLicenses
const total = scoped.length
const active = scoped.filter((l) => l.status === 'active').length
const revoked = scoped.filter((l) => l.status === 'revoked').length
const now = Date.now()
const expiringSoon = scoped.filter((l) => {
if (!l.expires_at) return false
const t = new Date(l.expires_at).getTime()
return !isNaN(t) && t > now && (t - now) < 30 * 86_400_000
}).length
function statBox(label, value, helpText) {
return el('div', { class: 'stat' }, [
el('div', { class: 'stat-label', style: 'display:flex; align-items:center' }, [
label,
helpText ? helpIcon(helpText) : null,
]),
el('div', { class: 'stat-value' }, String(value)),
])
}
statsRow.appendChild(statBox('Licenses', total, 'Total in this scope (all-time, regardless of status).'))
statsRow.appendChild(statBox('Active', active, 'Status = active. Excludes suspended, revoked, expired.'))
statsRow.appendChild(statBox('Revoked', revoked, 'Status = revoked. Irreversible.'))
statsRow.appendChild(statBox('Expiring < 30d', expiringSoon,
'Active or unsuspended licenses with expires_at within the next 30 days. Useful for retention nudges.'))
}
function render() {
tableHolder.innerHTML = ''
let scoped = allLicenses
if (currentProductFilter) scoped = scoped.filter((l) => l.product_id === currentProductFilter)
// Single-product or product-filtered: flat table.
const inSearchMode = lastQuery.length > 0
if (products.length <= 1 || currentProductFilter || inSearchMode) {
const titleProduct = currentProductFilter
? (productById(currentProductFilter) || { name: 'Product' }).name + ' — '
: ''
const title = inSearchMode
? 'Search results'
: (titleProduct + 'Recent licenses')
const subtitle = scoped.length + ' license' + (scoped.length === 1 ? '' : 's') +
(inSearchMode ? '' : (scoped.length >= 100 ? ' (most recent 100)' : ''))
tableHolder.appendChild(buildLicensesTable(
scoped, title, subtitle,
inSearchMode
? 'No matches.'
: 'No licenses yet — once a buyer purchases or you manually issue, they appear here.',
)) ))
renderStats()
return
}
// Multi-product: group + collapse empty products.
const grouped = products
.map((p) => ({ product: p, licenses: scoped.filter((l) => l.product_id === p.id) }))
.filter((g) => g.licenses.length > 0)
if (grouped.length === 0) {
tableHolder.appendChild(plainCard([
el('p', { class: 'muted', style: 'margin:0' }, 'No licenses match this filter.'),
]))
renderStats()
return
}
grouped.forEach(({ product, licenses }) => {
const breakdown = {}
licenses.forEach((l) => { breakdown[l.status] = (breakdown[l.status] || 0) + 1 })
const breakdownTxt = Object.keys(breakdown).sort().map((k) => breakdown[k] + ' ' + k).join(' · ')
const card = el('div', { class: 'card', style: 'margin-bottom:14px' }, [
el('div', { class: 'card-head' }, [
el('h3', null, product.name + ' — ' + product.slug),
el('span', { class: 'sub' }, breakdownTxt),
]),
buildLicensesTable(licenses, '', '', '(none)'),
])
tableHolder.appendChild(card)
})
renderStats()
}
async function loadLicenses() {
const q = queryInput.value.trim()
lastQuery = q
lastQueryField = fieldSel.value
tableHolder.innerHTML = ''
tableHolder.appendChild(plainCard([el('p', { class: 'muted', style: 'margin:0' },
q ? 'Searching…' : 'Loading recent licenses…')]))
try {
const params = q ? '?' + new URLSearchParams({ [lastQueryField]: q }).toString() : ''
const [productsResp, licResp] = await Promise.all([
api('/v1/products').catch(() => ({ products: [] })),
api('/v1/admin/licenses/search' + params),
])
products = productsResp.products || []
allLicenses = licResp.licenses || []
renderProductPills()
render()
} catch (e) { } catch (e) {
tableHolder.innerHTML = '' tableHolder.innerHTML = ''
tableHolder.appendChild(plainCard([err(e.message)])) 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() }) queryInput.addEventListener('keypress', (e) => { if (e.key === 'Enter') loadLicenses() })
// ---------- Manual issue (admin comp / promo / paper-licensed) ---------- // ---------- Manual issue (admin comp / promo / paper-licensed) ----------
@@ -3314,11 +3805,23 @@ The request will be refused if there are licenses or invoices tied to it — use
} }
const productOptions = products.map((p) => ({ value: p.slug, label: p.name + ' (' + p.slug + ')' })) const productOptions = products.map((p) => ({ value: p.slug, label: p.name + ' (' + p.slug + ')' }))
const productSel = formSelect('issue_product', 'Product', productOptions, { required: true }) const productSel = formSelect('issue_product', 'Product', productOptions, {
const policySel = formSelect('issue_policy', 'Policy', [{ value: '', label: '— loading —' }], { required: true }) 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.') help: 'Which product the license is for. Determines the buyer\'s available policies (tiers) below.',
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' }) const policySel = formSelect('issue_policy', 'Policy', [{ value: '', label: '— loading —' }], {
required: true,
help: 'Tier the license inherits from — entitlements, duration, max_machines, trial flag all come from here.',
})
const noteField = formInput('issue_note', 'Internal note', {
help: 'Free-form note attached to the audit log. Examples: "comp", "press", "self-issue Pro for dogfood". Not visible to the buyer.',
placeholder: 'optional',
})
const emailField = formInput('issue_email', 'Buyer email', {
type: 'email',
help: 'Optional. Stored on the license + invoice. Used by buyer self-service recovery if they later lose their key.',
placeholder: 'optional',
})
// Populate policy dropdown when product changes. // Populate policy dropdown when product changes.
async function refreshPolicies() { async function refreshPolicies() {
@@ -3372,7 +3875,6 @@ The request will be refused if there are licenses or invoices tied to it — use
body.appendChild(productSel) body.appendChild(productSel)
body.appendChild(policySel) body.appendChild(policySel)
body.appendChild(policyHint)
body.appendChild(emailField) body.appendChild(emailField)
body.appendChild(noteField) body.appendChild(noteField)
body.appendChild(submitBtn) body.appendChild(submitBtn)
@@ -3426,8 +3928,13 @@ The request will be refused if there are licenses or invoices tied to it — use
} }
target.appendChild(plainCard([ target.appendChild(plainCard([
el('p', { class: 'muted', style: 'margin:0 0 16px' }, 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.'), 'Showing the 100 most recently issued licenses by default. Search by buyer email, Nostr npub, or BTCPay invoice id to filter. ',
helpIcon(
'Multi-product instances render as per-product sections. Use the product pills above to filter to a single product. ' +
'Search results bypass the per-product grouping (search is global across all products).',
),
]),
el('div', { class: 'toolbar' }, [ el('div', { class: 'toolbar' }, [
fieldSel, fieldSel,
queryInput, queryInput,
@@ -3443,6 +3950,8 @@ The request will be refused if there are licenses or invoices tied to it — use
]), ]),
issueDisclosure, issueDisclosure,
])) ]))
target.appendChild(productPillRow)
target.appendChild(statsRow)
target.appendChild(tableHolder) target.appendChild(tableHolder)
buildIssueForm() buildIssueForm()
if (window.lucide) lucide.createIcons() if (window.lucide) lucide.createIcons()
+19 -1
View File
@@ -58,6 +58,24 @@ const RELEASE_NOTES = [
// in RELEASE_NOTES above (the milestone). Subsequent revisions // in RELEASE_NOTES above (the milestone). Subsequent revisions
// append here. // append here.
const ROUTINE_NOTES = [ const ROUTINE_NOTES = [
'0.2.0:10 — **Licenses + Subscriptions tabs reorganized to match Products + Policies.** Both tabs now group by product (matching the per-product card sections used elsewhere in the admin UI), with product-filter pills + per-product license counts at the top. Single-product instances continue to see a flat table; multi-product instances see one section per product with a status breakdown subtitle ("3 active · 1 revoked · 2 expired"). Search results bypass grouping (search is global across all products).',
'',
'**Licenses tab gains a quick-stats row** matching the Overview dashboard: Licenses, Active, Revoked, Expiring within 30 days. Scope follows the active product filter — pick a product, the stats reflect just that product. Hover the "?" icons next to each stat label for definitions.',
'',
'**Subscriptions tab gains a Product column + status filter pill counts.** "Active (3) · Past due (0) · Cancelled (1) · Lapsed (0)" so operators see the breakdown at a glance. Status badges hover-explain what each state means ("past_due → renewal invoice exists, license still valid through grace window," etc.).',
'',
'**Inline reason modals replace browser prompt() dialogs.** Cancelling a subscription or revoking / suspending a license used to fire a jarring native prompt() box and a separate confirm(); both flows are now the same overlay-card UX as Change Tier — title, contextual message, optional warning banner for irreversible operations, audit-reason textarea, Cancel / Confirm buttons. Operators get clearer copy + a less-noisy interaction.',
'',
'**Click-to-copy IDs.** License IDs and subscription license_ids in both tabs render as clickable codes — click to copy the full UUID to clipboard with a brief "✓ copied" indicator. Replaces the older hover-to-see-full-id pattern; one fewer step to grab an id for SDK debugging or audit-log spelunking.',
'',
'**Relative dates with absolute hover.** `5/12/2026, 2:31:00 PM` becomes `in 3 days` / `12 hours ago` / `2 months ago` with the absolute timestamp in the hover tooltip. Applied to license issued/expires + subscription next_renewal. Operators care about "is this happening soon?" more than the wall-clock value; full timestamp still one hover away.',
'',
'**Manual-issue form on Licenses tab uses help icons.** Verbose hint blocks under each input replaced with `?` hover tooltips — same compact-form treatment as the Products + Policies tabs got in :8 / :9.',
'',
'**Test count: 78** (UI-only release, unchanged from :9).',
'',
'**Upgrade path.** v0.2.0:9 → v0.2.0:10 is a drop-in. No schema, SDK, or behavior change. Pure admin UI.',
'',
'0.2.0:9 — **Side-by-side tier-card policy authoring + form polish.** The Policies tab\'s table view is gone — replaced with a card grid where each existing policy renders as a buy-page-style tier card sitting alongside a dashed "+ Add tier" placeholder. Click the placeholder and it morphs into an editable draft card with form fields inline; submit "Create" on the card and it flips back to a read-only tier preview. **Multiple drafts can coexist** in the same product\'s grid, so operators can author Core / Pro / Patron in parallel and visually compare what each will look like to a buyer before committing any of them. Same visual language as the buy page, so what you see while authoring is what buyers see.', '0.2.0:9 — **Side-by-side tier-card policy authoring + form polish.** The Policies tab\'s table view is gone — replaced with a card grid where each existing policy renders as a buy-page-style tier card sitting alongside a dashed "+ Add tier" placeholder. Click the placeholder and it morphs into an editable draft card with form fields inline; submit "Create" on the card and it flips back to a read-only tier preview. **Multiple drafts can coexist** in the same product\'s grid, so operators can author Core / Pro / Patron in parallel and visually compare what each will look like to a buyer before committing any of them. Same visual language as the buy page, so what you see while authoring is what buyers see.',
'', '',
'**Form polish.** New `helpIcon()` helper renders a small "?" hover-tooltip next to field labels — replaces the verbose hint text under inputs that was making forms feel cluttered. Applied first to the product create form (Display name → Slug → Description → Price all use help icons now); spread to other forms incrementally over follow-up releases.', '**Form polish.** New `helpIcon()` helper renders a small "?" hover-tooltip next to field labels — replaces the verbose hint text under inputs that was making forms feel cluttered. Applied first to the product create form (Display name → Slug → Description → Price all use help icons now); spread to other forms incrementally over follow-up releases.',
@@ -191,7 +209,7 @@ const ROUTINE_NOTES = [
].join('\n\n') ].join('\n\n')
export const v0_2_0 = VersionInfo.of({ export const v0_2_0 = VersionInfo.of({
version: '0.2.0:9', version: '0.2.0:10',
releaseNotes: { en_US: ROUTINE_NOTES }, releaseNotes: { en_US: ROUTINE_NOTES },
// No on-disk transformation needed — v0.2.0:0 is a label change. // No on-disk transformation needed — v0.2.0:0 is a label change.
// SQLite-level migrations live separately under // SQLite-level migrations live separately under