ZDDC/shared/profile-menu.js
2026-06-11 13:32:31 -05:00

165 lines
6.8 KiB
JavaScript

// 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();
}
})();