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>
268 lines
12 KiB
JavaScript
268 lines
12 KiB
JavaScript
// 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
|
|
// authority, profile admin scaffolds). State is carried in a
|
|
// `zddc-elevate=1` cookie that the server reads via handler.ACLMiddleware
|
|
// → zddc.Principal{Elevated}.
|
|
//
|
|
// 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.
|
|
//
|
|
// 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';
|
|
|
|
if (!window.zddc) window.zddc = {};
|
|
if (window.zddc.elevation) return;
|
|
|
|
var COOKIE_NAME = 'zddc-elevate';
|
|
|
|
function isElevated() {
|
|
var parts = document.cookie.split(';');
|
|
for (var i = 0; i < parts.length; i++) {
|
|
var kv = parts[i].trim().split('=');
|
|
if (kv[0] === COOKIE_NAME && kv[1] === '1') return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
function setElevated(on) {
|
|
if (on) {
|
|
// SameSite=Lax blocks cross-site form-post / image-tag CSRF
|
|
// 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', {
|
|
headers: { 'Accept': 'application/json' },
|
|
credentials: 'same-origin',
|
|
cache: 'no-cache'
|
|
});
|
|
if (!resp.ok) return null;
|
|
return await resp.json();
|
|
} catch (_e) {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
// ── URL toggle: ?admin=true | ?admin=false (typeable anywhere) ──────
|
|
//
|
|
// Admin mode is toggled via a URL query param rather than an on-screen
|
|
// checkbox, so it's reachable from any zddc-server page. The param only
|
|
// SETS the cookie; the cookie is the sticky state (it persists across
|
|
// navigation for its Max-Age window and is what the server reads), so
|
|
// there's no need to keep ?admin= in the URL once applied.
|
|
|
|
// adminParam returns true/false for a recognised ?admin= value, or null
|
|
// when absent / unrecognised (ignored).
|
|
function adminParam() {
|
|
try {
|
|
var v = new URLSearchParams(window.location.search).get('admin');
|
|
if (v === null) return null;
|
|
v = v.toLowerCase();
|
|
if (v === 'true' || v === '1' || v === 'on' || v === 'yes') return true;
|
|
if (v === 'false' || v === '0' || v === 'off' || v === 'no') return false;
|
|
return null;
|
|
} catch (_e) { return null; }
|
|
}
|
|
|
|
// urlWithoutAdmin returns the current URL with the admin param stripped
|
|
// (other params + hash preserved) — what we navigate/replace to so the
|
|
// dirty param isn't bookmarked and Back doesn't re-trigger it.
|
|
function urlWithoutAdmin() {
|
|
var u = new URL(window.location.href);
|
|
u.searchParams.delete('admin');
|
|
var qs = u.searchParams.toString();
|
|
return u.pathname + (qs ? '?' + qs : '') + u.hash;
|
|
}
|
|
|
|
// 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;
|
|
var clean = urlWithoutAdmin();
|
|
try { history.replaceState(history.state, '', clean); } catch (_e) {}
|
|
if (want === isElevated()) return; // already in the requested state
|
|
if (want === true) {
|
|
if (access === undefined) access = await fetchAccess();
|
|
if (!access || !access.can_elevate) return; // silent no-op
|
|
setOn();
|
|
} else {
|
|
setOff();
|
|
}
|
|
}
|
|
|
|
// Page-wide affordances when elevation is active. The toggle alone
|
|
// is easy to miss — admin mode silently bypasses WORM and ACL
|
|
// restrictions, which produces surprising "I shouldn't have been
|
|
// able to do that" moments. A body class + a sticky banner with a
|
|
// one-click disable make the armed state unmistakable.
|
|
function applyArmedChrome(elevated) {
|
|
var b = document.body;
|
|
if (!b) return;
|
|
if (elevated) b.classList.add('is-elevated');
|
|
else b.classList.remove('is-elevated');
|
|
|
|
var banner = document.getElementById('elevation-banner');
|
|
if (elevated) {
|
|
if (!banner) {
|
|
banner = document.createElement('div');
|
|
banner.id = 'elevation-banner';
|
|
banner.className = 'elevation-banner';
|
|
banner.setAttribute('role', 'alert');
|
|
banner.innerHTML =
|
|
'<span class="elevation-banner__dot" aria-hidden="true"></span>'
|
|
+ '<span class="elevation-banner__msg">'
|
|
+ 'Admin mode is on — write access bypasses WORM and ACL safeguards.'
|
|
+ '</span>'
|
|
+ '<button type="button" class="elevation-banner__off" id="elevation-banner-off">'
|
|
+ 'Drop admin'
|
|
+ '</button>';
|
|
document.body.insertBefore(banner, document.body.firstChild);
|
|
var off = banner.querySelector('#elevation-banner-off');
|
|
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
|
|
// `<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() {
|
|
// file:// (offline FS-Access mode) has no server to elevate against
|
|
// — skip the access probe and the toggle entirely.
|
|
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());
|
|
|
|
// One /.profile/access probe drives both decisions: whether to show
|
|
// the toggle (can_elevate) and whether a ?admin=true may arm.
|
|
var access = await fetchAccess();
|
|
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') {
|
|
document.addEventListener('DOMContentLoaded', init);
|
|
} else {
|
|
init();
|
|
}
|
|
|
|
window.zddc.elevation = {
|
|
isElevated: isElevated,
|
|
setElevated: setElevated,
|
|
setOn: setOn,
|
|
setOff: setOff
|
|
};
|
|
})();
|