mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-02 16:58:52 +01:00
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - The board reads API data and keeps the current task on screen. > - A restart or temporary network failure can interrupt those reads. > - Separate page error handlers can hide cached data or expose raw server codes. > - The app needs one connection state, readable error copy and a consistent read policy. > - This change provides that foundation and moves affected read surfaces onto it. > - The dependent write layer in #17960 preserves edits and submissions across the same failures. ## Linked Issues or Issue Description **What happened?** Temporary API failures could replace loaded content with errors. Different surfaces handled the same outage differently. Some showed raw server codes. A successful query to an unrelated service could also falsely report that Paperclip had recovered. **Expected behavior** Keep cached content visible. Show one connection banner during sustained trouble. Confirm recovery through the Paperclip health probe or its WebSocket. Retry transient reads and show readable errors for permanent failures. **Steps to reproduce** 1. Open a populated task or workspace page. 2. Interrupt API requests with a 503 or a dropped connection. 3. Keep an unrelated external request in flight. 4. Confirm that cached content remains and that the external result cannot clear the outage. 5. Restore the API and check that reads refresh. The write layer first merged into this feature branch in #15769 now has its own dependent PR, #17960. This PR contains the shared foundation and read changes. Merge this PR first. ## What Changed - Classify API errors and provide readable error copy, including non-JSON proxy responses and Retry-After handling. - Use one connection store and health probe with bounded backoff. Confirm recovery from the server health probe or live-updates WebSocket. - Pause reads while disconnected, retry transient errors, and refresh active reads after recovery. - Keep cached data visible through shared query views and affected page, panel and plugin read surfaces. - Add one app-level connection banner and support the pending-write count consumed by the dependent write layer. - Add a query-error rendering guard. Call it directly from the existing CI step. Update development documentation. ## Verification - Historical-chat lookup now follows the same ready/stale roster state as rendering. Three regressions fail before the fix; the six new cases pass after it, including working Retry, fresh data, no-cache error, permanent 403 and company isolation. All six focused query/recovery suites pass 200 tests. Final-commit CI passes and Apex is 5/5 with no unresolved findings. Canary Dry Run initially refused a preview version that was already on npm. It passed after one rerun with fresh registry metadata; no check was disabled. - Integrated current master and retained both transcript interest registration and connectivity reporting on socket open. The seven focused recovery/query suites pass 288 tests, and UI typecheck and production build pass. All 9,451 UI tests across 799 files pass on the merge commit. Final-commit CI passes and Apex is 5/5 with no unresolved findings. - Current head `70d9335d732219352ef0f98414439766bbcb33df`: all hosted CI checks pass. Apex is **5/5** on that head with no unresolved findings. The branch has no merge conflict. GitHub still requires code-owner approval. - The foundation passes 9,430 UI tests in 804 files. After integrating current master page paths, 190 focused page tests pass. - Two unrelated-success regressions fail before the latest fix. With the fix, 62 focused UI tests, all six guard tests and UI TypeScript pass. - The composed foundation and write layer pass **9,505 UI tests in 808 files**, UI TypeScript, a production UI build, and query-error, token and map guards. - A real Chromium journey on the composed source injects a 503 during title save. The loaded task stays visible; one reconnect banner appears; the edit saves after recovery; another task stays unchanged; the saved title survives reload. - Full recursive local checks were not repeated because Runner compilation exhausted local disk during another repair. Hosted checks must pass before merge. No new staging deployment is claimed. - Latest review follow-up: a cached CaseDetail now survives a failed HTTP 429 refresh. Its page regression fails before the fix and passes after it. Eighty focused foundation tests, UI TypeScript, build and guards pass after that change. - Earlier CI evidence is retained: several foundation jobs received a runner shutdown signal together. The write-layer head also failed an unchanged Runner descriptor close test with `provider_transport_failed`. The same Runner source passed that suite in the foundation run. New CI runs cover the updated heads; no check or assertion was weakened. ## Risks - Shared retry defaults change the time before transient read errors appear. Permanent errors still fail promptly. - ApiError.message now contains readable copy. Callers that compare raw codes must use ApiError.code. - Cache success can come from another service or local data. It must not confirm Paperclip recovery. - Durable writes are reviewed separately in #17960. This PR does not change database or mutation contracts. ## Model Used Anthropic Claude Opus 5.5 (`claude-opus-5-5`) produced the original work with tool use. OpenAI Codex, GPT-6 family, assisted with restoration, integration, tests and review fixes. The Codex session does not expose its exact serving model ID or context window size. ## 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 - [x] 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 - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: devinfoley <139239+devinfoley@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> Co-authored-by: Paperclip <noreply@paperclip.ing> Co-authored-by: Amy (Opus) <claude@users.noreply.github.com>
873 lines
36 KiB
TypeScript
873 lines
36 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 { AuthApiError, authApi, authErrorServerMessage } 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;
|
|
}
|
|
|
|
/** A bare HTTP status from the auth API, with no server explanation. */
|
|
function isUnexplainedAuthStatus(error: unknown, status: number) {
|
|
return error instanceof AuthApiError && error.status === status && authErrorServerMessage(error.body) === 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" && isUnexplainedAuthStatus(error, 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" && isUnexplainedAuthStatus(error, 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>
|
|
);
|
|
}
|