import { useState } from "react"; import { useQuery } from "@tanstack/react-query"; import { BookOpen, Flag, LogOut, Settings, type LucideIcon, UserPlus, } from "lucide-react"; import { hidesCompanyPage, type DeploymentMode } from "@paperclipai/shared"; import { Link } from "@/lib/router"; import { authApi } from "@/api/auth"; import { queryKeys } from "@/lib/queryKeys"; import { useCloudInstance } from "@/hooks/useCloudInstance"; import { useCloudInviteUrl } from "@/hooks/useCloudInviteUrl"; import { useCanInviteCompanyMembers } from "@/hooks/useCompanyInviteAccess"; import { useHiddenSettings } from "@/hooks/useHiddenSettings"; import { useSignOut } from "@/hooks/useSignOut"; import { useStagingCommit } from "@/hooks/useStagingCommit"; import { userProfilePath } from "@/lib/userProfileLinks"; import { useSidebar } from "../context/SidebarContext"; import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; import { cn, SIDEBAR_RAIL_HIDDEN_LABEL } from "../lib/utils"; import { ThemeToggle } from "./ThemeToggle"; import { SidebarServerInfo } from "./SidebarServerInfo"; const INVITES_PATH = "/company/settings/members?tab=invites"; const DOCS_URL = "https://docs.paperclip.ing/"; const FEEDBACK_URL = "https://paperclip.ing/feedback"; interface SidebarAccountMenuProps { deploymentMode?: DeploymentMode; open?: boolean; onOpenChange?: (open: boolean) => void; /** Contextual navigation occupies a full sidebar even if the saved global nav mode is collapsed. */ forceExpanded?: boolean; } interface MenuActionProps { label: string; icon: LucideIcon; onClick?: () => void; href?: string; /** Opens `href` in a new tab (docs and other off-product links). */ external?: boolean; /** * Leaves the app in the current tab with a full navigation. Cloud links * must use this: the cloud harness shadows those paths on tenant hosts, so * the in-app router can never reach them. */ topLevel?: boolean; } function deriveInitials(name: string) { const parts = name.trim().split(/\s+/).filter(Boolean); if (parts.length >= 2) { return `${parts[0]?.[0] ?? ""}${parts[parts.length - 1]?.[0] ?? ""}`.toUpperCase(); } return name.slice(0, 2).toUpperCase(); } function MenuAction({ label, icon: Icon, onClick, href, external = false, topLevel = false, }: MenuActionProps) { const className = "flex h-(--profile-popover-row-height) w-full items-center gap-(--profile-popover-row-gap) rounded-lg px-2.5 text-left text-(length:--text-compact) font-medium leading-(--profile-popover-label-line-height) text-foreground transition-colors hover:bg-accent"; const content = ( <> {label} ); if (href) { if (external) { return ( {content} ); } if (topLevel) { return ( {content} ); } return ( {content} ); } return ( ); } export function SidebarAccountMenu({ deploymentMode, open: controlledOpen, onOpenChange, forceExpanded = false, }: SidebarAccountMenuProps) { const cloud = useCloudInstance(); const isCloud = Boolean(cloud); // Invites live on the Members page (or in Cloud's People settings). Hide the // shortcut when the hosting operator hides either surface, and until the // health response resolves so a hidden surface never flashes. const { hidden: hiddenSettings, loaded: hiddenSettingsLoaded } = useHiddenSettings(); // On Cloud the shortcut exists only for the current stack's owner/admin and // only once the stack metadata is known; the in-app Invites tab is never a // fallback there because it drives a different invitation flow. const cloudInviteUrl = useCloudInviteUrl(); // Self-hosted invites need the `users:invite` grant. Offer the shortcut only // to boards with role-default access (company owner/admin/operator, // instance admins, local boards). The server checks the actual grants. const canInviteMembers = useCanInviteCompanyMembers(!isCloud); const inviteHref = isCloud ? cloudInviteUrl : canInviteMembers ? INVITES_PATH : null; const showInvite = hiddenSettingsLoaded && inviteHref !== null && !hidesCompanyPage(hiddenSettings, "company.members") && !hidesCompanyPage(hiddenSettings, "company.invites"); const [internalOpen, setInternalOpen] = useState(false); const { isMobile, setSidebarOpen, collapsed, peeking } = useSidebar(); const rail = collapsed && !peeking && !forceExpanded; const open = controlledOpen ?? internalOpen; const stagingCommit = useStagingCommit(open); const setOpen = onOpenChange ?? setInternalOpen; const { data: session } = useQuery({ queryKey: queryKeys.auth.session, queryFn: () => authApi.getSession(), retry: false, }); const signOutMutation = useSignOut({ onSignedOut: closeNavigationChrome }); const displayName = session?.user.name?.trim() || "Board"; const secondaryLabel = session?.user.email?.trim() || (deploymentMode === "authenticated" ? "Signed in" : "Local workspace board"); const initials = deriveInitials(displayName); const profileHref = userProfilePath(session?.user); function closeNavigationChrome() { setOpen(false); if (isMobile) setSidebarOpen(false); } function handleSignOut() { signOutMutation.mutate(); } return (
{/* The profile link is a stretched overlay so the staging SHA anchor can sit beside the email without nesting anchors. */}
{session?.user.image ? : null} {initials}

{displayName}

{secondaryLabel}

{stagingCommit ? ( SHA {stagingCommit.slice(0, 7)} ) : null}
{showInvite && inviteHref ? ( ) : null} setOpen(false)} /> setOpen(false)} /> {deploymentMode === "authenticated" ? ( ) : null}
{!rail && !isCloud ? ( Share feedback ) : null}
); }