From 682ffe679597471397bd79e53f6d9c48c151aa89 Mon Sep 17 00:00:00 2001 From: Dotta Date: Fri, 10 Apr 2026 16:58:41 -0500 Subject: [PATCH] Expose created invite links --- .../src/__tests__/invite-create-route.test.ts | 118 ++++++++++++++++++ server/src/routes/access.ts | 9 +- ui/src/pages/CompanyInvites.test.tsx | 18 ++- ui/src/pages/CompanyInvites.tsx | 66 ++++++++-- 4 files changed, 196 insertions(+), 15 deletions(-) create mode 100644 server/src/__tests__/invite-create-route.test.ts diff --git a/server/src/__tests__/invite-create-route.test.ts b/server/src/__tests__/invite-create-route.test.ts new file mode 100644 index 00000000000..996b781ad95 --- /dev/null +++ b/server/src/__tests__/invite-create-route.test.ts @@ -0,0 +1,118 @@ +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"; + +const logActivityMock = vi.fn(); + +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: (...args: unknown[]) => logActivityMock(...args), + notifyHireApproved: vi.fn(), +})); + +function createDbStub() { + const createdInvite = { + id: "invite-1", + companyId: "company-1", + inviteType: "company_join", + allowedJoinTypes: "human", + tokenHash: "hash", + defaultsPayload: { humanRole: "viewer" }, + expiresAt: new Date("2027-03-07T00:10:00.000Z"), + invitedByUserId: null, + revokedAt: null, + acceptedAt: null, + createdAt: new Date("2026-03-07T00:00:00.000Z"), + updatedAt: new Date("2026-03-07T00:00:00.000Z"), + }; + + return { + insert() { + return { + values() { + return { + returning() { + return Promise.resolve([createdInvite]); + }, + }; + }, + }; + }, + select(_shape?: unknown) { + return { + from() { + return { + where() { + return Promise.resolve([{ name: "Acme Robotics" }]); + }, + }; + }, + }; + }, + }; +} + +function createApp() { + const app = express(); + app.use(express.json()); + app.use((req, _res, next) => { + (req as any).actor = { + type: "board", + source: "local_implicit", + userId: null, + companyIds: ["company-1"], + }; + next(); + }); + app.use( + "/api", + accessRoutes(createDbStub() as any, { + deploymentMode: "local_trusted", + deploymentExposure: "private", + bindHost: "127.0.0.1", + allowedHostnames: [], + }), + ); + app.use(errorHandler); + return app; +} + +describe("POST /companies/:companyId/invites", () => { + beforeEach(() => { + logActivityMock.mockReset(); + }); + + it("returns an absolute invite URL using the request base URL", async () => { + const app = createApp(); + + const res = await request(app) + .post("/api/companies/company-1/invites") + .set("host", "paperclip.example") + .set("x-forwarded-proto", "https") + .send({ + allowedJoinTypes: "human", + humanRole: "viewer", + }); + + expect(res.status).toBe(201); + expect(res.body.companyName).toBe("Acme Robotics"); + expect(res.body.invitePath).toMatch(/^\/invite\/pcp_invite_/); + expect(res.body.inviteUrl).toMatch(/^https:\/\/paperclip\.example\/invite\/pcp_invite_/); + }); +}); diff --git a/server/src/routes/access.ts b/server/src/routes/access.ts index a53bf0dcb25..717b4a9db86 100644 --- a/server/src/routes/access.ts +++ b/server/src/routes/access.ts @@ -861,6 +861,7 @@ function toInviteSummaryResponse( companyName: string | null = null ) { const baseUrl = requestBaseUrl(req); + const invitePath = `/invite/${token}`; const onboardingPath = `/api/invites/${token}/onboarding`; const onboardingTextPath = `/api/invites/${token}/onboarding.txt`; const inviteMessage = extractInviteMessage(invite); @@ -871,6 +872,8 @@ function toInviteSummaryResponse( inviteType: invite.inviteType, allowedJoinTypes: invite.allowedJoinTypes, expiresAt: invite.expiresAt, + invitePath, + inviteUrl: baseUrl ? `${baseUrl}${invitePath}` : invitePath, onboardingPath, onboardingUrl: baseUrl ? `${baseUrl}${onboardingPath}` : onboardingPath, onboardingTextPath, @@ -1976,7 +1979,8 @@ export function accessRoutes( res.status(201).json({ ...created, token, - inviteUrl: `/invite/${token}`, + invitePath: inviteSummary.invitePath, + inviteUrl: inviteSummary.inviteUrl, companyName, onboardingTextPath: inviteSummary.onboardingTextPath, onboardingTextUrl: inviteSummary.onboardingTextUrl, @@ -2028,7 +2032,8 @@ export function accessRoutes( res.status(201).json({ ...created, token, - inviteUrl: `/invite/${token}`, + invitePath: inviteSummary.invitePath, + inviteUrl: inviteSummary.inviteUrl, companyName, onboardingTextPath: inviteSummary.onboardingTextPath, onboardingTextUrl: inviteSummary.onboardingTextUrl, diff --git a/ui/src/pages/CompanyInvites.test.tsx b/ui/src/pages/CompanyInvites.test.tsx index 2f29b9a718f..b4b0e2ab996 100644 --- a/ui/src/pages/CompanyInvites.test.tsx +++ b/ui/src/pages/CompanyInvites.test.tsx @@ -191,12 +191,28 @@ describe("CompanyInvites", () => { 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.textContent).toContain("https://paperclip.local/invite/new-token"); + expect(container.textContent).toContain("Copy link"); + expect(container.textContent).toContain("Open invite"); expect(pushToastMock).toHaveBeenCalledWith({ title: "Invite created", - body: "Invite link copied to clipboard.", + body: "Invite ready below and copied to clipboard.", tone: "success", }); + const copyLinkButton = Array.from(container.querySelectorAll("button")).find( + (button) => button.textContent?.includes("Copy link"), + ); + + await act(async () => { + copyLinkButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + await flushReact(); + + expect(clipboardWriteTextMock).toHaveBeenCalledTimes(2); + await act(async () => { revokeButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })); }); diff --git a/ui/src/pages/CompanyInvites.tsx b/ui/src/pages/CompanyInvites.tsx index 3ae189a5e45..e28c8ad06aa 100644 --- a/ui/src/pages/CompanyInvites.tsx +++ b/ui/src/pages/CompanyInvites.tsx @@ -1,6 +1,6 @@ import { useEffect, useState } from "react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; -import { MailPlus } from "lucide-react"; +import { Copy, ExternalLink, MailPlus } from "lucide-react"; import { accessApi } from "@/api/access"; import { ApiError } from "@/api/client"; import { Button } from "@/components/ui/button"; @@ -43,6 +43,30 @@ export function CompanyInvites() { const { pushToast } = useToast(); const queryClient = useQueryClient(); const [humanRole, setHumanRole] = useState<"owner" | "admin" | "operator" | "viewer">("operator"); + const [latestInviteUrl, setLatestInviteUrl] = useState(null); + + async function copyInviteUrl(url: string) { + try { + if (typeof navigator !== "undefined" && navigator.clipboard?.writeText) { + await navigator.clipboard.writeText(url); + pushToast({ + title: "Invite copied", + body: "Invite link copied to clipboard.", + tone: "success", + }); + return true; + } + } catch { + // Fall through to the unavailable message below. + } + + pushToast({ + title: "Clipboard unavailable", + body: "Copy the invite URL manually from the field below.", + tone: "warn", + }); + return false; + } useEffect(() => { setBreadcrumbs([ @@ -66,21 +90,13 @@ export function CompanyInvites() { agentMessage: null, }), onSuccess: async (invite) => { - let copied = false; - - try { - if (typeof navigator !== "undefined" && navigator.clipboard?.writeText) { - await navigator.clipboard.writeText(invite.inviteUrl); - copied = true; - } - } catch { - copied = false; - } + setLatestInviteUrl(invite.inviteUrl); + const copied = await copyInviteUrl(invite.inviteUrl); await queryClient.invalidateQueries({ queryKey: queryKeys.access.invites(selectedCompanyId!, "all") }); pushToast({ title: "Invite created", - body: copied ? "Invite link copied to clipboard." : "Invite link created, but clipboard copy was unavailable.", + body: copied ? "Invite ready below and copied to clipboard." : "Invite ready below.", tone: "success", }); }, @@ -192,6 +208,32 @@ export function CompanyInvites() { Invite history below keeps the audit trail. + + {latestInviteUrl ? ( +
+
+
Latest invite link
+
+ This URL includes the current Paperclip domain returned by the server. +
+
+
+ {latestInviteUrl} +
+
+ + +
+
+ ) : null}