Polish pass after the big refactor in 2d114fc.
== Header elevation slot propagated ==
shared/elevation.{js,css} surface a header checkbox for admins.
30-minute sudo-style cookie window (Max-Age=1800, SameSite=Lax).
Only renders when /.profile/access reports can_elevate=true; quiet
for non-admins. Slot added to all 7 tool templates and concat'd
into all 7 build.sh files; admin in any tool now sees the toggle.
Three text-rename ride-alongs in archive/classifier/transmittal
templates: "Add Local Directory" → "Use Local Directory" (the same
rename that landed in browse earlier in this branch).
== Docs ==
- CLAUDE.md gets an "Admin elevation is sudo-style" paragraph in
the "Things that bite if you forget" section.
- AGENTS.md gets a dedicated "Admin elevation (sudo-style)" section
alongside "Bearer tokens" — same depth as the existing auth docs.
== Helper file splits ==
The retired form editor's shared helpers got bundled into a single
zddc_admin.go in the cleanup; that name is now misleading. Split by
concern:
- admin_helpers.go: hasAnyAdminScope (the only admin-specific helper)
- paths.go: resolvePath, urlPathOf, chainDirs (URL ↔ filesystem path
math — used by several profile / zddc-file handlers)
- profile_assets.go (renamed from zddc_admin_assets.go): custom CSS
pipeline. URL renamed from /.profile/zddc/assets/ → /.profile/assets/
since /.profile/zddc/ no longer hosts an editor.
- treeEntry moves to profilehandler.go (alongside AccessView, its
only consumer).
- writeError moves to profileprojects.go (its only consumer).
== Smell cleanup ==
- zddc.HasAnyAdminGrant(fsRoot, email) — new elevation-independent
primitive that walks the cascade and reports whether email is named
in any admin: list anywhere. Replaces the synthetic-elevated probe
hack in enumerateAccess (`Principal{Email, Elevated: true}` was
"lying" to the elevation gate to ask what it would say). The handler's
hasAnyAdminScope collapses to a 4-line wrapper that gates on
p.Elevated and delegates.
- Access-log middleware records `elevated` per request, so forensics
can distinguish "admin acting as user" from "admin exercising power."
- browse/js/app.js's ?file= deep link walks multi-segment paths. Each
intermediate segment is matched + expanded; the leaf gets
selected/previewed. Auto-shows hidden when any segment starts with
. or _. Silently no-ops on unresolved segments.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
103 lines
4.1 KiB
JavaScript
103 lines
4.1 KiB
JavaScript
// shared/elevation.js — admin elevation toggle.
|
|
//
|
|
// Sudo-style model: admins behave as normal users by default; clicking
|
|
// the header toggle elevates the session so admin escape hatches (WORM
|
|
// bypass, .zddc edit authority, profile admin scaffolds) start firing.
|
|
// State is carried in a `zddc-elevate=1` cookie that the server reads
|
|
// via handler.ACLMiddleware → zddc.Principal{Elevated}.
|
|
//
|
|
// Only renders the toggle when /.profile/access reports the caller has
|
|
// some admin scope — a non-admin sees nothing, which keeps the chrome
|
|
// quiet for the common case. The toggle fades in once access loads so
|
|
// non-admins never even see the affordance flash.
|
|
//
|
|
// Click flow: set/clear the cookie, then reload the page so the server
|
|
// sees the new state on the next render. The reload is intentional —
|
|
// admin scaffolds in tool HTML are server-rendered for some tools, so
|
|
// a soft state flip on the client alone wouldn't reach those.
|
|
(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. 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';
|
|
} else {
|
|
document.cookie = COOKIE_NAME + '=; Path=/; SameSite=Lax; Max-Age=0';
|
|
}
|
|
}
|
|
|
|
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;
|
|
}
|
|
}
|
|
|
|
function render(host, elevated) {
|
|
host.classList.remove('hidden');
|
|
host.innerHTML =
|
|
'<input type="checkbox" id="elevation-checkbox"'
|
|
+ (elevated ? ' checked' : '') + '>'
|
|
+ '<label for="elevation-checkbox" class="elevation-toggle__label">'
|
|
+ 'Admin</label>';
|
|
var cb = host.querySelector('#elevation-checkbox');
|
|
cb.addEventListener('change', function () {
|
|
setElevated(cb.checked);
|
|
// Hard reload so server-rendered admin surfaces (profile
|
|
// page scaffolds, hidden-entry listings) catch up. URL
|
|
// and scroll state are preserved by the browser's normal
|
|
// back-forward cache rules.
|
|
window.location.reload();
|
|
});
|
|
}
|
|
|
|
async function init() {
|
|
var host = document.getElementById('elevation-toggle');
|
|
if (!host) return; // tool doesn't include the slot yet — no-op
|
|
var access = await fetchAccess();
|
|
if (!access) return; // anonymous / endpoint missing — no-op
|
|
// Surface ONLY for users who have admin authority somewhere.
|
|
// /.profile/access ships `can_elevate` as an elevation-
|
|
// INDEPENDENT signal — true for any user named in any admin
|
|
// list, regardless of current cookie state. The other flags
|
|
// (is_super_admin, has_any_admin_scope) reflect EFFECTIVE
|
|
// authority and would be false for an un-elevated admin
|
|
// who hasn't toggled yet — so we can't gate on those.
|
|
if (!access.can_elevate) return;
|
|
render(host, isElevated());
|
|
}
|
|
|
|
if (document.readyState === 'loading') {
|
|
document.addEventListener('DOMContentLoaded', init);
|
|
} else {
|
|
init();
|
|
}
|
|
|
|
window.zddc.elevation = { isElevated: isElevated, setElevated: setElevated };
|
|
})();
|