diff --git a/ui/src/components/SidebarAccountMenu.production.tsx b/ui/src/components/SidebarAccountMenu.production.tsx index 005a31ad9f..e0549e56fe 100644 --- a/ui/src/components/SidebarAccountMenu.production.tsx +++ b/ui/src/components/SidebarAccountMenu.production.tsx @@ -5,16 +5,19 @@ import { Flag, LogOut, type LucideIcon, - UserRound, - UserRoundPen, + UserPlus, } from "lucide-react"; -import type { DeploymentMode } from "@paperclipai/shared"; +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"; @@ -23,7 +26,7 @@ import { cn, SIDEBAR_RAIL_HIDDEN_LABEL } from "../lib/utils"; import { ThemeToggle } from "./ThemeToggle"; import { SidebarServerInfo } from "./SidebarServerInfo"; -const PROFILE_SETTINGS_PATH = "/company/settings/instance/profile"; +const INVITES_PATH = "/company/settings/members?tab=invites"; const DOCS_URL = "https://docs.paperclip.ing/"; const FEEDBACK_URL = "https://paperclip.ing/feedback"; @@ -39,7 +42,14 @@ interface MenuActionProps { 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) { @@ -50,21 +60,15 @@ function deriveInitials(name: string) { return name.slice(0, 2).toUpperCase(); } -function deriveUserSlug(name: string | null | undefined, email: string | null | undefined, id: string | null | undefined) { - const candidates = [name, email?.split("@")[0], email, id]; - for (const candidate of candidates) { - const slug = candidate - ?.trim() - .toLowerCase() - .replace(/['"]/g, "") - .replace(/[^a-z0-9]+/g, "-") - .replace(/^-+|-+$/g, ""); - if (slug) return slug; - } - return "me"; -} - -function MenuAction({ label, description, icon: Icon, onClick, href, external = false }: MenuActionProps) { +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"; @@ -89,6 +93,14 @@ function MenuAction({ label, description, icon: Icon, onClick, href, external = ); } + if (topLevel) { + return ( + + {content} + + ); + } + return ( {content} @@ -108,7 +120,26 @@ export function SidebarAccountMenu({ open: controlledOpen, onOpenChange, }: SidebarAccountMenuProps) { - const isCloud = Boolean(useCloudInstance()); + 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 that hold it (company owner/admin, instance admins, local + // boards) so a plain member is never sent to a permission error. + 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; @@ -127,7 +158,7 @@ export function SidebarAccountMenu({ const secondaryLabel = session?.user.email?.trim() || (deploymentMode === "authenticated" ? "Signed in" : "Local workspace board"); const initials = deriveInitials(displayName); - const profileHref = `/u/${deriveUserSlug(session?.user.name, session?.user.email, session?.user.id)}`; + const profileHref = userProfilePath(session?.user); function closeNavigationChrome() { setOpen(false); @@ -166,19 +197,26 @@ export function SidebarAccountMenu({ >
{secondaryLabel}
{stagingCommit ? (