From a4f1b3c5330d35dff8171348cb76f404d253105c Mon Sep 17 00:00:00 2001 From: Devin Foley Date: Thu, 27 Aug 2026 11:34:32 -0700 Subject: [PATCH] Move company invites into a tab of the Members page (#12289) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work > - Company settings include member management and invites, which are one workflow: invite someone, watch the join request, manage the membership > - Today invites sit on a standalone settings page, costing a sidebar entry and forcing people to bounce between two pages for one task > - Merging invites into the Members page keeps the workflow in one place without losing any capability > - This pull request turns the Members page into a Members/Invites tab bar, redirects the old URL, and keeps both operator-visibility keys meaningful > - The benefit is a tighter settings surface for every deployment, with `company.invites` now hiding just the tab while `company.members` hides the whole page ## Linked Issues or Issue Description No public issue exists; describing the issue inline per the enhancement template: **What existing behavior does this improve?** Company invites live on a standalone settings page separate from the Members page, even though inviting someone and managing the resulting membership are one workflow. **Subsystem affected** Web UI (company settings) **Current behavior** `/company/settings/invites` is its own page with its own sidebar entry (gated by `company.invites`); the Members page (`company.members`) is separate. The sidebar company menu's "Invite people" shortcut links to the invites page and is not gated by the hidden-settings mechanism at all. **Proposed behavior** The Members page carries a Members/Invites tab bar, addressable via `?tab=invites`. The invite creation flow, latest-link panel, and invite history move unchanged into an `InvitesSection` component. The old URL redirects to the tab (still behind its `HiddenSettingsPageGate`). `company.members` hides the whole page; `company.invites` hides just the Invites tab, and the tab bar collapses when only Members remains. The "Invite people" shortcut points at the tab and hides when either surface is operator-hidden. **Reason and benefit** One settings surface for one workflow: fewer sidebar entries and no bouncing between two pages to invite someone and then manage the membership. Operators keep the same visibility controls, with a sharper meaning for each key. **Breaking changes** None. Bookmarks to the old invites URL redirect to the tab, invite and membership APIs are unchanged, and both hidden-settings keys keep working. ## What Changed - `ui/src/pages/CompanyInvites.tsx` → `ui/src/components/access/InvitesSection.tsx` (page chrome and breadcrumbs dropped; content unchanged), with its tests moved alongside. - `ui/src/pages/CompanyAccess.tsx`: Members/Invites tabs via the shared `PageTabBar`, `?tab=invites` search param, `company.invites` gating with tab snap-back; legacy "Open Invites" button retargeted. - `ui/src/App.tsx`: the invites route becomes a gated redirect to `/company/settings/members?tab=invites`. - `CompanySettingsSidebar` / `CompanySettingsNav`: standalone Invites entry/tab removed; the old path maps to the members tab. - `ui/src/components/SidebarCompanyMenu.tsx`: "Invite people" now links to the tab and hides when `company.members` or `company.invites` is hidden (closes an existing gating gap). - Tests: moved invites tests, new tab coverage (default tab, deep link, operator-hidden tab skips the invites fetch), sidebar/nav suites updated. ## Verification - `npx vitest run ui/src/components/CompanySettingsSidebar.test.tsx ui/src/components/access/CompanySettingsNav.test.tsx ui/src/pages/CompanyAccess.test.tsx ui/src/components/access/InvitesSection.test.tsx` — 24 tests passing. - `pnpm --filter @paperclipai/ui typecheck` — clean. ## Risks - Low. Pure UI restructure: invite APIs, membership APIs, and the hidden-settings registry keys are unchanged. Bookmarks to the old invites URL redirect (and stay gated). The `company.invites` key's meaning narrows from "hide the page" to "hide the tab", which is the same effective surface. ## Model Used Claude (Anthropic), model id `claude-fable-5`, extended thinking, agentic tool use via Claude Code. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [ ] All Paperclip CI gates are green - [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --- ui/src/App.tsx | 8 +- .../CompanySettingsSidebar.test.tsx | 8 -- ui/src/components/CompanySettingsSidebar.tsx | 4 - ui/src/components/Layout.test.tsx | 4 +- ui/src/components/SidebarCompanyMenu.test.tsx | 54 +++++++++ ui/src/components/SidebarCompanyMenu.tsx | 47 +++++--- .../access/CompanySettingsNav.test.tsx | 9 +- .../components/access/CompanySettingsNav.tsx | 5 +- .../access/InvitesSection.test.tsx} | 11 +- .../access/InvitesSection.tsx} | 30 ++--- ui/src/pages/CompanyAccess.test.tsx | 107 ++++++++++++++++++ ui/src/pages/CompanyAccess.tsx | 51 ++++++++- 12 files changed, 266 insertions(+), 72 deletions(-) rename ui/src/{pages/CompanyInvites.test.tsx => components/access/InvitesSection.test.tsx} (97%) rename ui/src/{pages/CompanyInvites.tsx => components/access/InvitesSection.tsx} (94%) diff --git a/ui/src/App.tsx b/ui/src/App.tsx index c7f59babf0..66490c991c 100644 --- a/ui/src/App.tsx +++ b/ui/src/App.tsx @@ -68,7 +68,6 @@ import { AppDetail } from "./pages/apps/AppDetail"; import { AppNotConnected } from "./pages/apps/AppNotConnected"; import { GatewaysList } from "./pages/apps/gateways/GatewaysList"; import { GatewayDetail } from "./pages/apps/gateways/GatewayDetail"; -import { CompanyInvites } from "./pages/CompanyInvites"; import { CompanySkills } from "./pages/CompanySkills"; import { SkillStudio } from "./pages/SkillStudio"; import { Secrets } from "./pages/Secrets"; @@ -122,8 +121,13 @@ function boardRoutes() { } /> } /> + {/* Invites moved into the Members page; the old URL redirects (and stays + gated so a hidden Invites surface never round-trips through it). */} }> - } /> + } + /> }> { expect(container.textContent).toContain("Export"); expect(container.textContent).toContain("Import"); expect(container.textContent).toContain("Members"); - expect(container.textContent).toContain("Invites"); expect(container.textContent).toContain("Secrets"); expect(container.textContent).toContain("Access"); expect(container.textContent).not.toContain("Tools & Access"); @@ -191,13 +190,6 @@ describe("CompanySettingsSidebar", () => { end: true, }), ); - expect(sidebarNavItemMock).toHaveBeenCalledWith( - expect.objectContaining({ - to: "/company/settings/invites", - label: "Invites", - end: true, - }), - ); expect(sidebarNavItemMock).toHaveBeenCalledWith( expect.objectContaining({ to: "/company/settings/secrets", diff --git a/ui/src/components/CompanySettingsSidebar.tsx b/ui/src/components/CompanySettingsSidebar.tsx index ecb1c8fca3..f7c1977602 100644 --- a/ui/src/components/CompanySettingsSidebar.tsx +++ b/ui/src/components/CompanySettingsSidebar.tsx @@ -5,7 +5,6 @@ import { Download, FlaskConical, KeyRound, - MailPlus, MonitorCog, Puzzle, Shield, @@ -128,9 +127,6 @@ export function CompanySettingsSidebar() { end /> ))} - {showPage("company.invites") && ( - - )} {showPage("company.secrets") && ( )} diff --git a/ui/src/components/Layout.test.tsx b/ui/src/components/Layout.test.tsx index d5868016a1..6cdd9b8363 100644 --- a/ui/src/components/Layout.test.tsx +++ b/ui/src/components/Layout.test.tsx @@ -467,7 +467,9 @@ describe("Layout", () => { expect(selectorText).toContain("export"); expect(selectorText).toContain("import"); expect(selectorText).toContain("members"); - expect(selectorText).toContain("invites"); + // Invites live on a tab of the Members page now, so the selector no + // longer carries a standalone entry for them. + expect(selectorText).not.toContain("invites"); expect(selectorText).toContain("secrets"); expect(selectorText).toContain("profile"); expect(selectorText).toContain("environments"); diff --git a/ui/src/components/SidebarCompanyMenu.test.tsx b/ui/src/components/SidebarCompanyMenu.test.tsx index 52bb357074..db12a89d1b 100644 --- a/ui/src/components/SidebarCompanyMenu.test.tsx +++ b/ui/src/components/SidebarCompanyMenu.test.tsx @@ -316,6 +316,9 @@ describe("SidebarCompanyMenu", () => { const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } }, }); + // The invite shortcut waits for the health response before it shows, so + // resolve it here the way CloudAccessGate does in the app. + queryClient.setQueryData(queryKeys.health, { status: "ok" }); act(() => { root.render( @@ -366,6 +369,57 @@ describe("SidebarCompanyMenu", () => { }); }); + it("keeps the invite shortcut out of the menu until hidden settings resolve", async () => { + // No health data in the cache: the hidden-settings set is unknown, so the + // shortcut must not flash in and then disappear once the response lands. + const { root } = renderMenu(); + await flushReact(); + await flushReact(); + + await openMenu("Open Acme Labs organization switcher"); + + expect(document.body.textContent).toContain("Switch organization"); + expect(document.body.textContent).not.toContain("Invite people"); + + act(() => { + root.unmount(); + }); + }); + + it("hides the invite shortcut when the operator hides the invites surface", async () => { + const { root } = renderMenu({ + health: { status: "ok", hiddenSettings: ["company.invites"] }, + }); + await flushReact(); + await flushReact(); + + await openMenu("Open Acme Labs organization switcher"); + + expect(document.body.textContent).toContain("Switch organization"); + expect(document.body.textContent).not.toContain("Invite people"); + + act(() => { + root.unmount(); + }); + }); + + it("hides the invite shortcut when the operator hides the members page", async () => { + const { root } = renderMenu({ + health: { status: "ok", hiddenSettings: ["company.members"] }, + }); + await flushReact(); + await flushReact(); + + await openMenu("Open Acme Labs organization switcher"); + + expect(document.body.textContent).toContain("Switch organization"); + expect(document.body.textContent).not.toContain("Invite people"); + + act(() => { + root.unmount(); + }); + }); + it("toggles company order editing without selecting a company", async () => { const root = createRoot(container); const queryClient = new QueryClient({ diff --git a/ui/src/components/SidebarCompanyMenu.tsx b/ui/src/components/SidebarCompanyMenu.tsx index ec5728afd7..1380b7c8cf 100644 --- a/ui/src/components/SidebarCompanyMenu.tsx +++ b/ui/src/components/SidebarCompanyMenu.tsx @@ -20,7 +20,7 @@ import { } from "@dnd-kit/core"; import { SortableContext, arrayMove, useSortable, verticalListSortingStrategy } from "@dnd-kit/sortable"; import { CSS } from "@dnd-kit/utilities"; -import type { Company } from "@paperclipai/shared"; +import { hidesCompanyPage, type Company } from "@paperclipai/shared"; import { Link, useLocation, useNavigate } from "@/lib/router"; import { authApi } from "@/api/auth"; import { cloudApi, type CloudStackSummary } from "@/api/cloud"; @@ -36,6 +36,7 @@ import { import { useCompany } from "@/context/CompanyContext"; import { useDialogActions } from "@/context/DialogContext"; import { useCloudInstance } from "@/hooks/useCloudInstance"; +import { useHiddenSettings } from "@/hooks/useHiddenSettings"; import { useCompanyOrder } from "@/hooks/useCompanyOrder"; import { useSignOut } from "@/hooks/useSignOut"; import { navigateTopLevel } from "@/lib/browserNavigation"; @@ -236,6 +237,14 @@ export function SidebarCompanyMenu({ open: controlledOpen, onOpenChange }: Sideb // exactly one company, and switching means leaving this tenant host entirely. const cloud = useCloudInstance(); const isCloud = Boolean(cloud); + // Invites now live on the Members page; hide the shortcut when the hosting + // operator hides either surface. Until the health response resolves, the + // hidden set is unknown — keep the shortcut out rather than flash it. + const { hidden: hiddenSettings, loaded: hiddenSettingsLoaded } = useHiddenSettings(); + const showInvitePeople = + hiddenSettingsLoaded && + !hidesCompanyPage(hiddenSettings, "company.members") && + !hidesCompanyPage(hiddenSettings, "company.invites"); const cloudBaseUrl = cloud?.cloudBaseUrl ?? null; const stacksQuery = useQuery({ queryKey: queryKeys.cloud.stacks, @@ -480,23 +489,25 @@ export function SidebarCompanyMenu({ open: controlledOpen, onOpenChange }: Sideb )} - - { - if (isEditingOrder) { - event.preventDefault(); - return; - } - closeNavigationChrome(); - }} - > - - - {currentName ? `Invite people to ${currentName}` : "Invite people"} - - - + {showInvitePeople ? ( + + { + if (isEditingOrder) { + event.preventDefault(); + return; + } + closeNavigationChrome(); + }} + > + + + {currentName ? `Invite people to ${currentName}` : "Invite people"} + + + + ) : null} {session?.session ? ( <> diff --git a/ui/src/components/access/CompanySettingsNav.test.tsx b/ui/src/components/access/CompanySettingsNav.test.tsx index 683e081393..9b145035e1 100644 --- a/ui/src/components/access/CompanySettingsNav.test.tsx +++ b/ui/src/components/access/CompanySettingsNav.test.tsx @@ -31,7 +31,7 @@ vi.mock("@/components/PageTabBar", () => ({ return (
{props.value}
-
@@ -77,7 +77,7 @@ describe("CompanySettingsNav", () => { expect(getCompanySettingsTab("/PAP/company/settings/members")).toBe("members"); expect(getCompanySettingsTab("/company/settings/access")).toBe("members"); expect(getCompanySettingsTab("/PAP/company/settings/access")).toBe("members"); - expect(getCompanySettingsTab("/company/settings/invites")).toBe("invites"); + expect(getCompanySettingsTab("/company/settings/invites")).toBe("members"); expect(getCompanySettingsTab("/PAP/company/settings/secrets")).toBe("secrets"); expect(getCompanySettingsTab("/company/settings/instance/profile")).toBe("instance-profile"); expect(getCompanySettingsTab("/PAP/company/settings/instance/general")).toBe("general"); @@ -124,7 +124,6 @@ describe("CompanySettingsNav", () => { { value: "export", label: "Export" }, { value: "import", label: "Import" }, { value: "members", label: "Members" }, - { value: "invites", label: "Invites" }, { value: "secrets", label: "Secrets" }, { value: "instance-profile", label: "Profile" }, { value: "instance-environments", label: "Environments" }, @@ -143,7 +142,7 @@ describe("CompanySettingsNav", () => { button?.dispatchEvent(new MouseEvent("click", { bubbles: true })); }); - expect(navigateMock).toHaveBeenCalledWith("/company/settings/invites"); + expect(navigateMock).toHaveBeenCalledWith("/company/settings/secrets"); await act(async () => { root.unmount(); @@ -166,7 +165,6 @@ describe("CompanySettingsNav", () => { "export", "import", "members", - "invites", "secrets", "instance-profile", "instance-environments", @@ -195,7 +193,6 @@ describe("CompanySettingsNav", () => { expect(renderedValues).not.toContain("secrets"); expect(renderedValues).toContain("export"); expect(renderedValues).toContain("members"); - expect(renderedValues).toContain("invites"); await act(async () => { root.unmount(); diff --git a/ui/src/components/access/CompanySettingsNav.tsx b/ui/src/components/access/CompanySettingsNav.tsx index a05b7acd55..ff59aac5af 100644 --- a/ui/src/components/access/CompanySettingsNav.tsx +++ b/ui/src/components/access/CompanySettingsNav.tsx @@ -10,7 +10,6 @@ const items = [ { value: "export", label: "Export", href: "/company/export" }, { value: "import", label: "Import", href: "/company/import" }, { value: "members", label: "Members", href: "/company/settings/members" }, - { value: "invites", label: "Invites", href: "/company/settings/invites" }, { value: "secrets", label: "Secrets", href: "/company/settings/secrets" }, { value: "instance-profile", label: "Profile", href: `${INSTANCE_SETTINGS_PATH_PREFIX}/profile` }, { value: "instance-environments", label: "Environments", href: `${INSTANCE_SETTINGS_PATH_PREFIX}/environments` }, @@ -27,7 +26,6 @@ const hiddenSettingKeyByTab: Partial> = { export: "company.export", import: "company.import", members: "company.members", - invites: "company.invites", secrets: "company.secrets", "instance-profile": "instance.profile", "instance-environments": "instance.environments", @@ -83,7 +81,8 @@ export function getCompanySettingsTab(pathname: string): CompanySettingsTab { } if (pathname.includes("/company/settings/invites")) { - return "invites"; + // Invites live on the Members page now; the old URL redirects there. + return "members"; } if (pathname.includes("/company/settings/secrets")) { diff --git a/ui/src/pages/CompanyInvites.test.tsx b/ui/src/components/access/InvitesSection.test.tsx similarity index 97% rename from ui/src/pages/CompanyInvites.test.tsx rename to ui/src/components/access/InvitesSection.test.tsx index c7da613c50..be0403737f 100644 --- a/ui/src/pages/CompanyInvites.test.tsx +++ b/ui/src/components/access/InvitesSection.test.tsx @@ -5,7 +5,7 @@ import { createRoot } from "react-dom/client"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { MemoryRouter } from "react-router-dom"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { CompanyInvites } from "./CompanyInvites"; +import { InvitesSection } from "./InvitesSection"; import { queryKeys } from "@/lib/queryKeys"; const listInvitesMock = vi.hoisted(() => vi.fn()); @@ -49,7 +49,7 @@ async function flushReact() { }); } -describe("CompanyInvites", () => { +describe("InvitesSection", () => { let container: HTMLDivElement; const inviteHistory = Array.from({ length: 25 }, (_, index) => { const inviteNumber = 25 - index; @@ -128,7 +128,7 @@ describe("CompanyInvites", () => { root.render( - + , ); @@ -136,7 +136,6 @@ describe("CompanyInvites", () => { await flushReact(); await flushReact(); - expect(container.textContent).toContain("Organization Invites"); expect(container.textContent).toContain("Invite a person"); expect(container.textContent).not.toContain("Invite an agent"); expect(container.textContent).not.toContain("Generate agent onboarding prompt"); @@ -262,7 +261,7 @@ describe("CompanyInvites", () => { root.render( - + , ); @@ -305,7 +304,7 @@ describe("CompanyInvites", () => { root.render( - + , ); diff --git a/ui/src/pages/CompanyInvites.tsx b/ui/src/components/access/InvitesSection.tsx similarity index 94% rename from ui/src/pages/CompanyInvites.tsx rename to ui/src/components/access/InvitesSection.tsx index 8540cf784d..bc1c274156 100644 --- a/ui/src/pages/CompanyInvites.tsx +++ b/ui/src/components/access/InvitesSection.tsx @@ -1,10 +1,9 @@ import { useEffect, useMemo, useRef, useState } from "react"; import { useInfiniteQuery, useMutation, useQueryClient } from "@tanstack/react-query"; -import { Check, Copy, ExternalLink, MailPlus } from "lucide-react"; +import { Check, Copy, ExternalLink } from "lucide-react"; import { accessApi } from "@/api/access"; import { ApiError } from "@/api/client"; import { Button } from "@/components/ui/button"; -import { useBreadcrumbs } from "@/context/BreadcrumbContext"; import { useCompany } from "@/context/CompanyContext"; import { useToast } from "@/context/ToastContext"; import { Link } from "@/lib/router"; @@ -46,9 +45,9 @@ function isInviteHistoryRow(value: unknown): value is Awaited("operator"); @@ -88,14 +87,6 @@ export function CompanyInvites() { return copyText(url, "The invite URL is selected. Copy it manually from the field.", selectLatestInviteUrl); } - useEffect(() => { - setBreadcrumbs([ - { label: selectedCompany?.name ?? "Organization", href: "/dashboard" }, - { label: "Settings", href: "/company/settings" }, - { label: "Invites" }, - ]); - }, [selectedCompany?.name, setBreadcrumbs]); - const inviteHistoryQueryKey = queryKeys.access.invites(selectedCompanyId ?? "", "all", INVITE_HISTORY_PAGE_SIZE); const invitesQuery = useInfiniteQuery({ queryKey: inviteHistoryQueryKey, @@ -179,15 +170,10 @@ export function CompanyInvites() { return (
-
-
- -

Organization Invites

-
-

- Invite people to request access to this organization. New invite links are copied to your clipboard when they are generated. -

-
+

+ Invite people to request access to this organization. New invite links are copied to your clipboard when they are + generated. +

diff --git a/ui/src/pages/CompanyAccess.test.tsx b/ui/src/pages/CompanyAccess.test.tsx index 84ac8c8f66..e1bbf45f0c 100644 --- a/ui/src/pages/CompanyAccess.test.tsx +++ b/ui/src/pages/CompanyAccess.test.tsx @@ -14,6 +14,8 @@ const listAgentsMock = vi.hoisted(() => vi.fn()); const listIssuesMock = vi.hoisted(() => vi.fn()); const mockUsePluginSlots = vi.hoisted(() => vi.fn()); const mockNavigate = vi.hoisted(() => vi.fn()); +const listInvitesMock = vi.hoisted(() => vi.fn()); +const mockSearchParamsState = vi.hoisted(() => ({ current: new URLSearchParams() })); vi.mock("@/api/access", () => ({ accessApi: { @@ -27,6 +29,9 @@ vi.mock("@/api/access", () => ({ archiveMemberMock(companyId, memberId, input), approveJoinRequest: vi.fn(), rejectJoinRequest: vi.fn(), + listInvites: (companyId: string, options: unknown) => listInvitesMock(companyId, options), + createCompanyInvite: vi.fn(), + revokeInvite: vi.fn(), }, })); @@ -48,12 +53,31 @@ vi.mock("@/lib/router", () => ({ mockNavigate(to, replace); return
{to}
; }, + useSearchParams: () => [ + mockSearchParamsState.current, + ( + updater: + | URLSearchParams + | ((prev: URLSearchParams) => URLSearchParams), + ) => { + mockSearchParamsState.current = + typeof updater === "function" + ? updater(mockSearchParamsState.current) + : new URLSearchParams(updater); + }, + ], })); vi.mock("@/plugins/slots", () => ({ usePluginSlots: mockUsePluginSlots, })); +vi.mock("@/context/SidebarContext", () => ({ + useSidebar: () => ({ + isMobile: false, + }), +})); + vi.mock("@/context/CompanyContext", () => ({ useCompany: () => ({ selectedCompanyId: "company-1", @@ -85,6 +109,8 @@ describe("CompanyAccess", () => { beforeEach(() => { container = document.createElement("div"); document.body.appendChild(container); + mockSearchParamsState.current = new URLSearchParams(); + listInvitesMock.mockResolvedValue({ invites: [], nextOffset: null }); listMembersMock.mockResolvedValue({ members: [ { @@ -468,3 +494,84 @@ describe("CompanyAccess", () => { }); }); }); + +describe("CompanyAccess invites tab", () => { + let container: HTMLDivElement; + + beforeEach(() => { + container = document.createElement("div"); + document.body.appendChild(container); + mockSearchParamsState.current = new URLSearchParams(); + listInvitesMock.mockResolvedValue({ invites: [], nextOffset: null }); + listMembersMock.mockResolvedValue({ + members: [], + access: { currentUserRole: "owner", canApproveJoinRequests: false }, + }); + listAgentsMock.mockResolvedValue([]); + listJoinRequestsMock.mockResolvedValue([]); + mockUsePluginSlots.mockReturnValue([]); + }); + + afterEach(() => { + container.remove(); + document.body.innerHTML = ""; + vi.clearAllMocks(); + }); + + async function renderPage(queryClient?: QueryClient) { + const root = createRoot(container); + const client = + queryClient ?? new QueryClient({ defaultOptions: { queries: { retry: false } } }); + await act(async () => { + root.render( + + + , + ); + }); + await flushReact(); + await flushReact(); + return root; + } + + it("shows Members and Invites tabs with Members active by default", async () => { + const root = await renderPage(); + + const tabLabels = [...container.querySelectorAll('[role="tab"]')].map((tab) => tab.textContent); + expect(tabLabels).toEqual(["Members", "Invites"]); + expect(container.textContent).toContain("Organization Members"); + expect(container.textContent).not.toContain("Invite a person"); + + await act(async () => { + root.unmount(); + }); + }); + + it("opens the Invites tab from a ?tab=invites deep link", async () => { + mockSearchParamsState.current = new URLSearchParams("tab=invites"); + const root = await renderPage(); + + expect(container.textContent).toContain("Invite a person"); + expect(container.textContent).toContain("Invite history"); + + await act(async () => { + root.unmount(); + }); + }); + + it("hides the Invites tab when the operator hides company.invites", async () => { + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + client.setQueryData(["health"], { hiddenSettings: ["company.invites"] } as never); + mockSearchParamsState.current = new URLSearchParams("tab=invites"); + const root = await renderPage(client); + + expect(container.querySelectorAll('[role="tab"]')).toHaveLength(0); + expect(container.textContent).not.toContain("Invite a person"); + expect(container.textContent).toContain("Organization Members"); + expect(listInvitesMock).not.toHaveBeenCalled(); + + await act(async () => { + root.unmount(); + }); + }); +}); diff --git a/ui/src/pages/CompanyAccess.tsx b/ui/src/pages/CompanyAccess.tsx index 9f7e71da87..88e262e5a2 100644 --- a/ui/src/pages/CompanyAccess.tsx +++ b/ui/src/pages/CompanyAccess.tsx @@ -2,6 +2,7 @@ import { useEffect, useMemo, useState } from "react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { HUMAN_COMPANY_MEMBERSHIP_ROLE_LABELS, + hidesCompanyPage, type Agent, } from "@paperclipai/shared"; import { Shield, ShieldCheck, Trash2 } from "lucide-react"; @@ -23,9 +24,13 @@ import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; import { useBreadcrumbs } from "@/context/BreadcrumbContext"; import { useCompany } from "@/context/CompanyContext"; import { useToast } from "@/context/ToastContext"; -import { Link, Navigate } from "@/lib/router"; +import { Link, Navigate, useSearchParams } from "@/lib/router"; import { queryKeys } from "@/lib/queryKeys"; import { usePluginSlots } from "@/plugins/slots"; +import { Tabs, TabsContent } from "@/components/ui/tabs"; +import { PageTabBar } from "@/components/PageTabBar"; +import { useHiddenSettings } from "@/hooks/useHiddenSettings"; +import { InvitesSection } from "@/components/access/InvitesSection"; const reassignmentIssueStatuses = "backlog,todo,in_progress,in_review,blocked,failed,timed_out"; type EditableMemberStatus = "pending" | "active" | "suspended"; @@ -35,6 +40,27 @@ export function CompanyAccess() { const { setBreadcrumbs } = useBreadcrumbs(); const { pushToast } = useToast(); const queryClient = useQueryClient(); + const [searchParams, setSearchParams] = useSearchParams(); + // Invites render as a tab of this page; `company.invites` hides just that + // tab while `company.members` (the route gate) hides the whole page. + const { hidden: hiddenSettings } = useHiddenSettings(); + const hideInvitesTab = hidesCompanyPage(hiddenSettings, "company.invites"); + const requestedTab = searchParams.get("tab") === "invites" ? "invites" : "members"; + const activeTab = hideInvitesTab ? "members" : requestedTab; + const handleTabChange = (value: string) => { + setSearchParams( + (prev) => { + const next = new URLSearchParams(prev); + if (value === "invites") { + next.set("tab", "invites"); + } else { + next.delete("tab"); + } + return next; + }, + { replace: true }, + ); + }; const [editingMemberId, setEditingMemberId] = useState(null); const [removingMemberId, setRemovingMemberId] = useState(null); const [reassignmentTarget, setReassignmentTarget] = useState("__unassigned"); @@ -241,6 +267,20 @@ export function CompanyAccess() {

Organization Members

+ + {!hideInvitesTab && ( + + )} + + {access && !access.currentUserRole && (
This account can manage access here through instance-admin privileges, but it does not currently hold an active organization membership. @@ -519,6 +559,13 @@ export function CompanyAccess() { + + {!hideInvitesTab && ( + + + + )} +
); } @@ -575,7 +622,7 @@ export function CompanyAccessLegacyRoute() { Open Members