Add agent invite message flow and txt onboarding link UX
This commit is contained in:
@@ -11,6 +11,7 @@ export const createCompanyInviteSchema = z.object({
|
|||||||
allowedJoinTypes: z.enum(INVITE_JOIN_TYPES).default("both"),
|
allowedJoinTypes: z.enum(INVITE_JOIN_TYPES).default("both"),
|
||||||
expiresInHours: z.number().int().min(1).max(24 * 30).optional().default(72),
|
expiresInHours: z.number().int().min(1).max(24 * 30).optional().default(72),
|
||||||
defaultsPayload: z.record(z.string(), z.unknown()).optional().nullable(),
|
defaultsPayload: z.record(z.string(), z.unknown()).optional().nullable(),
|
||||||
|
agentMessage: z.string().max(4000).optional().nullable(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export type CreateCompanyInvite = z.infer<typeof createCompanyInviteSchema>;
|
export type CreateCompanyInvite = z.infer<typeof createCompanyInviteSchema>;
|
||||||
|
|||||||
@@ -70,4 +70,34 @@ describe("buildInviteOnboardingTextDocument", () => {
|
|||||||
expect(text).toContain("Connectivity diagnostics");
|
expect(text).toContain("Connectivity diagnostics");
|
||||||
expect(text).toContain("loopback hostname");
|
expect(text).toContain("loopback hostname");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("includes inviter message in the onboarding text when provided", () => {
|
||||||
|
const req = buildReq("localhost:3100");
|
||||||
|
const invite = {
|
||||||
|
id: "invite-3",
|
||||||
|
companyId: "company-1",
|
||||||
|
inviteType: "company_join",
|
||||||
|
allowedJoinTypes: "agent",
|
||||||
|
tokenHash: "hash",
|
||||||
|
defaultsPayload: {
|
||||||
|
agentMessage: "Please join as our QA lead and prioritize flaky test triage first.",
|
||||||
|
},
|
||||||
|
expiresAt: new Date("2026-03-05T00:00:00.000Z"),
|
||||||
|
invitedByUserId: null,
|
||||||
|
revokedAt: null,
|
||||||
|
acceptedAt: null,
|
||||||
|
createdAt: new Date("2026-03-04T00:00:00.000Z"),
|
||||||
|
updatedAt: new Date("2026-03-04T00:00:00.000Z"),
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
const text = buildInviteOnboardingTextDocument(req, "token-789", invite as any, {
|
||||||
|
deploymentMode: "local_trusted",
|
||||||
|
deploymentExposure: "private",
|
||||||
|
bindHost: "127.0.0.1",
|
||||||
|
allowedHostnames: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(text).toContain("Message from inviter");
|
||||||
|
expect(text).toContain("prioritize flaky test triage first");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -294,6 +294,7 @@ function toInviteSummaryResponse(req: Request, token: string, invite: typeof inv
|
|||||||
const baseUrl = requestBaseUrl(req);
|
const baseUrl = requestBaseUrl(req);
|
||||||
const onboardingPath = `/api/invites/${token}/onboarding`;
|
const onboardingPath = `/api/invites/${token}/onboarding`;
|
||||||
const onboardingTextPath = `/api/invites/${token}/onboarding.txt`;
|
const onboardingTextPath = `/api/invites/${token}/onboarding.txt`;
|
||||||
|
const inviteMessage = extractInviteMessage(invite);
|
||||||
return {
|
return {
|
||||||
id: invite.id,
|
id: invite.id,
|
||||||
companyId: invite.companyId,
|
companyId: invite.companyId,
|
||||||
@@ -306,6 +307,7 @@ function toInviteSummaryResponse(req: Request, token: string, invite: typeof inv
|
|||||||
onboardingTextUrl: baseUrl ? `${baseUrl}${onboardingTextPath}` : onboardingTextPath,
|
onboardingTextUrl: baseUrl ? `${baseUrl}${onboardingTextPath}` : onboardingTextPath,
|
||||||
skillIndexPath: "/api/skills/index",
|
skillIndexPath: "/api/skills/index",
|
||||||
skillIndexUrl: baseUrl ? `${baseUrl}/api/skills/index` : "/api/skills/index",
|
skillIndexUrl: baseUrl ? `${baseUrl}/api/skills/index` : "/api/skills/index",
|
||||||
|
inviteMessage,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -406,6 +408,7 @@ function buildInviteOnboardingManifest(
|
|||||||
onboarding: {
|
onboarding: {
|
||||||
instructions:
|
instructions:
|
||||||
"Join as an agent, save your one-time claim secret, wait for board approval, then claim your API key and install the Paperclip skill before starting heartbeat loops.",
|
"Join as an agent, save your one-time claim secret, wait for board approval, then claim your API key and install the Paperclip skill before starting heartbeat loops.",
|
||||||
|
inviteMessage: extractInviteMessage(invite),
|
||||||
recommendedAdapterType: "openclaw",
|
recommendedAdapterType: "openclaw",
|
||||||
requiredFields: {
|
requiredFields: {
|
||||||
requestType: "agent",
|
requestType: "agent",
|
||||||
@@ -466,6 +469,7 @@ export function buildInviteOnboardingTextDocument(
|
|||||||
) {
|
) {
|
||||||
const manifest = buildInviteOnboardingManifest(req, token, invite, opts);
|
const manifest = buildInviteOnboardingManifest(req, token, invite, opts);
|
||||||
const onboarding = manifest.onboarding as {
|
const onboarding = manifest.onboarding as {
|
||||||
|
inviteMessage?: string | null;
|
||||||
registrationEndpoint: { method: string; path: string; url: string };
|
registrationEndpoint: { method: string; path: string; url: string };
|
||||||
claimEndpointTemplate: { method: string; path: string };
|
claimEndpointTemplate: { method: string; path: string };
|
||||||
textInstructions: { path: string; url: string };
|
textInstructions: { path: string; url: string };
|
||||||
@@ -486,6 +490,13 @@ export function buildInviteOnboardingTextDocument(
|
|||||||
`- allowedJoinTypes: ${invite.allowedJoinTypes}`,
|
`- allowedJoinTypes: ${invite.allowedJoinTypes}`,
|
||||||
`- expiresAt: ${invite.expiresAt.toISOString()}`,
|
`- expiresAt: ${invite.expiresAt.toISOString()}`,
|
||||||
"",
|
"",
|
||||||
|
];
|
||||||
|
|
||||||
|
if (onboarding.inviteMessage) {
|
||||||
|
lines.push("## Message from inviter", onboarding.inviteMessage, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
lines.push(
|
||||||
"## Step 1: Submit agent join request",
|
"## Step 1: Submit agent join request",
|
||||||
`${onboarding.registrationEndpoint.method} ${onboarding.registrationEndpoint.url}`,
|
`${onboarding.registrationEndpoint.method} ${onboarding.registrationEndpoint.url}`,
|
||||||
"",
|
"",
|
||||||
@@ -533,7 +544,7 @@ export function buildInviteOnboardingTextDocument(
|
|||||||
"",
|
"",
|
||||||
"## Connectivity guidance",
|
"## Connectivity guidance",
|
||||||
onboarding.connectivity?.guidance ?? "Ensure Paperclip is reachable from your OpenClaw runtime.",
|
onboarding.connectivity?.guidance ?? "Ensure Paperclip is reachable from your OpenClaw runtime.",
|
||||||
];
|
);
|
||||||
|
|
||||||
if (diagnostics.length > 0) {
|
if (diagnostics.length > 0) {
|
||||||
lines.push("", "## Connectivity diagnostics");
|
lines.push("", "## Connectivity diagnostics");
|
||||||
@@ -555,6 +566,32 @@ export function buildInviteOnboardingTextDocument(
|
|||||||
return `${lines.join("\n")}\n`;
|
return `${lines.join("\n")}\n`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function extractInviteMessage(invite: typeof invites.$inferSelect): string | null {
|
||||||
|
const rawDefaults = invite.defaultsPayload;
|
||||||
|
if (!rawDefaults || typeof rawDefaults !== "object" || Array.isArray(rawDefaults)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const rawMessage = (rawDefaults as Record<string, unknown>).agentMessage;
|
||||||
|
if (typeof rawMessage !== "string") {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const trimmed = rawMessage.trim();
|
||||||
|
return trimmed.length ? trimmed : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function mergeInviteDefaults(
|
||||||
|
defaultsPayload: Record<string, unknown> | null | undefined,
|
||||||
|
agentMessage: string | null,
|
||||||
|
): Record<string, unknown> | null {
|
||||||
|
const merged = defaultsPayload && typeof defaultsPayload === "object"
|
||||||
|
? { ...defaultsPayload }
|
||||||
|
: {};
|
||||||
|
if (agentMessage) {
|
||||||
|
merged.agentMessage = agentMessage;
|
||||||
|
}
|
||||||
|
return Object.keys(merged).length ? merged : null;
|
||||||
|
}
|
||||||
|
|
||||||
function requestIp(req: Request) {
|
function requestIp(req: Request) {
|
||||||
const forwarded = req.header("x-forwarded-for");
|
const forwarded = req.header("x-forwarded-for");
|
||||||
if (forwarded) {
|
if (forwarded) {
|
||||||
@@ -704,6 +741,9 @@ export function accessRoutes(
|
|||||||
async (req, res) => {
|
async (req, res) => {
|
||||||
const companyId = req.params.companyId as string;
|
const companyId = req.params.companyId as string;
|
||||||
await assertCompanyPermission(req, companyId, "users:invite");
|
await assertCompanyPermission(req, companyId, "users:invite");
|
||||||
|
const normalizedAgentMessage = typeof req.body.agentMessage === "string"
|
||||||
|
? req.body.agentMessage.trim() || null
|
||||||
|
: null;
|
||||||
|
|
||||||
const token = createInviteToken();
|
const token = createInviteToken();
|
||||||
const created = await db
|
const created = await db
|
||||||
@@ -713,7 +753,7 @@ export function accessRoutes(
|
|||||||
inviteType: "company_join",
|
inviteType: "company_join",
|
||||||
tokenHash: hashToken(token),
|
tokenHash: hashToken(token),
|
||||||
allowedJoinTypes: req.body.allowedJoinTypes,
|
allowedJoinTypes: req.body.allowedJoinTypes,
|
||||||
defaultsPayload: req.body.defaultsPayload ?? null,
|
defaultsPayload: mergeInviteDefaults(req.body.defaultsPayload ?? null, normalizedAgentMessage),
|
||||||
expiresAt: new Date(Date.now() + req.body.expiresInHours * 60 * 60 * 1000),
|
expiresAt: new Date(Date.now() + req.body.expiresInHours * 60 * 60 * 1000),
|
||||||
invitedByUserId: req.actor.userId ?? null,
|
invitedByUserId: req.actor.userId ?? null,
|
||||||
})
|
})
|
||||||
@@ -731,13 +771,18 @@ export function accessRoutes(
|
|||||||
inviteType: created.inviteType,
|
inviteType: created.inviteType,
|
||||||
allowedJoinTypes: created.allowedJoinTypes,
|
allowedJoinTypes: created.allowedJoinTypes,
|
||||||
expiresAt: created.expiresAt.toISOString(),
|
expiresAt: created.expiresAt.toISOString(),
|
||||||
|
hasAgentMessage: Boolean(normalizedAgentMessage),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const inviteSummary = toInviteSummaryResponse(req, token, created);
|
||||||
res.status(201).json({
|
res.status(201).json({
|
||||||
...created,
|
...created,
|
||||||
token,
|
token,
|
||||||
inviteUrl: `/invite/${token}`,
|
inviteUrl: `/invite/${token}`,
|
||||||
|
onboardingTextPath: inviteSummary.onboardingTextPath,
|
||||||
|
onboardingTextUrl: inviteSummary.onboardingTextUrl,
|
||||||
|
inviteMessage: inviteSummary.inviteMessage,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ type InviteSummary = {
|
|||||||
onboardingTextUrl?: string;
|
onboardingTextUrl?: string;
|
||||||
skillIndexPath?: string;
|
skillIndexPath?: string;
|
||||||
skillIndexUrl?: string;
|
skillIndexUrl?: string;
|
||||||
|
inviteMessage?: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
type AcceptInviteInput =
|
type AcceptInviteInput =
|
||||||
@@ -56,6 +57,7 @@ export const accessApi = {
|
|||||||
allowedJoinTypes?: "human" | "agent" | "both";
|
allowedJoinTypes?: "human" | "agent" | "both";
|
||||||
expiresInHours?: number;
|
expiresInHours?: number;
|
||||||
defaultsPayload?: Record<string, unknown> | null;
|
defaultsPayload?: Record<string, unknown> | null;
|
||||||
|
agentMessage?: string | null;
|
||||||
} = {},
|
} = {},
|
||||||
) =>
|
) =>
|
||||||
api.post<{
|
api.post<{
|
||||||
@@ -64,6 +66,9 @@ export const accessApi = {
|
|||||||
inviteUrl: string;
|
inviteUrl: string;
|
||||||
expiresAt: string;
|
expiresAt: string;
|
||||||
allowedJoinTypes: "human" | "agent" | "both";
|
allowedJoinTypes: "human" | "agent" | "both";
|
||||||
|
onboardingTextPath?: string;
|
||||||
|
onboardingTextUrl?: string;
|
||||||
|
inviteMessage?: string | null;
|
||||||
}>(`/companies/${companyId}/invites`, input),
|
}>(`/companies/${companyId}/invites`, input),
|
||||||
|
|
||||||
getInvite: (token: string) => api.get<InviteSummary>(`/invites/${token}`),
|
getInvite: (token: string) => api.get<InviteSummary>(`/invites/${token}`),
|
||||||
|
|||||||
@@ -30,7 +30,10 @@ export function CompanySettings() {
|
|||||||
|
|
||||||
const [inviteLink, setInviteLink] = useState<string | null>(null);
|
const [inviteLink, setInviteLink] = useState<string | null>(null);
|
||||||
const [inviteError, setInviteError] = useState<string | null>(null);
|
const [inviteError, setInviteError] = useState<string | null>(null);
|
||||||
|
const [inviteMessage, setInviteMessage] = useState("");
|
||||||
|
const [frozenInviteMessage, setFrozenInviteMessage] = useState<string | null>(null);
|
||||||
const [copied, setCopied] = useState(false);
|
const [copied, setCopied] = useState(false);
|
||||||
|
const [copyDelightId, setCopyDelightId] = useState(0);
|
||||||
|
|
||||||
const generalDirty =
|
const generalDirty =
|
||||||
!!selectedCompany &&
|
!!selectedCompany &&
|
||||||
@@ -59,19 +62,27 @@ export function CompanySettings() {
|
|||||||
const inviteMutation = useMutation({
|
const inviteMutation = useMutation({
|
||||||
mutationFn: () =>
|
mutationFn: () =>
|
||||||
accessApi.createCompanyInvite(selectedCompanyId!, {
|
accessApi.createCompanyInvite(selectedCompanyId!, {
|
||||||
allowedJoinTypes: "both",
|
allowedJoinTypes: "agent",
|
||||||
expiresInHours: 72,
|
expiresInHours: 72,
|
||||||
|
agentMessage: inviteMessage.trim() || null,
|
||||||
}),
|
}),
|
||||||
onSuccess: async (invite) => {
|
onSuccess: async (invite) => {
|
||||||
setInviteError(null);
|
setInviteError(null);
|
||||||
const base = window.location.origin.replace(/\/+$/, "");
|
const base = window.location.origin.replace(/\/+$/, "");
|
||||||
const absoluteUrl = invite.inviteUrl.startsWith("http")
|
const onboardingTextLink = invite.onboardingTextUrl
|
||||||
? invite.inviteUrl
|
?? invite.onboardingTextPath
|
||||||
: `${base}${invite.inviteUrl}`;
|
?? `/api/invites/${invite.token}/onboarding.txt`;
|
||||||
|
const absoluteUrl = onboardingTextLink.startsWith("http")
|
||||||
|
? onboardingTextLink
|
||||||
|
: `${base}${onboardingTextLink}`;
|
||||||
setInviteLink(absoluteUrl);
|
setInviteLink(absoluteUrl);
|
||||||
|
const submittedMessage = inviteMessage.trim() || null;
|
||||||
|
setInviteMessage(submittedMessage ?? "");
|
||||||
|
setFrozenInviteMessage(invite.inviteMessage ?? submittedMessage);
|
||||||
try {
|
try {
|
||||||
await navigator.clipboard.writeText(absoluteUrl);
|
await navigator.clipboard.writeText(absoluteUrl);
|
||||||
setCopied(true);
|
setCopied(true);
|
||||||
|
setCopyDelightId((prev) => prev + 1);
|
||||||
setTimeout(() => setCopied(false), 2000);
|
setTimeout(() => setCopied(false), 2000);
|
||||||
} catch { /* clipboard may not be available */ }
|
} catch { /* clipboard may not be available */ }
|
||||||
queryClient.invalidateQueries({ queryKey: queryKeys.sidebarBadges(selectedCompanyId!) });
|
queryClient.invalidateQueries({ queryKey: queryKeys.sidebarBadges(selectedCompanyId!) });
|
||||||
@@ -80,6 +91,15 @@ export function CompanySettings() {
|
|||||||
setInviteError(err instanceof Error ? err.message : "Failed to create invite");
|
setInviteError(err instanceof Error ? err.message : "Failed to create invite");
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setInviteLink(null);
|
||||||
|
setInviteError(null);
|
||||||
|
setInviteMessage("");
|
||||||
|
setFrozenInviteMessage(null);
|
||||||
|
setCopied(false);
|
||||||
|
setCopyDelightId(0);
|
||||||
|
}, [selectedCompanyId]);
|
||||||
const archiveMutation = useMutation({
|
const archiveMutation = useMutation({
|
||||||
mutationFn: ({
|
mutationFn: ({
|
||||||
companyId,
|
companyId,
|
||||||
@@ -250,19 +270,51 @@ export function CompanySettings() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="space-y-3 rounded-md border border-border px-4 py-4">
|
<div className="space-y-3 rounded-md border border-border px-4 py-4">
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5">
|
||||||
<span className="text-xs text-muted-foreground">Generate a link to invite humans or agents to this company.</span>
|
<span className="text-xs text-muted-foreground">
|
||||||
<HintIcon text="Invite links expire after 72 hours and allow both human and agent joins." />
|
Generate an agent onboarding link (`.txt`) for OpenClaw-style join flows.
|
||||||
|
</span>
|
||||||
|
<HintIcon text="Creates an agent-only invite link that expires in 72 hours and copies the onboarding text URL." />
|
||||||
</div>
|
</div>
|
||||||
<Button size="sm" onClick={() => inviteMutation.mutate()} disabled={inviteMutation.isPending}>
|
<Field
|
||||||
{inviteMutation.isPending ? "Creating..." : "Create invite link"}
|
label="Agent message (optional)"
|
||||||
</Button>
|
hint="Included in the onboarding .txt document and frozen after link generation."
|
||||||
|
>
|
||||||
|
<textarea
|
||||||
|
className="min-h-[84px] w-full resize-y rounded-md border border-border bg-transparent px-2.5 py-1.5 text-sm outline-none disabled:cursor-not-allowed disabled:opacity-80"
|
||||||
|
placeholder="Optional message for the joining agent..."
|
||||||
|
value={inviteLink ? (frozenInviteMessage ?? "") : inviteMessage}
|
||||||
|
readOnly={Boolean(inviteLink)}
|
||||||
|
onChange={(event) => setInviteMessage(event.target.value)}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<Button size="sm" onClick={() => inviteMutation.mutate()} disabled={inviteMutation.isPending}>
|
||||||
|
{inviteMutation.isPending ? "Generating..." : "Generate agent link"}
|
||||||
|
</Button>
|
||||||
|
{inviteLink && (
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => {
|
||||||
|
setInviteLink(null);
|
||||||
|
setFrozenInviteMessage(null);
|
||||||
|
setCopied(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
New message
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{inviteLink && (
|
||||||
|
<p className="text-xs text-muted-foreground">Message is frozen for this invite link.</p>
|
||||||
|
)}
|
||||||
{inviteError && <p className="text-sm text-destructive">{inviteError}</p>}
|
{inviteError && <p className="text-sm text-destructive">{inviteError}</p>}
|
||||||
{inviteLink && (
|
{inviteLink && (
|
||||||
<div className="rounded-md border border-border bg-muted/30 p-2">
|
<div className="rounded-md border border-border bg-muted/30 p-2">
|
||||||
<div className="flex items-center justify-between gap-2">
|
<div className="flex items-center justify-between gap-2">
|
||||||
<div className="text-xs text-muted-foreground">Share link</div>
|
<div className="text-xs text-muted-foreground">Agent onboarding link</div>
|
||||||
{copied && (
|
{copied && (
|
||||||
<span className="flex items-center gap-1 text-xs text-green-600">
|
<span key={copyDelightId} className="flex items-center gap-1 text-xs text-green-600 animate-pulse">
|
||||||
<Check className="h-3 w-3" />
|
<Check className="h-3 w-3" />
|
||||||
Copied
|
Copied
|
||||||
</span>
|
</span>
|
||||||
@@ -272,11 +324,12 @@ export function CompanySettings() {
|
|||||||
<div className="flex-1 break-all font-mono text-xs">{inviteLink}</div>
|
<div className="flex-1 break-all font-mono text-xs">{inviteLink}</div>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="shrink-0 rounded p-1 text-muted-foreground hover:bg-muted hover:text-foreground transition-colors"
|
className="shrink-0 rounded p-1 text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
try {
|
try {
|
||||||
await navigator.clipboard.writeText(inviteLink);
|
await navigator.clipboard.writeText(inviteLink);
|
||||||
setCopied(true);
|
setCopied(true);
|
||||||
|
setCopyDelightId((prev) => prev + 1);
|
||||||
setTimeout(() => setCopied(false), 2000);
|
setTimeout(() => setCopied(false), 2000);
|
||||||
} catch { /* clipboard may not be available */ }
|
} catch { /* clipboard may not be available */ }
|
||||||
}}
|
}}
|
||||||
|
|||||||
Reference in New Issue
Block a user