v0.2.0:28 — Settings polish, operator-name fix, Hide-revoked toggle

Three small admin-UI fixes:

- Settings page intro card removed. The preamble was redundant with
  the page title + section headers.
- Operator-name save no longer 404s. The JS was POSTing to
  /v1/admin/operator-name; the daemon mounts the endpoint at
  /v1/admin/settings/operator-name. Fixed both GET and POST paths.
- Licenses page: pill toggle "Hide revoked" between the product
  filter row and stat cards. Filters rendered rows; stat cards
  still show the true revoked count so operators don't lose
  visibility.

UI-only; no schema, API, or SDK change.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Grant
2026-05-11 15:18:09 -05:00
parent 4377dfbb34
commit 559e657b90
2 changed files with 51 additions and 9 deletions
+38 -8
View File
@@ -4513,12 +4513,40 @@ hr.div { border:none; border-top:1px solid var(--border-1); margin:18px 0; }
let products = [] // for product-name lookup + grouping let products = [] // for product-name lookup + grouping
let allLicenses = [] // last fetched set let allLicenses = [] // last fetched set
let currentProductFilter = '' // empty = all products let currentProductFilter = '' // empty = all products
let hideRevoked = false // toggle: hide revoked rows from table (stats unaffected)
let lastQuery = '' let lastQuery = ''
let lastQueryField = 'email' let lastQueryField = 'email'
const productPillRow = el('div', { const productPillRow = el('div', {
style: 'display:flex; gap:8px; flex-wrap:wrap; margin:14px 0', style: 'display:flex; gap:8px; flex-wrap:wrap; margin:14px 0 4px',
}) })
// Status-filter row: a single "Hide revoked" toggle for now. Stats
// cards below still show the revoked count so operators don't lose
// visibility — this only filters which rows render in the table.
const statusFilterRow = el('div', {
style: 'display:flex; gap:8px; flex-wrap:wrap; margin:0 0 14px; ' +
'font-size:12px; color:var(--ink-500);',
})
const hideRevokedBtn = el('button', {
type: 'button',
style: 'font-size:12px; padding:4px 12px; border-radius:999px; cursor:pointer; ' +
'font-family:var(--font-body); font-weight:500; transition:all 100ms; ' +
'background:transparent; color:var(--ink-700); border:1px solid var(--border-2);',
}, 'Hide revoked')
hideRevokedBtn.addEventListener('click', () => {
hideRevoked = !hideRevoked
if (hideRevoked) {
hideRevokedBtn.style.background = 'var(--navy-800)'
hideRevokedBtn.style.color = 'var(--cream-50)'
hideRevokedBtn.style.borderColor = 'var(--navy-800)'
} else {
hideRevokedBtn.style.background = 'transparent'
hideRevokedBtn.style.color = 'var(--ink-700)'
hideRevokedBtn.style.borderColor = 'var(--border-2)'
}
render()
})
statusFilterRow.appendChild(hideRevokedBtn)
const statsRow = el('div', { class: 'stats', style: 'margin:0 0 14px' }) const statsRow = el('div', { class: 'stats', style: 'margin:0 0 14px' })
const tableHolder = el('div') const tableHolder = el('div')
@@ -4717,6 +4745,11 @@ hr.div { border:none; border-top:1px solid var(--border-1); margin:18px 0; }
tableHolder.innerHTML = '' tableHolder.innerHTML = ''
let scoped = allLicenses let scoped = allLicenses
if (currentProductFilter) scoped = scoped.filter((l) => l.product_id === currentProductFilter) if (currentProductFilter) scoped = scoped.filter((l) => l.product_id === currentProductFilter)
// Hide-revoked toggle: applied to TABLE rows only. Stats below
// still show the full revoked count from `scoped` (renderStats
// ignores hideRevoked deliberately so the operator never loses
// visibility into how many revoked licenses exist).
if (hideRevoked) scoped = scoped.filter((l) => l.status !== 'revoked')
// Single-product or product-filtered: flat table. // Single-product or product-filtered: flat table.
const inSearchMode = lastQuery.length > 0 const inSearchMode = lastQuery.length > 0
@@ -4964,6 +4997,7 @@ hr.div { border:none; border-top:1px solid var(--border-1); margin:18px 0; }
issueDisclosure, issueDisclosure,
])) ]))
target.appendChild(productPillRow) target.appendChild(productPillRow)
target.appendChild(statusFilterRow)
target.appendChild(statsRow) target.appendChild(statsRow)
target.appendChild(tableHolder) target.appendChild(tableHolder)
buildIssueForm() buildIssueForm()
@@ -5442,12 +5476,8 @@ hr.div { border:none; border-top:1px solid var(--border-1); margin:18px 0; }
routes.settings = async function () { routes.settings = async function () {
const target = document.getElementById('route-target') const target = document.getElementById('route-target')
target.innerHTML = '' target.innerHTML = ''
target.appendChild(plainCard([
el('p', { class: 'muted', style: 'margin:0' },
'Operator-facing configuration. Display name, payment provider connections, and scoped API keys for agent / automation access.'),
]))
const opNameHost = el('div', { style: 'margin-top:18px' }) const opNameHost = el('div')
target.appendChild(opNameHost) target.appendChild(opNameHost)
renderOperatorNameCard(opNameHost) renderOperatorNameCard(opNameHost)
@@ -5464,7 +5494,7 @@ hr.div { border:none; border-top:1px solid var(--border-1); margin:18px 0; }
host.innerHTML = '' host.innerHTML = ''
let stored = '', effective = '', fallbackEnv = '' let stored = '', effective = '', fallbackEnv = ''
try { try {
const r = await api('/v1/admin/operator-name').catch(() => null) const r = await api('/v1/admin/settings/operator-name').catch(() => null)
if (r) { if (r) {
stored = r.stored || '' stored = r.stored || ''
effective = r.effective || '' effective = r.effective || ''
@@ -5483,7 +5513,7 @@ hr.div { border:none; border-top:1px solid var(--border-1); margin:18px 0; }
saveBtn.disabled = true saveBtn.disabled = true
status.textContent = 'Saving…' status.textContent = 'Saving…'
try { try {
await api('/v1/admin/operator-name', { await api('/v1/admin/settings/operator-name', {
method: 'POST', method: 'POST',
body: { name: input.value.trim() }, body: { name: input.value.trim() },
}) })
+13 -1
View File
@@ -58,6 +58,18 @@ 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:28 — **Settings cleanup, operator-name save fix, Licenses "Hide revoked" toggle.** Three small admin-UI changes.',
'',
'**Settings page intro card removed.** The "Operator-facing configuration. Display name, payment provider connections, …" preamble at the top of the Settings page was redundant with the page title + the section headers below; dropping it tightens the layout and gets the operator straight to the controls.',
'',
'**Operator name save no longer 404s.** The admin UI was POSTing to `/v1/admin/operator-name`, but the daemon mounts the endpoint at `/v1/admin/settings/operator-name`. Mismatch fixed in the JS — both GET (page load) and POST (save) now hit the correct path. The route on the daemon side was already correct; this was a UI typo from the original wiring.',
'',
'**Licenses page: "Hide revoked" toggle.** A pill toggle sits between the product filter row and the stat cards. Off (default) = table shows every license. On = table filters out `status = "revoked"` rows so operators reviewing active state aren\'t scrolling past revoked entries. The Revoked stat card stays accurate either way — the toggle only affects rendered rows, never the stat counts. Per-product grouping, search, and product-filter pills all still work alongside it.',
'',
'**Test count: 87** (unchanged — UI-only release).',
'',
'**Upgrade path.** v0.2.0:27 → v0.2.0:28 is a drop-in. No data, schema, or API change. Existing operator-name settings in the DB are untouched.',
'',
'0.2.0:27 — **Tier-card feature list merged into a single ul; "MOST POPULAR" + "Limited: …" no longer collide.** Two remaining buy-page nits from :26.', '0.2.0:27 — **Tier-card feature list merged into a single ul; "MOST POPULAR" + "Limited: …" no longer collide.** Two remaining buy-page nits from :26.',
'', '',
'**Single feature list.** The :26 attempt at zeroing the gap between adjacent `<ul.tier-bullets>` and `<ul.tier-entitlements>` worked on paper but the boundary was still visible to the eye — probably from list-item bottom-padding accumulating around the ul switch. Solved structurally: the daemon now builds ONE `<ul class="tier-features">` server-side, concatenating marketing bullets and entitlements in the operator-controlled order. No list boundary = no gap to fight with CSS. Both groups render with the same ✓ checkmark, indistinguishable to the buyer.', '**Single feature list.** The :26 attempt at zeroing the gap between adjacent `<ul.tier-bullets>` and `<ul.tier-entitlements>` worked on paper but the boundary was still visible to the eye — probably from list-item bottom-padding accumulating around the ul switch. Solved structurally: the daemon now builds ONE `<ul class="tier-features">` server-side, concatenating marketing bullets and entitlements in the operator-controlled order. No list boundary = no gap to fight with CSS. Both groups render with the same ✓ checkmark, indistinguishable to the buyer.',
@@ -430,7 +442,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:27', version: '0.2.0:28',
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