diff --git a/tables/build.sh b/tables/build.sh index 5fd6b07..03181d1 100755 --- a/tables/build.sh +++ b/tables/build.sh @@ -60,6 +60,7 @@ concat_files \ "js/clipboard.js" \ "js/export.js" \ "js/render.js" \ + "js/api-actions.js" \ "js/main.js" \ "../form/js/app.js" \ "../form/js/context.js" \ diff --git a/tables/css/table.css b/tables/css/table.css index 9aa07b5..00b0bca 100644 --- a/tables/css/table.css +++ b/tables/css/table.css @@ -207,3 +207,32 @@ color: var(--color-text-muted); font-style: italic; } + +/* ── api-actions.js: create modal + per-row delete (e.g. /.tokens) ─────────── */ +.api-modal__overlay { + position: fixed; inset: 0; z-index: 9500; + display: flex; align-items: center; justify-content: center; + background: rgba(0, 0, 0, 0.4); +} +.api-modal { + background: var(--bg, #fff); color: var(--text, #222); + border: 1px solid var(--border, #ccc); border-radius: var(--radius, 6px); + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.25); + padding: 1.1rem 1.2rem; width: min(28rem, 92vw); +} +.api-modal__title { margin: 0 0 .8rem; font-size: 1.1rem; } +.api-modal__field { display: flex; flex-direction: column; gap: .25rem; margin-bottom: .7rem; font-size: .85rem; } +.api-modal__field input { + padding: .4rem .5rem; font: inherit; + border: 1px solid var(--border, #ccc); border-radius: var(--radius, 4px); + background: var(--bg, #fff); color: var(--text, #222); +} +.api-modal__actions { display: flex; justify-content: flex-end; gap: .5rem; margin-top: .8rem; } +.api-modal__err { color: var(--danger, #b00020); font-size: .82rem; margin: .2rem 0; } +.api-modal__secret-label { margin: 0 0 .5rem; font-size: .9rem; } +.api-modal__secret { + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: .8rem; + word-break: break-all; padding: .6rem .7rem; border-radius: var(--radius, 4px); + background: var(--bg-alt, #f3f4f6); border: 1px solid var(--border, #ccc); +} +.api-revoke { white-space: nowrap; margin-left: .6rem; float: right; } diff --git a/tables/js/api-actions.js b/tables/js/api-actions.js new file mode 100644 index 0000000..a002983 --- /dev/null +++ b/tables/js/api-actions.js @@ -0,0 +1,209 @@ +// api-actions.js — generic "tables over an API collection" layer. +// +// When the injected #table-context carries an `apiActions` block, this turns +// the otherwise read-only table into a managed collection backed by a REST +// endpoint, WITHOUT touching the file-save/row-ops machinery (which is bound +// to /*.yaml row files). It drives create + per-row delete against the +// configured URLs and reloads on success (the server re-renders the fresh +// list). First consumer: the self-service token page at /.tokens. +// +// apiActions: { +// create: { url, title?, fields:[{name,label,placeholder?,type?}], secretField?, secretLabel? }, +// deleteRow: { urlTemplate (with {id}), label?, confirm? } // {id} ← row's data-url +// } +(function (app) { + 'use strict'; + + function cfg() { + return (app && app.context && app.context.apiActions) || null; + } + + function el(tag, attrs, text) { + var e = document.createElement(tag); + if (attrs) Object.keys(attrs).forEach(function (k) { e.setAttribute(k, attrs[k]); }); + if (text != null) e.textContent = text; + return e; + } + + // ── Create ──────────────────────────────────────────────────────────── + var createMounted = false; + function mountCreate(c) { + if (createMounted) return; + var bar = document.querySelector('.table-toolbar__left') || document.getElementById('table-toolbar'); + if (!bar) return; + // The native "+ Add row" posts to the form-create file endpoint, which + // doesn't apply to an API collection — hide it; this button replaces it. + var native = document.getElementById('table-add-row'); + if (native) native.hidden = true; + var btn = el('button', { type: 'button', class: 'btn btn-primary btn-sm', id: 'api-create-btn' }, '+ ' + (c.title || 'New')); + btn.addEventListener('click', function () { openCreate(c); }); + bar.appendChild(btn); + createMounted = true; + } + + function openCreate(c) { + var overlay = el('div', { class: 'api-modal__overlay' }); + var modal = el('div', { class: 'api-modal' }); + modal.appendChild(el('h2', { class: 'api-modal__title' }, c.title || 'New')); + var form = el('form', { class: 'api-modal__form' }); + var inputs = {}; + (c.fields || []).forEach(function (f) { + var lab = el('label', { class: 'api-modal__field' }); + lab.appendChild(el('span', null, f.label || f.name)); + var inp = el('input', { type: f.type || 'text' }); + if (f.placeholder) inp.setAttribute('placeholder', f.placeholder); + inputs[f.name] = inp; + lab.appendChild(inp); + form.appendChild(lab); + }); + var err = el('div', { class: 'api-modal__err', hidden: 'hidden' }); + form.appendChild(err); + var actions = el('div', { class: 'api-modal__actions' }); + var cancel = el('button', { type: 'button', class: 'btn btn-secondary btn-sm' }, 'Cancel'); + var submit = el('button', { type: 'submit', class: 'btn btn-primary btn-sm' }, 'Create'); + actions.appendChild(cancel); actions.appendChild(submit); + form.appendChild(actions); + modal.appendChild(form); + overlay.appendChild(modal); + document.body.appendChild(overlay); + var firstInput = form.querySelector('input'); + if (firstInput) firstInput.focus(); + + function close() { if (overlay.parentNode) overlay.parentNode.removeChild(overlay); } + cancel.addEventListener('click', close); + overlay.addEventListener('click', function (e) { if (e.target === overlay) close(); }); + + form.addEventListener('submit', function (e) { + e.preventDefault(); + err.hidden = true; + var body = {}; + (c.fields || []).forEach(function (f) { + var v = inputs[f.name].value.trim(); + if (!v) return; + // Date fields → RFC3339 so the Go time.Time decoder accepts them. + body[f.name] = (f.type === 'date') ? new Date(v + 'T00:00:00').toISOString() : v; + }); + submit.disabled = true; + fetch(c.url, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + credentials: 'same-origin', + body: JSON.stringify(body) + }).then(function (r) { + return r.text().then(function (t) { return { ok: r.ok, status: r.status, text: t }; }); + }).then(function (res) { + if (!res.ok) { + submit.disabled = false; + err.textContent = 'Create failed: ' + res.status + ' ' + res.text; + err.hidden = false; + return; + } + close(); + var secret = ''; + if (c.secretField) { + try { secret = (JSON.parse(res.text) || {})[c.secretField] || ''; } catch (_e) { /* ignore */ } + } + if (secret) showSecret(c.secretLabel || 'New secret (shown once):', secret); + else location.reload(); + }).catch(function (e2) { + submit.disabled = false; + err.textContent = 'Create failed: ' + (e2 && e2.message ? e2.message : e2); + err.hidden = false; + }); + }); + } + + function showSecret(label, secret) { + var overlay = el('div', { class: 'api-modal__overlay' }); + var modal = el('div', { class: 'api-modal' }); + modal.appendChild(el('p', { class: 'api-modal__secret-label' }, label)); + var box = el('div', { class: 'api-modal__secret' }, secret); + modal.appendChild(box); + var actions = el('div', { class: 'api-modal__actions' }); + var copy = el('button', { type: 'button', class: 'btn btn-secondary btn-sm' }, 'Copy'); + copy.addEventListener('click', function () { + if (navigator.clipboard) navigator.clipboard.writeText(secret); + copy.textContent = 'Copied'; + }); + var done = el('button', { type: 'button', class: 'btn btn-primary btn-sm' }, 'Done'); + done.addEventListener('click', function () { location.reload(); }); + actions.appendChild(copy); actions.appendChild(done); + modal.appendChild(actions); + overlay.appendChild(modal); + document.body.appendChild(overlay); + } + + // ── Per-row delete ────────────────────────────────────────────────────── + function ensureRowDelete(d) { + var tbody = document.querySelector('#table-root tbody'); + if (!tbody) return; + var trs = tbody.querySelectorAll('tr'); + for (var i = 0; i < trs.length; i++) { + var tr = trs[i]; + if (tr.querySelector('.api-revoke')) continue; + var id = tr.getAttribute('data-url'); + if (!id) continue; + var cell = tr.lastElementChild; + if (!cell) continue; + var b = el('button', { type: 'button', class: 'btn btn-secondary btn-sm api-revoke' }, d.label || 'Delete'); + (function (rowId) { + b.addEventListener('click', function () { revoke(d, rowId); }); + })(id); + cell.appendChild(b); + } + } + + function revoke(d, id) { + if (d.confirm && !window.confirm(d.confirm)) return; + var url = d.urlTemplate.replace('{id}', encodeURIComponent(id)); + fetch(url, { method: 'DELETE', credentials: 'same-origin' }).then(function (r) { + if (r.ok || r.status === 204) location.reload(); + else r.text().then(function (t) { window.alert('Delete failed: ' + r.status + ' ' + t); }); + }).catch(function (e) { window.alert('Delete failed: ' + (e && e.message ? e.message : e)); }); + } + + // Suppress the file-model toolbar affordances that don't apply to an API + // collection: native "+ Add row" (posts to the form-create file endpoint) + // and "Save" (flushes dirty row files). Re-run each tick in case main.js + // toggles them after us. + function hideNative() { + // Use inline display:none, not the [hidden] attr — the .btn display + // rule overrides [hidden] and the buttons would stay visible. + ['table-add-row', 'table-save'].forEach(function (id) { + var b = document.getElementById(id); + if (b) b.style.display = 'none'; + }); + } + + function tick() { + var c = cfg(); + if (!c) return; + hideNative(); + if (c.create) mountCreate(c.create); + if (c.deleteRow) ensureRowDelete(c.deleteRow); + } + + function start() { + // app.context is set asynchronously by main.js (await context.load()). + // Poll until it's present, then run once + observe the tbody so the + // per-row buttons survive sort/filter re-renders. + var tries = 0; + var iv = setInterval(function () { + if (cfg() || tries++ > 60) { + clearInterval(iv); + if (!cfg()) return; + tick(); + var tbody = document.querySelector('#table-root tbody'); + if (tbody && window.MutationObserver) { + new MutationObserver(function () { tick(); }).observe(tbody, { childList: true }); + } + } + }, 100); + } + + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', start); + } else { + start(); + } +})(window.tablesApp = window.tablesApp || {}); diff --git a/tests/tokens.spec.js b/tests/tokens.spec.js index 8ef6b3d..d6e19f5 100644 --- a/tests/tokens.spec.js +++ b/tests/tokens.spec.js @@ -48,12 +48,16 @@ test.describe('/.tokens self-service token UI', () => { expect(r.status()).toBe(401); }); - test('authenticated GET /.tokens renders the page with email', async ({ page }) => { + test('authenticated GET /.tokens renders the tokens table with email', async ({ page }) => { + // The page now renders through the shared tables engine (header chrome + // + declarative columns), not the bespoke skeleton: the title lives in + // #table-title, the signed-in email in the table description, create is + // the apiActions "+ New token" button, and the grid is #table-root. await page.goto(`${server.baseURL}/.tokens`); - await expect(page.locator('h1')).toHaveText(/API tokens/i); - await expect(page.locator('.who')).toContainText(TEST_EMAIL); - await expect(page.locator('#create')).toBeVisible(); - await expect(page.locator('#tokens')).toBeVisible(); + await expect(page.locator('#table-title')).toHaveText(/API tokens/i); + await expect(page.locator('#table-description')).toContainText(TEST_EMAIL); + await expect(page.locator('#api-create-btn')).toBeVisible(); + await expect(page.locator('#table-root')).toBeVisible(); }); test('GET /.api/tokens initially returns empty list', async ({ request }) => { @@ -64,47 +68,43 @@ test.describe('/.tokens self-service token UI', () => { expect(list.filter(t => t.email === TEST_EMAIL)).toEqual([]); }); - test('create token via the page → plaintext shown once → list contains the new entry', async ({ page }) => { + test('create token via the page → plaintext shown once → list contains it → revoke', async ({ page }) => { + page.on('dialog', d => d.accept()); // auto-accept the revoke confirm() await page.goto(`${server.baseURL}/.tokens`); - // Wait for the inline JS's initial refresh() so we know the - // table is populated (or shows "No tokens issued yet."). - await expect(page.locator('#tokens tbody')).not.toBeEmpty(); - - // Fill the form and submit. + // Create via the apiActions "+ New token" modal. + await page.locator('#api-create-btn').click(); + await expect(page.locator('.api-modal')).toBeVisible(); const description = `playwright-${Date.now()}`; - await page.fill('#desc', description); - await page.click('button[type="submit"]'); + await page.locator('.api-modal input').first().fill(description); + await page.locator('.api-modal button[type="submit"]').click(); - // The plaintext token appears in #created div.token-secret — - // shown exactly once per the API contract. - const secret = page.locator('#created .token-secret'); + // The plaintext token is shown exactly once, in the secret dialog. + const secret = page.locator('.api-modal__secret'); await expect(secret).toBeVisible(); const plaintext = (await secret.textContent()).trim(); expect(plaintext.length).toBeGreaterThan(20); expect(plaintext).not.toContain('<'); expect(plaintext).not.toContain('"'); - // The token appears in the table. - const row = page.locator('#tokens tbody tr', { hasText: description }); - await expect(row).toBeVisible(); - - // Verify via the API too — the listed token's description matches. + // Verify via the API while the dialog is up. const r = await page.request.get(`${server.baseURL}/.api/tokens`); - const list = await r.json(); - const matches = list.filter(t => t.description === description); + const matches = (await r.json()).filter(t => t.description === description); expect(matches.length).toBe(1); expect(matches[0].email).toBe(TEST_EMAIL); - // Revoke via the row's button. The page's confirm() dialog needs - // to be auto-accepted. - page.on('dialog', d => d.accept()); - await row.locator('button.danger').click(); + // Done reloads; the new token appears as a row. + await page.locator('.api-modal button:has-text("Done")').click(); + await page.waitForLoadState('networkidle'); + const row = page.locator('#table-root tbody tr', { hasText: description }); + await expect(row).toBeVisible(); - // Token should disappear from the table. - await expect(page.locator('#tokens tbody tr', { hasText: description })).toHaveCount(0); + // Revoke via the row's button (reloads on success). + await row.locator('.api-revoke').click(); + await page.waitForLoadState('networkidle'); + await expect(page.locator('#table-root tbody tr', { hasText: description })).toHaveCount(0); - // And from the API list. + // And gone from the API list. const after = await (await page.request.get(`${server.baseURL}/.api/tokens`)).json(); expect(after.filter(t => t.description === description)).toEqual([]); }); diff --git a/zddc/internal/handler/tablehandler.go b/zddc/internal/handler/tablehandler.go index e29a8bb..ab76d43 100644 --- a/zddc/internal/handler/tablehandler.go +++ b/zddc/internal/handler/tablehandler.go @@ -439,6 +439,31 @@ func injectTableContext(template, tableYAML, formYAML []byte) ([]byte, error) { return bytesReplace(template, needle, replacement), nil } +// injectTableContextObj writes a fully pre-assembled table context (title, +// columns, rows, apiActions, …) into the `#table-context` placeholder, so the +// client renders it as-is with no directory walk (context.js treats a context +// carrying a columns[] array as authoritative). Used to render dynamic +// server-side collections — e.g. the token list at /.tokens — through the same +// tables engine + chrome as on-disk tables, instead of a bespoke page. +func injectTableContextObj(template []byte, ctx interface{}) ([]byte, error) { + js, err := json.Marshal(ctx) + if err != nil { + return nil, err + } + js = []byte(strings.ReplaceAll(string(js), "{}`) + if !bytesContains(template, needle) { + return nil, errBundle("#table-context placeholder not found in template") + } + replacement := append([]byte(``)...) + return bytesReplace(template, needle, replacement), nil +} + +// EmbeddedTablesHTML exposes the embedded tables renderer to sibling handlers +// (e.g. the token page) that render a server-injected collection through it. +func EmbeddedTablesHTML() []byte { return embeddedTablesHTML } + type errBundle string func (e errBundle) Error() string { return string(e) } diff --git a/zddc/internal/handler/tables.html b/zddc/internal/handler/tables.html index 020dd96..0c0d587 100644 --- a/zddc/internal/handler/tables.html +++ b/zddc/internal/handler/tables.html @@ -855,53 +855,10 @@ body.help-open .app-header { filter: brightness(1.1); } -/* shared/elevation.css — admin-elevation toggle in the tool header. - Renders only for users with admin scope (handled by elevation.js; - the placeholder is `.hidden` by default). When visible, sits left - of the theme button — sudo-style affordance for opting into admin - powers. */ - -.elevation-toggle { - display: inline-flex; - align-items: center; - gap: 0.3rem; - font-size: 0.78rem; - color: var(--text-muted); - user-select: none; - cursor: pointer; - padding: 0.15rem 0.45rem; - border: 1px solid var(--border); - border-radius: var(--radius); - background: var(--bg); - transition: background 0.12s, border-color 0.12s, color 0.12s; -} - -.elevation-toggle:hover { - background: var(--bg-hover); - border-color: var(--border-dark); -} - -.elevation-toggle input[type="checkbox"] { - margin: 0; - cursor: pointer; - accent-color: var(--danger); -} - -.elevation-toggle__label { - cursor: pointer; - letter-spacing: 0.02em; -} - -/* Active state — when elevation is ON, the toggle reads as "armed" - so the user can't miss that admin powers are currently live. - :has(:checked) lets us style the wrapper based on the inner - checkbox without JS. */ -.elevation-toggle:has(input:checked) { - background: rgba(220, 53, 69, 0.12); - border-color: var(--danger); - color: var(--danger); - font-weight: 600; -} +/* shared/elevation.css — page-wide armed chrome for admin mode. + The elevate CONTROL is the "Admin mode" item in the shared profile menu + (shared/profile-menu.{js,css}); this file only styles the unmistakable + "you are elevated" cues: the red viewport frame + the sticky banner. */ /* Page-wide chrome when admin mode is active. The toggle alone is easy to miss; these add an inescapable visual cue: @@ -978,6 +935,118 @@ body.is-elevated::after { background: rgba(255, 255, 255, 0.3); } +/* shared/profile-menu.css — header account menu (upper-right). + shared/profile-menu.js mounts a button into `.header-right` and toggles + a dropdown with the signed-in email, Admin mode, Profile, Access tokens, + and Sign out. Server mode only. */ + +.profile-menu { + position: relative; + display: inline-flex; +} + +/* The button: a small circular avatar showing the email initial. */ +.profile-btn { + display: inline-flex; + align-items: center; + justify-content: center; + padding: 0; + width: 1.9rem; + height: 1.9rem; + border-radius: 50%; + line-height: 1; +} +.profile-btn__avatar { + font-size: 0.8rem; + font-weight: 700; + letter-spacing: 0.01em; + text-transform: uppercase; +} +/* Armed (admin mode on): a red ring so the elevated state reads from the + button even when the menu is closed — pairs with the page banner/frame. */ +.profile-btn--armed { + box-shadow: 0 0 0 2px var(--danger, #dc3545); + border-color: var(--danger, #dc3545); + color: var(--danger, #dc3545); +} + +.profile-menu__panel { + display: none; + /* Fixed + JS-positioned from the button rect: an absolute panel gets + trapped below the content layer by the app's stacking contexts, so + anchor it to the viewport instead (profile-menu.js sets top/right). */ + position: fixed; + min-width: 15rem; + z-index: 9400; /* above the is-elevated frame (9200) + banner (9100) */ + background: var(--bg, #fff); + border: 1px solid var(--border, #ddd); + border-radius: var(--radius, 6px); + box-shadow: 0 6px 24px rgba(0, 0, 0, 0.16); + padding: 0.3rem; + font-size: 0.85rem; +} +.profile-menu__panel.open { display: block; } + +.profile-menu__id { + padding: 0.35rem 0.55rem 0.45rem; +} +.profile-menu__email { + font-weight: 600; + color: var(--text, #222); + word-break: break-all; +} +.profile-menu__role { + margin-top: 0.1rem; + font-size: 0.72rem; + font-weight: 600; + letter-spacing: 0.04em; + text-transform: uppercase; + color: var(--danger, #dc3545); +} + +.profile-menu__sep { + height: 1px; + margin: 0.25rem 0; + background: var(--border, #eee); +} + +.profile-menu__item { + display: flex; + align-items: center; + gap: 0.5rem; + width: 100%; + box-sizing: border-box; + padding: 0.4rem 0.55rem; + border-radius: var(--radius, 4px); + color: var(--text, #222); + text-decoration: none; + cursor: pointer; + background: none; + border: none; + text-align: left; + font: inherit; +} +.profile-menu__item:hover { + background: var(--bg-hover, rgba(0, 0, 0, 0.05)); +} + +.profile-menu__toggle { cursor: pointer; } +.profile-menu__check { + margin: 0; + cursor: pointer; + accent-color: var(--danger, #dc3545); + flex-shrink: 0; +} +.profile-menu__toggle-label { + display: flex; + flex-direction: column; + line-height: 1.25; +} +.profile-menu__hint { + font-size: 0.72rem; + color: var(--text-muted, #888); +} + /* shared/logo.css — paired with shared/logo.js. The wrapping anchor inherits the logo's box and adds a subtle hover/focus affordance so it reads as clickable without altering the logo's visual weight. */ @@ -1320,6 +1389,35 @@ body.is-elevated::after { font-style: italic; } +/* ── api-actions.js: create modal + per-row delete (e.g. /.tokens) ─────────── */ +.api-modal__overlay { + position: fixed; inset: 0; z-index: 9500; + display: flex; align-items: center; justify-content: center; + background: rgba(0, 0, 0, 0.4); +} +.api-modal { + background: var(--bg, #fff); color: var(--text, #222); + border: 1px solid var(--border, #ccc); border-radius: var(--radius, 6px); + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.25); + padding: 1.1rem 1.2rem; width: min(28rem, 92vw); +} +.api-modal__title { margin: 0 0 .8rem; font-size: 1.1rem; } +.api-modal__field { display: flex; flex-direction: column; gap: .25rem; margin-bottom: .7rem; font-size: .85rem; } +.api-modal__field input { + padding: .4rem .5rem; font: inherit; + border: 1px solid var(--border, #ccc); border-radius: var(--radius, 4px); + background: var(--bg, #fff); color: var(--text, #222); +} +.api-modal__actions { display: flex; justify-content: flex-end; gap: .5rem; margin-top: .8rem; } +.api-modal__err { color: var(--danger, #b00020); font-size: .82rem; margin: .2rem 0; } +.api-modal__secret-label { margin: 0 0 .5rem; font-size: .9rem; } +.api-modal__secret { + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: .8rem; + word-break: break-all; padding: .6rem .7rem; border-radius: var(--radius, 4px); + background: var(--bg-alt, #f3f4f6); border: 1px solid var(--border, #ccc); +} +.api-revoke { white-space: nowrap; margin-left: .6rem; float: right; } + /* form/ — ZDDC generic form renderer. Form-specific layout only; theme tokens (--primary, --bg, --text, --border, --bg-secondary, --text-muted, --font-mono, --radius) come @@ -1534,16 +1632,10 @@ body.is-elevated::after {
ZDDC Table - v0.0.27-beta · 2026-06-05 12:41:17 · 382645b + v0.0.27-dev · 2026-06-06 20:06:06 · 76087c8-dirty
- -
@@ -2822,26 +2914,31 @@ body.is-elevated::after { } }()); -// shared/elevation.js — admin elevation via URL toggle. +// shared/elevation.js — admin elevation state machine. // // Sudo-style model: admins behave as normal users by default; elevating -// the session turns on admin escape hatches (WORM bypass, .zddc edit -// authority, profile admin scaffolds). State is carried in a -// `zddc-elevate=1` cookie that the server reads via handler.ACLMiddleware -// → zddc.Principal{Elevated}. +// the session turns on admin escape hatches (WORM bypass, recursive +// delete, rearranging records, profile admin scaffolds — NOT config-edit, +// which is standing). State is carried in a `zddc-elevate=1` cookie that +// the server reads via handler.ACLMiddleware → zddc.Principal{Elevated}. // -// Toggle is by URL query param — `?admin=true` to arm, `?admin=false` -// (or the red banner's "Drop admin" button) to drop — so it's reachable -// from ANY zddc-server page, not just ones that render a header control. -// The cookie is the sticky state: it persists across navigation for its -// Max-Age window, so the param need not stay in the URL (we strip it). -// Arming is gated on /.profile/access `can_elevate`, so only real admins -// can set it; a non-admin's ?admin=true is a silent no-op. +// This module owns the STATE (cookie, armed chrome/banner, ephemeral +// lifecycle, the change event) + exposes setOn/setOff/isElevated. The +// on-page elevate CONTROL lives in the shared profile menu +// (shared/profile-menu.js) — an "Admin mode" item shown only to +// can_elevate users — which calls setOn/setOff. `?admin=true|false` typed +// into any URL is also honoured (gated on can_elevate), for deep links / +// scripting. // -// Applying the cookie reloads to the cleaned URL so the server re-renders -// under the new state (admin scaffolds in some tool HTML are server- -// rendered, so a client-only flip wouldn't reach them). The red viewport -// border + banner (applyArmedChrome) reflect the cookie on every load. +// Admin mode is EPHEMERAL — scoped to the page you turned it on: +// * the cookie is a SESSION cookie (no Max-Age), and +// * we clear it on `pagehide`, so navigating away / closing the tab +// drops admin (you re-arm deliberately on the next page). +// Because of that we apply state IN PLACE (no reload — a reload's pagehide +// would race the clear). SPAs that server-render elevation-dependent data +// (e.g. browse's listing verbs) listen for the `zddc:elevationchange` +// event we emit and re-fetch. The red viewport border + banner +// (applyArmedChrome) reflect the cookie, kept in sync on every change. (function () { 'use strict'; @@ -2862,16 +2959,43 @@ body.is-elevated::after { function setElevated(on) { if (on) { // SameSite=Lax blocks cross-site form-post / image-tag CSRF - // shapes. Max-Age caps the elevation window so a forgotten - // tab doesn't leave admin powers active indefinitely (sudo's - // 5-minute precedent informs the number — 30 minutes is a - // reasonable trade between annoyance and exposure). - document.cookie = COOKIE_NAME + '=1; Path=/; SameSite=Lax; Max-Age=1800'; + // shapes. No Max-Age → a SESSION cookie: it dies with the tab + // and, combined with the pagehide handler below, is cleared the + // moment you leave the page. Admin powers never silently + // outlive the page you armed them on. + document.cookie = COOKIE_NAME + '=1; Path=/; SameSite=Lax'; } else { document.cookie = COOKIE_NAME + '=; Path=/; SameSite=Lax; Max-Age=0'; } } + // emitChange notifies same-page listeners (SPAs that server-render + // elevation-dependent data, e.g. browse's listing verbs / editor + // affordances) so they can re-fetch without a full reload. + function emitChange() { + try { + window.dispatchEvent(new CustomEvent('zddc:elevationchange', { + detail: { elevated: isElevated() } + })); + } catch (_e) { /* CustomEvent unsupported — non-fatal */ } + } + + // setOn / setOff are the single funnel for every arm/drop path (the + // profile menu's Admin mode item, the ?admin= URL param, the banner's + // Drop button). Each flips the cookie, re-paints the armed chrome, and + // emits the change — no reload. The profile menu listens for the change + // event to keep its checkbox + armed indicator in sync. + function setOn() { + setElevated(true); + applyArmedChrome(true); + emitChange(); + } + function setOff() { + setElevated(false); + applyArmedChrome(false); + emitChange(); + } + async function fetchAccess() { try { var resp = await fetch('/.profile/access', { @@ -2917,34 +3041,26 @@ body.is-elevated::after { return u.pathname + (qs ? '?' + qs : '') + u.hash; } - // handleAdminParam applies a ?admin= request. Returns true when a - // navigation (reload) is underway so the caller can stop. Enabling is - // gated on can_elevate — a non-admin who types ?admin=true just gets - // the param stripped, never a misleading red border. Disabling is open - // (anyone may drop a cookie they somehow hold). - async function handleAdminParam() { + // handleAdminParam applies a ?admin= request IN PLACE (no reload — see + // the module header on why reloads would race the pagehide-clear). + // Enabling is gated on can_elevate — a non-admin who types ?admin=true + // just gets the param stripped, never a misleading red border. + // Disabling is open (anyone may drop a cookie they somehow hold). + // `access` (a prefetched /.profile/access, may be null) lets init reuse + // its single fetch instead of issuing a second one. + async function handleAdminParam(access) { var want = adminParam(); - if (want === null) return false; + if (want === null) return; var clean = urlWithoutAdmin(); - if (want === isElevated()) { - // Already in the requested state — just clean the URL, no reload. - try { history.replaceState(history.state, '', clean); } catch (_e) {} - return false; - } + try { history.replaceState(history.state, '', clean); } catch (_e) {} + if (want === isElevated()) return; // already in the requested state if (want === true) { - var access = await fetchAccess(); - if (!access || !access.can_elevate) { - try { history.replaceState(history.state, '', clean); } catch (_e) {} - return false; - } - setElevated(true); + if (access === undefined) access = await fetchAccess(); + if (!access || !access.can_elevate) return; // silent no-op + setOn(); } else { - setElevated(false); + setOff(); } - // Navigate to the clean URL (a real load, so the server re-renders - // under the new cookie) and replace history so Back is safe. - window.location.replace(clean); - return true; } // Page-wide affordances when elevation is active. The toggle alone @@ -2975,10 +3091,7 @@ body.is-elevated::after { + ''; document.body.insertBefore(banner, document.body.firstChild); var off = banner.querySelector('#elevation-banner-off'); - if (off) off.addEventListener('click', function () { - setElevated(false); - window.location.reload(); - }); + if (off) off.addEventListener('click', function () { setOff(); }); } } else if (banner) { banner.parentNode.removeChild(banner); @@ -2986,16 +3099,30 @@ body.is-elevated::after { } async function init() { - // Apply (or tear down) the red border + banner from the cookie on - // every page load — admin mode is toggled by URL, but the armed - // chrome must surface everywhere so the user can't accidentally - // write through an elevated context on a page they didn't toggle. + // file:// (offline FS-Access mode) has no server to elevate against. + if (window.location.protocol === 'file:') return; + + // Reflect the cookie's armed chrome on every load (a leftover from a + // not-yet-fired pagehide, or an arrived-with ?admin link). applyArmedChrome(isElevated()); - // Honour ?admin=true|false typed into any zddc-server URL. There's - // no on-screen toggle anymore — the URL is the enable path and the - // red banner's "Drop admin" button is the one-click disable. + // Honour ?admin=true|false typed into any URL — handleAdminParam + // fetches /.profile/access itself to gate arming on can_elevate. The + // on-page elevate control lives in the shared profile menu + // (shared/profile-menu.js), which calls setOn/setOff and listens for + // zddc:elevationchange to keep its checkbox + armed ring in sync. await handleAdminParam(); + + // Admin mode is per-page: clear the cookie when the page goes away so + // it never persists past a navigation. + window.addEventListener('pagehide', function () { + if (isElevated()) setElevated(false); + }); + // bfcache can restore a page whose pagehide already cleared the + // cookie — re-sync the armed chrome so chrome ≠ cookie can't happen. + window.addEventListener('pageshow', function (e) { + if (e.persisted) applyArmedChrome(isElevated()); + }); } if (document.readyState === 'loading') { @@ -3004,7 +3131,178 @@ body.is-elevated::after { init(); } - window.zddc.elevation = { isElevated: isElevated, setElevated: setElevated }; + window.zddc.elevation = { + isElevated: isElevated, + setElevated: setElevated, + setOn: setOn, + setOff: setOff + }; +})(); + +// shared/profile-menu.js — account menu in the header's upper-right. +// +// Replaces the old floating elevation toggle. Admin mode is now one item in +// this dropdown, alongside the signed-in email, Profile, and Access tokens. +// Mounts into the tool header's `.header-right` cluster (every tool ships one) +// and drives elevation via window.zddc.elevation, so the cookie / armed-chrome +// / ephemeral state machine stays in shared/elevation.js. +// +// No logout: authentication is the upstream proxy's concern (oauth2-proxy / +// Authelia) — ZDDC owns no session, so it doesn't touch sign-out. +// +// Server mode only: it reads /.profile/access for the email + can_elevate. +// On file:// (offline FS-Access mode) there's no server account, so nothing +// renders. +(function () { + 'use strict'; + + if (!window.zddc) window.zddc = {}; + if (window.zddc.profileMenu) return; + + function el(tag, cls, text) { + var e = document.createElement(tag); + if (cls) e.className = cls; + if (text != null) e.textContent = text; + return e; + } + + async function fetchAccess() { + try { + var r = await fetch('/.profile/access', { + headers: { Accept: 'application/json' }, + credentials: 'same-origin', + cache: 'no-cache' + }); + if (!r.ok) return null; + return await r.json(); + } catch (_e) { return null; } + } + + var elevation = null; + var panelEl = null, btnEl = null, adminInput = null; + + function isElevated() { + return !!(elevation && elevation.isElevated && elevation.isElevated()); + } + + // Keep the button's armed ring + the menu checkbox in lockstep with the + // elevation cookie (flipped here, by ?admin=, or by the banner's Drop). + function syncArmed() { + if (btnEl) btnEl.classList.toggle('profile-btn--armed', isElevated()); + if (adminInput) adminInput.checked = isElevated(); + } + + function closeMenu() { + if (panelEl) panelEl.classList.remove('open'); + if (btnEl) btnEl.setAttribute('aria-expanded', 'false'); + } + // The panel is position:fixed (to escape the app's stacking contexts), so + // anchor it to the button rect — top just below it, right-aligned. + function positionPanel() { + var r = btnEl.getBoundingClientRect(); + panelEl.style.top = (r.bottom + 4) + 'px'; + panelEl.style.right = Math.max(4, window.innerWidth - r.right) + 'px'; + panelEl.style.left = 'auto'; + } + function toggleMenu() { + if (!panelEl) return; + var open = panelEl.classList.toggle('open'); + if (open) positionPanel(); + btnEl.setAttribute('aria-expanded', open ? 'true' : 'false'); + } + + function linkItem(text, href) { + var a = el('a', 'profile-menu__item', text); + a.href = href; + a.setAttribute('role', 'menuitem'); + return a; + } + + function build(access) { + var wrap = el('div', 'profile-menu'); + + btnEl = el('button', 'btn btn-secondary profile-btn'); + btnEl.type = 'button'; + btnEl.id = 'profile-btn'; + btnEl.title = 'Account: ' + (access.email || 'signed in'); + btnEl.setAttribute('aria-haspopup', 'menu'); + btnEl.setAttribute('aria-expanded', 'false'); + var initial = ((access.email || '?').trim().charAt(0) || '?').toUpperCase(); + btnEl.appendChild(el('span', 'profile-btn__avatar', initial)); + btnEl.addEventListener('click', function (e) { e.stopPropagation(); toggleMenu(); }); + wrap.appendChild(btnEl); + + panelEl = el('div', 'profile-menu__panel'); + panelEl.setAttribute('role', 'menu'); + + var id = el('div', 'profile-menu__id'); + id.appendChild(el('div', 'profile-menu__email', access.email || '(anonymous)')); + if (access.is_super_admin) id.appendChild(el('div', 'profile-menu__role', 'super admin')); + else if (access.has_any_admin_scope) id.appendChild(el('div', 'profile-menu__role', 'admin')); + panelEl.appendChild(id); + panelEl.appendChild(el('div', 'profile-menu__sep')); + + // Admin mode — only offered to principals who actually have admin + // authority somewhere (can_elevate). Drops automatically on leave. + if (access.can_elevate && elevation) { + var row = el('label', 'profile-menu__item profile-menu__toggle'); + adminInput = document.createElement('input'); + adminInput.type = 'checkbox'; + adminInput.className = 'profile-menu__check'; + adminInput.checked = isElevated(); + adminInput.addEventListener('change', function () { + if (adminInput.checked) elevation.setOn(); else elevation.setOff(); + }); + row.appendChild(adminInput); + var txt = el('span', 'profile-menu__toggle-label'); + txt.appendChild(el('span', null, 'Admin mode')); + txt.appendChild(el('span', 'profile-menu__hint', 'bypass WORM/ACL — drops when you leave the page')); + row.appendChild(txt); + panelEl.appendChild(row); + panelEl.appendChild(el('div', 'profile-menu__sep')); + } + + panelEl.appendChild(linkItem('Profile', '/.profile')); + panelEl.appendChild(linkItem('Access tokens', '/.tokens')); + // No "Sign out": authentication is the upstream proxy's concern + // (oauth2-proxy / Authelia). ZDDC doesn't own a session, so it + // doesn't render a logout affordance. + + // Portal the panel to , not inside the header: the app's + // layout creates stacking contexts that trap even a fixed+high + // z-index panel below the content. As a direct body child it sits in + // the root stacking context and reliably overlays everything. + // position:fixed + positionPanel() keep it anchored to the button. + document.body.appendChild(panelEl); + return wrap; + } + + async function init() { + if (window.location.protocol === 'file:') return; + elevation = window.zddc.elevation || null; + var access = await fetchAccess(); + if (!access || !access.email) return; // unauthenticated / non-zddc backend + var host = document.querySelector('.header-right'); + if (!host) return; + + host.appendChild(build(access)); + syncArmed(); + + document.addEventListener('click', function (e) { + if (panelEl && panelEl.classList.contains('open') + && !panelEl.contains(e.target) && !btnEl.contains(e.target)) closeMenu(); + }); + document.addEventListener('keydown', function (e) { if (e.key === 'Escape') closeMenu(); }); + window.addEventListener('zddc:elevationchange', syncArmed); + + window.zddc.profileMenu = { close: closeMenu }; + } + + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', init); + } else { + init(); + } })(); // shared/cap.js — client-side capability helpers for permission gating. @@ -6743,6 +7041,216 @@ body.is-elevated::after { }; })(window.tablesApp); +// api-actions.js — generic "tables over an API collection" layer. +// +// When the injected #table-context carries an `apiActions` block, this turns +// the otherwise read-only table into a managed collection backed by a REST +// endpoint, WITHOUT touching the file-save/row-ops machinery (which is bound +// to /*.yaml row files). It drives create + per-row delete against the +// configured URLs and reloads on success (the server re-renders the fresh +// list). First consumer: the self-service token page at /.tokens. +// +// apiActions: { +// create: { url, title?, fields:[{name,label,placeholder?,type?}], secretField?, secretLabel? }, +// deleteRow: { urlTemplate (with {id}), label?, confirm? } // {id} ← row's data-url +// } +(function (app) { + 'use strict'; + + function cfg() { + return (app && app.context && app.context.apiActions) || null; + } + + function el(tag, attrs, text) { + var e = document.createElement(tag); + if (attrs) Object.keys(attrs).forEach(function (k) { e.setAttribute(k, attrs[k]); }); + if (text != null) e.textContent = text; + return e; + } + + // ── Create ──────────────────────────────────────────────────────────── + var createMounted = false; + function mountCreate(c) { + if (createMounted) return; + var bar = document.querySelector('.table-toolbar__left') || document.getElementById('table-toolbar'); + if (!bar) return; + // The native "+ Add row" posts to the form-create file endpoint, which + // doesn't apply to an API collection — hide it; this button replaces it. + var native = document.getElementById('table-add-row'); + if (native) native.hidden = true; + var btn = el('button', { type: 'button', class: 'btn btn-primary btn-sm', id: 'api-create-btn' }, '+ ' + (c.title || 'New')); + btn.addEventListener('click', function () { openCreate(c); }); + bar.appendChild(btn); + createMounted = true; + } + + function openCreate(c) { + var overlay = el('div', { class: 'api-modal__overlay' }); + var modal = el('div', { class: 'api-modal' }); + modal.appendChild(el('h2', { class: 'api-modal__title' }, c.title || 'New')); + var form = el('form', { class: 'api-modal__form' }); + var inputs = {}; + (c.fields || []).forEach(function (f) { + var lab = el('label', { class: 'api-modal__field' }); + lab.appendChild(el('span', null, f.label || f.name)); + var inp = el('input', { type: f.type || 'text' }); + if (f.placeholder) inp.setAttribute('placeholder', f.placeholder); + inputs[f.name] = inp; + lab.appendChild(inp); + form.appendChild(lab); + }); + var err = el('div', { class: 'api-modal__err', hidden: 'hidden' }); + form.appendChild(err); + var actions = el('div', { class: 'api-modal__actions' }); + var cancel = el('button', { type: 'button', class: 'btn btn-secondary btn-sm' }, 'Cancel'); + var submit = el('button', { type: 'submit', class: 'btn btn-primary btn-sm' }, 'Create'); + actions.appendChild(cancel); actions.appendChild(submit); + form.appendChild(actions); + modal.appendChild(form); + overlay.appendChild(modal); + document.body.appendChild(overlay); + var firstInput = form.querySelector('input'); + if (firstInput) firstInput.focus(); + + function close() { if (overlay.parentNode) overlay.parentNode.removeChild(overlay); } + cancel.addEventListener('click', close); + overlay.addEventListener('click', function (e) { if (e.target === overlay) close(); }); + + form.addEventListener('submit', function (e) { + e.preventDefault(); + err.hidden = true; + var body = {}; + (c.fields || []).forEach(function (f) { + var v = inputs[f.name].value.trim(); + if (!v) return; + // Date fields → RFC3339 so the Go time.Time decoder accepts them. + body[f.name] = (f.type === 'date') ? new Date(v + 'T00:00:00').toISOString() : v; + }); + submit.disabled = true; + fetch(c.url, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + credentials: 'same-origin', + body: JSON.stringify(body) + }).then(function (r) { + return r.text().then(function (t) { return { ok: r.ok, status: r.status, text: t }; }); + }).then(function (res) { + if (!res.ok) { + submit.disabled = false; + err.textContent = 'Create failed: ' + res.status + ' ' + res.text; + err.hidden = false; + return; + } + close(); + var secret = ''; + if (c.secretField) { + try { secret = (JSON.parse(res.text) || {})[c.secretField] || ''; } catch (_e) { /* ignore */ } + } + if (secret) showSecret(c.secretLabel || 'New secret (shown once):', secret); + else location.reload(); + }).catch(function (e2) { + submit.disabled = false; + err.textContent = 'Create failed: ' + (e2 && e2.message ? e2.message : e2); + err.hidden = false; + }); + }); + } + + function showSecret(label, secret) { + var overlay = el('div', { class: 'api-modal__overlay' }); + var modal = el('div', { class: 'api-modal' }); + modal.appendChild(el('p', { class: 'api-modal__secret-label' }, label)); + var box = el('div', { class: 'api-modal__secret' }, secret); + modal.appendChild(box); + var actions = el('div', { class: 'api-modal__actions' }); + var copy = el('button', { type: 'button', class: 'btn btn-secondary btn-sm' }, 'Copy'); + copy.addEventListener('click', function () { + if (navigator.clipboard) navigator.clipboard.writeText(secret); + copy.textContent = 'Copied'; + }); + var done = el('button', { type: 'button', class: 'btn btn-primary btn-sm' }, 'Done'); + done.addEventListener('click', function () { location.reload(); }); + actions.appendChild(copy); actions.appendChild(done); + modal.appendChild(actions); + overlay.appendChild(modal); + document.body.appendChild(overlay); + } + + // ── Per-row delete ────────────────────────────────────────────────────── + function ensureRowDelete(d) { + var tbody = document.querySelector('#table-root tbody'); + if (!tbody) return; + var trs = tbody.querySelectorAll('tr'); + for (var i = 0; i < trs.length; i++) { + var tr = trs[i]; + if (tr.querySelector('.api-revoke')) continue; + var id = tr.getAttribute('data-url'); + if (!id) continue; + var cell = tr.lastElementChild; + if (!cell) continue; + var b = el('button', { type: 'button', class: 'btn btn-secondary btn-sm api-revoke' }, d.label || 'Delete'); + (function (rowId) { + b.addEventListener('click', function () { revoke(d, rowId); }); + })(id); + cell.appendChild(b); + } + } + + function revoke(d, id) { + if (d.confirm && !window.confirm(d.confirm)) return; + var url = d.urlTemplate.replace('{id}', encodeURIComponent(id)); + fetch(url, { method: 'DELETE', credentials: 'same-origin' }).then(function (r) { + if (r.ok || r.status === 204) location.reload(); + else r.text().then(function (t) { window.alert('Delete failed: ' + r.status + ' ' + t); }); + }).catch(function (e) { window.alert('Delete failed: ' + (e && e.message ? e.message : e)); }); + } + + // Suppress the file-model toolbar affordances that don't apply to an API + // collection: native "+ Add row" (posts to the form-create file endpoint) + // and "Save" (flushes dirty row files). Re-run each tick in case main.js + // toggles them after us. + function hideNative() { + // Use inline display:none, not the [hidden] attr — the .btn display + // rule overrides [hidden] and the buttons would stay visible. + ['table-add-row', 'table-save'].forEach(function (id) { + var b = document.getElementById(id); + if (b) b.style.display = 'none'; + }); + } + + function tick() { + var c = cfg(); + if (!c) return; + hideNative(); + if (c.create) mountCreate(c.create); + if (c.deleteRow) ensureRowDelete(c.deleteRow); + } + + function start() { + // app.context is set asynchronously by main.js (await context.load()). + // Poll until it's present, then run once + observe the tbody so the + // per-row buttons survive sort/filter re-renders. + var tries = 0; + var iv = setInterval(function () { + if (cfg() || tries++ > 60) { + clearInterval(iv); + if (!cfg()) return; + tick(); + var tbody = document.querySelector('#table-root tbody'); + if (tbody && window.MutationObserver) { + new MutationObserver(function () { tick(); }).observe(tbody, { childList: true }); + } + } + }, 100); + } + + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', start); + } else { + start(); + } +})(window.tablesApp = window.tablesApp || {}); + (function (app) { 'use strict'; diff --git a/zddc/internal/handler/tokenhandler.go b/zddc/internal/handler/tokenhandler.go index a0034e8..f5cde23 100644 --- a/zddc/internal/handler/tokenhandler.go +++ b/zddc/internal/handler/tokenhandler.go @@ -209,9 +209,85 @@ func ServeTokensPage(cfg config.Config, store *auth.Store, w http.ResponseWriter if r.Method == http.MethodHead { return } - storeAvailable := store != nil - body := renderTokensPage(email, storeAvailable) - _, _ = w.Write([]byte(body)) + + // Render the token list through the shared tables engine (chrome + + // declarative columns) with a server-injected collection, instead of a + // bespoke chrome-less page. Create + revoke are driven by the generic + // apiActions layer against the existing /.api/tokens endpoints (the + // tables file-save path is untouched). Falls back to the legacy + // skeleton if the store or the tables renderer isn't available. + tablesHTML := EmbeddedTablesHTML() + if store == nil || len(tablesHTML) == 0 { + _, _ = w.Write([]byte(renderTokensPage(email, store != nil))) + return + } + injected, err := injectTableContextObj(tablesHTML, buildTokensTableContext(store, email)) + if err != nil { + _, _ = w.Write([]byte(renderTokensPage(email, true))) + return + } + _, _ = w.Write(injected) +} + +// buildTokensTableContext assembles the pre-rendered #table-context for the +// token page: the user's tokens as read-only rows + the apiActions config that +// wires create/revoke to /.api/tokens (create surfaces the one-time secret). +func buildTokensTableContext(store *auth.Store, email string) map[string]interface{} { + rows := []map[string]interface{}{} + if list, err := store.List(email); err == nil { + for _, t := range list { + exp := "never" + if !t.Expires.IsZero() { + exp = t.Expires.Format("2006-01-02") + } + rows = append(rows, map[string]interface{}{ + "url": t.ID(), + "editable": false, + "data": map[string]interface{}{ + "description": t.Description, + "created": t.Created.Format("2006-01-02 15:04"), + "expires": exp, + "id": t.ID(), + }, + }) + } + } + col := func(field, title, width string) map[string]interface{} { + c := map[string]interface{}{"field": field, "title": title} + if width != "" { + c["width"] = width + } + return c + } + return map[string]interface{}{ + "title": "API tokens", + "description": "Bearer tokens for CLI / scripted access as " + email + ". A token's secret is shown once, at creation.", + "addable": false, + "columns": []map[string]interface{}{ + col("description", "Description", ""), + col("created", "Created", "12em"), + col("expires", "Expires", "9em"), + col("id", "ID", "16em"), + }, + "rows": rows, + "apiActions": map[string]interface{}{ + "create": map[string]interface{}{ + "url": TokensAPIPathPrefix, + "title": "New token", + "secretField": "token", + "secretLabel": "New token — copy it now, it is shown only once:", + "fields": []map[string]interface{}{ + {"name": "description", "label": "Description", "placeholder": "e.g. Field laptop"}, + {"name": "expires", "label": "Expires (optional)", "type": "date"}, + }, + }, + "deleteRow": map[string]interface{}{ + "urlTemplate": TokensAPIPathPrefix + "/{id}", + "label": "Revoke", + "confirm": "Revoke this token? Any client using it will stop working.", + }, + }, + } } // renderTokensPage builds the HTML for the management page. Kept inline