/** * Project switcher (#244) — picks the active project; the selection persists in * the active-project module and rides on X-Project-Id (set in api/core.ts). * * Mirrors OrgSwitcher/TimeRangePicker: local open-state + outside-click/Escape, * brand-* Tailwind. On select (and on default/recover) we persist + reload — the * proven OrgContext.switchOrg pattern — so every useApi call refetches under the * new scope (the dashboard has no react-query / query invalidation to lean on, * and reload also wipes useApi's in-memory cache so no prior-scope data bleeds). */ import { useEffect, useRef, useState } from 'react'; import { getProjects, getActiveProjectId, setActiveProjectId, type ProjectAccess } from '../api/projects'; const MENU_ID = 'project-switcher-menu'; export function ProjectSwitcher() { const [projects, setProjects] = useState([]); const [activeId, setActiveId] = useState(getActiveProjectId()); const [open, setOpen] = useState(false); const ref = useRef(null); useEffect(() => { let cancelled = false; // getProjects() is unscoped (api/projects.ts), so it succeeds even when the // persisted active project was revoked — letting us detect + recover here. getProjects() .then((list) => { if (cancelled) return; setProjects(list); const active = getActiveProjectId(); const valid = !!active && list.some((p) => p.project.id === active); if (!valid && list.length > 0) { // No active project, or the persisted one is no longer accessible → // default to the first accessible project. setActiveProjectId(list[0]!.project.id); setActiveId(list[0]!.project.id); // Reload when a stale scope must be dropped (the current page's requests // already 403'd / used the wrong scope), or when the visible switcher // must match on-screen data. A silent single-project default needs neither. if (active || list.length > 1) window.location.reload(); } }) .catch(() => { /* no projects API (e.g. single-tenant OSS) — switcher stays hidden */ }); return () => { cancelled = true; }; }, []); useEffect(() => { function onDown(e: MouseEvent) { if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false); } function onKey(e: KeyboardEvent) { if (e.key === 'Escape') setOpen(false); } if (open) { document.addEventListener('mousedown', onDown); document.addEventListener('keydown', onKey); } return () => { document.removeEventListener('mousedown', onDown); document.removeEventListener('keydown', onKey); }; }, [open]); // Nothing to switch between — hide for single/no-project users. if (projects.length <= 1) return null; const current = projects.find((p) => p.project.id === activeId) ?? projects[0]!; function select(id: string) { setOpen(false); if (id === activeId) return; setActiveProjectId(id); window.location.reload(); } return (
{open && ( )}
); }