167 lines
6.0 KiB
JavaScript
167 lines
6.0 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 [allSurfaces, setAllSurfaces] = useState([]);
|
|
const btnRef = useRef(null);
|
|
|
|
const sw = window.sw;
|
|
const user = sw?.auth?.user;
|
|
const authenticated = sw?.auth?.isAuthenticated;
|
|
|
|
// Fetch installed surfaces on mount and when packages/auth change.
|
|
const CORE_IDS = new Set(['admin', 'settings', 'team-admin', 'workflow', 'workflow-landing', 'docs', 'welcome']);
|
|
|
|
useEffect(() => {
|
|
if (!sw?.api?.surfaces?.list) return;
|
|
|
|
function fetchSurfaces() {
|
|
sw.api.surfaces.list().then(data => {
|
|
const raw = Array.isArray(data) ? data : data?.data || [];
|
|
setAllSurfaces(raw);
|
|
}).catch(() => {});
|
|
}
|
|
|
|
fetchSurfaces();
|
|
|
|
// Re-fetch when packages or auth change (WS events from CS-1)
|
|
const off1 = sw.on?.('package.changed', fetchSurfaces);
|
|
const off2 = sw.on?.('auth.changed', fetchSurfaces);
|
|
|
|
return () => {
|
|
if (typeof off1 === 'function') off1();
|
|
if (typeof off2 === 'function') off2();
|
|
};
|
|
}, [authenticated]);
|
|
|
|
const extSurfaces = allSurfaces.filter(s => !CORE_IDS.has(s.id));
|
|
const enabledIds = new Set(allSurfaces.map(s => s.id));
|
|
|
|
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 — only if enabled and not current
|
|
if (current !== 'docs' && enabledIds.has('docs')) {
|
|
list.push({ label: 'Docs', action: 'docs', icon: '\ud83d\udcd6' });
|
|
}
|
|
|
|
// Settings — only if enabled and not current
|
|
if (current !== 'settings' && enabledIds.has('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' && enabledIds.has('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, enabledIds]);
|
|
|
|
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>
|
|
`;
|
|
}
|