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>
This commit is contained in:
Devin Foley
2026-09-24 08:48:04 -07:00
committed by GitHub
co-authored by Paperclip
parent 7b7c4d4172
commit c341588bdb
5 changed files with 133 additions and 5 deletions
+7
View File
@@ -1003,6 +1003,13 @@ stack id to the Cloud tenant portfolio endpoint. Client-supplied user ids are
never forwarded. Successful responses are cached briefly per user; self-hosted
instances return `404`.
On Cloud-managed instances, the Members page offers **Invite people** to the
current stack's owner/admin. It opens that stack's Cloud People settings with a
full-page navigation. The destination uses the configured Cloud origin and the
current stack from the authenticated portfolio. The action stays hidden until
that role is known, or when the portfolio request fails. Hiding the tenant-local
Invites tab does not hide this Cloud action.
## 10.2 Goals
- `GET /companies/:companyId/goals`
+10 -1
View File
@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
import { cloudAppUrl, cloudPortfolioManageUrl, cloudStackCreateUrl, cloudStackEnterUrl } from "./cloudLinks";
import { cloudAppUrl, cloudPortfolioManageUrl, cloudStackCreateUrl, cloudStackEnterUrl, cloudStackInviteUrl } from "./cloudLinks";
describe("cloudLinks", () => {
it("resolves stack links against the cloud origin", () => {
@@ -27,6 +27,15 @@ describe("cloudLinks", () => {
);
});
it("opens Cloud People settings on the configured origin with an escaped stack slug", () => {
expect(cloudStackInviteUrl("https://cloud.example.test/control-plane", "team/with?query")).toBe(
"https://cloud.example.test/workspaces/team%2Fwith%3Fquery/settings?section=people",
);
expect(cloudStackInviteUrl(null, "team")).toBeNull();
expect(cloudStackInviteUrl("https://cloud.example.test", " ")).toBeNull();
expect(cloudStackInviteUrl("javascript:alert(1)", "team")).toBeNull();
});
it("returns null without a usable base or slug", () => {
expect(cloudStackEnterUrl(null, "acme")).toBeNull();
expect(cloudStackEnterUrl(" ", "acme")).toBeNull();
+10
View File
@@ -43,6 +43,16 @@ export function cloudStackCreateUrl(cloudBaseUrl: string | null | undefined): st
return cloudAppUrl(cloudBaseUrl, "/stacks/new");
}
/** Cloud manages human invitations in the current stack's People settings. */
export function cloudStackInviteUrl(
cloudBaseUrl: string | null | undefined,
stackSlug: string | null | undefined,
): string | null {
const slug = stackSlug?.trim();
if (!slug) return null;
return cloudAppUrl(cloudBaseUrl, `/workspaces/${encodeURIComponent(slug)}/settings?section=people`);
}
/**
* Cloud's organization portfolio in its explicit manage view. `?manage=1`
* matters: the plain launchpad auto-forwards a solo user straight back into
+77
View File
@@ -15,8 +15,13 @@ 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),
@@ -503,6 +508,7 @@ describe("CompanyAccess invites tab", () => {
document.body.appendChild(container);
mockSearchParamsState.current = new URLSearchParams();
listInvitesMock.mockResolvedValue({ invites: [], nextOffset: null });
listCloudStacksMock.mockResolvedValue({ stacks: [] });
listMembersMock.mockResolvedValue({
members: [],
access: { currentUserRole: "owner", canApproveJoinRequests: false },
@@ -541,6 +547,8 @@ describe("CompanyAccess invites tab", () => {
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();
@@ -574,4 +582,73 @@ describe("CompanyAccess invites tab", () => {
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());
});
});
+29 -4
View File
@@ -5,10 +5,11 @@ import {
hidesCompanyPage,
type Agent,
} from "@paperclipai/shared";
import { Shield, ShieldCheck, Trash2 } from "lucide-react";
import { Shield, ShieldCheck, Trash2, UserPlus } from "lucide-react";
import { accessApi, type CompanyMember } from "@/api/access";
import { agentsApi } from "@/api/agents";
import { ApiError } from "@/api/client";
import { cloudApi } from "@/api/cloud";
import { issuesApi } from "@/api/issues";
import { Button } from "@/components/ui/button";
import {
@@ -26,10 +27,12 @@ import { useCompany } from "@/context/CompanyContext";
import { useToast } from "@/context/ToastContext";
import { Link, Navigate, useSearchParams } from "@/lib/router";
import { queryKeys } from "@/lib/queryKeys";
import { cloudStackInviteUrl } from "@/lib/cloudLinks";
import { usePluginSlots } from "@/plugins/slots";
import { Tabs, TabsContent } from "@/components/ui/tabs";
import { PageTabBar } from "@/components/PageTabBar";
import { useHiddenSettings } from "@/hooks/useHiddenSettings";
import { useCloudInstance } from "@/hooks/useCloudInstance";
import { InvitesSection } from "@/components/access/InvitesSection";
const reassignmentIssueStatuses = "backlog,todo,in_progress,in_review,blocked,failed,timed_out";
@@ -41,6 +44,21 @@ export function CompanyAccess() {
const { pushToast } = useToast();
const queryClient = useQueryClient();
const [searchParams, setSearchParams] = useSearchParams();
const cloud = useCloudInstance();
const cloudStacksQuery = useQuery({
queryKey: queryKeys.cloud.stacks,
queryFn: () => cloudApi.listStacks(),
enabled: Boolean(cloud && selectedCompanyId),
staleTime: 30_000,
retry: false,
});
const currentStack = cloudStacksQuery.data?.stacks.find((stack) => stack.isCurrent);
// Company roles can differ from Cloud roles. Only the current stack's
// owner/admin may invite, even if another portfolio entry grants ownership.
const cloudInviteUrl = cloud && !cloudStacksQuery.isError &&
(currentStack?.role === "owner" || currentStack?.role === "admin")
? cloudStackInviteUrl(cloud.cloudBaseUrl, currentStack.stackSlug)
: null;
// 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();
@@ -262,9 +280,16 @@ export function CompanyAccess() {
return (
<div className="max-w-6xl space-y-8">
<div className="flex items-center gap-2">
<ShieldCheck className="h-5 w-5 text-muted-foreground" />
<h1 className="text-lg font-semibold">Organization Members</h1>
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="flex items-center gap-2">
<ShieldCheck className="h-5 w-5 text-muted-foreground" />
<h1 className="text-lg font-semibold">Organization Members</h1>
</div>
{cloudInviteUrl && (
<Button asChild>
<a href={cloudInviteUrl}><UserPlus />Invite people</a>
</Button>
)}
</div>
<Tabs value={activeTab} onValueChange={handleTabChange} className="flex flex-col gap-4">