Expose created invite links

This commit is contained in:
Dotta committed 2026-04-10 16:58:41 -05:00
1 parent 27401076d4
commit 682ffe6795
4 files changed
+196 -15

No files matched your search

@@ -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_/);
});
});
+7 -2
View File
@@ -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,
+17 -1
View File
@@ -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 }));
});
+54 -12
View File
@@ -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<string | null>(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() {
</Button>
<span className="text-sm text-muted-foreground">Invite history below keeps the audit trail.</span>
</div>
{latestInviteUrl ? (
<div className="space-y-3 rounded-lg border border-border px-4 py-4">
<div className="space-y-1">
<div className="text-sm font-medium">Latest invite link</div>
<div className="text-sm text-muted-foreground">
This URL includes the current Paperclip domain returned by the server.
</div>
</div>
<div className="rounded-md border border-border bg-background px-3 py-2 text-sm break-all">
{latestInviteUrl}
</div>
<div className="flex flex-wrap gap-2">
<Button size="sm" variant="outline" onClick={() => void copyInviteUrl(latestInviteUrl)}>
<Copy className="h-4 w-4" />
Copy link
</Button>
<Button size="sm" variant="outline" asChild>
<a href={latestInviteUrl} target="_blank" rel="noreferrer">
<ExternalLink className="h-4 w-4" />
Open invite
</a>
</Button>
</div>
</div>
) : null}
</section>
<section className="rounded-xl border border-border">