From 5e65bb2b92ae765815b6816cef60c25cdda837ca Mon Sep 17 00:00:00 2001 From: dotta Date: Mon, 30 Mar 2026 13:20:31 -0500 Subject: [PATCH] Add company name to invite summaries Co-Authored-By: Paperclip --- .../openclaw-invite-prompt-route.test.ts | 44 +++++++++++- server/src/routes/access.ts | 69 ++++++++++++++++--- ui/src/api/access.ts | 2 + ui/src/pages/InviteLanding.tsx | 14 +++- 4 files changed, 116 insertions(+), 13 deletions(-) diff --git a/server/src/__tests__/openclaw-invite-prompt-route.test.ts b/server/src/__tests__/openclaw-invite-prompt-route.test.ts index 189126f9..990587d3 100644 --- a/server/src/__tests__/openclaw-invite-prompt-route.test.ts +++ b/server/src/__tests__/openclaw-invite-prompt-route.test.ts @@ -1,6 +1,7 @@ import express from "express"; import request from "supertest"; import { beforeEach, describe, expect, it, vi } from "vitest"; +import { companies, invites } from "@paperclipai/db"; import { accessRoutes } from "../routes/access.js"; import { errorHandler } from "../middleware/index.js"; @@ -51,19 +52,35 @@ function createDbStub() { inviteType: "company_join", allowedJoinTypes: "agent", defaultsPayload: null, - expiresAt: new Date("2026-03-07T00:10:00.000Z"), + expiresAt: new Date("2099-03-07T00:10:00.000Z"), invitedByUserId: null, tokenHash: "hash", revokedAt: null, acceptedAt: null, - createdAt: new Date("2026-03-07T00:00:00.000Z"), - updatedAt: new Date("2026-03-07T00:00:00.000Z"), + createdAt: new Date("2099-03-07T00:00:00.000Z"), + updatedAt: new Date("2099-03-07T00:00:00.000Z"), }; const returning = vi.fn().mockResolvedValue([createdInvite]); const values = vi.fn().mockReturnValue({ returning }); const insert = vi.fn().mockReturnValue({ values }); + const select = vi.fn(() => ({ + from(table: unknown) { + return { + where: vi.fn().mockImplementation(() => { + if (table === invites) { + return Promise.resolve([createdInvite]); + } + if (table === companies) { + return Promise.resolve([{ name: "Acme AI" }]); + } + return Promise.resolve([]); + }), + }; + }, + })); return { insert, + select, }; } @@ -143,9 +160,30 @@ describe("POST /companies/:companyId/openclaw/invite-prompt", () => { expect(res.status).toBe(201); expect(res.body.allowedJoinTypes).toBe("agent"); expect(typeof res.body.token).toBe("string"); + expect(res.body.companyName).toBe("Acme AI"); expect(res.body.onboardingTextPath).toContain("/api/invites/"); }); + it("includes companyName in invite summary responses", async () => { + const db = createDbStub(); + const app = createApp( + { + type: "board", + userId: "user-1", + companyIds: ["company-1"], + source: "session", + isInstanceAdmin: false, + }, + db, + ); + + const res = await request(app).get("/api/invites/pcp_invite_test"); + + expect(res.status).toBe(200); + expect(res.body.companyId).toBe("company-1"); + expect(res.body.companyName).toBe("Acme AI"); + }); + it("allows board callers with invite permission", async () => { const db = createDbStub(); mockAccessService.canUser.mockResolvedValue(true); diff --git a/server/src/routes/access.ts b/server/src/routes/access.ts index 7d7dfe2b..a53bf0dc 100644 --- a/server/src/routes/access.ts +++ b/server/src/routes/access.ts @@ -14,6 +14,7 @@ import type { Db } from "@paperclipai/db"; import { agentApiKeys, authUsers, + companies, invites, joinRequests } from "@paperclipai/db"; @@ -856,7 +857,8 @@ export function normalizeAgentDefaultsForJoin(input: { function toInviteSummaryResponse( req: Request, token: string, - invite: typeof invites.$inferSelect + invite: typeof invites.$inferSelect, + companyName: string | null = null ) { const baseUrl = requestBaseUrl(req); const onboardingPath = `/api/invites/${token}/onboarding`; @@ -865,6 +867,7 @@ function toInviteSummaryResponse( return { id: invite.id, companyId: invite.companyId, + companyName, inviteType: invite.inviteType, allowedJoinTypes: invite.allowedJoinTypes, expiresAt: invite.expiresAt, @@ -993,6 +996,7 @@ function buildInviteOnboardingManifest( token: string, invite: typeof invites.$inferSelect, opts: { + companyName?: string | null; deploymentMode: DeploymentMode; deploymentExposure: DeploymentExposure; bindHost: string; @@ -1024,7 +1028,12 @@ function buildInviteOnboardingManifest( }); return { - invite: toInviteSummaryResponse(req, token, invite), + invite: toInviteSummaryResponse( + req, + token, + invite, + opts.companyName ?? null + ), onboarding: { instructions: "Join as an OpenClaw Gateway agent, save your one-time claim secret, wait for board approval, then claim your API key. Save the claim response token to ~/.openclaw/workspace/paperclip-claimed-api-key.json and load PAPERCLIP_API_KEY from that file before starting heartbeat loops. You MUST submit adapterType='openclaw_gateway', set agentDefaultsPayload.url to your ws:// or wss:// OpenClaw gateway endpoint, and include agentDefaultsPayload.headers.x-openclaw-token (or legacy x-openclaw-auth).", @@ -1084,6 +1093,7 @@ export function buildInviteOnboardingTextDocument( token: string, invite: typeof invites.$inferSelect, opts: { + companyName?: string | null; deploymentMode: DeploymentMode; deploymentExposure: DeploymentExposure; bindHost: string; @@ -1133,6 +1143,10 @@ export function buildInviteOnboardingTextDocument( - expiresAt: ${invite.expiresAt.toISOString()} `); + if (manifest.invite.companyName) { + lines.push(`- companyName: ${manifest.invite.companyName}`); + } + if (onboarding.inviteMessage) { appendBlock(` ## Message from inviter @@ -1882,6 +1896,16 @@ export function accessRoutes( return { token, created, normalizedAgentMessage }; } + async function getInviteCompanyName(companyId: string | null) { + if (!companyId) return null; + const company = await db + .select({ name: companies.name }) + .from(companies) + .where(eq(companies.id, companyId)) + .then((rows) => rows[0] ?? null); + return company?.name ?? null; + } + router.get("/skills/available", (_req, res) => { res.json({ skills: listAvailableSkills() }); }); @@ -1942,11 +1966,18 @@ export function accessRoutes( } }); - const inviteSummary = toInviteSummaryResponse(req, token, created); + const companyName = await getInviteCompanyName(created.companyId); + const inviteSummary = toInviteSummaryResponse( + req, + token, + created, + companyName + ); res.status(201).json({ ...created, token, inviteUrl: `/invite/${token}`, + companyName, onboardingTextPath: inviteSummary.onboardingTextPath, onboardingTextUrl: inviteSummary.onboardingTextUrl, inviteMessage: inviteSummary.inviteMessage @@ -1987,11 +2018,18 @@ export function accessRoutes( } }); - const inviteSummary = toInviteSummaryResponse(req, token, created); + const companyName = await getInviteCompanyName(created.companyId); + const inviteSummary = toInviteSummaryResponse( + req, + token, + created, + companyName + ); res.status(201).json({ ...created, token, inviteUrl: `/invite/${token}`, + companyName, onboardingTextPath: inviteSummary.onboardingTextPath, onboardingTextUrl: inviteSummary.onboardingTextUrl, inviteMessage: inviteSummary.inviteMessage @@ -2016,7 +2054,8 @@ export function accessRoutes( throw notFound("Invite not found"); } - res.json(toInviteSummaryResponse(req, token, invite)); + const companyName = await getInviteCompanyName(invite.companyId); + res.json(toInviteSummaryResponse(req, token, invite, companyName)); }); router.get("/invites/:token/onboarding", async (req, res) => { @@ -2031,7 +2070,11 @@ export function accessRoutes( throw notFound("Invite not found"); } - res.json(buildInviteOnboardingManifest(req, token, invite, opts)); + const companyName = await getInviteCompanyName(invite.companyId); + res.json(buildInviteOnboardingManifest(req, token, invite, { + ...opts, + companyName + })); }); router.get("/invites/:token/onboarding.txt", async (req, res) => { @@ -2046,9 +2089,15 @@ export function accessRoutes( throw notFound("Invite not found"); } + const companyName = await getInviteCompanyName(invite.companyId); res .type("text/plain; charset=utf-8") - .send(buildInviteOnboardingTextDocument(req, token, invite, opts)); + .send( + buildInviteOnboardingTextDocument(req, token, invite, { + ...opts, + companyName + }) + ); }); router.get("/invites/:token/test-resolution", async (req, res) => { @@ -2458,11 +2507,15 @@ export function accessRoutes( const response = toJoinRequestResponse(created); if (claimSecret) { + const companyName = await getInviteCompanyName(invite.companyId); const onboardingManifest = buildInviteOnboardingManifest( req, token, invite, - opts + { + ...opts, + companyName + } ); res.status(202).json({ ...response, diff --git a/ui/src/api/access.ts b/ui/src/api/access.ts index 90afd1dd..0a111150 100644 --- a/ui/src/api/access.ts +++ b/ui/src/api/access.ts @@ -4,6 +4,7 @@ import { api } from "./client"; type InviteSummary = { id: string; companyId: string | null; + companyName?: string | null; inviteType: "company_join" | "bootstrap_ceo"; allowedJoinTypes: "human" | "agent" | "both"; expiresAt: string; @@ -87,6 +88,7 @@ type CompanyInviteCreated = { inviteUrl: string; expiresAt: string; allowedJoinTypes: "human" | "agent" | "both"; + companyName?: string | null; onboardingTextPath?: string; onboardingTextUrl?: string; inviteMessage?: string | null; diff --git a/ui/src/pages/InviteLanding.tsx b/ui/src/pages/InviteLanding.tsx index ec0d5e1d..6d412aa8 100644 --- a/ui/src/pages/InviteLanding.tsx +++ b/ui/src/pages/InviteLanding.tsx @@ -69,6 +69,7 @@ export function InviteLandingPage() { }); const invite = inviteQuery.data; + const companyName = invite?.companyName?.trim() || null; const allowedJoinTypes = invite?.allowedJoinTypes ?? "both"; const availableJoinTypes = useMemo(() => { if (invite?.inviteType === "bootstrap_ceo") return ["human"] as JoinType[]; @@ -227,9 +228,18 @@ export function InviteLandingPage() {

- {invite.inviteType === "bootstrap_ceo" ? "Bootstrap your Paperclip instance" : "Join this Paperclip company"} + {invite.inviteType === "bootstrap_ceo" + ? "Bootstrap your Paperclip instance" + : companyName + ? `Join ${companyName}` + : "Join this Paperclip company"}

-

Invite expires {dateTime(invite.expiresAt)}.

+

+ {invite.inviteType !== "bootstrap_ceo" && companyName + ? `You were invited to join ${companyName}. ` + : null} + Invite expires {dateTime(invite.expiresAt)}. +

{invite.inviteType !== "bootstrap_ceo" && (