mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-02 02:07:25 +08:00
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:
@@ -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`
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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());
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user