import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useMutation, useQuery } from "@tanstack/react-query"; import { ArrowUpRight, Bot, Building2, Check, ChevronDown, ChevronRight, Cloud, Copy, Link2, Loader2, Lock, Search, UserRound, UsersRound, } from "lucide-react"; import type { Agent, AppDefinition, ConnectionGrantKind, ConnectionIntentSetupConnection, ConnectionMethodDef, ConnectToolAppResult, FieldDef, ToolApplication, ToolConnection, ToolConnectionAuthKind, ToolConnectionCredentialSource, ToolConnectionCreateCapabilities, ToolOAuthStartResult, } from "@paperclipai/shared"; import { connectionMethodAcceptsCustomerOAuthClient, connectionMethodRequiresConfiguration, connectionMethodSupportsAutomaticOAuth, credentialConfigPath, getAppDefinitionForUrl, getConnectableAppDefinition, getAvailableConnectionMethods, getRecommendedConnectionMethod, } from "@paperclipai/shared"; import { useNavigate, useParams, useSearchParams } from "@/lib/router"; import { useCompany } from "@/context/CompanyContext"; import { useBreadcrumbs } from "@/context/BreadcrumbContext"; import { useToast } from "@/context/ToastContext"; import { useChatConnectorsEnabled } from "@/hooks/useChatConnectorsEnabled"; import { queryKeys } from "@/lib/queryKeys"; import { RadioCardGroup } from "@/components/ui/radio-card"; import { ApiError } from "@/api/client"; import { toolsApi } from "@/api/tools"; import { agentsApi } from "@/api/agents"; import { appCopyFor, credentialFieldLabel } from "@/lib/app-gallery-copy"; import { AgentIcon } from "@/components/AgentIconPicker"; import { AgentMultiSelect } from "@/components/AgentMultiSelect"; import { InlineBanner } from "@/components/InlineBanner"; import { Button } from "@/components/ui/button"; import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible"; import { Input } from "@/components/ui/input"; import { Textarea } from "@/components/ui/textarea"; import { ToggleSwitch } from "@/components/ui/toggle-switch"; import { Skeleton } from "@/components/ui/skeleton"; import { cn } from "@/lib/utils"; import { copyTextToClipboard } from "@/lib/clipboard"; import { resolveAuthorizationTarget } from "@/lib/authorizationUrl"; import { navigateTopLevel } from "@/lib/browserNavigation"; import { prepareOAuthNavigation, savePendingCloudHandoff } from "@/lib/oauthHandoff"; import { redactUrlSecrets } from "@/lib/redact-url-secrets"; import { AppLogo } from "@/pages/apps/AppLogo"; import { appApplicationSourceSlug } from "@/pages/apps/app-definition-display"; import { UnverifiedServerBadge } from "@/pages/apps/UnverifiedServerBadge"; import { appSourceConnectHref, appSupportsToolCatalogSetup, isMcpDirectOAuthConnectSlug, resolveAppsConnectRouteKey, vercelConnectSourceHref, } from "@/pages/apps/app-connect-policy"; import { parseGoogleSheetIds } from "@/pages/apps/google-sheets"; import { connectionNameForGrantKind } from "@/pages/apps/connection-identity"; import { canSubmitGenericConnect, customHeaderError, defaultGenericMcpName, endpointHost, genericConnectGuidance, genericConnectPayload, newCustomHeaderRow, oauthCallbackUrlForBrowser, type CustomHeaderRow, type GenericConnectDraft, type GenericConnectGuidance, type GenericMcpAuthMode, } from "@/pages/apps/generic-mcp-connect"; import { autoExtendNotice, INSTALL_ALL_WARNING, installInfoNotice, installPayload } from "@/lib/tool-installs"; type Step = "gallery" | "access" | "key" | "success"; export type OAuthConnectPhase = "entry" | "starting" | "redirecting" | "error"; type EnrollmentAccessState = { companyId: string; grantKind: ConnectionGrantKind; installChoice: "specific" | "all"; agentIds: string[]; }; function enrollmentAccessStorageKey(appKey: string): string { return `paperclip.connector-enrollment-access:${appKey}`; } function validEnrollmentAccessState(value: unknown): value is EnrollmentAccessState { if (!value || typeof value !== "object" || Array.isArray(value)) return false; const candidate = value as Record; if (typeof candidate.companyId !== "string" || !candidate.companyId.trim()) return false; if (!(candidate.grantKind === "user" || candidate.grantKind === "agent" || candidate.grantKind === "organization")) { return false; } if (candidate.installChoice !== "specific" && candidate.installChoice !== "all") return false; if (!Array.isArray(candidate.agentIds) || candidate.agentIds.some((id) => typeof id !== "string" || !id.trim())) { return false; } const agentIds = new Set(candidate.agentIds); if (agentIds.size !== candidate.agentIds.length) return false; if (candidate.grantKind === "agent") { return candidate.installChoice === "specific" && agentIds.size === 1; } return candidate.installChoice === "all" ? agentIds.size === 0 : agentIds.size > 0; } function saveEnrollmentAccessState( companyId: string, appKey: string, state: Omit, ): void { try { window.sessionStorage.setItem(enrollmentAccessStorageKey(appKey), JSON.stringify({ ...state, companyId })); } catch { // Browser storage can be unavailable under restrictive privacy settings. // The callback will safely use the provider's defaults in that case. } } function consumeEnrollmentAccessState(appKey: string): EnrollmentAccessState | null { const key = enrollmentAccessStorageKey(appKey); try { const raw = window.sessionStorage.getItem(key); window.sessionStorage.removeItem(key); if (!raw) return null; const parsed: unknown = JSON.parse(raw); return validEnrollmentAccessState(parsed) ? parsed : null; } catch { return null; } } function githubRecoveryUrl(value: string | null): string | null { if (!value) return null; try { const url = new URL(value); return url.protocol === "https:" && url.hostname.toLowerCase() === "github.com" ? url.toString() : null; } catch { return null; } } function oauthCallbackErrorMessage(outcome: string | null, code: string | null): string { if (outcome === "denied") { return "Authorization was cancelled or declined. Your saved connection was not changed."; } if (code === "github_installation_required") { return "GitHub access is required. Install Paperclip and grant at least one repository, then try again."; } return "Authorization did not complete. Your saved connection is still here, so you can try again."; } const ROUTE_STAGE_BY_STEP: Partial> = { access: "access", key: "setup", success: "complete", }; export function requestedConnectionInitialStep(input: { requestedAppKey: string | undefined; routeStage: string | null; resumeConnectionId: string | null; hasPrefilledLink: boolean; zapierSource: boolean; }): Step { if (input.requestedAppKey) { return input.resumeConnectionId || input.routeStage === "setup" ? "key" : "access"; } return input.hasPrefilledLink || input.zapierSource ? "access" : "gallery"; } export function requestedConnectionEntry(input: { requestedAppKey: string; galleryApps: readonly AppDefinition[]; reconnectConnection: ToolConnection | null; applications: readonly ToolApplication[]; }): AppDefinition | null { const visible = input.galleryApps.find((candidate) => candidate.slug === input.requestedAppKey); if (visible) return visible; if (!input.reconnectConnection) return null; const application = input.applications.find( (candidate) => candidate.id === input.reconnectConnection?.applicationId, ); if (appApplicationSourceSlug(application) !== input.requestedAppKey) return null; return getConnectableAppDefinition(input.requestedAppKey); } export function retainedReconnectMatches(input: { requestedAppKey: string | undefined; byo: boolean; applicationId: string | undefined; reconnectConnection: ToolConnection | null; reconnectApplication: ToolApplication | null; }): boolean { if ( !input.reconnectConnection || !input.reconnectApplication || input.reconnectConnection.applicationId !== input.reconnectApplication.id ) return false; if (input.requestedAppKey) { return appApplicationSourceSlug(input.reconnectApplication) === input.requestedAppKey; } // Generic MCP applications intentionally have no provider slug. Their // reconnect URL instead carries the exact retained application identity. return input.byo && input.applicationId === input.reconnectApplication.id; } export function isVercelConnectUnavailable(input: { credentialSource: ToolConnectionCredentialSource; available: boolean; retainedReconnectMatches: boolean; }): boolean { return input.credentialSource === "vercel_connect" && !input.available && !input.retainedReconnectMatches; } export function isConnectionDefinitionUnavailable(input: { available: boolean | undefined; reconnectConnectionId: string | null | undefined; reconnectSourceMatches: boolean; }): boolean { return input.available === false && !(input.reconnectConnectionId && input.reconnectSourceMatches); } export function requestedConnectionSetupResolution(input: { reconnectConnectionId: string | null | undefined; hasRequestedEntry: boolean; supportedMethodCount: number; unsupportedOAuth: boolean; vercelUnavailable: boolean; definitionUnavailable: boolean; }): "ready" | "fallback" | "reconnect_unavailable" { const unavailable = !input.hasRequestedEntry || input.supportedMethodCount === 0 || input.unsupportedOAuth || input.vercelUnavailable || input.definitionUnavailable; if (!unavailable) return "ready"; return input.reconnectConnectionId ? "reconnect_unavailable" : "fallback"; } function appConnectHref( appKey: string, step: Step, credentialSource: ToolConnectionCredentialSource, existing?: { resumeConnectionId?: string | null; reconnectConnectionId?: string | null; interactionId?: string | null; }, ): string { const stage = ROUTE_STAGE_BY_STEP[step] ?? "setup"; const params = new URLSearchParams({ source: appKey, stage }); if (existing?.resumeConnectionId) params.set("resume", existing.resumeConnectionId); if (existing?.reconnectConnectionId) params.set("reconnect", existing.reconnectConnectionId); if (existing?.interactionId) params.set("intent", existing.interactionId); const path = credentialSource === "vercel_connect" ? "/apps/vercel-connect" : "/apps/connect"; return `${path}?${params.toString()}`; } function withConnectionIntent(href: string, interactionId?: string | null): string { if (!interactionId) return href; const [path, query = ""] = href.split("?", 2); const params = new URLSearchParams(query); params.set("intent", interactionId); return `${path}?${params.toString()}`; } type AppAccessSelection = "all_agents" | { agentIds: string[] }; // Access comes before credentials so the reader knows what identity and reach // the secret is about to get before they share it (PAP-17835). const STEP_LABELS = ["Pick app", "Access", "Add your key"]; const STEP_INDEX: Record, number> = { gallery: 0, access: 1, key: 2, }; const SELECTED_APP_STEP_INDEX: Record, number> = { access: 0, key: 1, }; const ZAPIER_STEP_LABELS = ["Access", "Add MCP URL"]; /** * Which identity a fresh connection should default to (PAP-17835). * * Company identity is the product default whenever the selected method permits * it. Personal-only methods still stay personal, and reconnects preserve their * original identity through the explicit reconnect hint. */ function defaultGrantKindFor(method: ConnectionMethodDef | null, preferPersonal = false): ConnectionGrantKind { if (preferPersonal && method?.auth !== "none" && (!method?.grantKinds || method.grantKinds.includes("user"))) return "user"; if (method?.grantKinds?.length === 1) return method.grantKinds[0]!; if (method?.grantKinds && !method.grantKinds.includes("organization")) return method.grantKinds[0]!; return "organization"; } function configuredAgentIdentity(connection: ToolConnection): string | undefined { const oauth = connection.config?.oauth; if (!oauth || typeof oauth !== "object" || Array.isArray(oauth)) return undefined; const value = (oauth as Record).connectorSubjectAgentId; return typeof value === "string" ? value : undefined; } function isGoogleSheetsRobotMethod( entry: AppDefinition | null, method: ConnectionMethodDef | string | null | undefined, ): boolean { const methodKey = typeof method === "string" ? method : method?.key; return entry?.slug === "google-sheets" && methodKey === "local"; } function defaultMethodConfig(method: ConnectionMethodDef | null): Record { if (!method) return {}; return Object.fromEntries( [...(method.tenantFields ?? []), ...(method.extensionFields ?? [])] .filter((field) => field.defaultValue !== undefined) .map((field) => [field.key, field.defaultValue!]), ); } function connectionMethodsForCredentialSource( entry: AppDefinition | null | undefined, credentialSource: ToolConnectionCredentialSource, ): ConnectionMethodDef[] { if (!entry) return []; const methods = availableToolConnectionMethods(entry); return credentialSource === "vercel_connect" ? methods.filter((method) => Boolean(method.credentialSources?.vercelConnect)) : methods; } function availableToolConnectionMethods( entry: AppDefinition, ): ConnectionMethodDef[] { return getAvailableConnectionMethods(entry).filter( (method) => (method.purpose ?? "tool") === "tool", ); } function availableToolConnectionMethod( entry: AppDefinition, methodKey?: string | null, ): ConnectionMethodDef | null { const methods = availableToolConnectionMethods(entry); return methodKey ? methods.find((method) => method.key === methodKey) ?? null : getRecommendedConnectionMethod(methods); } function recommendedSetupConnectionMethod( methods: readonly ConnectionMethodDef[], ): ConnectionMethodDef | null { const recommended = getRecommendedConnectionMethod(methods); // Capability choices (for example Google Workspace read versus write) have // an intentional default. Unrelated region/authentication variants should // still ask the operator to choose unless only one is available. A method // that supports an agent-owned identity must also be selected before the // Access step: that ownership decision cannot be represented by a legacy // compatibility method such as GitHub's advanced PAT option. return methods.length === 1 || recommended?.capabilityProfile || recommended?.grantKinds?.includes("agent") ? recommended : null; } function recommendedManagedConnectorMethod( entry: AppDefinition | null | undefined, ): ConnectionMethodDef | null { return recommendedSetupConnectionMethod( entry ? entry.methods.filter( (candidate) => (candidate.purpose ?? "tool") === "tool" && (candidate.oauthStrategy === "paperclip_cloud_connector" || candidate.oauthStrategy === "paperclip_id_connector"), ) : [], ); } function canUseAutomaticOAuthFastPath(entry: AppDefinition | null | undefined): boolean { if (!entry) return false; const methods = availableToolConnectionMethods(entry); const method = methods.length === 1 ? methods[0] : null; return Boolean( method && connectionMethodSupportsAutomaticOAuth(method) && !connectionMethodRequiresConfiguration(method), ); } function automaticOAuthMethod(entry: AppDefinition | null | undefined): ConnectionMethodDef | null { if (!entry) return null; const methods = availableToolConnectionMethods(entry); const method = methods.length === 1 ? methods[0] : null; return method && connectionMethodSupportsAutomaticOAuth(method) ? method : null; } function appSourceSlug(application: ToolApplication): string | null { const metadata = application.metadata; if (!metadata) return null; const source = metadata.sourceTemplateKey ?? metadata.galleryKey; return typeof source === "string" ? source : null; } function connectionSourceSlug(connection: ToolConnection): string | null { const source = connection.config?.sourceTemplateKey ?? connection.transportConfig.sourceTemplateKey; return typeof source === "string" ? source : null; } function reusableOAuthConnection( sourceSlug: string | null, applications: ToolApplication[], connections: ToolConnection[], options: { applicationId?: string; draftOnly?: boolean } = {}, ): ToolConnection | null { if (!sourceSlug) return null; const matchingApplicationIds = new Set( applications .filter((application) => application.status !== "archived" && appSourceSlug(application) === sourceSlug && (!options.applicationId || application.id === options.applicationId) ) .map((application) => application.id), ); return connections.find((connection) => { const matchesApplication = options.applicationId ? connection.applicationId === options.applicationId : matchingApplicationIds.has(connection.applicationId) || connectionSourceSlug(connection) === sourceSlug; return connection.status !== "archived" && (!options.draftOnly || connection.status === "draft") && connection.authKind === "oauth" && matchesApplication; }) ?? null; } export type ConnectionSetupCompletion = | { connectionId: string; resolvedByCallback?: false } /** OAuth callbacks finalize server-side before notifying their opener. */ | { resolvedByCallback: true }; export type ConnectionIntentOAuthOutcome = "connected" | "declined" | "failed"; export function readConnectionIntentOAuthOutcome( event: Pick, expectedOrigin: string, interactionId: string, ): ConnectionIntentOAuthOutcome | null { if (event.origin !== expectedOrigin || !event.data || typeof event.data !== "object") return null; const message = event.data as { type?: unknown; interactionId?: unknown; outcome?: unknown; }; if ( message.type !== "paperclip.connection-intent.oauth" || message.interactionId !== interactionId ) return null; return message.outcome === "connected" || message.outcome === "declined" || message.outcome === "failed" ? message.outcome : null; } export interface ConnectionSetupFlowProps { byoOnly?: boolean; credentialSource?: ToolConnectionCredentialSource; host?: "page" | "dialog"; serviceSlug?: string; requestedAgentId?: string; interactionId?: string; forceNewConnection?: boolean; existingConnections?: ConnectionIntentSetupConnection[]; configuredConnection?: ConnectionIntentSetupConnection; onUseExisting?: (connectionId: string) => Promise; onComplete?: (result: ConnectionSetupCompletion) => void; onOAuthDeclined?: () => void; onPhaseChange?: (phase: "requested" | "authorizing" | "needs_retry") => void; onCancel?: () => void; } /** * The single connection setup implementation used by both the Apps store and * task-hosted connection intents. Hosts supply presentation and completion * callbacks; provider fields, validation, OAuth, access, and finishing remain * here so a provider can never drift between entry points. */ export function ConnectionSetupFlow({ byoOnly = false, credentialSource = "paperclip_vault", host = "page", serviceSlug, requestedAgentId, interactionId, forceNewConnection = false, existingConnections = [], configuredConnection, onUseExisting, onComplete, onOAuthDeclined, onPhaseChange, onCancel, }: ConnectionSetupFlowProps = {}) { const routeNavigate = useNavigate(); const navigate = useCallback((to: string, options?: { replace?: boolean }) => { if (host !== "page") return; if (options) routeNavigate(to, options); else routeNavigate(to); }, [host, routeNavigate]); const routeParams = useParams<{ appKey?: string }>(); const { selectedCompany, selectedCompanyId } = useCompany(); const { enabled: chatConnectorsEnabled } = useChatConnectorsEnabled(); const { setBreadcrumbs } = useBreadcrumbs(); const { pushToast } = useToast(); const [searchParams] = useSearchParams(); const connectionIntentId = interactionId?.trim() || searchParams.get("intent")?.trim() || null; const appKey = routeParams.appKey ?? searchParams.get("appKey") ?? undefined; const sourceSlug = searchParams.get("source")?.trim() || null; const createNewConnection = forceNewConnection || searchParams.get("new") === "1"; const routeStage = searchParams.get("stage")?.trim() || null; const resumeConnectionId = searchParams.get("resume")?.trim() || null; const oauthCallbackOutcome = searchParams.get("oauth"); const oauthCallbackCode = searchParams.get("code"); const githubInstallationUrl = githubRecoveryUrl(searchParams.get("installation_url")); const githubManagementUrl = githubRecoveryUrl(searchParams.get("management_url")); const reconnectConnectionId = configuredConnection?.id ?? (searchParams.get("reconnect")?.trim() || null); const reconnectGrantKindHint: ConnectionGrantKind | null = searchParams.get("identity") === "user" ? "user" : searchParams.get("identity") === "organization" ? "organization" : null; const vercelConnectMode = credentialSource === "vercel_connect"; const directOAuthCandidate = serviceSlug ?? sourceSlug; const directOAuthSource = credentialSource === "paperclip_vault" && isMcpDirectOAuthConnectSlug(directOAuthCandidate) ? directOAuthCandidate : null; // `source` is the generic curated-app route contract, not an OAuth-only // shortcut. Manual OAuth, API-key, no-auth, and configured MCP definitions // must all enter the same branded setup flow when Browse links to them. const routeAppKey = resolveAppsConnectRouteKey({ serviceSlug, appKey, sourceSlug }); const zapierSource = (serviceSlug ?? sourceSlug ?? appKey) === "zapier"; const requestedAppKey = zapierSource ? undefined : routeAppKey; const byo = Boolean(configuredConnection) || (host === "page" && (byoOnly || searchParams.get("byo") === "1")); const [restoredEnrollmentAccess] = useState(() => host === "page" && searchParams.get("cloud_connector") === "enrolled" && requestedAppKey ? consumeEnrollmentAccessState(requestedAppKey) : null, ); // Prefill arrives from the app page for reconnects; read once so later // wizard navigation doesn't fight the URL. const [prefill] = useState(() => { const rawLink = searchParams.get("link")?.trim() ?? ""; return { link: /^https?:\/\//i.test(rawLink) ? rawLink : "", name: configuredConnection?.name ?? searchParams.get("name")?.trim() ?? "", applicationId: configuredConnection?.applicationId ?? (searchParams.get("applicationId")?.trim() || undefined), }; }); const [step, setStep] = useState(() => requestedConnectionInitialStep({ requestedAppKey, routeStage, resumeConnectionId, hasPrefilledLink: Boolean(prefill.link), zapierSource, })); const [entry, setEntry] = useState(null); const [galleryName, setGalleryName] = useState(""); const [linkUrl, setLinkUrl] = useState(prefill.link); const [linkName, setLinkName] = useState(prefill.name || (zapierSource ? "Zapier" : "")); const [linkNeedsKey, setLinkNeedsKey] = useState(false); const [linkKey, setLinkKey] = useState(""); // Generic ("connect your own MCP server") flow state. `authMode: auto` is the // simple path: Paperclip probes the endpoint and branches on what it finds. const [linkAuthMode, setLinkAuthMode] = useState("auto"); const [linkHeaders, setLinkHeaders] = useState(() => [newCustomHeaderRow()]); const [linkOAuthClientId, setLinkOAuthClientId] = useState(""); const [linkOAuthClientSecret, setLinkOAuthClientSecret] = useState(""); const [linkAdvancedOpen, setLinkAdvancedOpen] = useState(false); const [linkGuidance, setLinkGuidance] = useState(null); const [genericOAuthPending, setGenericOAuthPending] = useState(false); const [credentials, setCredentials] = useState>({}); const [curatedOAuthClientId, setCuratedOAuthClientId] = useState(""); const [curatedOAuthClientSecret, setCuratedOAuthClientSecret] = useState(""); const [vercelConnector, setVercelConnector] = useState(""); const [connectionMethodKey, setConnectionMethodKey] = useState(""); const [configValues, setConfigValues] = useState>({}); const [googleSheetsLinks, setGoogleSheetsLinks] = useState(""); const [googleSheetsError, setGoogleSheetsError] = useState(null); const [connectResult, setConnectResult] = useState(null); const [enabled, setEnabled] = useState>({}); const [access, setAccess] = useState<"all" | "specific">("all"); const [agentIds, setAgentIds] = useState>(new Set()); const [installAgentIds, setInstallAgentIds] = useState>( () => new Set(restoredEnrollmentAccess?.agentIds ?? (requestedAgentId ? [requestedAgentId] : [])), ); /** * Access-step selections (PAP-17835). These are chosen before the credential * and committed with it, so they must survive a failed submit and a trip * backwards through the wizard. */ const [grantKind, setGrantKind] = useState( restoredEnrollmentAccess?.grantKind ?? reconnectGrantKindHint ?? (requestedAgentId ? "user" : "organization"), ); const [installChoice, setInstallChoice] = useState<"specific" | "all">( restoredEnrollmentAccess?.installChoice ?? (requestedAgentId ? "specific" : "all"), ); const resumingAfterOAuthFailure = Boolean( resumeConnectionId && (oauthCallbackOutcome === "failed" || oauthCallbackOutcome === "denied"), ); const [oauthPhase, setOAuthPhase] = useState( resumingAfterOAuthFailure ? "error" : "entry", ); const [oauthError, setOAuthError] = useState(() => { if (!resumingAfterOAuthFailure) return null; return oauthCallbackErrorMessage(oauthCallbackOutcome, oauthCallbackCode); }); /** Host of the page the operator is about to be sent to, shown while redirecting. */ const [authorizationHost, setAuthorizationHost] = useState(null); const directOAuthAccessConfirmedRef = useRef(false); const directOAuthRetryingRef = useRef(false); const hydratedResumeConnectionIdRef = useRef(null); const [hydratedResumeConnectionId, setHydratedResumeConnectionId] = useState(null); const oauthPopupRef = useRef(null); const [dialogOAuthConnectionId, setDialogOAuthConnectionId] = useState(null); const [authorizationFallbackUrl, setAuthorizationFallbackUrl] = useState(null); const oauthHandoffAbortRef = useRef(null); const [showConnectionChoice, setShowConnectionChoice] = useState( existingConnections.length > 0 && Boolean(onUseExisting), ); const [existingConnectionPendingId, setExistingConnectionPendingId] = useState(null); const [existingConnectionError, setExistingConnectionError] = useState(null); const [unavailableReconnectId, setUnavailableReconnectId] = useState(null); const reserveOAuthPopup = useCallback(() => { if (host !== "dialog" || oauthPopupRef.current?.closed === false) return; oauthPopupRef.current = window.open( "about:blank", "paperclip-connection-oauth", "popup,width=720,height=760,resizable=yes,scrollbars=yes", ); }, [host]); const openAuthorization = useCallback((url: string) => { if (host !== "dialog") { navigateTopLevel(url); return; } setAuthorizationFallbackUrl(url); const popup = oauthPopupRef.current; if (!popup || popup.closed) { setOAuthPhase("error"); setOAuthError("Paperclip couldn’t open the sign-in window. Open sign-in in a new tab to continue."); onPhaseChange?.("needs_retry"); return; } popup.location.assign(url); popup.focus(); }, [host, onPhaseChange]); const openAuthorizationTab = useCallback(() => { // Let a real link own navigation. Some embedded browsers return a window // proxy from window.open without opening a usable authorization tab. oauthPopupRef.current = null; setOAuthError(null); setOAuthPhase("redirecting"); onPhaseChange?.("authorizing"); }, [onPhaseChange]); useEffect(() => { if (host !== "dialog" || oauthPhase !== "redirecting") return; const timer = window.setInterval(() => { if (!oauthPopupRef.current?.closed) return; setOAuthPhase("error"); setOAuthError("The sign-in window closed. If authorization did not finish, try again."); setAuthorizationFallbackUrl(null); onPhaseChange?.("needs_retry"); }, 1_000); return () => window.clearInterval(timer); }, [host, oauthPhase, onPhaseChange]); const prepareAndOpenOAuth = useCallback(async ( start: Pick, ) => { oauthHandoffAbortRef.current?.abort(); const controller = new AbortController(); oauthHandoffAbortRef.current = controller; try { const target = await prepareOAuthNavigation(start, { signal: controller.signal }); if (target.kind === "reauthentication") { const destination = host === "dialog" ? oauthPopupRef.current : window; if (!destination || destination.closed || !start.handoff) { throw new Error("Paperclip couldn’t preserve this sign-in while refreshing your account."); } savePendingCloudHandoff(start.handoff.session, destination.sessionStorage); setOAuthPhase("starting"); } else { setAuthorizationHost(target.host); setOAuthPhase("redirecting"); } openAuthorization(target.url); } catch (error) { if (controller.signal.aborted) return; setOAuthPhase("error"); setOAuthError(error instanceof Error ? error.message : "Paperclip couldn’t start secure sign-in. Try again."); onPhaseChange?.("needs_retry"); } finally { if (oauthHandoffAbortRef.current === controller) oauthHandoffAbortRef.current = null; } }, [host, onPhaseChange, openAuthorization]); useEffect(() => () => oauthHandoffAbortRef.current?.abort(), []); useEffect(() => { if (host !== "dialog" || !connectionIntentId) return; const receiveOAuthOutcome = (event: MessageEvent) => { const outcome = readConnectionIntentOAuthOutcome(event, window.location.origin, connectionIntentId); if (outcome === "connected") { setOAuthPhase("entry"); setAuthorizationFallbackUrl(null); onComplete?.({ resolvedByCallback: true }); return; } if (outcome === "declined") { setOAuthPhase("error"); setOAuthError("Authorization was cancelled. You can try again."); onPhaseChange?.("needs_retry"); return; } if (outcome !== "failed") return; setOAuthPhase("error"); setOAuthError("Authorization did not complete. Try again when you’re ready."); }; window.addEventListener("message", receiveOAuthOutcome); return () => window.removeEventListener("message", receiveOAuthOutcome); }, [connectionIntentId, host, onComplete, onOAuthDeclined]); // Standalone dialog hosts have no task interaction to receive a callback. // Wait for this popup to return to our origin, then verify durable state via // the API. Provider-window contents never determine the saved connection. useEffect(() => { if (host !== "dialog" || connectionIntentId || !dialogOAuthConnectionId) return; let cancelled = false; let checking = false; const timer = window.setInterval(async () => { if (checking || cancelled) return; const popup = oauthPopupRef.current; if (!popup || popup.closed) { setDialogOAuthConnectionId(null); setOAuthPhase("error"); setOAuthError("The sign-in window closed. Try again to finish connecting GitHub."); return; } let returned: URL; try { returned = new URL(popup.location.href); } catch { return; } if (returned.origin !== window.location.origin || !returned.pathname.includes(dialogOAuthConnectionId)) return; if (returned.searchParams.has("oauth")) { setDialogOAuthConnectionId(null); setOAuthPhase("error"); setOAuthError("Authorization did not complete. Finish setup in the sign-in window or try again."); return; } if (returned.searchParams.get("success") !== "1") return; checking = true; try { const connection = await toolsApi.getConnection(dialogOAuthConnectionId); if (!cancelled && connection.status === "active") { setDialogOAuthConnectionId(null); popup.close(); onComplete?.({ connectionId: connection.id }); } } catch { if (!cancelled) { setDialogOAuthConnectionId(null); setOAuthPhase("error"); setOAuthError("Could not confirm the connection. Try again."); } } finally { checking = false; } }, 1000); return () => { cancelled = true; window.clearInterval(timer); }; }, [connectionIntentId, dialogOAuthConnectionId, host, onComplete]); const resetGenericAuthState = () => { setLinkAuthMode("auto"); setLinkHeaders([newCustomHeaderRow()]); setLinkOAuthClientId(""); setLinkOAuthClientSecret(""); setLinkAdvancedOpen(false); setLinkGuidance(null); setGenericOAuthPending(false); }; /** * Switch to a curated app's branded setup. Reached from the gallery grid and, as * a convenience, from the guided generic flow when the pasted endpoint matches a * definition — the generic path stays available either way. */ const useMatchedGalleryEntry = (picked: AppDefinition) => { if (picked.slug === "zapier") { setEntry(null); setGalleryName(""); setLinkUrl(""); setLinkName("Zapier"); setLinkNeedsKey(false); setLinkKey(""); resetGenericAuthState(); setCredentials({}); setConnectResult(null); setStep("access"); navigate(withConnectionIntent("/apps/connect?source=zapier", connectionIntentId)); return; } if (credentialSource === "paperclip_vault" && canUseAutomaticOAuthFastPath(picked)) { navigate(appSourceConnectHref(picked.slug, connectionIntentId)); return; } setEntry(picked); setGalleryName(picked.name); setLinkUrl(""); setLinkName(""); setLinkNeedsKey(false); setLinkKey(""); resetGenericAuthState(); setCredentials({}); setCuratedOAuthClientId(""); setCuratedOAuthClientSecret(""); setVercelConnector(""); const methods = connectionMethodsForCredentialSource(picked, credentialSource); const initialMethod = recommendedSetupConnectionMethod(methods); setConnectionMethodKey(initialMethod?.key ?? ""); setConfigValues(defaultMethodConfig(initialMethod)); setGoogleSheetsLinks(""); setGoogleSheetsError(null); setConnectResult(null); setInstallAgentIds(new Set(requestedAgentId ? [requestedAgentId] : [])); setInstallChoice(requestedAgentId ? "specific" : "all"); setGrantKind(reconnectGrantKind ?? defaultGrantKindFor(initialMethod, Boolean(requestedAgentId))); setStep("access"); navigate( credentialSource === "vercel_connect" ? withConnectionIntent(vercelConnectSourceHref(picked.slug), connectionIntentId) : appSourceConnectHref(picked.slug, connectionIntentId), ); }; const backToGallery = () => { // Back is a wizard transition, so keep the selected app and entered draft // intact. Picking another connector will replace that state explicitly. setStep("gallery"); navigate(withConnectionIntent( credentialSource === "vercel_connect" ? vercelConnectSourceHref() : byoOnly ? "/apps/byo" : "/apps", connectionIntentId, )); }; useEffect(() => { if (host !== "page") return; setBreadcrumbs([ { label: "Connectors", href: "/apps" }, { label: vercelConnectMode ? "Vercel Connect" : byoOnly ? "Connect your own tool" : "Connect an app" }, ]); return () => setBreadcrumbs([]); }, [byoOnly, host, setBreadcrumbs, vercelConnectMode]); const galleryQuery = useQuery({ queryKey: queryKeys.apps.gallery(selectedCompanyId ?? "__none__"), queryFn: () => toolsApi.listGallery(selectedCompanyId!), enabled: !!selectedCompanyId, }); // Use the same visible catalog for cards and every branded URL shortcut. // Generic custom URLs remain usable without selecting a hidden provider. const visibleGalleryApps = useMemo( () => (galleryQuery.data?.apps ?? []).filter((app) => chatConnectorsEnabled || !app.methods.some((method) => method.transport === "chat_sdk") || appSupportsToolCatalogSetup(app), ), [galleryQuery.data, chatConnectorsEnabled], ); const fullRequestedDefinition = requestedAppKey ? getConnectableAppDefinition(requestedAppKey) : null; const requestedDefinitionUsesManagedConnector = Boolean( fullRequestedDefinition?.methods.some((candidate) => candidate.oauthStrategy === "paperclip_cloud_connector" || candidate.oauthStrategy === "paperclip_id_connector" ), ); const entryAdvertisesManagedConnector = Boolean( entry?.methods.some((candidate) => candidate.oauthStrategy === "paperclip_cloud_connector" || candidate.oauthStrategy === "paperclip_id_connector" ), ); // Before a self-hosted instance enrolls, the server intentionally withholds // platform-managed methods from the advertised gallery. The setup route still // needs the managed method's identity model, labels, and defaults because the // next step is enrollment for that exact method—not the visible PAT/BYO // compatibility fallback. const preEnrollmentManagedMethod = entry && requestedDefinitionUsesManagedConnector && !entryAdvertisesManagedConnector ? recommendedManagedConnectorMethod(fullRequestedDefinition) : null; const [enrollmentAuthorizationUrl, setEnrollmentAuthorizationUrl] = useState(null); const connectorEnrollmentQuery = useQuery({ queryKey: ["cloud-connector", "enrollment"], queryFn: () => toolsApi.getCloudConnectorEnrollment(), refetchInterval: enrollmentAuthorizationUrl ? 2_000 : false, enabled: Boolean( selectedCompanyId && requestedDefinitionUsesManagedConnector && !entryAdvertisesManagedConnector ), }); const [connectorEnrollmentError, setConnectorEnrollmentError] = useState(null); const closeEnrollmentPopup = useCallback(() => { oauthPopupRef.current?.close(); oauthPopupRef.current = null; setEnrollmentAuthorizationUrl(null); }, []); const preserveEnrollmentAccess = useCallback(() => { if (!selectedCompanyId || !requestedAppKey) return; saveEnrollmentAccessState(selectedCompanyId, requestedAppKey, { grantKind, installChoice, agentIds: installChoice === "specific" ? [...installAgentIds] : [], }); }, [grantKind, installAgentIds, installChoice, requestedAppKey, selectedCompanyId]); const openConnectorEnrollment = useCallback((verificationUrl: string) => { const target = resolveAuthorizationTarget(verificationUrl); if (!target.ok) { closeEnrollmentPopup(); setConnectorEnrollmentError(target.message); return; } if (host === "dialog") { setEnrollmentAuthorizationUrl(target.url); const popup = oauthPopupRef.current; if (popup && !popup.closed) { popup.location.assign(target.url); popup.focus(); } else { setConnectorEnrollmentError("Open authorization in a new tab to continue."); } return; } navigateTopLevel(target.url); }, [host, closeEnrollmentPopup]); useEffect(() => { if (!enrollmentAuthorizationUrl || connectorEnrollmentQuery.data?.status !== "active") return; // Enrollment is only a prerequisite. Re-read the server catalog and keep // the task's access selection and interaction binding in this dialog. closeEnrollmentPopup(); setConnectorEnrollmentError(null); void galleryQuery.refetch(); }, [enrollmentAuthorizationUrl, connectorEnrollmentQuery.data?.status, galleryQuery.refetch, closeEnrollmentPopup]); const startConnectorEnrollment = useMutation({ mutationFn: () => toolsApi.startCloudConnectorEnrollment( selectedCompanyId!, selectedCompany?.name, requestedAppKey ? appConnectHref(requestedAppKey, "key", credentialSource, { resumeConnectionId, reconnectConnectionId, interactionId: connectionIntentId, }) + (host === "dialog" ? "&enrollment_host=dialog" : "") : undefined, ), onSuccess: (status) => { if (!status.verificationUrl) { closeEnrollmentPopup(); setConnectorEnrollmentError("Paperclip Cloud did not return an enrollment link. Try again."); return; } openConnectorEnrollment(status.verificationUrl); }, onError: (error) => { closeEnrollmentPopup(); setConnectorEnrollmentError( error instanceof Error ? error.message : "Paperclip couldn’t reach Paperclip Cloud. Try again.", ); }, }); const applicationsQuery = useQuery({ queryKey: queryKeys.tools.applications(selectedCompanyId ?? "__none__"), queryFn: () => toolsApi.listApplications(selectedCompanyId!), enabled: !!selectedCompanyId && (!!directOAuthSource || !!resumeConnectionId || !!reconnectConnectionId), refetchOnMount: "always", }); const connectionsQuery = useQuery({ queryKey: queryKeys.tools.connections(selectedCompanyId ?? "__none__"), queryFn: () => toolsApi.listConnections(selectedCompanyId!), enabled: !!selectedCompanyId && (!!directOAuthSource || !!resumeConnectionId || !!reconnectConnectionId), refetchOnMount: "always", }); const existingOAuthConnection = useMemo( () => forceNewConnection ? null : reusableOAuthConnection( directOAuthSource, applicationsQuery.data?.applications ?? [], connectionsQuery.data?.connections ?? [], createNewConnection ? { applicationId: prefill.applicationId, draftOnly: true } : {}, ), [applicationsQuery.data, connectionsQuery.data, createNewConnection, directOAuthSource, prefill.applicationId, forceNewConnection], ); const reconnectConnection = useMemo( () => reconnectConnectionId ? (connectionsQuery.data?.connections ?? []).find((connection) => connection.id === reconnectConnectionId) ?? null : null, [connectionsQuery.data, reconnectConnectionId], ); const seededReconnectEndpoint = useRef(null); useEffect(() => { // Task choices contain selection metadata only. Load the endpoint through // the existing authorized connection query, once, without overwriting edits. if (!configuredConnection || !connectionsQuery.isFetchedAfterMount || !reconnectConnection || seededReconnectEndpoint.current === reconnectConnection.id) return; const endpoint = reconnectConnection.config?.url ?? reconnectConnection.transportConfig?.url ?? reconnectConnection.transportConfig?.serverUrl ?? reconnectConnection.config?.serverUrl; if (typeof endpoint !== "string" || !/^https?:\/\//i.test(endpoint)) return; seededReconnectEndpoint.current = reconnectConnection.id; setLinkUrl((current) => current || endpoint); }, [configuredConnection, connectionsQuery.isFetchedAfterMount, reconnectConnection]); const reconnectApplication = useMemo( () => reconnectConnection ? (applicationsQuery.data?.applications ?? []).find( (application) => application.id === reconnectConnection.applicationId, ) ?? null : null, [applicationsQuery.data, reconnectConnection], ); const reconnectSourceMatches = retainedReconnectMatches({ requestedAppKey, byo, applicationId: prefill.applicationId, reconnectConnection, reconnectApplication, }); const resumeConnection = useMemo( () => resumeConnectionId ? (connectionsQuery.data?.connections ?? []).find((connection) => connection.id === resumeConnectionId) ?? null : null, [connectionsQuery.data, resumeConnectionId], ); const identityConnection = resumeConnection ?? reconnectConnection; const reconnectGrantKind: ConnectionGrantKind | null = identityConnection ? identityConnection.credentialPolicy === "per_user" ? "user" : identityConnection.credentialPolicy === "per_agent" ? "agent" : "organization" : reconnectGrantKindHint; const resumableOAuthConnection = resumeConnection?.authKind === "oauth" ? resumeConnection : existingOAuthConnection; const existingOAuthGrantKind: ConnectionGrantKind | null = existingOAuthConnection ? existingOAuthConnection.credentialPolicy === "per_user" ? "user" : existingOAuthConnection.credentialPolicy === "per_agent" ? "agent" : "organization" : null; const fixedGrantKind = reconnectGrantKind ?? existingOAuthGrantKind; useEffect(() => { if (fixedGrantKind) setGrantKind(fixedGrantKind); }, [fixedGrantKind]); // A curated definition covering the pasted endpoint is offered as a branded // convenience only; the generic flow remains the default and stays complete. const linkMatchedEntry = useMemo( () => (linkUrl && !entry ? getAppDefinitionForUrl(linkUrl, visibleGalleryApps) : null), [entry, visibleGalleryApps, linkUrl], ); const selectedSetupMethod = entry ? availableToolConnectionMethod(entry, connectionMethodKey || null) : null; // Apps with an advanced PAT option still need OAuth progress and recovery // screens when their selected method is managed sign-in. const entryAutomaticOAuthMethod = selectedSetupMethod && connectionMethodSupportsAutomaticOAuth(selectedSetupMethod) ? selectedSetupMethod : automaticOAuthMethod(entry); const automaticOAuthEntry = credentialSource === "paperclip_vault" && entryAutomaticOAuthMethod ? entry : null; const directOAuthEntry = credentialSource === "paperclip_vault" && canUseAutomaticOAuthFastPath(entry) ? entry : null; const directOAuthLookupPending = Boolean(directOAuthSource) && ( !applicationsQuery.isFetchedAfterMount || !connectionsQuery.isFetchedAfterMount ); const setAppStep = (nextStep: Step) => { setStep(nextStep); if (entry) { navigate(appConnectHref(entry.slug, nextStep, credentialSource, { resumeConnectionId, reconnectConnectionId, interactionId: connectionIntentId, })); } }; const oauthStartMutation = useMutation({ // Retry/reconnect reads identity from the durable connection. Provider is // not identity: an organization Notion connection must stay organization- // scoped, while a personal one must put its token back on that user grant. mutationFn: (connection: ToolConnection) => toolsApi.startOAuth(connection.id, { asCurrentUser: connection.credentialPolicy === "per_user", ...(connection.credentialPolicy === "per_agent" ? { asAgentId: configuredAgentIdentity(connection) ?? [...installAgentIds][0] } : {}), ...(connectionIntentId ? { interactionId: connectionIntentId } : {}), }), onSuccess: (start) => void prepareAndOpenOAuth(start), onError: (error) => { const details = error instanceof ApiError && error.body && typeof error.body === "object" ? (error.body as { details?: { code?: unknown } }).details : null; setOAuthPhase("error"); onPhaseChange?.("needs_retry"); setOAuthError( details?.code === "invalid_grant" ? "Your authorization expired or was revoked. Reconnect to continue." : error instanceof Error ? error.message : "Paperclip couldn’t start secure sign-in. Try again.", ); }, }); const mutateOAuthStart = oauthStartMutation.mutate; const startOAuth = useCallback((connection: ToolConnection) => { onPhaseChange?.("authorizing"); reserveOAuthPopup(); if (host === "dialog" && !connectionIntentId) setDialogOAuthConnectionId(connection.id); mutateOAuthStart(connection); }, [mutateOAuthStart, onPhaseChange, reserveOAuthPopup, host, connectionIntentId]); /** * Commit the Access step's agent reach for a connection. Shared by the * key-path finish and the OAuth redirect, so both routes through the wizard * apply the same selection. */ const applyAccessInstalls = async (connectionId: string) => { // The task completion endpoint adds the requester atomically with resolution. if (requestedAgentId) return; const dedicatedIdentity = (fixedGrantKind ?? grantKind) === "agent"; const installState = !dedicatedIdentity && installChoice === "all" ? { onAll: true, agentIds: new Set() } : { onAll: false, agentIds: installAgentIds }; const desired = installPayload(selectedCompanyId!, installState); await toolsApi.putConnectionInstalls(connectionId, desired); }; const effectiveGrantKind = fixedGrantKind ?? grantKind; const connectMutation = useMutation({ mutationFn: async (entryOverride?: AppDefinition) => { const connectEntry = entryOverride ?? entry; let result: ConnectToolAppResult; if (connectEntry) { const requestedGrantKind = fixedGrantKind ?? grantKind; const selectedMethod = availableToolConnectionMethod( connectEntry, connectionMethodKey || null, ); const sheetIds = isGoogleSheetsRobotMethod(connectEntry, selectedMethod) ? parseGoogleSheetIds(googleSheetsLinks).ids : []; const trimmedGalleryName = galleryName.trim(); const connectionName = connectionNameForGrantKind( trimmedGalleryName || connectEntry.name, requestedGrantKind, ); result = await toolsApi.connectApp(selectedCompanyId!, { galleryKey: connectEntry.slug, ...(connectionMethodKey ? { connectionMethodKey } : {}), name: connectionName, credentialSource, ...(credentialSource === "paperclip_vault" ? { credentialValues: credentials } : {}), ...(credentialSource === "vercel_connect" ? { vercelConnect: { connector: vercelConnector.trim() } } : {}), ...(curatedOAuthClientId.trim() ? { oauthClient: { clientId: curatedOAuthClientId.trim(), ...(curatedOAuthClientSecret ? { clientSecret: curatedOAuthClientSecret } : {}), }, } : {}), configValues: isGoogleSheetsRobotMethod(connectEntry, selectedMethod) ? { allowedSpreadsheetIds: sheetIds } : Object.keys(configValues).length > 0 ? configValues : undefined, applicationId: prefill.applicationId, ...(resumeConnectionId ? { resumeConnectionId } : reconnectConnectionId ? { reconnectConnectionId } : {}), ...(requestedGrantKind !== "organization" ? { grantKind: requestedGrantKind } : {}), ...(requestedGrantKind === "agent" ? { subjectAgentId: [...installAgentIds][0] } : {}), }); } else { const genericPayload = genericConnectPayload({ link: linkUrl, name: linkName, authMode: linkAuthMode, needsKey: linkNeedsKey, keyValue: linkKey, headers: linkHeaders, oauthClientId: linkOAuthClientId, oauthClientSecret: linkOAuthClientSecret, }); const connectionName = connectionNameForGrantKind( genericPayload.name ?? defaultGenericMcpName(linkUrl) ?? "Custom app", effectiveGrantKind, ); result = await toolsApi.connectApp(selectedCompanyId!, { ...genericPayload, ...(reconnectConnectionId ? { reconnectConnectionId } : {}), // Zapier issues a credential-bearing URL, so its branded setup keeps // the compact pasted-URL step. It is still a curated app, though: the // gallery identity must reach the server or Browse can only see a // generic `link` application after setup succeeds. ...(zapierSource || linkMatchedEntry?.slug === "zapier" ? { galleryKey: "zapier", connectionMethodKey: "generated-url", } : {}), name: connectionName, applicationId: prefill.applicationId, ...(effectiveGrantKind !== "organization" ? { grantKind: effectiveGrantKind } : {}), ...(effectiveGrantKind === "agent" ? { subjectAgentId: [...installAgentIds][0] } : {}), }); } // A resumable draft already owns its identity and install reach. Replacing // those choices with this page's defaults would turn "finish setup" into a // silent access change. Fresh connections still persist the Access step // before the browser leaves Paperclip. if (result.auth?.kind === "oauth" && !resumeConnectionId && !reconnectConnectionId) { await applyAccessInstalls(result.connectionId); } return result; }, onSuccess: (result) => { if (result.auth?.kind === "oauth") { setConnectResult(result); // The mutation saves the Access selection before this callback opens // the provider, so the authorization handoff cannot outrun agent reach. // Discovery worked but this authorization server insists on a client the // operator registers themselves. Keep the draft and ask for it in place // rather than sending them back to the start. if (result.auth.manualClientRequired) { setLinkGuidance(genericConnectGuidance("oauth_manual_client_required", null)); setLinkAuthMode("oauth"); setLinkAdvancedOpen(true); setGenericOAuthPending(false); return; } if (host === "dialog" || connectionIntentId) { setOAuthPhase("starting"); setGenericOAuthPending(!entry); startOAuth(result.connection); return; } const startUrl = result.auth.startUrl?.trim(); if (!startUrl) { setOAuthPhase("starting"); setGenericOAuthPending(true); startOAuth(result.connection); return; } setGenericOAuthPending(true); void prepareAndOpenOAuth({ authorizationUrl: startUrl, handoff: result.auth.handoff, }); return; } setLinkGuidance(null); setConnectResult(result); const defaults: Record = {}; for (const a of result.actions.readOnly) defaults[a.catalogEntryId] = true; for (const a of result.actions.canMakeChanges) defaults[a.catalogEntryId] = true; setEnabled(defaults); finishMutation.mutate({ result, enabled: defaults }); }, onError: (error) => { const details = error instanceof ApiError && error.body && typeof error.body === "object" ? (error.body as { details?: { code?: unknown } }).details : null; if (automaticOAuthEntry) { setOAuthPhase("error"); onPhaseChange?.("needs_retry"); setOAuthError( details?.code === "invalid_grant" ? "Your authorization expired or was revoked. Reconnect to continue." : error instanceof Error ? error.message : "Paperclip couldn’t start secure sign-in. Try again.", ); return; } // The generic URL path explains the specific corrective action inline, // beside the fields the operator has to change. A toast can't do that, and // for a pasted address "check your key" is usually the wrong advice. if (!entry && linkUrl) { const code = typeof details?.code === "string" ? details.code : null; const guidance = genericConnectGuidance(code, error instanceof Error ? error.message : null); setLinkGuidance(guidance); setGenericOAuthPending(false); if (guidance.focus === "credentials") setLinkAdvancedOpen(true); return; } pushToast({ title: "Couldn’t connect", body: error instanceof Error ? error.message : "Please check your key and try again.", tone: "error", }); }, }); const mutateConnect = connectMutation.mutate; const connectApp = useCallback((entryOverride?: AppDefinition) => { const connectEntry = entryOverride ?? entry; const method = connectEntry ? availableToolConnectionMethod(connectEntry, connectionMethodKey || null) : null; if (method?.auth === "oauth") { reserveOAuthPopup(); } mutateConnect(entryOverride); }, [connectionMethodKey, entry, mutateConnect, reserveOAuthPopup]); useEffect(() => { if (!requestedAppKey || galleryQuery.isLoading || !galleryQuery.data) return; if (reconnectConnectionId && ( !connectionsQuery.isFetchedAfterMount || !applicationsQuery.isFetchedAfterMount )) return; const requestedEntry = requestedConnectionEntry({ requestedAppKey, galleryApps: galleryQuery.data.apps, reconnectConnection, applications: applicationsQuery.data?.applications ?? [], }); const requestedEntryAdvertisesManagedConnector = Boolean( requestedEntry?.methods.some((candidate) => candidate.oauthStrategy === "paperclip_cloud_connector" || candidate.oauthStrategy === "paperclip_id_connector" ), ); // The enrollment lookup decides whether a hidden managed method means // "enroll this instance" or "that Cloud profile is unavailable here". // Preserve managed sign-in intent in both cases; the setup screen explains // unavailable profiles rather than downgrading to a credential form. if ( requestedDefinitionUsesManagedConnector && !requestedEntryAdvertisesManagedConnector && connectorEnrollmentQuery.isLoading ) return; const methods = connectionMethodsForCredentialSource(requestedEntry, credentialSource); const initialMethod = ( requestedDefinitionUsesManagedConnector && !requestedEntryAdvertisesManagedConnector ? recommendedManagedConnectorMethod(fullRequestedDefinition) : null ) ?? recommendedSetupConnectionMethod(methods); const method = methods.length === 1 ? methods[0]! : null; const automaticOAuth = credentialSource === "paperclip_vault" && Boolean(automaticOAuthMethod(requestedEntry)); const vercelUnavailable = isVercelConnectUnavailable({ credentialSource, available: galleryQuery.data.credentialSources?.vercelConnect.available === true, retainedReconnectMatches: Boolean(reconnectConnectionId && reconnectSourceMatches), }); const unsupportedOAuth = methods.length === 1 && method?.auth === "oauth" && !connectionMethodSupportsAutomaticOAuth(method) && !connectionMethodAcceptsCustomerOAuthClient(method); const definitionUnavailable = isConnectionDefinitionUnavailable({ available: requestedEntry?.availability?.available, reconnectConnectionId, reconnectSourceMatches, }); const setupResolution = requestedConnectionSetupResolution({ reconnectConnectionId, hasRequestedEntry: Boolean(requestedEntry), supportedMethodCount: methods.length, unsupportedOAuth, vercelUnavailable, definitionUnavailable, }); if (setupResolution !== "ready") { setEntry(null); setStep("gallery"); if (setupResolution === "reconnect_unavailable" && reconnectConnectionId) { setUnavailableReconnectId(reconnectConnectionId); return; } navigate(withConnectionIntent( credentialSource === "vercel_connect" ? vercelConnectSourceHref() : "/apps/connect", connectionIntentId, ), { replace: true }); return; } setUnavailableReconnectId(null); if (!requestedEntry) return; if (entry?.slug !== requestedEntry.slug) { setEntry(requestedEntry); setGalleryName(requestedEntry.name); setLinkUrl(""); setLinkName(""); setLinkNeedsKey(false); setLinkKey(""); setCredentials({}); setCuratedOAuthClientId(""); setCuratedOAuthClientSecret(""); setVercelConnector(""); setConnectionMethodKey(initialMethod?.key ?? ""); setConfigValues(defaultMethodConfig(initialMethod)); setGoogleSheetsLinks(""); setGoogleSheetsError(null); setConnectResult(null); const matchingEnrollmentAccess = restoredEnrollmentAccess?.companyId === selectedCompanyId ? restoredEnrollmentAccess : null; setGrantKind(reconnectGrantKind ?? matchingEnrollmentAccess?.grantKind ?? defaultGrantKindFor(initialMethod, Boolean(requestedAgentId))); setInstallAgentIds(new Set( matchingEnrollmentAccess?.agentIds ?? (requestedAgentId ? [requestedAgentId] : []), )); setInstallChoice(matchingEnrollmentAccess?.installChoice ?? (requestedAgentId ? "specific" : "all")); // Route/service selection initializes the wizard once. Later renders must // preserve the user's current step in both hosts instead of snapping back // to Access after they continue. setStep(requestedConnectionInitialStep({ requestedAppKey, routeStage, resumeConnectionId, hasPrefilledLink: Boolean(prefill.link), zapierSource, })); } else if (entryAdvertisesManagedConnector !== requestedEntryAdvertisesManagedConnector) { // A capability refresh must replace the stale gallery entry as well as // its method, while preserving the chosen audience and wizard step. setEntry(requestedEntry); setConnectionMethodKey(initialMethod?.key ?? ""); setConfigValues(defaultMethodConfig(initialMethod)); } if (automaticOAuth && ( !applicationsQuery.isFetchedAfterMount || !connectionsQuery.isFetchedAfterMount )) return; if (automaticOAuth && directOAuthRetryingRef.current) return; if (automaticOAuth && (applicationsQuery.isError || connectionsQuery.isError)) { setOAuthPhase("error"); setOAuthError("Paperclip couldn’t check for an existing connection. Try again."); setStep("key"); return; } }, [ applicationsQuery.isError, applicationsQuery.isFetchedAfterMount, applicationsQuery.data, connectionsQuery.isError, connectionsQuery.isFetchedAfterMount, connectorEnrollmentQuery.data?.configured, connectorEnrollmentQuery.isLoading, connectionMethodKey, credentialSource, entry?.slug, entryAdvertisesManagedConnector, galleryQuery.data, galleryQuery.isLoading, navigate, reconnectGrantKind, reconnectConnection, reconnectConnectionId, reconnectSourceMatches, resumeConnectionId, fullRequestedDefinition, requestedAppKey, requestedAgentId, restoredEnrollmentAccess, routeStage, zapierSource, ]); // Resume the exact method and non-secret provider configuration that the // interrupted draft already chose. Secrets are intentionally never read back // into the browser; credential-based methods ask for a replacement value. useEffect(() => { hydratedResumeConnectionIdRef.current = null; setHydratedResumeConnectionId(null); }, [resumeConnectionId]); useEffect(() => { if ( !resumeConnection || !entry || resumeConnection.status !== "draft" || hydratedResumeConnectionIdRef.current === resumeConnection.id ) return; const storedConfig = resumeConnection.config && typeof resumeConnection.config === "object" ? resumeConnection.config : {}; const storedSource = typeof storedConfig.sourceTemplateKey === "string" ? storedConfig.sourceTemplateKey : null; if (storedSource && storedSource !== entry.slug) return; const storedMethodKey = typeof storedConfig.connectionMethodKey === "string" ? storedConfig.connectionMethodKey : null; const resumedMethod = storedMethodKey ? connectionMethodsForCredentialSource(entry, credentialSource).find( (candidate) => candidate.key === storedMethodKey, ) ?? null : null; setGalleryName(resumeConnection.name || entry.name); if (resumedMethod) { setConnectionMethodKey(resumedMethod.key); const storedMethodConfig = storedConfig.methodConfig && typeof storedConfig.methodConfig === "object" ? storedConfig.methodConfig as Record : {}; setConfigValues({ ...defaultMethodConfig(resumedMethod), ...Object.fromEntries( Object.entries(storedMethodConfig).filter( (entry): entry is [string, string | boolean] => typeof entry[1] === "string" || typeof entry[1] === "boolean", ), ), }); } const storedOAuth = storedConfig.oauth && typeof storedConfig.oauth === "object" ? storedConfig.oauth as Record : null; // Only an operator-owned client belongs in the editable Advanced fields. // Rehydrating a DCR/CIMD client id there would submit it as customer-owned // and corrupt the automatic registration's provenance on the next click. if ( storedOAuth?.clientRegistrationSource === "manual" && typeof storedOAuth.clientId === "string" ) { setCuratedOAuthClientId(storedOAuth.clientId); } // A draft exists only after the first half of setup has been saved. Resume // at the credential/provider checkpoint instead of asking for identity and // agent reach again. Automatic OAuth renders the dedicated one-action // screen; configured methods render their saved setup form with secrets // intentionally blank. if (oauthCallbackOutcome === "failed" || oauthCallbackOutcome === "denied") { setOAuthPhase("error"); setOAuthError(oauthCallbackErrorMessage(oauthCallbackOutcome, oauthCallbackCode)); } else { setOAuthPhase("entry"); setOAuthError(null); } setStep("key"); hydratedResumeConnectionIdRef.current = resumeConnection.id; setHydratedResumeConnectionId(resumeConnection.id); }, [credentialSource, entry, oauthCallbackCode, oauthCallbackOutcome, resumeConnection]); /** * Commit the connection: action defaults, agent reach, and installs. * * Takes the connect result and the enabled set as arguments rather than * reading them from state. The Access step removed the separate who/install * screens, so this now runs in the same tick as the `setConnectResult` / * `setEnabled` that precede it, where that state has not been applied yet. */ const finishMutation = useMutation({ mutationFn: async (input: { result: ConnectToolAppResult; enabled: Record }) => { const { result: connected, enabled: enabledMap } = input; const enabledIds = Object.entries(enabledMap) .filter(([, on]) => on) .map(([id]) => id); const askFirstRiskLevels = new Set( Array.isArray(connected.suggestedDefaults.askFirstRiskLevels) ? connected.suggestedDefaults.askFirstRiskLevels.filter( (riskLevel): riskLevel is string => typeof riskLevel === "string", ) : [], ); const askFirstIds = connected.actions.canMakeChanges .filter((action) => enabledMap[action.catalogEntryId] && askFirstRiskLevels.has(action.riskLevel)) .map((action) => action.catalogEntryId); // The Access step asks one question about agent reach, so profile access // and installs are committed to the same target set instead of drifting // apart behind two separate wizard screens. const selection: AppAccessSelection = installChoice === "all" ? "all_agents" : { agentIds: Array.from(installAgentIds) }; const finished = await toolsApi.finishApp(selectedCompanyId!, connected.connectionId, { enabledCatalogEntryIds: enabledIds, askFirstCatalogEntryIds: askFirstIds, access: selection, ...(requestedAgentId ? { preserveExistingAccess: true } : {}), }); await applyAccessInstalls(connected.connectionId); return finished; }, onSuccess: (_finished, input) => { setAppStep("success"); onComplete?.({ connectionId: input.result.connectionId }); }, onError: (error) => { // Creation must feel transactional: a failed commit returns the operator // to Access with their identity and agent selections intact rather than // stranding them on a half-made connection. setAppStep("access"); pushToast({ title: "Couldn’t finish setup", body: error instanceof Error ? error.message : "Please try again.", tone: "error", }); }, }); if (!selectedCompanyId) { return
Select a company to connect apps.
; } if ( (resumeConnectionId || reconnectConnectionId) && (connectionsQuery.isError || applicationsQuery.isError) ) { return (

Couldn’t load connection setup

Paperclip couldn’t check the retained connection. The retained connection was not changed.

); } if (resumeConnectionId && connectionsQuery.isFetchedAfterMount && !resumeConnection) { return (

This setup can’t be resumed

The saved connection no longer exists or is not available to this company.

); } if ( reconnectConnectionId && connectionsQuery.isFetchedAfterMount && applicationsQuery.isFetchedAfterMount && (!reconnectConnection || !reconnectSourceMatches) ) { return (

This connection can’t be reconnected

{!reconnectConnection ? "The retained connection no longer exists or is not available to this company." : "This reconnect link does not match the retained connection's provider."}

); } if ((resumeConnectionId || reconnectConnectionId) && galleryQuery.isError) { return (

Couldn’t load connection setup

Paperclip couldn’t load the provider details needed to restore this connection. The retained connection was not changed.

); } if (reconnectConnectionId && unavailableReconnectId === reconnectConnectionId) { return (

This connection can’t be reconnected

Paperclip no longer has a supported setup method for this retained connection. The retained connection was not changed.

); } if (resumeConnectionId && ( !connectionsQuery.isFetchedAfterMount || galleryQuery.isLoading || !entry || hydratedResumeConnectionId !== resumeConnection?.id )) { return (
); } if (reconnectConnectionId && ( !connectionsQuery.isFetchedAfterMount || !applicationsQuery.isFetchedAfterMount || galleryQuery.isLoading || Boolean(requestedAppKey && !entry) )) { return (
); } if (showConnectionChoice && onUseExisting) { return (

Use an existing {requestedAppKey ? "connection" : "app connection"}

Reuse a connection without changing who already has access, or connect a new one.

{existingConnections.map((connection) => ( ))}
{existingConnectionError ? ( {existingConnectionError} ) : null}
{onCancel ? : null}
); } const showCuratedOAuthState = Boolean( automaticOAuthEntry && step === "key" && (directOAuthEntry || oauthPhase !== "entry"), ); const managedConnectorUnavailable = Boolean( step === "key" && entry && requestedDefinitionUsesManagedConnector && !entryAdvertisesManagedConnector && connectorEnrollmentQuery.data?.configured === true ); const showConnectorEnrollmentStep = Boolean( step === "key" && entry && requestedDefinitionUsesManagedConnector && !entryAdvertisesManagedConnector && ( connectorEnrollmentQuery.isLoading || connectorEnrollmentQuery.isError || connectorEnrollmentQuery.data?.configured !== true ) ); if (showCuratedOAuthState && automaticOAuthEntry) { return ( { setOAuthError(null); setOAuthPhase("starting"); const connection = connectResult?.connection ?? resumableOAuthConnection; if (connection) { startOAuth(connection); return; } // The create request may have reached the server even when its // response did not reach the browser. Re-read both resources before // creating again so Retry resumes that durable draft instead of // duplicating it. directOAuthRetryingRef.current = true; try { const [applicationsResult, connectionsResult] = await Promise.all([ applicationsQuery.refetch(), connectionsQuery.refetch(), ]); if (applicationsResult.isError || connectionsResult.isError) { setOAuthPhase("error"); setOAuthError("Paperclip couldn’t check for an existing connection. Try again."); return; } const refreshedResumeConnection = resumeConnectionId ? (connectionsResult.data?.connections ?? []).find( (candidate) => candidate.id === resumeConnectionId && candidate.authKind === "oauth", ) ?? null : null; const refreshedConnection = refreshedResumeConnection ?? reusableOAuthConnection( directOAuthSource, applicationsResult.data?.applications ?? [], connectionsResult.data?.connections ?? [], createNewConnection ? { applicationId: prefill.applicationId, draftOnly: true } : {}, ); if (!directOAuthAccessConfirmedRef.current && !resumeConnectionId) { if (refreshedConnection) { setGrantKind( refreshedConnection.credentialPolicy === "per_user" ? "user" : refreshedConnection.credentialPolicy === "per_agent" ? "agent" : "organization", ); } setOAuthPhase("entry"); setOAuthError(null); setStep("access"); return; } if (refreshedConnection) { startOAuth(refreshedConnection); } else { connectMutation.mutate(automaticOAuthEntry); } } finally { directOAuthRetryingRef.current = false; } }} onBack={() => { oauthHandoffAbortRef.current?.abort(); setOAuthPhase("entry"); setOAuthError(null); setAppStep("access"); }} onCancel={() => { oauthHandoffAbortRef.current?.abort(); (onCancel ?? (() => navigate("/apps")))(); }} /> ); } // A pasted endpoint that needs browser sign-in gets the same waiting/retry // screen a curated OAuth app does, minus the branding it doesn't have. if (genericOAuthPending && !entry && linkUrl && step === "key") { return ( { setOAuthError(null); const connection = connectResult?.connection; if (connection) { setOAuthPhase("starting"); startOAuth(connection); return; } // No draft to resume, so fall back to the setup screen rather than // creating a second connection for the same endpoint. setGenericOAuthPending(false); setOAuthPhase("entry"); }} onBack={() => { oauthHandoffAbortRef.current?.abort(); setGenericOAuthPending(false); setOAuthPhase("entry"); setOAuthError(null); }} onCancel={() => { oauthHandoffAbortRef.current?.abort(); (onCancel ?? (() => navigate("/apps")))(); }} /> ); } const appName = connectResult?.application.name ?? entry?.name ?? (linkName.trim() || defaultGenericMcpName(linkUrl) || "this app"); const credentialSourceMethods = connectionMethodsForCredentialSource(entry, credentialSource); const setupCredentialSourceMethods = preEnrollmentManagedMethod ? [preEnrollmentManagedMethod] : credentialSourceMethods; const credentialSourceApps = vercelConnectMode ? visibleGalleryApps.filter( (app) => connectionMethodsForCredentialSource(app, credentialSource).length > 0, ) : visibleGalleryApps; const zapierEntry = zapierSource ? galleryQuery.data?.apps.find((app) => app.slug === "zapier") ?? null : null; const stepLabels = zapierSource ? ZAPIER_STEP_LABELS : entry && setupCredentialSourceMethods.length > 1 ? ["Access", "Choose connection"] : entry && setupCredentialSourceMethods[0]?.auth === "oauth" ? ["Access", "Sign in"] : isGoogleSheetsRobotMethod(entry, connectionMethodKey) ? ["Access", "Share sheet"] : entry ? ["Access", "Add your key"] : STEP_LABELS; // The Access step's identity question only makes sense when there *is* a // credential, so it reads the selected method's auth kind. const accessStepMethod = entry ? (connectionMethodKey ? setupCredentialSourceMethods.find((m) => m.key === connectionMethodKey) ?? null : setupCredentialSourceMethods[0] ?? null) : null; const accessStepAuthKind: ToolConnectionAuthKind = entry ? accessStepMethod?.auth ?? "none" : linkAuthMode === "none" ? "none" : linkAuthMode === "oauth" ? "oauth" : "api_key"; // Name the actual next effect: multi-method apps and enrollment still have // a local setup screen, even when OAuth is already the selected method. const accessContinuesToProvider = Boolean(directOAuthEntry); const accessSubmitLabel = accessContinuesToProvider ? `Continue to ${entry?.name ?? "sign-in"}` : accessStepAuthKind === "oauth" ? "Continue" : "Save and continue"; const stepIndex = (zapierSource || entry) && step !== "gallery" && step !== "success" ? SELECTED_APP_STEP_INDEX[step] : step === "success" ? stepLabels.length : STEP_INDEX[step]; return (
{step !== "success" && ( !(byoOnly && step === "gallery") && ( navigate("/apps"))} /> ) )} {step === "gallery" && ( { const matchedEntry = getAppDefinitionForUrl(url, visibleGalleryApps); setEntry(null); setGalleryName(""); setLinkUrl(url); setLinkName(matchedEntry?.name ?? defaultGenericMcpName(url) ?? ""); setLinkNeedsKey(false); setLinkKey(""); setCredentials({}); setGoogleSheetsLinks(""); setGoogleSheetsError(null); setInstallAgentIds(new Set(requestedAgentId ? [requestedAgentId] : [])); setInstallChoice(requestedAgentId ? "specific" : "all"); setGrantKind(reconnectGrantKind ?? (requestedAgentId ? "user" : "organization")); setStep("access"); }} /> )} {managedConnectorUnavailable && entry ? (

{entry.name} sign-in is unavailable

This instance is connected to Paperclip, but {entry.name} sign-in is not currently available. Try again shortly or contact your instance administrator.

) : step === "key" && entry && showConnectorEnrollmentStep ? (

Connect with Paperclip

You must connect this instance to Paperclip to connect to {entry.name} (you only need to do this once).

{connectorEnrollmentQuery.isError || connectorEnrollmentError ? ( {connectorEnrollmentError ?? "Paperclip couldn’t check Cloud registration. Try again."} ) : null} {enrollmentAuthorizationUrl ? (

Finish authorization in the opened window.{' '} Open authorization in a new tab

) : null}
) : step === "key" && entry ? ( { setConnectionMethodKey(nextMethod?.key ?? ""); if (!reconnectGrantKind) { setGrantKind(defaultGrantKindFor(nextMethod, Boolean(requestedAgentId))); } setCredentials({}); setCuratedOAuthClientId(""); setCuratedOAuthClientSecret(""); setVercelConnector(""); setConfigValues(defaultMethodConfig(nextMethod)); }} configValues={configValues} onConfigChange={setConfigValues} googleSheetsLinks={googleSheetsLinks} googleSheetsError={googleSheetsError} onGoogleSheetsLinksChange={(next) => { setGoogleSheetsLinks(next); setGoogleSheetsError(null); }} submitting={connectMutation.isPending} // Back returns to Access for new, resumed, and reconnected accounts. // Cancel is the separate exit to the connector list. onBack={() => setAppStep("access")} onConnect={() => { if (isGoogleSheetsRobotMethod(entry, connectionMethodKey)) { const parsed = parseGoogleSheetIds(googleSheetsLinks); if (parsed.invalidCount > 0) { setGoogleSheetsError("That doesn't look like a Google Sheets link."); return; } if (parsed.ids.length === 0) { setGoogleSheetsError("Paste at least one Google Sheets link."); return; } } const selectedMethod = availableToolConnectionMethod( entry, connectionMethodKey || null, ); const selectedMethodHasProviderFields = Boolean( selectedMethod?.credentialFields?.length || selectedMethod?.tenantFields?.some((field) => !field.hidden) || selectedMethod?.extensionFields?.some((field) => !field.hidden), ); if ( selectedMethod && connectionMethodSupportsAutomaticOAuth(selectedMethod) && resumableOAuthConnection && !selectedMethodHasProviderFields && !curatedOAuthClientId.trim() ) { directOAuthAccessConfirmedRef.current = true; setOAuthError(null); setOAuthPhase("starting"); startOAuth(resumableOAuthConnection); return; } if (selectedMethod && connectionMethodSupportsAutomaticOAuth(selectedMethod)) { directOAuthAccessConfirmedRef.current = true; setOAuthError(null); setOAuthPhase("starting"); } connectApp(); }} /> ) : null} {step === "key" && !entry && linkUrl && !zapierSource && ( { setLinkNeedsKey(next); if (!next) setLinkKey(""); }} keyValue={linkKey} onKeyChange={setLinkKey} authMode={linkAuthMode} onAuthModeChange={(next) => { setLinkAuthMode(next); setLinkGuidance(null); // Leaving the simple path means the explicit choice governs; drop the // "does it need a key?" answer so the two can't disagree. if (next !== "auto") setLinkNeedsKey(false); if (next !== "bearer" && next !== "auto") setLinkKey(""); }} headers={linkHeaders} onHeadersChange={(next) => { setLinkHeaders(next); setLinkGuidance(null); }} oauthClientId={linkOAuthClientId} onOAuthClientIdChange={setLinkOAuthClientId} oauthClientSecret={linkOAuthClientSecret} onOAuthClientSecretChange={setLinkOAuthClientSecret} advancedOpen={linkAdvancedOpen} onAdvancedOpenChange={setLinkAdvancedOpen} guidance={linkGuidance} matchedEntry={linkMatchedEntry} onUseMatchedEntry={linkMatchedEntry ? () => useMatchedGalleryEntry(linkMatchedEntry) : undefined} submitting={connectMutation.isPending || genericOAuthPending} onBack={() => setStep("access")} onConnect={() => { setLinkGuidance(null); connectMutation.mutate(undefined); }} /> )} {step === "key" && !entry && zapierSource && ( setStep("access")} onConnect={() => connectMutation.mutate(undefined)} /> )} {step === "access" && ( { if (directOAuthEntry) { directOAuthAccessConfirmedRef.current = true; setOAuthError(null); setOAuthPhase("starting"); setAppStep("key"); if (resumableOAuthConnection) startOAuth(resumableOAuthConnection); else connectApp(directOAuthEntry); return; } if (entry) setAppStep("key"); else setStep("key"); }} /> )} {step === "success" && ( navigate("/apps"))} /> )}
); } function StepHeader({ subtitle, step, activeIndex, labels, appIdentity, unverifiedHost, onCancel, }: { subtitle: string; step: Step; activeIndex: number; labels: string[]; appIdentity?: { name: string; logoUrl: string | null; darkLogoUrl?: string | null }; /** * Host of an unknown remote MCP server. Present for the whole generic flow so * the operator can see whose server they are configuring at every step, not * just on the screen where they pasted the address. */ unverifiedHost?: string | null; onCancel: () => void; }) { return (
{appIdentity ? ( ) : null}

{appIdentity ? `Connect ${appIdentity.name}` : "Connect your own MCP server"}

{subtitle}

{unverifiedHost ? : null}
{step !== "gallery" && (
{labels.map((label, i) => (
))}
{labels.join(" · ")}
)}
); } export function OAuthConnectStateScreen({ entry, identity, resuming = false, phase, error, recoveryActions, authorizationHost, authorizationUrl, onRetry, onOpenAuthorization, onBack, onCancel, }: { /** A curated app. Omit for a generic endpoint and pass `identity` instead. */ entry?: AppDefinition | null; /** Identity for an unknown remote MCP server: its own name plus its host. */ identity?: { name: string; unverifiedHost: string | null }; /** This screen is continuing a durable draft rather than creating a new one. */ resuming?: boolean; phase: OAuthConnectPhase; error?: string | null; recoveryActions?: { installationUrl: string | null; managementUrl: string | null }; /** * Host of the authorization page being opened. A valid HTTPS authorization * page can still be a phishing page, so the operator sees exactly which host * they are being handed to (PAP-17099). */ authorizationHost?: string | null; /** Already validated by prepareOAuthNavigation; used for a native browser link. */ authorizationUrl?: string | null; onOpenAuthorization?: () => void; onRetry: () => void; onBack: () => void; onCancel: () => void; }) { const serverName = entry?.name ?? identity?.name ?? "this server"; const unverifiedHost = entry ? null : identity?.unverifiedHost ?? null; const status = phase === "entry" ? { title: resuming ? `Finish connecting ${serverName}` : `Connect ${serverName} to Paperclip`, body: resuming ? `Your connection is saved. Continue in ${serverName} to approve access; its identity and agent access will stay the same.` : `Paperclip will open ${serverName} so you can choose a workspace and approve access.`, } : phase === "starting" ? { title: "Preparing secure sign-in", body: `Paperclip is creating a secure ${serverName} connection.`, } : phase === "redirecting" ? { title: `Opening ${serverName}`, body: authorizationHost ? `Continue at ${authorizationHost} to choose a workspace and approve access. Only approve access if you recognize that address.` : `Continue in ${serverName} to choose a workspace and approve access.`, } : { title: `${serverName} couldn’t connect`, body: error ?? "Paperclip couldn’t start secure sign-in. Try again.", }; return (
{phase === "error" ? ( ) : phase === "entry" ? ( ) : ( )}

{status.title}

{status.body}

{unverifiedHost ? : null}
{phase === "error" && recoveryActions && (recoveryActions.installationUrl || recoveryActions.managementUrl) ? (
{recoveryActions.installationUrl ? ( ) : null} {recoveryActions.managementUrl ? ( ) : null}
) : null}
{phase === "error" || phase === "entry" ? ( ) : ( )} {authorizationUrl ? : null}
); } function ZapierConnectStep({ link, onLinkChange, submitting, onBack, onConnect, }: { link: string; onLinkChange: (next: string) => void; submitting: boolean; onBack: () => void; onConnect: () => void; }) { const normalizedLink = normalizeAppLink(link); const zapierHostname = normalizedLink ? new URL(normalizedLink).hostname : ""; const isZapierLink = zapierHostname === "zapier.com" || zapierHostname.endsWith(".zapier.com"); return (
onLinkChange(event.target.value)} onKeyDown={(event) => { if (event.key === "Enter" && isZapierLink && !submitting) onConnect(); }} placeholder="https://mcp.zapier.com/api/v1/connect?token=…" className="mt-2 h-11" autoFocus /> {link.trim() && !isZapierLink && (

Paste a valid Zapier URL to continue.

)}
); } function GalleryStep({ loading, initialLink = "", apps, byo = false, byoOnly = false, vercelConnect = false, vercelConnectAvailability = null, source = null, onPick, onUseLink, }: { loading: boolean; initialLink?: string; apps: AppDefinition[]; /** Entered via the "Connect your own MCP server" card (PAP-12371, Finding C): focus the link path. */ byo?: boolean; /** Canonical BYO page: keep the focused URL setup without the app gallery. */ byoOnly?: boolean; /** Isolated Vercel catalog: no native-provider or bring-your-own setup paths. */ vercelConnect?: boolean; vercelConnectAvailability?: { available: boolean; manageUrl: string; reason: string | null; } | null; source?: string | null; onPick: (entry: AppDefinition) => void; onUseLink: (link: string) => void; }) { const [search, setSearch] = useState(""); const [linkInput, setLinkInput] = useState(initialLink); const linkInputEdited = useRef(false); useEffect(() => { if (!linkInputEdited.current) setLinkInput(initialLink); }, [initialLink]); const [linkError, setLinkError] = useState(null); const linkSectionRef = useRef(null); const linkInputRef = useRef(null); const linkInputSelectedRef = useRef(false); // Arriving from the BYO card: scroll the URL section into view and select its // input so the operator can paste immediately. useEffect(() => { if (!byo || (loading && !byoOnly) || linkInputSelectedRef.current) return; if (!byoOnly) linkSectionRef.current?.scrollIntoView?.({ block: "center" }); linkInputRef.current?.focus(); linkInputRef.current?.select(); linkInputSelectedRef.current = true; }, [byo, byoOnly, loading]); const filtered = useMemo(() => { const q = search.trim().toLowerCase(); return apps.filter((app) => !q || app.name.toLowerCase().includes(q)); }, [apps, search]); const normalizedLink = normalizeAppLink(linkInput); const matchedEntry = normalizedLink ? getAppDefinitionForUrl(normalizedLink, apps) : null; const zapierSource = source === "zapier"; const continueWithLink = () => { const next = normalizeAppLink(linkInput); if (!next) { setLinkError("Paste a full http or https link."); return; } setLinkError(null); onUseLink(next); }; if (loading && !byoOnly) { return (
{Array.from({ length: 8 }).map((_, i) => ( ))}
); } return (
{vercelConnect ? (

Connect through Vercel

Create and manage the provider connector in Vercel. Paperclip stores its reference and applies agent access, policy, approval, and audit controls here.

{vercelConnectAvailability ? ( ) : null}
{vercelConnectAvailability?.available === false ? ( {vercelConnectAvailability.reason ?? "Vercel Connect is unavailable on this instance."} ) : null}
) : null} {!byoOnly && ( <>
setSearch(e.target.value)} placeholder="Search apps…" className="h-11 pl-9" />
{filtered.map((app) => { const copy = appCopyFor(app.slug, app.description); const methods = availableToolConnectionMethods(app); const oauthBlocked = methods.length === 0 || methods.every((candidate) => candidate.auth === "oauth" && !connectionMethodSupportsAutomaticOAuth(candidate) && !connectionMethodAcceptsCustomerOAuthClient(candidate) ); const unavailable = app.availability?.available === false || (vercelConnect && vercelConnectAvailability?.available !== true); return ( ); })}
{filtered.length === 0 && (
No apps match “{search}”.
)} )} {!vercelConnect ?
{zapierSource ? "Connect Zapier" : byo ? "Connect your own MCP server" : "Connect with a link"}

{zapierSource ? "Paste the complete MCP URL Zapier gives you, including its token." : byo ? "Paste your MCP server’s URL and every discovered tool will be available immediately." : "Paste a setup link from an app that is not listed here."}

{!zapierSource && (

Any remote tool URL works here — including a local MCP server like{" "} http://127.0.0.1:8848/mcp.

)} {matchedEntry && (
This looks like {matchedEntry.name}.
)}
{ linkInputEdited.current = true; setLinkInput(e.target.value); setLinkError(null); }} onKeyDown={(e) => { if (e.key === "Enter") continueWithLink(); }} placeholder={zapierSource ? "https://mcp.zapier.com/api/v1/connect?token=…" : "https://example.com/actions"} className="h-10" />
{linkError &&
{linkError}
}
: null}
); } function normalizeAppLink(value: string): string | null { try { const parsed = new URL(value.trim()); if (parsed.protocol !== "http:" && parsed.protocol !== "https:") return null; return parsed.toString(); } catch { return null; } } /** * The guided universal flow for an unknown remote MCP server (PAP-17087, plan 2). * * The simple path stays exactly as short as it was — URL, name, "does it need a * key?" — because that is all most servers need. Everything protocol-shaped lives * behind "Advanced authentication", and no OAuth/DCR/CIMD jargon appears on the * consumer path: the operator picks how the server authenticates, not which RFC * Paperclip will use to satisfy it. * * The endpoint host and the "Unverified server" label stay visible the whole way * through, so the operator can always see whose server they are about to let * agents call. */ function LinkConnectStep({ link, name, needsKey, onNeedsKeyChange, keyValue, onKeyChange, authMode, onAuthModeChange, headers, onHeadersChange, oauthClientId, onOAuthClientIdChange, oauthClientSecret, onOAuthClientSecretChange, advancedOpen, onAdvancedOpenChange, guidance, matchedEntry, onUseMatchedEntry, submitting, onBack, onConnect, }: { link: string; name: string; needsKey: boolean; onNeedsKeyChange: (next: boolean) => void; keyValue: string; onKeyChange: (next: string) => void; authMode: GenericMcpAuthMode; onAuthModeChange: (next: GenericMcpAuthMode) => void; headers: CustomHeaderRow[]; onHeadersChange: (next: CustomHeaderRow[]) => void; oauthClientId: string; onOAuthClientIdChange: (next: string) => void; oauthClientSecret: string; onOAuthClientSecretChange: (next: string) => void; advancedOpen: boolean; onAdvancedOpenChange: (next: boolean) => void; guidance: GenericConnectGuidance | null; /** A curated app whose endpoint matches, offered as a convenience only. */ matchedEntry?: AppDefinition | null; onUseMatchedEntry?: () => void; submitting: boolean; onBack: () => void; onConnect: () => void; }) { const host = endpointHost(link); const headerError = authMode === "custom_headers" ? customHeaderError(headers) : null; const draft: GenericConnectDraft = { link, name, authMode, needsKey, keyValue, headers, oauthClientId, oauthClientSecret, }; const canSubmit = canSubmitGenericConnect(draft); const showSimpleKeyQuestion = authMode === "auto"; const displayedLink = matchedEntry?.slug === "zapier" ? redactUrlSecrets(link) : link; const updateHeader = (id: string, patch: Partial) => { onHeadersChange(headers.map((row) => (row.id === id ? { ...row, ...patch } : row))); }; return (

{displayedLink}

{matchedEntry && onUseMatchedEntry ? (
Paperclip has a guided setup for {matchedEntry.name}.
) : null} {guidance ? (
{guidance.body}
) : null}
{showSimpleKeyQuestion && (
onNeedsKeyChange(false)} /> onNeedsKeyChange(true)} />

{needsKey ? "Paste the key this app gave you." : "Most servers just work from the address — pick Yes only if the server gave you a key, or if it asks you to sign in."}

)} {(showSimpleKeyQuestion && needsKey) || authMode === "bearer" ? (
onKeyChange(e.target.value)} placeholder="••••••••••••••••" className="mt-2 h-11 font-mono" />
) : null} Advanced authentication

