// The account chip at the foot of the sidebar, and the menu it opens. // Asked for 2026-09-15 with ChatGPT's sidebar as the reference: an avatar with // the name and what kind of account it is, opening upward into everything that // used to be a loose row of links. // // The menu holds what the dark banner used to: the guides, the lab-only pages, // the admin link, and the way in or out. Keyboard and outside-click handling // follow ConstructPicker, which is the house pattern for a popup. import { useEffect, useRef, useState } from "react"; const ROLE_LABELS = { external: "External user", lab: "Lab member", maintainer: "Maintainer", pi: "Principal investigator", }; // "Deva Anand" -> "DA"; "mohammad" -> "M". Two letters at most, because three // stop being legible at 26px. function initials(name) { const parts = (name || "").trim().split(/\s+/).filter(Boolean); if (!parts.length) return ""; return (parts[0][0] + (parts.length > 1 ? parts[parts.length - 1][0] : "")).toUpperCase(); } function Icon({ d, circle }) { return ( ); } const ICONS = { about: , guide: , testing: , product: , admin: , signout: , signin: , }; export default function AccountMenu({ auth, onAbout, onSignIn, onSignOut }) { const [open, setOpen] = useState(false); const rootRef = useRef(null); const buttonRef = useRef(null); useEffect(() => { if (!open) return undefined; const onDown = (e) => { if (rootRef.current && !rootRef.current.contains(e.target)) setOpen(false); }; const onKey = (e) => { if (e.key !== "Escape") return; // Stop here: the drawer also closes on Escape, and dismissing both with // one key would leave you looking at a workspace you did not ask for. e.stopPropagation(); setOpen(false); buttonRef.current?.focus(); }; document.addEventListener("mousedown", onDown); document.addEventListener("keydown", onKey, true); return () => { document.removeEventListener("mousedown", onDown); document.removeEventListener("keydown", onKey, true); }; }, [open]); const signedIn = !!auth?.signed_in; const name = signedIn ? auth.name : "Not signed in"; // The line under the name is ChatGPT's plan slot. Ours says what kind of // account this is, which is the thing that changes what you can do here. const subtitle = signedIn ? ROLE_LABELS[auth.role] || "Account" : "Sign in to keep your work"; const run = (fn) => () => { setOpen(false); fn?.(); }; return (
{open && (
{name} {signedIn ? auth.email : subtitle}
{ICONS.guide} Guide {/* /testing and /product are lab-internal; the server enforces the gate, the menu just does not advertise them to everyone else. */} {["lab", "maintainer", "pi"].includes(auth?.role) && ( <> {ICONS.testing} Testing guide {ICONS.product} How it works )} {auth?.is_admin && ( {ICONS.admin} Admin )}
{signedIn ? ( ) : ( )}
)}
); }