/** * 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 extension surfaces once on mount useEffect(() => { if (!sw?.api?.surfaces?.list) return; sw.api.surfaces.list().then(data => { const all = data || []; const CORE = new Set(['chat', 'admin', 'notes', 'settings', 'team-admin', 'workflow', 'workflow-landing']); setExtSurfaces(all.filter(s => !CORE.has(s.id))); }).catch(() => {}); }, [authenticated]); const items = useMemo(() => { const current = _currentSurface(); const list = []; // ── Surface links (auto-filtered) ────── // Each surface that exists gets a menu item, except the current one. const surfaces = [ { key: 'chat', label: 'Chat', icon: '\ud83d\udcac' }, { key: 'notes', label: 'Notes', icon: '\ud83d\udcdd' }, ]; // Add extension surfaces fetched from API for (const ext of extSurfaces) { surfaces.push({ key: ext.id, label: ext.title, icon: '\ud83e\udde9', route: ext.route }); } const surfaceItems = surfaces .filter(s => s.key !== current) .map(s => ({ label: s.label, action: s.key, icon: s.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 ───────────────────── // 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 'debug': const dbg = document.getElementById('debugModal'); if (dbg) dbg.classList.toggle('active'); break; case 'chat': location.href = BASE + '/'; break; default: { // Extension surfaces use /s/{id} routes 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`
`; }