Files
PaperClipAI/ui/src/pages/CompanyAccess.test.tsx
T
Devin FoleyandPaperclip c341588bdb fix(ui): add Cloud invitations to the Members page (#13922)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - People manage collaborators from the Members page.
> - Cloud manages invitations outside the tenant's local invitation
system.
> - The local Invites tab is hidden on Cloud, so this page has no way to
invite a person.
> - This pull request adds an Invite people action for the current Cloud
stack's owner or admin.
> - The action opens the existing Cloud People settings for that stack.

## Linked Issues or Issue Description

**What happened?**

A Cloud owner opens Organization Settings → Members and finds no
invitation action. The tenant-local Invites tab is hidden, and the page
does not link to Cloud's invitation flow.

**Expected behavior**

Cloud owners and admins can start an invitation from Members.

**Steps to reproduce**

1. Sign in to a Cloud-managed instance as the current stack's owner or
admin.
2. Open Organization Settings → Members with `company.invites` hidden.
3. Look for an invitation action beside the page heading.

**Paperclip version or commit**

`7b7c4d4172d6aac14919e2682b702ae87bc17653`.

**Deployment mode**

Cloud-managed, authenticated.

Related search: #2388 proposes broader member-management UI. This change
only connects the existing Members page to Cloud invitations. No
duplicate Cloud invitation action PR was found.

## What Changed

- Add **Invite people** beside the Members heading for the current Cloud
stack's owner/admin.
- Read the role from the authenticated Cloud portfolio. Ownership of
another stack does not enable the action.
- Navigate to the current stack's People settings on the configured
Cloud origin. Keep the local Invites tab hidden when configured.
- Cover allowed roles, denied roles, loading, failed refresh, missing
configuration, current-stack selection, and self-hosted behavior.
Document the navigation contract.

## Verification

- Focused Members and Cloud link tests: 23 passed.
- Full UI suite: 6,669 passed across 634 files.
- UI typecheck and `pnpm check:token-gates`: passed.
- `pnpm build`: passed.
- `pnpm -r typecheck`: passed.
- All PR CI checks passed, including the full general, serialized,
browser, and runner test jobs. The duplicate local repository-wide `pnpm
test:run` was stopped after CI completed; it is not reported as a local
pass.
- Manual acceptance after tenant rollout: an owner/admin opens Members,
selects **Invite people**, and reaches the same stack's Cloud People
settings. A member does not see the action.

## Risks

- The action needs a tenant app update before it appears on an existing
stack.
- The portfolio request must identify the current stack and its role.
The action stays hidden when that information is unavailable or the
request fails.
- Cloud rechecks invitation authorization at the destination. No schema,
API, or invitation-acceptance behavior changes.

## Model Used

- OpenAI Codex, GPT-6, with reasoning, code execution, and repository
tools. The session does not expose a more specific model identifier or
context-window size.

## 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
- [x] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

Co-authored-by: Paperclip <noreply@paperclip.ing>
2026-09-24 08:48:04 -07:00

655 lines
22 KiB
TypeScript

// @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 { CompanyAccess, CompanyAccessLegacyRoute } from "./CompanyAccess";
const listMembersMock = vi.hoisted(() => vi.fn());
const listJoinRequestsMock = vi.hoisted(() => vi.fn());
const updateMemberMock = vi.hoisted(() => vi.fn());
const archiveMemberMock = vi.hoisted(() => vi.fn());
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 listCloudStacksMock = vi.hoisted(() => vi.fn());
const mockSearchParamsState = vi.hoisted(() => ({ current: new URLSearchParams() }));
vi.mock("@/api/cloud", () => ({
cloudApi: { listStacks: listCloudStacksMock },
}));
vi.mock("@/api/access", () => ({
accessApi: {
listMembers: (companyId: string) => listMembersMock(companyId),
listJoinRequests: (companyId: string, status: string) => listJoinRequestsMock(companyId, status),
updateMember: (companyId: string, memberId: string, input: unknown) =>
updateMemberMock(companyId, memberId, input),
updateMemberPermissions: vi.fn(),
updateMemberAccess: vi.fn(),
archiveMember: (companyId: string, memberId: string, input: unknown) =>
archiveMemberMock(companyId, memberId, input),
approveJoinRequest: vi.fn(),
rejectJoinRequest: vi.fn(),
listInvites: (companyId: string, options: unknown) => listInvitesMock(companyId, options),
createCompanyInvite: vi.fn(),
revokeInvite: vi.fn(),
},
}));
vi.mock("@/api/agents", () => ({
agentsApi: {
list: (companyId: string) => listAgentsMock(companyId),
},
}));
vi.mock("@/api/issues", () => ({
issuesApi: {
list: (companyId: string, filters: unknown) => listIssuesMock(companyId, filters),
},
}));
vi.mock("@/lib/router", () => ({
Link: ({ to, children }: { to: string; children: React.ReactNode }) => <a href={to}>{children}</a>,
Navigate: ({ to, replace }: { to: string; replace?: boolean }) => {
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",
selectedCompany: { id: "company-1", name: "Paperclip" },
}),
}));
vi.mock("@/context/BreadcrumbContext", () => ({
useBreadcrumbs: () => ({ setBreadcrumbs: vi.fn() }),
}));
vi.mock("@/context/ToastContext", () => ({
useToast: () => ({ pushToast: vi.fn() }),
}));
// 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));
});
}
describe("CompanyAccess", () => {
let container: HTMLDivElement;
beforeEach(() => {
container = document.createElement("div");
document.body.appendChild(container);
mockSearchParamsState.current = new URLSearchParams();
listInvitesMock.mockResolvedValue({ invites: [], nextOffset: null });
listMembersMock.mockResolvedValue({
members: [
{
id: "member-1",
companyId: "company-1",
principalType: "user",
principalId: "user-1",
status: "active",
membershipRole: "owner",
createdAt: "2026-04-10T00:00:00.000Z",
updatedAt: "2026-04-10T00:00:00.000Z",
user: {
id: "user-1",
email: "codexcoder@paperclip.local",
name: "Codex Coder",
image: "/api/assets/avatar-1/content",
},
grants: [],
},
{
id: "member-2",
companyId: "company-1",
principalType: "user",
principalId: "user-2",
status: "active",
membershipRole: "operator",
createdAt: "2026-04-10T00:00:00.000Z",
updatedAt: "2026-04-10T00:00:00.000Z",
user: {
id: "user-2",
email: "board@paperclip.local",
name: "Board User",
image: null,
},
grants: [],
},
],
access: {
currentUserRole: "owner",
canManageMembers: true,
canInviteUsers: true,
canApproveJoinRequests: true,
},
});
listJoinRequestsMock.mockResolvedValue([
{
id: "join-1",
requestType: "human",
createdAt: "2026-04-10T00:00:00.000Z",
requesterUser: {
id: "user-2",
email: "board@paperclip.local",
name: "Board User",
image: null,
},
requestEmailSnapshot: "board@paperclip.local",
requestingUserId: "user-2",
invite: {
allowedJoinTypes: "human",
humanRole: "operator",
},
},
{
id: "join-2",
requestType: "agent",
createdAt: "2026-04-10T00:00:00.000Z",
agentName: "Codex Worker",
adapterType: "codex_local",
capabilities: "Implements code changes",
invite: {
allowedJoinTypes: "agent",
humanRole: null,
},
},
]);
updateMemberMock.mockResolvedValue({});
archiveMemberMock.mockResolvedValue({ reassignedIssueCount: 1 });
listAgentsMock.mockResolvedValue([
{
id: "agent-1",
name: "Codex Worker",
role: "engineer",
status: "active",
},
]);
listIssuesMock.mockResolvedValue([
{
id: "issue-1",
identifier: "PAP-1",
title: "Assigned to removed user",
status: "todo",
},
]);
mockUsePluginSlots.mockReturnValue({
slots: [],
isLoading: false,
errorMessage: null,
});
});
afterEach(() => {
container.remove();
document.body.innerHTML = "";
vi.clearAllMocks();
});
it("renders a compact member table without redundant explanatory copy", async () => {
const root = createRoot(container);
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
await act(async () => {
root.render(
<QueryClientProvider client={queryClient}>
<CompanyAccess />
</QueryClientProvider>,
);
});
await flushReact();
await flushReact();
expect(container.textContent).not.toContain("Manage the people who can work in Paperclip");
expect(container.textContent).not.toContain("Members can collaborate across the company by default");
expect(container.textContent).not.toContain("Core keeps this page focused on membership");
expect(container.textContent).not.toContain("Manage human company memberships and status here");
expect(container.textContent).toContain("Pending human joins");
expect(container.textContent).toContain("Name");
expect(container.textContent).toContain("Email");
expect(container.querySelector('[data-slot="avatar"]')).not.toBeNull();
expect(container.textContent).not.toContain("Grants");
expect(container.textContent).not.toContain("explicit grants");
expect(container.textContent).not.toContain("Assign scoped tasks");
expect(container.textContent).not.toContain("Agents");
expect(container.textContent).not.toContain("Pending agent joins");
expect(container.textContent).not.toContain("Open join request queue");
expect(container.textContent).not.toContain("Manage invites");
expect(container.textContent).not.toContain("Active user accounts");
expect(container.textContent).not.toContain("Suspended user accounts");
expect(container.textContent).not.toContain("Pending user joins");
const editButton = Array.from(container.querySelectorAll("button")).find(
(button) => button.textContent === "Edit",
);
expect(editButton).toBeTruthy();
await act(async () => {
editButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
await flushReact();
expect(document.body.textContent).toContain("Update organization role and membership status");
expect(document.body.textContent).not.toContain("Implicit grants from role");
expect(document.body.textContent).not.toContain("permissionKey");
await act(async () => {
root.unmount();
});
});
it("saves member role and status without touching grants", async () => {
const root = createRoot(container);
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
await act(async () => {
root.render(
<QueryClientProvider client={queryClient}>
<CompanyAccess />
</QueryClientProvider>,
);
});
await flushReact();
await flushReact();
const editButton = Array.from(container.querySelectorAll("button")).find(
(button) => button.textContent === "Edit",
);
expect(editButton).toBeTruthy();
await act(async () => {
editButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
await flushReact();
const saveButton = Array.from(document.body.querySelectorAll("button")).find(
(button) => button.textContent === "Save member",
);
expect(saveButton).toBeTruthy();
await act(async () => {
saveButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
await flushReact();
expect(updateMemberMock).toHaveBeenCalledWith("company-1", "member-1", {
membershipRole: "owner",
status: "active",
});
await act(async () => {
root.unmount();
});
});
it("removes a member with an issue reassignment target", async () => {
const root = createRoot(container);
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
await act(async () => {
root.render(
<QueryClientProvider client={queryClient}>
<CompanyAccess />
</QueryClientProvider>,
);
});
await flushReact();
await flushReact();
const removeButtons = Array.from(container.querySelectorAll("button")).filter(
(button) => button.textContent?.includes("Remove"),
);
expect(removeButtons.length).toBeGreaterThan(0);
await act(async () => {
removeButtons[0]?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
await flushReact();
expect(document.body.textContent).toContain("Remove member");
expect(document.body.textContent).toContain("Assigned to removed user");
const reassignmentSelect = document.body.querySelector("select");
expect(reassignmentSelect).toBeTruthy();
await act(async () => {
reassignmentSelect!.value = "user:user-2";
reassignmentSelect!.dispatchEvent(new Event("change", { bubbles: true }));
});
const confirmButton = Array.from(document.body.querySelectorAll("button")).find(
(button) => button.textContent === "Remove member",
);
expect(confirmButton).toBeTruthy();
await act(async () => {
confirmButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
await flushReact();
expect(archiveMemberMock).toHaveBeenCalledWith("company-1", "member-1", {
reassignment: { assigneeAgentId: null, assigneeUserId: "user-2" },
});
await act(async () => {
root.unmount();
});
});
it("shows protected member removal reasons from the API", async () => {
listMembersMock.mockResolvedValueOnce({
members: [
{
id: "member-admin",
companyId: "company-1",
principalType: "user",
principalId: "admin-user",
status: "active",
membershipRole: "admin",
createdAt: "2026-04-10T00:00:00.000Z",
updatedAt: "2026-04-10T00:00:00.000Z",
user: {
id: "admin-user",
email: "admin@paperclip.local",
name: "Admin User",
image: null,
},
grants: [],
removal: {
canArchive: false,
reason: "Company admins cannot be removed from company access.",
},
},
],
access: {
currentUserRole: "owner",
canManageMembers: true,
canInviteUsers: true,
canApproveJoinRequests: false,
},
});
const root = createRoot(container);
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
await act(async () => {
root.render(
<QueryClientProvider client={queryClient}>
<CompanyAccess />
</QueryClientProvider>,
);
});
await flushReact();
await flushReact();
expect(container.textContent).not.toContain("Company admins cannot be removed from company access.");
const removeButton = Array.from(container.querySelectorAll("button")).find(
(button) => button.textContent?.includes("Remove"),
);
expect(removeButton).toBeTruthy();
expect(removeButton).toHaveProperty("disabled", true);
expect(removeButton?.getAttribute("title")).toBe("Company admins cannot be removed from company access.");
await act(async () => {
root.unmount();
});
});
it("redirects legacy access deep links to the permissions extension route when installed", async () => {
mockUsePluginSlots.mockReturnValue({
slots: [
{
type: "companySettingsPage",
id: "permissions",
displayName: "Permissions",
routePath: "permissions",
pluginKey: "permissions-extension",
},
],
isLoading: false,
errorMessage: null,
});
const root = createRoot(container);
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
await act(async () => {
root.render(
<QueryClientProvider client={queryClient}>
<CompanyAccessLegacyRoute />
</QueryClientProvider>,
);
});
await flushReact();
expect(mockNavigate).toHaveBeenCalledWith("/company/settings/permissions", true);
expect(container.textContent).toContain("/company/settings/permissions");
await act(async () => {
root.unmount();
});
});
it("shows a read-only unavailable fallback for legacy access deep links", async () => {
const root = createRoot(container);
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
await act(async () => {
root.render(
<QueryClientProvider client={queryClient}>
<CompanyAccessLegacyRoute />
</QueryClientProvider>,
);
});
await flushReact();
expect(container.textContent).toContain("Advanced Permissions");
expect(container.textContent).toContain("Advanced permissions unavailable");
expect(container.textContent).toContain("Open Members");
expect(container.textContent).toContain("Open Invites");
await act(async () => {
root.unmount();
});
});
});
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 });
listCloudStacksMock.mockResolvedValue({ stacks: [] });
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");
expect(container.textContent).not.toContain("Invite people");
expect(listCloudStacksMock).not.toHaveBeenCalled();
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();
});
});
function cloudClient(cloudBaseUrl: string | null = "https://cloud.example.test") {
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
client.setQueryData(["health"], {
hiddenSettings: ["company.invites"],
cloud: { managed: true, managedBy: "paperclip-cloud", cloudBaseUrl, stackSlug: "old-slug" },
});
return client;
}
function cloudStack(role: string, isCurrent = true) {
return { stackSlug: isCurrent ? "current-team" : "other-team", role, isCurrent,
displayName: "Team", primaryHost: null, lifecycleState: "active", sleepState: "awake" };
}
it.each(["owner", "admin"])("offers Cloud invitations to the current stack's %s even with local invites hidden", async (role) => {
listCloudStacksMock.mockResolvedValue({ stacks: [cloudStack("owner", false), cloudStack(role)] });
const root = await renderPage(cloudClient());
const invite = [...container.querySelectorAll("a")].find((link) => link.textContent === "Invite people");
expect(invite?.getAttribute("href")).toBe("https://cloud.example.test/workspaces/current-team/settings?section=people");
expect(invite?.getAttribute("target")).toBeNull();
expect(container.querySelectorAll('[role="tab"]')).toHaveLength(0);
expect(listInvitesMock).not.toHaveBeenCalled();
await act(async () => root.unmount());
});
it.each(["member", "support", "unknown"])("does not expose Cloud invitations to a %s who owns another stack", async (role) => {
listCloudStacksMock.mockResolvedValue({ stacks: [cloudStack("owner", false), cloudStack(role)] });
const root = await renderPage(cloudClient());
expect(container.textContent).not.toContain("Invite people");
await act(async () => root.unmount());
});
it("waits for the Cloud role before showing the invitation action", async () => {
let resolveStacks!: (value: { stacks: ReturnType<typeof cloudStack>[] }) => void;
listCloudStacksMock.mockReturnValue(new Promise((resolve) => { resolveStacks = resolve; }));
const root = await renderPage(cloudClient());
expect(container.textContent).not.toContain("Invite people");
await act(async () => resolveStacks({ stacks: [cloudStack("admin")] }));
await flushReact();
expect(container.textContent).toContain("Invite people");
await act(async () => root.unmount());
});
it("hides a cached invitation action when the Cloud role refresh fails", async () => {
const client = cloudClient();
client.setQueryData(["cloud", "stacks"], { stacks: [cloudStack("owner")] });
const root = await renderPage(client);
expect(container.textContent).toContain("Invite people");
listCloudStacksMock.mockRejectedValue(new Error("Portfolio unavailable"));
await act(async () => { await client.invalidateQueries({ queryKey: ["cloud", "stacks"] }); });
await flushReact();
expect(container.textContent).not.toContain("Invite people");
await act(async () => root.unmount());
});
it("requires an identified current stack and a configured Cloud destination", async () => {
listCloudStacksMock.mockResolvedValue({ stacks: [cloudStack("owner", false)] });
const root = await renderPage(cloudClient());
expect(container.textContent).not.toContain("Invite people");
await act(async () => root.unmount());
listCloudStacksMock.mockResolvedValue({ stacks: [cloudStack("owner")] });
const secondRoot = await renderPage(cloudClient(null));
expect(container.textContent).not.toContain("Invite people");
await act(async () => secondRoot.unmount());
});
});