mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-28 20:21:50 +01:00
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work > - The Members page has an Invites tab where an admin mints single-use invite links for people > - After a link is created, the section offers two actions: "Copy link" and "Open invite" > - "Open invite" opens the inviter's own single-use link in a new tab, which is never what the inviter means — the link is for the invitee > - This pull request removes the "Open invite" button and keeps "Copy link" as the only action > - The benefit is that the section no longer invites a mistake, and the one remaining action matches the section's purpose ## Linked Issues or Issue Description No existing issue. Description follows the enhancement template: **What existing behavior does this improve?** The latest-invite panel on the Members page Invites tab. **Subsystem affected** UI — `ui/src/components/access/InvitesSection.tsx`. **Current behavior** After an invite is created, the panel shows a "Copy link" button and an "Open invite" button. "Open invite" opens the invite URL in a new tab as the inviter. **Proposed behavior** The panel shows only "Copy link". The invite URL field itself stays visible and selectable. **Reason and benefit** Invite links are single-use and addressed to the invitee. The inviter opening their own link at best shows them their own landing page and at worst walks the link toward consumption. Removing the button removes the trap. **Breaking changes** None. No API or data change. ## What Changed - Removed the "Open invite" anchor button from `InvitesSection`. - Removed the now-unused `ExternalLink` icon import. - The component test now asserts the action is absent. ## Verification - `cd ui && npx vitest run src/components/access/InvitesSection.test.tsx` — 3 tests pass. - `cd ui && npx tsc -p tsconfig.json --noEmit` — clean. - Manual: create an invite on the Members page Invites tab; the latest-invite panel shows the URL field and "Copy link" only. ## Risks Low risk. UI-only removal of one button; the invite URL remains fully visible and copyable. ## Model Used Claude Fable 5 (`claude-fable-5`, Anthropic), 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
334 lines
12 KiB
TypeScript
334 lines
12 KiB
TypeScript
// @vitest-environment jsdom
|
|
|
|
import { act } from "react";
|
|
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 { InvitesSection } from "./InvitesSection";
|
|
import { queryKeys } from "@/lib/queryKeys";
|
|
|
|
const listInvitesMock = vi.hoisted(() => vi.fn());
|
|
const createCompanyInviteMock = vi.hoisted(() => vi.fn());
|
|
const revokeInviteMock = vi.hoisted(() => vi.fn());
|
|
const pushToastMock = vi.hoisted(() => vi.fn());
|
|
const setBreadcrumbsMock = vi.hoisted(() => vi.fn());
|
|
const clipboardWriteTextMock = vi.hoisted(() => vi.fn());
|
|
|
|
vi.mock("@/api/access", () => ({
|
|
accessApi: {
|
|
listInvites: (companyId: string, options?: unknown) => listInvitesMock(companyId, options),
|
|
createCompanyInvite: (companyId: string, input: unknown) =>
|
|
createCompanyInviteMock(companyId, input),
|
|
revokeInvite: (inviteId: string) => revokeInviteMock(inviteId),
|
|
},
|
|
}));
|
|
|
|
vi.mock("@/context/CompanyContext", () => ({
|
|
useCompany: () => ({
|
|
selectedCompanyId: "company-1",
|
|
selectedCompany: { id: "company-1", name: "Paperclip", issuePrefix: "PAP" },
|
|
}),
|
|
}));
|
|
|
|
vi.mock("@/context/BreadcrumbContext", () => ({
|
|
useBreadcrumbs: () => ({ setBreadcrumbs: setBreadcrumbsMock }),
|
|
}));
|
|
|
|
vi.mock("@/context/ToastContext", () => ({
|
|
useToast: () => ({ pushToast: pushToastMock }),
|
|
}));
|
|
|
|
// 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("InvitesSection", () => {
|
|
let container: HTMLDivElement;
|
|
const inviteHistory = Array.from({ length: 25 }, (_, index) => {
|
|
const inviteNumber = 25 - index;
|
|
const isActive = inviteNumber === 25;
|
|
return {
|
|
id: `invite-${inviteNumber}`,
|
|
companyId: "company-1",
|
|
inviteType: "company_join",
|
|
tokenHash: `hash-${inviteNumber}`,
|
|
allowedJoinTypes: "human",
|
|
defaultsPayload: null,
|
|
expiresAt: "2026-04-20T00:00:00.000Z",
|
|
invitedByUserId: "user-1",
|
|
revokedAt: null,
|
|
acceptedAt: isActive ? null : "2026-04-11T00:00:00.000Z",
|
|
createdAt: `2026-04-${String(inviteNumber).padStart(2, "0")}T00:00:00.000Z`,
|
|
updatedAt: `2026-04-${String(inviteNumber).padStart(2, "0")}T00:00:00.000Z`,
|
|
companyName: "Paperclip",
|
|
humanRole: isActive ? "operator" : "viewer",
|
|
inviteMessage: null,
|
|
state: isActive ? "active" : "accepted",
|
|
invitedByUser: {
|
|
id: "user-1",
|
|
name: `Board User ${inviteNumber}`,
|
|
email: `board${inviteNumber}@paperclip.local`,
|
|
image: null,
|
|
},
|
|
relatedJoinRequestId: isActive ? "join-1" : null,
|
|
};
|
|
});
|
|
|
|
beforeEach(() => {
|
|
container = document.createElement("div");
|
|
document.body.appendChild(container);
|
|
|
|
listInvitesMock.mockImplementation((_companyId: string, options?: { limit?: number; offset?: number }) => {
|
|
const limit = options?.limit ?? 20;
|
|
const offset = options?.offset ?? 0;
|
|
const invites = inviteHistory.slice(offset, offset + limit);
|
|
const nextOffset = offset + invites.length < inviteHistory.length ? offset + invites.length : null;
|
|
return Promise.resolve({ invites, nextOffset });
|
|
});
|
|
|
|
createCompanyInviteMock.mockImplementation(() => {
|
|
return Promise.resolve({
|
|
token: "new-token",
|
|
inviteUrl: "https://paperclip.local/invite/new-token",
|
|
onboardingTextUrl: null,
|
|
onboardingTextPath: null,
|
|
humanRole: "viewer",
|
|
allowedJoinTypes: "human",
|
|
});
|
|
});
|
|
|
|
revokeInviteMock.mockResolvedValue(undefined);
|
|
|
|
Object.defineProperty(globalThis.navigator, "clipboard", {
|
|
configurable: true,
|
|
value: { writeText: clipboardWriteTextMock },
|
|
});
|
|
});
|
|
|
|
afterEach(() => {
|
|
container.remove();
|
|
document.body.innerHTML = "";
|
|
vi.clearAllMocks();
|
|
});
|
|
|
|
it("renders a human-only invite flow and keeps invite history in a table", async () => {
|
|
const root = createRoot(container);
|
|
const queryClient = new QueryClient({
|
|
defaultOptions: { queries: { retry: false } },
|
|
});
|
|
|
|
await act(async () => {
|
|
root.render(
|
|
<MemoryRouter>
|
|
<QueryClientProvider client={queryClient}>
|
|
<InvitesSection />
|
|
</QueryClientProvider>
|
|
</MemoryRouter>,
|
|
);
|
|
});
|
|
await flushReact();
|
|
await flushReact();
|
|
|
|
expect(container.textContent).toContain("Invite a person");
|
|
expect(container.textContent).not.toContain("Invite an agent");
|
|
expect(container.textContent).not.toContain("Generate agent onboarding prompt");
|
|
expect(container.textContent).toContain("Invite history");
|
|
expect(container.textContent).toContain("Board User 25");
|
|
expect(container.textContent).toContain("Board User 21");
|
|
expect(container.textContent).not.toContain("Board User 20");
|
|
expect(container.textContent).toContain("Review request");
|
|
expect(container.textContent).toContain("View more");
|
|
expect(container.textContent).not.toContain("Human or agent");
|
|
expect(container.textContent).not.toContain("Invite message");
|
|
expect(container.textContent).not.toContain("Latest generated invite");
|
|
expect(container.textContent).not.toContain("Active invites");
|
|
expect(container.textContent).not.toContain("Consumed invites");
|
|
expect(container.textContent).not.toContain("Expired invites");
|
|
expect(container.textContent).not.toContain("OpenClaw shortcut");
|
|
|
|
expect(container.textContent).toContain("Choose a role");
|
|
expect(container.textContent).toContain("Each invite link is single-use.");
|
|
expect(container.textContent).toContain("Can create agents, invite users, assign tasks, and approve join requests.");
|
|
expect(container.textContent).toContain("Everything in Admin, plus managing members.");
|
|
expect(container.textContent).not.toContain("permission grants");
|
|
expect(listInvitesMock).toHaveBeenCalledWith("company-1", { limit: 5, offset: 0 });
|
|
|
|
const viewMoreButton = Array.from(container.querySelectorAll("button")).find(
|
|
(button) => button.textContent === "View more",
|
|
);
|
|
|
|
await act(async () => {
|
|
viewMoreButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
|
});
|
|
await flushReact();
|
|
await flushReact();
|
|
|
|
expect(listInvitesMock).toHaveBeenCalledWith("company-1", { limit: 5, offset: 5 });
|
|
expect(container.textContent).toContain("Board User 20");
|
|
expect(container.textContent).toContain("Board User 16");
|
|
expect(container.textContent).toContain("View more");
|
|
|
|
await act(async () => {
|
|
const viewerRadio = container.querySelector('input[type="radio"][value="viewer"]') as HTMLInputElement | null;
|
|
viewerRadio?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
|
viewerRadio?.dispatchEvent(new Event("change", { bubbles: true }));
|
|
});
|
|
|
|
const buttons = Array.from(container.querySelectorAll("button"));
|
|
const createButton = buttons.find((button) => button.textContent === "Create invite");
|
|
const revokeButton = buttons.find((button) => button.textContent === "Revoke");
|
|
|
|
expect(createButton).toBeTruthy();
|
|
expect(revokeButton).toBeTruthy();
|
|
|
|
await act(async () => {
|
|
createButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
|
});
|
|
await flushReact();
|
|
await flushReact();
|
|
|
|
expect(createCompanyInviteMock).toHaveBeenCalledWith("company-1", {
|
|
allowedJoinTypes: "human",
|
|
humanRole: "viewer",
|
|
agentMessage: null,
|
|
});
|
|
expect(clipboardWriteTextMock).toHaveBeenCalledWith("https://paperclip.local/invite/new-token");
|
|
expect(container.textContent).toContain("Latest invite link");
|
|
expect(container.textContent).toContain("This URL includes the current Paperclip domain returned by the server.");
|
|
expect(container.querySelector('input[aria-label="Latest invite URL"]')).toHaveProperty(
|
|
"value",
|
|
"https://paperclip.local/invite/new-token",
|
|
);
|
|
expect(container.textContent).toContain("Copy link");
|
|
// The inviter opening their own single-use link is never what they
|
|
// mean, so the section deliberately offers no "Open invite" action.
|
|
expect(container.textContent).not.toContain("Open invite");
|
|
expect(pushToastMock).toHaveBeenCalledWith({
|
|
title: "Invite created",
|
|
body: "Invite ready below and copied to clipboard.",
|
|
tone: "success",
|
|
});
|
|
|
|
const copyLinkButton = Array.from(container.querySelectorAll("button")).find(
|
|
(button) => button.textContent === "Copy link",
|
|
);
|
|
|
|
await act(async () => {
|
|
copyLinkButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
|
});
|
|
await flushReact();
|
|
|
|
expect(clipboardWriteTextMock).toHaveBeenCalledTimes(2);
|
|
expect(container.textContent).toContain("Copied");
|
|
|
|
await act(async () => {
|
|
revokeButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
|
});
|
|
await flushReact();
|
|
|
|
expect(revokeInviteMock).toHaveBeenCalledWith("invite-25");
|
|
|
|
await act(async () => {
|
|
root.unmount();
|
|
});
|
|
});
|
|
|
|
it("falls back to selectable text when browser clipboard access is unavailable", async () => {
|
|
Object.defineProperty(globalThis.navigator, "clipboard", {
|
|
configurable: true,
|
|
value: undefined,
|
|
});
|
|
Object.defineProperty(document, "queryCommandSupported", {
|
|
configurable: true,
|
|
value: vi.fn((command: string) => command === "copy"),
|
|
});
|
|
Object.defineProperty(document, "execCommand", {
|
|
configurable: true,
|
|
value: vi.fn(() => true),
|
|
});
|
|
|
|
const root = createRoot(container);
|
|
const queryClient = new QueryClient({
|
|
defaultOptions: { queries: { retry: false } },
|
|
});
|
|
|
|
await act(async () => {
|
|
root.render(
|
|
<MemoryRouter>
|
|
<QueryClientProvider client={queryClient}>
|
|
<InvitesSection />
|
|
</QueryClientProvider>
|
|
</MemoryRouter>,
|
|
);
|
|
});
|
|
await flushReact();
|
|
await flushReact();
|
|
|
|
const createButton = Array.from(container.querySelectorAll("button")).find(
|
|
(button) => button.textContent === "Create invite",
|
|
);
|
|
|
|
await act(async () => {
|
|
createButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
|
});
|
|
await flushReact();
|
|
await flushReact();
|
|
|
|
const inviteInput = container.querySelector('input[aria-label="Latest invite URL"]') as HTMLInputElement | null;
|
|
expect(inviteInput?.value).toBe("https://paperclip.local/invite/new-token");
|
|
expect(document.execCommand).toHaveBeenCalledWith("copy");
|
|
expect(pushToastMock).toHaveBeenCalledWith({
|
|
title: "Invite created",
|
|
body: "Invite ready below and copied to clipboard.",
|
|
tone: "success",
|
|
});
|
|
|
|
await act(async () => {
|
|
root.unmount();
|
|
});
|
|
});
|
|
|
|
it("ignores legacy cached invite arrays and refetches paginated history", async () => {
|
|
const root = createRoot(container);
|
|
const queryClient = new QueryClient({
|
|
defaultOptions: { queries: { retry: false } },
|
|
});
|
|
queryClient.setQueryData(["access", "invites", "company-1", "all"], inviteHistory.slice(0, 2));
|
|
|
|
await act(async () => {
|
|
root.render(
|
|
<MemoryRouter>
|
|
<QueryClientProvider client={queryClient}>
|
|
<InvitesSection />
|
|
</QueryClientProvider>
|
|
</MemoryRouter>,
|
|
);
|
|
});
|
|
await flushReact();
|
|
await flushReact();
|
|
|
|
expect(container.textContent).toContain("Board User 25");
|
|
expect(container.textContent).not.toContain("Board User 20");
|
|
expect(listInvitesMock).toHaveBeenCalledWith("company-1", { limit: 5, offset: 0 });
|
|
expect(queryClient.getQueryData(queryKeys.access.invites("company-1", "all", 5))).toMatchObject({
|
|
pages: [
|
|
{
|
|
invites: expect.any(Array),
|
|
nextOffset: 5,
|
|
},
|
|
],
|
|
});
|
|
|
|
await act(async () => {
|
|
root.unmount();
|
|
});
|
|
});
|
|
});
|