paperclip/ui/src/pages/InviteLanding.tsx
Dotta a498c268c5 feat: add openclaw_gateway adapter
New adapter type for invoking OpenClaw agents via the gateway protocol.
Registers in server, CLI, and UI adapter registries. Adds onboarding
wizard support with gateway URL field and e2e smoke test script.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 08:59:29 -06:00

321 lines
13 KiB
TypeScript

import { useEffect, useMemo, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Link, useParams } from "@/lib/router";
import { accessApi } from "../api/access";
import { authApi } from "../api/auth";
import { healthApi } from "../api/health";
import { queryKeys } from "../lib/queryKeys";
import { Button } from "@/components/ui/button";
import { AGENT_ADAPTER_TYPES } from "@paperclipai/shared";
import type { AgentAdapterType, JoinRequest } from "@paperclipai/shared";
type JoinType = "human" | "agent";
const joinAdapterOptions: AgentAdapterType[] = [
"openclaw",
...AGENT_ADAPTER_TYPES.filter((type): type is Exclude<AgentAdapterType, "openclaw"> => type !== "openclaw"),
];
const adapterLabels: Record<string, string> = {
claude_local: "Claude (local)",
codex_local: "Codex (local)",
opencode_local: "OpenCode (local)",
openclaw: "OpenClaw",
openclaw_gateway: "OpenClaw Gateway",
cursor: "Cursor (local)",
process: "Process",
http: "HTTP",
};
const ENABLED_INVITE_ADAPTERS = new Set(["claude_local", "codex_local", "opencode_local", "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) {
if (!current || typeof current !== "object") return null;
current = (current as Record<string, unknown>)[segment];
}
return typeof current === "string" && current.trim().length > 0 ? current : null;
}
export function InviteLandingPage() {
const queryClient = useQueryClient();
const params = useParams();
const token = (params.token ?? "").trim();
const [joinType, setJoinType] = useState<JoinType>("human");
const [agentName, setAgentName] = useState("");
const [adapterType, setAdapterType] = useState<AgentAdapterType>("claude_local");
const [capabilities, setCapabilities] = useState("");
const [result, setResult] = useState<{ kind: "bootstrap" | "join"; payload: unknown } | null>(null);
const [error, setError] = useState<string | null>(null);
const healthQuery = useQuery({
queryKey: queryKeys.health,
queryFn: () => healthApi.get(),
retry: false,
});
const sessionQuery = useQuery({
queryKey: queryKeys.auth.session,
queryFn: () => authApi.getSession(),
retry: false,
});
const inviteQuery = useQuery({
queryKey: queryKeys.access.invite(token),
queryFn: () => accessApi.getInvite(token),
enabled: token.length > 0,
retry: false,
});
const invite = inviteQuery.data;
const allowedJoinTypes = invite?.allowedJoinTypes ?? "both";
const availableJoinTypes = useMemo(() => {
if (invite?.inviteType === "bootstrap_ceo") return ["human"] as JoinType[];
if (allowedJoinTypes === "both") return ["human", "agent"] as JoinType[];
return [allowedJoinTypes] as JoinType[];
}, [invite?.inviteType, allowedJoinTypes]);
useEffect(() => {
if (!availableJoinTypes.includes(joinType)) {
setJoinType(availableJoinTypes[0] ?? "human");
}
}, [availableJoinTypes, joinType]);
const requiresAuthForHuman =
joinType === "human" &&
healthQuery.data?.deploymentMode === "authenticated" &&
!sessionQuery.data;
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") {
return accessApi.acceptInvite(token, { requestType: "human" });
}
return accessApi.acceptInvite(token, {
requestType: "agent",
agentName: agentName.trim(),
adapterType,
capabilities: capabilities.trim() || null,
});
},
onSuccess: async (payload) => {
setError(null);
await queryClient.invalidateQueries({ queryKey: queryKeys.auth.session });
await queryClient.invalidateQueries({ queryKey: queryKeys.companies.all });
const asBootstrap =
payload && typeof payload === "object" && "bootstrapAccepted" in (payload as Record<string, unknown>);
setResult({ kind: asBootstrap ? "bootstrap" : "join", payload });
},
onError: (err) => {
setError(err instanceof Error ? err.message : "Failed to accept invite");
},
});
if (!token) {
return <div className="mx-auto max-w-xl py-10 text-sm text-destructive">Invalid invite token.</div>;
}
if (inviteQuery.isLoading || healthQuery.isLoading || sessionQuery.isLoading) {
return <div className="mx-auto max-w-xl py-10 text-sm text-muted-foreground">Loading invite...</div>;
}
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">
<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.
</p>
</div>
</div>
);
}
if (result?.kind === "bootstrap") {
return (
<div className="mx-auto max-w-xl py-10">
<div className="rounded-lg border border-border bg-card 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>
</div>
);
}
if (result?.kind === "join") {
const payload = result.payload as JoinRequest & {
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 : [];
return (
<div className="mx-auto max-w-xl py-10">
<div className="rounded-lg border border-border bg-card p-6">
<h1 className="text-lg font-semibold">Join request submitted</h1>
<p className="mt-2 text-sm text-muted-foreground">
Your request is pending admin approval. You will not have access until approved.
</p>
<div className="mt-4 rounded-md border border-border bg-muted/30 p-3 text-xs text-muted-foreground">
Request ID: <span className="font-mono">{payload.id}</span>
</div>
{claimSecret && claimApiKeyPath && (
<div className="mt-3 space-y-1 rounded-md border border-border bg-muted/30 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>
</div>
)}
{(onboardingSkillUrl || onboardingSkillPath || onboardingInstallPath) && (
<div className="mt-3 space-y-1 rounded-md border border-border bg-muted/30 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>}
</div>
)}
{(onboardingTextUrl || onboardingTextPath) && (
<div className="mt-3 space-y-1 rounded-md border border-border bg-muted/30 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>}
</div>
)}
{diagnostics.length > 0 && (
<div className="mt-3 space-y-1 rounded-md border border-border bg-muted/30 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>
)}
</div>
</div>
);
}
return (
<div className="mx-auto max-w-xl py-10">
<div className="rounded-lg border border-border bg-card p-6">
<h1 className="text-xl font-semibold">
{invite.inviteType === "bootstrap_ceo" ? "Bootstrap your Paperclip instance" : "Join this Paperclip company"}
</h1>
<p className="mt-2 text-sm text-muted-foreground">Invite expires {dateTime(invite.expiresAt)}.</p>
{invite.inviteType !== "bootstrap_ceo" && (
<div className="mt-5 flex gap-2">
{availableJoinTypes.map((type) => (
<button
key={type}
type="button"
onClick={() => setJoinType(type)}
className={`rounded-md border px-3 py-1.5 text-sm ${
joinType === type
? "border-foreground bg-foreground text-background"
: "border-border bg-background text-foreground"
}`}
>
Join as {type}
</button>
))}
</div>
)}
{joinType === "agent" && invite.inviteType !== "bootstrap_ceo" && (
<div className="mt-4 space-y-3">
<label className="block text-sm">
<span className="mb-1 block text-muted-foreground">Agent name</span>
<input
className="w-full rounded-md border border-border bg-background px-3 py-2 text-sm"
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-md border border-border bg-background px-3 py-2 text-sm"
value={adapterType}
onChange={(event) => setAdapterType(event.target.value as AgentAdapterType)}
>
{joinAdapterOptions.map((type) => (
<option key={type} value={type} disabled={!ENABLED_INVITE_ADAPTERS.has(type)}>
{adapterLabels[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-md border border-border bg-background px-3 py-2 text-sm"
rows={4}
value={capabilities}
onChange={(event) => setCapabilities(event.target.value)}
/>
</label>
</div>
)}
{requiresAuthForHuman && (
<div className="mt-4 rounded-md border border-border bg-muted/30 p-3 text-sm">
Sign in or create an account before submitting a human join request.
<div className="mt-2">
<Button asChild size="sm" variant="outline">
<Link to={`/auth?next=${encodeURIComponent(`/invite/${token}`)}`}>Sign in / Create account</Link>
</Button>
</div>
</div>
)}
{error && <p className="mt-3 text-sm text-destructive">{error}</p>}
<Button
className="mt-5"
disabled={
acceptMutation.isPending ||
(joinType === "agent" && invite.inviteType !== "bootstrap_ceo" && agentName.trim().length === 0) ||
requiresAuthForHuman
}
onClick={() => acceptMutation.mutate()}
>
{acceptMutation.isPending
? "Submitting…"
: invite.inviteType === "bootstrap_ceo"
? "Accept bootstrap invite"
: "Submit join request"}
</Button>
</div>
</div>
);
}