Files
paperclip/ui/src/pages/InviteLanding.tsx

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",
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>
);
}