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:
parent
76087c861c
commit
2a05b7716c
7 changed files with 991 additions and 143 deletions
|
|
@ -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" \
|
||||
|
|
|
|||
|
|
@ -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
209
tables/js/api-actions.js
Normal 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 || {});
|
||||
|
|
@ -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([]);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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) }
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
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 {
|
|||
+ '</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';
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue