165 lines
6.8 KiB
JavaScript
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();
|
|
}
|
|
})();
|