/** * 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`
<${Menu} open=${open} anchor=${btnRef.current} direction=${placement} items=${items} onSelect=${handleSelect} onClose=${() => setOpen(false)} />
`; }