All checks were successful
Co-authored-by: Jeffrey Smith <jasafpro@gmail.com> Co-committed-by: Jeffrey Smith <jasafpro@gmail.com>
151 lines
5.4 KiB
JavaScript
151 lines
5.4 KiB
JavaScript
/**
|
|
* UserMenu — avatar button + flyout menu
|
|
*
|
|
* RBAC-gated items based on sw.can() / sw.isAdmin / sw.isTeamAdmin().
|
|
* Surfaces mount this wherever they want; the shell does not own placement.
|
|
*
|
|
* Surface links (Notes, extensions) are auto-included and the CURRENT
|
|
* surface is filtered out so every surface gets the same menu minus itself.
|
|
*
|
|
* Props:
|
|
* placement — Menu direction: 'down-right' (default) | 'up-right' etc.
|
|
* onAction — Override handler; if omitted, navigates via location.href
|
|
* extraItems — Additional menu items inserted after surface links
|
|
*/
|
|
const { html } = window;
|
|
const { useState, useRef, useMemo, useEffect } = hooks;
|
|
|
|
import { Avatar } from '../primitives/avatar.js';
|
|
import { Menu } from '../primitives/menu.js';
|
|
|
|
/**
|
|
* Detect current surface from body[data-surface].
|
|
*/
|
|
function _currentSurface() {
|
|
return document.body?.dataset?.surface || '';
|
|
}
|
|
|
|
export function UserMenu({ placement = 'down-right', onAction, extraItems }) {
|
|
const [open, setOpen] = useState(false);
|
|
const [extSurfaces, setExtSurfaces] = useState([]);
|
|
const btnRef = useRef(null);
|
|
|
|
const sw = window.sw;
|
|
const user = sw?.auth?.user;
|
|
const authenticated = sw?.auth?.isAuthenticated;
|
|
|
|
// Fetch installed surfaces once on mount.
|
|
// Filter out core surfaces that have dedicated menu entries below.
|
|
const CORE_IDS = new Set(['admin', 'settings', 'team-admin', 'workflow', 'workflow-landing', 'docs']);
|
|
|
|
useEffect(() => {
|
|
if (!sw?.api?.surfaces?.list) return;
|
|
sw.api.surfaces.list().then(data => {
|
|
const raw = Array.isArray(data) ? data : data?.data || [];
|
|
setExtSurfaces(raw.filter(s => !CORE_IDS.has(s.id)));
|
|
}).catch(() => {});
|
|
}, [authenticated]);
|
|
|
|
const items = useMemo(() => {
|
|
const current = _currentSurface();
|
|
const list = [];
|
|
|
|
// ── Surface links (from API — only installed surfaces) ──────
|
|
const DEFAULT_ICON = '\ud83e\udde9';
|
|
const surfaceItems = extSurfaces
|
|
.filter(s => s.id !== current)
|
|
.map(s => ({ label: s.title, action: s.id, icon: s.icon || DEFAULT_ICON }));
|
|
|
|
if (surfaceItems.length) {
|
|
list.push(...surfaceItems);
|
|
list.push({ divider: true });
|
|
}
|
|
|
|
// ── Extra items from caller ────────────
|
|
if (extraItems && extraItems.length) {
|
|
list.push(...extraItems);
|
|
list.push({ divider: true });
|
|
}
|
|
|
|
// ── Standard items ─────────────────────
|
|
// Docs — skip if current surface IS docs
|
|
if (current !== 'docs') {
|
|
list.push({ label: 'Docs', action: 'docs', icon: '\ud83d\udcd6' });
|
|
}
|
|
|
|
// Settings — skip if current surface IS settings
|
|
if (current !== 'settings') {
|
|
list.push({ label: 'Settings', action: 'settings', icon: '\u2699\ufe0f' });
|
|
}
|
|
|
|
// Admin — requires admin role
|
|
if (authenticated && sw?.isAdmin && current !== 'admin') {
|
|
list.push({ label: 'Admin', action: 'admin', icon: '\ud83d\udee1\ufe0f' });
|
|
}
|
|
|
|
// Team Admin — requires admin role on at least one team
|
|
const hasTeamAdmin = sw?.auth?.teams?.some(t => t.my_role === 'admin');
|
|
if (authenticated && hasTeamAdmin && current !== 'team-admin') {
|
|
list.push({ label: 'Team Admin', action: 'team-admin', icon: '\ud83d\udc65' });
|
|
}
|
|
|
|
// Debug — admin only
|
|
if (authenticated && sw?.isAdmin) {
|
|
list.push({ label: 'Debug', action: 'debug', icon: '\ud83d\udc1b' });
|
|
}
|
|
|
|
list.push({ divider: true });
|
|
list.push({ label: 'Sign Out', action: 'sign-out' });
|
|
|
|
return list;
|
|
}, [user, authenticated, extraItems, extSurfaces]);
|
|
|
|
function handleSelect(action) {
|
|
setOpen(false);
|
|
if (onAction) return onAction(action);
|
|
|
|
// Default routing
|
|
const BASE = window.__BASE__ || '';
|
|
switch (action) {
|
|
case 'sign-out':
|
|
window.sw?.auth?.logout().then(() => {
|
|
location.href = BASE + '/login';
|
|
});
|
|
break;
|
|
case 'docs':
|
|
location.href = BASE + '/docs/GETTING-STARTED';
|
|
break;
|
|
case 'debug':
|
|
window.dispatchEvent(new CustomEvent('debug:toggle'));
|
|
break;
|
|
default: {
|
|
// Surfaces use their route from the API, or fall back to /{id}
|
|
const ext = extSurfaces.find(s => s.id === action);
|
|
location.href = BASE + (ext?.route || '/' + action);
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
const displayName = user?.display_name || user?.username || 'Demo User';
|
|
const avatar = user?.avatar || null;
|
|
|
|
return html`
|
|
<div class="user-menu-wrap">
|
|
<button class="user-btn" ref=${btnRef}
|
|
onClick=${() => setOpen(!open)} aria-label="User menu">
|
|
<${Avatar} src=${avatar} name=${displayName} size="sm" />
|
|
<span class="sb-label">${displayName}</span>
|
|
</button>
|
|
<${Menu}
|
|
open=${open}
|
|
anchor=${btnRef.current}
|
|
direction=${placement}
|
|
items=${items}
|
|
onSelect=${handleSelect}
|
|
onClose=${() => setOpen(false)}
|
|
/>
|
|
</div>
|
|
`;
|
|
}
|