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
+