chore(embedded): cut v0.0.27-beta
Some checks failed
Notify chart dev on beta cut / notify-chart-dev (push) Failing after 8s
Some checks failed
Notify chart dev on beta cut / notify-chart-dev (push) Failing after 8s
This commit is contained in:
parent
0d7feb3468
commit
d5ce4e1230
7 changed files with 2668 additions and 660 deletions
|
|
@ -74,6 +74,20 @@
|
|||
/* Shape */
|
||||
--radius: 4px;
|
||||
|
||||
/* Spacing scale — referenced by the tables tool (tables/css/table.css).
|
||||
Were undefined (var() with no fallback → collapsed to 0), which left
|
||||
table cells unpadded and the table flush to the viewport edges. */
|
||||
--spacing-sm: 0.4rem;
|
||||
--spacing-md: 0.8rem;
|
||||
--spacing-lg: 1.5rem;
|
||||
|
||||
/* Token aliases the tables tool references under --color-*/--radius-*
|
||||
names; map them to the canonical tokens (themed values flow through). */
|
||||
--color-text-muted: var(--text-muted);
|
||||
--color-border: var(--border);
|
||||
--color-bg-elevated: var(--bg-secondary);
|
||||
--radius-sm: var(--radius);
|
||||
|
||||
/* Typography. --font-display covers headings (Source Serif 4 — a refined
|
||||
transitional serif that reads as "engineering / document / serious"
|
||||
without being academic). --font is body UI text (IBM Plex Sans —
|
||||
|
|
@ -855,53 +869,10 @@ body.help-open .app-header {
|
|||
filter: brightness(1.1);
|
||||
}
|
||||
|
||||
/* 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. */
|
||||
|
||||
.elevation-toggle {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.3rem;
|
||||
font-size: 0.78rem;
|
||||
color: var(--text-muted);
|
||||
user-select: none;
|
||||
cursor: pointer;
|
||||
padding: 0.15rem 0.45rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
background: var(--bg);
|
||||
transition: background 0.12s, border-color 0.12s, color 0.12s;
|
||||
}
|
||||
|
||||
.elevation-toggle:hover {
|
||||
background: var(--bg-hover);
|
||||
border-color: var(--border-dark);
|
||||
}
|
||||
|
||||
.elevation-toggle input[type="checkbox"] {
|
||||
margin: 0;
|
||||
cursor: pointer;
|
||||
accent-color: var(--danger);
|
||||
}
|
||||
|
||||
.elevation-toggle__label {
|
||||
cursor: pointer;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
/* Active state — when elevation is ON, the toggle reads as "armed"
|
||||
so the user can't miss that admin powers are currently live.
|
||||
:has(:checked) lets us style the wrapper based on the inner
|
||||
checkbox without JS. */
|
||||
.elevation-toggle:has(input:checked) {
|
||||
background: rgba(220, 53, 69, 0.12);
|
||||
border-color: var(--danger);
|
||||
color: var(--danger);
|
||||
font-weight: 600;
|
||||
}
|
||||
/* shared/elevation.css — page-wide armed chrome for admin mode.
|
||||
The elevate CONTROL is the "Admin mode" item in the shared profile menu
|
||||
(shared/profile-menu.{js,css}); this file only styles the unmistakable
|
||||
"you are elevated" cues: the red viewport frame + the sticky banner. */
|
||||
|
||||
/* Page-wide chrome when admin mode is active. The toggle alone is
|
||||
easy to miss; these add an inescapable visual cue:
|
||||
|
|
@ -978,6 +949,118 @@ body.is-elevated::after {
|
|||
background: rgba(255, 255, 255, 0.3);
|
||||
}
|
||||
|
||||
/* shared/profile-menu.css — header account menu (upper-right).
|
||||
shared/profile-menu.js mounts a button into `.header-right` and toggles
|
||||
a dropdown with the signed-in email, Admin mode, Profile, Access tokens,
|
||||
and Sign out. Server mode only. */
|
||||
|
||||
.profile-menu {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
}
|
||||
|
||||
/* The button: a small circular avatar showing the email initial. */
|
||||
.profile-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0;
|
||||
width: 1.9rem;
|
||||
height: 1.9rem;
|
||||
border-radius: 50%;
|
||||
line-height: 1;
|
||||
}
|
||||
.profile-btn__avatar {
|
||||
font-size: 0.8rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.01em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
/* Armed (admin mode on): a red ring so the elevated state reads from the
|
||||
button even when the menu is closed — pairs with the page banner/frame. */
|
||||
.profile-btn--armed {
|
||||
box-shadow: 0 0 0 2px var(--danger, #dc3545);
|
||||
border-color: var(--danger, #dc3545);
|
||||
color: var(--danger, #dc3545);
|
||||
}
|
||||
|
||||
.profile-menu__panel {
|
||||
display: none;
|
||||
/* Fixed + JS-positioned from the button rect: an absolute panel gets
|
||||
trapped below the content layer by the app's stacking contexts, so
|
||||
anchor it to the viewport instead (profile-menu.js sets top/right). */
|
||||
position: fixed;
|
||||
min-width: 15rem;
|
||||
z-index: 9400; /* above the is-elevated frame (9200) + banner (9100) */
|
||||
background: var(--bg, #fff);
|
||||
border: 1px solid var(--border, #ddd);
|
||||
border-radius: var(--radius, 6px);
|
||||
box-shadow: 0 6px 24px rgba(0, 0, 0, 0.16);
|
||||
padding: 0.3rem;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.profile-menu__panel.open { display: block; }
|
||||
|
||||
.profile-menu__id {
|
||||
padding: 0.35rem 0.55rem 0.45rem;
|
||||
}
|
||||
.profile-menu__email {
|
||||
font-weight: 600;
|
||||
color: var(--text, #222);
|
||||
word-break: break-all;
|
||||
}
|
||||
.profile-menu__role {
|
||||
margin-top: 0.1rem;
|
||||
font-size: 0.72rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
color: var(--danger, #dc3545);
|
||||
}
|
||||
|
||||
.profile-menu__sep {
|
||||
height: 1px;
|
||||
margin: 0.25rem 0;
|
||||
background: var(--border, #eee);
|
||||
}
|
||||
|
||||
.profile-menu__item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
padding: 0.4rem 0.55rem;
|
||||
border-radius: var(--radius, 4px);
|
||||
color: var(--text, #222);
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
background: none;
|
||||
border: none;
|
||||
text-align: left;
|
||||
font: inherit;
|
||||
}
|
||||
.profile-menu__item:hover {
|
||||
background: var(--bg-hover, rgba(0, 0, 0, 0.05));
|
||||
}
|
||||
|
||||
.profile-menu__toggle { cursor: pointer; }
|
||||
.profile-menu__check {
|
||||
margin: 0;
|
||||
cursor: pointer;
|
||||
accent-color: var(--danger, #dc3545);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.profile-menu__toggle-label {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
line-height: 1.25;
|
||||
}
|
||||
.profile-menu__hint {
|
||||
font-size: 0.72rem;
|
||||
color: var(--text-muted, #888);
|
||||
}
|
||||
|
||||
/* shared/logo.css — paired with shared/logo.js. The wrapping anchor
|
||||
inherits the logo's box and adds a subtle hover/focus affordance
|
||||
so it reads as clickable without altering the logo's visual weight. */
|
||||
|
|
@ -2582,18 +2665,12 @@ td[data-field="trackingNumber"] {
|
|||
</svg>
|
||||
<div class="header-title-group">
|
||||
<span class="app-header__title">ZDDC Archive</span>
|
||||
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.27-beta · 2026-06-05 12:41:17 · 382645b</span></span>
|
||||
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.27-beta · 2026-06-08 11:50:20 · 0d7feb3</span></span>
|
||||
</div>
|
||||
<button id="addDirectoryBtn" class="btn btn-primary">Use Local Directory</button>
|
||||
<button id="refreshHeaderBtn" class="btn btn-secondary hidden" title="Refresh Data">⟳</button>
|
||||
</div>
|
||||
<div class="header-right">
|
||||
<!-- Elevation toggle slot. shared/elevation.js fills it
|
||||
when /.profile/access reports the user has admin
|
||||
authority; stays empty + hidden for non-admins so
|
||||
the chrome is quiet for the common case. -->
|
||||
<span id="elevation-toggle" class="elevation-toggle hidden"
|
||||
title="Opt into your admin powers for the next 30 minutes (sudo-style)."></span>
|
||||
<button id="theme-btn" class="btn btn-secondary" title="Theme: auto (follows OS)" aria-label="Theme: auto (follows OS)">◐</button>
|
||||
<button id="help-btn" class="btn btn-secondary" title="Help">?</button>
|
||||
</div>
|
||||
|
|
@ -10855,26 +10932,31 @@ window.app.modules.filtering = {
|
|||
}
|
||||
}());
|
||||
|
||||
// shared/elevation.js — admin elevation via URL toggle.
|
||||
// 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, .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}.
|
||||
// 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}.
|
||||
//
|
||||
// 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.
|
||||
// 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.
|
||||
//
|
||||
// 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';
|
||||
|
||||
|
|
@ -10895,16 +10977,43 @@ window.app.modules.filtering = {
|
|||
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 (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', {
|
||||
|
|
@ -10950,34 +11059,26 @@ window.app.modules.filtering = {
|
|||
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;
|
||||
}
|
||||
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
|
||||
|
|
@ -11008,10 +11109,7 @@ window.app.modules.filtering = {
|
|||
+ '</button>';
|
||||
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);
|
||||
|
|
@ -11019,16 +11117,30 @@ window.app.modules.filtering = {
|
|||
}
|
||||
|
||||
async function init() {
|
||||
// Apply (or tear down) the red border + banner from the cookie on
|
||||
// every page load — admin mode is toggled by URL, but the armed
|
||||
// chrome must surface everywhere so the user can't accidentally
|
||||
// write through an elevated context on a page they didn't toggle.
|
||||
// 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 zddc-server URL. There's
|
||||
// no on-screen toggle anymore — the URL is the enable path and the
|
||||
// red banner's "Drop admin" button is the one-click disable.
|
||||
// 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') {
|
||||
|
|
@ -11037,7 +11149,178 @@ window.app.modules.filtering = {
|
|||
init();
|
||||
}
|
||||
|
||||
window.zddc.elevation = { isElevated: isElevated, setElevated: setElevated };
|
||||
window.zddc.elevation = {
|
||||
isElevated: isElevated,
|
||||
setElevated: setElevated,
|
||||
setOn: setOn,
|
||||
setOff: setOff
|
||||
};
|
||||
})();
|
||||
|
||||
// shared/profile-menu.js — account menu in the header's upper-right.
|
||||
//
|
||||
// Replaces the old floating elevation toggle. Admin mode is now one item in
|
||||
// this dropdown, alongside the signed-in email, Profile, and Access tokens.
|
||||
// Mounts into the tool header's `.header-right` cluster (every tool ships one)
|
||||
// and drives elevation via window.zddc.elevation, so the cookie / armed-chrome
|
||||
// / ephemeral state machine stays in shared/elevation.js.
|
||||
//
|
||||
// No logout: authentication is the upstream proxy's concern (oauth2-proxy /
|
||||
// Authelia) — ZDDC owns no session, so it doesn't touch sign-out.
|
||||
//
|
||||
// Server mode only: it reads /.profile/access for the email + can_elevate.
|
||||
// On file:// (offline FS-Access mode) there's no server account, so nothing
|
||||
// renders.
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
if (!window.zddc) window.zddc = {};
|
||||
if (window.zddc.profileMenu) return;
|
||||
|
||||
function el(tag, cls, text) {
|
||||
var e = document.createElement(tag);
|
||||
if (cls) e.className = cls;
|
||||
if (text != null) e.textContent = text;
|
||||
return e;
|
||||
}
|
||||
|
||||
async function fetchAccess() {
|
||||
try {
|
||||
var r = await fetch('/.profile/access', {
|
||||
headers: { Accept: 'application/json' },
|
||||
credentials: 'same-origin',
|
||||
cache: 'no-cache'
|
||||
});
|
||||
if (!r.ok) return null;
|
||||
return await r.json();
|
||||
} catch (_e) { return null; }
|
||||
}
|
||||
|
||||
var elevation = null;
|
||||
var panelEl = null, btnEl = null, adminInput = null;
|
||||
|
||||
function isElevated() {
|
||||
return !!(elevation && elevation.isElevated && elevation.isElevated());
|
||||
}
|
||||
|
||||
// Keep the button's armed ring + the menu checkbox in lockstep with the
|
||||
// elevation cookie (flipped here, by ?admin=, or by the banner's Drop).
|
||||
function syncArmed() {
|
||||
if (btnEl) btnEl.classList.toggle('profile-btn--armed', isElevated());
|
||||
if (adminInput) adminInput.checked = isElevated();
|
||||
}
|
||||
|
||||
function closeMenu() {
|
||||
if (panelEl) panelEl.classList.remove('open');
|
||||
if (btnEl) btnEl.setAttribute('aria-expanded', 'false');
|
||||
}
|
||||
// The panel is position:fixed (to escape the app's stacking contexts), so
|
||||
// anchor it to the button rect — top just below it, right-aligned.
|
||||
function positionPanel() {
|
||||
var r = btnEl.getBoundingClientRect();
|
||||
panelEl.style.top = (r.bottom + 4) + 'px';
|
||||
panelEl.style.right = Math.max(4, window.innerWidth - r.right) + 'px';
|
||||
panelEl.style.left = 'auto';
|
||||
}
|
||||
function toggleMenu() {
|
||||
if (!panelEl) return;
|
||||
var open = panelEl.classList.toggle('open');
|
||||
if (open) positionPanel();
|
||||
btnEl.setAttribute('aria-expanded', open ? 'true' : 'false');
|
||||
}
|
||||
|
||||
function linkItem(text, href) {
|
||||
var a = el('a', 'profile-menu__item', text);
|
||||
a.href = href;
|
||||
a.setAttribute('role', 'menuitem');
|
||||
return a;
|
||||
}
|
||||
|
||||
function build(access) {
|
||||
var wrap = el('div', 'profile-menu');
|
||||
|
||||
btnEl = el('button', 'btn btn-secondary profile-btn');
|
||||
btnEl.type = 'button';
|
||||
btnEl.id = 'profile-btn';
|
||||
btnEl.title = 'Account: ' + (access.email || 'signed in');
|
||||
btnEl.setAttribute('aria-haspopup', 'menu');
|
||||
btnEl.setAttribute('aria-expanded', 'false');
|
||||
var initial = ((access.email || '?').trim().charAt(0) || '?').toUpperCase();
|
||||
btnEl.appendChild(el('span', 'profile-btn__avatar', initial));
|
||||
btnEl.addEventListener('click', function (e) { e.stopPropagation(); toggleMenu(); });
|
||||
wrap.appendChild(btnEl);
|
||||
|
||||
panelEl = el('div', 'profile-menu__panel');
|
||||
panelEl.setAttribute('role', 'menu');
|
||||
|
||||
var id = el('div', 'profile-menu__id');
|
||||
id.appendChild(el('div', 'profile-menu__email', access.email || '(anonymous)'));
|
||||
if (access.is_super_admin) id.appendChild(el('div', 'profile-menu__role', 'super admin'));
|
||||
else if (access.has_any_admin_scope) id.appendChild(el('div', 'profile-menu__role', 'admin'));
|
||||
panelEl.appendChild(id);
|
||||
panelEl.appendChild(el('div', 'profile-menu__sep'));
|
||||
|
||||
// Admin mode — only offered to principals who actually have admin
|
||||
// authority somewhere (can_elevate). Drops automatically on leave.
|
||||
if (access.can_elevate && elevation) {
|
||||
var row = el('label', 'profile-menu__item profile-menu__toggle');
|
||||
adminInput = document.createElement('input');
|
||||
adminInput.type = 'checkbox';
|
||||
adminInput.className = 'profile-menu__check';
|
||||
adminInput.checked = isElevated();
|
||||
adminInput.addEventListener('change', function () {
|
||||
if (adminInput.checked) elevation.setOn(); else elevation.setOff();
|
||||
});
|
||||
row.appendChild(adminInput);
|
||||
var txt = el('span', 'profile-menu__toggle-label');
|
||||
txt.appendChild(el('span', null, 'Admin mode'));
|
||||
txt.appendChild(el('span', 'profile-menu__hint', 'bypass WORM/ACL — drops when you leave the page'));
|
||||
row.appendChild(txt);
|
||||
panelEl.appendChild(row);
|
||||
panelEl.appendChild(el('div', 'profile-menu__sep'));
|
||||
}
|
||||
|
||||
panelEl.appendChild(linkItem('Profile', '/.profile'));
|
||||
panelEl.appendChild(linkItem('Access tokens', '/.tokens'));
|
||||
// No "Sign out": authentication is the upstream proxy's concern
|
||||
// (oauth2-proxy / Authelia). ZDDC doesn't own a session, so it
|
||||
// doesn't render a logout affordance.
|
||||
|
||||
// Portal the panel to <body>, not inside the header: the app's
|
||||
// layout creates stacking contexts that trap even a fixed+high
|
||||
// z-index panel below the content. As a direct body child it sits in
|
||||
// the root stacking context and reliably overlays everything.
|
||||
// position:fixed + positionPanel() keep it anchored to the button.
|
||||
document.body.appendChild(panelEl);
|
||||
return wrap;
|
||||
}
|
||||
|
||||
async function init() {
|
||||
if (window.location.protocol === 'file:') return;
|
||||
elevation = window.zddc.elevation || null;
|
||||
var access = await fetchAccess();
|
||||
if (!access || !access.email) return; // unauthenticated / non-zddc backend
|
||||
var host = document.querySelector('.header-right');
|
||||
if (!host) return;
|
||||
|
||||
host.appendChild(build(access));
|
||||
syncArmed();
|
||||
|
||||
document.addEventListener('click', function (e) {
|
||||
if (panelEl && panelEl.classList.contains('open')
|
||||
&& !panelEl.contains(e.target) && !btnEl.contains(e.target)) closeMenu();
|
||||
});
|
||||
document.addEventListener('keydown', function (e) { if (e.key === 'Escape') closeMenu(); });
|
||||
window.addEventListener('zddc:elevationchange', syncArmed);
|
||||
|
||||
window.zddc.profileMenu = { close: closeMenu };
|
||||
}
|
||||
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', init);
|
||||
} else {
|
||||
init();
|
||||
}
|
||||
})();
|
||||
|
||||
// shared/cap.js — client-side capability helpers for permission gating.
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -74,6 +74,20 @@
|
|||
/* Shape */
|
||||
--radius: 4px;
|
||||
|
||||
/* Spacing scale — referenced by the tables tool (tables/css/table.css).
|
||||
Were undefined (var() with no fallback → collapsed to 0), which left
|
||||
table cells unpadded and the table flush to the viewport edges. */
|
||||
--spacing-sm: 0.4rem;
|
||||
--spacing-md: 0.8rem;
|
||||
--spacing-lg: 1.5rem;
|
||||
|
||||
/* Token aliases the tables tool references under --color-*/--radius-*
|
||||
names; map them to the canonical tokens (themed values flow through). */
|
||||
--color-text-muted: var(--text-muted);
|
||||
--color-border: var(--border);
|
||||
--color-bg-elevated: var(--bg-secondary);
|
||||
--radius-sm: var(--radius);
|
||||
|
||||
/* Typography. --font-display covers headings (Source Serif 4 — a refined
|
||||
transitional serif that reads as "engineering / document / serious"
|
||||
without being academic). --font is body UI text (IBM Plex Sans —
|
||||
|
|
@ -855,53 +869,10 @@ body.help-open .app-header {
|
|||
filter: brightness(1.1);
|
||||
}
|
||||
|
||||
/* 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. */
|
||||
|
||||
.elevation-toggle {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.3rem;
|
||||
font-size: 0.78rem;
|
||||
color: var(--text-muted);
|
||||
user-select: none;
|
||||
cursor: pointer;
|
||||
padding: 0.15rem 0.45rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
background: var(--bg);
|
||||
transition: background 0.12s, border-color 0.12s, color 0.12s;
|
||||
}
|
||||
|
||||
.elevation-toggle:hover {
|
||||
background: var(--bg-hover);
|
||||
border-color: var(--border-dark);
|
||||
}
|
||||
|
||||
.elevation-toggle input[type="checkbox"] {
|
||||
margin: 0;
|
||||
cursor: pointer;
|
||||
accent-color: var(--danger);
|
||||
}
|
||||
|
||||
.elevation-toggle__label {
|
||||
cursor: pointer;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
/* Active state — when elevation is ON, the toggle reads as "armed"
|
||||
so the user can't miss that admin powers are currently live.
|
||||
:has(:checked) lets us style the wrapper based on the inner
|
||||
checkbox without JS. */
|
||||
.elevation-toggle:has(input:checked) {
|
||||
background: rgba(220, 53, 69, 0.12);
|
||||
border-color: var(--danger);
|
||||
color: var(--danger);
|
||||
font-weight: 600;
|
||||
}
|
||||
/* shared/elevation.css — page-wide armed chrome for admin mode.
|
||||
The elevate CONTROL is the "Admin mode" item in the shared profile menu
|
||||
(shared/profile-menu.{js,css}); this file only styles the unmistakable
|
||||
"you are elevated" cues: the red viewport frame + the sticky banner. */
|
||||
|
||||
/* Page-wide chrome when admin mode is active. The toggle alone is
|
||||
easy to miss; these add an inescapable visual cue:
|
||||
|
|
@ -978,6 +949,118 @@ body.is-elevated::after {
|
|||
background: rgba(255, 255, 255, 0.3);
|
||||
}
|
||||
|
||||
/* shared/profile-menu.css — header account menu (upper-right).
|
||||
shared/profile-menu.js mounts a button into `.header-right` and toggles
|
||||
a dropdown with the signed-in email, Admin mode, Profile, Access tokens,
|
||||
and Sign out. Server mode only. */
|
||||
|
||||
.profile-menu {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
}
|
||||
|
||||
/* The button: a small circular avatar showing the email initial. */
|
||||
.profile-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0;
|
||||
width: 1.9rem;
|
||||
height: 1.9rem;
|
||||
border-radius: 50%;
|
||||
line-height: 1;
|
||||
}
|
||||
.profile-btn__avatar {
|
||||
font-size: 0.8rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.01em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
/* Armed (admin mode on): a red ring so the elevated state reads from the
|
||||
button even when the menu is closed — pairs with the page banner/frame. */
|
||||
.profile-btn--armed {
|
||||
box-shadow: 0 0 0 2px var(--danger, #dc3545);
|
||||
border-color: var(--danger, #dc3545);
|
||||
color: var(--danger, #dc3545);
|
||||
}
|
||||
|
||||
.profile-menu__panel {
|
||||
display: none;
|
||||
/* Fixed + JS-positioned from the button rect: an absolute panel gets
|
||||
trapped below the content layer by the app's stacking contexts, so
|
||||
anchor it to the viewport instead (profile-menu.js sets top/right). */
|
||||
position: fixed;
|
||||
min-width: 15rem;
|
||||
z-index: 9400; /* above the is-elevated frame (9200) + banner (9100) */
|
||||
background: var(--bg, #fff);
|
||||
border: 1px solid var(--border, #ddd);
|
||||
border-radius: var(--radius, 6px);
|
||||
box-shadow: 0 6px 24px rgba(0, 0, 0, 0.16);
|
||||
padding: 0.3rem;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.profile-menu__panel.open { display: block; }
|
||||
|
||||
.profile-menu__id {
|
||||
padding: 0.35rem 0.55rem 0.45rem;
|
||||
}
|
||||
.profile-menu__email {
|
||||
font-weight: 600;
|
||||
color: var(--text, #222);
|
||||
word-break: break-all;
|
||||
}
|
||||
.profile-menu__role {
|
||||
margin-top: 0.1rem;
|
||||
font-size: 0.72rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
color: var(--danger, #dc3545);
|
||||
}
|
||||
|
||||
.profile-menu__sep {
|
||||
height: 1px;
|
||||
margin: 0.25rem 0;
|
||||
background: var(--border, #eee);
|
||||
}
|
||||
|
||||
.profile-menu__item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
padding: 0.4rem 0.55rem;
|
||||
border-radius: var(--radius, 4px);
|
||||
color: var(--text, #222);
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
background: none;
|
||||
border: none;
|
||||
text-align: left;
|
||||
font: inherit;
|
||||
}
|
||||
.profile-menu__item:hover {
|
||||
background: var(--bg-hover, rgba(0, 0, 0, 0.05));
|
||||
}
|
||||
|
||||
.profile-menu__toggle { cursor: pointer; }
|
||||
.profile-menu__check {
|
||||
margin: 0;
|
||||
cursor: pointer;
|
||||
accent-color: var(--danger, #dc3545);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.profile-menu__toggle-label {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
line-height: 1.25;
|
||||
}
|
||||
.profile-menu__hint {
|
||||
font-size: 0.72rem;
|
||||
color: var(--text-muted, #888);
|
||||
}
|
||||
|
||||
/* shared/logo.css — paired with shared/logo.js. The wrapping anchor
|
||||
inherits the logo's box and adds a subtle hover/focus affordance
|
||||
so it reads as clickable without altering the logo's visual weight. */
|
||||
|
|
@ -1793,18 +1876,12 @@ body.is-elevated::after {
|
|||
</svg>
|
||||
<div class="header-title-group">
|
||||
<span class="app-header__title">ZDDC Classifier</span>
|
||||
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.27-beta · 2026-06-05 12:41:17 · 382645b</span></span>
|
||||
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.27-beta · 2026-06-08 11:50:20 · 0d7feb3</span></span>
|
||||
</div>
|
||||
<button id="addDirectoryBtn" class="btn btn-primary">Use Local Directory</button>
|
||||
<button id="refreshHeaderBtn" class="btn btn-secondary hidden" title="Refresh and rescan directory" aria-label="Refresh" style="font-size:1.1rem;">⟳</button>
|
||||
</div>
|
||||
<div class="header-right">
|
||||
<!-- Elevation toggle slot. shared/elevation.js fills it
|
||||
when /.profile/access reports the user has admin
|
||||
authority; stays empty + hidden for non-admins so
|
||||
the chrome is quiet for the common case. -->
|
||||
<span id="elevation-toggle" class="elevation-toggle hidden"
|
||||
title="Opt into your admin powers for the next 30 minutes (sudo-style)."></span>
|
||||
<button id="theme-btn" class="btn btn-secondary" title="Theme: auto (follows OS)" aria-label="Theme: auto (follows OS)">◐</button>
|
||||
<button id="help-btn" class="btn btn-secondary" title="Help">?</button>
|
||||
</div>
|
||||
|
|
@ -9979,26 +10056,31 @@ X.B(E,Y);return E}return J}())
|
|||
}
|
||||
}());
|
||||
|
||||
// shared/elevation.js — admin elevation via URL toggle.
|
||||
// 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, .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}.
|
||||
// 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}.
|
||||
//
|
||||
// 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.
|
||||
// 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.
|
||||
//
|
||||
// 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';
|
||||
|
||||
|
|
@ -10019,16 +10101,43 @@ X.B(E,Y);return E}return J}())
|
|||
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 (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', {
|
||||
|
|
@ -10074,34 +10183,26 @@ X.B(E,Y);return E}return J}())
|
|||
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;
|
||||
}
|
||||
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
|
||||
|
|
@ -10132,10 +10233,7 @@ X.B(E,Y);return E}return J}())
|
|||
+ '</button>';
|
||||
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);
|
||||
|
|
@ -10143,16 +10241,30 @@ X.B(E,Y);return E}return J}())
|
|||
}
|
||||
|
||||
async function init() {
|
||||
// Apply (or tear down) the red border + banner from the cookie on
|
||||
// every page load — admin mode is toggled by URL, but the armed
|
||||
// chrome must surface everywhere so the user can't accidentally
|
||||
// write through an elevated context on a page they didn't toggle.
|
||||
// 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 zddc-server URL. There's
|
||||
// no on-screen toggle anymore — the URL is the enable path and the
|
||||
// red banner's "Drop admin" button is the one-click disable.
|
||||
// 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') {
|
||||
|
|
@ -10161,7 +10273,178 @@ X.B(E,Y);return E}return J}())
|
|||
init();
|
||||
}
|
||||
|
||||
window.zddc.elevation = { isElevated: isElevated, setElevated: setElevated };
|
||||
window.zddc.elevation = {
|
||||
isElevated: isElevated,
|
||||
setElevated: setElevated,
|
||||
setOn: setOn,
|
||||
setOff: setOff
|
||||
};
|
||||
})();
|
||||
|
||||
// shared/profile-menu.js — account menu in the header's upper-right.
|
||||
//
|
||||
// Replaces the old floating elevation toggle. Admin mode is now one item in
|
||||
// this dropdown, alongside the signed-in email, Profile, and Access tokens.
|
||||
// Mounts into the tool header's `.header-right` cluster (every tool ships one)
|
||||
// and drives elevation via window.zddc.elevation, so the cookie / armed-chrome
|
||||
// / ephemeral state machine stays in shared/elevation.js.
|
||||
//
|
||||
// No logout: authentication is the upstream proxy's concern (oauth2-proxy /
|
||||
// Authelia) — ZDDC owns no session, so it doesn't touch sign-out.
|
||||
//
|
||||
// Server mode only: it reads /.profile/access for the email + can_elevate.
|
||||
// On file:// (offline FS-Access mode) there's no server account, so nothing
|
||||
// renders.
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
if (!window.zddc) window.zddc = {};
|
||||
if (window.zddc.profileMenu) return;
|
||||
|
||||
function el(tag, cls, text) {
|
||||
var e = document.createElement(tag);
|
||||
if (cls) e.className = cls;
|
||||
if (text != null) e.textContent = text;
|
||||
return e;
|
||||
}
|
||||
|
||||
async function fetchAccess() {
|
||||
try {
|
||||
var r = await fetch('/.profile/access', {
|
||||
headers: { Accept: 'application/json' },
|
||||
credentials: 'same-origin',
|
||||
cache: 'no-cache'
|
||||
});
|
||||
if (!r.ok) return null;
|
||||
return await r.json();
|
||||
} catch (_e) { return null; }
|
||||
}
|
||||
|
||||
var elevation = null;
|
||||
var panelEl = null, btnEl = null, adminInput = null;
|
||||
|
||||
function isElevated() {
|
||||
return !!(elevation && elevation.isElevated && elevation.isElevated());
|
||||
}
|
||||
|
||||
// Keep the button's armed ring + the menu checkbox in lockstep with the
|
||||
// elevation cookie (flipped here, by ?admin=, or by the banner's Drop).
|
||||
function syncArmed() {
|
||||
if (btnEl) btnEl.classList.toggle('profile-btn--armed', isElevated());
|
||||
if (adminInput) adminInput.checked = isElevated();
|
||||
}
|
||||
|
||||
function closeMenu() {
|
||||
if (panelEl) panelEl.classList.remove('open');
|
||||
if (btnEl) btnEl.setAttribute('aria-expanded', 'false');
|
||||
}
|
||||
// The panel is position:fixed (to escape the app's stacking contexts), so
|
||||
// anchor it to the button rect — top just below it, right-aligned.
|
||||
function positionPanel() {
|
||||
var r = btnEl.getBoundingClientRect();
|
||||
panelEl.style.top = (r.bottom + 4) + 'px';
|
||||
panelEl.style.right = Math.max(4, window.innerWidth - r.right) + 'px';
|
||||
panelEl.style.left = 'auto';
|
||||
}
|
||||
function toggleMenu() {
|
||||
if (!panelEl) return;
|
||||
var open = panelEl.classList.toggle('open');
|
||||
if (open) positionPanel();
|
||||
btnEl.setAttribute('aria-expanded', open ? 'true' : 'false');
|
||||
}
|
||||
|
||||
function linkItem(text, href) {
|
||||
var a = el('a', 'profile-menu__item', text);
|
||||
a.href = href;
|
||||
a.setAttribute('role', 'menuitem');
|
||||
return a;
|
||||
}
|
||||
|
||||
function build(access) {
|
||||
var wrap = el('div', 'profile-menu');
|
||||
|
||||
btnEl = el('button', 'btn btn-secondary profile-btn');
|
||||
btnEl.type = 'button';
|
||||
btnEl.id = 'profile-btn';
|
||||
btnEl.title = 'Account: ' + (access.email || 'signed in');
|
||||
btnEl.setAttribute('aria-haspopup', 'menu');
|
||||
btnEl.setAttribute('aria-expanded', 'false');
|
||||
var initial = ((access.email || '?').trim().charAt(0) || '?').toUpperCase();
|
||||
btnEl.appendChild(el('span', 'profile-btn__avatar', initial));
|
||||
btnEl.addEventListener('click', function (e) { e.stopPropagation(); toggleMenu(); });
|
||||
wrap.appendChild(btnEl);
|
||||
|
||||
panelEl = el('div', 'profile-menu__panel');
|
||||
panelEl.setAttribute('role', 'menu');
|
||||
|
||||
var id = el('div', 'profile-menu__id');
|
||||
id.appendChild(el('div', 'profile-menu__email', access.email || '(anonymous)'));
|
||||
if (access.is_super_admin) id.appendChild(el('div', 'profile-menu__role', 'super admin'));
|
||||
else if (access.has_any_admin_scope) id.appendChild(el('div', 'profile-menu__role', 'admin'));
|
||||
panelEl.appendChild(id);
|
||||
panelEl.appendChild(el('div', 'profile-menu__sep'));
|
||||
|
||||
// Admin mode — only offered to principals who actually have admin
|
||||
// authority somewhere (can_elevate). Drops automatically on leave.
|
||||
if (access.can_elevate && elevation) {
|
||||
var row = el('label', 'profile-menu__item profile-menu__toggle');
|
||||
adminInput = document.createElement('input');
|
||||
adminInput.type = 'checkbox';
|
||||
adminInput.className = 'profile-menu__check';
|
||||
adminInput.checked = isElevated();
|
||||
adminInput.addEventListener('change', function () {
|
||||
if (adminInput.checked) elevation.setOn(); else elevation.setOff();
|
||||
});
|
||||
row.appendChild(adminInput);
|
||||
var txt = el('span', 'profile-menu__toggle-label');
|
||||
txt.appendChild(el('span', null, 'Admin mode'));
|
||||
txt.appendChild(el('span', 'profile-menu__hint', 'bypass WORM/ACL — drops when you leave the page'));
|
||||
row.appendChild(txt);
|
||||
panelEl.appendChild(row);
|
||||
panelEl.appendChild(el('div', 'profile-menu__sep'));
|
||||
}
|
||||
|
||||
panelEl.appendChild(linkItem('Profile', '/.profile'));
|
||||
panelEl.appendChild(linkItem('Access tokens', '/.tokens'));
|
||||
// No "Sign out": authentication is the upstream proxy's concern
|
||||
// (oauth2-proxy / Authelia). ZDDC doesn't own a session, so it
|
||||
// doesn't render a logout affordance.
|
||||
|
||||
// Portal the panel to <body>, not inside the header: the app's
|
||||
// layout creates stacking contexts that trap even a fixed+high
|
||||
// z-index panel below the content. As a direct body child it sits in
|
||||
// the root stacking context and reliably overlays everything.
|
||||
// position:fixed + positionPanel() keep it anchored to the button.
|
||||
document.body.appendChild(panelEl);
|
||||
return wrap;
|
||||
}
|
||||
|
||||
async function init() {
|
||||
if (window.location.protocol === 'file:') return;
|
||||
elevation = window.zddc.elevation || null;
|
||||
var access = await fetchAccess();
|
||||
if (!access || !access.email) return; // unauthenticated / non-zddc backend
|
||||
var host = document.querySelector('.header-right');
|
||||
if (!host) return;
|
||||
|
||||
host.appendChild(build(access));
|
||||
syncArmed();
|
||||
|
||||
document.addEventListener('click', function (e) {
|
||||
if (panelEl && panelEl.classList.contains('open')
|
||||
&& !panelEl.contains(e.target) && !btnEl.contains(e.target)) closeMenu();
|
||||
});
|
||||
document.addEventListener('keydown', function (e) { if (e.key === 'Escape') closeMenu(); });
|
||||
window.addEventListener('zddc:elevationchange', syncArmed);
|
||||
|
||||
window.zddc.profileMenu = { close: closeMenu };
|
||||
}
|
||||
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', init);
|
||||
} else {
|
||||
init();
|
||||
}
|
||||
})();
|
||||
|
||||
// shared/cap.js — client-side capability helpers for permission gating.
|
||||
|
|
|
|||
|
|
@ -74,6 +74,20 @@
|
|||
/* Shape */
|
||||
--radius: 4px;
|
||||
|
||||
/* Spacing scale — referenced by the tables tool (tables/css/table.css).
|
||||
Were undefined (var() with no fallback → collapsed to 0), which left
|
||||
table cells unpadded and the table flush to the viewport edges. */
|
||||
--spacing-sm: 0.4rem;
|
||||
--spacing-md: 0.8rem;
|
||||
--spacing-lg: 1.5rem;
|
||||
|
||||
/* Token aliases the tables tool references under --color-*/--radius-*
|
||||
names; map them to the canonical tokens (themed values flow through). */
|
||||
--color-text-muted: var(--text-muted);
|
||||
--color-border: var(--border);
|
||||
--color-bg-elevated: var(--bg-secondary);
|
||||
--radius-sm: var(--radius);
|
||||
|
||||
/* Typography. --font-display covers headings (Source Serif 4 — a refined
|
||||
transitional serif that reads as "engineering / document / serious"
|
||||
without being academic). --font is body UI text (IBM Plex Sans —
|
||||
|
|
@ -855,53 +869,10 @@ body.help-open .app-header {
|
|||
filter: brightness(1.1);
|
||||
}
|
||||
|
||||
/* 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. */
|
||||
|
||||
.elevation-toggle {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.3rem;
|
||||
font-size: 0.78rem;
|
||||
color: var(--text-muted);
|
||||
user-select: none;
|
||||
cursor: pointer;
|
||||
padding: 0.15rem 0.45rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
background: var(--bg);
|
||||
transition: background 0.12s, border-color 0.12s, color 0.12s;
|
||||
}
|
||||
|
||||
.elevation-toggle:hover {
|
||||
background: var(--bg-hover);
|
||||
border-color: var(--border-dark);
|
||||
}
|
||||
|
||||
.elevation-toggle input[type="checkbox"] {
|
||||
margin: 0;
|
||||
cursor: pointer;
|
||||
accent-color: var(--danger);
|
||||
}
|
||||
|
||||
.elevation-toggle__label {
|
||||
cursor: pointer;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
/* Active state — when elevation is ON, the toggle reads as "armed"
|
||||
so the user can't miss that admin powers are currently live.
|
||||
:has(:checked) lets us style the wrapper based on the inner
|
||||
checkbox without JS. */
|
||||
.elevation-toggle:has(input:checked) {
|
||||
background: rgba(220, 53, 69, 0.12);
|
||||
border-color: var(--danger);
|
||||
color: var(--danger);
|
||||
font-weight: 600;
|
||||
}
|
||||
/* shared/elevation.css — page-wide armed chrome for admin mode.
|
||||
The elevate CONTROL is the "Admin mode" item in the shared profile menu
|
||||
(shared/profile-menu.{js,css}); this file only styles the unmistakable
|
||||
"you are elevated" cues: the red viewport frame + the sticky banner. */
|
||||
|
||||
/* Page-wide chrome when admin mode is active. The toggle alone is
|
||||
easy to miss; these add an inescapable visual cue:
|
||||
|
|
@ -978,6 +949,118 @@ body.is-elevated::after {
|
|||
background: rgba(255, 255, 255, 0.3);
|
||||
}
|
||||
|
||||
/* shared/profile-menu.css — header account menu (upper-right).
|
||||
shared/profile-menu.js mounts a button into `.header-right` and toggles
|
||||
a dropdown with the signed-in email, Admin mode, Profile, Access tokens,
|
||||
and Sign out. Server mode only. */
|
||||
|
||||
.profile-menu {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
}
|
||||
|
||||
/* The button: a small circular avatar showing the email initial. */
|
||||
.profile-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0;
|
||||
width: 1.9rem;
|
||||
height: 1.9rem;
|
||||
border-radius: 50%;
|
||||
line-height: 1;
|
||||
}
|
||||
.profile-btn__avatar {
|
||||
font-size: 0.8rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.01em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
/* Armed (admin mode on): a red ring so the elevated state reads from the
|
||||
button even when the menu is closed — pairs with the page banner/frame. */
|
||||
.profile-btn--armed {
|
||||
box-shadow: 0 0 0 2px var(--danger, #dc3545);
|
||||
border-color: var(--danger, #dc3545);
|
||||
color: var(--danger, #dc3545);
|
||||
}
|
||||
|
||||
.profile-menu__panel {
|
||||
display: none;
|
||||
/* Fixed + JS-positioned from the button rect: an absolute panel gets
|
||||
trapped below the content layer by the app's stacking contexts, so
|
||||
anchor it to the viewport instead (profile-menu.js sets top/right). */
|
||||
position: fixed;
|
||||
min-width: 15rem;
|
||||
z-index: 9400; /* above the is-elevated frame (9200) + banner (9100) */
|
||||
background: var(--bg, #fff);
|
||||
border: 1px solid var(--border, #ddd);
|
||||
border-radius: var(--radius, 6px);
|
||||
box-shadow: 0 6px 24px rgba(0, 0, 0, 0.16);
|
||||
padding: 0.3rem;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.profile-menu__panel.open { display: block; }
|
||||
|
||||
.profile-menu__id {
|
||||
padding: 0.35rem 0.55rem 0.45rem;
|
||||
}
|
||||
.profile-menu__email {
|
||||
font-weight: 600;
|
||||
color: var(--text, #222);
|
||||
word-break: break-all;
|
||||
}
|
||||
.profile-menu__role {
|
||||
margin-top: 0.1rem;
|
||||
font-size: 0.72rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
color: var(--danger, #dc3545);
|
||||
}
|
||||
|
||||
.profile-menu__sep {
|
||||
height: 1px;
|
||||
margin: 0.25rem 0;
|
||||
background: var(--border, #eee);
|
||||
}
|
||||
|
||||
.profile-menu__item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
padding: 0.4rem 0.55rem;
|
||||
border-radius: var(--radius, 4px);
|
||||
color: var(--text, #222);
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
background: none;
|
||||
border: none;
|
||||
text-align: left;
|
||||
font: inherit;
|
||||
}
|
||||
.profile-menu__item:hover {
|
||||
background: var(--bg-hover, rgba(0, 0, 0, 0.05));
|
||||
}
|
||||
|
||||
.profile-menu__toggle { cursor: pointer; }
|
||||
.profile-menu__check {
|
||||
margin: 0;
|
||||
cursor: pointer;
|
||||
accent-color: var(--danger, #dc3545);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.profile-menu__toggle-label {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
line-height: 1.25;
|
||||
}
|
||||
.profile-menu__hint {
|
||||
font-size: 0.72rem;
|
||||
color: var(--text-muted, #888);
|
||||
}
|
||||
|
||||
/* shared/logo.css — paired with shared/logo.js. The wrapping anchor
|
||||
inherits the logo's box and adds a subtle hover/focus affordance
|
||||
so it reads as clickable without altering the logo's visual weight. */
|
||||
|
|
@ -1536,16 +1619,10 @@ body {
|
|||
</svg>
|
||||
<div class="header-title-group">
|
||||
<span class="app-header__title">ZDDC</span>
|
||||
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.27-beta · 2026-06-05 12:41:17 · 382645b</span></span>
|
||||
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.27-beta · 2026-06-08 11:50:20 · 0d7feb3</span></span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="header-right">
|
||||
<!-- Elevation toggle slot. shared/elevation.js fills it
|
||||
when /.profile/access reports the user has admin
|
||||
authority; stays empty + hidden for non-admins so
|
||||
the chrome is quiet for the common case. -->
|
||||
<span id="elevation-toggle" class="elevation-toggle hidden"
|
||||
title="Opt into your admin powers for the next 30 minutes (sudo-style)."></span>
|
||||
<button id="theme-btn" class="btn btn-secondary" title="Theme: auto (follows OS)" aria-label="Theme: auto (follows OS)">◐</button>
|
||||
<button id="help-btn" class="btn btn-secondary" title="Help" aria-label="Help">?</button>
|
||||
</div>
|
||||
|
|
@ -2546,26 +2623,31 @@ body {
|
|||
}
|
||||
}());
|
||||
|
||||
// shared/elevation.js — admin elevation via URL toggle.
|
||||
// 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, .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}.
|
||||
// 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}.
|
||||
//
|
||||
// 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.
|
||||
// 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.
|
||||
//
|
||||
// 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';
|
||||
|
||||
|
|
@ -2586,16 +2668,43 @@ body {
|
|||
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 (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', {
|
||||
|
|
@ -2641,34 +2750,26 @@ body {
|
|||
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;
|
||||
}
|
||||
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
|
||||
|
|
@ -2699,10 +2800,7 @@ body {
|
|||
+ '</button>';
|
||||
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);
|
||||
|
|
@ -2710,16 +2808,30 @@ body {
|
|||
}
|
||||
|
||||
async function init() {
|
||||
// Apply (or tear down) the red border + banner from the cookie on
|
||||
// every page load — admin mode is toggled by URL, but the armed
|
||||
// chrome must surface everywhere so the user can't accidentally
|
||||
// write through an elevated context on a page they didn't toggle.
|
||||
// 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 zddc-server URL. There's
|
||||
// no on-screen toggle anymore — the URL is the enable path and the
|
||||
// red banner's "Drop admin" button is the one-click disable.
|
||||
// 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') {
|
||||
|
|
@ -2728,7 +2840,178 @@ body {
|
|||
init();
|
||||
}
|
||||
|
||||
window.zddc.elevation = { isElevated: isElevated, setElevated: setElevated };
|
||||
window.zddc.elevation = {
|
||||
isElevated: isElevated,
|
||||
setElevated: setElevated,
|
||||
setOn: setOn,
|
||||
setOff: setOff
|
||||
};
|
||||
})();
|
||||
|
||||
// shared/profile-menu.js — account menu in the header's upper-right.
|
||||
//
|
||||
// Replaces the old floating elevation toggle. Admin mode is now one item in
|
||||
// this dropdown, alongside the signed-in email, Profile, and Access tokens.
|
||||
// Mounts into the tool header's `.header-right` cluster (every tool ships one)
|
||||
// and drives elevation via window.zddc.elevation, so the cookie / armed-chrome
|
||||
// / ephemeral state machine stays in shared/elevation.js.
|
||||
//
|
||||
// No logout: authentication is the upstream proxy's concern (oauth2-proxy /
|
||||
// Authelia) — ZDDC owns no session, so it doesn't touch sign-out.
|
||||
//
|
||||
// Server mode only: it reads /.profile/access for the email + can_elevate.
|
||||
// On file:// (offline FS-Access mode) there's no server account, so nothing
|
||||
// renders.
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
if (!window.zddc) window.zddc = {};
|
||||
if (window.zddc.profileMenu) return;
|
||||
|
||||
function el(tag, cls, text) {
|
||||
var e = document.createElement(tag);
|
||||
if (cls) e.className = cls;
|
||||
if (text != null) e.textContent = text;
|
||||
return e;
|
||||
}
|
||||
|
||||
async function fetchAccess() {
|
||||
try {
|
||||
var r = await fetch('/.profile/access', {
|
||||
headers: { Accept: 'application/json' },
|
||||
credentials: 'same-origin',
|
||||
cache: 'no-cache'
|
||||
});
|
||||
if (!r.ok) return null;
|
||||
return await r.json();
|
||||
} catch (_e) { return null; }
|
||||
}
|
||||
|
||||
var elevation = null;
|
||||
var panelEl = null, btnEl = null, adminInput = null;
|
||||
|
||||
function isElevated() {
|
||||
return !!(elevation && elevation.isElevated && elevation.isElevated());
|
||||
}
|
||||
|
||||
// Keep the button's armed ring + the menu checkbox in lockstep with the
|
||||
// elevation cookie (flipped here, by ?admin=, or by the banner's Drop).
|
||||
function syncArmed() {
|
||||
if (btnEl) btnEl.classList.toggle('profile-btn--armed', isElevated());
|
||||
if (adminInput) adminInput.checked = isElevated();
|
||||
}
|
||||
|
||||
function closeMenu() {
|
||||
if (panelEl) panelEl.classList.remove('open');
|
||||
if (btnEl) btnEl.setAttribute('aria-expanded', 'false');
|
||||
}
|
||||
// The panel is position:fixed (to escape the app's stacking contexts), so
|
||||
// anchor it to the button rect — top just below it, right-aligned.
|
||||
function positionPanel() {
|
||||
var r = btnEl.getBoundingClientRect();
|
||||
panelEl.style.top = (r.bottom + 4) + 'px';
|
||||
panelEl.style.right = Math.max(4, window.innerWidth - r.right) + 'px';
|
||||
panelEl.style.left = 'auto';
|
||||
}
|
||||
function toggleMenu() {
|
||||
if (!panelEl) return;
|
||||
var open = panelEl.classList.toggle('open');
|
||||
if (open) positionPanel();
|
||||
btnEl.setAttribute('aria-expanded', open ? 'true' : 'false');
|
||||
}
|
||||
|
||||
function linkItem(text, href) {
|
||||
var a = el('a', 'profile-menu__item', text);
|
||||
a.href = href;
|
||||
a.setAttribute('role', 'menuitem');
|
||||
return a;
|
||||
}
|
||||
|
||||
function build(access) {
|
||||
var wrap = el('div', 'profile-menu');
|
||||
|
||||
btnEl = el('button', 'btn btn-secondary profile-btn');
|
||||
btnEl.type = 'button';
|
||||
btnEl.id = 'profile-btn';
|
||||
btnEl.title = 'Account: ' + (access.email || 'signed in');
|
||||
btnEl.setAttribute('aria-haspopup', 'menu');
|
||||
btnEl.setAttribute('aria-expanded', 'false');
|
||||
var initial = ((access.email || '?').trim().charAt(0) || '?').toUpperCase();
|
||||
btnEl.appendChild(el('span', 'profile-btn__avatar', initial));
|
||||
btnEl.addEventListener('click', function (e) { e.stopPropagation(); toggleMenu(); });
|
||||
wrap.appendChild(btnEl);
|
||||
|
||||
panelEl = el('div', 'profile-menu__panel');
|
||||
panelEl.setAttribute('role', 'menu');
|
||||
|
||||
var id = el('div', 'profile-menu__id');
|
||||
id.appendChild(el('div', 'profile-menu__email', access.email || '(anonymous)'));
|
||||
if (access.is_super_admin) id.appendChild(el('div', 'profile-menu__role', 'super admin'));
|
||||
else if (access.has_any_admin_scope) id.appendChild(el('div', 'profile-menu__role', 'admin'));
|
||||
panelEl.appendChild(id);
|
||||
panelEl.appendChild(el('div', 'profile-menu__sep'));
|
||||
|
||||
// Admin mode — only offered to principals who actually have admin
|
||||
// authority somewhere (can_elevate). Drops automatically on leave.
|
||||
if (access.can_elevate && elevation) {
|
||||
var row = el('label', 'profile-menu__item profile-menu__toggle');
|
||||
adminInput = document.createElement('input');
|
||||
adminInput.type = 'checkbox';
|
||||
adminInput.className = 'profile-menu__check';
|
||||
adminInput.checked = isElevated();
|
||||
adminInput.addEventListener('change', function () {
|
||||
if (adminInput.checked) elevation.setOn(); else elevation.setOff();
|
||||
});
|
||||
row.appendChild(adminInput);
|
||||
var txt = el('span', 'profile-menu__toggle-label');
|
||||
txt.appendChild(el('span', null, 'Admin mode'));
|
||||
txt.appendChild(el('span', 'profile-menu__hint', 'bypass WORM/ACL — drops when you leave the page'));
|
||||
row.appendChild(txt);
|
||||
panelEl.appendChild(row);
|
||||
panelEl.appendChild(el('div', 'profile-menu__sep'));
|
||||
}
|
||||
|
||||
panelEl.appendChild(linkItem('Profile', '/.profile'));
|
||||
panelEl.appendChild(linkItem('Access tokens', '/.tokens'));
|
||||
// No "Sign out": authentication is the upstream proxy's concern
|
||||
// (oauth2-proxy / Authelia). ZDDC doesn't own a session, so it
|
||||
// doesn't render a logout affordance.
|
||||
|
||||
// Portal the panel to <body>, not inside the header: the app's
|
||||
// layout creates stacking contexts that trap even a fixed+high
|
||||
// z-index panel below the content. As a direct body child it sits in
|
||||
// the root stacking context and reliably overlays everything.
|
||||
// position:fixed + positionPanel() keep it anchored to the button.
|
||||
document.body.appendChild(panelEl);
|
||||
return wrap;
|
||||
}
|
||||
|
||||
async function init() {
|
||||
if (window.location.protocol === 'file:') return;
|
||||
elevation = window.zddc.elevation || null;
|
||||
var access = await fetchAccess();
|
||||
if (!access || !access.email) return; // unauthenticated / non-zddc backend
|
||||
var host = document.querySelector('.header-right');
|
||||
if (!host) return;
|
||||
|
||||
host.appendChild(build(access));
|
||||
syncArmed();
|
||||
|
||||
document.addEventListener('click', function (e) {
|
||||
if (panelEl && panelEl.classList.contains('open')
|
||||
&& !panelEl.contains(e.target) && !btnEl.contains(e.target)) closeMenu();
|
||||
});
|
||||
document.addEventListener('keydown', function (e) { if (e.key === 'Escape') closeMenu(); });
|
||||
window.addEventListener('zddc:elevationchange', syncArmed);
|
||||
|
||||
window.zddc.profileMenu = { close: closeMenu };
|
||||
}
|
||||
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', init);
|
||||
} else {
|
||||
init();
|
||||
}
|
||||
})();
|
||||
|
||||
// shared/cap.js — client-side capability helpers for permission gating.
|
||||
|
|
|
|||
|
|
@ -78,6 +78,20 @@ conventions at https://codeberg.org/VARASYS/ZDDC#file-naming-convention.
|
|||
/* Shape */
|
||||
--radius: 4px;
|
||||
|
||||
/* Spacing scale — referenced by the tables tool (tables/css/table.css).
|
||||
Were undefined (var() with no fallback → collapsed to 0), which left
|
||||
table cells unpadded and the table flush to the viewport edges. */
|
||||
--spacing-sm: 0.4rem;
|
||||
--spacing-md: 0.8rem;
|
||||
--spacing-lg: 1.5rem;
|
||||
|
||||
/* Token aliases the tables tool references under --color-*/--radius-*
|
||||
names; map them to the canonical tokens (themed values flow through). */
|
||||
--color-text-muted: var(--text-muted);
|
||||
--color-border: var(--border);
|
||||
--color-bg-elevated: var(--bg-secondary);
|
||||
--radius-sm: var(--radius);
|
||||
|
||||
/* Typography. --font-display covers headings (Source Serif 4 — a refined
|
||||
transitional serif that reads as "engineering / document / serious"
|
||||
without being academic). --font is body UI text (IBM Plex Sans —
|
||||
|
|
@ -859,53 +873,10 @@ body.help-open .app-header {
|
|||
filter: brightness(1.1);
|
||||
}
|
||||
|
||||
/* 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. */
|
||||
|
||||
.elevation-toggle {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.3rem;
|
||||
font-size: 0.78rem;
|
||||
color: var(--text-muted);
|
||||
user-select: none;
|
||||
cursor: pointer;
|
||||
padding: 0.15rem 0.45rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
background: var(--bg);
|
||||
transition: background 0.12s, border-color 0.12s, color 0.12s;
|
||||
}
|
||||
|
||||
.elevation-toggle:hover {
|
||||
background: var(--bg-hover);
|
||||
border-color: var(--border-dark);
|
||||
}
|
||||
|
||||
.elevation-toggle input[type="checkbox"] {
|
||||
margin: 0;
|
||||
cursor: pointer;
|
||||
accent-color: var(--danger);
|
||||
}
|
||||
|
||||
.elevation-toggle__label {
|
||||
cursor: pointer;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
/* Active state — when elevation is ON, the toggle reads as "armed"
|
||||
so the user can't miss that admin powers are currently live.
|
||||
:has(:checked) lets us style the wrapper based on the inner
|
||||
checkbox without JS. */
|
||||
.elevation-toggle:has(input:checked) {
|
||||
background: rgba(220, 53, 69, 0.12);
|
||||
border-color: var(--danger);
|
||||
color: var(--danger);
|
||||
font-weight: 600;
|
||||
}
|
||||
/* shared/elevation.css — page-wide armed chrome for admin mode.
|
||||
The elevate CONTROL is the "Admin mode" item in the shared profile menu
|
||||
(shared/profile-menu.{js,css}); this file only styles the unmistakable
|
||||
"you are elevated" cues: the red viewport frame + the sticky banner. */
|
||||
|
||||
/* Page-wide chrome when admin mode is active. The toggle alone is
|
||||
easy to miss; these add an inescapable visual cue:
|
||||
|
|
@ -982,6 +953,118 @@ body.is-elevated::after {
|
|||
background: rgba(255, 255, 255, 0.3);
|
||||
}
|
||||
|
||||
/* shared/profile-menu.css — header account menu (upper-right).
|
||||
shared/profile-menu.js mounts a button into `.header-right` and toggles
|
||||
a dropdown with the signed-in email, Admin mode, Profile, Access tokens,
|
||||
and Sign out. Server mode only. */
|
||||
|
||||
.profile-menu {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
}
|
||||
|
||||
/* The button: a small circular avatar showing the email initial. */
|
||||
.profile-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0;
|
||||
width: 1.9rem;
|
||||
height: 1.9rem;
|
||||
border-radius: 50%;
|
||||
line-height: 1;
|
||||
}
|
||||
.profile-btn__avatar {
|
||||
font-size: 0.8rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.01em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
/* Armed (admin mode on): a red ring so the elevated state reads from the
|
||||
button even when the menu is closed — pairs with the page banner/frame. */
|
||||
.profile-btn--armed {
|
||||
box-shadow: 0 0 0 2px var(--danger, #dc3545);
|
||||
border-color: var(--danger, #dc3545);
|
||||
color: var(--danger, #dc3545);
|
||||
}
|
||||
|
||||
.profile-menu__panel {
|
||||
display: none;
|
||||
/* Fixed + JS-positioned from the button rect: an absolute panel gets
|
||||
trapped below the content layer by the app's stacking contexts, so
|
||||
anchor it to the viewport instead (profile-menu.js sets top/right). */
|
||||
position: fixed;
|
||||
min-width: 15rem;
|
||||
z-index: 9400; /* above the is-elevated frame (9200) + banner (9100) */
|
||||
background: var(--bg, #fff);
|
||||
border: 1px solid var(--border, #ddd);
|
||||
border-radius: var(--radius, 6px);
|
||||
box-shadow: 0 6px 24px rgba(0, 0, 0, 0.16);
|
||||
padding: 0.3rem;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.profile-menu__panel.open { display: block; }
|
||||
|
||||
.profile-menu__id {
|
||||
padding: 0.35rem 0.55rem 0.45rem;
|
||||
}
|
||||
.profile-menu__email {
|
||||
font-weight: 600;
|
||||
color: var(--text, #222);
|
||||
word-break: break-all;
|
||||
}
|
||||
.profile-menu__role {
|
||||
margin-top: 0.1rem;
|
||||
font-size: 0.72rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
color: var(--danger, #dc3545);
|
||||
}
|
||||
|
||||
.profile-menu__sep {
|
||||
height: 1px;
|
||||
margin: 0.25rem 0;
|
||||
background: var(--border, #eee);
|
||||
}
|
||||
|
||||
.profile-menu__item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
padding: 0.4rem 0.55rem;
|
||||
border-radius: var(--radius, 4px);
|
||||
color: var(--text, #222);
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
background: none;
|
||||
border: none;
|
||||
text-align: left;
|
||||
font: inherit;
|
||||
}
|
||||
.profile-menu__item:hover {
|
||||
background: var(--bg-hover, rgba(0, 0, 0, 0.05));
|
||||
}
|
||||
|
||||
.profile-menu__toggle { cursor: pointer; }
|
||||
.profile-menu__check {
|
||||
margin: 0;
|
||||
cursor: pointer;
|
||||
accent-color: var(--danger, #dc3545);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.profile-menu__toggle-label {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
line-height: 1.25;
|
||||
}
|
||||
.profile-menu__hint {
|
||||
font-size: 0.72rem;
|
||||
color: var(--text-muted, #888);
|
||||
}
|
||||
|
||||
/* shared/logo.css — paired with shared/logo.js. The wrapping anchor
|
||||
inherits the logo's box and adds a subtle hover/focus affordance
|
||||
so it reads as clickable without altering the logo's visual weight. */
|
||||
|
|
@ -2635,7 +2718,7 @@ dialog.modal--narrow {
|
|||
</svg>
|
||||
<div class="header-title-group">
|
||||
<span class="app-header__title">ZDDC Transmittal</span>
|
||||
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.27-beta · 2026-06-05 12:41:16 · 382645b</span></span>
|
||||
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.27-beta · 2026-06-08 11:50:20 · 0d7feb3</span></span>
|
||||
</div>
|
||||
<span id="no-js-notice" class="text-gray-400 text-xs italic">JavaScript not available</span>
|
||||
<!-- Publish split-button (Transmittal-specific primary action;
|
||||
|
|
@ -2647,12 +2730,6 @@ dialog.modal--narrow {
|
|||
</div>
|
||||
</div>
|
||||
<div class="header-right">
|
||||
<!-- Elevation toggle slot. shared/elevation.js fills it
|
||||
when /.profile/access reports the user has admin
|
||||
authority; stays empty + hidden for non-admins so
|
||||
the chrome is quiet for the common case. -->
|
||||
<span id="elevation-toggle" class="elevation-toggle hidden"
|
||||
title="Opt into your admin powers for the next 30 minutes (sudo-style)."></span>
|
||||
<button type="button" id="theme-btn" class="btn btn-secondary" title="Theme: auto (follows OS)" aria-label="Theme: auto (follows OS)">◐</button>
|
||||
<button type="button" id="help-btn" class="btn btn-secondary" aria-label="Help" title="Help">?</button>
|
||||
</div>
|
||||
|
|
@ -13403,26 +13480,31 @@ X.B(E,Y);return E}return J}())
|
|||
}
|
||||
}());
|
||||
|
||||
// shared/elevation.js — admin elevation via URL toggle.
|
||||
// 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, .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}.
|
||||
// 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}.
|
||||
//
|
||||
// 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.
|
||||
// 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.
|
||||
//
|
||||
// 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';
|
||||
|
||||
|
|
@ -13443,16 +13525,43 @@ X.B(E,Y);return E}return J}())
|
|||
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 (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', {
|
||||
|
|
@ -13498,34 +13607,26 @@ X.B(E,Y);return E}return J}())
|
|||
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;
|
||||
}
|
||||
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
|
||||
|
|
@ -13556,10 +13657,7 @@ X.B(E,Y);return E}return J}())
|
|||
+ '</button>';
|
||||
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);
|
||||
|
|
@ -13567,16 +13665,30 @@ X.B(E,Y);return E}return J}())
|
|||
}
|
||||
|
||||
async function init() {
|
||||
// Apply (or tear down) the red border + banner from the cookie on
|
||||
// every page load — admin mode is toggled by URL, but the armed
|
||||
// chrome must surface everywhere so the user can't accidentally
|
||||
// write through an elevated context on a page they didn't toggle.
|
||||
// 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 zddc-server URL. There's
|
||||
// no on-screen toggle anymore — the URL is the enable path and the
|
||||
// red banner's "Drop admin" button is the one-click disable.
|
||||
// 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') {
|
||||
|
|
@ -13585,7 +13697,178 @@ X.B(E,Y);return E}return J}())
|
|||
init();
|
||||
}
|
||||
|
||||
window.zddc.elevation = { isElevated: isElevated, setElevated: setElevated };
|
||||
window.zddc.elevation = {
|
||||
isElevated: isElevated,
|
||||
setElevated: setElevated,
|
||||
setOn: setOn,
|
||||
setOff: setOff
|
||||
};
|
||||
})();
|
||||
|
||||
// shared/profile-menu.js — account menu in the header's upper-right.
|
||||
//
|
||||
// Replaces the old floating elevation toggle. Admin mode is now one item in
|
||||
// this dropdown, alongside the signed-in email, Profile, and Access tokens.
|
||||
// Mounts into the tool header's `.header-right` cluster (every tool ships one)
|
||||
// and drives elevation via window.zddc.elevation, so the cookie / armed-chrome
|
||||
// / ephemeral state machine stays in shared/elevation.js.
|
||||
//
|
||||
// No logout: authentication is the upstream proxy's concern (oauth2-proxy /
|
||||
// Authelia) — ZDDC owns no session, so it doesn't touch sign-out.
|
||||
//
|
||||
// Server mode only: it reads /.profile/access for the email + can_elevate.
|
||||
// On file:// (offline FS-Access mode) there's no server account, so nothing
|
||||
// renders.
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
if (!window.zddc) window.zddc = {};
|
||||
if (window.zddc.profileMenu) return;
|
||||
|
||||
function el(tag, cls, text) {
|
||||
var e = document.createElement(tag);
|
||||
if (cls) e.className = cls;
|
||||
if (text != null) e.textContent = text;
|
||||
return e;
|
||||
}
|
||||
|
||||
async function fetchAccess() {
|
||||
try {
|
||||
var r = await fetch('/.profile/access', {
|
||||
headers: { Accept: 'application/json' },
|
||||
credentials: 'same-origin',
|
||||
cache: 'no-cache'
|
||||
});
|
||||
if (!r.ok) return null;
|
||||
return await r.json();
|
||||
} catch (_e) { return null; }
|
||||
}
|
||||
|
||||
var elevation = null;
|
||||
var panelEl = null, btnEl = null, adminInput = null;
|
||||
|
||||
function isElevated() {
|
||||
return !!(elevation && elevation.isElevated && elevation.isElevated());
|
||||
}
|
||||
|
||||
// Keep the button's armed ring + the menu checkbox in lockstep with the
|
||||
// elevation cookie (flipped here, by ?admin=, or by the banner's Drop).
|
||||
function syncArmed() {
|
||||
if (btnEl) btnEl.classList.toggle('profile-btn--armed', isElevated());
|
||||
if (adminInput) adminInput.checked = isElevated();
|
||||
}
|
||||
|
||||
function closeMenu() {
|
||||
if (panelEl) panelEl.classList.remove('open');
|
||||
if (btnEl) btnEl.setAttribute('aria-expanded', 'false');
|
||||
}
|
||||
// The panel is position:fixed (to escape the app's stacking contexts), so
|
||||
// anchor it to the button rect — top just below it, right-aligned.
|
||||
function positionPanel() {
|
||||
var r = btnEl.getBoundingClientRect();
|
||||
panelEl.style.top = (r.bottom + 4) + 'px';
|
||||
panelEl.style.right = Math.max(4, window.innerWidth - r.right) + 'px';
|
||||
panelEl.style.left = 'auto';
|
||||
}
|
||||
function toggleMenu() {
|
||||
if (!panelEl) return;
|
||||
var open = panelEl.classList.toggle('open');
|
||||
if (open) positionPanel();
|
||||
btnEl.setAttribute('aria-expanded', open ? 'true' : 'false');
|
||||
}
|
||||
|
||||
function linkItem(text, href) {
|
||||
var a = el('a', 'profile-menu__item', text);
|
||||
a.href = href;
|
||||
a.setAttribute('role', 'menuitem');
|
||||
return a;
|
||||
}
|
||||
|
||||
function build(access) {
|
||||
var wrap = el('div', 'profile-menu');
|
||||
|
||||
btnEl = el('button', 'btn btn-secondary profile-btn');
|
||||
btnEl.type = 'button';
|
||||
btnEl.id = 'profile-btn';
|
||||
btnEl.title = 'Account: ' + (access.email || 'signed in');
|
||||
btnEl.setAttribute('aria-haspopup', 'menu');
|
||||
btnEl.setAttribute('aria-expanded', 'false');
|
||||
var initial = ((access.email || '?').trim().charAt(0) || '?').toUpperCase();
|
||||
btnEl.appendChild(el('span', 'profile-btn__avatar', initial));
|
||||
btnEl.addEventListener('click', function (e) { e.stopPropagation(); toggleMenu(); });
|
||||
wrap.appendChild(btnEl);
|
||||
|
||||
panelEl = el('div', 'profile-menu__panel');
|
||||
panelEl.setAttribute('role', 'menu');
|
||||
|
||||
var id = el('div', 'profile-menu__id');
|
||||
id.appendChild(el('div', 'profile-menu__email', access.email || '(anonymous)'));
|
||||
if (access.is_super_admin) id.appendChild(el('div', 'profile-menu__role', 'super admin'));
|
||||
else if (access.has_any_admin_scope) id.appendChild(el('div', 'profile-menu__role', 'admin'));
|
||||
panelEl.appendChild(id);
|
||||
panelEl.appendChild(el('div', 'profile-menu__sep'));
|
||||
|
||||
// Admin mode — only offered to principals who actually have admin
|
||||
// authority somewhere (can_elevate). Drops automatically on leave.
|
||||
if (access.can_elevate && elevation) {
|
||||
var row = el('label', 'profile-menu__item profile-menu__toggle');
|
||||
adminInput = document.createElement('input');
|
||||
adminInput.type = 'checkbox';
|
||||
adminInput.className = 'profile-menu__check';
|
||||
adminInput.checked = isElevated();
|
||||
adminInput.addEventListener('change', function () {
|
||||
if (adminInput.checked) elevation.setOn(); else elevation.setOff();
|
||||
});
|
||||
row.appendChild(adminInput);
|
||||
var txt = el('span', 'profile-menu__toggle-label');
|
||||
txt.appendChild(el('span', null, 'Admin mode'));
|
||||
txt.appendChild(el('span', 'profile-menu__hint', 'bypass WORM/ACL — drops when you leave the page'));
|
||||
row.appendChild(txt);
|
||||
panelEl.appendChild(row);
|
||||
panelEl.appendChild(el('div', 'profile-menu__sep'));
|
||||
}
|
||||
|
||||
panelEl.appendChild(linkItem('Profile', '/.profile'));
|
||||
panelEl.appendChild(linkItem('Access tokens', '/.tokens'));
|
||||
// No "Sign out": authentication is the upstream proxy's concern
|
||||
// (oauth2-proxy / Authelia). ZDDC doesn't own a session, so it
|
||||
// doesn't render a logout affordance.
|
||||
|
||||
// Portal the panel to <body>, not inside the header: the app's
|
||||
// layout creates stacking contexts that trap even a fixed+high
|
||||
// z-index panel below the content. As a direct body child it sits in
|
||||
// the root stacking context and reliably overlays everything.
|
||||
// position:fixed + positionPanel() keep it anchored to the button.
|
||||
document.body.appendChild(panelEl);
|
||||
return wrap;
|
||||
}
|
||||
|
||||
async function init() {
|
||||
if (window.location.protocol === 'file:') return;
|
||||
elevation = window.zddc.elevation || null;
|
||||
var access = await fetchAccess();
|
||||
if (!access || !access.email) return; // unauthenticated / non-zddc backend
|
||||
var host = document.querySelector('.header-right');
|
||||
if (!host) return;
|
||||
|
||||
host.appendChild(build(access));
|
||||
syncArmed();
|
||||
|
||||
document.addEventListener('click', function (e) {
|
||||
if (panelEl && panelEl.classList.contains('open')
|
||||
&& !panelEl.contains(e.target) && !btnEl.contains(e.target)) closeMenu();
|
||||
});
|
||||
document.addEventListener('keydown', function (e) { if (e.key === 'Escape') closeMenu(); });
|
||||
window.addEventListener('zddc:elevationchange', syncArmed);
|
||||
|
||||
window.zddc.profileMenu = { close: closeMenu };
|
||||
}
|
||||
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', init);
|
||||
} else {
|
||||
init();
|
||||
}
|
||||
})();
|
||||
|
||||
// shared/cap.js — client-side capability helpers for permission gating.
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
# Generated by build.sh — do not edit. One <app>=<build label> per line.
|
||||
archive=v0.0.27-beta · 2026-06-05 12:41:17 · 382645b
|
||||
transmittal=v0.0.27-beta · 2026-06-05 12:41:16 · 382645b
|
||||
classifier=v0.0.27-beta · 2026-06-05 12:41:17 · 382645b
|
||||
landing=v0.0.27-beta · 2026-06-05 12:41:17 · 382645b
|
||||
form=v0.0.27-beta · 2026-06-05 12:41:17 · 382645b
|
||||
tables=v0.0.27-beta · 2026-06-05 12:41:17 · 382645b
|
||||
browse=v0.0.27-beta · 2026-06-05 12:41:17 · 382645b
|
||||
archive=v0.0.27-beta · 2026-06-08 11:50:20 · 0d7feb3
|
||||
transmittal=v0.0.27-beta · 2026-06-08 11:50:20 · 0d7feb3
|
||||
classifier=v0.0.27-beta · 2026-06-08 11:50:20 · 0d7feb3
|
||||
landing=v0.0.27-beta · 2026-06-08 11:50:20 · 0d7feb3
|
||||
form=v0.0.27-beta · 2026-06-08 11:50:20 · 0d7feb3
|
||||
tables=v0.0.27-beta · 2026-06-08 11:50:21 · 0d7feb3
|
||||
browse=v0.0.27-beta · 2026-06-08 11:50:21 · 0d7feb3
|
||||
|
|
|
|||
|
|
@ -1648,7 +1648,7 @@ body.is-elevated::after {
|
|||
</svg>
|
||||
<div class="header-title-group">
|
||||
<span class="app-header__title" id="table-title">ZDDC Table</span>
|
||||
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.27-dev · 2026-06-07 13:11:11 · 1b9fec6-dirty</span></span>
|
||||
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.27-beta · 2026-06-08 11:50:21 · 0d7feb3</span></span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="header-right">
|
||||
|
|
|
|||
Loading…
Reference in a new issue