Polish invite links and agent snippet UX
This commit is contained in:
@@ -32,8 +32,18 @@ function hashToken(token: string) {
|
|||||||
return createHash("sha256").update(token).digest("hex");
|
return createHash("sha256").update(token).digest("hex");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const INVITE_TOKEN_PREFIX = "pcp_invite_";
|
||||||
|
const INVITE_TOKEN_ALPHABET = "abcdefghijklmnopqrstuvwxyz0123456789";
|
||||||
|
const INVITE_TOKEN_SUFFIX_LENGTH = 8;
|
||||||
|
const INVITE_TOKEN_MAX_RETRIES = 5;
|
||||||
|
|
||||||
function createInviteToken() {
|
function createInviteToken() {
|
||||||
return `pcp_invite_${randomBytes(24).toString("hex")}`;
|
const bytes = randomBytes(INVITE_TOKEN_SUFFIX_LENGTH);
|
||||||
|
let suffix = "";
|
||||||
|
for (let idx = 0; idx < INVITE_TOKEN_SUFFIX_LENGTH; idx += 1) {
|
||||||
|
suffix += INVITE_TOKEN_ALPHABET[bytes[idx]! % INVITE_TOKEN_ALPHABET.length];
|
||||||
|
}
|
||||||
|
return `${INVITE_TOKEN_PREFIX}${suffix}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function createClaimSecret() {
|
function createClaimSecret() {
|
||||||
@@ -718,6 +728,25 @@ function grantsFromDefaults(
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isInviteTokenHashCollisionError(error: unknown) {
|
||||||
|
const candidates = [
|
||||||
|
error,
|
||||||
|
(error as { cause?: unknown } | null)?.cause ?? null,
|
||||||
|
];
|
||||||
|
for (const candidate of candidates) {
|
||||||
|
if (!candidate || typeof candidate !== "object") continue;
|
||||||
|
const code = "code" in candidate && typeof candidate.code === "string" ? candidate.code : null;
|
||||||
|
const message = "message" in candidate && typeof candidate.message === "string" ? candidate.message : "";
|
||||||
|
const constraint = "constraint" in candidate && typeof candidate.constraint === "string"
|
||||||
|
? candidate.constraint
|
||||||
|
: null;
|
||||||
|
if (code !== "23505") continue;
|
||||||
|
if (constraint === "invites_token_hash_unique_idx") return true;
|
||||||
|
if (message.includes("invites_token_hash_unique_idx")) return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
export function accessRoutes(
|
export function accessRoutes(
|
||||||
db: Db,
|
db: Db,
|
||||||
opts: {
|
opts: {
|
||||||
@@ -811,21 +840,40 @@ export function accessRoutes(
|
|||||||
const normalizedAgentMessage = typeof req.body.agentMessage === "string"
|
const normalizedAgentMessage = typeof req.body.agentMessage === "string"
|
||||||
? req.body.agentMessage.trim() || null
|
? req.body.agentMessage.trim() || null
|
||||||
: null;
|
: null;
|
||||||
|
const insertValues = {
|
||||||
|
companyId,
|
||||||
|
inviteType: "company_join" as const,
|
||||||
|
allowedJoinTypes: req.body.allowedJoinTypes,
|
||||||
|
defaultsPayload: mergeInviteDefaults(req.body.defaultsPayload ?? null, normalizedAgentMessage),
|
||||||
|
expiresAt: new Date(Date.now() + req.body.expiresInHours * 60 * 60 * 1000),
|
||||||
|
invitedByUserId: req.actor.userId ?? null,
|
||||||
|
};
|
||||||
|
|
||||||
const token = createInviteToken();
|
let token: string | null = null;
|
||||||
const created = await db
|
let created: typeof invites.$inferSelect | null = null;
|
||||||
.insert(invites)
|
for (let attempt = 0; attempt < INVITE_TOKEN_MAX_RETRIES; attempt += 1) {
|
||||||
.values({
|
const candidateToken = createInviteToken();
|
||||||
companyId,
|
try {
|
||||||
inviteType: "company_join",
|
const row = await db
|
||||||
tokenHash: hashToken(token),
|
.insert(invites)
|
||||||
allowedJoinTypes: req.body.allowedJoinTypes,
|
.values({
|
||||||
defaultsPayload: mergeInviteDefaults(req.body.defaultsPayload ?? null, normalizedAgentMessage),
|
...insertValues,
|
||||||
expiresAt: new Date(Date.now() + req.body.expiresInHours * 60 * 60 * 1000),
|
tokenHash: hashToken(candidateToken),
|
||||||
invitedByUserId: req.actor.userId ?? null,
|
})
|
||||||
})
|
.returning()
|
||||||
.returning()
|
.then((rows) => rows[0]);
|
||||||
.then((rows) => rows[0]);
|
token = candidateToken;
|
||||||
|
created = row;
|
||||||
|
break;
|
||||||
|
} catch (error) {
|
||||||
|
if (!isInviteTokenHashCollisionError(error)) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!token || !created) {
|
||||||
|
throw conflict("Failed to generate a unique invite token. Please retry.");
|
||||||
|
}
|
||||||
|
|
||||||
await logActivity(db, {
|
await logActivity(db, {
|
||||||
companyId,
|
companyId,
|
||||||
|
|||||||
@@ -10,10 +10,9 @@ import { Settings, Check, Copy } from "lucide-react";
|
|||||||
import { CompanyPatternIcon } from "../components/CompanyPatternIcon";
|
import { CompanyPatternIcon } from "../components/CompanyPatternIcon";
|
||||||
import { Field, ToggleField, HintIcon } from "../components/agent-config-primitives";
|
import { Field, ToggleField, HintIcon } from "../components/agent-config-primitives";
|
||||||
|
|
||||||
type AgentFallbackSnippetInput = {
|
type AgentSnippetInput = {
|
||||||
onboardingTextUrl: string;
|
onboardingTextUrl: string;
|
||||||
inviteMessage?: string | null;
|
inviteMessage?: string | null;
|
||||||
guidance?: string | null;
|
|
||||||
connectionCandidates?: string[] | null;
|
connectionCandidates?: string[] | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -94,17 +93,15 @@ export function CompanySettings() {
|
|||||||
setSnippetCopyDelightId(0);
|
setSnippetCopyDelightId(0);
|
||||||
try {
|
try {
|
||||||
const manifest = await accessApi.getInviteOnboarding(invite.token);
|
const manifest = await accessApi.getInviteOnboarding(invite.token);
|
||||||
setInviteSnippet(buildAgentFallbackSnippet({
|
setInviteSnippet(buildAgentSnippet({
|
||||||
onboardingTextUrl: absoluteUrl,
|
onboardingTextUrl: absoluteUrl,
|
||||||
inviteMessage: nextInviteMessage,
|
inviteMessage: nextInviteMessage,
|
||||||
guidance: manifest.onboarding.connectivity?.guidance ?? null,
|
|
||||||
connectionCandidates: manifest.onboarding.connectivity?.connectionCandidates ?? null,
|
connectionCandidates: manifest.onboarding.connectivity?.connectionCandidates ?? null,
|
||||||
}));
|
}));
|
||||||
} catch {
|
} catch {
|
||||||
setInviteSnippet(buildAgentFallbackSnippet({
|
setInviteSnippet(buildAgentSnippet({
|
||||||
onboardingTextUrl: absoluteUrl,
|
onboardingTextUrl: absoluteUrl,
|
||||||
inviteMessage: nextInviteMessage,
|
inviteMessage: nextInviteMessage,
|
||||||
guidance: null,
|
|
||||||
connectionCandidates: null,
|
connectionCandidates: null,
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
@@ -303,13 +300,13 @@ export function CompanySettings() {
|
|||||||
<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">
|
<span className="text-xs text-muted-foreground">
|
||||||
Generate an agent onboarding link (`.txt`) for OpenClaw-style join flows.
|
Generate an agent onboarding link (`.txt`) for agent join flows.
|
||||||
</span>
|
</span>
|
||||||
<HintIcon text="Creates an agent-only invite link that expires in 72 hours and copies the onboarding text URL." />
|
<HintIcon text="Creates an agent-only invite link that expires in 72 hours and copies the onboarding text URL." />
|
||||||
</div>
|
</div>
|
||||||
<Field
|
<Field
|
||||||
label="Agent message (optional)"
|
label="Agent message (optional)"
|
||||||
hint="Included in the onboarding .txt document and frozen after link generation."
|
hint="Included in the onboarding .txt document."
|
||||||
>
|
>
|
||||||
<textarea
|
<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"
|
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"
|
||||||
@@ -339,9 +336,6 @@ export function CompanySettings() {
|
|||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</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">
|
||||||
@@ -377,7 +371,7 @@ export function CompanySettings() {
|
|||||||
{inviteSnippet && (
|
{inviteSnippet && (
|
||||||
<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">Fallback snippet for agent chat</div>
|
<div className="text-xs text-muted-foreground">Agent Snippet</div>
|
||||||
{snippetCopied && (
|
{snippetCopied && (
|
||||||
<span key={snippetCopyDelightId} className="flex items-center gap-1 text-xs text-green-600 animate-pulse">
|
<span key={snippetCopyDelightId} 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" />
|
||||||
@@ -387,7 +381,7 @@ export function CompanySettings() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="mt-1 space-y-1.5">
|
<div className="mt-1 space-y-1.5">
|
||||||
<textarea
|
<textarea
|
||||||
className="min-h-[160px] w-full rounded-md border border-border bg-background px-2 py-1.5 font-mono text-xs outline-none"
|
className="h-[28rem] w-full rounded-md border border-border bg-background px-2 py-1.5 font-mono text-xs outline-none"
|
||||||
value={inviteSnippet}
|
value={inviteSnippet}
|
||||||
readOnly
|
readOnly
|
||||||
/>
|
/>
|
||||||
@@ -458,42 +452,86 @@ export function CompanySettings() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildAgentFallbackSnippet(input: AgentFallbackSnippetInput) {
|
function buildAgentSnippet(input: AgentSnippetInput) {
|
||||||
const lines = [
|
const candidateUrls = buildCandidateOnboardingUrls(input);
|
||||||
"Paperclip onboarding fallback snippet",
|
const lines = ["You're invited to join a Paperclip organization.", ""];
|
||||||
"Use this if the agent cannot open the onboarding URL directly.",
|
|
||||||
"",
|
|
||||||
`Onboarding .txt URL: ${input.onboardingTextUrl}`,
|
|
||||||
"",
|
|
||||||
];
|
|
||||||
|
|
||||||
if (input.inviteMessage) {
|
if (input.inviteMessage) {
|
||||||
lines.push("Message from inviter:", input.inviteMessage, "");
|
lines.push("Message from inviter:", input.inviteMessage, "");
|
||||||
}
|
}
|
||||||
|
|
||||||
lines.push("Connectivity guidance:");
|
lines.push("The URLs you should try are:");
|
||||||
lines.push(input.guidance || "Try reachable Paperclip hosts, then continue with the onboarding URL.");
|
if (candidateUrls.length > 0) {
|
||||||
lines.push("");
|
for (const candidate of candidateUrls) {
|
||||||
|
|
||||||
const candidates = (input.connectionCandidates ?? [])
|
|
||||||
.map((candidate) => candidate.trim())
|
|
||||||
.filter(Boolean);
|
|
||||||
|
|
||||||
if (candidates.length > 0) {
|
|
||||||
lines.push("Suggested Paperclip base URLs:");
|
|
||||||
for (const candidate of candidates) {
|
|
||||||
lines.push(`- ${candidate}`);
|
lines.push(`- ${candidate}`);
|
||||||
}
|
}
|
||||||
lines.push("", "For each candidate, test: GET <candidate>/api/health");
|
} else {
|
||||||
|
lines.push("- (No candidate URLs available yet.)");
|
||||||
}
|
}
|
||||||
|
|
||||||
lines.push(
|
lines.push(
|
||||||
"",
|
"",
|
||||||
"If none are reachable, ask the human operator for a reachable hostname/address.",
|
"Connectivity guidance:",
|
||||||
"In authenticated/private mode they may need:",
|
"If you are running on a different machine than Paperclip, Paperclip must be reachable at one of the hostnames used above.",
|
||||||
"- pnpm paperclipai allowed-hostname <host>",
|
"Verify the hostname works from your runtime with: GET <base-url>/api/health",
|
||||||
"- restart Paperclip and retry onboarding.",
|
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if (candidateUrls.length === 0) {
|
||||||
|
lines.push(
|
||||||
|
"",
|
||||||
|
"No candidate URLs are available. Ask your user to configure a reachable hostname in Paperclip, then retry.",
|
||||||
|
"Suggested steps:",
|
||||||
|
"- choose a hostname that resolves to the Paperclip host from your runtime",
|
||||||
|
"- run: pnpm paperclipai allowed-hostname <host>",
|
||||||
|
"- restart Paperclip",
|
||||||
|
"- verify with: curl -fsS http://<host>:3100/api/health",
|
||||||
|
"- regenerate this invite snippet",
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
lines.push(
|
||||||
|
"",
|
||||||
|
"If none are reachable, ask your user to add a reachable hostname in Paperclip, restart, and retry.",
|
||||||
|
"Suggested command:",
|
||||||
|
"- pnpm paperclipai allowed-hostname <host>",
|
||||||
|
"Then verify with: curl -fsS <base-url>/api/health",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return `${lines.join("\n")}\n`;
|
return `${lines.join("\n")}\n`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function buildCandidateOnboardingUrls(input: AgentSnippetInput): string[] {
|
||||||
|
const candidates = (input.connectionCandidates ?? [])
|
||||||
|
.map((candidate) => candidate.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
const urls = new Set<string>();
|
||||||
|
let onboardingUrl: URL | null = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
onboardingUrl = new URL(input.onboardingTextUrl);
|
||||||
|
urls.add(onboardingUrl.toString());
|
||||||
|
} catch {
|
||||||
|
const trimmed = input.onboardingTextUrl.trim();
|
||||||
|
if (trimmed) {
|
||||||
|
urls.add(trimmed);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!onboardingUrl) {
|
||||||
|
for (const candidate of candidates) {
|
||||||
|
urls.add(candidate);
|
||||||
|
}
|
||||||
|
return Array.from(urls);
|
||||||
|
}
|
||||||
|
|
||||||
|
const onboardingPath = `${onboardingUrl.pathname}${onboardingUrl.search}`;
|
||||||
|
for (const candidate of candidates) {
|
||||||
|
try {
|
||||||
|
const base = new URL(candidate);
|
||||||
|
urls.add(`${base.origin}${onboardingPath}`);
|
||||||
|
} catch {
|
||||||
|
urls.add(candidate);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Array.from(urls);
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user