mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-11 05:11:49 +01:00
Expose created invite links
This commit is contained in:
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_/);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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 }));
|
||||
});
|
||||
|
||||
@@ -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">
|
||||
|
||||
Reference in new issue
Block a user