diff --git a/server/src/__tests__/invite-accept-existing-member.test.ts b/server/src/__tests__/invite-accept-existing-member.test.ts new file mode 100644 index 00000000000..913fcdbf32e --- /dev/null +++ b/server/src/__tests__/invite-accept-existing-member.test.ts @@ -0,0 +1,126 @@ +import express from "express"; +import request from "supertest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { accessRoutes } from "../routes/access.js"; +import { errorHandler } from "../middleware/index.js"; + +vi.mock("../services/index.js", () => ({ + accessService: () => ({ + isInstanceAdmin: vi.fn(), + canUser: vi.fn(), + hasPermission: vi.fn(), + }), + agentService: () => ({ + getById: vi.fn(), + }), + boardAuthService: () => ({ + createChallenge: vi.fn(), + resolveBoardAccess: vi.fn(), + assertCurrentBoardKey: vi.fn(), + revokeBoardApiKey: vi.fn(), + }), + deduplicateAgentName: vi.fn(), + logActivity: vi.fn(), + notifyHireApproved: vi.fn(), +})); + +function createDbStub() { + const updateMock = vi.fn(); + const invite = { + id: "invite-1", + companyId: "company-1", + inviteType: "company_join", + allowedJoinTypes: "human", + tokenHash: "hash", + defaultsPayload: { humanRole: "viewer" }, + expiresAt: new Date("2027-03-10T00:00:00.000Z"), + invitedByUserId: "user-1", + revokedAt: null, + acceptedAt: null, + createdAt: new Date("2026-03-07T00:00:00.000Z"), + updatedAt: new Date("2026-03-07T00:00:00.000Z"), + }; + + const db = { + select() { + return { + from() { + return { + where() { + return Promise.resolve([invite]); + }, + }; + }, + }; + }, + update(...args: unknown[]) { + updateMock(...args); + return { + set() { + return { + where() { + return { + returning() { + return Promise.resolve([]); + }, + }; + }, + }; + }, + }; + }, + }; + + return { db, updateMock }; +} + +function createApp(db: Record) { + const app = express(); + app.use(express.json()); + app.use((req, _res, next) => { + (req as any).actor = { + type: "board", + source: "session", + userId: "user-1", + companyIds: ["company-1"], + memberships: [ + { + companyId: "company-1", + membershipRole: "owner", + status: "active", + }, + ], + }; + next(); + }); + app.use( + "/api", + accessRoutes(db as any, { + deploymentMode: "authenticated", + deploymentExposure: "private", + bindHost: "127.0.0.1", + allowedHostnames: [], + }), + ); + app.use(errorHandler); + return app; +} + +describe("POST /invites/:token/accept", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("does not consume a human invite when the signed-in user is already a company member", async () => { + const { db, updateMock } = createDbStub(); + const app = createApp(db); + + const res = await request(app) + .post("/api/invites/pcp_invite_test/accept") + .send({ requestType: "human" }); + + expect(res.status).toBe(409); + expect(res.body.error).toBe("You already belong to this company"); + expect(updateMock).not.toHaveBeenCalled(); + }); +}); diff --git a/server/src/routes/access.ts b/server/src/routes/access.ts index 444ab905c90..d4af585997d 100644 --- a/server/src/routes/access.ts +++ b/server/src/routes/access.ts @@ -918,6 +918,18 @@ function toInviteSummaryResponse( }; } +function actorHasActiveUserMembership(req: Request, companyId: string) { + return ( + req.actor.type === "board" && + typeof req.actor.userId === "string" && + Array.isArray(req.actor.memberships) && + req.actor.memberships.some( + (membership) => + membership.companyId === companyId && membership.status === "active", + ) + ); +} + async function loadUsersById(db: Db, userIds: string[]) { if (userIds.length === 0) return new Map>(); const rows = await db @@ -2697,6 +2709,12 @@ export function accessRoutes( ) { throw unauthorized("Authenticated user is required"); } + if ( + requestType === "human" && + actorHasActiveUserMembership(req, companyId) + ) { + throw conflict("You already belong to this company"); + } if (requestType === "agent" && !req.body.agentName) { if ( !inviteAlreadyAccepted || diff --git a/ui/src/pages/InviteLanding.test.tsx b/ui/src/pages/InviteLanding.test.tsx index a8802532615..18f272917b6 100644 --- a/ui/src/pages/InviteLanding.test.tsx +++ b/ui/src/pages/InviteLanding.test.tsx @@ -13,6 +13,7 @@ const getSessionMock = vi.hoisted(() => vi.fn()); const signInEmailMock = vi.hoisted(() => vi.fn()); const signUpEmailMock = vi.hoisted(() => vi.fn()); const healthGetMock = vi.hoisted(() => vi.fn()); +const listCompaniesMock = vi.hoisted(() => vi.fn()); vi.mock("../api/access", () => ({ accessApi: { @@ -35,6 +36,12 @@ vi.mock("../api/health", () => ({ }, })); +vi.mock("../api/companies", () => ({ + companiesApi: { + list: () => listCompaniesMock(), + }, +})); + vi.mock("@/context/CompanyContext", () => ({ useCompany: () => ({ selectedCompany: null, @@ -98,6 +105,7 @@ describe("InviteLandingPage", () => { status: "ok", deploymentMode: "authenticated", }); + listCompaniesMock.mockResolvedValue([]); getSessionMock.mockResolvedValue(null); signInEmailMock.mockResolvedValue(undefined); signUpEmailMock.mockResolvedValue(undefined); @@ -225,4 +233,57 @@ describe("InviteLandingPage", () => { root.unmount(); }); }); + + it("waits for the membership check before showing invite acceptance to signed-in users", async () => { + let resolveCompanies: ((value: Array<{ id: string; name: string }>) => void) | null = null; + listCompaniesMock.mockImplementation( + () => + new Promise>((resolve) => { + resolveCompanies = resolve; + }), + ); + getSessionMock.mockResolvedValue({ + session: { id: "session-1", userId: "user-1" }, + user: { + id: "user-1", + name: "Jane Example", + email: "jane@example.com", + image: null, + }, + }); + + const root = createRoot(container); + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + + await act(async () => { + root.render( + + + + } /> + + + , + ); + }); + await flushReact(); + + expect(container.textContent).toContain("Checking your access..."); + expect(container.textContent).not.toContain("Join company"); + expect(acceptInviteMock).not.toHaveBeenCalled(); + + await act(async () => { + resolveCompanies?.([{ id: "company-1", name: "Acme Robotics" }]); + }); + await flushReact(); + await flushReact(); + + expect(acceptInviteMock).not.toHaveBeenCalled(); + + await act(async () => { + root.unmount(); + }); + }); }); diff --git a/ui/src/pages/InviteLanding.tsx b/ui/src/pages/InviteLanding.tsx index c204aa20744..14ccf45a767 100644 --- a/ui/src/pages/InviteLanding.tsx +++ b/ui/src/pages/InviteLanding.tsx @@ -93,6 +93,15 @@ export function InviteLandingPage() { }, [companiesQuery.data, inviteQuery.data, token, navigate]); const invite = inviteQuery.data; + const isCheckingExistingMembership = + Boolean(sessionQuery.data) && + Boolean(invite?.companyId) && + companiesQuery.isLoading; + const isCurrentMember = + Boolean(invite?.companyId) && + Boolean( + companiesQuery.data?.some((company) => company.id === invite?.companyId), + ); const companyName = invite?.companyName?.trim() || null; const companyDisplayName = companyName || "this Paperclip company"; const companyLogoUrl = invite?.companyLogoUrl?.trim() || null; @@ -116,6 +125,12 @@ export function InviteLandingPage() { const acceptMutation = useMutation({ mutationFn: async () => { if (!invite) throw new Error("Invite not found"); + if (isCheckingExistingMembership) { + throw new Error("Checking your company access. Try again in a moment."); + } + if (isCurrentMember) { + throw new Error("This account already belongs to the company."); + } if (invite.inviteType === "bootstrap_ceo" || invite.allowedJoinTypes !== "agent") { return accessApi.acceptInvite(token, { requestType: "human" }); } @@ -178,6 +193,10 @@ export function InviteLandingPage() { return
Loading invite...
; } + if (isCheckingExistingMembership) { + return
Checking your access...
; + } + if (inviteQuery.error || !invite) { return (