From 20b5293c8104ad7021bb6fcc6078fca6b6feb06b Mon Sep 17 00:00:00 2001 From: Grant Date: Sun, 10 May 2026 12:07:06 -0500 Subject: [PATCH] =?UTF-8?q?v0.2.0:10=20=E2=80=94=20Licenses=20+=20Subscrip?= =?UTF-8?q?tions=20tabs=20reorganized=20by=20product?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- licensing-service/web/index.html | 877 ++++++++++++++++++++++++------- startos/versions/v0.2.0.ts | 20 +- 2 files changed, 712 insertions(+), 185 deletions(-) diff --git a/licensing-service/web/index.html b/licensing-service/web/index.html index 850ac3b..82b1534 100644 --- a/licensing-service/web/index.html +++ b/licensing-service/web/index.html @@ -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…') + * → a1b2c3d4… + */ + 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 * auto-slug feature on the product create form. */ 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') 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.'), + el('p', { class: 'muted', style: 'margin:0' }, [ + 'Recurring subscriptions tied to active licenses. ', + helpIcon( + 'Cancellation here is non-destructive — the license stays valid through ' + + 'the end of the current cycle, the renewal worker just stops creating ' + + 'new invoices. Lapses fire automatically when grace expires past a ' + + 'past-due cycle.', + ), + ]), ])) - // Status filter pills. + // Status copy with tooltip help. const STATUSES = [ - { value: '', label: 'All' }, - { value: 'active', label: 'Active' }, - { value: 'past_due', label: 'Past due' }, - { value: 'cancelled', label: 'Cancelled' }, - { value: 'lapsed', label: 'Lapsed' }, + { value: '', label: 'All', help: 'Every subscription regardless of state.' }, + { value: 'active', label: 'Active', help: 'Currently paid through the next renewal date.' }, + { value: 'past_due', label: 'Past due', help: 'Renewal invoice was created but not yet paid. Buyer\'s license is still valid through the grace window.' }, + { value: 'cancelled', label: 'Cancelled', help: 'Operator or buyer cancelled. License stays valid through the current cycle; renewal worker stops.' }, + { value: 'lapsed', label: 'Lapsed', help: 'Past-due window expired. License rejects validation; only re-purchase reactivates.' }, ] - let 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 + let currentStatusFilter = '' + let currentProductFilter = '' // empty = all products + let allSubs = [] // full list, refreshed on load + let products = [] // for product slug → name lookup + + const productPillRow = el('div', { style: 'display:flex; gap:8px; flex-wrap:wrap; margin:14px 0 6px' }) + const statusPillRow = el('div', { style: 'display:flex; gap:8px; flex-wrap:wrap; margin:6px 0 14px' }) + + function statusBadge(s) { + const klass = s === 'active' ? 'b-success' + : s === 'past_due' ? 'b-warning' + : s === 'cancelled' ? 'b-neutral' + : s === 'lapsed' ? 'b-danger' : 'b-neutral' + const help = (STATUSES.find((row) => row.value === s) || {}).help + return el('span', { class: 'badge ' + klass, title: help || s }, s) + } + + function fmtCadence(periodDays) { + return periodDays === 7 ? 'weekly' + : periodDays === 30 ? 'monthly' + : periodDays === 90 ? 'quarterly' + : periodDays === 180 ? 'semi-annual' + : periodDays === 365 ? 'annual' + : 'every ' + periodDays + 'd' + } + + function fmtPrice(s) { + if (s.listed_currency === 'SAT') { + return Number(s.listed_value).toLocaleString() + ' sats' + } + return (s.listed_value / 100).toFixed(2) + ' ' + s.listed_currency + } + + function productNameForId(productId) { + const p = products.find((pr) => pr.id === productId) + return p ? p.name : null + } + + function productSlugForId(productId) { + const p = products.find((pr) => pr.id === productId) + return p ? p.slug : null + } + + function renderProductPills() { + productPillRow.innerHTML = '' + // Compute counts per product after applying the status filter + // (so the product pill counts reflect the active status view). + const statusFiltered = currentStatusFilter + ? allSubs.filter((s) => s.status === currentStatusFilter) + : allSubs + const byProduct = {} + statusFiltered.forEach((s) => { + byProduct[s.product_id] = (byProduct[s.product_id] || 0) + 1 + }) + const allCount = statusFiltered.length + const pills = [{ id: '', label: 'All products', count: allCount }] + products.forEach((p) => { + pills.push({ id: p.id, label: p.name, count: byProduct[p.id] || 0 }) + }) + pills.forEach((opt) => { + const active = opt.id === currentProductFilter const pill = el('button', { class: 'btn sm ' + (active ? 'primary' : 'secondary'), - onclick: () => { currentFilter = s.value; renderFilterPills(); load() }, - }, s.label) - filterRow.appendChild(pill) + onclick: () => { currentProductFilter = opt.id; renderProductPills(); renderStatusPills(); render() }, + }, [ + opt.label, + opt.count > 0 + ? el('span', { style: 'margin-left:6px; opacity:0.7; font-weight:500' }, '(' + opt.count + ')') + : null, + ]) + productPillRow.appendChild(pill) }) } - 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') target.appendChild(tableHost) - async function load() { + function buildTableForSubs(subs) { + if (subs.length === 0) { + return plainCard([ + el('p', { class: 'muted', style: 'margin:0' }, + currentStatusFilter || currentProductFilter + ? '(no subscriptions match these filters)' + : 'No subscriptions yet — once a buyer purchases a recurring policy, they appear here.'), + ]) + } + const headers = [ + 'License', 'Product', 'Cadence', 'Listed price', 'Status', + 'Next renewal', 'Failures', '', + ] + const rows = subs.map((s) => el('tr', null, [ + el('td', null, clickToCopy(s.license_id, s.license_id.slice(0, 8) + '…')), + el('td', null, productNameForId(s.product_id) + ? el('div', null, [ + el('div', { style: 'font-weight:500; color:var(--navy-950)' }, productNameForId(s.product_id)), + el('div', { class: 'muted', style: 'font-size:11px; font-family:var(--font-mono)' }, + productSlugForId(s.product_id) || s.product_id.slice(0, 8) + '…'), + ]) + : el('span', { class: 'muted' }, '–')), + el('td', null, fmtCadence(s.period_days)), + el('td', null, fmtPrice(s)), + el('td', null, statusBadge(s.status)), + el('td', null, s.next_renewal_at + ? relativeDate(s.next_renewal_at, { muted: false }) + : el('span', { class: 'muted' }, '–')), + el('td', null, String(s.consecutive_failures || 0)), + el('td', null, (s.status === 'active' || s.status === 'past_due') + ? el('button', { + class: 'btn sm danger', + onclick: async () => { + const reason = await reasonModal({ + eyebrow: 'Cancel subscription', + title: 'Cancel this subscription?', + message: + 'The license stays valid through the end of the current cycle. ' + + 'No new invoices will be created. The buyer can resubscribe later.', + confirmLabel: 'Cancel subscription', + confirmVariant: 'danger', + }) + if (reason === null) return + try { + await api('/v1/admin/subscriptions/' + s.id + '/cancel', { + method: 'POST', + body: { reason: reason || null }, + }) + loadAll() + } catch (e) { alert(e.message) } + }, + }, 'Cancel') + : el('span', { class: 'muted', style: 'font-size:12px' }, '–')), + ])) + const t = el('table', { class: 't' }) + t.appendChild(el('thead', null, el('tr', null, headers.map((h) => el('th', null, h))))) + const tb = el('tbody') + rows.forEach((r) => tb.appendChild(r)) + t.appendChild(tb) + return t + } + + function render() { tableHost.innerHTML = '' - 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'), + // Apply both filters. + let subs = allSubs + if (currentStatusFilter) subs = subs.filter((s) => s.status === currentStatusFilter) + if (currentProductFilter) subs = subs.filter((s) => s.product_id === currentProductFilter) + + // If the operator has multiple products and isn't filtering to + // one specifically, group by product. Single-product instances + // get a flat table without the section chrome. + if (products.length <= 1 || currentProductFilter) { + const card = el('div', { class: 'card' }, [ + el('div', { class: 'card-head' }, [ + el('h3', null, currentProductFilter + ? (productNameForId(currentProductFilter) || 'Subscriptions') + : 'Subscriptions'), + el('span', { class: 'sub' }, subs.length + ' subscription' + (subs.length === 1 ? '' : 's')), + ]), + buildTableForSubs(subs), + ]) + tableHost.appendChild(card) + return + } + + // Multi-product: group + collapse empty products. + const productsWithSubs = products + .map((p) => ({ + product: p, + subs: subs.filter((s) => s.product_id === p.id), + })) + .filter((g) => g.subs.length > 0) + + if (productsWithSubs.length === 0) { + tableHost.appendChild(plainCard([ + el('p', { class: 'muted', style: 'margin:0' }, + '(no subscriptions match these filters)'), ])) - 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) + return + } + + productsWithSubs.forEach(({ product, subs: subList }) => { + // Per-product status breakdown for the section subtitle. + const breakdown = {} + subList.forEach((s) => { breakdown[s.status] = (breakdown[s.status] || 0) + 1 }) + const breakdownTxt = Object.keys(breakdown).sort().map((k) => breakdown[k] + ' ' + k).join(' · ') + const card = el('div', { class: 'card', style: 'margin-bottom:14px' }, [ + el('div', { class: 'card-head' }, [ + el('h3', null, product.name + ' — ' + product.slug), + el('span', { class: 'sub' }, breakdownTxt), + ]), + buildTableForSubs(subList), + ]) + tableHost.appendChild(card) + }) + } + + async function loadAll() { + tableHost.innerHTML = '' + tableHost.appendChild(plainCard([el('p', { class: 'muted', style: 'margin:0' }, 'Loading…')])) + try { + // Pull product list (for grouping + name lookup) and subs in parallel. + const [productsResp, subsResp] = await Promise.all([ + api('/v1/products').catch(() => ({ products: [] })), + api('/v1/admin/subscriptions'), + ]) + products = productsResp.products || [] + allSubs = subsResp.subscriptions || [] + renderProductPills() + renderStatusPills() + render() } catch (e) { + tableHost.innerHTML = '' tableHost.appendChild(plainCard([err('Failed to load subscriptions: ' + e.message)])) } } - load() + + loadAll() } // -------- Discount codes -------- @@ -3184,108 +3496,287 @@ The request will be refused if there are licenses or invoices tied to it — use 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)' }) + // ---- Search row ---- + const queryInput = el('input', { + class: 'input', type: 'text', + placeholder: 'email, npub, or invoice id (leave blank for recent)', + }) const fieldSel = el('select', { class: 'select' }, [ el('option', { value: 'email' }, 'Email'), el('option', { value: 'npub' }, 'Nostr npub'), el('option', { value: 'invoice' }, 'BTCPay invoice id'), ]) + + // ---- State for filters + grouping ---- + let products = [] // for product-name lookup + grouping + let allLicenses = [] // last fetched set + let currentProductFilter = '' // empty = all products + let lastQuery = '' + let lastQueryField = 'email' + + const productPillRow = el('div', { + style: 'display:flex; gap:8px; flex-wrap:wrap; margin:14px 0', + }) + const statsRow = el('div', { class: 'stats', style: 'margin:0 0 14px' }) const tableHolder = el('div') + function entitlementsCell(ents, productCatalog) { + if (!ents || ents.length === 0) { + return el('span', { class: 'muted' }, '–') + } + const cat = productCatalog || [] + const wrap = el('div', { style: 'display:flex; flex-wrap:wrap; gap:4px;' }) + ents.forEach((slug) => { + const entry = cat.find((c) => c.slug === slug) + const display = entry && entry.name ? entry.name : slug + const help = entry && entry.description ? entry.description : slug + wrap.appendChild(el('span', { + class: 'badge', + style: + 'font-size:10.5px; padding:2px 7px; background:var(--cream-200); ' + + 'color:var(--ink-700); font-weight:500;', + title: help, + }, display)) + }) + return wrap + } + + function productById(id) { + return products.find((p) => p.id === id) + } + function productCatalogFor(license) { + const p = productById(license.product_id) + return (p && p.entitlements_catalog) || [] + } + + async function actLicense(l, op) { + // Suspend / unsuspend / revoke. Revoke is irreversible — the + // confirmation modal makes that obvious + collects an audit + // reason in one step (replaces the older confirm() + prompt() + // double-dialog flow). + const opts = { + suspend: { + eyebrow: 'Suspend license', + title: 'Suspend this license?', + message: + 'The buyer\'s app will fail validation immediately ("suspended"). ' + + 'Reversible — you can unsuspend later.', + confirmLabel: 'Suspend', + confirmVariant: 'danger', + }, + unsuspend: { + eyebrow: 'Unsuspend license', + title: 'Unsuspend this license?', + message: 'License returns to active. Buyer\'s validate calls succeed again.', + confirmLabel: 'Unsuspend', + confirmVariant: 'primary', + }, + revoke: { + eyebrow: 'Revoke license', + title: 'Revoke this license?', + message: + 'Irreversible. Validate calls return "revoked" forever. ' + + 'Use Suspend instead if you want a reversible block.', + warning: 'This action cannot be undone.', + confirmLabel: 'Revoke', + confirmVariant: 'danger', + }, + }[op] + const reason = await reasonModal(opts) + if (reason === null) return + try { + await api('/v1/admin/licenses/' + l.id + '/' + op, { + method: 'POST', body: { reason: reason || '' }, + }) + loadLicenses() + } catch (e) { alert(e.message) } + } + + function buildRowsFor(licenses) { + return licenses.map((l) => el('tr', null, [ + el('td', null, clickToCopy(l.id, shortId(l.id))), + el('td', null, l.product_slug + ? el('code', { title: l.product_id }, l.product_slug) + : el('span', { class: 'muted' }, shortId(l.product_id))), + el('td', null, l.policy_slug + ? el('div', null, [ + el('div', { style: 'font-weight:500; color:var(--navy-950)' }, + l.policy_name || l.policy_slug), + l.policy_name + ? el('div', { class: 'muted', style: 'font-size:11.5px; font-family:var(--font-mono)' }, l.policy_slug) + : null, + ]) + : el('span', { class: 'muted' }, '–')), + el('td', null, entitlementsCell(l.entitlements, productCatalogFor(l))), + el('td', null, statusBadge(l.status)), + el('td', null, relativeDate(l.issued_at)), + el('td', null, l.expires_at + ? relativeDate(l.expires_at, { muted: false }) + : el('span', { class: 'badge b-gold' }, 'perpetual')), + el('td', { class: 'muted' }, l.buyer_email || '–'), + el('td', null, el('div', { class: 'actions-row' }, [ + l.status !== 'revoked' + ? el('button', { + class: 'btn sm secondary', + title: 'Move this license to a different policy/tier', + onclick: () => openChangeTier(l), + }, 'Change tier') + : null, + l.status !== 'revoked' && l.status !== 'suspended' + ? el('button', { class: 'btn sm secondary', onclick: () => actLicense(l, 'suspend') }, 'Suspend') + : null, + l.status === 'suspended' + ? el('button', { class: 'btn sm secondary', onclick: () => actLicense(l, 'unsuspend') }, 'Unsuspend') + : null, + l.status !== 'revoked' + ? el('button', { class: 'btn sm danger', onclick: () => actLicense(l, 'revoke') }, 'Revoke') + : null, + ])), + ])) + } + + function buildLicensesTable(licenses, title, subtitle, emptyMsg) { + return tableCard( + title, + subtitle, + ['ID', 'Product', 'Policy', 'Entitlements', 'Status', 'Issued', 'Expires', 'Buyer', ''], + buildRowsFor(licenses), + emptyMsg, + ) + } + + function renderProductPills() { + productPillRow.innerHTML = '' + const byProduct = {} + allLicenses.forEach((l) => { + byProduct[l.product_id] = (byProduct[l.product_id] || 0) + 1 + }) + const pills = [{ id: '', label: 'All products', count: allLicenses.length }] + products.forEach((p) => { + pills.push({ id: p.id, label: p.name, count: byProduct[p.id] || 0 }) + }) + pills.forEach((opt) => { + const active = opt.id === currentProductFilter + const pill = el('button', { + class: 'btn sm ' + (active ? 'primary' : 'secondary'), + onclick: () => { currentProductFilter = opt.id; renderProductPills(); render() }, + }, [ + opt.label, + el('span', { style: 'margin-left:6px; opacity:0.7; font-weight:500' }, '(' + opt.count + ')'), + ]) + productPillRow.appendChild(pill) + }) + } + + function renderStats() { + statsRow.innerHTML = '' + // Filter by current product first; the row tells the operator + // about THIS scope, not the whole instance. + const scoped = currentProductFilter + ? allLicenses.filter((l) => l.product_id === currentProductFilter) + : allLicenses + const total = scoped.length + const active = scoped.filter((l) => l.status === 'active').length + const revoked = scoped.filter((l) => l.status === 'revoked').length + const now = Date.now() + const expiringSoon = scoped.filter((l) => { + if (!l.expires_at) return false + const t = new Date(l.expires_at).getTime() + return !isNaN(t) && t > now && (t - now) < 30 * 86_400_000 + }).length + function statBox(label, value, helpText) { + return el('div', { class: 'stat' }, [ + el('div', { class: 'stat-label', style: 'display:flex; align-items:center' }, [ + label, + helpText ? helpIcon(helpText) : null, + ]), + el('div', { class: 'stat-value' }, String(value)), + ]) + } + statsRow.appendChild(statBox('Licenses', total, 'Total in this scope (all-time, regardless of status).')) + statsRow.appendChild(statBox('Active', active, 'Status = active. Excludes suspended, revoked, expired.')) + statsRow.appendChild(statBox('Revoked', revoked, 'Status = revoked. Irreversible.')) + statsRow.appendChild(statBox('Expiring < 30d', expiringSoon, + 'Active or unsuspended licenses with expires_at within the next 30 days. Useful for retention nudges.')) + } + + function render() { + tableHolder.innerHTML = '' + let scoped = allLicenses + if (currentProductFilter) scoped = scoped.filter((l) => l.product_id === currentProductFilter) + + // Single-product or product-filtered: flat table. + const inSearchMode = lastQuery.length > 0 + if (products.length <= 1 || currentProductFilter || inSearchMode) { + const titleProduct = currentProductFilter + ? (productById(currentProductFilter) || { name: 'Product' }).name + ' — ' + : '' + const title = inSearchMode + ? 'Search results' + : (titleProduct + 'Recent licenses') + const subtitle = scoped.length + ' license' + (scoped.length === 1 ? '' : 's') + + (inSearchMode ? '' : (scoped.length >= 100 ? ' (most recent 100)' : '')) + tableHolder.appendChild(buildLicensesTable( + scoped, title, subtitle, + inSearchMode + ? 'No matches.' + : 'No licenses yet — once a buyer purchases or you manually issue, they appear here.', + )) + renderStats() + return + } + + // Multi-product: group + collapse empty products. + const grouped = products + .map((p) => ({ product: p, licenses: scoped.filter((l) => l.product_id === p.id) })) + .filter((g) => g.licenses.length > 0) + + if (grouped.length === 0) { + tableHolder.appendChild(plainCard([ + el('p', { class: 'muted', style: 'margin:0' }, 'No licenses match this filter.'), + ])) + renderStats() + return + } + grouped.forEach(({ product, licenses }) => { + const breakdown = {} + licenses.forEach((l) => { breakdown[l.status] = (breakdown[l.status] || 0) + 1 }) + const breakdownTxt = Object.keys(breakdown).sort().map((k) => breakdown[k] + ' ' + k).join(' · ') + const card = el('div', { class: 'card', style: 'margin-bottom:14px' }, [ + el('div', { class: 'card-head' }, [ + el('h3', null, product.name + ' — ' + product.slug), + el('span', { class: 'sub' }, breakdownTxt), + ]), + buildLicensesTable(licenses, '', '', '(none)'), + ]) + tableHolder.appendChild(card) + }) + renderStats() + } + async function loadLicenses() { const q = queryInput.value.trim() + lastQuery = q + lastQueryField = fieldSel.value tableHolder.innerHTML = '' - tableHolder.appendChild(el('div', { class: 'card' }, el('div', { class: 'empty' }, q ? 'Searching…' : 'Loading recent licenses…'))) + tableHolder.appendChild(plainCard([el('p', { class: 'muted', style: 'margin:0' }, + 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.' - )) + const params = q ? '?' + new URLSearchParams({ [lastQueryField]: q }).toString() : '' + const [productsResp, licResp] = await Promise.all([ + api('/v1/products').catch(() => ({ products: [] })), + api('/v1/admin/licenses/search' + params), + ]) + products = productsResp.products || [] + allLicenses = licResp.licenses || [] + renderProductPills() + render() } catch (e) { tableHolder.innerHTML = '' tableHolder.appendChild(plainCard([err(e.message)])) } } - 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) ---------- @@ -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 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' }) + const productSel = formSelect('issue_product', 'Product', productOptions, { + required: true, + help: 'Which product the license is for. Determines the buyer\'s available policies (tiers) below.', + }) + const policySel = formSelect('issue_policy', 'Policy', [{ value: '', label: '— loading —' }], { + required: true, + help: 'Tier the license inherits from — entitlements, duration, max_machines, trial flag all come from here.', + }) + const noteField = formInput('issue_note', 'Internal note', { + help: 'Free-form note attached to the audit log. Examples: "comp", "press", "self-issue Pro for dogfood". Not visible to the buyer.', + placeholder: 'optional', + }) + const emailField = formInput('issue_email', 'Buyer email', { + type: 'email', + help: 'Optional. Stored on the license + invoice. Used by buyer self-service recovery if they later lose their key.', + placeholder: 'optional', + }) // Populate policy dropdown when product changes. async function refreshPolicies() { @@ -3372,7 +3875,6 @@ The request will be refused if there are licenses or invoices tied to it — use body.appendChild(productSel) body.appendChild(policySel) - body.appendChild(policyHint) body.appendChild(emailField) body.appendChild(noteField) 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([ - 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('p', { class: 'muted', style: 'margin:0 0 16px' }, [ + 'Showing the 100 most recently issued licenses by default. Search by buyer email, Nostr npub, or BTCPay invoice id to filter. ', + helpIcon( + 'Multi-product instances render as per-product sections. Use the product pills above to filter to a single product. ' + + 'Search results bypass the per-product grouping (search is global across all products).', + ), + ]), el('div', { class: 'toolbar' }, [ fieldSel, queryInput, @@ -3443,6 +3950,8 @@ The request will be refused if there are licenses or invoices tied to it — use ]), issueDisclosure, ])) + target.appendChild(productPillRow) + target.appendChild(statsRow) target.appendChild(tableHolder) buildIssueForm() if (window.lucide) lucide.createIcons() diff --git a/startos/versions/v0.2.0.ts b/startos/versions/v0.2.0.ts index e55d78c..de0bf78 100644 --- a/startos/versions/v0.2.0.ts +++ b/startos/versions/v0.2.0.ts @@ -58,6 +58,24 @@ const RELEASE_NOTES = [ // in RELEASE_NOTES above (the milestone). Subsequent revisions // append here. 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.', '', '**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') export const v0_2_0 = VersionInfo.of({ - version: '0.2.0:9', + version: '0.2.0:10', releaseNotes: { en_US: ROUTINE_NOTES }, // No on-disk transformation needed — v0.2.0:0 is a label change. // SQLite-level migrations live separately under