mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-11 05:11:49 +01:00
Fix invite logo fallback and storage checks
This commit is contained in:
1 parent
e6fd9d29db
commit
38a2c91483
6 files changed
+596
-476
No files matched your search
@@ -0,0 +1,146 @@
|
||||
import { Readable } from "node:stream";
|
||||
import express from "express";
|
||||
import request from "supertest";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const mockStorage = vi.hoisted(() => ({
|
||||
getObject: vi.fn(),
|
||||
headObject: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../storage/index.js", () => ({
|
||||
getStorageService: () => mockStorage,
|
||||
}));
|
||||
|
||||
import { accessRoutes } from "../routes/access.js";
|
||||
import { errorHandler } from "../middleware/index.js";
|
||||
|
||||
function createSelectChain(rows: unknown[]) {
|
||||
const query = {
|
||||
leftJoin() {
|
||||
return query;
|
||||
},
|
||||
where() {
|
||||
return Promise.resolve(rows);
|
||||
},
|
||||
};
|
||||
return {
|
||||
from() {
|
||||
return query;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function createDbStub(inviteRows: unknown[], companyRows: unknown[]) {
|
||||
let selectCall = 0;
|
||||
return {
|
||||
select() {
|
||||
selectCall += 1;
|
||||
return selectCall === 1
|
||||
? createSelectChain(inviteRows)
|
||||
: createSelectChain(companyRows);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function createApp(db: Record<string, unknown>) {
|
||||
const app = express();
|
||||
app.use((req, _res, next) => {
|
||||
(req as any).actor = { type: "anon" };
|
||||
next();
|
||||
});
|
||||
app.use(
|
||||
"/api",
|
||||
accessRoutes(db as any, {
|
||||
deploymentMode: "local_trusted",
|
||||
deploymentExposure: "private",
|
||||
bindHost: "127.0.0.1",
|
||||
allowedHostnames: [],
|
||||
}),
|
||||
);
|
||||
app.use(errorHandler);
|
||||
return app;
|
||||
}
|
||||
|
||||
describe("GET /invites/:token/logo", () => {
|
||||
beforeEach(() => {
|
||||
mockStorage.getObject.mockReset();
|
||||
mockStorage.headObject.mockReset();
|
||||
});
|
||||
|
||||
it("serves the company logo for an active invite without company auth", async () => {
|
||||
const invite = {
|
||||
id: "invite-1",
|
||||
companyId: "company-1",
|
||||
inviteType: "company_join",
|
||||
allowedJoinTypes: "human",
|
||||
tokenHash: "hash",
|
||||
defaultsPayload: null,
|
||||
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"),
|
||||
};
|
||||
mockStorage.headObject.mockResolvedValue({
|
||||
exists: true,
|
||||
contentType: "image/png",
|
||||
contentLength: 3,
|
||||
});
|
||||
mockStorage.getObject.mockResolvedValue({
|
||||
contentType: "image/png",
|
||||
contentLength: 3,
|
||||
stream: Readable.from([Buffer.from("png")]),
|
||||
});
|
||||
const app = createApp(
|
||||
createDbStub([invite], [{
|
||||
companyId: "company-1",
|
||||
objectKey: "assets/companies/logo-1",
|
||||
contentType: "image/png",
|
||||
byteSize: 3,
|
||||
originalFilename: "logo.png",
|
||||
}]),
|
||||
);
|
||||
|
||||
const res = await request(app).get("/api/invites/pcp_invite_test/logo");
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.headers["content-type"]).toContain("image/png");
|
||||
expect(mockStorage.headObject).toHaveBeenCalledWith("company-1", "assets/companies/logo-1");
|
||||
expect(mockStorage.getObject).toHaveBeenCalledWith("company-1", "assets/companies/logo-1");
|
||||
});
|
||||
|
||||
it("returns 404 when the logo asset record exists but storage does not", async () => {
|
||||
const invite = {
|
||||
id: "invite-1",
|
||||
companyId: "company-1",
|
||||
inviteType: "company_join",
|
||||
allowedJoinTypes: "human",
|
||||
tokenHash: "hash",
|
||||
defaultsPayload: null,
|
||||
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"),
|
||||
};
|
||||
mockStorage.headObject.mockResolvedValue({ exists: false });
|
||||
const app = createApp(
|
||||
createDbStub([invite], [{
|
||||
companyId: "company-1",
|
||||
objectKey: "assets/companies/logo-1",
|
||||
contentType: "image/png",
|
||||
byteSize: 3,
|
||||
originalFilename: "logo.png",
|
||||
}]),
|
||||
);
|
||||
|
||||
const res = await request(app).get("/api/invites/pcp_invite_test/logo");
|
||||
|
||||
expect(res.status).toBe(404);
|
||||
expect(res.body.error).toBe("Invite logo not found");
|
||||
expect(mockStorage.getObject).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,15 @@
|
||||
import express from "express";
|
||||
import request from "supertest";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const mockStorage = vi.hoisted(() => ({
|
||||
headObject: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../storage/index.js", () => ({
|
||||
getStorageService: () => mockStorage,
|
||||
}));
|
||||
|
||||
import { accessRoutes } from "../routes/access.js";
|
||||
import { errorHandler } from "../middleware/index.js";
|
||||
|
||||
@@ -20,14 +29,14 @@ function createSelectChain(rows: unknown[]) {
|
||||
};
|
||||
}
|
||||
|
||||
function createDbStub(inviteRows: unknown[], companyRows: unknown[]) {
|
||||
function createDbStub(inviteRows: unknown[], companyRows: unknown[], logoRows: unknown[]) {
|
||||
let selectCall = 0;
|
||||
return {
|
||||
select() {
|
||||
selectCall += 1;
|
||||
return selectCall === 1
|
||||
? createSelectChain(inviteRows)
|
||||
: createSelectChain(companyRows);
|
||||
if (selectCall === 1) return createSelectChain(inviteRows);
|
||||
if (selectCall === 2) return createSelectChain(companyRows);
|
||||
return createSelectChain(logoRows);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -52,6 +61,11 @@ function createApp(db: Record<string, unknown>) {
|
||||
}
|
||||
|
||||
describe("GET /invites/:token", () => {
|
||||
beforeEach(() => {
|
||||
mockStorage.headObject.mockReset();
|
||||
mockStorage.headObject.mockResolvedValue({ exists: true, contentLength: 3, contentType: "image/png" });
|
||||
});
|
||||
|
||||
it("returns company branding in the invite summary response", async () => {
|
||||
const invite = {
|
||||
id: "invite-1",
|
||||
@@ -68,11 +82,21 @@ describe("GET /invites/:token", () => {
|
||||
updatedAt: new Date("2026-03-07T00:00:00.000Z"),
|
||||
};
|
||||
const app = createApp(
|
||||
createDbStub([invite], [{
|
||||
name: "Acme Robotics",
|
||||
brandColor: "#114488",
|
||||
logoAssetId: "logo-1",
|
||||
}]),
|
||||
createDbStub(
|
||||
[invite],
|
||||
[{
|
||||
name: "Acme Robotics",
|
||||
brandColor: "#114488",
|
||||
logoAssetId: "logo-1",
|
||||
}],
|
||||
[{
|
||||
companyId: "company-1",
|
||||
objectKey: "company-1/assets/companies/logo-1",
|
||||
contentType: "image/png",
|
||||
byteSize: 3,
|
||||
originalFilename: "logo.png",
|
||||
}],
|
||||
),
|
||||
);
|
||||
|
||||
const res = await request(app).get("/api/invites/pcp_invite_test");
|
||||
@@ -81,7 +105,48 @@ describe("GET /invites/:token", () => {
|
||||
expect(res.body.companyId).toBe("company-1");
|
||||
expect(res.body.companyName).toBe("Acme Robotics");
|
||||
expect(res.body.companyBrandColor).toBe("#114488");
|
||||
expect(res.body.companyLogoUrl).toBe("/api/assets/logo-1/content");
|
||||
expect(res.body.companyLogoUrl).toBe("/api/invites/pcp_invite_test/logo");
|
||||
expect(res.body.inviteType).toBe("company_join");
|
||||
});
|
||||
|
||||
it("omits companyLogoUrl when the stored logo object is missing", async () => {
|
||||
mockStorage.headObject.mockResolvedValue({ exists: false });
|
||||
|
||||
const invite = {
|
||||
id: "invite-1",
|
||||
companyId: "company-1",
|
||||
inviteType: "company_join",
|
||||
allowedJoinTypes: "human",
|
||||
tokenHash: "hash",
|
||||
defaultsPayload: null,
|
||||
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"),
|
||||
};
|
||||
const app = createApp(
|
||||
createDbStub(
|
||||
[invite],
|
||||
[{
|
||||
name: "Acme Robotics",
|
||||
brandColor: "#114488",
|
||||
logoAssetId: "logo-1",
|
||||
}],
|
||||
[{
|
||||
companyId: "company-1",
|
||||
objectKey: "company-1/assets/companies/logo-1",
|
||||
contentType: "image/png",
|
||||
byteSize: 3,
|
||||
originalFilename: "logo.png",
|
||||
}],
|
||||
),
|
||||
);
|
||||
|
||||
const res = await request(app).get("/api/invites/pcp_invite_test");
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.companyLogoUrl).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -34,6 +34,9 @@ const mockBoardAuthService = vi.hoisted(() => ({
|
||||
}));
|
||||
|
||||
const mockLogActivity = vi.hoisted(() => vi.fn());
|
||||
const mockStorage = vi.hoisted(() => ({
|
||||
headObject: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../services/index.js", () => ({
|
||||
accessService: () => mockAccessService,
|
||||
@@ -44,6 +47,10 @@ vi.mock("../services/index.js", () => ({
|
||||
notifyHireApproved: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../storage/index.js", () => ({
|
||||
getStorageService: () => mockStorage,
|
||||
}));
|
||||
|
||||
function createDbStub() {
|
||||
const createdInvite = {
|
||||
id: "invite-1",
|
||||
@@ -82,6 +89,15 @@ function createDbStub() {
|
||||
if (isInvitesTable(table)) {
|
||||
return Promise.resolve([createdInvite]);
|
||||
}
|
||||
if (selection && typeof selection === "object" && "objectKey" in selection) {
|
||||
return Promise.resolve([{
|
||||
companyId: "company-1",
|
||||
objectKey: "company-1/assets/companies/logo-1",
|
||||
contentType: "image/png",
|
||||
byteSize: 3,
|
||||
originalFilename: "logo.png",
|
||||
}]);
|
||||
}
|
||||
if (
|
||||
(selection && typeof selection === "object" && "name" in selection) ||
|
||||
isCompaniesTable(table)
|
||||
@@ -131,6 +147,7 @@ describe("POST /companies/:companyId/openclaw/invite-prompt", () => {
|
||||
mockAccessService.canUser.mockResolvedValue(false);
|
||||
mockAgentService.getById.mockReset();
|
||||
mockLogActivity.mockResolvedValue(undefined);
|
||||
mockStorage.headObject.mockResolvedValue({ exists: true, contentLength: 3, contentType: "image/png" });
|
||||
});
|
||||
|
||||
it("rejects non-CEO agent callers", async () => {
|
||||
@@ -209,7 +226,7 @@ describe("POST /companies/:companyId/openclaw/invite-prompt", () => {
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.companyName).toBe("Acme AI");
|
||||
expect(res.body.companyBrandColor).toBe("#225577");
|
||||
expect(res.body.companyLogoUrl).toBe("/api/assets/logo-1/content");
|
||||
expect(res.body.companyLogoUrl).toBe("/api/invites/pcp_invite_test/logo");
|
||||
expect(res.body.inviteType).toBe("company_join");
|
||||
expect(res.body.allowedJoinTypes).toBe("agent");
|
||||
});
|
||||
|
||||
+120
-6
@@ -12,6 +12,7 @@ import type { Request } from "express";
|
||||
import { and, desc, eq, inArray, isNull } from "drizzle-orm";
|
||||
import type { Db } from "@paperclipai/db";
|
||||
import {
|
||||
assets,
|
||||
agentApiKeys,
|
||||
authUsers,
|
||||
companies,
|
||||
@@ -64,6 +65,7 @@ import {
|
||||
claimBoardOwnership,
|
||||
inspectBoardClaimChallenge
|
||||
} from "../board-claim.js";
|
||||
import { getStorageService } from "../storage/index.js";
|
||||
|
||||
function hashToken(token: string) {
|
||||
return createHash("sha256").update(token).digest("hex");
|
||||
@@ -2228,9 +2230,17 @@ export function accessRoutes(
|
||||
return { token, created, normalizedAgentMessage };
|
||||
}
|
||||
|
||||
async function getInviteCompanyBranding(companyId: string | null) {
|
||||
async function getInviteCompanyBranding(
|
||||
companyId: string | null,
|
||||
inviteToken: string | null = null,
|
||||
): Promise<{
|
||||
name: string | null;
|
||||
brandColor: string | null;
|
||||
logoAssetId: string | null;
|
||||
logoUrl: string | null;
|
||||
}> {
|
||||
if (!companyId) {
|
||||
return { name: null, brandColor: null, logoUrl: null };
|
||||
return { name: null, brandColor: null, logoAssetId: null, logoUrl: null };
|
||||
}
|
||||
const company = await db
|
||||
.select({
|
||||
@@ -2242,10 +2252,66 @@ export function accessRoutes(
|
||||
.leftJoin(companyLogos, eq(companyLogos.companyId, companies.id))
|
||||
.where(eq(companies.id, companyId))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
let logoUrl: string | null = null;
|
||||
if (inviteToken && company?.logoAssetId) {
|
||||
const logoAsset = await getInviteLogoAsset(companyId);
|
||||
if (logoAsset?.companyId) {
|
||||
try {
|
||||
const storage = getStorageService();
|
||||
const logoObject = await storage.headObject(logoAsset.companyId, logoAsset.objectKey);
|
||||
if (logoObject.exists) {
|
||||
logoUrl = `/api/invites/${inviteToken}/logo`;
|
||||
}
|
||||
} catch (err) {
|
||||
logger.warn(
|
||||
{
|
||||
err,
|
||||
companyId,
|
||||
logoAssetId: company.logoAssetId,
|
||||
},
|
||||
"invite logo storage check failed",
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
name: company?.name ?? null,
|
||||
brandColor: company?.brandColor ?? null,
|
||||
logoUrl: company?.logoAssetId ? `/api/assets/${company.logoAssetId}/content` : null,
|
||||
logoAssetId: company?.logoAssetId ?? null,
|
||||
logoUrl,
|
||||
};
|
||||
}
|
||||
|
||||
async function getInviteLogoAsset(companyId: string | null): Promise<{
|
||||
companyId: string | null;
|
||||
objectKey: string;
|
||||
contentType: string | null;
|
||||
byteSize: number | null;
|
||||
originalFilename: string | null;
|
||||
} | null> {
|
||||
if (!companyId) return null;
|
||||
const logoAsset = await db
|
||||
.select({
|
||||
companyId: companies.id,
|
||||
objectKey: assets.objectKey,
|
||||
contentType: assets.contentType,
|
||||
byteSize: assets.byteSize,
|
||||
originalFilename: assets.originalFilename,
|
||||
})
|
||||
.from(companies)
|
||||
.leftJoin(companyLogos, eq(companyLogos.companyId, companies.id))
|
||||
.leftJoin(assets, eq(assets.id, companyLogos.assetId))
|
||||
.where(eq(companies.id, companyId))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
|
||||
if (!logoAsset?.objectKey) return null;
|
||||
return {
|
||||
companyId: logoAsset.companyId,
|
||||
objectKey: logoAsset.objectKey,
|
||||
contentType: logoAsset.contentType,
|
||||
byteSize: logoAsset.byteSize,
|
||||
originalFilename: logoAsset.originalFilename,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -2311,7 +2377,7 @@ export function accessRoutes(
|
||||
}
|
||||
});
|
||||
|
||||
const companyBranding = await getInviteCompanyBranding(created.companyId);
|
||||
const companyBranding = await getInviteCompanyBranding(created.companyId, token);
|
||||
const inviteSummary = toInviteSummaryResponse(
|
||||
req,
|
||||
token,
|
||||
@@ -2365,7 +2431,7 @@ export function accessRoutes(
|
||||
}
|
||||
});
|
||||
|
||||
const companyBranding = await getInviteCompanyBranding(created.companyId);
|
||||
const companyBranding = await getInviteCompanyBranding(created.companyId, token);
|
||||
const inviteSummary = toInviteSummaryResponse(
|
||||
req,
|
||||
token,
|
||||
@@ -2402,10 +2468,58 @@ export function accessRoutes(
|
||||
throw notFound("Invite not found");
|
||||
}
|
||||
|
||||
const companyBranding = await getInviteCompanyBranding(invite.companyId);
|
||||
const companyBranding = await getInviteCompanyBranding(invite.companyId, token);
|
||||
res.json(toInviteSummaryResponse(req, token, invite, companyBranding));
|
||||
});
|
||||
|
||||
router.get("/invites/:token/logo", async (req, res, next) => {
|
||||
const token = (req.params.token as string).trim();
|
||||
if (!token) throw notFound("Invite not found");
|
||||
const invite = await db
|
||||
.select()
|
||||
.from(invites)
|
||||
.where(eq(invites.tokenHash, hashToken(token)))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
if (!invite || invite.revokedAt || invite.acceptedAt || inviteExpired(invite)) {
|
||||
throw notFound("Invite not found");
|
||||
}
|
||||
|
||||
const logoAsset = await getInviteLogoAsset(invite.companyId);
|
||||
if (!logoAsset || !logoAsset.companyId) {
|
||||
throw notFound("Invite logo not found");
|
||||
}
|
||||
const companyId = logoAsset.companyId;
|
||||
|
||||
const storage = getStorageService();
|
||||
const logoHead = await storage.headObject(companyId, logoAsset.objectKey);
|
||||
if (!logoHead.exists) {
|
||||
throw notFound("Invite logo not found");
|
||||
}
|
||||
const object = await storage.getObject(companyId, logoAsset.objectKey);
|
||||
const responseContentType =
|
||||
logoAsset.contentType ||
|
||||
logoHead.contentType ||
|
||||
object.contentType ||
|
||||
"application/octet-stream";
|
||||
res.setHeader("Content-Type", responseContentType);
|
||||
res.setHeader(
|
||||
"Content-Length",
|
||||
String(logoAsset.byteSize || logoHead.contentLength || object.contentLength || 0),
|
||||
);
|
||||
res.setHeader("Cache-Control", "private, max-age=60");
|
||||
res.setHeader("X-Content-Type-Options", "nosniff");
|
||||
if (responseContentType === "image/svg+xml") {
|
||||
res.setHeader("Content-Security-Policy", "sandbox; default-src 'none'; img-src 'self' data:; style-src 'unsafe-inline'");
|
||||
}
|
||||
const filename = logoAsset.originalFilename ?? "company-logo";
|
||||
res.setHeader("Content-Disposition", `inline; filename=\"${filename.replaceAll("\"", "")}\"`);
|
||||
|
||||
object.stream.on("error", (err) => {
|
||||
next(err);
|
||||
});
|
||||
object.stream.pipe(res);
|
||||
});
|
||||
|
||||
router.get("/invites/:token/onboarding", async (req, res) => {
|
||||
const token = (req.params.token as string).trim();
|
||||
if (!token) throw notFound("Invite not found");
|
||||
|
||||
@@ -85,7 +85,7 @@ describe("InviteLandingPage", () => {
|
||||
id: "invite-1",
|
||||
companyId: "company-1",
|
||||
companyName: "Acme Robotics",
|
||||
companyLogoUrl: "/api/assets/logo-1/content",
|
||||
companyLogoUrl: "/api/invites/pcp_invite_test/logo",
|
||||
companyBrandColor: "#114488",
|
||||
inviteType: "company_join",
|
||||
allowedJoinTypes: "both",
|
||||
@@ -143,9 +143,11 @@ describe("InviteLandingPage", () => {
|
||||
await flushReact();
|
||||
|
||||
expect(container.textContent).toContain("Join Acme Robotics");
|
||||
expect(container.textContent).toContain("Personal access");
|
||||
expect(container.textContent).toContain("Sign in");
|
||||
expect(container.textContent).toContain("Create account");
|
||||
expect(container.textContent).not.toContain("Join as human");
|
||||
expect(container.textContent).not.toContain("How personal access works");
|
||||
expect(container.textContent).not.toContain("Choose your path");
|
||||
expect(container.querySelector('[data-testid="invite-inline-auth"]')).not.toBeNull();
|
||||
expect(localStorage.getItem("paperclip:pending-invite-token")).toBe("pcp_invite_test");
|
||||
expect(container.querySelector('img[alt="Acme Robotics logo"]')).not.toBeNull();
|
||||
@@ -166,7 +168,7 @@ describe("InviteLandingPage", () => {
|
||||
passwordInput!.dispatchEvent(new Event("change", { bubbles: true }));
|
||||
});
|
||||
|
||||
const authForm = container.querySelector('[data-testid="invite-inline-auth"] form') as HTMLFormElement | null;
|
||||
const authForm = container.querySelector('[data-testid="invite-inline-auth"]') as HTMLFormElement | null;
|
||||
expect(authForm).not.toBeNull();
|
||||
|
||||
await act(async () => {
|
||||
@@ -187,4 +189,40 @@ describe("InviteLandingPage", () => {
|
||||
root.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
it("falls back to the generated company icon when the invite logo fails to load", async () => {
|
||||
const root = createRoot(container);
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false } },
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<MemoryRouter initialEntries={["/invite/pcp_invite_test"]}>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<Routes>
|
||||
<Route path="/invite/:token" element={<InviteLandingPage />} />
|
||||
</Routes>
|
||||
</QueryClientProvider>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
});
|
||||
await flushReact();
|
||||
await flushReact();
|
||||
|
||||
const logo = container.querySelector('img[alt="Acme Robotics logo"]') as HTMLImageElement | null;
|
||||
expect(logo).not.toBeNull();
|
||||
|
||||
await act(async () => {
|
||||
logo?.dispatchEvent(new Event("error"));
|
||||
});
|
||||
await flushReact();
|
||||
|
||||
expect(container.querySelector('img[alt="Acme Robotics logo"]')).toBeNull();
|
||||
expect(container.querySelector('img[aria-hidden="true"]')).not.toBeNull();
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
});
|
||||
});
|
||||
+195
-455
@@ -2,7 +2,6 @@ import { useEffect, useMemo, useState } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { AGENT_ADAPTER_TYPES } from "@paperclipai/shared";
|
||||
import type { AgentAdapterType, JoinRequest } from "@paperclipai/shared";
|
||||
import { ArrowRight, CheckCircle2, Sparkles } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { CompanyPatternIcon } from "@/components/CompanyPatternIcon";
|
||||
import { Link, useParams } from "@/lib/router";
|
||||
@@ -10,10 +9,9 @@ import { accessApi } from "../api/access";
|
||||
import { authApi } from "../api/auth";
|
||||
import { healthApi } from "../api/health";
|
||||
import { getAdapterLabel } from "../adapters/adapter-display-registry";
|
||||
import { rememberPendingInviteToken, clearPendingInviteToken } from "../lib/invite-memory";
|
||||
import { clearPendingInviteToken, rememberPendingInviteToken } from "../lib/invite-memory";
|
||||
import { queryKeys } from "../lib/queryKeys";
|
||||
|
||||
type JoinType = "human" | "agent";
|
||||
type AuthMode = "sign_in" | "sign_up";
|
||||
|
||||
const joinAdapterOptions: AgentAdapterType[] = [...AGENT_ADAPTER_TYPES];
|
||||
@@ -26,10 +24,6 @@ const ENABLED_INVITE_ADAPTERS = new Set([
|
||||
"cursor",
|
||||
]);
|
||||
|
||||
function dateTime(value: string) {
|
||||
return new Date(value).toLocaleString();
|
||||
}
|
||||
|
||||
function readNestedString(value: unknown, path: string[]): string | null {
|
||||
let current: unknown = value;
|
||||
for (const segment of path) {
|
||||
@@ -39,27 +33,13 @@ function readNestedString(value: unknown, path: string[]): string | null {
|
||||
return typeof current === "string" && current.trim().length > 0 ? current : null;
|
||||
}
|
||||
|
||||
function hexToRgba(hex: string | null | undefined, alpha: number) {
|
||||
if (!hex || !/^#[0-9a-f]{6}$/i.test(hex)) {
|
||||
return `rgba(15, 118, 110, ${alpha})`;
|
||||
}
|
||||
const normalized = hex.replace("#", "");
|
||||
const red = Number.parseInt(normalized.slice(0, 2), 16);
|
||||
const green = Number.parseInt(normalized.slice(2, 4), 16);
|
||||
const blue = Number.parseInt(normalized.slice(4, 6), 16);
|
||||
return `rgba(${red}, ${green}, ${blue}, ${alpha})`;
|
||||
}
|
||||
|
||||
function formatHumanRole(role: string | null | undefined) {
|
||||
if (!role) return null;
|
||||
return role.charAt(0).toUpperCase() + role.slice(1);
|
||||
}
|
||||
const fieldClassName =
|
||||
"w-full border border-zinc-800 bg-zinc-950 px-3 py-2 text-sm text-zinc-100 outline-none focus:border-zinc-500";
|
||||
|
||||
export function InviteLandingPage() {
|
||||
const queryClient = useQueryClient();
|
||||
const params = useParams();
|
||||
const token = (params.token ?? "").trim();
|
||||
const [joinType, setJoinType] = useState<JoinType>("human");
|
||||
const [authMode, setAuthMode] = useState<AuthMode>("sign_in");
|
||||
const [name, setName] = useState("");
|
||||
const [email, setEmail] = useState("");
|
||||
@@ -94,31 +74,19 @@ export function InviteLandingPage() {
|
||||
|
||||
const invite = inviteQuery.data;
|
||||
const companyName = invite?.companyName?.trim() || null;
|
||||
const companyDisplayName = companyName || "this Paperclip company";
|
||||
const companyLogoUrl = invite?.companyLogoUrl?.trim() || null;
|
||||
const companyBrandColor = invite?.companyBrandColor?.trim() || null;
|
||||
const companyDisplayName = companyName || "this Paperclip company";
|
||||
const availableJoinTypes = useMemo(() => {
|
||||
if (invite?.inviteType === "bootstrap_ceo") return ["human"] as JoinType[];
|
||||
if (invite?.allowedJoinTypes === "both") return ["human", "agent"] as JoinType[];
|
||||
return [(invite?.allowedJoinTypes ?? "human") as JoinType];
|
||||
}, [invite?.allowedJoinTypes, invite?.inviteType]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!availableJoinTypes.includes(joinType)) {
|
||||
setJoinType(availableJoinTypes[0] ?? "human");
|
||||
}
|
||||
}, [availableJoinTypes, joinType]);
|
||||
|
||||
const requiresAccount =
|
||||
const requiresHumanAccount =
|
||||
healthQuery.data?.deploymentMode === "authenticated" &&
|
||||
!sessionQuery.data &&
|
||||
(invite?.inviteType === "bootstrap_ceo" || joinType === "human");
|
||||
|
||||
invite?.allowedJoinTypes !== "agent";
|
||||
const showsAgentForm = invite?.inviteType !== "bootstrap_ceo" && invite?.allowedJoinTypes === "agent";
|
||||
const sessionLabel =
|
||||
sessionQuery.data?.user.name?.trim() ||
|
||||
sessionQuery.data?.user.email?.trim() ||
|
||||
"this account";
|
||||
const requestedRole = formatHumanRole(invite?.humanRole);
|
||||
|
||||
const authCanSubmit =
|
||||
email.trim().length > 0 &&
|
||||
password.trim().length > 0 &&
|
||||
@@ -127,10 +95,7 @@ export function InviteLandingPage() {
|
||||
const acceptMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
if (!invite) throw new Error("Invite not found");
|
||||
if (invite.inviteType === "bootstrap_ceo") {
|
||||
return accessApi.acceptInvite(token, { requestType: "human" });
|
||||
}
|
||||
if (joinType === "human") {
|
||||
if (invite.inviteType === "bootstrap_ceo" || invite.allowedJoinTypes !== "agent") {
|
||||
return accessApi.acceptInvite(token, { requestType: "human" });
|
||||
}
|
||||
return accessApi.acceptInvite(token, {
|
||||
@@ -177,6 +142,13 @@ export function InviteLandingPage() {
|
||||
},
|
||||
});
|
||||
|
||||
const joinButtonLabel = useMemo(() => {
|
||||
if (!invite) return "Continue";
|
||||
if (invite.inviteType === "bootstrap_ceo") return "Accept invite";
|
||||
if (showsAgentForm) return "Submit";
|
||||
return "Join company";
|
||||
}, [invite, showsAgentForm]);
|
||||
|
||||
if (!token) {
|
||||
return <div className="mx-auto max-w-xl py-10 text-sm text-destructive">Invalid invite token.</div>;
|
||||
}
|
||||
@@ -188,7 +160,7 @@ export function InviteLandingPage() {
|
||||
if (inviteQuery.error || !invite) {
|
||||
return (
|
||||
<div className="mx-auto max-w-xl py-10">
|
||||
<div className="rounded-lg border border-border bg-card p-6" data-testid="invite-error">
|
||||
<div className="border border-border bg-card p-6" data-testid="invite-error">
|
||||
<h1 className="text-lg font-semibold">Invite not available</h1>
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
This invite may be expired, revoked, or already used.
|
||||
@@ -200,15 +172,14 @@ export function InviteLandingPage() {
|
||||
|
||||
if (result?.kind === "bootstrap") {
|
||||
return (
|
||||
<div className="mx-auto max-w-xl py-10">
|
||||
<div className="rounded-3xl border border-border bg-card p-6 shadow-sm">
|
||||
<div className="min-h-screen bg-zinc-950 px-6 py-12 text-zinc-100">
|
||||
<div className="mx-auto max-w-md border border-zinc-800 bg-zinc-950 p-6">
|
||||
<h1 className="text-lg font-semibold">Bootstrap complete</h1>
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
The first instance admin is now configured. You can continue to the board.
|
||||
</p>
|
||||
<Button asChild className="mt-4">
|
||||
<Link to="/">Open board</Link>
|
||||
</Button>
|
||||
<div className="mt-4">
|
||||
<Button asChild className="rounded-none">
|
||||
<Link to="/">Open board</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -219,427 +190,196 @@ export function InviteLandingPage() {
|
||||
claimSecret?: string;
|
||||
claimApiKeyPath?: string;
|
||||
onboarding?: Record<string, unknown>;
|
||||
diagnostics?: Array<{
|
||||
code: string;
|
||||
level: "info" | "warn";
|
||||
message: string;
|
||||
hint?: string;
|
||||
}>;
|
||||
};
|
||||
const claimSecret = typeof payload.claimSecret === "string" ? payload.claimSecret : null;
|
||||
const claimApiKeyPath = typeof payload.claimApiKeyPath === "string" ? payload.claimApiKeyPath : null;
|
||||
const onboardingSkillUrl = readNestedString(payload.onboarding, ["skill", "url"]);
|
||||
const onboardingSkillPath = readNestedString(payload.onboarding, ["skill", "path"]);
|
||||
const onboardingInstallPath = readNestedString(payload.onboarding, ["skill", "installPath"]);
|
||||
const onboardingTextUrl = readNestedString(payload.onboarding, ["textInstructions", "url"]);
|
||||
const onboardingTextPath = readNestedString(payload.onboarding, ["textInstructions", "path"]);
|
||||
const diagnostics = Array.isArray(payload.diagnostics) ? payload.diagnostics : [];
|
||||
const personalAccessApproved = joinType === "human" && payload.status === "approved";
|
||||
const joinedNow = !showsAgentForm && payload.status === "approved";
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-xl py-10">
|
||||
<div className="rounded-3xl border border-border bg-card p-6 shadow-sm">
|
||||
<h1 className="text-lg font-semibold">
|
||||
{personalAccessApproved ? "You joined the company" : "Access request submitted"}
|
||||
</h1>
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
{personalAccessApproved
|
||||
? "Your membership is active now. You can continue to the board."
|
||||
: "Your request is pending admin approval. You will not have access until approved."}
|
||||
</p>
|
||||
<div className="mt-4 rounded-2xl border border-border bg-muted/40 p-3 text-xs text-muted-foreground">
|
||||
Request ID: <span className="font-mono">{payload.id}</span>
|
||||
<div className="min-h-screen bg-zinc-950 px-6 py-12 text-zinc-100">
|
||||
<div className="mx-auto max-w-md border border-zinc-800 bg-zinc-950 p-6">
|
||||
<h1 className="text-lg font-semibold">{joinedNow ? "You joined the company" : "Request submitted"}</h1>
|
||||
<div className="mt-4 text-sm text-zinc-400">
|
||||
Request ID: <span className="font-mono text-zinc-200">{payload.id}</span>
|
||||
</div>
|
||||
{personalAccessApproved && (
|
||||
<Button asChild className="mt-4">
|
||||
<Link to="/">Open board</Link>
|
||||
</Button>
|
||||
)}
|
||||
{claimSecret && claimApiKeyPath && (
|
||||
<div className="mt-3 space-y-1 rounded-2xl border border-border bg-muted/40 p-3 text-xs text-muted-foreground">
|
||||
<p className="font-medium text-foreground">One-time claim secret (save now)</p>
|
||||
<p className="font-mono break-all">{claimSecret}</p>
|
||||
<p className="font-mono break-all">POST {claimApiKeyPath}</p>
|
||||
{claimSecret && claimApiKeyPath ? (
|
||||
<div className="mt-4 space-y-1 border border-zinc-800 p-3 text-xs text-zinc-400">
|
||||
<div className="text-zinc-200">Claim secret</div>
|
||||
<div className="font-mono break-all">{claimSecret}</div>
|
||||
<div className="font-mono break-all">POST {claimApiKeyPath}</div>
|
||||
</div>
|
||||
)}
|
||||
{(onboardingSkillUrl || onboardingSkillPath || onboardingInstallPath) && (
|
||||
<div className="mt-3 space-y-1 rounded-2xl border border-border bg-muted/40 p-3 text-xs text-muted-foreground">
|
||||
<p className="font-medium text-foreground">Paperclip skill bootstrap</p>
|
||||
{onboardingSkillUrl && <p className="font-mono break-all">GET {onboardingSkillUrl}</p>}
|
||||
{!onboardingSkillUrl && onboardingSkillPath && <p className="font-mono break-all">GET {onboardingSkillPath}</p>}
|
||||
{onboardingInstallPath && <p className="font-mono break-all">Install to {onboardingInstallPath}</p>}
|
||||
) : null}
|
||||
{onboardingTextUrl ? (
|
||||
<div className="mt-4 text-xs text-zinc-400">
|
||||
Onboarding: <span className="font-mono break-all">{onboardingTextUrl}</span>
|
||||
</div>
|
||||
)}
|
||||
{(onboardingTextUrl || onboardingTextPath) && (
|
||||
<div className="mt-3 space-y-1 rounded-2xl border border-border bg-muted/40 p-3 text-xs text-muted-foreground">
|
||||
<p className="font-medium text-foreground">Agent-readable onboarding text</p>
|
||||
{onboardingTextUrl && <p className="font-mono break-all">GET {onboardingTextUrl}</p>}
|
||||
{!onboardingTextUrl && onboardingTextPath && <p className="font-mono break-all">GET {onboardingTextPath}</p>}
|
||||
) : null}
|
||||
{joinedNow ? (
|
||||
<div className="mt-4">
|
||||
<Button asChild className="rounded-none">
|
||||
<Link to="/">Open board</Link>
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
{diagnostics.length > 0 && (
|
||||
<div className="mt-3 space-y-1 rounded-2xl border border-border bg-muted/40 p-3 text-xs text-muted-foreground">
|
||||
<p className="font-medium text-foreground">Connectivity diagnostics</p>
|
||||
{diagnostics.map((diag, idx) => (
|
||||
<div key={`${diag.code}:${idx}`} className="space-y-0.5">
|
||||
<p className={diag.level === "warn" ? "text-amber-600 dark:text-amber-400" : undefined}>
|
||||
[{diag.level}] {diag.message}
|
||||
</p>
|
||||
{diag.hint && <p className="font-mono break-all">{diag.hint}</p>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const canSubmitJoin =
|
||||
!requiresAccount &&
|
||||
!acceptMutation.isPending &&
|
||||
(joinType !== "agent" || invite.inviteType === "bootstrap_ceo" || agentName.trim().length > 0);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="min-h-screen bg-background px-4 py-6 sm:px-6 lg:px-8"
|
||||
style={{
|
||||
backgroundImage: [
|
||||
`radial-gradient(circle at top left, ${hexToRgba(companyBrandColor, 0.18)}, transparent 36%)`,
|
||||
`radial-gradient(circle at bottom right, ${hexToRgba(companyBrandColor, 0.1)}, transparent 32%)`,
|
||||
"linear-gradient(180deg, rgba(255,255,255,0.98), rgba(248,248,245,0.98))",
|
||||
].join(", "),
|
||||
}}
|
||||
>
|
||||
<div className="mx-auto grid max-w-6xl gap-6 lg:grid-cols-[minmax(0,1.2fr)_24rem]">
|
||||
<section className="overflow-hidden rounded-[28px] border border-border/70 bg-card/95 shadow-[0_24px_70px_rgba(15,23,42,0.08)]">
|
||||
<div
|
||||
className="border-b border-border/70 px-6 py-6 sm:px-8"
|
||||
style={{
|
||||
backgroundImage: `linear-gradient(135deg, ${hexToRgba(companyBrandColor, 0.16)}, transparent 58%)`,
|
||||
}}
|
||||
>
|
||||
<div className="flex items-start gap-4">
|
||||
<CompanyPatternIcon
|
||||
companyName={companyDisplayName}
|
||||
logoUrl={companyLogoUrl}
|
||||
brandColor={companyBrandColor}
|
||||
className="h-16 w-16 rounded-[20px] text-xl shadow-sm"
|
||||
<div className="min-h-screen bg-zinc-950 px-6 py-12 text-zinc-100">
|
||||
<div className="mx-auto max-w-md border border-zinc-800 bg-zinc-950 p-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<CompanyPatternIcon
|
||||
companyName={companyDisplayName}
|
||||
logoUrl={companyLogoUrl}
|
||||
brandColor={companyBrandColor}
|
||||
className="h-12 w-12 border border-zinc-800 rounded-none"
|
||||
/>
|
||||
<div>
|
||||
<h1 className="text-lg font-semibold">
|
||||
{invite.inviteType === "bootstrap_ceo" ? "Set up Paperclip" : `Join ${companyDisplayName}`}
|
||||
</h1>
|
||||
{sessionQuery.data ? (
|
||||
<p className="mt-1 text-sm text-zinc-400">Signed in as {sessionLabel}</p>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showsAgentForm ? (
|
||||
<div className="mt-6 space-y-4">
|
||||
<label className="block text-sm">
|
||||
<span className="mb-1 block text-zinc-400">Agent name</span>
|
||||
<input
|
||||
className={fieldClassName}
|
||||
value={agentName}
|
||||
onChange={(event) => setAgentName(event.target.value)}
|
||||
/>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="inline-flex items-center gap-2 rounded-full border border-border/80 bg-background/80 px-3 py-1 text-xs font-medium text-muted-foreground">
|
||||
<Sparkles className="h-3.5 w-3.5" />
|
||||
Invite ready
|
||||
</div>
|
||||
<h1 className="mt-3 text-2xl font-semibold tracking-tight sm:text-3xl">
|
||||
{invite.inviteType === "bootstrap_ceo"
|
||||
? "Set up your Paperclip instance"
|
||||
: `Join ${companyDisplayName}`}
|
||||
</h1>
|
||||
<p className="mt-3 max-w-2xl text-sm leading-6 text-muted-foreground sm:text-base">
|
||||
{invite.inviteType === "bootstrap_ceo"
|
||||
? "Finish creating the first operator account without leaving this page."
|
||||
: "Review the invite, sign in or create an account inline if needed, and keep this invite in place until you finish."}
|
||||
</p>
|
||||
</div>
|
||||
</label>
|
||||
<label className="block text-sm">
|
||||
<span className="mb-1 block text-zinc-400">Adapter type</span>
|
||||
<select
|
||||
className={fieldClassName}
|
||||
value={adapterType}
|
||||
onChange={(event) => setAdapterType(event.target.value as AgentAdapterType)}
|
||||
>
|
||||
{joinAdapterOptions.map((type) => (
|
||||
<option key={type} value={type} disabled={!ENABLED_INVITE_ADAPTERS.has(type)}>
|
||||
{getAdapterLabel(type)}{!ENABLED_INVITE_ADAPTERS.has(type) ? " (Coming soon)" : ""}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label className="block text-sm">
|
||||
<span className="mb-1 block text-zinc-400">Capabilities</span>
|
||||
<textarea
|
||||
className={fieldClassName}
|
||||
rows={4}
|
||||
value={capabilities}
|
||||
onChange={(event) => setCapabilities(event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
{error ? <p className="text-xs text-red-400">{error}</p> : null}
|
||||
<Button
|
||||
className="w-full rounded-none"
|
||||
disabled={acceptMutation.isPending || agentName.trim().length === 0}
|
||||
onClick={() => acceptMutation.mutate()}
|
||||
>
|
||||
{acceptMutation.isPending ? "Working..." : joinButtonLabel}
|
||||
</Button>
|
||||
</div>
|
||||
) : requiresHumanAccount ? (
|
||||
<div className="mt-6">
|
||||
<form
|
||||
className="space-y-4"
|
||||
method="post"
|
||||
action={authMode === "sign_up" ? "/api/auth/sign-up/email" : "/api/auth/sign-in/email"}
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
if (authMutation.isPending) return;
|
||||
if (!authCanSubmit) {
|
||||
setAuthError("Please fill in all required fields.");
|
||||
return;
|
||||
}
|
||||
authMutation.mutate();
|
||||
}}
|
||||
data-testid="invite-inline-auth"
|
||||
>
|
||||
{authMode === "sign_up" ? (
|
||||
<label className="block text-sm">
|
||||
<span className="mb-1 block text-zinc-400">Name</span>
|
||||
<input
|
||||
name="name"
|
||||
className={fieldClassName}
|
||||
value={name}
|
||||
onChange={(event) => setName(event.target.value)}
|
||||
autoComplete="name"
|
||||
/>
|
||||
</label>
|
||||
) : null}
|
||||
<label className="block text-sm">
|
||||
<span className="mb-1 block text-zinc-400">Email</span>
|
||||
<input
|
||||
name="email"
|
||||
type="email"
|
||||
className={fieldClassName}
|
||||
value={email}
|
||||
onChange={(event) => setEmail(event.target.value)}
|
||||
autoComplete="email"
|
||||
/>
|
||||
</label>
|
||||
<label className="block text-sm">
|
||||
<span className="mb-1 block text-zinc-400">Password</span>
|
||||
<input
|
||||
name="password"
|
||||
type="password"
|
||||
className={fieldClassName}
|
||||
value={password}
|
||||
onChange={(event) => setPassword(event.target.value)}
|
||||
autoComplete={authMode === "sign_in" ? "current-password" : "new-password"}
|
||||
/>
|
||||
</label>
|
||||
{authError ? <p className="text-xs text-red-400">{authError}</p> : null}
|
||||
<Button
|
||||
type="submit"
|
||||
className="w-full rounded-none"
|
||||
disabled={authMutation.isPending}
|
||||
aria-disabled={!authCanSubmit || authMutation.isPending}
|
||||
>
|
||||
{authMutation.isPending
|
||||
? "Working..."
|
||||
: authMode === "sign_in"
|
||||
? "Sign in"
|
||||
: "Create account"}
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
<div className="mt-3">
|
||||
<button
|
||||
type="button"
|
||||
className="text-sm text-zinc-400 underline underline-offset-2"
|
||||
onClick={() => {
|
||||
setAuthError(null);
|
||||
setAuthMode(authMode === "sign_in" ? "sign_up" : "sign_in");
|
||||
}}
|
||||
>
|
||||
{authMode === "sign_in" ? "Create account" : "Back to sign in"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6 px-6 py-6 sm:px-8 lg:grid-cols-[minmax(0,1fr)_260px]">
|
||||
<div className="space-y-6">
|
||||
<div className="grid gap-3 sm:grid-cols-3">
|
||||
<div className="rounded-2xl border border-border/70 bg-background/80 p-4">
|
||||
<div className="text-xs font-medium uppercase tracking-[0.2em] text-muted-foreground">
|
||||
Access
|
||||
</div>
|
||||
<div className="mt-2 text-sm font-medium text-foreground">
|
||||
{invite.inviteType === "bootstrap_ceo"
|
||||
? "Instance setup"
|
||||
: invite.allowedJoinTypes === "both"
|
||||
? "Personal or agent"
|
||||
: invite.allowedJoinTypes === "agent"
|
||||
? "Agent"
|
||||
: "Personal"}
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-2xl border border-border/70 bg-background/80 p-4">
|
||||
<div className="text-xs font-medium uppercase tracking-[0.2em] text-muted-foreground">
|
||||
Default role
|
||||
</div>
|
||||
<div className="mt-2 text-sm font-medium text-foreground">{requestedRole ?? "Review on submit"}</div>
|
||||
</div>
|
||||
<div className="rounded-2xl border border-border/70 bg-background/80 p-4">
|
||||
<div className="text-xs font-medium uppercase tracking-[0.2em] text-muted-foreground">
|
||||
Expires
|
||||
</div>
|
||||
<div className="mt-2 text-sm font-medium text-foreground">{dateTime(invite.expiresAt)}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{invite.inviteMessage ? (
|
||||
<div className="rounded-2xl border border-border/70 bg-muted/30 p-4 text-sm leading-6 text-foreground">
|
||||
{invite.inviteMessage}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{invite.inviteType !== "bootstrap_ceo" ? (
|
||||
<div>
|
||||
<div className="text-xs font-medium uppercase tracking-[0.2em] text-muted-foreground">
|
||||
Choose your path
|
||||
</div>
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
{availableJoinTypes.map((type) => (
|
||||
<button
|
||||
key={type}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setError(null);
|
||||
setJoinType(type);
|
||||
}}
|
||||
className={`rounded-full border px-4 py-2 text-sm font-medium transition ${
|
||||
joinType === type
|
||||
? "border-foreground bg-foreground text-background"
|
||||
: "border-border bg-background text-foreground hover:border-foreground/40"
|
||||
}`}
|
||||
>
|
||||
{type === "human" ? "Personal access" : "Agent access"}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="rounded-3xl border border-border/70 bg-background/70 p-5">
|
||||
<div className="text-sm font-semibold text-foreground">
|
||||
{invite.inviteType === "bootstrap_ceo"
|
||||
? "What happens next"
|
||||
: joinType === "human"
|
||||
? "How personal access works"
|
||||
: "How agent access works"}
|
||||
</div>
|
||||
<div className="mt-4 space-y-3 text-sm text-muted-foreground">
|
||||
{(invite.inviteType === "bootstrap_ceo" || joinType === "human") ? (
|
||||
<>
|
||||
<div className="flex items-start gap-3">
|
||||
<CheckCircle2 className="mt-0.5 h-4 w-4 text-foreground" />
|
||||
<p>Stay on this page while you sign in or create an account.</p>
|
||||
</div>
|
||||
<div className="flex items-start gap-3">
|
||||
<CheckCircle2 className="mt-0.5 h-4 w-4 text-foreground" />
|
||||
<p>The invite stays remembered locally until you finish the flow.</p>
|
||||
</div>
|
||||
<div className="flex items-start gap-3">
|
||||
<CheckCircle2 className="mt-0.5 h-4 w-4 text-foreground" />
|
||||
<p>After submit, access is activated immediately or routed for approval, depending on company policy.</p>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex items-start gap-3">
|
||||
<CheckCircle2 className="mt-0.5 h-4 w-4 text-foreground" />
|
||||
<p>Choose the adapter and name for the agent you want to connect.</p>
|
||||
</div>
|
||||
<div className="flex items-start gap-3">
|
||||
<CheckCircle2 className="mt-0.5 h-4 w-4 text-foreground" />
|
||||
<p>Paperclip returns the one-time claim secret and onboarding endpoints right here.</p>
|
||||
</div>
|
||||
<div className="flex items-start gap-3">
|
||||
<CheckCircle2 className="mt-0.5 h-4 w-4 text-foreground" />
|
||||
<p>Admins can still review the request before the agent gets company access.</p>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<aside className="rounded-3xl border border-border/70 bg-background/90 p-5 shadow-sm">
|
||||
<div className="text-xs font-medium uppercase tracking-[0.2em] text-muted-foreground">
|
||||
Continue
|
||||
</div>
|
||||
<div className="mt-2 text-lg font-semibold text-foreground">
|
||||
{requiresAccount
|
||||
? authMode === "sign_in"
|
||||
? "Sign in to continue"
|
||||
: "Create an account to continue"
|
||||
: invite.inviteType === "bootstrap_ceo"
|
||||
? "Finish bootstrap"
|
||||
: joinType === "human"
|
||||
? "Use your account"
|
||||
: "Connect an agent"}
|
||||
</div>
|
||||
<p className="mt-2 text-sm leading-6 text-muted-foreground">
|
||||
{requiresAccount
|
||||
? "This invite stays on deck while you authenticate."
|
||||
: invite.inviteType === "bootstrap_ceo"
|
||||
? `Signed in as ${sessionLabel}.`
|
||||
: joinType === "human"
|
||||
? `This invite will be submitted for ${sessionLabel}.`
|
||||
: "Fill in the agent details and submit the request."}
|
||||
</p>
|
||||
|
||||
{invite.inviteType !== "bootstrap_ceo" && joinType === "agent" ? (
|
||||
<div className="mt-5 space-y-3">
|
||||
<label className="block text-sm">
|
||||
<span className="mb-1 block text-muted-foreground">Agent name</span>
|
||||
<input
|
||||
className="w-full rounded-2xl border border-border bg-background px-3 py-2 text-sm outline-none transition focus:ring-1 focus:ring-ring"
|
||||
value={agentName}
|
||||
onChange={(event) => setAgentName(event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<label className="block text-sm">
|
||||
<span className="mb-1 block text-muted-foreground">Adapter type</span>
|
||||
<select
|
||||
className="w-full rounded-2xl border border-border bg-background px-3 py-2 text-sm outline-none transition focus:ring-1 focus:ring-ring"
|
||||
value={adapterType}
|
||||
onChange={(event) => setAdapterType(event.target.value as AgentAdapterType)}
|
||||
>
|
||||
{joinAdapterOptions.map((type) => (
|
||||
<option key={type} value={type} disabled={!ENABLED_INVITE_ADAPTERS.has(type)}>
|
||||
{getAdapterLabel(type)}{!ENABLED_INVITE_ADAPTERS.has(type) ? " (Coming soon)" : ""}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label className="block text-sm">
|
||||
<span className="mb-1 block text-muted-foreground">Capabilities (optional)</span>
|
||||
<textarea
|
||||
className="w-full rounded-2xl border border-border bg-background px-3 py-2 text-sm outline-none transition focus:ring-1 focus:ring-ring"
|
||||
rows={4}
|
||||
value={capabilities}
|
||||
onChange={(event) => setCapabilities(event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{requiresAccount ? (
|
||||
<div className="mt-5 rounded-3xl border border-border/70 bg-muted/20 p-4" data-testid="invite-inline-auth">
|
||||
<div className="mb-4 inline-flex rounded-full border border-border bg-background p-1">
|
||||
<button
|
||||
type="button"
|
||||
className={`rounded-full px-3 py-1.5 text-sm ${
|
||||
authMode === "sign_in" ? "bg-foreground text-background" : "text-muted-foreground"
|
||||
}`}
|
||||
onClick={() => {
|
||||
setAuthError(null);
|
||||
setAuthMode("sign_in");
|
||||
}}
|
||||
>
|
||||
Sign in
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`rounded-full px-3 py-1.5 text-sm ${
|
||||
authMode === "sign_up" ? "bg-foreground text-background" : "text-muted-foreground"
|
||||
}`}
|
||||
onClick={() => {
|
||||
setAuthError(null);
|
||||
setAuthMode("sign_up");
|
||||
}}
|
||||
>
|
||||
Create account
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form
|
||||
className="space-y-3"
|
||||
method="post"
|
||||
action={authMode === "sign_up" ? "/api/auth/sign-up/email" : "/api/auth/sign-in/email"}
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
if (authMutation.isPending) return;
|
||||
if (!authCanSubmit) {
|
||||
setAuthError("Please fill in all required fields.");
|
||||
return;
|
||||
}
|
||||
authMutation.mutate();
|
||||
}}
|
||||
>
|
||||
{authMode === "sign_up" ? (
|
||||
<label className="block text-sm">
|
||||
<span className="mb-1 block text-muted-foreground">Name</span>
|
||||
<input
|
||||
name="name"
|
||||
className="w-full rounded-2xl border border-border bg-background px-3 py-2 text-sm outline-none transition focus:ring-1 focus:ring-ring"
|
||||
value={name}
|
||||
onChange={(event) => setName(event.target.value)}
|
||||
autoComplete="name"
|
||||
/>
|
||||
</label>
|
||||
) : null}
|
||||
<label className="block text-sm">
|
||||
<span className="mb-1 block text-muted-foreground">Email</span>
|
||||
<input
|
||||
name="email"
|
||||
type="email"
|
||||
className="w-full rounded-2xl border border-border bg-background px-3 py-2 text-sm outline-none transition focus:ring-1 focus:ring-ring"
|
||||
value={email}
|
||||
onChange={(event) => setEmail(event.target.value)}
|
||||
autoComplete="email"
|
||||
/>
|
||||
</label>
|
||||
<label className="block text-sm">
|
||||
<span className="mb-1 block text-muted-foreground">Password</span>
|
||||
<input
|
||||
name="password"
|
||||
type="password"
|
||||
className="w-full rounded-2xl border border-border bg-background px-3 py-2 text-sm outline-none transition focus:ring-1 focus:ring-ring"
|
||||
value={password}
|
||||
onChange={(event) => setPassword(event.target.value)}
|
||||
autoComplete={authMode === "sign_in" ? "current-password" : "new-password"}
|
||||
/>
|
||||
</label>
|
||||
{authError ? <p className="text-xs text-destructive">{authError}</p> : null}
|
||||
<Button
|
||||
type="submit"
|
||||
className="w-full"
|
||||
disabled={authMutation.isPending}
|
||||
aria-disabled={!authCanSubmit || authMutation.isPending}
|
||||
>
|
||||
{authMutation.isPending
|
||||
? "Working..."
|
||||
: authMode === "sign_in"
|
||||
? "Sign in and continue"
|
||||
: "Create account and continue"}
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
<div className="mt-3 text-xs text-muted-foreground">
|
||||
Prefer the full auth page?{" "}
|
||||
<Link
|
||||
className="font-medium text-foreground underline underline-offset-2"
|
||||
to={`/auth?next=${encodeURIComponent(`/invite/${token}`)}`}
|
||||
>
|
||||
Open auth in a separate page
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="mt-5 space-y-3">
|
||||
<div className="rounded-2xl border border-border/70 bg-muted/20 p-3 text-sm text-muted-foreground">
|
||||
{invite.inviteType === "bootstrap_ceo" || joinType === "human"
|
||||
? `Ready to continue as ${sessionLabel}.`
|
||||
: "Ready to submit the agent request."}
|
||||
</div>
|
||||
{error ? <p className="text-sm text-destructive">{error}</p> : null}
|
||||
<Button className="w-full justify-between" disabled={!canSubmitJoin} onClick={() => acceptMutation.mutate()}>
|
||||
<span>
|
||||
{acceptMutation.isPending
|
||||
? "Submitting..."
|
||||
: invite.inviteType === "bootstrap_ceo"
|
||||
? "Accept bootstrap invite"
|
||||
: joinType === "human"
|
||||
? "Join company"
|
||||
: "Submit agent request"}
|
||||
</span>
|
||||
<ArrowRight className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</aside>
|
||||
) : (
|
||||
<div className="mt-6">
|
||||
{error ? <p className="mb-3 text-xs text-red-400">{error}</p> : null}
|
||||
<Button
|
||||
className="w-full rounded-none"
|
||||
disabled={acceptMutation.isPending}
|
||||
onClick={() => acceptMutation.mutate()}
|
||||
>
|
||||
{acceptMutation.isPending ? "Working..." : joinButtonLabel}
|
||||
</Button>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
Reference in new issue
Block a user