feat(shared): replace floating elevation toggle with a header profile menu
Drop the bottom-right floating "Admin mode" switch in favour of a proper
account menu in the header's upper-right (every tool's .header-right).
New shared/profile-menu.{js,css}: a circular avatar button (email initial)
opening a dropdown with the signed-in email, an "Admin mode" item (only for
can_elevate principals — drives elevation.setOn/setOff, drops on leave),
Profile (/.profile), and Access tokens (/.tokens). The panel is portaled to
<body> + position:fixed so it overlays content reliably regardless of the
app's stacking contexts; the button shows a red ring while elevated.
No logout: authentication is the upstream proxy's concern (oauth2-proxy /
Authelia) — ZDDC owns no session, so the menu doesn't render sign-out.
elevation.js keeps the state machine (cookie, armed banner/frame, ephemeral
pagehide-clear, zddc:elevationchange, ?admin= URL) but no longer renders any
control — the profile menu is the UI. elevation.css drops the floating-
toggle styles (keeps banner + frame). All 7 templates drop the dead
elevation-toggle placeholder; all 7 build.sh bundle profile-menu.{js,css}.
Validated in a containerized browser: menu items, links, elevation arming +
armed ring, dropdown overlays content, no floating toggle.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
7f5a54f845
commit
ef849ab3fa
18 changed files with 322 additions and 167 deletions
|
|
@ -23,6 +23,7 @@ concat_files \
|
|||
"../shared/base.css" \
|
||||
"../shared/toast.css" \
|
||||
"../shared/elevation.css" \
|
||||
"../shared/profile-menu.css" \
|
||||
"../shared/logo.css" \
|
||||
"css/base.css" \
|
||||
"css/layout.css" \
|
||||
|
|
@ -64,6 +65,7 @@ concat_files \
|
|||
"js/app.js" \
|
||||
"../shared/help.js" \
|
||||
"../shared/elevation.js" \
|
||||
"../shared/profile-menu.js" \
|
||||
"../shared/cap.js" \
|
||||
> "$js_raw"
|
||||
|
||||
|
|
|
|||
|
|
@ -36,12 +36,6 @@
|
|||
<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>
|
||||
|
|
|
|||
|
|
@ -29,6 +29,7 @@ concat_files \
|
|||
"../shared/vendor/codemirror-yaml.min.css" \
|
||||
"../shared/context-menu.css" \
|
||||
"../shared/elevation.css" \
|
||||
"../shared/profile-menu.css" \
|
||||
"css/base.css" \
|
||||
"css/tree.css" \
|
||||
"css/preview-yaml.css" \
|
||||
|
|
@ -59,6 +60,7 @@ concat_files \
|
|||
"../shared/preview-lib.js" \
|
||||
"../shared/context-menu.js" \
|
||||
"../shared/elevation.js" \
|
||||
"../shared/profile-menu.js" \
|
||||
"../shared/cap.js" \
|
||||
"../shared/icons.js" \
|
||||
"../shared/zddc-source.js" \
|
||||
|
|
|
|||
|
|
@ -28,12 +28,6 @@
|
|||
<button id="refreshHeaderBtn" class="btn btn-secondary hidden" title="Refresh listing" aria-label="Refresh listing">⟳</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" aria-label="Help">?</button>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ concat_files \
|
|||
"../shared/base.css" \
|
||||
"../shared/toast.css" \
|
||||
"../shared/elevation.css" \
|
||||
"../shared/profile-menu.css" \
|
||||
"../shared/logo.css" \
|
||||
"css/base.css" \
|
||||
"css/layout.css" \
|
||||
|
|
@ -62,6 +63,7 @@ concat_files \
|
|||
"js/excel.js" \
|
||||
"../shared/help.js" \
|
||||
"../shared/elevation.js" \
|
||||
"../shared/profile-menu.js" \
|
||||
"../shared/cap.js" \
|
||||
> "$js_raw"
|
||||
|
||||
|
|
|
|||
|
|
@ -32,12 +32,6 @@
|
|||
<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>
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ concat_files \
|
|||
"../shared/base.css" \
|
||||
"../shared/toast.css" \
|
||||
"../shared/elevation.css" \
|
||||
"../shared/profile-menu.css" \
|
||||
"../shared/logo.css" \
|
||||
"css/form.css" \
|
||||
> "$css_temp"
|
||||
|
|
@ -32,6 +33,7 @@ concat_files \
|
|||
"../shared/logo.js" \
|
||||
"../shared/help.js" \
|
||||
"../shared/elevation.js" \
|
||||
"../shared/profile-menu.js" \
|
||||
"../shared/cap.js" \
|
||||
"js/app.js" \
|
||||
"js/context.js" \
|
||||
|
|
|
|||
|
|
@ -26,12 +26,6 @@
|
|||
</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>
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ concat_files \
|
|||
"../shared/base.css" \
|
||||
"../shared/toast.css" \
|
||||
"../shared/elevation.css" \
|
||||
"../shared/profile-menu.css" \
|
||||
"../shared/logo.css" \
|
||||
"css/landing.css" \
|
||||
> "$css_temp"
|
||||
|
|
@ -34,6 +35,7 @@ concat_files \
|
|||
"../shared/logo.js" \
|
||||
"../shared/help.js" \
|
||||
"../shared/elevation.js" \
|
||||
"../shared/profile-menu.js" \
|
||||
"../shared/cap.js" \
|
||||
"js/landing.js" \
|
||||
> "$js_raw"
|
||||
|
|
|
|||
|
|
@ -26,12 +26,6 @@
|
|||
</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>
|
||||
|
|
|
|||
|
|
@ -1,56 +1,7 @@
|
|||
/* shared/elevation.css — on-page admin-elevation toggle.
|
||||
elevation.js appends this control to <body> ONLY for users the
|
||||
server says can_elevate (sudo-style opt-in). It's a fixed bottom-
|
||||
right switch so it works in any tool without a header slot and
|
||||
stays clear of the top "admin mode is on" banner. Arming is
|
||||
per-page: the cookie is session-scoped and cleared on pagehide. */
|
||||
|
||||
.elevation-toggle {
|
||||
position: fixed;
|
||||
right: 0.9rem;
|
||||
bottom: 0.9rem;
|
||||
z-index: 9300; /* above the is-elevated frame (9200) so it stays clickable */
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.3rem;
|
||||
font-size: 0.78rem;
|
||||
color: var(--text-muted);
|
||||
user-select: none;
|
||||
cursor: pointer;
|
||||
padding: 0.2rem 0.5rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
background: var(--bg);
|
||||
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.14);
|
||||
transition: background 0.12s, border-color 0.12s, color 0.12s;
|
||||
}
|
||||
|
||||
.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:
|
||||
|
|
|
|||
|
|
@ -1,17 +1,18 @@
|
|||
// shared/elevation.js — admin elevation via an on-page 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}.
|
||||
//
|
||||
// Two ways to arm, both gated on /.profile/access `can_elevate` so only
|
||||
// real admins can flip it (a non-admin's attempt is a silent no-op):
|
||||
// 1. The on-page toggle (renderToggle) — a small fixed control that we
|
||||
// render ONLY for users who can_elevate. This is the primary path.
|
||||
// 2. `?admin=true|false` typed into any URL — still honoured (handy for
|
||||
// deep links / scripting), just normalised into the same state.
|
||||
// This module owns the STATE (cookie, armed chrome/banner, ephemeral
|
||||
// lifecycle, the change event) + exposes setOn/setOff/isElevated. The
|
||||
// on-page elevate CONTROL lives in the shared profile menu
|
||||
// (shared/profile-menu.js) — an "Admin mode" item shown only to
|
||||
// can_elevate users — which calls setOn/setOff. `?admin=true|false` typed
|
||||
// into any URL is also honoured (gated on can_elevate), for deep links /
|
||||
// scripting.
|
||||
//
|
||||
// Admin mode is EPHEMERAL — scoped to the page you turned it on:
|
||||
// * the cookie is a SESSION cookie (no Max-Age), and
|
||||
|
|
@ -63,19 +64,19 @@
|
|||
} catch (_e) { /* CustomEvent unsupported — non-fatal */ }
|
||||
}
|
||||
|
||||
// setOn / setOff are the single funnel for every arm/drop path (toggle,
|
||||
// URL param, banner button). Each flips the cookie, re-paints the armed
|
||||
// chrome, syncs the toggle checkbox, and emits the change — no reload.
|
||||
// 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);
|
||||
syncToggle();
|
||||
emitChange();
|
||||
}
|
||||
function setOff() {
|
||||
setElevated(false);
|
||||
applyArmedChrome(false);
|
||||
syncToggle();
|
||||
emitChange();
|
||||
}
|
||||
|
||||
|
|
@ -181,75 +182,30 @@
|
|||
}
|
||||
}
|
||||
|
||||
// renderToggle mounts the on-page admin switch — but ONLY for users the
|
||||
// server says can_elevate (i.e. who'd actually gain edit authority by
|
||||
// arming). Everyone else never sees it. It's a fixed control so it works
|
||||
// in any tool without that tool rendering a header slot for it.
|
||||
function renderToggle() {
|
||||
// Reuse the header placeholder the tool templates ship (an empty
|
||||
// `<span id="elevation-toggle" class="… hidden">`) when present —
|
||||
// dropping its `hidden` class and repopulating it; otherwise create
|
||||
// one. Either way the fixed-position CSS floats it bottom-right, so
|
||||
// it doesn't matter where in the DOM it lives.
|
||||
var el = document.getElementById('elevation-toggle');
|
||||
var created = false;
|
||||
if (!el) {
|
||||
el = document.createElement('span');
|
||||
el.id = 'elevation-toggle';
|
||||
created = true;
|
||||
}
|
||||
el.className = 'elevation-toggle'; // drops any stale `hidden`
|
||||
el.title = 'Arm admin mode for this page. Drops automatically when you leave.';
|
||||
el.innerHTML = '';
|
||||
var input = document.createElement('input');
|
||||
input.type = 'checkbox';
|
||||
input.id = 'elevation-toggle-input';
|
||||
input.className = 'elevation-toggle__input';
|
||||
input.checked = isElevated();
|
||||
input.addEventListener('change', function () {
|
||||
if (input.checked) setOn(); else setOff();
|
||||
});
|
||||
var txt = document.createElement('span');
|
||||
txt.className = 'elevation-toggle__label';
|
||||
txt.textContent = 'Admin mode';
|
||||
txt.addEventListener('click', function () { input.click(); });
|
||||
el.appendChild(input);
|
||||
el.appendChild(txt);
|
||||
if (created) document.body.appendChild(el);
|
||||
}
|
||||
|
||||
// syncToggle keeps the checkbox honest when state is flipped by some
|
||||
// other path (URL param, banner "Drop admin", bfcache restore).
|
||||
function syncToggle() {
|
||||
var input = document.getElementById('elevation-toggle-input');
|
||||
if (input) input.checked = isElevated();
|
||||
}
|
||||
|
||||
async function init() {
|
||||
// file:// (offline FS-Access mode) has no server to elevate against
|
||||
// — skip the access probe and the toggle entirely.
|
||||
// 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());
|
||||
|
||||
// One /.profile/access probe drives both decisions: whether to show
|
||||
// the toggle (can_elevate) and whether a ?admin=true may arm.
|
||||
var access = await fetchAccess();
|
||||
if (access && access.can_elevate) renderToggle();
|
||||
await handleAdminParam(access);
|
||||
// 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. (No UI work here — the
|
||||
// page is unloading; the next page re-derives state from scratch.)
|
||||
// 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 visible state so chrome ≠ cookie can't happen.
|
||||
// cookie — re-sync the armed chrome so chrome ≠ cookie can't happen.
|
||||
window.addEventListener('pageshow', function (e) {
|
||||
if (e.persisted) { applyArmedChrome(isElevated()); syncToggle(); }
|
||||
if (e.persisted) applyArmedChrome(isElevated());
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
111
shared/profile-menu.css
Normal file
111
shared/profile-menu.css
Normal file
|
|
@ -0,0 +1,111 @@
|
|||
/* 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);
|
||||
}
|
||||
165
shared/profile-menu.js
Normal file
165
shared/profile-menu.js
Normal file
|
|
@ -0,0 +1,165 @@
|
|||
// 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();
|
||||
}
|
||||
})();
|
||||
|
|
@ -22,6 +22,7 @@ concat_files \
|
|||
"../shared/base.css" \
|
||||
"../shared/toast.css" \
|
||||
"../shared/elevation.css" \
|
||||
"../shared/profile-menu.css" \
|
||||
"../shared/logo.css" \
|
||||
"../shared/context-menu.css" \
|
||||
"css/table.css" \
|
||||
|
|
@ -42,6 +43,7 @@ concat_files \
|
|||
"../shared/logo.js" \
|
||||
"../shared/help.js" \
|
||||
"../shared/elevation.js" \
|
||||
"../shared/profile-menu.js" \
|
||||
"../shared/cap.js" \
|
||||
"../shared/context-menu.js" \
|
||||
"js/mode.js" \
|
||||
|
|
|
|||
|
|
@ -26,12 +26,6 @@
|
|||
</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>
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@ concat_files \
|
|||
"../shared/base.css" \
|
||||
"../shared/toast.css" \
|
||||
"../shared/elevation.css" \
|
||||
"../shared/profile-menu.css" \
|
||||
"../shared/logo.css" \
|
||||
"css/base.css" \
|
||||
"css/layout.css" \
|
||||
|
|
@ -87,6 +88,7 @@ concat_files \
|
|||
"js/focus.js" \
|
||||
"../shared/help.js" \
|
||||
"../shared/elevation.js" \
|
||||
"../shared/profile-menu.js" \
|
||||
"../shared/cap.js" \
|
||||
"js/main.js" \
|
||||
> "$js_raw"
|
||||
|
|
|
|||
|
|
@ -51,12 +51,6 @@ conventions at https://codeberg.org/VARASYS/ZDDC#file-naming-convention.
|
|||
</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>
|
||||
|
|
|
|||
Loading…
Reference in a new issue