From 4dfbc44d457d04b2770d8f390516e98cb557c0f4 Mon Sep 17 00:00:00 2001 From: ZDDC Date: Fri, 5 Jun 2026 15:29:19 -0500 Subject: [PATCH] feat(elevation): on-page admin-mode toggle, ephemeral per-page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- browse/js/events.js | 13 +++ shared/elevation.css | 18 ++-- shared/elevation.js | 190 +++++++++++++++++++++++++++++++------------ 3 files changed, 162 insertions(+), 59 deletions(-) diff --git a/browse/js/events.js b/browse/js/events.js index 94f1aef..3c688e9 100644 --- a/browse/js/events.js +++ b/browse/js/events.js @@ -225,6 +225,19 @@ var refresh = document.getElementById('refreshHeaderBtn'); 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 ── // View settings live on the toolbar (not in per-row right-click // menus); create has a discoverable affordance here now that file diff --git a/shared/elevation.css b/shared/elevation.css index b88d12e..0ef67ec 100644 --- a/shared/elevation.css +++ b/shared/elevation.css @@ -1,10 +1,15 @@ -/* 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. */ +/* shared/elevation.css — on-page admin-elevation toggle. + elevation.js appends this control to ONLY for users the + server says can_elevate (sudo-style opt-in). It's a fixed bottom- + right switch so it works in any tool without a header slot and + 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 { + position: fixed; + right: 0.9rem; + bottom: 0.9rem; + z-index: 9300; /* above the is-elevated frame (9200) so it stays clickable */ display: inline-flex; align-items: center; gap: 0.3rem; @@ -12,10 +17,11 @@ color: var(--text-muted); user-select: none; cursor: pointer; - padding: 0.15rem 0.45rem; + padding: 0.2rem 0.5rem; border: 1px solid var(--border); border-radius: var(--radius); 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; } diff --git a/shared/elevation.js b/shared/elevation.js index 326c3d1..ff6a442 100644 --- a/shared/elevation.js +++ b/shared/elevation.js @@ -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 // 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.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. +// Two ways to arm, both gated on /.profile/access `can_elevate` so only +// real admins can flip it (a non-admin's attempt is a silent no-op): +// 1. The on-page toggle (renderToggle) — a small fixed control that we +// render ONLY for users who can_elevate. This is the primary path. +// 2. `?admin=true|false` typed into any URL — still honoured (handy for +// deep links / scripting), just normalised into the same state. // -// 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'; @@ -38,16 +42,43 @@ 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 (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() { try { var resp = await fetch('/.profile/access', { @@ -93,34 +124,26 @@ 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 @@ -151,27 +174,83 @@ + ''; 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); } } + // 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 + // `