import { useState } from "react";
import { useQuery } from "@tanstack/react-query";
import {
BookOpen,
Flag,
LogOut,
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;
}
interface MenuActionProps {
label: string;
description: 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,
description,
icon: Icon,
onClick,
href,
external = false,
topLevel = false,
}: MenuActionProps) {
const className =
"flex w-full items-start gap-3 rounded-xl px-3 py-3 text-left transition-colors hover:bg-accent/60";
const content = (
<>
{label}
{description}
>
);
if (href) {
if (external) {
return (
{content}
);
}
if (topLevel) {
return (
{content}
);
}
return (
{content}
);
}
return (
);
}
export function SidebarAccountMenu({
deploymentMode,
open: controlledOpen,
onOpenChange,
}: 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;
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}
{showInvite && inviteHref ? (
) : null}
setOpen(false)}
/>
setOpen(false)} />
{deploymentMode === "authenticated" ? (
) : null}
{!rail && !isCloud ? (
Share feedback
) : null}
);
}