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({ >
-
-
+ {/* 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 ? (
- - + {showInvite && inviteHref ? ( + + ) : null} ({ getExperimental: vi.fn(), })); const mockHealthApi = vi.hoisted(() => ({ get: vi.fn() })); +const mockGetCurrentBoardAccess = vi.hoisted(() => vi.fn()); const mockToggleTheme = vi.hoisted(() => vi.fn()); const mockSetSidebarOpen = vi.hoisted(() => vi.fn()); const mockNavigateTopLevel = vi.hoisted(() => vi.fn()); @@ -30,6 +31,18 @@ vi.mock("@/api/auth", () => ({ vi.mock("@/api/health", () => ({ healthApi: mockHealthApi })); +vi.mock("@/api/access", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + accessApi: { ...actual.accessApi, getCurrentBoardAccess: mockGetCurrentBoardAccess }, + }; +}); + +vi.mock("@/context/CompanyContext", () => ({ + useOptionalCompany: () => ({ selectedCompanyId: "company-1" }), +})); + vi.mock("@/lib/browserNavigation", () => ({ navigateTopLevel: mockNavigateTopLevel, })); @@ -78,6 +91,39 @@ async function flushReact() { }); } +/** Stack portfolio with the acme-labs stack current at the given Cloud role. */ +function cloudStacksPortfolio(role: string) { + return { + stacks: [ + { + displayName: "Acme Labs", + stackSlug: "acme-labs", + primaryHost: "acme-labs.example.test", + lifecycleState: "active", + sleepState: "awake", + role, + isCurrent: true, + }, + ], + }; +} + +/** Board access snapshot for a signed-in member of company-1 at the given company role. */ +function boardAccess( + membershipRole: "owner" | "admin" | "operator" | "viewer", + overrides: { isInstanceAdmin?: boolean; source?: string } = {}, +) { + return { + user: { id: "user-1", email: "jane@example.com", name: "Jane Example", image: null }, + userId: "user-1", + isInstanceAdmin: overrides.isInstanceAdmin ?? false, + companyIds: ["company-1"], + memberships: [{ companyId: "company-1", membershipRole, status: "active" as const }], + source: overrides.source ?? "session", + keyId: null, + }; +} + describe("SidebarAccountMenu", () => { let container: HTMLDivElement; @@ -97,6 +143,9 @@ describe("SidebarAccountMenu", () => { enableIsolatedWorkspaces: false, }); mockAuthApi.signOut.mockResolvedValue({ success: true, redirectTo: "/cloud/logout" }); + // Default to a company owner: the self-hosted Invite shortcut needs the + // `users:invite` grant, which owners and admins hold. + mockGetCurrentBoardAccess.mockResolvedValue(boardAccess("owner")); }); afterEach(() => { @@ -347,7 +396,6 @@ describe("SidebarAccountMenu", () => { }); await flushReact(); - expect(document.body.textContent).toContain("Edit profile"); expect(document.body.textContent).toContain("Settings"); expect(document.body.textContent).not.toContain("Instance settings"); expect(document.body.textContent).toContain("Documentation"); @@ -356,6 +404,35 @@ describe("SidebarAccountMenu", () => { expect(popover?.textContent).not.toContain("Feedback"); expect(popover?.querySelector('a[href="https://paperclip.ing/feedback"]')).toBeNull(); + // Profile access moved off the action list: the header links to the + // profile, and the separate view/edit rows are gone. Editing lives on the + // profile page itself. + expect(popover?.textContent).not.toContain("View profile"); + expect(popover?.textContent).not.toContain("Edit profile"); + // The link resolves by user id, not display name: names are not unique, + // and the profile endpoint returns the first member whose slug matches. + const profileHeaderLink = popover?.querySelector('a[aria-label="View profile"]'); + expect(profileHeaderLink?.getAttribute("href")).toBe("/u/user-1"); + // The link is a stretched overlay over the header block (so the staging + // SHA anchor can live beside the email without nesting anchors), so the + // identity text is in the header block rather than inside the anchor. + const profileHeader = profileHeaderLink?.parentElement; + expect(profileHeader?.textContent).toContain("Jane Example"); + expect(profileHeader?.textContent).toContain("jane@example.com"); + expect(profileHeader?.querySelector("a a")).toBeNull(); + expect(popover?.querySelector('a[href="/company/settings/instance/profile"]')).toBeNull(); + + // Self-hosted invites open the in-app Members page on its Invites tab. + const inviteLink = popover?.querySelector( + 'a[href="/company/settings/members?tab=invites"]', + ); + expect(inviteLink?.textContent).toBe("Invite"); + expect(inviteLink?.querySelector("svg")?.classList).toContain("lucide-user-plus"); + expect(inviteLink?.hasAttribute("target")).toBe(false); + const menuOrder = popover?.textContent ?? ""; + expect(menuOrder.indexOf("Settings")).toBeLessThan(menuOrder.indexOf("Invite")); + expect(menuOrder.indexOf("Invite")).toBeLessThan(menuOrder.indexOf("Documentation")); + // Documentation still appears before the theme toggle. const menuText = popover?.textContent ?? ""; const docsPos = menuText.indexOf("Documentation"); @@ -376,7 +453,6 @@ describe("SidebarAccountMenu", () => { .not.toContain("bg-muted"); expect(document.body.textContent).not.toContain("Manage company and instance settings."); expect(document.body.textContent).not.toContain("Open your activity, task, and usage ledger."); - expect(document.body.querySelector('a[href="/company/settings/instance/profile"]')).not.toBeNull(); expect(document.body.querySelector('a[href="/company/settings"]')).not.toBeNull(); const signOutButton = Array.from(document.body.querySelectorAll("button")).find( @@ -412,6 +488,7 @@ describe("SidebarAccountMenu", () => { cloudBaseUrl: "https://cloud.example.test", }, }); + queryClient.setQueryData(queryKeys.cloud.stacks, cloudStacksPortfolio("owner")); await act(async () => { root.render( @@ -430,6 +507,20 @@ describe("SidebarAccountMenu", () => { expect(container.querySelector('a[aria-label="Share feedback"]')).toBeNull(); + // Cloud manages invitations in the stack's People settings, reached with a + // same-tab top-level navigation rather than the in-app router. + const popover = document.body.querySelector('[data-slot="popover-content"]'); + const inviteLink = Array.from(popover?.querySelectorAll("a") ?? []).find( + (anchor) => anchor.textContent?.includes("Invite"), + ); + expect(inviteLink?.getAttribute("href")).toBe( + "https://cloud.example.test/workspaces/acme-labs/settings?section=people", + ); + expect(inviteLink?.hasAttribute("target")).toBe(false); + expect(popover?.querySelector('a[href="/company/settings/members?tab=invites"]')).toBeNull(); + expect(popover?.querySelector('a[aria-label="View profile"]')?.getAttribute("href")).toBe("/u/user-1"); + expect(popover?.textContent).not.toContain("Edit profile"); + const signOutButton = Array.from(document.body.querySelectorAll("button")).find( (button) => button.textContent?.includes("Sign out"), ); @@ -448,6 +539,198 @@ describe("SidebarAccountMenu", () => { }); }); + it.each([SidebarAccountMenu, ProductionSidebarAccountMenu])("offers no cloud invite shortcut to members below stack admin (%#)", async (AccountMenu) => { + const root = createRoot(container); + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + queryClient.setQueryData(queryKeys.health, { + status: "ok", + deploymentMode: "authenticated", + cloud: { + managed: true, + managedBy: "paperclip-cloud", + stackSlug: "acme-labs", + cloudBaseUrl: "https://cloud.example.test", + }, + }); + // Company roles can differ from Cloud roles; the Members page only offers + // the Cloud invite link to the current stack's owner/admin, so the menu + // must match rather than send a plain member to People settings. + queryClient.setQueryData(queryKeys.cloud.stacks, cloudStacksPortfolio("member")); + + await act(async () => { + root.render( + + + + + , + ); + }); + await flushReact(); + + const popover = document.body.querySelector('[data-slot="popover-content"]'); + expect(popover?.textContent).not.toContain("Invite"); + expect(popover?.querySelector('a[href^="https://cloud.example.test/"]')).toBeNull(); + expect(popover?.querySelector('a[href="/company/settings/members?tab=invites"]')).toBeNull(); + expect(popover?.querySelector('a[aria-label="View profile"]')).not.toBeNull(); + + await act(async () => { + root.unmount(); + }); + }); + + it.each([SidebarAccountMenu, ProductionSidebarAccountMenu])("never falls back to in-app invites when cloud stack metadata is missing (%#)", async (AccountMenu) => { + const root = createRoot(container); + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + // Managed instance whose health block lacks the stack slug: the portfolio + // has no current stack either, so no Cloud People URL can be built. The + // in-app Invites tab drives a different flow and must not appear instead. + queryClient.setQueryData(queryKeys.health, { + status: "ok", + deploymentMode: "authenticated", + cloud: { + managed: true, + managedBy: "paperclip-cloud", + stackSlug: null, + cloudBaseUrl: "https://cloud.example.test", + }, + }); + queryClient.setQueryData(queryKeys.cloud.stacks, { stacks: [] }); + + await act(async () => { + root.render( + + + + + , + ); + }); + await flushReact(); + + const popover = document.body.querySelector('[data-slot="popover-content"]'); + expect(popover?.textContent).not.toContain("Invite"); + expect(popover?.querySelector('a[href="/company/settings/members?tab=invites"]')).toBeNull(); + expect(popover?.textContent).toContain("Documentation"); + + await act(async () => { + root.unmount(); + }); + }); + + it.each([SidebarAccountMenu, ProductionSidebarAccountMenu])("hides the invite shortcut when the operator hides invites (%#)", async (AccountMenu) => { + const root = createRoot(container); + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + queryClient.setQueryData(queryKeys.health, { + status: "ok", + deploymentMode: "authenticated", + hiddenSettings: ["company.invites"], + }); + + await act(async () => { + root.render( + + + + + , + ); + }); + await flushReact(); + + const popover = document.body.querySelector('[data-slot="popover-content"]'); + expect(popover?.textContent).not.toContain("Invite"); + expect(popover?.querySelector('a[href="/company/settings/members?tab=invites"]')).toBeNull(); + // The settings-independent entries stay put. + expect(popover?.querySelector('a[aria-label="View profile"]')).not.toBeNull(); + expect(popover?.textContent).toContain("Documentation"); + + await act(async () => { + root.unmount(); + }); + }); + + it.each([SidebarAccountMenu, ProductionSidebarAccountMenu])("offers no self-hosted invite shortcut to members without the invite grant (%#)", async (AccountMenu) => { + const root = createRoot(container); + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + queryClient.setQueryData(queryKeys.health, { + status: "ok", + deploymentMode: "authenticated", + }); + // Operators and viewers lack `users:invite`; the Invites tab would only + // show them a permission error, so the menu must not offer it. + mockGetCurrentBoardAccess.mockResolvedValue(boardAccess("operator")); + + await act(async () => { + root.render( + + + + + , + ); + }); + await flushReact(); + await flushReact(); + + expect(mockGetCurrentBoardAccess).toHaveBeenCalledOnce(); + const popover = document.body.querySelector('[data-slot="popover-content"]'); + expect(popover?.textContent).not.toContain("Invite"); + expect(popover?.querySelector('a[href="/company/settings/members?tab=invites"]')).toBeNull(); + expect(popover?.querySelector('a[aria-label="View profile"]')).not.toBeNull(); + expect(popover?.textContent).toContain("Documentation"); + + await act(async () => { + root.unmount(); + }); + }); + + it.each([ + ["instance admin", boardAccess("viewer", { isInstanceAdmin: true })], + ["local board", boardAccess("viewer", { source: "local_implicit" })], + ["company admin", boardAccess("admin")], + ])("offers the self-hosted invite shortcut to a %s", async (_label, access) => { + const root = createRoot(container); + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + queryClient.setQueryData(queryKeys.health, { + status: "ok", + deploymentMode: "authenticated", + }); + mockGetCurrentBoardAccess.mockResolvedValue(access); + + await act(async () => { + root.render( + + + + + , + ); + }); + await flushReact(); + await flushReact(); + + const popover = document.body.querySelector('[data-slot="popover-content"]'); + const inviteLink = popover?.querySelector( + 'a[href="/company/settings/members?tab=invites"]', + ); + expect(inviteLink?.textContent).toBe("Invite"); + + await act(async () => { + root.unmount(); + }); + }); + it("keeps sign-out hidden outside authenticated deployment mode", async () => { const root = createRoot(container); const queryClient = new QueryClient({ diff --git a/ui/src/components/SidebarAccountMenu.tsx b/ui/src/components/SidebarAccountMenu.tsx index d6552c3a50..f40959aa70 100644 --- a/ui/src/components/SidebarAccountMenu.tsx +++ b/ui/src/components/SidebarAccountMenu.tsx @@ -6,16 +6,19 @@ import { LogOut, Settings, 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"; @@ -24,7 +27,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"; @@ -41,7 +44,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) { @@ -52,26 +62,13 @@ 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, 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"; @@ -94,6 +91,14 @@ function MenuAction({ ); } + if (topLevel) { + return ( + + {content} + + ); + } + return ( {content} @@ -114,7 +119,26 @@ export function SidebarAccountMenu({ onOpenChange, forceExpanded = false, }: 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 && !forceExpanded; @@ -133,7 +157,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); @@ -170,12 +194,19 @@ export function SidebarAccountMenu({ sideOffset={10} className="min-h-(--profile-popover-min-height) w-(--profile-popover-width) max-w-(--sz-calc-24) overflow-hidden rounded-xl border-border bg-popover p-0 shadow-(--shadow-profile-popover)" > -
- + {/* 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}

@@ -184,7 +215,7 @@ export function SidebarAccountMenu({

{stagingCommit ? ( - - + {showInvite && inviteHref ? ( + + ) : null} cloudApi.listStacks(), + enabled: isCloud, + staleTime: 30_000, + retry: false, + }); + if (!cloud || cloudStacksQuery.isError) return null; + const currentStack = cloudStacksQuery.data?.stacks.find((stack) => stack.isCurrent); + if (currentStack?.role !== "owner" && currentStack?.role !== "admin") return null; + return cloudStackInviteUrl(cloud.cloudBaseUrl, currentStack.stackSlug); +} diff --git a/ui/src/hooks/useCompanyInviteAccess.test.ts b/ui/src/hooks/useCompanyInviteAccess.test.ts new file mode 100644 index 0000000000..1939426a12 --- /dev/null +++ b/ui/src/hooks/useCompanyInviteAccess.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, it } from "vitest"; +import type { CurrentBoardAccess } from "@/api/access"; +import { canInviteCompanyMembers } from "./useCompanyInviteAccess"; + +function snapshot(overrides: Partial = {}): CurrentBoardAccess { + return { + user: { id: "user-1", email: "jane@example.com", name: "Jane Example", image: null }, + userId: "user-1", + isInstanceAdmin: false, + companyIds: ["company-1"], + memberships: [{ companyId: "company-1", membershipRole: "operator", status: "active" }], + source: "session", + keyId: null, + ...overrides, + }; +} + +describe("canInviteCompanyMembers", () => { + it("is false until the board access snapshot is known", () => { + expect(canInviteCompanyMembers("company-1", undefined)).toBe(false); + }); + + it("always passes local boards and instance admins", () => { + expect(canInviteCompanyMembers("company-1", snapshot({ source: "local_implicit" }))).toBe(true); + expect(canInviteCompanyMembers("company-1", snapshot({ isInstanceAdmin: true }))).toBe(true); + // Both bypass the company lookup, so a missing company is fine. + expect(canInviteCompanyMembers(null, snapshot({ isInstanceAdmin: true }))).toBe(true); + }); + + it.each(["owner", "admin"] as const)("passes an active company %s", (membershipRole) => { + const access = snapshot({ + memberships: [{ companyId: "company-1", membershipRole, status: "active" }], + }); + expect(canInviteCompanyMembers("company-1", access)).toBe(true); + }); + + it.each(["operator", "viewer", "member", null] as const)("rejects a company %s", (membershipRole) => { + const access = snapshot({ + memberships: [{ companyId: "company-1", membershipRole, status: "active" }], + }); + expect(canInviteCompanyMembers("company-1", access)).toBe(false); + }); + + it("ignores inactive memberships and other companies", () => { + const suspended = snapshot({ + memberships: [{ companyId: "company-1", membershipRole: "owner", status: "suspended" }], + }); + expect(canInviteCompanyMembers("company-1", suspended)).toBe(false); + const elsewhere = snapshot({ + memberships: [{ companyId: "company-2", membershipRole: "owner", status: "active" }], + }); + expect(canInviteCompanyMembers("company-1", elsewhere)).toBe(false); + expect(canInviteCompanyMembers(null, elsewhere)).toBe(false); + }); + + it("is false when the snapshot carries no memberships", () => { + expect(canInviteCompanyMembers("company-1", snapshot({ memberships: undefined }))).toBe(false); + }); +}); diff --git a/ui/src/hooks/useCompanyInviteAccess.ts b/ui/src/hooks/useCompanyInviteAccess.ts new file mode 100644 index 0000000000..7eec8933fc --- /dev/null +++ b/ui/src/hooks/useCompanyInviteAccess.ts @@ -0,0 +1,46 @@ +import { useQuery } from "@tanstack/react-query"; +import { accessApi, type CurrentBoardAccess } from "@/api/access"; +import { queryKeys } from "@/lib/queryKeys"; +import { useOptionalCompany } from "@/context/CompanyContext"; + +/** + * Whether a board may invite people to `companyId` on a self-hosted instance, + * judged from the current board access snapshot. + * + * Mirrors the other client-side role gates (`ToolsAdminGate`, the run + * ledger): local implicit boards and instance admins always pass; otherwise + * the active company membership must be owner or admin, the roles that carry + * the `users:invite` grant. Resolves to false while the snapshot is unknown. + * The server stays authoritative; this only decides whether to offer a + * shortcut that would otherwise end in a permission error. + */ +export function canInviteCompanyMembers( + companyId: string | null | undefined, + boardAccess: CurrentBoardAccess | undefined, +): boolean { + if (!boardAccess) return false; + if (boardAccess.source === "local_implicit" || boardAccess.isInstanceAdmin) return true; + if (!companyId) return false; + const membership = boardAccess.memberships?.find( + (item) => item.companyId === companyId && item.status === "active", + ); + return membership?.membershipRole === "owner" || membership?.membershipRole === "admin"; +} + +/** + * Whether the signed-in board may invite people to the selected company on a + * self-hosted instance. Pass `enabled: false` on Cloud, where invitations live + * in the stack's People settings and use a different rule + * (see `useCloudInviteUrl`). + */ +export function useCanInviteCompanyMembers(enabled = true): boolean { + const company = useOptionalCompany(); + const boardAccess = useQuery({ + queryKey: queryKeys.access.currentBoardAccess, + queryFn: () => accessApi.getCurrentBoardAccess(), + enabled, + retry: false, + staleTime: 30_000, + }); + return enabled && canInviteCompanyMembers(company?.selectedCompanyId, boardAccess.data); +} diff --git a/ui/src/lib/userProfileLinks.test.ts b/ui/src/lib/userProfileLinks.test.ts new file mode 100644 index 0000000000..19a5c5e891 --- /dev/null +++ b/ui/src/lib/userProfileLinks.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, it } from "vitest"; +import { userProfilePath } from "./userProfileLinks"; + +describe("userProfilePath", () => { + it("links by user id even when a display name is available", () => { + // Two members can share a name; the endpoint returns the first slug match, + // so a name-based link could open a namesake's profile. + expect(userProfilePath({ id: "user-1", name: "Jane Example", email: "jane@example.com" })).toBe("/u/user-1"); + }); + + it("slugifies ids the same way the profile endpoint does", () => { + expect(userProfilePath({ id: "paperclip-id:TLqGPuleJpjX" })).toBe("/u/paperclip-id-tlqgpulejpjx"); + }); + + it("falls back to name, then email, when the session carries no id", () => { + expect(userProfilePath({ id: null, name: "Jane Example", email: "jane@example.com" })).toBe("/u/jane-example"); + expect(userProfilePath({ id: "", name: " ", email: "jane.doe@example.com" })).toBe("/u/jane-doe"); + expect(userProfilePath({ email: "@example.com" })).toBe("/u/example-com"); + }); + + it("uses a placeholder when nothing identifies the user", () => { + expect(userProfilePath(undefined)).toBe("/u/me"); + expect(userProfilePath({ id: "!!!", name: "'\"" })).toBe("/u/me"); + }); +}); diff --git a/ui/src/lib/userProfileLinks.ts b/ui/src/lib/userProfileLinks.ts new file mode 100644 index 0000000000..4fcc3c6cc0 --- /dev/null +++ b/ui/src/lib/userProfileLinks.ts @@ -0,0 +1,34 @@ +interface ProfileLinkUser { + id?: string | null; + name?: string | null; + email?: string | null; +} + +function slugifyUserPart(value: string | null | undefined): string | null { + const slug = value + ?.trim() + .toLowerCase() + .replace(/['"]/g, "") + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, ""); + return slug || null; +} + +/** + * In-app path of a user's profile page (`/u/:userSlug`). + * + * The profile endpoint resolves `:userSlug` against every member's name, + * email local part, email, and principal id, and returns the first member + * that matches. Display names are not unique, so a name-based slug can open a + * namesake's profile instead of the intended one. The principal id is the one + * candidate that is unique per member, so links to a known user always use + * it; name and email slugs are only a fallback for sessions with no user id. + */ +export function userProfilePath(user: ProfileLinkUser | null | undefined): string { + const candidates = [user?.id, user?.name, user?.email?.split("@")[0], user?.email]; + for (const candidate of candidates) { + const slug = slugifyUserPart(candidate); + if (slug) return `/u/${slug}`; + } + return "/u/me"; +} diff --git a/ui/src/pages/UserProfile.test.tsx b/ui/src/pages/UserProfile.test.tsx new file mode 100644 index 0000000000..3d7b8517c0 --- /dev/null +++ b/ui/src/pages/UserProfile.test.tsx @@ -0,0 +1,177 @@ +// @vitest-environment jsdom + +import { act } from "react"; +import { createRoot } from "react-dom/client"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { UserProfileResponse } from "@paperclipai/shared"; +import { queryKeys } from "../lib/queryKeys"; +import { UserProfile } from "./UserProfile"; + +const mockAuthApi = vi.hoisted(() => ({ + getSession: vi.fn(), +})); + +const mockUserProfilesApi = vi.hoisted(() => ({ + get: vi.fn(), +})); + +const mockSetBreadcrumbs = vi.hoisted(() => vi.fn()); +const mockUseParams = vi.hoisted(() => vi.fn()); + +vi.mock("../api/auth", () => ({ + authApi: mockAuthApi, +})); + +vi.mock("../api/userProfiles", () => ({ + userProfilesApi: mockUserProfilesApi, +})); + +vi.mock("../context/BreadcrumbContext", () => ({ + useBreadcrumbs: () => ({ + setBreadcrumbs: mockSetBreadcrumbs, + }), +})); + +vi.mock("../context/CompanyContext", () => ({ + useCompany: () => ({ + selectedCompanyId: "company-1", + selectedCompany: { id: "company-1", name: "Paperclip", issuePrefix: "PAP" }, + }), +})); + +vi.mock("@/lib/router", () => ({ + Link: ({ children, to, ...props }: { children: React.ReactNode; to: string }) => ( + {children} + ), + useParams: () => mockUseParams(), +})); + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true; + +async function flushReact() { + await act(async () => { + await Promise.resolve(); + await new Promise((resolve) => window.setTimeout(resolve, 0)); + }); +} + +function windowStats(key: "last7" | "last30" | "all", label: string): UserProfileResponse["stats"][number] { + return { + key, + label, + touchedIssues: 4, + createdIssues: 2, + completedIssues: 3, + assignedOpenIssues: 1, + commentCount: 5, + activityCount: 9, + costCents: 1200, + inputTokens: 1000, + cachedInputTokens: 200, + outputTokens: 300, + costEventCount: 2, + }; +} + +function profileFor(userId: string): UserProfileResponse { + return { + user: { + id: userId, + slug: "jane-example", + name: "Jane Example", + email: "jane@example.com", + image: null, + membershipRole: "owner", + membershipStatus: "active", + joinedAt: new Date("2026-01-05T00:00:00Z"), + }, + stats: [windowStats("last7", "Last 7 days"), windowStats("last30", "Last 30 days"), windowStats("all", "All time")], + daily: [], + recentIssues: [], + recentActivity: [], + topAgents: [], + topProviders: [], + }; +} + +const EDIT_PROFILE_HREF = "/company/settings/instance/profile"; + +describe("UserProfile", () => { + let container: HTMLDivElement; + + beforeEach(() => { + container = document.createElement("div"); + document.body.appendChild(container); + mockUseParams.mockReturnValue({ userSlug: "jane-example" }); + mockAuthApi.getSession.mockResolvedValue({ + session: { id: "session-1", userId: "user-1" }, + user: { + id: "user-1", + name: "Jane Example", + email: "jane@example.com", + image: null, + }, + }); + }); + + afterEach(() => { + container.remove(); + document.body.innerHTML = ""; + vi.clearAllMocks(); + }); + + async function render(profile: UserProfileResponse, hiddenSettings: string[] = []) { + mockUserProfilesApi.get.mockResolvedValue(profile); + const root = createRoot(container); + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + queryClient.setQueryData(queryKeys.health, { + status: "ok", + deploymentMode: "authenticated", + hiddenSettings, + }); + + await act(async () => { + root.render( + + + , + ); + }); + await flushReact(); + await flushReact(); + return root; + } + + it("offers an edit link on the viewer's own profile", async () => { + const root = await render(profileFor("user-1")); + + expect(container.textContent).toContain("Jane Example"); + const editLink = container.querySelector(`a[href="${EDIT_PROFILE_HREF}"]`); + expect(editLink).not.toBeNull(); + expect(editLink?.textContent).toContain("Edit profile"); + expect(editLink?.querySelector("svg")?.classList).toContain("lucide-user-round-pen"); + + await act(async () => root.unmount()); + }); + + it("keeps other users' profiles read-only", async () => { + const root = await render(profileFor("user-2")); + + expect(container.textContent).toContain("Jane Example"); + expect(container.querySelector(`a[href="${EDIT_PROFILE_HREF}"]`)).toBeNull(); + expect(container.textContent).not.toContain("Edit profile"); + + await act(async () => root.unmount()); + }); + + it("drops the edit link when the operator hides profile settings", async () => { + const root = await render(profileFor("user-1"), ["instance.profile"]); + + expect(container.textContent).toContain("Jane Example"); + expect(container.querySelector(`a[href="${EDIT_PROFILE_HREF}"]`)).toBeNull(); + + await act(async () => root.unmount()); + }); +}); diff --git a/ui/src/pages/UserProfile.tsx b/ui/src/pages/UserProfile.tsx index f91e33c2f9..5bca922917 100644 --- a/ui/src/pages/UserProfile.tsx +++ b/ui/src/pages/UserProfile.tsx @@ -1,15 +1,22 @@ import { useEffect, useMemo } from "react"; import { useQuery } from "@tanstack/react-query"; -import { AlertCircle, UserRound } from "lucide-react"; -import type { UserProfileDailyPoint, UserProfileWindowStats } from "@paperclipai/shared"; +import { AlertCircle, UserRound, UserRoundPen } from "lucide-react"; +import { + hidesInstancePage, + type UserProfileDailyPoint, + type UserProfileWindowStats, +} from "@paperclipai/shared"; import { Link, useParams } from "@/lib/router"; +import { authApi } from "../api/auth"; import { userProfilesApi } from "../api/userProfiles"; import { Avatar, AvatarFallback, AvatarImage } from "../components/ui/avatar"; +import { Button } from "../components/ui/button"; import { EmptyState } from "../components/EmptyState"; import { PageSkeleton } from "../components/PageSkeleton"; import { IssueStatusBadge } from "../components/StatusBadge"; import { useBreadcrumbs } from "../context/BreadcrumbContext"; import { useCompany } from "../context/CompanyContext"; +import { useHiddenSettings } from "../hooks/useHiddenSettings"; import { queryKeys } from "../lib/queryKeys"; import { formatCents, @@ -23,6 +30,7 @@ import { } from "../lib/utils"; const NO_COMPANY = "__none__"; +const PROFILE_SETTINGS_PATH = "/company/settings/instance/profile"; function initials(name: string | null | undefined) { const value = name?.trim() || "User"; @@ -205,6 +213,18 @@ export function UserProfile() { queryFn: () => userProfilesApi.get(companyId, userSlug), enabled: !!selectedCompanyId && !!userSlug, }); + // The edit link only belongs on the viewer's own profile, and never when the + // hosting operator hides the profile settings surface the link points at. + const { data: session } = useQuery({ + queryKey: queryKeys.auth.session, + queryFn: () => authApi.getSession(), + retry: false, + }); + const { hidden: hiddenSettings } = useHiddenSettings(); + const canEditProfile = + Boolean(data?.user.id) && + data?.user.id === session?.user.id && + !hidesInstancePage(hiddenSettings, "instance.profile"); useEffect(() => { setBreadcrumbs([{ label: "Users" }, { label: data?.user.name ?? userSlug }]); @@ -280,6 +300,14 @@ export function UserProfile() { {metaParts.join(" ยท ")}
+ {canEditProfile ? ( + + ) : null}