feat(elevation): on-page admin-mode toggle, ephemeral per-page
Admins opt into admin powers via an on-page switch instead of only ?admin=true. The toggle renders ONLY for users the server reports can_elevate, reusing each tool's existing header placeholder (or creating one) and floating it bottom-right via fixed positioning. Admin mode is now EPHEMERAL — scoped to the page you armed it on: - the zddc-elevate cookie is session-scoped (drops the 30-min Max-Age) - pagehide clears it, so navigating away / closing drops admin Because a reload would race the pagehide-clear, every arm/drop path (toggle, ?admin= URL, banner "Drop admin") now applies IN PLACE and emits a `zddc:elevationchange` event. browse listens for it and re-fetches the listing (server-computed verbs) + re-renders the open preview, so editor affordances reflect the new elevation without a manual reload. Validated end-to-end in a containerized Chromium (Playwright over CDP) against a local zddc-server: the toggle renders for can_elevate, arming sets the session cookie + armed chrome, "Drop admin" and navigate-away both clear it, and ?admin=true still arms via the same funnel. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
cd645c53bb
commit
4dfbc44d45
3 changed files with 162 additions and 59 deletions
|
|
@ -225,6 +225,19 @@
|
||||||
var refresh = document.getElementById('refreshHeaderBtn');
|
var refresh = document.getElementById('refreshHeaderBtn');
|
||||||
if (refresh) refresh.addEventListener('click', refreshListing);
|
if (refresh) refresh.addEventListener('click', refreshListing);
|
||||||
|
|
||||||
|
// Admin mode (shared/elevation.js) flipped on this page. Listing
|
||||||
|
// verbs + editor affordances (canSave) are computed against the
|
||||||
|
// server WITH the elevation cookie, so re-fetch the listing (which
|
||||||
|
// re-runs prefetchScopeAccess) and re-render the open preview —
|
||||||
|
// restoreState only restores the highlight, not the pane contents.
|
||||||
|
window.addEventListener('zddc:elevationchange', async function () {
|
||||||
|
if (state.source !== 'server') return; // FS mode has no server elevation
|
||||||
|
await refreshListing();
|
||||||
|
var node = state.lastPreviewedNodeId && state.nodes.get(state.lastPreviewedNodeId);
|
||||||
|
var p = window.app.modules.preview;
|
||||||
|
if (node && !node.isDir && p && p.showFilePreview) p.showFilePreview(node);
|
||||||
|
});
|
||||||
|
|
||||||
// ── Tree-pane toolbar: New folder / New file, Sort, Show hidden ──
|
// ── Tree-pane toolbar: New folder / New file, Sort, Show hidden ──
|
||||||
// View settings live on the toolbar (not in per-row right-click
|
// View settings live on the toolbar (not in per-row right-click
|
||||||
// menus); create has a discoverable affordance here now that file
|
// menus); create has a discoverable affordance here now that file
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,15 @@
|
||||||
/* shared/elevation.css — admin-elevation toggle in the tool header.
|
/* shared/elevation.css — on-page admin-elevation toggle.
|
||||||
Renders only for users with admin scope (handled by elevation.js;
|
elevation.js appends this control to <body> ONLY for users the
|
||||||
the placeholder is `.hidden` by default). When visible, sits left
|
server says can_elevate (sudo-style opt-in). It's a fixed bottom-
|
||||||
of the theme button — sudo-style affordance for opting into admin
|
right switch so it works in any tool without a header slot and
|
||||||
powers. */
|
stays clear of the top "admin mode is on" banner. Arming is
|
||||||
|
per-page: the cookie is session-scoped and cleared on pagehide. */
|
||||||
|
|
||||||
.elevation-toggle {
|
.elevation-toggle {
|
||||||
|
position: fixed;
|
||||||
|
right: 0.9rem;
|
||||||
|
bottom: 0.9rem;
|
||||||
|
z-index: 9300; /* above the is-elevated frame (9200) so it stays clickable */
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.3rem;
|
gap: 0.3rem;
|
||||||
|
|
@ -12,10 +17,11 @@
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
user-select: none;
|
user-select: none;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
padding: 0.15rem 0.45rem;
|
padding: 0.2rem 0.5rem;
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
border-radius: var(--radius);
|
border-radius: var(--radius);
|
||||||
background: var(--bg);
|
background: var(--bg);
|
||||||
|
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.14);
|
||||||
transition: background 0.12s, border-color 0.12s, color 0.12s;
|
transition: background 0.12s, border-color 0.12s, color 0.12s;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
// shared/elevation.js — admin elevation via URL toggle.
|
// shared/elevation.js — admin elevation via an on-page toggle.
|
||||||
//
|
//
|
||||||
// Sudo-style model: admins behave as normal users by default; elevating
|
// Sudo-style model: admins behave as normal users by default; elevating
|
||||||
// the session turns on admin escape hatches (WORM bypass, .zddc edit
|
// the session turns on admin escape hatches (WORM bypass, .zddc edit
|
||||||
|
|
@ -6,18 +6,22 @@
|
||||||
// `zddc-elevate=1` cookie that the server reads via handler.ACLMiddleware
|
// `zddc-elevate=1` cookie that the server reads via handler.ACLMiddleware
|
||||||
// → zddc.Principal{Elevated}.
|
// → zddc.Principal{Elevated}.
|
||||||
//
|
//
|
||||||
// Toggle is by URL query param — `?admin=true` to arm, `?admin=false`
|
// Two ways to arm, both gated on /.profile/access `can_elevate` so only
|
||||||
// (or the red banner's "Drop admin" button) to drop — so it's reachable
|
// real admins can flip it (a non-admin's attempt is a silent no-op):
|
||||||
// from ANY zddc-server page, not just ones that render a header control.
|
// 1. The on-page toggle (renderToggle) — a small fixed control that we
|
||||||
// The cookie is the sticky state: it persists across navigation for its
|
// render ONLY for users who can_elevate. This is the primary path.
|
||||||
// Max-Age window, so the param need not stay in the URL (we strip it).
|
// 2. `?admin=true|false` typed into any URL — still honoured (handy for
|
||||||
// Arming is gated on /.profile/access `can_elevate`, so only real admins
|
// deep links / scripting), just normalised into the same state.
|
||||||
// can set it; a non-admin's ?admin=true is a silent no-op.
|
|
||||||
//
|
//
|
||||||
// Applying the cookie reloads to the cleaned URL so the server re-renders
|
// Admin mode is EPHEMERAL — scoped to the page you turned it on:
|
||||||
// under the new state (admin scaffolds in some tool HTML are server-
|
// * the cookie is a SESSION cookie (no Max-Age), and
|
||||||
// rendered, so a client-only flip wouldn't reach them). The red viewport
|
// * we clear it on `pagehide`, so navigating away / closing the tab
|
||||||
// border + banner (applyArmedChrome) reflect the cookie on every load.
|
// 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 () {
|
(function () {
|
||||||
'use strict';
|
'use strict';
|
||||||
|
|
||||||
|
|
@ -38,16 +42,43 @@
|
||||||
function setElevated(on) {
|
function setElevated(on) {
|
||||||
if (on) {
|
if (on) {
|
||||||
// SameSite=Lax blocks cross-site form-post / image-tag CSRF
|
// SameSite=Lax blocks cross-site form-post / image-tag CSRF
|
||||||
// shapes. Max-Age caps the elevation window so a forgotten
|
// shapes. No Max-Age → a SESSION cookie: it dies with the tab
|
||||||
// tab doesn't leave admin powers active indefinitely (sudo's
|
// and, combined with the pagehide handler below, is cleared the
|
||||||
// 5-minute precedent informs the number — 30 minutes is a
|
// moment you leave the page. Admin powers never silently
|
||||||
// reasonable trade between annoyance and exposure).
|
// outlive the page you armed them on.
|
||||||
document.cookie = COOKIE_NAME + '=1; Path=/; SameSite=Lax; Max-Age=1800';
|
document.cookie = COOKIE_NAME + '=1; Path=/; SameSite=Lax';
|
||||||
} else {
|
} else {
|
||||||
document.cookie = COOKIE_NAME + '=; Path=/; SameSite=Lax; Max-Age=0';
|
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 (toggle,
|
||||||
|
// URL param, banner button). Each flips the cookie, re-paints the armed
|
||||||
|
// chrome, syncs the toggle checkbox, and emits the change — no reload.
|
||||||
|
function setOn() {
|
||||||
|
setElevated(true);
|
||||||
|
applyArmedChrome(true);
|
||||||
|
syncToggle();
|
||||||
|
emitChange();
|
||||||
|
}
|
||||||
|
function setOff() {
|
||||||
|
setElevated(false);
|
||||||
|
applyArmedChrome(false);
|
||||||
|
syncToggle();
|
||||||
|
emitChange();
|
||||||
|
}
|
||||||
|
|
||||||
async function fetchAccess() {
|
async function fetchAccess() {
|
||||||
try {
|
try {
|
||||||
var resp = await fetch('/.profile/access', {
|
var resp = await fetch('/.profile/access', {
|
||||||
|
|
@ -93,34 +124,26 @@
|
||||||
return u.pathname + (qs ? '?' + qs : '') + u.hash;
|
return u.pathname + (qs ? '?' + qs : '') + u.hash;
|
||||||
}
|
}
|
||||||
|
|
||||||
// handleAdminParam applies a ?admin= request. Returns true when a
|
// handleAdminParam applies a ?admin= request IN PLACE (no reload — see
|
||||||
// navigation (reload) is underway so the caller can stop. Enabling is
|
// the module header on why reloads would race the pagehide-clear).
|
||||||
// gated on can_elevate — a non-admin who types ?admin=true just gets
|
// Enabling is gated on can_elevate — a non-admin who types ?admin=true
|
||||||
// the param stripped, never a misleading red border. Disabling is open
|
// just gets the param stripped, never a misleading red border.
|
||||||
// (anyone may drop a cookie they somehow hold).
|
// Disabling is open (anyone may drop a cookie they somehow hold).
|
||||||
async function handleAdminParam() {
|
// `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();
|
var want = adminParam();
|
||||||
if (want === null) return false;
|
if (want === null) return;
|
||||||
var clean = urlWithoutAdmin();
|
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) {}
|
try { history.replaceState(history.state, '', clean); } catch (_e) {}
|
||||||
return false;
|
if (want === isElevated()) return; // already in the requested state
|
||||||
}
|
|
||||||
if (want === true) {
|
if (want === true) {
|
||||||
var access = await fetchAccess();
|
if (access === undefined) access = await fetchAccess();
|
||||||
if (!access || !access.can_elevate) {
|
if (!access || !access.can_elevate) return; // silent no-op
|
||||||
try { history.replaceState(history.state, '', clean); } catch (_e) {}
|
setOn();
|
||||||
return false;
|
|
||||||
}
|
|
||||||
setElevated(true);
|
|
||||||
} else {
|
} 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
|
// Page-wide affordances when elevation is active. The toggle alone
|
||||||
|
|
@ -151,27 +174,83 @@
|
||||||
+ '</button>';
|
+ '</button>';
|
||||||
document.body.insertBefore(banner, document.body.firstChild);
|
document.body.insertBefore(banner, document.body.firstChild);
|
||||||
var off = banner.querySelector('#elevation-banner-off');
|
var off = banner.querySelector('#elevation-banner-off');
|
||||||
if (off) off.addEventListener('click', function () {
|
if (off) off.addEventListener('click', function () { setOff(); });
|
||||||
setElevated(false);
|
|
||||||
window.location.reload();
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
} else if (banner) {
|
} else if (banner) {
|
||||||
banner.parentNode.removeChild(banner);
|
banner.parentNode.removeChild(banner);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// renderToggle mounts the on-page admin switch — but ONLY for users the
|
||||||
|
// server says can_elevate (i.e. who'd actually gain edit authority by
|
||||||
|
// arming). Everyone else never sees it. It's a fixed control so it works
|
||||||
|
// in any tool without that tool rendering a header slot for it.
|
||||||
|
function renderToggle() {
|
||||||
|
// Reuse the header placeholder the tool templates ship (an empty
|
||||||
|
// `<span id="elevation-toggle" class="… hidden">`) when present —
|
||||||
|
// dropping its `hidden` class and repopulating it; otherwise create
|
||||||
|
// one. Either way the fixed-position CSS floats it bottom-right, so
|
||||||
|
// it doesn't matter where in the DOM it lives.
|
||||||
|
var el = document.getElementById('elevation-toggle');
|
||||||
|
var created = false;
|
||||||
|
if (!el) {
|
||||||
|
el = document.createElement('span');
|
||||||
|
el.id = 'elevation-toggle';
|
||||||
|
created = true;
|
||||||
|
}
|
||||||
|
el.className = 'elevation-toggle'; // drops any stale `hidden`
|
||||||
|
el.title = 'Arm admin mode for this page. Drops automatically when you leave.';
|
||||||
|
el.innerHTML = '';
|
||||||
|
var input = document.createElement('input');
|
||||||
|
input.type = 'checkbox';
|
||||||
|
input.id = 'elevation-toggle-input';
|
||||||
|
input.className = 'elevation-toggle__input';
|
||||||
|
input.checked = isElevated();
|
||||||
|
input.addEventListener('change', function () {
|
||||||
|
if (input.checked) setOn(); else setOff();
|
||||||
|
});
|
||||||
|
var txt = document.createElement('span');
|
||||||
|
txt.className = 'elevation-toggle__label';
|
||||||
|
txt.textContent = 'Admin mode';
|
||||||
|
txt.addEventListener('click', function () { input.click(); });
|
||||||
|
el.appendChild(input);
|
||||||
|
el.appendChild(txt);
|
||||||
|
if (created) document.body.appendChild(el);
|
||||||
|
}
|
||||||
|
|
||||||
|
// syncToggle keeps the checkbox honest when state is flipped by some
|
||||||
|
// other path (URL param, banner "Drop admin", bfcache restore).
|
||||||
|
function syncToggle() {
|
||||||
|
var input = document.getElementById('elevation-toggle-input');
|
||||||
|
if (input) input.checked = isElevated();
|
||||||
|
}
|
||||||
|
|
||||||
async function init() {
|
async function init() {
|
||||||
// Apply (or tear down) the red border + banner from the cookie on
|
// file:// (offline FS-Access mode) has no server to elevate against
|
||||||
// every page load — admin mode is toggled by URL, but the armed
|
// — skip the access probe and the toggle entirely.
|
||||||
// chrome must surface everywhere so the user can't accidentally
|
if (window.location.protocol === 'file:') return;
|
||||||
// write through an elevated context on a page they didn't toggle.
|
|
||||||
|
// 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());
|
applyArmedChrome(isElevated());
|
||||||
|
|
||||||
// Honour ?admin=true|false typed into any zddc-server URL. There's
|
// One /.profile/access probe drives both decisions: whether to show
|
||||||
// no on-screen toggle anymore — the URL is the enable path and the
|
// the toggle (can_elevate) and whether a ?admin=true may arm.
|
||||||
// red banner's "Drop admin" button is the one-click disable.
|
var access = await fetchAccess();
|
||||||
await handleAdminParam();
|
if (access && access.can_elevate) renderToggle();
|
||||||
|
await handleAdminParam(access);
|
||||||
|
|
||||||
|
// Admin mode is per-page: clear the cookie when the page goes away
|
||||||
|
// so it never persists past a navigation. (No UI work here — the
|
||||||
|
// page is unloading; the next page re-derives state from scratch.)
|
||||||
|
window.addEventListener('pagehide', function () {
|
||||||
|
if (isElevated()) setElevated(false);
|
||||||
|
});
|
||||||
|
// bfcache can restore a page whose pagehide already cleared the
|
||||||
|
// cookie — re-sync the visible state so chrome ≠ cookie can't happen.
|
||||||
|
window.addEventListener('pageshow', function (e) {
|
||||||
|
if (e.persisted) { applyArmedChrome(isElevated()); syncToggle(); }
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (document.readyState === 'loading') {
|
if (document.readyState === 'loading') {
|
||||||
|
|
@ -180,5 +259,10 @@
|
||||||
init();
|
init();
|
||||||
}
|
}
|
||||||
|
|
||||||
window.zddc.elevation = { isElevated: isElevated, setElevated: setElevated };
|
window.zddc.elevation = {
|
||||||
|
isElevated: isElevated,
|
||||||
|
setElevated: setElevated,
|
||||||
|
setOn: setOn,
|
||||||
|
setOff: setOff
|
||||||
|
};
|
||||||
})();
|
})();
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue