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), "", "<\\/"))
+ needle := []byte(``)
+ 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 {
@@ -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