feat(tokens): render /.tokens via the tables engine + generic apiActions layer

Retire the bespoke, chrome-less /.tokens page. It now renders through the
shared tables engine — getting the standard header (logo, theme, profile
menu) + declarative columns/filters for free — from a server-injected,
pre-assembled #table-context built from the user's tokens (Store.List).

New, reusable "tables over an API collection" primitive (tables/js/
api-actions.js): when the injected context carries an `apiActions` block,
it drives create (a modal form → POST, surfacing the one-time secret) and
per-row delete (→ DELETE) against a REST endpoint, and hides the file-model
toolbar affordances (+ Add row / Save). It deliberately does NOT touch the
file-save/row-ops machinery (ETag/conflict/row-file writes), so the secrets
surface stays on the existing tested /.api/tokens endpoints.

Server: handler.injectTableContextObj injects an arbitrary pre-assembled
context; EmbeddedTablesHTML() exposes the renderer to sibling handlers;
ServeTokensPage builds the token context (+ apiActions for /.api/tokens)
and serves the tables HTML, falling back to the legacy skeleton only when
the store or the tables renderer is unavailable.

This is the first dynamic/virtual-record collection rendered by the same
declarative engine + chrome as on-disk tables — no bespoke page. Validated
end-to-end in a containerized browser (list + create→secret + revoke);
tests/tokens.spec.js updated to the new UI; full Go suite green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
ZDDC 2026-06-06 15:09:40 -05:00
parent 76087c861c
commit 2a05b7716c
7 changed files with 991 additions and 143 deletions

View file

@ -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" \

View file

@ -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; }

209
tables/js/api-actions.js Normal file
View file

@ -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 <dir>/*.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 || {});

View file

@ -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([]);
});

View file

@ -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), "</", "<\\/"))
needle := []byte(`<script id="table-context" type="application/json">{}</script>`)
if !bytesContains(template, needle) {
return nil, errBundle("#table-context placeholder not found in template")
}
replacement := append([]byte(`<script id="table-context" type="application/json">`), js...)
replacement = append(replacement, []byte(`</script>`)...)
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) }

View file

@ -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 {
</svg>
<div class="header-title-group">
<span class="app-header__title" id="table-title">ZDDC Table</span>
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.27-beta · 2026-06-05 12:41:17 · 382645b</span></span>
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.27-dev · 2026-06-06 20:06:06 · 76087c8-dirty</span></span>
</div>
</div>
<div class="header-right">
<!-- Elevation toggle slot. shared/elevation.js fills it
when /.profile/access reports the user has admin
authority; stays empty + hidden for non-admins so
the chrome is quiet for the common case. -->
<span id="elevation-toggle" class="elevation-toggle hidden"
title="Opt into your admin powers for the next 30 minutes (sudo-style)."></span>
<button id="theme-btn" class="btn btn-secondary" title="Theme: auto (follows OS)" aria-label="Theme: auto (follows OS)"></button>
<button id="help-btn" class="btn btn-secondary" title="Help" aria-label="Help">?</button>
</div>
@ -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;
}
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 {
+ '</button>';
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 <body>, 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 <dir>/*.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';

View file

@ -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