This repository has been archived on 2026-04-03. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
core/src/js/sw/shell/user-menu.js
xcaliber e916ed41ea
All checks were successful
CI/CD / detect-changes (push) Successful in 3s
CI/CD / test-frontend (push) Successful in 6s
CI/CD / test-sqlite (push) Successful in 2m54s
CI/CD / test-go-pg (push) Successful in 2m55s
CI/CD / build-and-deploy (push) Successful in 1m5s
V0.7.0 shell contract (#54)
2026-04-01 20:19:45 +00:00

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>
`;
}