mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-21 08:22:20 +02:00
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - Agent adapters control how Paperclip starts and resumes an agent runtime. > - Paperclip Runner is an experimental Rust runtime and must stay opt-in. > - The server already rejected new runner selections when the flag was off. > - Some setup and onboarding views did not enforce the same boundary. > - This pull request exposes the existing flag and applies it to every new setup path. > - The benefit is a safe rollout with unchanged legacy onboarding and recoverable existing native runs. ## Linked Issues or Issue Description **What existing behavior does this improve?** This improves experimental adapter selection in Settings, onboarding, new-agent setup, invite setup, and company import. **Subsystem affected** Cross-cutting: the React UI and the server onboarding seed service. **Current behavior** The server defaulted Paperclip Runner to off, but Settings did not expose the flag. First-run onboarding could show the runner after opt-in. A direct new-agent URL and some setup pickers could also reveal native runner configuration before the availability check completed. **Proposed behavior** Settings has a default-off Paperclip Runner toggle. Explicit agent configuration shows the runner only after the server reports that the flag is enabled. First-run and invite onboarding always use legacy adapters. Existing native agents and runs remain readable and recoverable. **Reason and benefit** This keeps the experimental runtime out of normal onboarding. It also gives administrators one clear opt-in before users can create a native runner agent. **Breaking changes** None. Legacy adapter selection and execution stay unchanged. Existing native records remain available. ## What Changed - Added the Paperclip Runner opt-in to Experimental Settings. - Refreshed adapter availability after the setting changes. - Kept UI and server-seeded onboarding on legacy adapters. - Made native runner choices fail closed in new-agent, invite, and import setup. - Preserved edit and recovery behavior for existing native agents and runs. - Added focused regression tests for flag-off and flag-on behavior. ## Verification - GitHub Actions will run the repository test, typecheck, build, and policy gates. - Focused tests cover Settings, onboarding, agent creation, invite setup, import setup, and server-seeded onboarding. - No local test suite was run, per the maintainer request to use GitHub Actions for verification. - `git diff --check` passes. ## Risks Low risk. The change narrows new adapter selection only. The server remains the final enforcement point. Existing native records do not depend on the current flag value for read or recovery behavior. > For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and discuss it in `#dev` before opening the PR. Feature PRs that overlap with planned core work may need to be redirected — check the roadmap first. See `CONTRIBUTING.md`. ## Model Used OpenAI Codex, GPT-5, with reasoning, tool use, and code execution. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [ ] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] All Paperclip CI gates are green - [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge
868 lines
35 KiB
TypeScript
868 lines
35 KiB
TypeScript
import { useEffect, useMemo, useState } from "react";
|
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
|
import { AGENT_ADAPTER_TYPES } from "@paperclipai/shared";
|
|
import type { AgentAdapterType, JoinRequest } from "@paperclipai/shared";
|
|
import { Button } from "@/components/ui/button";
|
|
import { CompanyPatternIcon } from "@/components/CompanyPatternIcon";
|
|
import { useCompany } from "@/context/CompanyContext";
|
|
import { Link, useNavigate, useParams } from "@/lib/router";
|
|
import { accessApi } from "../api/access";
|
|
import { authApi } from "../api/auth";
|
|
import { fetchCompanyListForCurrentAccount, useCompanyListQuery } from "../api/companies-query";
|
|
import { healthApi } from "../api/health";
|
|
import { getAdapterLabel } from "../adapters/adapter-display-registry";
|
|
import { clearPendingInviteToken, rememberPendingInviteToken } from "../lib/invite-memory";
|
|
import { queryKeys } from "../lib/queryKeys";
|
|
import { formatDate } from "../lib/utils";
|
|
|
|
type AuthMode = "sign_in" | "sign_up";
|
|
type AuthFeedback = { tone: "error" | "info"; message: string };
|
|
|
|
// Agent-invite onboarding remains a legacy-adapter flow. Native runner setup
|
|
// belongs in authenticated agent configuration after an administrator enables
|
|
// the experimental flag.
|
|
const joinAdapterOptions: AgentAdapterType[] = AGENT_ADAPTER_TYPES.filter(
|
|
(type) => type !== "paperclip_runner",
|
|
);
|
|
const ENABLED_INVITE_ADAPTERS = new Set([
|
|
"claude_local",
|
|
"codex_local",
|
|
"gemini_local",
|
|
"kimi_local",
|
|
"opencode_local",
|
|
"pi_local",
|
|
"cursor",
|
|
]);
|
|
|
|
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;
|
|
}
|
|
|
|
const fieldClassName =
|
|
"w-full border border-zinc-800 bg-zinc-950 px-3 py-2 text-sm text-zinc-100 outline-none focus:border-zinc-500";
|
|
const panelClassName = "border border-zinc-800 bg-zinc-950/95 p-6";
|
|
const modeButtonBaseClassName =
|
|
"flex-1 border px-3 py-2 text-sm transition-colors";
|
|
|
|
function formatHumanRole(role: string | null | undefined) {
|
|
if (!role) return null;
|
|
return role.charAt(0).toUpperCase() + role.slice(1);
|
|
}
|
|
|
|
function getAuthErrorCode(error: unknown) {
|
|
if (!error || typeof error !== "object") return null;
|
|
const code = (error as { code?: unknown }).code;
|
|
return typeof code === "string" && code.trim().length > 0 ? code : null;
|
|
}
|
|
|
|
function getAuthErrorMessage(error: unknown) {
|
|
if (!(error instanceof Error)) return null;
|
|
const message = error.message.trim();
|
|
return message.length > 0 ? message : null;
|
|
}
|
|
|
|
function mapInviteAuthFeedback(
|
|
error: unknown,
|
|
authMode: AuthMode,
|
|
email: string,
|
|
): AuthFeedback {
|
|
const code = getAuthErrorCode(error);
|
|
const message = getAuthErrorMessage(error);
|
|
const emailLabel = email.trim().length > 0 ? email.trim() : "that email";
|
|
|
|
if (code === "USER_ALREADY_EXISTS_USE_ANOTHER_EMAIL") {
|
|
return {
|
|
tone: "info",
|
|
message: `An account already exists for ${emailLabel}. Sign in below to continue with this invite.`,
|
|
};
|
|
}
|
|
|
|
if (code === "INVALID_EMAIL_OR_PASSWORD") {
|
|
return {
|
|
tone: "error",
|
|
message:
|
|
"That email and password did not match an existing Paperclip account. Check both fields, or create an account first if you are new here.",
|
|
};
|
|
}
|
|
|
|
if (authMode === "sign_in" && message === "Request failed: 401") {
|
|
return {
|
|
tone: "error",
|
|
message:
|
|
"That email and password did not match an existing Paperclip account. Check both fields, or create an account first if you are new here.",
|
|
};
|
|
}
|
|
|
|
if (authMode === "sign_up" && message === "Request failed: 422") {
|
|
return {
|
|
tone: "info",
|
|
message: `An account may already exist for ${emailLabel}. Try signing in instead.`,
|
|
};
|
|
}
|
|
|
|
return {
|
|
tone: "error",
|
|
message: message ?? "Authentication failed",
|
|
};
|
|
}
|
|
|
|
function isBootstrapAcceptancePayload(payload: unknown) {
|
|
return Boolean(
|
|
payload &&
|
|
typeof payload === "object" &&
|
|
"bootstrapAccepted" in (payload as Record<string, unknown>),
|
|
);
|
|
}
|
|
|
|
function isApprovedHumanJoinPayload(payload: unknown, showsAgentForm: boolean) {
|
|
if (!payload || typeof payload !== "object" || showsAgentForm) return false;
|
|
const status = (payload as { status?: unknown }).status;
|
|
return status === "approved";
|
|
}
|
|
|
|
type AwaitingJoinApprovalPanelProps = {
|
|
companyDisplayName: string;
|
|
companyLogoUrl: string | null;
|
|
invitedByUserName: string | null;
|
|
claimSecret?: string | null;
|
|
claimApiKeyPath?: string | null;
|
|
onboardingTextUrl?: string | null;
|
|
};
|
|
|
|
function InviteCompanyLogo({
|
|
companyDisplayName,
|
|
companyLogoUrl,
|
|
className,
|
|
}: {
|
|
companyDisplayName: string;
|
|
companyLogoUrl: string | null;
|
|
className?: string;
|
|
}) {
|
|
return (
|
|
<CompanyPatternIcon
|
|
companyName={companyDisplayName}
|
|
logoUrl={companyLogoUrl}
|
|
logoFit="contain"
|
|
className={className}
|
|
/>
|
|
);
|
|
}
|
|
|
|
function AwaitingJoinApprovalPanel({
|
|
companyDisplayName,
|
|
companyLogoUrl,
|
|
invitedByUserName,
|
|
claimSecret = null,
|
|
claimApiKeyPath = null,
|
|
onboardingTextUrl = null,
|
|
}: AwaitingJoinApprovalPanelProps) {
|
|
const approverLabel = invitedByUserName ?? "An organization admin";
|
|
|
|
return (
|
|
<div className="min-h-screen bg-zinc-950 px-6 py-12 text-zinc-100">
|
|
<div className="mx-auto max-w-md border border-zinc-800 bg-zinc-950 p-6" data-testid="invite-pending-approval">
|
|
<div className="flex items-center gap-3">
|
|
<InviteCompanyLogo
|
|
companyDisplayName={companyDisplayName}
|
|
companyLogoUrl={companyLogoUrl}
|
|
className="h-12 w-12 border border-zinc-800 rounded-none"
|
|
/>
|
|
<h1 className="text-lg font-semibold">Request to join {companyDisplayName}</h1>
|
|
</div>
|
|
<div className="mt-4 space-y-3">
|
|
<p className="text-sm text-zinc-400">
|
|
Your request is still awaiting approval. {approverLabel} must approve your request to join.
|
|
</p>
|
|
<div className="border border-zinc-800 p-3">
|
|
<p className="text-xs text-zinc-500 mb-1">Approval page</p>
|
|
<p className="text-sm text-zinc-200">Settings → Members</p>
|
|
</div>
|
|
<p className="text-sm text-zinc-400">
|
|
Ask them to visit <span className="text-zinc-200">Settings → Members</span> to approve your request.
|
|
</p>
|
|
<p className="text-xs text-zinc-500">
|
|
Refresh this page after you've been approved — you'll be redirected automatically.
|
|
</p>
|
|
</div>
|
|
{claimSecret && claimApiKeyPath ? (
|
|
<div className="mt-4 space-y-1 border border-zinc-800 p-3 text-xs text-zinc-400">
|
|
<div className="text-zinc-200">Claim secret</div>
|
|
<div className="font-mono break-all">{claimSecret}</div>
|
|
<div className="font-mono break-all">POST {claimApiKeyPath}</div>
|
|
</div>
|
|
) : null}
|
|
{onboardingTextUrl ? (
|
|
<div className="mt-4 text-xs text-zinc-400">
|
|
Onboarding: <span className="font-mono break-all">{onboardingTextUrl}</span>
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export function InviteLandingPage() {
|
|
const queryClient = useQueryClient();
|
|
const navigate = useNavigate();
|
|
const { setSelectedCompanyId } = useCompany();
|
|
const params = useParams();
|
|
const token = (params.token ?? "").trim();
|
|
const [authMode, setAuthMode] = useState<AuthMode>("sign_up");
|
|
const [name, setName] = useState("");
|
|
const [email, setEmail] = useState("");
|
|
const [password, setPassword] = useState("");
|
|
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 [authFeedback, setAuthFeedback] = useState<AuthFeedback | null>(null);
|
|
const [autoAcceptStarted, setAutoAcceptStarted] = useState(false);
|
|
const authErrorId = "invite-auth-error";
|
|
|
|
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,
|
|
});
|
|
|
|
// Whose list this is, is no longer this page's problem: the entry is keyed by
|
|
// account, so another account's list is unreachable rather than merely
|
|
// distrusted. What is left for the gate below is narrower and still real — do
|
|
// we have an answer *yet*. Without it, a pending query reads as an empty list,
|
|
// which reads as "not a member", which auto-accepts an invite the customer may
|
|
// already hold.
|
|
//
|
|
// Hence `staleTime: 0` and a mount-scoped flag rather than `isSuccess`: a
|
|
// cached list is the right account's now, but it can be thirty seconds old, and
|
|
// acting on "not a member" is the direction that costs something.
|
|
//
|
|
// `local_trusted` has no accounts at all, so there is no identity to key on and
|
|
// nothing to check the list against; the gate stays open.
|
|
const companiesQuery = useCompanyListQuery({
|
|
enabled: !!sessionQuery.data && !!inviteQuery.data?.companyId,
|
|
staleTime: 0,
|
|
});
|
|
const membershipIsAccountScoped = healthQuery.data?.deploymentMode !== "local_trusted";
|
|
// No `sessionQuery.data` term: the query only fetches while a session exists, so
|
|
// the flag cannot be true without one, and if the session lapses the observer
|
|
// re-keys to the anonymous entry and holds no data to leak.
|
|
const membershipListIsCurrent = membershipIsAccountScoped
|
|
? companiesQuery.isFetchedAfterMount
|
|
: true;
|
|
const companyList = membershipListIsCurrent ? companiesQuery.data?.companies ?? [] : [];
|
|
|
|
useEffect(() => {
|
|
if (token) rememberPendingInviteToken(token);
|
|
}, [token]);
|
|
|
|
useEffect(() => {
|
|
setAutoAcceptStarted(false);
|
|
}, [token]);
|
|
|
|
useEffect(() => {
|
|
if (!membershipListIsCurrent) return;
|
|
const list = companiesQuery.data?.companies;
|
|
if (!list || !inviteQuery.data?.companyId) return;
|
|
if (list.some((c) => c.id === inviteQuery.data!.companyId)) {
|
|
clearPendingInviteToken(token);
|
|
}
|
|
}, [companiesQuery.data, inviteQuery.data, membershipListIsCurrent, token]);
|
|
|
|
const invite = inviteQuery.data;
|
|
const isCheckingExistingMembership =
|
|
Boolean(sessionQuery.data) &&
|
|
Boolean(invite?.companyId) &&
|
|
!membershipListIsCurrent;
|
|
const isCurrentMember =
|
|
Boolean(invite?.companyId) &&
|
|
companyList.some((company) => company.id === invite?.companyId);
|
|
const companyName = invite?.companyName?.trim() || null;
|
|
const companyDisplayName = companyName || "this Paperclip company";
|
|
const companyLogoUrl = invite?.companyLogoUrl?.trim() || null;
|
|
const invitedByUserName = invite?.invitedByUserName?.trim() || null;
|
|
const inviteMessage = invite?.inviteMessage?.trim() || null;
|
|
const requestedHumanRole = formatHumanRole(invite?.humanRole);
|
|
const inviteJoinRequestStatus = invite?.joinRequestStatus ?? null;
|
|
const inviteJoinRequestType = invite?.joinRequestType ?? null;
|
|
const canCompleteAcceptedHumanInvite =
|
|
inviteJoinRequestType === "human" &&
|
|
(inviteJoinRequestStatus === "pending_approval" || inviteJoinRequestStatus === "approved");
|
|
const requiresHumanAccount =
|
|
healthQuery.data?.deploymentMode === "authenticated" &&
|
|
!sessionQuery.data &&
|
|
invite?.allowedJoinTypes !== "agent";
|
|
const showsAgentForm = invite?.inviteType !== "bootstrap_ceo" && invite?.allowedJoinTypes === "agent";
|
|
const shouldAutoAcceptHumanInvite =
|
|
Boolean(sessionQuery.data) &&
|
|
!showsAgentForm &&
|
|
invite?.inviteType !== "bootstrap_ceo" &&
|
|
(!inviteJoinRequestStatus || canCompleteAcceptedHumanInvite) &&
|
|
!isCheckingExistingMembership &&
|
|
!isCurrentMember &&
|
|
!result &&
|
|
error === null;
|
|
const sessionLabel =
|
|
sessionQuery.data?.user.name?.trim() ||
|
|
sessionQuery.data?.user.email?.trim() ||
|
|
"this account";
|
|
|
|
const authCanSubmit =
|
|
email.trim().length > 0 &&
|
|
password.trim().length > 0 &&
|
|
(authMode === "sign_in" || (name.trim().length > 0 && password.trim().length >= 8));
|
|
|
|
const acceptMutation = useMutation({
|
|
mutationFn: async () => {
|
|
if (!invite) throw new Error("Invite not found");
|
|
if (isCheckingExistingMembership) {
|
|
throw new Error("Checking your organization access. Try again in a moment.");
|
|
}
|
|
if (isCurrentMember) {
|
|
throw new Error("This account already belongs to the organization.");
|
|
}
|
|
if (invite.inviteType === "bootstrap_ceo" || invite.allowedJoinTypes !== "agent") {
|
|
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);
|
|
clearPendingInviteToken(token);
|
|
const asBootstrap = isBootstrapAcceptancePayload(payload);
|
|
setResult({ kind: asBootstrap ? "bootstrap" : "join", payload });
|
|
await queryClient.invalidateQueries({ queryKey: queryKeys.auth.session });
|
|
await queryClient.invalidateQueries({ queryKey: queryKeys.access.currentBoardAccess });
|
|
await queryClient.invalidateQueries({ queryKey: queryKeys.companies.all });
|
|
if (invite?.companyId && isApprovedHumanJoinPayload(payload, showsAgentForm)) {
|
|
setSelectedCompanyId(invite.companyId, { source: "manual" });
|
|
navigate("/", { replace: true });
|
|
}
|
|
},
|
|
onError: (err) => {
|
|
setError(err instanceof Error ? err.message : "Failed to accept invite");
|
|
},
|
|
});
|
|
|
|
useEffect(() => {
|
|
if (!shouldAutoAcceptHumanInvite || autoAcceptStarted || acceptMutation.isPending) return;
|
|
setAutoAcceptStarted(true);
|
|
setError(null);
|
|
acceptMutation.mutate();
|
|
}, [acceptMutation, autoAcceptStarted, shouldAutoAcceptHumanInvite]);
|
|
|
|
const authMutation = useMutation({
|
|
mutationFn: async () => {
|
|
if (authMode === "sign_in") {
|
|
await authApi.signInEmail({ email: email.trim(), password });
|
|
return;
|
|
}
|
|
await authApi.signUpEmail({
|
|
name: name.trim(),
|
|
email: email.trim(),
|
|
password,
|
|
});
|
|
},
|
|
onSuccess: async () => {
|
|
setAuthFeedback(null);
|
|
rememberPendingInviteToken(token);
|
|
await queryClient.invalidateQueries({ queryKey: queryKeys.auth.session });
|
|
await queryClient.invalidateQueries({ queryKey: queryKeys.health });
|
|
await queryClient.invalidateQueries({ queryKey: queryKeys.access.currentBoardAccess });
|
|
// Keyed to the account that just signed in — the helper resolves the
|
|
// identity past the invalidation above rather than trusting the session
|
|
// entry still sitting in the cache — and forced past whatever is cached
|
|
// for it. This replaces the hand-rolled cancel-and-refetch this PR
|
|
// originally carried, which #11488 made both unnecessary and weaker.
|
|
const { companies: freshCompanies } = await fetchCompanyListForCurrentAccount(queryClient);
|
|
|
|
if (invite?.companyId && freshCompanies.some((company) => company.id === invite.companyId)) {
|
|
clearPendingInviteToken(token);
|
|
setSelectedCompanyId(invite.companyId, { source: "manual" });
|
|
navigate("/", { replace: true });
|
|
return;
|
|
}
|
|
|
|
if (!invite || invite.inviteType !== "bootstrap_ceo") {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const payload = await acceptMutation.mutateAsync();
|
|
if (isBootstrapAcceptancePayload(payload)) {
|
|
navigate("/", { replace: true });
|
|
}
|
|
} catch {
|
|
return;
|
|
}
|
|
},
|
|
onError: (err) => {
|
|
const nextFeedback = mapInviteAuthFeedback(err, authMode, email);
|
|
if (getAuthErrorCode(err) === "USER_ALREADY_EXISTS_USE_ANOTHER_EMAIL") {
|
|
setAuthMode("sign_in");
|
|
setPassword("");
|
|
}
|
|
setAuthFeedback(nextFeedback);
|
|
},
|
|
});
|
|
|
|
const joinButtonLabel = useMemo(() => {
|
|
if (!invite) return "Continue";
|
|
if (isCurrentMember) return "Open organization";
|
|
if (invite.inviteType === "bootstrap_ceo") return "Accept invite";
|
|
if (showsAgentForm) return "Submit request";
|
|
return sessionQuery.data ? "Accept invite" : "Continue";
|
|
}, [invite, isCurrentMember, sessionQuery.data, showsAgentForm]);
|
|
|
|
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 (isCheckingExistingMembership) {
|
|
return <div className="mx-auto max-w-xl py-10 text-sm text-muted-foreground">Checking your access...</div>;
|
|
}
|
|
|
|
if (inviteQuery.error || !invite) {
|
|
return (
|
|
<div className="mx-auto max-w-xl py-10">
|
|
<div className="border border-border bg-card p-6" data-testid="invite-error">
|
|
<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 (
|
|
inviteJoinRequestStatus === "approved" &&
|
|
inviteJoinRequestType === "human" &&
|
|
isCurrentMember
|
|
) {
|
|
return <div className="mx-auto max-w-xl py-10 text-sm text-muted-foreground">Opening organization...</div>;
|
|
}
|
|
|
|
if (inviteJoinRequestStatus === "pending_approval" && !canCompleteAcceptedHumanInvite) {
|
|
return (
|
|
<AwaitingJoinApprovalPanel
|
|
companyDisplayName={companyDisplayName}
|
|
companyLogoUrl={companyLogoUrl}
|
|
invitedByUserName={invitedByUserName}
|
|
/>
|
|
);
|
|
}
|
|
|
|
if (inviteJoinRequestStatus && !canCompleteAcceptedHumanInvite) {
|
|
return (
|
|
<div className="mx-auto max-w-xl py-10">
|
|
<div className="border border-border bg-card p-6" data-testid="invite-error">
|
|
<h1 className="text-lg font-semibold">Invite not available</h1>
|
|
<p className="mt-2 text-sm text-muted-foreground">
|
|
{inviteJoinRequestStatus === "rejected"
|
|
? "This join request was not approved."
|
|
: "This invite has already been used."}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (result?.kind === "bootstrap") {
|
|
return (
|
|
<div className="min-h-screen bg-zinc-950 px-6 py-12 text-zinc-100">
|
|
<div className="mx-auto max-w-md border border-zinc-800 bg-zinc-950 p-6">
|
|
<h1 className="text-lg font-semibold">Bootstrap complete</h1>
|
|
<div className="mt-4">
|
|
<Button asChild className="rounded-none">
|
|
<Link to="/">Open board</Link>
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (result?.kind === "join") {
|
|
const payload = result.payload as JoinRequest & {
|
|
claimSecret?: string;
|
|
claimApiKeyPath?: string;
|
|
onboarding?: Record<string, unknown>;
|
|
};
|
|
const claimSecret = typeof payload.claimSecret === "string" ? payload.claimSecret : null;
|
|
const claimApiKeyPath = typeof payload.claimApiKeyPath === "string" ? payload.claimApiKeyPath : null;
|
|
const onboardingTextUrl = readNestedString(payload.onboarding, ["textInstructions", "url"]);
|
|
const joinedNow = !showsAgentForm && payload.status === "approved";
|
|
|
|
return (
|
|
joinedNow ? (
|
|
<div className="min-h-screen bg-zinc-950 px-6 py-12 text-zinc-100">
|
|
<div className="mx-auto max-w-md border border-zinc-800 bg-zinc-950 p-6">
|
|
<div className="flex items-center gap-3">
|
|
<InviteCompanyLogo
|
|
companyDisplayName={companyDisplayName}
|
|
companyLogoUrl={companyLogoUrl}
|
|
className="h-12 w-12 border border-zinc-800 rounded-none"
|
|
/>
|
|
<h1 className="text-lg font-semibold">You joined the organization</h1>
|
|
</div>
|
|
<div className="mt-4">
|
|
<Button asChild className="w-full rounded-none">
|
|
<Link to="/">Open board</Link>
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<AwaitingJoinApprovalPanel
|
|
companyDisplayName={companyDisplayName}
|
|
companyLogoUrl={companyLogoUrl}
|
|
invitedByUserName={invitedByUserName}
|
|
claimSecret={claimSecret}
|
|
claimApiKeyPath={claimApiKeyPath}
|
|
onboardingTextUrl={onboardingTextUrl}
|
|
/>
|
|
)
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="min-h-screen bg-zinc-950 px-6 py-12 text-zinc-100">
|
|
<div className="mx-auto max-w-5xl">
|
|
<div className="grid gap-6 lg:grid-cols-(--gtc-36)">
|
|
<section className={`${panelClassName} space-y-6`}>
|
|
<div className="flex items-start gap-4">
|
|
<InviteCompanyLogo
|
|
companyDisplayName={companyDisplayName}
|
|
companyLogoUrl={companyLogoUrl}
|
|
className="h-16 w-16 rounded-none border border-zinc-800"
|
|
/>
|
|
<div className="min-w-0">
|
|
<p className="text-xs uppercase tracking-(--tracking-caps) text-zinc-500">
|
|
You've been invited to join Paperclip
|
|
</p>
|
|
<h1 className="mt-2 text-2xl font-semibold">
|
|
{invite.inviteType === "bootstrap_ceo" ? "Set up Paperclip" : `Join ${companyDisplayName}`}
|
|
</h1>
|
|
<p className="mt-2 max-w-2xl text-sm leading-6 text-zinc-300">
|
|
{showsAgentForm
|
|
? "Review the invite details, then submit the agent information below to start the join request."
|
|
: requiresHumanAccount
|
|
? "Create your Paperclip account first. If you already have one, switch to sign in and continue the invite with the same email."
|
|
: "Your account is ready. Review the invite details, then accept it to continue."}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="grid gap-3 sm:grid-cols-2">
|
|
<div className="border border-zinc-800 p-3">
|
|
<div className="text-xs uppercase tracking-(--tracking-caps) text-zinc-500">Organization</div>
|
|
<div className="mt-1 text-sm text-zinc-100">{companyDisplayName}</div>
|
|
</div>
|
|
<div className="border border-zinc-800 p-3">
|
|
<div className="text-xs uppercase tracking-(--tracking-caps) text-zinc-500">Invited by</div>
|
|
<div className="mt-1 text-sm text-zinc-100">{invitedByUserName ?? "Paperclip board"}</div>
|
|
</div>
|
|
<div className="border border-zinc-800 p-3">
|
|
<div className="text-xs uppercase tracking-(--tracking-caps) text-zinc-500">Requested access</div>
|
|
<div className="mt-1 text-sm text-zinc-100">
|
|
{showsAgentForm ? "Agent join request" : requestedHumanRole ?? "Organization access"}
|
|
</div>
|
|
</div>
|
|
<div className="border border-zinc-800 p-3">
|
|
<div className="text-xs uppercase tracking-(--tracking-caps) text-zinc-500">Invite expires</div>
|
|
<div className="mt-1 text-sm text-zinc-100">{formatDate(invite.expiresAt)}</div>
|
|
</div>
|
|
</div>
|
|
|
|
{inviteMessage ? (
|
|
<div className="border border-amber-500/40 bg-amber-500/10 p-4">
|
|
<div className="text-xs uppercase tracking-(--tracking-caps) text-amber-200/80">Message from inviter</div>
|
|
<p className="mt-2 text-sm leading-6 text-amber-50">{inviteMessage}</p>
|
|
</div>
|
|
) : null}
|
|
|
|
{sessionQuery.data ? (
|
|
<div className="border border-emerald-500/40 bg-emerald-500/10 p-4 text-sm text-emerald-50">
|
|
Signed in as <span className="font-medium">{sessionLabel}</span>.
|
|
</div>
|
|
) : null}
|
|
</section>
|
|
|
|
<section className={`${panelClassName} h-fit`}>
|
|
{showsAgentForm ? (
|
|
<div className="space-y-4">
|
|
<div>
|
|
<h2 className="text-lg font-semibold">Submit agent details</h2>
|
|
<p className="mt-1 text-sm text-zinc-400">
|
|
This invite will create an approval request for a new agent in {companyDisplayName}.
|
|
</p>
|
|
</div>
|
|
<label className="block text-sm">
|
|
<span className="mb-1 block text-zinc-400">Agent name</span>
|
|
<input
|
|
className={fieldClassName}
|
|
value={agentName}
|
|
onChange={(event) => setAgentName(event.target.value)}
|
|
/>
|
|
</label>
|
|
<label className="block text-sm">
|
|
<span className="mb-1 block text-zinc-400">Adapter type</span>
|
|
<select
|
|
className={fieldClassName}
|
|
value={adapterType}
|
|
onChange={(event) => setAdapterType(event.target.value as AgentAdapterType)}
|
|
>
|
|
{joinAdapterOptions.map((type) => (
|
|
<option key={type} value={type} disabled={!ENABLED_INVITE_ADAPTERS.has(type)}>
|
|
{getAdapterLabel(type)}{!ENABLED_INVITE_ADAPTERS.has(type) ? " (Coming soon)" : ""}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</label>
|
|
<label className="block text-sm">
|
|
<span className="mb-1 block text-zinc-400">Capabilities</span>
|
|
<textarea
|
|
className={fieldClassName}
|
|
rows={4}
|
|
value={capabilities}
|
|
onChange={(event) => setCapabilities(event.target.value)}
|
|
/>
|
|
</label>
|
|
{error ? <p className="text-xs text-red-400">{error}</p> : null}
|
|
<Button
|
|
className="w-full rounded-none"
|
|
disabled={acceptMutation.isPending || agentName.trim().length === 0}
|
|
onClick={() => acceptMutation.mutate()}
|
|
>
|
|
{acceptMutation.isPending ? "Working..." : joinButtonLabel}
|
|
</Button>
|
|
</div>
|
|
) : requiresHumanAccount ? (
|
|
<div className="space-y-5">
|
|
<div>
|
|
<h2 className="text-lg font-semibold">
|
|
{authMode === "sign_up" ? "Create your account" : "Sign in to continue"}
|
|
</h2>
|
|
<p className="mt-1 text-sm text-zinc-400">
|
|
{authMode === "sign_up"
|
|
? `Start with a Paperclip account. After that, you'll come right back here to accept the invite for ${companyDisplayName}.`
|
|
: "Use the Paperclip account that already matches this invite. If you do not have one yet, switch back to create account."}
|
|
</p>
|
|
</div>
|
|
|
|
<div className="flex gap-2">
|
|
<button
|
|
type="button"
|
|
className={`${modeButtonBaseClassName} ${
|
|
authMode === "sign_up"
|
|
? "border-zinc-100 bg-zinc-100 text-zinc-950"
|
|
: "border-zinc-800 text-zinc-300 hover:border-zinc-600"
|
|
}`}
|
|
onClick={() => {
|
|
setAuthFeedback(null);
|
|
setAuthMode("sign_up");
|
|
}}
|
|
>
|
|
Create account
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className={`${modeButtonBaseClassName} ${
|
|
authMode === "sign_in"
|
|
? "border-zinc-100 bg-zinc-100 text-zinc-950"
|
|
: "border-zinc-800 text-zinc-300 hover:border-zinc-600"
|
|
}`}
|
|
onClick={() => {
|
|
setAuthFeedback(null);
|
|
setAuthMode("sign_in");
|
|
}}
|
|
>
|
|
I already have an account
|
|
</button>
|
|
</div>
|
|
|
|
<form
|
|
className="space-y-4"
|
|
method="post"
|
|
action={authMode === "sign_up" ? "/api/auth/sign-up/email" : "/api/auth/sign-in/email"}
|
|
onSubmit={(event) => {
|
|
event.preventDefault();
|
|
if (authMutation.isPending) return;
|
|
if (!authCanSubmit) {
|
|
setAuthFeedback({ tone: "error", message: "Please fill in all required fields." });
|
|
return;
|
|
}
|
|
authMutation.mutate();
|
|
}}
|
|
data-testid="invite-inline-auth"
|
|
>
|
|
{authMode === "sign_up" ? (
|
|
<label className="block text-sm" htmlFor="invite-name">
|
|
<span className="mb-1 block text-zinc-400">Name</span>
|
|
<input
|
|
id="invite-name"
|
|
name="name"
|
|
className={fieldClassName}
|
|
value={name}
|
|
onChange={(event) => {
|
|
setName(event.target.value);
|
|
setAuthFeedback(null);
|
|
}}
|
|
autoComplete="name"
|
|
required
|
|
aria-required="true"
|
|
aria-invalid={authFeedback?.tone === "error" ? true : undefined}
|
|
aria-describedby={authFeedback ? authErrorId : undefined}
|
|
autoFocus
|
|
/>
|
|
</label>
|
|
) : null}
|
|
<label className="block text-sm" htmlFor="invite-email">
|
|
<span className="mb-1 block text-zinc-400">Email</span>
|
|
<input
|
|
id="invite-email"
|
|
name="email"
|
|
type="email"
|
|
className={fieldClassName}
|
|
value={email}
|
|
onChange={(event) => {
|
|
setEmail(event.target.value);
|
|
setAuthFeedback(null);
|
|
}}
|
|
autoComplete="username"
|
|
required
|
|
aria-required="true"
|
|
aria-invalid={authFeedback?.tone === "error" ? true : undefined}
|
|
aria-describedby={authFeedback ? authErrorId : undefined}
|
|
autoFocus={authMode === "sign_in"}
|
|
/>
|
|
</label>
|
|
<label className="block text-sm" htmlFor="invite-password">
|
|
<span className="mb-1 block text-zinc-400">Password</span>
|
|
<input
|
|
id="invite-password"
|
|
name="password"
|
|
type="password"
|
|
className={fieldClassName}
|
|
value={password}
|
|
onChange={(event) => {
|
|
setPassword(event.target.value);
|
|
setAuthFeedback(null);
|
|
}}
|
|
autoComplete={authMode === "sign_in" ? "current-password" : "new-password"}
|
|
required
|
|
aria-required="true"
|
|
aria-invalid={authFeedback?.tone === "error" ? true : undefined}
|
|
aria-describedby={authFeedback ? authErrorId : undefined}
|
|
/>
|
|
</label>
|
|
{authFeedback ? (
|
|
<p
|
|
id={authErrorId}
|
|
role="alert"
|
|
className={`text-xs ${
|
|
authFeedback.tone === "info" ? "text-amber-300" : "text-red-400"
|
|
}`}
|
|
>
|
|
{authFeedback.message}
|
|
</p>
|
|
) : null}
|
|
<Button
|
|
type="submit"
|
|
className="w-full rounded-none"
|
|
disabled={authMutation.isPending}
|
|
aria-disabled={!authCanSubmit || authMutation.isPending}
|
|
>
|
|
{authMutation.isPending
|
|
? "Working..."
|
|
: authMode === "sign_in"
|
|
? "Sign in and continue"
|
|
: "Create account and continue"}
|
|
</Button>
|
|
</form>
|
|
|
|
<p className="text-xs leading-5 text-zinc-500">
|
|
{authMode === "sign_up"
|
|
? "Already signed up before? Use the existing-account option instead so the invite lands on the right Paperclip user."
|
|
: "No account yet? Switch back to create account so you can accept the invite with a new login."}
|
|
</p>
|
|
</div>
|
|
) : (
|
|
<div className="space-y-4">
|
|
<div>
|
|
<h2 className="text-lg font-semibold">
|
|
{isCurrentMember
|
|
? "Already in this organization"
|
|
: shouldAutoAcceptHumanInvite
|
|
? "Completing organization access"
|
|
: invite.inviteType === "bootstrap_ceo"
|
|
? "Accept bootstrap invite"
|
|
: "Accept organization invite"}
|
|
</h2>
|
|
<p className="mt-1 text-sm text-zinc-400">
|
|
{shouldAutoAcceptHumanInvite
|
|
? `Granting your access to ${companyDisplayName}.`
|
|
: isCurrentMember
|
|
? `This account already belongs to ${companyDisplayName}.`
|
|
: `This will ${
|
|
invite.inviteType === "bootstrap_ceo" ? "finish setting up Paperclip" : `grant or complete your access to ${companyDisplayName}`
|
|
}.`}
|
|
</p>
|
|
</div>
|
|
{error ? <p className="text-xs text-red-400">{error}</p> : null}
|
|
{shouldAutoAcceptHumanInvite ? (
|
|
<div className="text-sm text-zinc-400">
|
|
{acceptMutation.isPending ? "Submitting request..." : "Finishing sign-in..."}
|
|
</div>
|
|
) : (
|
|
<Button
|
|
className="w-full rounded-none"
|
|
disabled={acceptMutation.isPending}
|
|
onClick={() => {
|
|
if (isCurrentMember && invite.companyId) {
|
|
clearPendingInviteToken(token);
|
|
setSelectedCompanyId(invite.companyId, { source: "manual" });
|
|
navigate("/", { replace: true });
|
|
return;
|
|
}
|
|
acceptMutation.mutate();
|
|
}}
|
|
>
|
|
{acceptMutation.isPending ? "Working..." : joinButtonLabel}
|
|
</Button>
|
|
)}
|
|
</div>
|
|
)}
|
|
</section>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|