Only needed when the server's docs are specific about how to authenticate.

{GENERIC_AUTH_MODE_OPTIONS.map((option) => ( onAuthModeChange(option.mode)} /> ))}

{GENERIC_AUTH_MODE_OPTIONS.find((option) => option.mode === authMode)?.hint}

{authMode === "custom_headers" ? (
{headers.map((row) => (
updateHeader(row.id, { name: e.target.value })} placeholder="Header name" aria-label="Header name" className="h-10 font-mono" /> updateHeader(row.id, { value: e.target.value })} placeholder="Value" aria-label={row.name.trim() ? `Value for ${row.name.trim()}` : "Header value"} className="h-10 font-mono" />
))} {headerError ?

{headerError}

: null}
) : null} {authMode === "oauth" ? (

Paperclip sets sign-in up on its own whenever the server allows it. Only fill these in when the server's docs tell you to register Paperclip yourself first.

onOAuthClientIdChange(e.target.value)} autoComplete="off" placeholder="Optional" className="mt-2 h-11 font-mono" />
onOAuthClientSecretChange(e.target.value)} placeholder="Optional" className="mt-2 h-11 font-mono" />
) : null}
); } /** * What the operator is choosing is how the *server* authenticates, in its own * terms. Paperclip decides internally whether that means a preconfigured client, * a client ID metadata document, dynamic registration, or the credentials pasted * below — none of which belongs on this screen. */ const GENERIC_AUTH_MODE_OPTIONS: Array<{ mode: GenericMcpAuthMode; label: string; hint: string }> = [ { mode: "auto", label: "Let Paperclip check", hint: "Paperclip asks the server what it needs and walks you through it. Start here.", }, { mode: "none", label: "No sign-in needed", hint: "The server is open to anyone with the address.", }, { mode: "bearer", label: "Key or token", hint: "Paperclip sends your key as an Authorization header.", }, { mode: "custom_headers", label: "Custom headers", hint: "For servers that name their own headers. Values are stored as Paperclip secrets and can\u2019t be read back.", }, { mode: "oauth", label: "Browser sign-in", hint: "You\u2019ll sign in at the provider. Add a client ID and secret only if the provider requires you to register Paperclip first.", }, ]; function SegmentedOption({ label, selected, onClick, }: { label: string; selected: boolean; onClick: () => void; }) { return ( ); } function KeyStep({ entry, error, values, onChange, oauthClientId, onOAuthClientIdChange, oauthClientSecret, onOAuthClientSecretChange, credentialSource, vercelConnector, onVercelConnectorChange, vercelConnectAvailability, methodKey, onMethodChange, configValues, onConfigChange, googleSheetsLinks, googleSheetsError, onGoogleSheetsLinksChange, submitting, onBack, onConnect, }: { entry: AppDefinition; error?: string | null; values: Record; onChange: (next: Record) => void; oauthClientId: string; onOAuthClientIdChange: (next: string) => void; oauthClientSecret: string; onOAuthClientSecretChange: (next: string) => void; credentialSource: ToolConnectionCredentialSource; vercelConnector: string; onVercelConnectorChange: (next: string) => void; vercelConnectAvailability: { available: boolean; manageUrl: string; reason: string | null; } | null; methodKey: string; onMethodChange: (method: ConnectionMethodDef | null) => void; configValues: Record; onConfigChange: (next: Record) => void; googleSheetsLinks: string; googleSheetsError: string | null; onGoogleSheetsLinksChange: (next: string) => void; submitting: boolean; onBack: () => void; onConnect: () => void; }) { const methods = useMemo( () => connectionMethodsForCredentialSource(entry, credentialSource), [credentialSource, entry], ); const method = methodKey ? methods.find((candidate) => candidate.key === methodKey) ?? null : recommendedSetupConnectionMethod(methods); const capabilityGroups = useMemo(() => { if (!methods.some((candidate) => candidate.capabilityProfile)) return []; return Array.from(methods.reduce((groups, candidate) => { const key = candidate.capabilityProfile!.key; const existing = groups.get(key); if (existing) { existing.methods.push(candidate); } else { groups.set(key, { key, label: candidate.capabilityProfile?.label ?? candidate.label ?? candidate.key, description: candidate.capabilityProfile?.description ?? candidate.whenToUse, methods: [candidate], }); } return groups; }, new Map()), ).map(([, group]) => group); }, [methods]); const selectedMethodCapabilityKey = method?.capabilityProfile?.key ?? method?.key ?? ""; const capabilityGroupKeys = capabilityGroups.map((group) => group.key).join("|"); const [capabilityKey, setCapabilityKey] = useState( selectedMethodCapabilityKey || recommendedSetupConnectionMethod(methods)?.capabilityProfile?.key || (capabilityGroups.length === 1 ? capabilityGroups[0]!.key : ""), ); useEffect(() => { if (selectedMethodCapabilityKey) { setCapabilityKey(selectedMethodCapabilityKey); return; } setCapabilityKey((current) => { if (capabilityGroups.some((group) => group.key === current)) return current; return recommendedSetupConnectionMethod(methods)?.capabilityProfile?.key || (capabilityGroups.length === 1 ? capabilityGroups[0]!.key : ""); }); }, [capabilityGroupKeys, capabilityGroups, methods, selectedMethodCapabilityKey]); const capabilityMethods = capabilityGroups.length > 1 ? capabilityGroups.find((group) => group.key === capabilityKey)?.methods ?? [] : methods; const fields = (method?.credentialFields ?? []).map((field) => ({ ...field, configPath: credentialConfigPath(field), helpUrl: method?.consoleLinks?.keys ?? method?.consoleLinks?.docs ?? "", })); const vercelReview = method?.credentialSources?.vercelConnect ?? null; const usingVercel = credentialSource === "vercel_connect"; const allFilled = usingVercel || fields.every( (f) => f.required === false || (values[f.configPath]?.trim().length ?? 0) > 0, ); const acceptsCustomerOAuthClient = connectionMethodAcceptsCustomerOAuthClient(method); const customerOAuthClientRequired = acceptsCustomerOAuthClient && !connectionMethodSupportsAutomaticOAuth(method); const oauthClientFilled = usingVercel || !customerOAuthClientRequired || oauthClientId.trim().length > 0; const vercelConnectorFilled = !usingVercel || vercelConnector.trim().length > 0; const oauthCallbackUrl = method?.auth === "oauth" && acceptsCustomerOAuthClient ? oauthCallbackUrlForBrowser() : null; const allConfigFields = [...(method?.tenantFields ?? []), ...(method?.extensionFields ?? [])]; const configFields = allConfigFields.filter((field) => !field.hidden); const standardConfigFields = configFields.filter((field) => field.advanced !== true); const advancedConfigFields = configFields.filter((field) => field.advanced === true); const [advancedOpen, setAdvancedOpen] = useState(false); const configFilled = allConfigFields.every((field) => { if (!field.required) return true; const value = configValues[field.key]; return typeof value === "boolean" || (typeof value === "string" && value.trim().length > 0); }); const alternativeKeys = method?.configRequirements?.atLeastOneOf ?? []; const configRequirementMet = alternativeKeys.length === 0 || alternativeKeys.some((key) => { const value = configValues[key]; return typeof value === "string" && value.trim().length > 0; }); const hasCapabilitySelection = capabilityGroups.length <= 1 || Boolean(capabilityKey); const hasMethodSelection = hasCapabilitySelection && (capabilityMethods.length <= 1 ? Boolean(method) : Boolean(methodKey)); const robotEmail = entry.availability?.robotEmail ?? null; const unavailable = entry.availability?.available === false; const optionalCustomerOAuthClient = !usingVercel && acceptsCustomerOAuthClient && !customerOAuthClientRequired; const hasAdvancedSettings = advancedConfigFields.length > 0 || optionalCustomerOAuthClient; const capabilitySelection = capabilityGroups.length > 1 ? (
{ const nextGroup = capabilityGroups.find((group) => group.key === nextKey); if (!nextGroup) return; setCapabilityKey(nextKey); onMethodChange(getRecommendedConnectionMethod(nextGroup.methods)); }} options={capabilityGroups.map((group) => ({ value: group.key, title: group.label, description: group.description, }))} /> {!capabilityKey &&

Choose an access level to continue.

}
) : null; const authenticationSelection = capabilityMethods.length > 1 ? (
{ const nextMethod = capabilityMethods.find((candidate) => candidate.key === nextKey); if (nextMethod) onMethodChange(nextMethod); }} options={capabilityMethods.map((candidate) => ({ value: candidate.key, title: candidate.label ?? (candidate.auth === "oauth" ? `Sign in with ${entry.name}` : "Use an API key"), }))} /> {!method &&

Choose a connection method to continue.

}
) : null; if (isGoogleSheetsRobotMethod(entry, method)) { const parsed = parseGoogleSheetIds(googleSheetsLinks); const canConnect = !unavailable && Boolean(robotEmail) && googleSheetsLinks.trim().length > 0; return (
{capabilitySelection} {robotEmail ? (
{robotEmail}

In Google Sheets, click Share and add this email as an Editor. Then paste the sheet links below.

) : (
Google Sheets is not available on this instance yet.
)}