Fix invite logo fallback and storage checks

This commit is contained in:
Dotta committed 2026-04-11 10:44:15 -05:00
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
View File
@@ -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");
+41 -3
View File
@@ -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
View File
@@ -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>
);