ZDDC/shared/elevation.js
ZDDC ef849ab3fa feat(shared): replace floating elevation toggle with a header profile menu
Drop the bottom-right floating "Admin mode" switch in favour of a proper
account menu in the header's upper-right (every tool's .header-right).

New shared/profile-menu.{js,css}: a circular avatar button (email initial)
opening a dropdown with the signed-in email, an "Admin mode" item (only for
can_elevate principals — drives elevation.setOn/setOff, drops on leave),
Profile (/.profile), and Access tokens (/.tokens). The panel is portaled to
<body> + position:fixed so it overlays content reliably regardless of the
app's stacking contexts; the button shows a red ring while elevated.

No logout: authentication is the upstream proxy's concern (oauth2-proxy /
Authelia) — ZDDC owns no session, so the menu doesn't render sign-out.

elevation.js keeps the state machine (cookie, armed banner/frame, ephemeral
pagehide-clear, zddc:elevationchange, ?admin= URL) but no longer renders any
control — the profile menu is the UI. elevation.css drops the floating-
toggle styles (keeps banner + frame). All 7 templates drop the dead
elevation-toggle placeholder; all 7 build.sh bundle profile-menu.{js,css}.

Validated in a containerized browser: menu items, links, elevation arming +
armed ring, dropdown overlays content, no floating toggle.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 19:43:43 -05:00

224 lines
9.6 KiB
JavaScript

// 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, 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}.
//
// 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.
//
// 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 (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', {
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);
}
}
async function init() {
// 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 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') {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
}
window.zddc.elevation = {
isElevated: isElevated,
setElevated: setElevated,
setOn: setOn,
setOff: setOff
};
})();