Move company invites into a tab of the Members page (#12289)

## 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
This commit is contained in:
Devin Foley 2026-08-27 11:34:32 -07:00 committed by GitHub
parent 7551b63ef2
commit a4f1b3c533
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 266 additions and 72 deletions

View File

@ -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() {
<Route path="company/settings/members" element={<CompanyAccess />} />
<Route path="company/settings/access" element={<CompanyAccessLegacyRoute />} />
</Route>
{/* Invites moved into the Members page; the old URL redirects (and stays
gated so a hidden Invites surface never round-trips through it). */}
<Route element={<HiddenSettingsPageGate pageKey="company.invites" />}>
<Route path="company/settings/invites" element={<CompanyInvites />} />
<Route
path="company/settings/invites"
element={<Navigate to="/company/settings/members?tab=invites" replace />}
/>
</Route>
<Route element={<HiddenSettingsPageGate pageKey="company.export" />}>
<Route

View File

@ -145,7 +145,6 @@ describe("CompanySettingsSidebar", () => {
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",

View File

@ -5,7 +5,6 @@ import {
Download,
FlaskConical,
KeyRound,
MailPlus,
MonitorCog,
Puzzle,
Shield,
@ -128,9 +127,6 @@ export function CompanySettingsSidebar() {
end
/>
))}
{showPage("company.invites") && (
<SidebarNavItem to="/company/settings/invites" label="Invites" icon={MailPlus} end />
)}
{showPage("company.secrets") && (
<SidebarNavItem to="/company/settings/secrets" label="Secrets" icon={KeyRound} end />
)}

View File

@ -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");

View File

@ -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({

View File

@ -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
<DropdownMenuSeparator />
</>
)}
<DropdownMenuItem asChild disabled={isEditingOrder}>
<Link
to="/company/settings/invites"
onClick={(event) => {
if (isEditingOrder) {
event.preventDefault();
return;
}
closeNavigationChrome();
}}
>
<UserPlus className="size-4" />
<span className="truncate">
{currentName ? `Invite people to ${currentName}` : "Invite people"}
</span>
</Link>
</DropdownMenuItem>
{showInvitePeople ? (
<DropdownMenuItem asChild disabled={isEditingOrder}>
<Link
to="/company/settings/members?tab=invites"
onClick={(event) => {
if (isEditingOrder) {
event.preventDefault();
return;
}
closeNavigationChrome();
}}
>
<UserPlus className="size-4" />
<span className="truncate">
{currentName ? `Invite people to ${currentName}` : "Invite people"}
</span>
</Link>
</DropdownMenuItem>
) : null}
{session?.session ? (
<>
<DropdownMenuSeparator />

View File

@ -31,7 +31,7 @@ vi.mock("@/components/PageTabBar", () => ({
return (
<div>
<div data-testid="active-tab">{props.value}</div>
<button type="button" onClick={() => props.onValueChange?.("invites")}>
<button type="button" onClick={() => props.onValueChange?.("secrets")}>
switch-tab
</button>
</div>
@ -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();

View File

@ -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<Record<CompanySettingsTab, string>> = {
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")) {

View File

@ -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(
<MemoryRouter>
<QueryClientProvider client={queryClient}>
<CompanyInvites />
<InvitesSection />
</QueryClientProvider>
</MemoryRouter>,
);
@ -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(
<MemoryRouter>
<QueryClientProvider client={queryClient}>
<CompanyInvites />
<InvitesSection />
</QueryClientProvider>
</MemoryRouter>,
);
@ -305,7 +304,7 @@ describe("CompanyInvites", () => {
root.render(
<MemoryRouter>
<QueryClientProvider client={queryClient}>
<CompanyInvites />
<InvitesSection />
</QueryClientProvider>
</MemoryRouter>,
);

View File

@ -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<ReturnType<typeof
return "id" in value && "state" in value && "createdAt" in value;
}
export function CompanyInvites() {
const { selectedCompany, selectedCompanyId } = useCompany();
const { setBreadcrumbs } = useBreadcrumbs();
/** The Invites tab of the Members page (extracted from the former standalone Invites page). */
export function InvitesSection() {
const { selectedCompanyId } = useCompany();
const { pushToast } = useToast();
const queryClient = useQueryClient();
const [humanRole, setHumanRole] = useState<"owner" | "admin" | "operator" | "viewer">("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 (
<div className="max-w-6xl space-y-8">
<div className="space-y-3">
<div className="flex items-center gap-2">
<MailPlus className="h-5 w-5 text-muted-foreground" />
<h1 className="text-lg font-semibold">Organization Invites</h1>
</div>
<p className="max-w-3xl text-sm text-muted-foreground">
Invite people to request access to this organization. New invite links are copied to your clipboard when they are generated.
</p>
</div>
<p className="max-w-3xl text-sm text-muted-foreground">
Invite people to request access to this organization. New invite links are copied to your clipboard when they are
generated.
</p>
<section className="space-y-4 rounded-xl border border-border p-5">
<div className="space-y-1">

View File

@ -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 <div data-testid="navigate">{to}</div>;
},
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(
<QueryClientProvider client={client}>
<CompanyAccess />
</QueryClientProvider>,
);
});
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();
});
});
});

View File

@ -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<string | null>(null);
const [removingMemberId, setRemovingMemberId] = useState<string | null>(null);
const [reassignmentTarget, setReassignmentTarget] = useState<string>("__unassigned");
@ -241,6 +267,20 @@ export function CompanyAccess() {
<h1 className="text-lg font-semibold">Organization Members</h1>
</div>
<Tabs value={activeTab} onValueChange={handleTabChange} className="flex flex-col gap-4">
{!hideInvitesTab && (
<PageTabBar
items={[
{ value: "members", label: "Members" },
{ value: "invites", label: "Invites" },
]}
align="start"
value={activeTab}
onValueChange={handleTabChange}
/>
)}
<TabsContent value="members" className="space-y-8">
{access && !access.currentUserRole && (
<div className="rounded-xl bg-amber-500/10 px-4 py-3 text-sm text-amber-800 dark:text-amber-200">
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() {
</DialogFooter>
</DialogContent>
</Dialog>
</TabsContent>
{!hideInvitesTab && (
<TabsContent value="invites">
<InvitesSection />
</TabsContent>
)}
</Tabs>
</div>
);
}
@ -575,7 +622,7 @@ export function CompanyAccessLegacyRoute() {
<Link to="/company/settings/members">Open Members</Link>
</Button>
<Button asChild variant="outline">
<Link to="/company/settings/invites">Open Invites</Link>
<Link to="/company/settings/members?tab=invites">Open Invites</Link>
</Button>
</div>
</div>