From 38a2c91483915155461b027902166c2dc1bf205e Mon Sep 17 00:00:00 2001 From: Dotta Date: Sat, 11 Apr 2026 10:44:15 -0500 Subject: [PATCH] Fix invite logo fallback and storage checks --- .../src/__tests__/invite-logo-route.test.ts | 146 ++++ .../__tests__/invite-summary-route.test.ts | 87 ++- .../openclaw-invite-prompt-route.test.ts | 19 +- server/src/routes/access.ts | 126 +++- ui/src/pages/InviteLanding.test.tsx | 44 +- ui/src/pages/InviteLanding.tsx | 650 ++++++------------ 6 files changed, 596 insertions(+), 476 deletions(-) create mode 100644 server/src/__tests__/invite-logo-route.test.ts diff --git a/server/src/__tests__/invite-logo-route.test.ts b/server/src/__tests__/invite-logo-route.test.ts new file mode 100644 index 00000000000..a18137c3ab4 --- /dev/null +++ b/server/src/__tests__/invite-logo-route.test.ts @@ -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) { + 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(); + }); +}); diff --git a/server/src/__tests__/invite-summary-route.test.ts b/server/src/__tests__/invite-summary-route.test.ts index 3a750b88901..805373b3a7e 100644 --- a/server/src/__tests__/invite-summary-route.test.ts +++ b/server/src/__tests__/invite-summary-route.test.ts @@ -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) { } 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(); + }); }); diff --git a/server/src/__tests__/openclaw-invite-prompt-route.test.ts b/server/src/__tests__/openclaw-invite-prompt-route.test.ts index 8f75d4ca8b8..216ff87f4df 100644 --- a/server/src/__tests__/openclaw-invite-prompt-route.test.ts +++ b/server/src/__tests__/openclaw-invite-prompt-route.test.ts @@ -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"); }); diff --git a/server/src/routes/access.ts b/server/src/routes/access.ts index dd95f691aeb..c1ea66756d4 100644 --- a/server/src/routes/access.ts +++ b/server/src/routes/access.ts @@ -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"); diff --git a/ui/src/pages/InviteLanding.test.tsx b/ui/src/pages/InviteLanding.test.tsx index 14d81ae6d5e..a8802532615 100644 --- a/ui/src/pages/InviteLanding.test.tsx +++ b/ui/src/pages/InviteLanding.test.tsx @@ -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( + + + + } /> + + + , + ); + }); + 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(); + }); + }); }); diff --git a/ui/src/pages/InviteLanding.tsx b/ui/src/pages/InviteLanding.tsx index 82c17542ff0..e4f5d135193 100644 --- a/ui/src/pages/InviteLanding.tsx +++ b/ui/src/pages/InviteLanding.tsx @@ -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("human"); const [authMode, setAuthMode] = useState("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
Invalid invite token.
; } @@ -188,7 +160,7 @@ export function InviteLandingPage() { if (inviteQuery.error || !invite) { return (
-
+

Invite not available

This invite may be expired, revoked, or already used. @@ -200,15 +172,14 @@ export function InviteLandingPage() { if (result?.kind === "bootstrap") { return ( -

-
+
+

Bootstrap complete

-

- The first instance admin is now configured. You can continue to the board. -

- +
+ +
); @@ -219,427 +190,196 @@ export function InviteLandingPage() { claimSecret?: string; claimApiKeyPath?: string; onboarding?: Record; - 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 ( -
-
-

- {personalAccessApproved ? "You joined the company" : "Access request submitted"} -

-

- {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."} -

-
- Request ID: {payload.id} +
+
+

{joinedNow ? "You joined the company" : "Request submitted"}

+
+ Request ID: {payload.id}
- {personalAccessApproved && ( - - )} - {claimSecret && claimApiKeyPath && ( -
-

One-time claim secret (save now)

-

{claimSecret}

-

POST {claimApiKeyPath}

+ {claimSecret && claimApiKeyPath ? ( +
+
Claim secret
+
{claimSecret}
+
POST {claimApiKeyPath}
- )} - {(onboardingSkillUrl || onboardingSkillPath || onboardingInstallPath) && ( -
-

Paperclip skill bootstrap

- {onboardingSkillUrl &&

GET {onboardingSkillUrl}

} - {!onboardingSkillUrl && onboardingSkillPath &&

GET {onboardingSkillPath}

} - {onboardingInstallPath &&

Install to {onboardingInstallPath}

} + ) : null} + {onboardingTextUrl ? ( +
+ Onboarding: {onboardingTextUrl}
- )} - {(onboardingTextUrl || onboardingTextPath) && ( -
-

Agent-readable onboarding text

- {onboardingTextUrl &&

GET {onboardingTextUrl}

} - {!onboardingTextUrl && onboardingTextPath &&

GET {onboardingTextPath}

} + ) : null} + {joinedNow ? ( +
+
- )} - {diagnostics.length > 0 && ( -
-

Connectivity diagnostics

- {diagnostics.map((diag, idx) => ( -
-

- [{diag.level}] {diag.message} -

- {diag.hint &&

{diag.hint}

} -
- ))} -
- )} + ) : null}
); } - const canSubmitJoin = - !requiresAccount && - !acceptMutation.isPending && - (joinType !== "agent" || invite.inviteType === "bootstrap_ceo" || agentName.trim().length > 0); - return ( -
-
-
-
-
- +
+
+ +
+

+ {invite.inviteType === "bootstrap_ceo" ? "Set up Paperclip" : `Join ${companyDisplayName}`} +

+ {sessionQuery.data ? ( +

Signed in as {sessionLabel}

+ ) : null} +
+
+ + {showsAgentForm ? ( +
+ + +