mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-01 23:43:16 +01:00
## Thinking Path > - Paperclip connects agents to external tools through saved accounts and per-action permissions. > - Notebook exposes artifact management through an ordinary remote MCP server with OAuth. > - A private pilot needs a hidden, default-off catalog and execution gate. > - Local file publishing belongs to Notebook, which can accept bounded file bytes through ordinary MCP tools. > - Paperclip therefore needs the connector registration and generic availability checks, with no file-transfer architecture. ## Linked Issues or Issue Description **Subsystem affected** Apps catalog, experimental feature metadata, connection setup, and connector availability. **Problem or motivation** Users need to connect Notebook as their Cloud account, assign its tools to agents, and retain Paperclip's normal Allowed / Ask first / Off rules. The private pilot must remain hidden until an operator enables it. Notebook should own its upload protocol and local publishing script. **Proposed solution** Register a personal remote MCP OAuth connection at Notebook's canonical endpoint. Reuse existing OAuth discovery, credential storage, refresh, reconnect, and removal. Enforce availability through generic catalog feature metadata and saved application identity. Keep Notebook's standalone uploader in its own service repository; it calls the ordinary gateway and receives no saved OAuth credential. **Alternatives considered** A gallery-only filter permits retained tools and deep links while disabled. A shared workspace publisher and private upload-ticket transport add runtime behavior that a normal MCP connector does not require. Notebook can receive file bytes directly through its own tools instead. **Roadmap alignment** This extends Connected Apps and supports Artifacts and Work Products. ROADMAP.md and related PRs were checked. This PR is standalone on master. The separate OAuth lifecycle and file-transfer proposals (#17085 and #17086) are closed and are not dependencies. ## What Changed - Register Notebook's personal MCP OAuth method, canonical endpoint, scopes, catalog metadata, and branding. - Declare managed `enablePaperclipNotebook` with false defaults and no user-facing settings toggle or staging opt-in. - Use generic availability metadata for gallery/setup, retained connection identity, OAuth completion, health, tool/context discovery, and dispatch. Resources/prompts recheck before credentials, including retained handles. Removal remains available while disabled. - Resolve catalog application identity from the saved application key, so editable connection labels cannot bypass the hidden gate. - Select a method's sole supported credential kind when no kind is supplied. - Keep UI changes to metadata-driven availability and existing setup/reconnect controls. - Document Notebook's service-owned upload script, inline publication, ordinary action policies, and credential behavior. - Add ordinary MCP OAuth, assignment, action-policy, disabled-state, settings, and catalog tests. No workspace contribution registry, private transfer endpoint, native publisher, Runner file-reader change, or Paperclip CLI upload command is included. The entire native-runtime and CLI trees match master. Upload code lives in `paperclip-cloud/notebook/`. ## Verification Source head: `65ef741e4f476c67425b57d2fb2d6d4c00e99b06`, directly on master `53732c811bb69655eb43d3d12f2063ae4ca5ad67`. The diff is 42 files. Shared execution changes only enforce generic catalog availability; tool access also resolves saved application identity and the sole supported credential kind. Neither module contains Notebook upload logic. - Catalog/shared/settings/UI suites: 592 tests passed across 12 files. Shared/server/UI typechecks passed using installed compilers. - Fresh real PostgreSQL generic MCP suite: 84/84 passed at final source; server typecheck and module boundaries pass. Coverage includes personal OAuth, ordinary refresh/reconnect, assignments, Allowed / Ask first / Off, hidden feature enforcement, and local credential removal. Four new resources/prompts cases failed against the prior code and pass after filtering saved application identity and rechecking before credentials. They prove missing/relabelled setup markers and stale eligibility cannot dispatch or read secrets while disabled; an ordinary provider remains available with the flag off. - The full tool-access service suite passed 463/464 cases. One unchanged Google Workspace `slides.write` case timed out at 15 seconds; it passed on one isolated retry without source or timeout changes. Its exact test block matches master. The cause is not established; final-head CI must qualify the complete suite. - Maps, module boundaries, token gates, and whitespace checks passed. Byte comparisons confirm the former transfer/runtime/CLI surfaces match master. - Fresh exact-head Greptile is 5/5 with zero unresolved findings after an exhaustive feedback audit. Current-head CI workflow [39625691137](https://github.com/paperclipai/paperclip/actions/runs/39625691137) succeeded, including both required checks (`ci / verify`, `ci / e2e`), Build, and browser E2E. Supplementary security/advisory scan status is recorded in the PR Checks panel and qualification receipt; prior-head scans are not counted. The superseded `4bd7af9b2e` completed CI successfully, but Greptile found the valid resources/prompts availability gap described above. It was fixed, replied to, and resolved; earlier-head results do not qualify this revision. Preserved environment failures: the first socket-restricted UI/route run had 93 listener setup failures, and the same 93 cases passed with local-socket access. The package-manager launcher could not fetch its signature metadata; installed compilers passed without dependency or configuration changes. The full service timeout and isolated retry remain recorded separately. The superseded `585e870e73` CI also failed an unchanged agent-chat project-plan revision assertion after task creation; the complete spec and relevant runtime sources match master, and a same-master prior run passed. Its cause is not established. Logs are preserved; no test or timeout was weakened and no old-head rerun was requested. The subsequent heads run full CI independently; no prior-head pass substitutes for current qualification. The Notebook uploader requires a separate service release. This public PR adds no uploader runtime to Paperclip and claims no newly deployed browser/agent acceptance. The Notebook-owned upload path is qualified separately. Code-owner review remains required before merge. ## Risks - The feature flag gates the curated connector; it does not create a Notebook service-side team allowlist or block arbitrary standard MCP URLs. - Publishing, sharing, and deletion retain the existing assignment and action-policy boundaries. - The hidden feature must be checked for retained tools and saved identity, including when connection labels change. - Disconnect removes local credentials. It does not revoke the remote Notebook authorization; the owner can revoke it in Notebook. - Ordinary refresh uses the existing client. A lost response after refresh-token rotation can require reconnect. - The standalone Notebook script and its bounded upload tools must ship together in the Notebook service before that documented convenience path is available. - No database schema, workflow, lockfile, shared transfer, or Runner change is included. No merge, deployment, or rollout is performed by this update. ## Model Used OpenAI GPT-6 in Codex, with reasoning, repository inspection, code execution, and tool use. The runtime did not expose a more specific model ID or context-window size. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip <noreply@paperclip.ing>
4726 lines
200 KiB
TypeScript
4726 lines
200 KiB
TypeScript
import { StepHeader } from "./ConnectionSetupHeader";
|
||
import { GoogleWorkspaceServicesDialog } from "./GoogleWorkspaceServices";
|
||
import type { GoogleWorkspaceServiceSelection } from "@paperclipai/shared";
|
||
export { StepHeader } from "./ConnectionSetupHeader";
|
||
import { ConnectionInstructionsEditor } from "./ConnectionInstructions";
|
||
import { connectionInstructionsConfig, defaultConnectionAgentInstructions, type ConnectionAgentInstructions } from "@paperclipai/shared";
|
||
import { RemoteMcpProductionSetup } from "./remote-mcp/RemoteMcpProductionSetup";
|
||
import { findAggregatorApp } from "@paperclipai/shared/aggregator-app-catalog";
|
||
import { useMemoryConnectorsEnabled } from "@/hooks/useMemoryConnectorsEnabled";
|
||
import { useConnectorAvailability } from "@/hooks/useConnectorAvailability";
|
||
import { AiConnectionCredentialStep } from "@/components/ai-connections/AiConnectionCredentialStep";
|
||
import { ConnectionChoiceList } from "./ConnectionChoiceList";
|
||
import { useCallback, useEffect, useId, useMemo, useRef, useState, type ReactNode, type Ref } from "react";
|
||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||
import {
|
||
ArrowUpRight,
|
||
Bot,
|
||
Building2,
|
||
Check,
|
||
ChevronDown,
|
||
ChevronRight,
|
||
Cloud,
|
||
Copy,
|
||
Link2,
|
||
Loader2,
|
||
Lock,
|
||
Search,
|
||
UserRound,
|
||
UsersRound,
|
||
} from "lucide-react";
|
||
import type {
|
||
AppDefinition,
|
||
ConnectionGrantKind,
|
||
ConnectionIntentSetupConnection,
|
||
ConnectionMethodDef,
|
||
ConnectToolAppResult,
|
||
FieldDef,
|
||
ToolApplication,
|
||
ToolConnection,
|
||
ToolConnectionAuthKind,
|
||
ToolConnectionCredentialSource,
|
||
ToolConnectionCreateCapabilities,
|
||
ToolOAuthStartResult,
|
||
} from "@paperclipai/shared";
|
||
import {
|
||
aiConnectionMetadataSchema,
|
||
isRemoteMcpConnectorId,
|
||
isMemoryConnectorId,
|
||
isRemoteMcpConnectorMethod,
|
||
connectionMethodAcceptsCustomerOAuthClient,
|
||
connectionMethodRequiresConfiguration,
|
||
connectionMethodSupportsAutomaticOAuth,
|
||
credentialConfigPath,
|
||
getAppDefinitionForUrl,
|
||
getAppDefinitionForApplicationKey,
|
||
getConnectableAppDefinition,
|
||
getAvailableConnectionMethods,
|
||
getRecommendedConnectionMethod,
|
||
isGoogleWorkspaceConnectorProfileId,
|
||
} 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, chatProviderVisible } 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 { AgentMultiSelect, type AgentMultiSelectOption } 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 { useCopyAction } from "@/lib/use-copy-action";
|
||
import { resolveAuthorizationTarget } from "@/lib/authorizationUrl";
|
||
import { navigateTopLevel } from "@/lib/browserNavigation";
|
||
import { prepareOAuthNavigation, savePendingCloudHandoff } from "@/lib/oauthHandoff";
|
||
import { redactUrlSecrets } from "@/lib/redact-url-secrets";
|
||
import { askFirstCatalogEntryIdsFor } from "./connection-defaults";
|
||
import { AppLogo } from "@/pages/apps/AppLogo";
|
||
import { appApplicationSourceSlug, appConnectionSourceSlug } 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" | "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<string, unknown>;
|
||
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<EnrollmentAccessState, "companyId">,
|
||
): 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<Record<Step, string>> = {
|
||
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 "key";
|
||
return input.hasPrefilledLink || input.zapierSource ? "key" : "gallery";
|
||
}
|
||
|
||
export function requestedConnectionEntry(input: {
|
||
requestedAppKey: string;
|
||
galleryApps: readonly AppDefinition[];
|
||
reconnectConnection: ToolConnection | null;
|
||
resumeConnection: ToolConnection | null;
|
||
resumeApplication: ToolApplication | null;
|
||
completedResumeConnectionId: string | null;
|
||
applications: readonly ToolApplication[];
|
||
}): AppDefinition | null {
|
||
const visible = input.galleryApps.find((candidate) => candidate.slug === input.requestedAppKey);
|
||
if (visible) return visible;
|
||
if (retainedResumeMatches({
|
||
requestedAppKey: input.requestedAppKey,
|
||
resumeConnection: input.resumeConnection,
|
||
resumeApplication: input.resumeApplication,
|
||
completedResumeConnectionId: input.completedResumeConnectionId,
|
||
})) return getConnectableAppDefinition(input.requestedAppKey);
|
||
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 retainedResumeMatches(input: {
|
||
requestedAppKey: string;
|
||
resumeConnection: ToolConnection | null;
|
||
resumeApplication: ToolApplication | null;
|
||
completedResumeConnectionId: string | null;
|
||
}): boolean {
|
||
const { requestedAppKey, resumeConnection, resumeApplication, completedResumeConnectionId } = input;
|
||
return Boolean(
|
||
resumeConnection
|
||
&& (
|
||
resumeConnection.status === "draft"
|
||
|| (resumeConnection.status === "active" && resumeConnection.id === completedResumeConnectionId)
|
||
)
|
||
&& resumeApplication
|
||
&& resumeConnection.applicationId === resumeApplication.id
|
||
&& resumeConnection.companyId === resumeApplication.companyId
|
||
&& appApplicationSourceSlug(resumeApplication) === requestedAppKey
|
||
&& appConnectionSourceSlug(resumeConnection) === 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;
|
||
connectionMethodKey?: 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);
|
||
if (existing?.connectionMethodKey) params.set("method", existing.connectionMethodKey);
|
||
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[] };
|
||
|
||
// PAP-659: identity and agent reach are no longer a step. They are resolved to
|
||
// a default, stated in one line above the primary action, and changed either in
|
||
// the Advanced disclosure on this screen or on the Permissions tab afterwards.
|
||
const STEP_LABELS = ["Pick app", "Add your key"];
|
||
const STEP_INDEX: Record<Exclude<Step, "success">, number> = {
|
||
gallery: 0,
|
||
key: 1,
|
||
};
|
||
const SELECTED_APP_STEP_INDEX: Record<Exclude<Step, "gallery" | "success">, number> = {
|
||
key: 0,
|
||
};
|
||
const ZAPIER_STEP_LABELS = ["Add MCP URL"];
|
||
// Waiting for browser sign-in happens *inside* the flow's last step, so the
|
||
// waiting screen reuses the step model of the flow that opened it. It must not
|
||
// append a trailing step the flow never lands on: a stepper that grows from one
|
||
// dot to two the moment you press Connect reads as a step you missed.
|
||
const OAUTH_SIGN_IN_STEP_LABELS = ["Sign in"];
|
||
|
||
/**
|
||
* 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 {
|
||
// A Copilot PAT represents a person's paid runtime account. Sharing it must
|
||
// be an explicit choice, independent of GitHub repository connections.
|
||
if (method?.ai?.provider === "github") return "user";
|
||
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 defaultInstallChoiceFor(method: ConnectionMethodDef | null, requestedAgentId?: string): "specific" | "all" {
|
||
return requestedAgentId || method?.ai?.provider === "github" ? "specific" : "all";
|
||
}
|
||
|
||
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<string, unknown>).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<string, string | boolean> {
|
||
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") !== "channel",
|
||
);
|
||
}
|
||
|
||
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 {
|
||
// PAP-659 C1: every connector arrives with a method already chosen.
|
||
// `getRecommendedConnectionMethod` already ranks managed/one-click OAuth over
|
||
// customer-owned OAuth over API keys, and write/draft capability over read;
|
||
// this used to throw that ranking away for multi-method apps and ask the
|
||
// operator instead. The alternates are still reachable, in the Advanced
|
||
// disclosure, so nothing became unavailable — it just stopped blocking.
|
||
return getRecommendedConnectionMethod(methods);
|
||
}
|
||
|
||
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<MessageEvent, "origin" | "data">,
|
||
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 {
|
||
/** Optional settings composed into the standard credential form above its footer. */
|
||
additionalSettings?: ReactNode;
|
||
/** Provider prerequisites shown before credentials in the standard setup flow. */
|
||
connectionSettings?: ReactNode;
|
||
additionalSettingsValid?: boolean;
|
||
upstreamServiceName?: string;
|
||
aiConnection?: import("@paperclipai/shared").AiConnectionBinding | Pick<import("@paperclipai/shared").AiConnectionBinding, "provider" | "method" | "mode">;
|
||
/** A company service requires a company-owned credential. Existing grants are never broadened. */
|
||
requiredAiOwnership?: "shared";
|
||
/** Provider-specific authentication inside the existing access/setup shell. Undefined retains the standard credential form. */
|
||
renderCredentialStep?: (context: { app: AppDefinition; name: string; grantKind: ConnectionGrantKind; agentIds: string[]; allAgents: boolean; onBack: () => void }) => ReactNode;
|
||
byoOnly?: boolean;
|
||
credentialSource?: ToolConnectionCredentialSource;
|
||
host?: "page" | "dialog";
|
||
serviceSlug?: string;
|
||
requestedAgentId?: string;
|
||
interactionId?: string;
|
||
forceNewConnection?: boolean;
|
||
existingConnections?: ConnectionIntentSetupConnection[];
|
||
configuredConnection?: ConnectionIntentSetupConnection;
|
||
onUseExisting?: (connectionId: string) => Promise<void>;
|
||
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(props: ConnectionSetupFlowProps = {}) {
|
||
const [searchParams] = useSearchParams();
|
||
const params = useParams<{ appKey?: string }>();
|
||
const { selectedCompanyId } = useCompany();
|
||
const memory = useMemoryConnectorsEnabled();
|
||
const availability = useConnectorAvailability();
|
||
const interactionId = props.interactionId || searchParams.get("intent") || undefined;
|
||
const source = (props.serviceSlug || searchParams.get("source") || params.appKey || searchParams.get("appKey"))?.trim() || undefined;
|
||
const draftId = useMemo(() => {
|
||
if (interactionId && isRemoteMcpConnectorId(source)) {
|
||
try { return sessionStorage.getItem(`paperclip:mcp-intent-draft:${selectedCompanyId}:${interactionId}`); } catch { /* Storage may be disabled. */ }
|
||
}
|
||
return null;
|
||
}, [interactionId, selectedCompanyId, source]);
|
||
const existingId = props.configuredConnection?.id || searchParams.get("resume") || searchParams.get("reconnect") || draftId;
|
||
const requestedDefinition = getConnectableAppDefinition(source ?? "");
|
||
const lookup = Boolean(existingId && (!source || isRemoteMcpConnectorId(source) || isMemoryConnectorId(source) || requestedDefinition?.experimentalFeatureKey));
|
||
const existing = useQuery({ queryKey: ["tools", "connection", existingId], queryFn: () => toolsApi.getConnection(existingId!), enabled: lookup });
|
||
const applications = useQuery({ queryKey: queryKeys.tools.applications(selectedCompanyId ?? "__none__"),
|
||
queryFn: () => toolsApi.listApplications(selectedCompanyId!), enabled: Boolean(existingId && selectedCompanyId) });
|
||
const connections = useQuery({ queryKey: queryKeys.tools.connections(selectedCompanyId ?? "__none__"),
|
||
queryFn: () => toolsApi.listConnections(selectedCompanyId!), enabled: Boolean(existingId && selectedCompanyId) });
|
||
const retained = existing.data ?? connections.data?.connections.find(item => item.id === existingId);
|
||
const application = applications.data?.applications.find(item => item.id === (retained?.applicationId ?? props.configuredConnection?.applicationId));
|
||
const persistedDefinition = application?.applicationKey != null
|
||
? getAppDefinitionForApplicationKey(application.applicationKey)
|
||
: getConnectableAppDefinition(String(retained?.config?.sourceTemplateKey ?? ""));
|
||
const gatedDefinitions = [persistedDefinition, requestedDefinition].filter(app => app?.experimentalFeatureKey);
|
||
const provider = source || existing.data?.config?.sourceTemplateKey;
|
||
const method = searchParams.get("method") || existing.data?.config?.connectionMethodKey;
|
||
if (lookup && existing.isPending) return <p className="p-6 text-sm text-muted-foreground">Loading connection…</p>;
|
||
if (lookup && existing.isError) return <div role="alert" className="space-y-3 p-6"><p>Could not load this connection. Your saved access and credentials have not changed.</p><Button variant="outline" onClick={() => void existing.refetch()}>Try again</Button></div>;
|
||
if (gatedDefinitions.length) {
|
||
if (!availability.loaded) return <p role="status" className="p-6 text-sm text-muted-foreground">Loading connection settings…</p>;
|
||
if (availability.failed) return <p role="alert" className="p-6 text-destructive text-sm">Could not load connector availability. Refresh to try again.</p>;
|
||
const disabled = gatedDefinitions.find(app => !availability.enabled(app));
|
||
if (disabled) return <p role="status" className="p-6 text-sm text-muted-foreground">{disabled.name} is not available on this instance.</p>;
|
||
}
|
||
if (isMemoryConnectorId(provider) && !(existing.data && existing.data.status !== "draft" && existing.data.config?.sourceTemplateKey === provider)) {
|
||
if (!memory.loaded) return <p className="p-6 text-sm text-muted-foreground">Loading connection settings…</p>;
|
||
if (!memory.enabled) return <p role="status" className="p-6 text-sm text-muted-foreground">Enable memory connectors in Settings → Experimental to set up this connection.</p>;
|
||
}
|
||
if (!props.byoOnly && (props.credentialSource ?? "paperclip_vault") === "paperclip_vault"
|
||
&& isRemoteMcpConnectorId(provider) && (!method || isRemoteMcpConnectorMethod(provider, method))) {
|
||
const target = findAggregatorApp(provider, searchParams.get("targetToolkit"));
|
||
return <RemoteMcpProductionSetup key={`${interactionId || "page"}:${provider}`} {...props} upstreamServiceName={props.upstreamServiceName ?? target?.name} interactionId={interactionId} providerId={provider} connection={existing.data} />;
|
||
}
|
||
return <StandardConnectionSetupFlow {...props} />;
|
||
}
|
||
|
||
function StandardConnectionSetupFlow({
|
||
additionalSettings,
|
||
connectionSettings,
|
||
additionalSettingsValid = true,
|
||
byoOnly = false,
|
||
credentialSource = "paperclip_vault",
|
||
host = "page",
|
||
serviceSlug,
|
||
requestedAgentId,
|
||
interactionId,
|
||
forceNewConnection = false,
|
||
existingConnections = [],
|
||
configuredConnection,
|
||
onUseExisting,
|
||
onComplete,
|
||
onOAuthDeclined,
|
||
aiConnection,
|
||
requiredAiOwnership,
|
||
onPhaseChange,
|
||
onCancel,
|
||
renderCredentialStep,
|
||
}: ConnectionSetupFlowProps = {}) {
|
||
const workspaceSelectionRef = useRef<GoogleWorkspaceServiceSelection | null>(null);
|
||
const [workspaceConsent, setWorkspaceConsent] = useState<(() => void) | null>(null);
|
||
const [workspaceConnection, setWorkspaceConnection] = useState<ToolConnection | undefined>();
|
||
const [workspaceDraft, setWorkspaceDraft] = useState<GoogleWorkspaceServiceSelection | undefined>();
|
||
const queryClient = useQueryClient();
|
||
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, githubEnabled } = useChatConnectorsEnabled();
|
||
const { enabled: memoryConnectorsEnabled } = useMemoryConnectorsEnabled();
|
||
const connectorAvailability = useConnectorAvailability();
|
||
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 requestedMethodKey = searchParams.get("method")?.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<EnrollmentAccessState | null>(() =>
|
||
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<Step>(() => requestedConnectionInitialStep({
|
||
requestedAppKey,
|
||
routeStage,
|
||
resumeConnectionId,
|
||
hasPrefilledLink: Boolean(prefill.link),
|
||
zapierSource,
|
||
}));
|
||
const [entry, setEntry] = useState<AppDefinition | null>(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<GenericMcpAuthMode>("auto");
|
||
const [linkHeaders, setLinkHeaders] = useState<CustomHeaderRow[]>(() => [newCustomHeaderRow()]);
|
||
const [linkOAuthClientId, setLinkOAuthClientId] = useState("");
|
||
const [linkOAuthClientSecret, setLinkOAuthClientSecret] = useState("");
|
||
const [linkAdvancedOpen, setLinkAdvancedOpen] = useState(false);
|
||
const [linkGuidance, setLinkGuidance] = useState<GenericConnectGuidance | null>(null);
|
||
const [genericOAuthPending, setGenericOAuthPending] = useState(false);
|
||
const [credentials, setCredentials] = useState<Record<string, string>>({});
|
||
const [curatedOAuthClientId, setCuratedOAuthClientId] = useState("");
|
||
const [curatedOAuthClientSecret, setCuratedOAuthClientSecret] = useState("");
|
||
const [vercelConnector, setVercelConnector] = useState("");
|
||
const [connectionMethodKey, setConnectionMethodKey] = useState(aiConnection && aiConnection.mode !== "responsible_user" ? `ai-${aiConnection.method}` : "");
|
||
const [instructionDraft, setInstructionDraft] = useState<{ slug: string; value: ConnectionAgentInstructions } | null>(null);
|
||
const [configValues, setConfigValues] = useState<Record<string, string | boolean>>({});
|
||
const [googleSheetsLinks, setGoogleSheetsLinks] = useState("");
|
||
const [googleSheetsError, setGoogleSheetsError] = useState<string | null>(null);
|
||
const [connectResult, setConnectResult] = useState<ConnectToolAppResult | null>(null);
|
||
const [enabled, setEnabled] = useState<Record<string, boolean>>({});
|
||
const [access, setAccess] = useState<"all" | "specific">("all");
|
||
const [agentIds, setAgentIds] = useState<Set<string>>(new Set());
|
||
const [installAgentIds, setInstallAgentIds] = useState<Set<string>>(
|
||
() => 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<ConnectionGrantKind>(
|
||
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<OAuthConnectPhase>(
|
||
resumingAfterOAuthFailure ? "error" : "entry",
|
||
);
|
||
const [oauthError, setOAuthError] = useState<string | null>(() => {
|
||
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<string | null>(null);
|
||
const directOAuthAccessConfirmedRef = useRef(false);
|
||
const directOAuthRetryingRef = useRef(false);
|
||
// A draft that sign-in already created, to be resumed with an operator's own
|
||
// OAuth client when automatic registration was refused.
|
||
const customerClientResumeRef = useRef<string | null>(null);
|
||
const hydratedResumeConnectionIdRef = useRef<string | null>(null);
|
||
const [hydratedResumeConnectionId, setHydratedResumeConnectionId] = useState<string | null>(null);
|
||
// Finishing a resumed unauthenticated/API-key draft activates that exact row.
|
||
// Permit it through retained-draft validation only after this flow's own
|
||
// finish request succeeds; an initially active resume remains invalid.
|
||
const [completedResumeConnectionId, setCompletedResumeConnectionId] = useState<string | null>(null);
|
||
const oauthPopupRef = useRef<Window | null>(null);
|
||
const [dialogOAuthConnectionId, setDialogOAuthConnectionId] = useState<string | null>(null);
|
||
const [authorizationFallbackUrl, setAuthorizationFallbackUrl] = useState<string | null>(null);
|
||
const oauthHandoffAbortRef = useRef<AbortController | null>(null);
|
||
const [showConnectionChoice, setShowConnectionChoice] = useState(
|
||
existingConnections.length > 0 && Boolean(onUseExisting),
|
||
);
|
||
const [existingConnectionPendingId, setExistingConnectionPendingId] = useState<string | null>(null);
|
||
const [existingConnectionError, setExistingConnectionError] = useState<string | null>(null);
|
||
const [unavailableReconnectId, setUnavailableReconnectId] = useState<string | null>(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<ToolOAuthStartResult, "authorizationUrl" | "handoff">,
|
||
) => {
|
||
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 (host === "page" && !connectionIntentId && credentialSource === "paperclip_vault" && isRemoteMcpConnectorId(picked.slug)) {
|
||
navigate(`/apps/connect?source=${picked.slug}`);
|
||
return;
|
||
}
|
||
if (picked.slug === "zapier") {
|
||
setEntry(null);
|
||
setGalleryName("");
|
||
setLinkUrl("");
|
||
setLinkName("Zapier");
|
||
setLinkNeedsKey(false);
|
||
setLinkKey("");
|
||
resetGenericAuthState();
|
||
setCredentials({});
|
||
setConnectResult(null);
|
||
setStep("key");
|
||
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(defaultInstallChoiceFor(initialMethod, requestedAgentId));
|
||
setGrantKind(reconnectGrantKind ?? defaultGrantKindFor(initialMethod, Boolean(requestedAgentId)));
|
||
setStep("key");
|
||
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!),
|
||
select: useCallback((data: Awaited<ReturnType<typeof toolsApi.listGallery>>) => connectionIntentId || aiConnection ? {
|
||
...data,
|
||
apps: data.apps.map(app => ({ ...app, methods: app.methods.filter(method => aiConnection ? method.ai?.provider === aiConnection.provider && (aiConnection.mode === "responsible_user" || method.ai.method === aiConnection.method) : method.transport !== "runtime_auth") })).filter(app => app.methods.length > 0),
|
||
} : data, [connectionIntentId, aiConnection?.provider, aiConnection?.method, aiConnection?.mode]),
|
||
enabled: !!selectedCompanyId,
|
||
});
|
||
const callbackUrlForSetup = oauthCallbackUrlForBrowser(
|
||
window.location.origin,
|
||
galleryQuery.data?.oauthCallbackUrl,
|
||
);
|
||
// 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) => (memoryConnectorsEnabled || !isMemoryConnectorId(app.slug))
|
||
&& connectorAvailability.enabled(app)).filter((app) =>
|
||
chatProviderVisible(app.slug, chatConnectorsEnabled, githubEnabled) ||
|
||
!app.methods.some((method) => method.purpose === "channel") ||
|
||
appSupportsToolCatalogSetup(app),
|
||
),
|
||
[galleryQuery.data, chatConnectorsEnabled, githubEnabled, memoryConnectorsEnabled, connectorAvailability.enabled],
|
||
);
|
||
const fullRequestedDefinition = requestedAppKey
|
||
? getConnectableAppDefinition(requestedAppKey)
|
||
: null;
|
||
const customOAuthMethod = fullRequestedDefinition?.methods.find((candidate) =>
|
||
connectionMethodAcceptsCustomerOAuthClient(candidate)
|
||
&& !connectionMethodSupportsAutomaticOAuth(candidate),
|
||
);
|
||
const selectedCustomOAuth = Boolean(customOAuthMethod && connectionMethodKey === customOAuthMethod.key);
|
||
const selectedRuntimeAuth = Boolean(fullRequestedDefinition?.methods.some(candidate =>
|
||
candidate.transport === "runtime_auth" && candidate.ai
|
||
&& candidate.key === (connectionMethodKey || requestedMethodKey),
|
||
));
|
||
const requestedDefinitionUsesManagedConnector = !selectedCustomOAuth && !selectedRuntimeAuth && 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<string | null>(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<string | null>(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<string | null>(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 resumeApplication = useMemo(
|
||
() => resumeConnection
|
||
? (applicationsQuery.data?.applications ?? []).find(
|
||
(application) => application.id === resumeConnection.applicationId,
|
||
) ?? null
|
||
: null,
|
||
[applicationsQuery.data, resumeConnection],
|
||
);
|
||
const completedRouteResumeConnectionId = routeStage === "complete"
|
||
&& resumeConnection?.status === "active"
|
||
? resumeConnection.id
|
||
: null;
|
||
const resumeSourceMatches = requestedAppKey
|
||
? retainedResumeMatches({
|
||
requestedAppKey,
|
||
resumeConnection,
|
||
resumeApplication,
|
||
completedResumeConnectionId: completedResumeConnectionId ?? completedRouteResumeConnectionId,
|
||
})
|
||
: false;
|
||
const identityConnection = resumeConnection ?? reconnectConnection;
|
||
useEffect(() => {
|
||
workspaceSelectionRef.current = null;
|
||
setWorkspaceConsent(null);
|
||
setWorkspaceConnection(undefined);
|
||
setWorkspaceDraft(undefined);
|
||
}, [selectedCompanyId, requestedAppKey, identityConnection?.id]);
|
||
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 ?? (requiredAiOwnership === "shared" ? "organization" : null);
|
||
|
||
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, {
|
||
connectionMethodKey: entry.methods.find(method => method.key === connectionMethodKey)?.ai ? connectionMethodKey : undefined,
|
||
resumeConnectionId,
|
||
reconnectConnectionId,
|
||
interactionId: connectionIntentId,
|
||
}));
|
||
}
|
||
};
|
||
|
||
const instructionConnection = resumeConnection ?? reconnectConnection ?? resumableOAuthConnection;
|
||
const instructionTemplate = entry?.agentInstructions;
|
||
const instructionValue = entry && instructionTemplate
|
||
? instructionDraft?.slug === entry.slug ? instructionDraft.value
|
||
: instructionConnection ? instructionConnection.agentInstructions ?? { ...defaultConnectionAgentInstructions(instructionTemplate)!, enabled: false }
|
||
: defaultConnectionAgentInstructions(instructionTemplate)
|
||
: null;
|
||
const instructionsValid = !instructionValue || Boolean(instructionValue.text.trim());
|
||
|
||
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: async (connection: ToolConnection) => {
|
||
// Persist before navigation, including retries that reuse an existing OAuth draft.
|
||
if (instructionValue && JSON.stringify(instructionValue) !== JSON.stringify(connection.agentInstructions)) {
|
||
await toolsApi.updateConnection(connection.id, { agentInstructions: instructionValue });
|
||
}
|
||
return toolsApi.startOAuth(connection.id, {
|
||
...(connection.config?.sourceTemplateKey === "google-workspace" && workspaceSelectionRef.current ? { workspaceServices: workspaceSelectionRef.current } : {}),
|
||
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");
|
||
workspaceSelectionRef.current = null;
|
||
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) => {
|
||
if (connection.config?.sourceTemplateKey === "google-workspace" && !workspaceSelectionRef.current) {
|
||
setWorkspaceConnection(connection);
|
||
setWorkspaceConsent(() => () => {
|
||
onPhaseChange?.("authorizing");
|
||
reserveOAuthPopup();
|
||
if (host === "dialog" && !connectionIntentId) setDialogOAuthConnectionId(connection.id);
|
||
mutateOAuthStart(connection);
|
||
});
|
||
return;
|
||
}
|
||
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<string>() }
|
||
: { 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!, {
|
||
...(connectEntry.slug === "google-workspace" && workspaceSelectionRef.current ? { workspaceServices: workspaceSelectionRef.current } : {}),
|
||
galleryKey: connectEntry.slug,
|
||
...(instructionValue ? { agentInstructions: instructionValue } : {}),
|
||
...(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 ?? customerClientResumeRef.current)
|
||
? { resumeConnectionId: resumeConnectionId ?? customerClientResumeRef.current! }
|
||
: reconnectConnectionId ? { reconnectConnectionId } : {}),
|
||
...(requestedGrantKind !== "organization" ? { grantKind: requestedGrantKind } : {}),
|
||
...(requestedGrantKind === "agent" ? { subjectAgentId: [...installAgentIds][0] } : {}),
|
||
});
|
||
customerClientResumeRef.current = null;
|
||
} 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<string, boolean> = {};
|
||
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 (entry?.slug === "google-workspace") workspaceSelectionRef.current = 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") {
|
||
// The probe is the answer to "does it need a key?", so the field
|
||
// appears now rather than being offered as a guess beforehand.
|
||
setLinkNeedsKey(true);
|
||
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 (connectEntry?.slug === "google-workspace" && !workspaceSelectionRef.current) {
|
||
setWorkspaceConnection(identityConnection ?? undefined);
|
||
setWorkspaceConsent(() => () => {
|
||
reserveOAuthPopup();
|
||
mutateConnect(entryOverride);
|
||
});
|
||
return;
|
||
}
|
||
if (method?.auth === "oauth") {
|
||
reserveOAuthPopup();
|
||
}
|
||
mutateConnect(entryOverride);
|
||
}, [connectionMethodKey, entry, identityConnection, mutateConnect, reserveOAuthPopup]);
|
||
|
||
useEffect(() => {
|
||
if (!requestedAppKey || galleryQuery.isLoading || !galleryQuery.data) return;
|
||
|
||
if ((resumeConnectionId || reconnectConnectionId) && (
|
||
!connectionsQuery.isFetchedAfterMount
|
||
|| !applicationsQuery.isFetchedAfterMount
|
||
)) return;
|
||
|
||
// A resume URL is only a recovery route. Invalid or cross-provider drafts
|
||
// must remain on the blocked recovery screen instead of falling through to
|
||
// fresh setup for a hidden provider.
|
||
if (
|
||
resumeConnectionId
|
||
&& routeStage === "complete"
|
||
&& resumeConnection?.status === "active"
|
||
&& completedResumeConnectionId !== resumeConnectionId
|
||
&& resumeSourceMatches
|
||
) {
|
||
navigate(`/apps/${resumeConnection.id}/permissions`, { replace: true });
|
||
return;
|
||
}
|
||
if (resumeConnectionId && !resumeSourceMatches) return;
|
||
|
||
const requestedEntry = requestedConnectionEntry({
|
||
requestedAppKey,
|
||
galleryApps: galleryQuery.data.apps,
|
||
reconnectConnection,
|
||
resumeConnection,
|
||
resumeApplication,
|
||
completedResumeConnectionId,
|
||
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 requestedAi = aiConnection ?? (reconnectConnection?.connectionPurpose === "ai"
|
||
? aiConnectionMetadataSchema.safeParse(reconnectConnection.config?.ai).data
|
||
: undefined);
|
||
const explicitMethod = methods.find(candidate => requestedAi
|
||
? candidate.ai?.provider === requestedAi.provider && (!("mode" in requestedAi) || requestedAi.mode !== "responsible_user") && candidate.ai.method === requestedAi.method
|
||
: candidate.key === requestedMethodKey);
|
||
const initialMethod = explicitMethod ?? (
|
||
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 ?? defaultInstallChoiceFor(initialMethod, requestedAgentId));
|
||
// 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;
|
||
}
|
||
}, [
|
||
aiConnection,
|
||
requestedMethodKey,
|
||
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,
|
||
resumeConnection,
|
||
resumeApplication,
|
||
resumeSourceMatches,
|
||
completedResumeConnectionId,
|
||
completedRouteResumeConnectionId,
|
||
routeStage,
|
||
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(() => {
|
||
const savedConnection = resumeConnection ?? reconnectConnection;
|
||
if (
|
||
!savedConnection
|
||
|| !entry
|
||
|| hydratedResumeConnectionIdRef.current === savedConnection.id
|
||
) return;
|
||
const storedConfig = connectionInstructionsConfig(savedConnection);
|
||
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(savedConnection.name || entry.name);
|
||
if (resumedMethod) {
|
||
setConnectionMethodKey(resumedMethod.key);
|
||
const storedMethodConfig = storedConfig.methodConfig && typeof storedConfig.methodConfig === "object"
|
||
? storedConfig.methodConfig as Record<string, unknown>
|
||
: {};
|
||
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<string, unknown>
|
||
: 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 = savedConnection.id;
|
||
setHydratedResumeConnectionId(savedConnection.id);
|
||
}, [credentialSource, entry, oauthCallbackCode, oauthCallbackOutcome, resumeConnection, reconnectConnection]);
|
||
|
||
/**
|
||
* 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<string, boolean> }) => {
|
||
const { result: connected, enabled: enabledMap } = input;
|
||
const enabledIds = Object.entries(enabledMap)
|
||
.filter(([, on]) => on)
|
||
.map(([id]) => id);
|
||
const askFirstIds = askFirstCatalogEntryIdsFor(
|
||
connected,
|
||
(catalogEntryId) => Boolean(enabledMap[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,
|
||
...(instructionValue ? { agentInstructions: instructionValue } : {}),
|
||
access: selection,
|
||
...(requestedAgentId ? { preserveExistingAccess: true } : {}),
|
||
});
|
||
await applyAccessInstalls(connected.connectionId);
|
||
return finished;
|
||
},
|
||
onSuccess: async (_finished, input) => {
|
||
if (resumeConnectionId && input.result.connectionId === resumeConnectionId) {
|
||
setCompletedResumeConnectionId(resumeConnectionId);
|
||
}
|
||
await queryClient.invalidateQueries({ queryKey: ["tools"] });
|
||
await queryClient.invalidateQueries({ queryKey: queryKeys.apps.attention(selectedCompanyId!) });
|
||
setAppStep("success");
|
||
onComplete?.({ connectionId: input.result.connectionId });
|
||
},
|
||
onError: (error) => {
|
||
// Creation must feel transactional: a failed commit returns the operator
|
||
// to the connect screen with their identity and agent selections intact
|
||
// rather than stranding them on a half-made connection.
|
||
setAppStep("key");
|
||
pushToast({
|
||
title: "Couldn’t finish setup",
|
||
body: error instanceof Error ? error.message : "Please try again.",
|
||
tone: "error",
|
||
});
|
||
},
|
||
});
|
||
|
||
if (!selectedCompanyId) {
|
||
return <div className="p-6 text-sm text-muted-foreground">Select an organization to connect apps.</div>;
|
||
}
|
||
|
||
if (
|
||
(resumeConnectionId || reconnectConnectionId)
|
||
&& (connectionsQuery.isError || applicationsQuery.isError)
|
||
) {
|
||
return (
|
||
<div className="mx-auto max-w-xl rounded-xl border border-border bg-card p-6">
|
||
<h2 className="text-lg font-semibold text-foreground">Couldn’t load connection setup</h2>
|
||
<p className="mt-2 text-sm text-muted-foreground">
|
||
Paperclip couldn’t check the retained connection. The retained connection was not changed.
|
||
</p>
|
||
<div className="mt-5 flex flex-wrap gap-2">
|
||
<Button
|
||
type="button"
|
||
onClick={async () => {
|
||
const [connectionsResult, applicationsResult] = await Promise.all([
|
||
connectionsQuery.refetch(),
|
||
applicationsQuery.refetch(),
|
||
]);
|
||
if (!connectionsResult.isError && !applicationsResult.isError) {
|
||
setOAuthError(null);
|
||
setOAuthPhase("entry");
|
||
}
|
||
}}
|
||
>
|
||
Try again
|
||
</Button>
|
||
<Button type="button" variant="outline" onClick={() => navigate("/apps")}>
|
||
Back to apps
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
if (resumeConnectionId && connectionsQuery.isFetchedAfterMount && !resumeConnection) {
|
||
return (
|
||
<div className="mx-auto max-w-xl rounded-xl border border-border bg-card p-6">
|
||
<h2 className="text-lg font-semibold text-foreground">This setup can’t be resumed</h2>
|
||
<p className="mt-2 text-sm text-muted-foreground">
|
||
The saved connection no longer exists or is not available to this organization.
|
||
</p>
|
||
<Button type="button" variant="outline" className="mt-5" onClick={() => navigate("/apps")}>
|
||
Back to apps
|
||
</Button>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
if (
|
||
resumeConnectionId
|
||
&& connectionsQuery.isFetchedAfterMount
|
||
&& applicationsQuery.isFetchedAfterMount
|
||
&& resumeConnection
|
||
&& !resumeSourceMatches
|
||
) {
|
||
return (
|
||
<div className="mx-auto max-w-xl rounded-xl border border-border bg-card p-6">
|
||
<h2 className="text-lg font-semibold text-foreground">This setup can’t be resumed</h2>
|
||
<p className="mt-2 text-sm text-muted-foreground">
|
||
This saved draft does not match the requested provider. The connection was not changed.
|
||
</p>
|
||
<Button type="button" variant="outline" className="mt-5" onClick={() => navigate("/apps")}>
|
||
Back to apps
|
||
</Button>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
if (
|
||
reconnectConnectionId
|
||
&& connectionsQuery.isFetchedAfterMount
|
||
&& applicationsQuery.isFetchedAfterMount
|
||
&& (!reconnectConnection || !reconnectSourceMatches)
|
||
) {
|
||
return (
|
||
<div className="mx-auto max-w-xl rounded-xl border border-border bg-card p-6">
|
||
<h2 className="text-lg font-semibold text-foreground">This connection can’t be reconnected</h2>
|
||
<p className="mt-2 text-sm text-muted-foreground">
|
||
{!reconnectConnection
|
||
? "The retained connection no longer exists or is not available to this organization."
|
||
: "This reconnect link does not match the retained connection's provider."}
|
||
</p>
|
||
<Button type="button" variant="outline" className="mt-5" onClick={() => navigate("/apps")}>
|
||
Back to apps
|
||
</Button>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
if ((resumeConnectionId || reconnectConnectionId) && galleryQuery.isError) {
|
||
return (
|
||
<div className="mx-auto max-w-xl rounded-xl border border-border bg-card p-6">
|
||
<h2 className="text-lg font-semibold text-foreground">Couldn’t load connection setup</h2>
|
||
<p className="mt-2 text-sm text-muted-foreground">
|
||
Paperclip couldn’t load the provider details needed to restore this connection. The retained connection was not changed.
|
||
</p>
|
||
<div className="mt-5 flex flex-wrap gap-2">
|
||
<Button type="button" onClick={() => void galleryQuery.refetch()}>
|
||
Try again
|
||
</Button>
|
||
<Button type="button" variant="outline" onClick={() => navigate("/apps")}>
|
||
Back to apps
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
if (reconnectConnectionId && unavailableReconnectId === reconnectConnectionId) {
|
||
return (
|
||
<div className="mx-auto max-w-xl rounded-xl border border-border bg-card p-6">
|
||
<h2 className="text-lg font-semibold text-foreground">This connection can’t be reconnected</h2>
|
||
<p className="mt-2 text-sm text-muted-foreground">
|
||
Paperclip no longer has a supported setup method for this retained connection. The retained connection was not changed.
|
||
</p>
|
||
<Button type="button" variant="outline" className="mt-5" onClick={() => navigate("/apps")}>
|
||
Back to apps
|
||
</Button>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
if (resumeConnectionId && (
|
||
!connectionsQuery.isFetchedAfterMount
|
||
|| galleryQuery.isLoading
|
||
|| !entry
|
||
|| hydratedResumeConnectionId !== resumeConnection?.id
|
||
)) {
|
||
return (
|
||
<div className="mx-auto max-w-xl space-y-4" aria-label="Loading saved connection setup">
|
||
<Skeleton className="h-8 w-48" />
|
||
<Skeleton className="h-40 w-full rounded-xl" />
|
||
</div>
|
||
);
|
||
}
|
||
|
||
if (reconnectConnectionId && (
|
||
!connectionsQuery.isFetchedAfterMount
|
||
|| !applicationsQuery.isFetchedAfterMount
|
||
|| galleryQuery.isLoading
|
||
|| Boolean(requestedAppKey && !entry)
|
||
)) {
|
||
return (
|
||
<div className="mx-auto max-w-xl space-y-4" aria-label="Loading retained connection setup">
|
||
<Skeleton className="h-8 w-48" />
|
||
<Skeleton className="h-40 w-full rounded-xl" />
|
||
</div>
|
||
);
|
||
}
|
||
|
||
if (showConnectionChoice && onUseExisting) {
|
||
return (
|
||
<div className="max-w-5xl" data-testid="connection-existing-choice">
|
||
<div className="mb-6">
|
||
<h1 className="text-2xl font-bold tracking-tight">
|
||
Use an existing {requestedAppKey ? "connection" : "app connection"}
|
||
</h1>
|
||
<p className="mt-1 text-sm text-muted-foreground">
|
||
Reuse a connection without changing who already has access, or connect a new one.
|
||
</p>
|
||
</div>
|
||
<ConnectionChoiceList
|
||
choices={existingConnections.map((connection) => ({
|
||
id: connection.id, name: connection.name,
|
||
description: connection.status === "active" && connection.enabled ? "Ready to use" : "Setup needs attention",
|
||
}))}
|
||
pendingId={existingConnectionPendingId}
|
||
onSelect={async (id) => {
|
||
setExistingConnectionPendingId(id);
|
||
setExistingConnectionError(null);
|
||
try { await onUseExisting(id); }
|
||
catch (error) {
|
||
setExistingConnectionError(error instanceof Error ? error.message : "Couldn’t use this connection.");
|
||
setExistingConnectionPendingId(null);
|
||
}
|
||
}}
|
||
/>
|
||
{existingConnectionError ? (
|
||
<InlineBanner tone="danger" className="mt-4">{existingConnectionError}</InlineBanner>
|
||
) : null}
|
||
<div className="mt-5 flex flex-wrap gap-2">
|
||
<Button type="button" variant="outline" onClick={() => setShowConnectionChoice(false)}>
|
||
{configuredConnection ? "Review connection setup" : "Connect new"}
|
||
</Button>
|
||
{onCancel ? <Button type="button" variant="ghost" onClick={onCancel}>Cancel</Button> : null}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
const credentialSourceMethods = connectionMethodsForCredentialSource(entry, credentialSource);
|
||
const setupCredentialSourceMethods = preEnrollmentManagedMethod
|
||
? [preEnrollmentManagedMethod]
|
||
: credentialSourceMethods;
|
||
|
||
// The stated default's identity line 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";
|
||
// PAP-659 C0: the resolved default is stated, not asked. `Change` opens the
|
||
// same controls the deleted Access step owned, inline and never blocking.
|
||
const renderConnectionDefaults = step === "key" ? (
|
||
(extra?: ReactNode, forceOpen?: boolean) => (
|
||
<>
|
||
<ConnectionAccessDefaults
|
||
key="connection-defaults"
|
||
extra={extra}
|
||
forceOpen={forceOpen}
|
||
companyId={selectedCompanyId}
|
||
sentence={connectionDefaultSummarySentence({
|
||
grantKind: effectiveGrantKind,
|
||
authKind: accessStepAuthKind,
|
||
installChoice,
|
||
installCount: installAgentIds.size,
|
||
lockedAgentId: requestedAgentId ?? null,
|
||
preserveAgentAccess: Boolean(automaticOAuthEntry && (resumableOAuthConnection || reconnectConnection)),
|
||
})}
|
||
notice={[
|
||
galleryQuery.data?.capabilities?.canCreateOrganizationGrant === false
|
||
? galleryQuery.data.capabilities.organizationGrantReason
|
||
?? "Only a connection manager can share this credential with the organization."
|
||
: null,
|
||
galleryQuery.data?.capabilities?.canSetCompanyInstall === false
|
||
? galleryQuery.data.capabilities.companyInstallReason
|
||
?? "Only someone who can configure this connection can give every agent access."
|
||
: null,
|
||
requestedAgentId ? "This task grants access only to the agent that asked for it." : null,
|
||
].filter((reason): reason is string => Boolean(reason))}
|
||
authKind={accessStepAuthKind}
|
||
grantKinds={fixedGrantKind ? [fixedGrantKind] : accessStepMethod?.grantKinds}
|
||
grantKind={effectiveGrantKind}
|
||
setGrantKind={setGrantKind}
|
||
installChoice={installChoice}
|
||
setInstallChoice={setInstallChoice}
|
||
installAgentIds={installAgentIds}
|
||
setInstallAgentIds={setInstallAgentIds}
|
||
lockedAgentId={requestedAgentId}
|
||
capabilities={galleryQuery.data?.capabilities}
|
||
githubIdentity={entry?.slug === "github"}
|
||
identityLoading={Boolean(automaticOAuthEntry) && directOAuthLookupPending}
|
||
preserveAgentAccess={Boolean((automaticOAuthEntry && (resumableOAuthConnection || reconnectConnection)) || reconnectConnection?.connectionPurpose === "ai")}
|
||
disabled={connectMutation.isPending || oauthStartMutation.isPending}
|
||
/>
|
||
{entry && instructionTemplate && instructionValue && <div className="mt-6"><ConnectionInstructionsEditor provider={entry.name} template={instructionTemplate} value={instructionValue} onChange={(value) => setInstructionDraft({ slug: entry.slug, value })} disabled={connectMutation.isPending || oauthStartMutation.isPending} /></div>}
|
||
{additionalSettings && <div className="mt-6">{additionalSettings}</div>}
|
||
</>
|
||
)
|
||
) : null;
|
||
// A provider can advertise registration and still refuse this deployment's
|
||
// callback. When the method also accepts an
|
||
// operator's own OAuth client, that client is the recovery path, so it sits
|
||
// in the same Advanced panel and opens itself once sign-in has failed.
|
||
const automaticCustomerClientMethod = automaticOAuthEntry
|
||
&& entryAutomaticOAuthMethod
|
||
&& connectionMethodAcceptsCustomerOAuthClient(entryAutomaticOAuthMethod)
|
||
? entryAutomaticOAuthMethod
|
||
: null;
|
||
const automaticCustomerClientFields = automaticCustomerClientMethod && automaticOAuthEntry ? (
|
||
<OAuthClientFields
|
||
entry={automaticOAuthEntry}
|
||
method={automaticCustomerClientMethod}
|
||
callbackUrl={callbackUrlForSetup}
|
||
clientId={curatedOAuthClientId}
|
||
onClientIdChange={setCuratedOAuthClientId}
|
||
clientSecret={curatedOAuthClientSecret}
|
||
onClientSecretChange={setCuratedOAuthClientSecret}
|
||
required={false}
|
||
/>
|
||
) : null;
|
||
const connectionDefaults = renderConnectionDefaults?.() ?? null;
|
||
const curatedOAuthDefaults = automaticCustomerClientFields
|
||
? renderConnectionDefaults?.(
|
||
automaticCustomerClientFields,
|
||
oauthPhase === "error" || curatedOAuthClientId.trim().length > 0,
|
||
) ?? null
|
||
: connectionDefaults;
|
||
|
||
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 (workspaceConsent) return <GoogleWorkspaceServicesDialog
|
||
connection={workspaceConnection}
|
||
initial={workspaceDraft}
|
||
onCancel={selection => { if (selection) setWorkspaceDraft(selection); setWorkspaceConsent(null); setOAuthPhase("entry"); onPhaseChange?.("requested"); }}
|
||
onContinue={selection => {
|
||
workspaceSelectionRef.current = selection;
|
||
setWorkspaceDraft(selection);
|
||
const proceed = workspaceConsent;
|
||
setWorkspaceConsent(null);
|
||
proceed();
|
||
}}
|
||
/>;
|
||
|
||
if (showCuratedOAuthState && automaticOAuthEntry) {
|
||
return (
|
||
<OAuthConnectStateScreen
|
||
entry={automaticOAuthEntry}
|
||
resuming={Boolean(resumeConnectionId)}
|
||
phase={oauthPhase}
|
||
error={oauthError}
|
||
recoveryActions={oauthCallbackCode === "github_installation_required" ? {
|
||
installationUrl: githubInstallationUrl,
|
||
managementUrl: githubManagementUrl,
|
||
} : undefined}
|
||
authorizationHost={authorizationHost}
|
||
authorizationUrl={authorizationFallbackUrl}
|
||
guidance={entry?.slug === "railway" && accessStepMethod ? (
|
||
<div className="space-y-3 text-sm text-muted-foreground">
|
||
<p>{accessStepMethod.guidanceMd}</p>
|
||
<ul className="list-disc space-y-2 pl-5">
|
||
{accessStepMethod.warnings?.map((warning) => <li key={warning}>{warning}</li>)}
|
||
</ul>
|
||
</div>
|
||
) : null}
|
||
defaults={curatedOAuthDefaults}
|
||
settingsValid={additionalSettingsValid && instructionsValid}
|
||
onOpenAuthorization={openAuthorizationTab}
|
||
onRetry={async () => {
|
||
const firstAttempt = !directOAuthAccessConfirmedRef.current;
|
||
directOAuthAccessConfirmedRef.current = true;
|
||
setOAuthError(null);
|
||
setOAuthPhase("starting");
|
||
const connection = connectResult?.connection ?? resumableOAuthConnection;
|
||
if (connection && automaticCustomerClientFields && curatedOAuthClientId.trim()) {
|
||
// The draft exists but its client must change: resume it through
|
||
// connect so the operator's client replaces the refused registration.
|
||
customerClientResumeRef.current = connection.id;
|
||
connectApp(automaticOAuthEntry);
|
||
return;
|
||
}
|
||
if (connection) {
|
||
startOAuth(connection);
|
||
return;
|
||
}
|
||
if (
|
||
firstAttempt
|
||
&& !resumeConnectionId
|
||
&& !applicationsQuery.isError
|
||
&& !connectionsQuery.isError
|
||
) {
|
||
connectApp(automaticOAuthEntry);
|
||
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 (refreshedConnection && !resumeConnectionId) {
|
||
// Adopt the durable draft's identity before resuming it, so the
|
||
// stated default on screen matches what is about to be authorized.
|
||
setGrantKind(
|
||
refreshedConnection.credentialPolicy === "per_user"
|
||
? "user"
|
||
: refreshedConnection.credentialPolicy === "per_agent"
|
||
? "agent"
|
||
: "organization",
|
||
);
|
||
}
|
||
if (refreshedConnection) {
|
||
startOAuth(refreshedConnection);
|
||
} else {
|
||
connectMutation.mutate(automaticOAuthEntry);
|
||
}
|
||
} finally {
|
||
directOAuthRetryingRef.current = false;
|
||
}
|
||
}}
|
||
onBack={() => {
|
||
oauthHandoffAbortRef.current?.abort();
|
||
setOAuthPhase("entry");
|
||
setOAuthError(null);
|
||
backToGallery();
|
||
}}
|
||
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 (
|
||
<OAuthConnectStateScreen
|
||
identity={{
|
||
name: linkName.trim() || endpointHost(linkUrl) || "this server",
|
||
unverifiedHost: endpointHost(linkUrl),
|
||
}}
|
||
// A pasted endpoint walks the generic three-step wizard, so keep that
|
||
// model rather than dropping to the curated two-step one.
|
||
steps={{ labels: STEP_LABELS, activeIndex: STEP_INDEX.key }}
|
||
phase={oauthPhase}
|
||
error={oauthError}
|
||
authorizationHost={authorizationHost}
|
||
authorizationUrl={authorizationFallbackUrl}
|
||
defaults={connectionDefaults}
|
||
onOpenAuthorization={openAuthorizationTab}
|
||
onRetry={() => {
|
||
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 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 reconnectAiMethod = reconnectConnection?.connectionPurpose === "ai"
|
||
? aiConnectionMetadataSchema.safeParse(reconnectConnection.config?.ai).data
|
||
: undefined;
|
||
const aiMethod = reconnectAiMethod ?? entry?.methods.find(method => method.key === connectionMethodKey)?.ai
|
||
?? (!connectionMethodKey && entry?.methods.every(method => method.ai) ? entry.methods[0]?.ai : undefined);
|
||
const credentialStep = entry ? renderCredentialStep?.({ app: entry, name: galleryName || entry.name, grantKind: effectiveGrantKind, agentIds: [...installAgentIds], allAgents: installChoice === "all", onBack: () => backToGallery() }) ?? (aiMethod && selectedCompanyId ? <><AiConnectionCredentialStep
|
||
defaults={reconnectConnection ? undefined : connectionDefaults}
|
||
companyId={selectedCompanyId} provider={aiMethod.provider} fixedMethod={Boolean(aiConnection && aiConnection.mode !== "responsible_user")} initialMethod={reconnectConnection?.connectionPurpose === "ai" ? (reconnectConnection.config?.ai as { method: "subscription" | "api_key" }).method : aiMethod.method}
|
||
connectionId={reconnectConnection?.connectionPurpose === "ai" ? reconnectConnection.id : undefined}
|
||
name={reconnectConnection?.connectionPurpose === "ai" ? reconnectConnection.name : galleryName || `My ${entry.name} ${aiMethod.method === "subscription" ? "subscription" : "API"}`}
|
||
ownership={(reconnectConnection?.connectionPurpose === "ai" ? reconnectConnection.credentialPolicy === "shared" : effectiveGrantKind === "organization") ? "shared" : "personal"}
|
||
agentIds={[...installAgentIds]} allAgents={installChoice === "all"}
|
||
onCancel={() => onCancel ? onCancel() : navigate("/apps")}
|
||
onComplete={result => { onComplete?.({ connectionId: result.connectionId }); if (!onComplete) navigate(`/apps/${result.connectionId}/permissions`); }}
|
||
/></> : undefined) : undefined;
|
||
// One screen per connector (PAP-659): a selected app has a single step, so
|
||
// its stepper collapses to nothing rather than showing one lonely dot.
|
||
const stepLabels = reconnectConnection?.connectionPurpose === "ai" ? ["Reconnect account"] : credentialStep !== undefined
|
||
? ["Connect account"]
|
||
: zapierSource
|
||
? ZAPIER_STEP_LABELS
|
||
: entry && setupCredentialSourceMethods.length > 1
|
||
? ["Choose connection"]
|
||
: entry && setupCredentialSourceMethods[0]?.auth === "oauth"
|
||
? ["Sign in"]
|
||
: isGoogleSheetsRobotMethod(entry, connectionMethodKey)
|
||
? ["Share sheet"]
|
||
: entry
|
||
? ["Add your key"]
|
||
: STEP_LABELS;
|
||
const stepIndex = reconnectConnection?.connectionPurpose === "ai" ? 0 : (zapierSource || entry) && step !== "gallery" && step !== "success"
|
||
? SELECTED_APP_STEP_INDEX[step]
|
||
: step === "success"
|
||
? stepLabels.length
|
||
: STEP_INDEX[step];
|
||
|
||
return (
|
||
<div className="max-w-5xl">
|
||
{step !== "success" && (
|
||
!(byoOnly && step === "gallery") && (
|
||
<StepHeader
|
||
subtitle={
|
||
step === "gallery"
|
||
? vercelConnectMode
|
||
? "Choose a reviewed app to connect through Vercel."
|
||
: "Pick the app you want your agents to use."
|
||
: stepLabels.length <= 1
|
||
? undefined
|
||
: `Step ${stepIndex + 1} of ${stepLabels.length}`
|
||
}
|
||
step={step}
|
||
activeIndex={stepIndex}
|
||
labels={stepLabels}
|
||
appIdentity={
|
||
zapierSource
|
||
? { name: "Zapier", logoUrl: zapierEntry?.branding.logoUrl ?? null, darkLogoUrl: zapierEntry?.branding.darkLogoUrl ?? null }
|
||
: entry && step !== "gallery"
|
||
? { name: aiMethod?.provider === "github" ? "GitHub Copilot" : entry.name, logoUrl: entry.branding.logoUrl, darkLogoUrl: entry.branding.darkLogoUrl ?? null }
|
||
: undefined
|
||
}
|
||
unverifiedHost={!entry && !zapierSource && step !== "gallery" ? endpointHost(linkUrl) : null}
|
||
onCancel={onCancel ?? (() => navigate("/apps"))}
|
||
/>
|
||
)
|
||
)}
|
||
|
||
{step === "key" && connectionSettings && <div className="mx-auto mb-6 max-w-xl">{connectionSettings}</div>}
|
||
|
||
{step === "gallery" && (
|
||
<GalleryStep
|
||
loading={galleryQuery.isLoading}
|
||
initialLink={linkUrl}
|
||
apps={credentialSourceApps}
|
||
vercelConnect={vercelConnectMode}
|
||
vercelConnectAvailability={galleryQuery.data?.credentialSources?.vercelConnect ?? null}
|
||
byo={byo}
|
||
byoOnly={byoOnly}
|
||
source={searchParams.get("source")}
|
||
onPick={useMatchedGalleryEntry}
|
||
onUseLink={(url) => {
|
||
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("key");
|
||
}}
|
||
/>
|
||
)}
|
||
|
||
{managedConnectorUnavailable && entry ? (
|
||
<div className="mx-auto max-w-xl rounded-xl border border-border bg-card p-6">
|
||
<h2 className="text-lg font-semibold text-foreground">{entry.name} sign-in is unavailable</h2>
|
||
<p className="mt-2 text-sm text-muted-foreground">
|
||
This instance is connected to Paperclip, but {entry.name} sign-in is not currently available. Try again shortly or contact your instance administrator.
|
||
</p>
|
||
{customOAuthMethod ? (
|
||
<Button type="button" variant="link" className="mt-4 h-auto p-0 text-xs" onClick={() => setConnectionMethodKey(customOAuthMethod.key)}>
|
||
Use your own {entry.name} OAuth app
|
||
</Button>
|
||
) : null}
|
||
<div className="mt-6 flex items-center justify-between gap-3">
|
||
<Button type="button" variant="ghost" onClick={backToGallery}>Back</Button>
|
||
<Button type="button" disabled={galleryQuery.isFetching} onClick={() => void galleryQuery.refetch()}>
|
||
{galleryQuery.isFetching ? <Loader2 className="h-4 w-4 animate-spin" /> : null}
|
||
Try again
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
) : step === "key" && entry && showConnectorEnrollmentStep ? (
|
||
<div className="mx-auto max-w-xl">
|
||
<div className="rounded-xl border border-border bg-card p-6">
|
||
<div className="flex items-start gap-3">
|
||
<div className="rounded-lg bg-muted p-2 text-muted-foreground">
|
||
<Cloud className="h-5 w-5" />
|
||
</div>
|
||
<div className="min-w-0">
|
||
<h2 className="text-lg font-semibold text-foreground">
|
||
Connect with Paperclip
|
||
</h2>
|
||
<p className="mt-2 text-sm text-muted-foreground">
|
||
You must connect this instance to Paperclip to connect to {entry.name} (you only need to do this once).
|
||
</p>
|
||
</div>
|
||
</div>
|
||
|
||
{customOAuthMethod ? (
|
||
<Button type="button" variant="link" className="mt-4 h-auto p-0 text-xs" onClick={() => setConnectionMethodKey(customOAuthMethod.key)}>
|
||
Use your own {entry.name} OAuth app
|
||
</Button>
|
||
) : null}
|
||
|
||
{connectorEnrollmentQuery.isError || connectorEnrollmentError ? (
|
||
<InlineBanner tone="danger" className="mt-4">
|
||
{connectorEnrollmentError ?? "Paperclip couldn’t check Cloud registration. Try again."}
|
||
</InlineBanner>
|
||
) : null}
|
||
|
||
{enrollmentAuthorizationUrl ? (
|
||
<p className="mt-4 text-sm text-muted-foreground">
|
||
Finish authorization in the opened window.{' '}
|
||
<a className="underline" href={enrollmentAuthorizationUrl} target="_blank" rel="noopener noreferrer">
|
||
Open authorization in a new tab
|
||
</a>
|
||
</p>
|
||
) : null}
|
||
{connectionDefaults}
|
||
<div className="mt-6 flex items-center justify-between gap-3">
|
||
<Button type="button" variant="ghost" onClick={backToGallery}>
|
||
Back
|
||
</Button>
|
||
<Button
|
||
type="button"
|
||
disabled={connectorEnrollmentQuery.isLoading || startConnectorEnrollment.isPending}
|
||
onClick={() => {
|
||
setConnectorEnrollmentError(null);
|
||
reserveOAuthPopup();
|
||
preserveEnrollmentAccess();
|
||
// Let the server reuse a live enrollment or replace an expired
|
||
// one. A cached verification URL may expire while this page is open.
|
||
startConnectorEnrollment.mutate();
|
||
}}
|
||
>
|
||
{startConnectorEnrollment.isPending ? <Loader2 className="h-4 w-4 animate-spin" /> : null}
|
||
{connectorEnrollmentQuery.data?.status === "pending"
|
||
? "Continue"
|
||
: "Connect with Paperclip"}
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
) : step === "key" && entry && credentialStep !== undefined ? credentialStep : step === "key" && entry ? (
|
||
<KeyStep
|
||
settingsValid={additionalSettingsValid && instructionsValid}
|
||
entry={entry}
|
||
error={connectMutation.isError ? (connectMutation.error instanceof Error ? connectMutation.error.message : "Please check your key and try again.") : null}
|
||
values={credentials}
|
||
onChange={setCredentials}
|
||
oauthClientId={curatedOAuthClientId}
|
||
onOAuthClientIdChange={setCuratedOAuthClientId}
|
||
oauthClientSecret={curatedOAuthClientSecret}
|
||
oauthCallbackUrl={callbackUrlForSetup}
|
||
canReuseOAuthClientSecret={Boolean(
|
||
identityConnection?.config?.oauth
|
||
&& (identityConnection.config.oauth as Record<string, unknown>).clientId === curatedOAuthClientId.trim()
|
||
&& identityConnection.hasSavedOAuthClientSecret
|
||
)}
|
||
onOAuthClientSecretChange={setCuratedOAuthClientSecret}
|
||
credentialSource={credentialSource}
|
||
vercelConnector={vercelConnector}
|
||
onVercelConnectorChange={setVercelConnector}
|
||
vercelConnectAvailability={galleryQuery.data?.credentialSources?.vercelConnect ?? null}
|
||
methodKey={connectionMethodKey}
|
||
onMethodChange={(nextMethod) => {
|
||
setConnectionMethodKey(nextMethod?.key ?? "");
|
||
// Capability/auth changes must not broaden the audience selected
|
||
// on Access (including choices restored after Cloud enrollment).
|
||
if (
|
||
!reconnectGrantKind
|
||
&& nextMethod?.grantKinds
|
||
&& !nextMethod.grantKinds.includes(grantKind)
|
||
) {
|
||
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}
|
||
renderDefaults={renderConnectionDefaults ?? undefined}
|
||
// Back returns to the connector gallery for new, resumed, and
|
||
// reconnected accounts. Cancel is the separate exit to the app list.
|
||
onBack={backToGallery}
|
||
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}
|
||
|
||
{/* A deep link can arrive before the gallery resolves its definition.
|
||
The Access step used to carry that wait; without it the connect screen
|
||
has to show the wait itself rather than render nothing. */}
|
||
{step === "key" && !entry && !linkUrl && !zapierSource && requestedAppKey && (
|
||
<div className="mx-auto max-w-xl" aria-busy="true" aria-label={`Loading ${requestedAppKey} setup`}>
|
||
<Skeleton className="h-6 w-2/3 rounded-md" />
|
||
<Skeleton className="mt-3 h-4 w-full rounded-md" />
|
||
<Skeleton className="mt-8 h-11 w-40 rounded-md" />
|
||
</div>
|
||
)}
|
||
|
||
{step === "key" && !entry && linkUrl && !zapierSource && (
|
||
<LinkConnectStep
|
||
link={linkUrl}
|
||
name={linkName}
|
||
needsKey={linkNeedsKey}
|
||
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}
|
||
defaults={connectionDefaults}
|
||
onBack={backToGallery}
|
||
onConnect={() => {
|
||
setLinkGuidance(null);
|
||
connectMutation.mutate(undefined);
|
||
}}
|
||
/>
|
||
)}
|
||
|
||
{step === "key" && !entry && zapierSource && (
|
||
<ZapierConnectStep
|
||
link={linkUrl}
|
||
onLinkChange={setLinkUrl}
|
||
submitting={connectMutation.isPending}
|
||
defaults={connectionDefaults}
|
||
onBack={backToGallery}
|
||
onConnect={() => connectMutation.mutate(undefined)}
|
||
/>
|
||
)}
|
||
|
||
{step === "success" && (
|
||
<ConnectionSetupCompletionScreen
|
||
appName={appName}
|
||
logoUrl={entry?.branding.logoUrl}
|
||
darkLogoUrl={entry?.branding.darkLogoUrl}
|
||
summary={accessSummaryLines({
|
||
grantKind: effectiveGrantKind,
|
||
authKind: accessStepAuthKind,
|
||
installChoice,
|
||
installCount: installAgentIds.size,
|
||
enabledCount: Object.values(enabled).filter(Boolean).length,
|
||
})}
|
||
statedDefault={connectionDefaultSummarySentence({
|
||
grantKind: effectiveGrantKind,
|
||
authKind: accessStepAuthKind,
|
||
installChoice,
|
||
installCount: installAgentIds.size,
|
||
lockedAgentId: requestedAgentId ?? null,
|
||
})}
|
||
onDone={onCancel ?? (() => navigate(
|
||
host === "page" && connectResult?.connectionId
|
||
? `/apps/${connectResult.connectionId}/permissions`
|
||
: "/apps",
|
||
))}
|
||
/>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
export function OAuthConnectStateScreen({
|
||
entry,
|
||
identity,
|
||
resuming = false,
|
||
phase,
|
||
error,
|
||
recoveryActions,
|
||
authorizationHost,
|
||
authorizationUrl,
|
||
steps = { labels: OAUTH_SIGN_IN_STEP_LABELS, activeIndex: 0 },
|
||
guidance,
|
||
defaults,
|
||
onRetry,
|
||
settingsValid = true,
|
||
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;
|
||
/**
|
||
* Step model of the flow that opened this screen, so the stepper keeps the
|
||
* shape it had on the previous screen. Defaults to the two-step curated
|
||
* sign-in model for hosts that have no wizard of their own.
|
||
*/
|
||
steps?: { labels: string[]; activeIndex: number };
|
||
/** Provider-specific warnings that must be read before authorizing. */
|
||
guidance?: ReactNode;
|
||
/** The stated default and its Advanced disclosure (PAP-659 C0). */
|
||
defaults?: ReactNode;
|
||
settingsValid?: boolean;
|
||
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 (
|
||
<div className="max-w-5xl">
|
||
<StepHeader
|
||
subtitle="Secure MCP sign-in"
|
||
step="key"
|
||
activeIndex={steps.activeIndex}
|
||
labels={steps.labels}
|
||
appIdentity={entry ? { name: entry.name, logoUrl: entry.branding.logoUrl, darkLogoUrl: entry.branding.darkLogoUrl } : undefined}
|
||
unverifiedHost={unverifiedHost}
|
||
onCancel={onCancel}
|
||
/>
|
||
<div className="mx-auto max-w-xl">
|
||
<div className="flex items-start gap-3">
|
||
<span className="mt-1 inline-flex h-10 w-10 shrink-0 items-center justify-center rounded-lg border border-border bg-background">
|
||
{phase === "error" ? (
|
||
<Link2 className="h-5 w-5 text-destructive" />
|
||
) : phase === "entry" ? (
|
||
<Lock className="h-5 w-5 text-muted-foreground" />
|
||
) : (
|
||
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
|
||
)}
|
||
</span>
|
||
<div className="min-w-0">
|
||
<h2 className="text-xl font-bold tracking-tight">{status.title}</h2>
|
||
<p className="mt-1 text-sm text-muted-foreground">{status.body}</p>
|
||
{unverifiedHost ? <UnverifiedServerBadge host={unverifiedHost} className="mt-2" /> : null}
|
||
</div>
|
||
</div>
|
||
|
||
{phase === "entry" && guidance ? <div className="mt-4">{guidance}</div> : null}
|
||
{phase === "entry" || phase === "error" ? defaults : null}
|
||
|
||
{phase === "error" && recoveryActions && (recoveryActions.installationUrl || recoveryActions.managementUrl) ? (
|
||
<div className="mt-4 flex flex-wrap gap-2">
|
||
{recoveryActions.installationUrl ? (
|
||
<Button type="button" variant="outline" asChild>
|
||
<a href={recoveryActions.installationUrl} target="_blank" rel="noreferrer">
|
||
Install Paperclip on GitHub
|
||
<ArrowUpRight className="ml-1.5 h-3.5 w-3.5" />
|
||
</a>
|
||
</Button>
|
||
) : null}
|
||
{recoveryActions.managementUrl ? (
|
||
<Button type="button" variant="ghost" asChild>
|
||
<a href={recoveryActions.managementUrl} target="_blank" rel="noreferrer">
|
||
Manage repositories on GitHub
|
||
</a>
|
||
</Button>
|
||
) : null}
|
||
</div>
|
||
) : null}
|
||
|
||
<div className="mt-6 flex items-center gap-2">
|
||
{phase === "error" || phase === "entry" ? (
|
||
<Button type="button" onClick={onRetry} disabled={!settingsValid}>
|
||
{phase === "entry"
|
||
? resuming ? `Finish with ${serverName}` : `Continue to ${serverName}`
|
||
: "Try again"}
|
||
{phase === "entry" ? <ArrowUpRight className="h-4 w-4" aria-hidden="true" /> : null}
|
||
</Button>
|
||
) : (
|
||
<Button type="button" disabled>
|
||
{phase === "redirecting" ? `Opening ${serverName}…` : "Preparing…"}
|
||
</Button>
|
||
)}
|
||
{authorizationUrl ? <Button variant="outline" asChild><a href={authorizationUrl} target="_blank" rel="noopener noreferrer" onClick={onOpenAuthorization}>Open sign-in in a new tab</a></Button> : null}
|
||
<Button type="button" variant="ghost" onClick={onBack}>Back</Button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function ZapierConnectStep({
|
||
link,
|
||
onLinkChange,
|
||
submitting,
|
||
defaults,
|
||
onBack,
|
||
onConnect,
|
||
}: {
|
||
link: string;
|
||
onLinkChange: (next: string) => void;
|
||
submitting: boolean;
|
||
/** The stated default and its Advanced disclosure (PAP-659 C0). */
|
||
defaults?: ReactNode;
|
||
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 (
|
||
<div className="mx-auto max-w-xl">
|
||
<div>
|
||
<label className="text-sm font-medium text-foreground">Zapier MCP URL</label>
|
||
<Input
|
||
type="password"
|
||
autoComplete="off"
|
||
spellCheck={false}
|
||
value={link}
|
||
onChange={(event) => 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 && (
|
||
<p className="mt-2 text-xs text-destructive">Paste a valid Zapier URL to continue.</p>
|
||
)}
|
||
</div>
|
||
|
||
{defaults}
|
||
|
||
<div className="mt-6 flex items-center justify-between">
|
||
<Button variant="ghost" onClick={onBack} disabled={submitting}>
|
||
Back
|
||
</Button>
|
||
<Button onClick={onConnect} disabled={submitting || !isZapierLink}>
|
||
{submitting && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||
{submitting ? "Checking…" : "Check link"}
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
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<string | null>(null);
|
||
const linkSectionRef = useRef<HTMLDivElement>(null);
|
||
const linkInputRef = useRef<HTMLInputElement>(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 (
|
||
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 lg:grid-cols-4">
|
||
{Array.from({ length: 8 }).map((_, i) => (
|
||
<Skeleton key={i} className="h-32 w-full rounded-xl" />
|
||
))}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<div className="space-y-6">
|
||
{vercelConnect ? (
|
||
<div className="rounded-xl border border-border bg-card p-5">
|
||
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
|
||
<div>
|
||
<h2 className="text-lg font-bold tracking-tight">Connect through Vercel</h2>
|
||
<p className="mt-1 max-w-2xl text-sm text-muted-foreground">
|
||
Create and manage the provider connector in Vercel. Paperclip stores its reference and applies agent access, policy, approval, and audit controls here.
|
||
</p>
|
||
</div>
|
||
{vercelConnectAvailability ? (
|
||
<Button asChild variant="outline" size="sm" className="shrink-0">
|
||
<a href={vercelConnectAvailability.manageUrl} target="_blank" rel="noreferrer">
|
||
Open Vercel Connect
|
||
<ArrowUpRight className="ml-2 h-3.5 w-3.5" />
|
||
</a>
|
||
</Button>
|
||
) : null}
|
||
</div>
|
||
{vercelConnectAvailability?.available === false ? (
|
||
<InlineBanner tone="danger" compact className="mt-4">
|
||
{vercelConnectAvailability.reason ?? "Vercel Connect is unavailable on this instance."}
|
||
</InlineBanner>
|
||
) : null}
|
||
</div>
|
||
) : null}
|
||
|
||
{!byoOnly && (
|
||
<>
|
||
<div className="relative">
|
||
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||
<Input
|
||
value={search}
|
||
onChange={(e) => setSearch(e.target.value)}
|
||
placeholder="Search apps…"
|
||
className="h-11 pl-9"
|
||
/>
|
||
</div>
|
||
|
||
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 lg:grid-cols-4">
|
||
{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 (
|
||
<button
|
||
key={app.slug}
|
||
type="button"
|
||
disabled={oauthBlocked || unavailable}
|
||
title={
|
||
unavailable
|
||
? app.availability?.reason ?? vercelConnectAvailability?.reason ?? undefined
|
||
: undefined
|
||
}
|
||
onClick={() => onPick(app)}
|
||
className={cn(
|
||
"flex flex-col rounded-xl border border-border bg-card p-4 text-left transition-colors",
|
||
oauthBlocked || unavailable ? "cursor-not-allowed opacity-60" : "hover:border-foreground/30 hover:bg-accent/40",
|
||
)}
|
||
>
|
||
<AppLogo name={app.name} logoUrl={app.branding.logoUrl} darkLogoUrl={app.branding.darkLogoUrl} size={36} />
|
||
<div className="mt-3 text-sm font-bold text-foreground">{app.name}</div>
|
||
<div className="mt-1 line-clamp-2 text-xs text-muted-foreground">{copy.tagline}</div>
|
||
<div className="mt-3 text-xs font-semibold text-foreground">
|
||
{unavailable ? (
|
||
<span className="text-muted-foreground">
|
||
{app.availability?.reason ?? vercelConnectAvailability?.reason ?? "Unavailable on this instance."}
|
||
</span>
|
||
) : oauthBlocked ? (
|
||
<span className="text-muted-foreground">Unavailable</span>
|
||
) : (
|
||
<span>Connect →</span>
|
||
)}
|
||
</div>
|
||
</button>
|
||
);
|
||
})}
|
||
</div>
|
||
|
||
{filtered.length === 0 && (
|
||
<div className="py-10 text-center text-sm text-muted-foreground">No apps match “{search}”.</div>
|
||
)}
|
||
</>
|
||
)}
|
||
|
||
{!vercelConnect ? <div
|
||
ref={linkSectionRef}
|
||
className={cn(
|
||
"grid gap-4 border-t border-border pt-5 md:grid-cols-(--gtc-13)",
|
||
byo && "-mx-3 rounded-xl border border-primary/40 bg-primary/[0.04] px-3 pb-4 md:mx-0",
|
||
)}
|
||
>
|
||
<div>
|
||
<div className="flex items-center gap-2 text-sm font-semibold text-foreground">
|
||
<Link2 className="h-4 w-4 text-muted-foreground" />
|
||
{zapierSource ? "Connect Zapier" : byo ? "Connect your own MCP server" : "Connect with a link"}
|
||
</div>
|
||
<p className="mt-1 text-xs text-muted-foreground">
|
||
{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."}
|
||
</p>
|
||
{!zapierSource && (
|
||
<p className="mt-1 text-xs text-muted-foreground">
|
||
Any remote tool URL works here — including a local MCP server like{" "}
|
||
<code className="rounded bg-muted px-1 py-0.5 text-xs">http://127.0.0.1:8848/mcp</code>.
|
||
</p>
|
||
)}
|
||
{matchedEntry && (
|
||
<div className="mt-3 flex items-center justify-between gap-3 rounded-lg border border-border bg-muted/40 px-3 py-2">
|
||
<div className="flex min-w-0 items-center gap-2 text-sm">
|
||
<AppLogo name={matchedEntry.name} logoUrl={matchedEntry.branding.logoUrl} darkLogoUrl={matchedEntry.branding.darkLogoUrl} size={24} />
|
||
<span className="truncate">This looks like {matchedEntry.name}.</span>
|
||
</div>
|
||
<Button
|
||
type="button"
|
||
size="sm"
|
||
variant="outline"
|
||
disabled={matchedEntry.availability?.available === false}
|
||
onClick={() => {
|
||
setLinkError(null);
|
||
if (matchedEntry.slug === "zapier") {
|
||
continueWithLink();
|
||
return;
|
||
}
|
||
onPick(matchedEntry);
|
||
}}
|
||
>
|
||
{matchedEntry.availability?.available === false
|
||
? "Not available"
|
||
: matchedEntry.slug === "zapier"
|
||
? "Continue"
|
||
: `Use ${matchedEntry.name}`}
|
||
</Button>
|
||
</div>
|
||
)}
|
||
</div>
|
||
<div className="flex min-w-0 flex-col gap-2 sm:min-w-(--sz-360px)">
|
||
<div className="flex gap-2">
|
||
<Input
|
||
ref={linkInputRef}
|
||
type={zapierSource || matchedEntry?.slug === "zapier" ? "password" : "url"}
|
||
autoComplete="off"
|
||
spellCheck={false}
|
||
aria-label="MCP server URL"
|
||
value={linkInput}
|
||
onChange={(e) => {
|
||
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"
|
||
/>
|
||
<Button type="button" variant="outline" onClick={continueWithLink}>
|
||
Continue
|
||
</Button>
|
||
</div>
|
||
{linkError && <div className="text-xs text-destructive">{linkError}</div>}
|
||
</div>
|
||
</div> : null}
|
||
|
||
</div>
|
||
);
|
||
}
|
||
|
||
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,
|
||
keyValue,
|
||
onKeyChange,
|
||
authMode,
|
||
onAuthModeChange,
|
||
headers,
|
||
onHeadersChange,
|
||
oauthClientId,
|
||
onOAuthClientIdChange,
|
||
oauthClientSecret,
|
||
onOAuthClientSecretChange,
|
||
advancedOpen,
|
||
onAdvancedOpenChange,
|
||
guidance,
|
||
matchedEntry,
|
||
onUseMatchedEntry,
|
||
submitting,
|
||
defaults,
|
||
onBack,
|
||
onConnect,
|
||
}: {
|
||
link: string;
|
||
name: string;
|
||
needsKey: boolean;
|
||
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;
|
||
/** The stated default and its Advanced disclosure (PAP-659 C0). */
|
||
defaults?: ReactNode;
|
||
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);
|
||
// PAP-659 bucket H: the address alone is the whole default path. `needsKey` is
|
||
// now set by the server's probe after a credential challenge, never guessed at
|
||
// up front, so the field appears exactly when it is known to be required.
|
||
const showKeyField = (authMode === "auto" && needsKey) || authMode === "bearer";
|
||
const displayedLink = matchedEntry?.slug === "zapier" ? redactUrlSecrets(link) : link;
|
||
|
||
const updateHeader = (id: string, patch: Partial<CustomHeaderRow>) => {
|
||
onHeadersChange(headers.map((row) => (row.id === id ? { ...row, ...patch } : row)));
|
||
};
|
||
|
||
return (
|
||
<div className="mx-auto max-w-xl">
|
||
<div className="flex items-center justify-between gap-3">
|
||
<p className="min-w-0 truncate font-mono text-sm text-muted-foreground" title={displayedLink}>{displayedLink}</p>
|
||
<UnverifiedServerBadge host={host} />
|
||
</div>
|
||
|
||
{matchedEntry && onUseMatchedEntry ? (
|
||
<div className="mt-6 flex flex-wrap items-center justify-between gap-3 rounded-lg border border-border bg-muted/40 px-3 py-2">
|
||
<div className="flex min-w-0 items-center gap-2 text-sm">
|
||
<AppLogo name={matchedEntry.name} logoUrl={matchedEntry.branding.logoUrl} darkLogoUrl={matchedEntry.branding.darkLogoUrl} size={24} />
|
||
<span className="truncate">Paperclip has a guided setup for {matchedEntry.name}.</span>
|
||
</div>
|
||
<Button type="button" size="sm" variant="outline" onClick={onUseMatchedEntry}>
|
||
Use {matchedEntry.name}
|
||
</Button>
|
||
</div>
|
||
) : null}
|
||
|
||
{guidance ? (
|
||
<div className="mt-6">
|
||
<InlineBanner tone="warning" title={guidance.title}>
|
||
{guidance.body}
|
||
</InlineBanner>
|
||
</div>
|
||
) : null}
|
||
|
||
<div className="mt-6 space-y-6">
|
||
{showKeyField ? (
|
||
<div className="space-y-4">
|
||
<div>
|
||
<label className="text-sm font-medium text-foreground" htmlFor="generic-mcp-key">App key</label>
|
||
<Input
|
||
id="generic-mcp-key"
|
||
type="password"
|
||
autoComplete="off"
|
||
value={keyValue}
|
||
onChange={(e) => onKeyChange(e.target.value)}
|
||
placeholder="••••••••••••••••"
|
||
className="mt-2 h-11 font-mono"
|
||
/>
|
||
<p className="mt-2 text-xs text-muted-foreground">
|
||
Grant this key read and write access to the resources your agents need. Paperclip cannot increase its permissions.
|
||
</p>
|
||
</div>
|
||
</div>
|
||
) : null}
|
||
|
||
<Collapsible open={advancedOpen} onOpenChange={onAdvancedOpenChange}>
|
||
<CollapsibleTrigger className="flex w-full items-center justify-between rounded-lg border border-border bg-background px-3 py-2 text-left text-sm font-medium text-foreground hover:bg-accent/40">
|
||
Advanced authentication
|
||
<ChevronDown className={cn("h-4 w-4 text-muted-foreground transition-transform", advancedOpen && "rotate-180")} />
|
||
</CollapsibleTrigger>
|
||
<CollapsibleContent className="space-y-5 pt-4">
|
||
<p className="text-xs text-muted-foreground">
|
||
Only needed when the server's docs are specific about how to authenticate.
|
||
</p>
|
||
<div className="flex flex-wrap gap-2">
|
||
{GENERIC_AUTH_MODE_OPTIONS.map((option) => (
|
||
<SegmentedOption
|
||
key={option.mode}
|
||
label={option.label}
|
||
selected={authMode === option.mode}
|
||
onClick={() => onAuthModeChange(option.mode)}
|
||
/>
|
||
))}
|
||
</div>
|
||
<p className="text-xs text-muted-foreground">
|
||
{GENERIC_AUTH_MODE_OPTIONS.find((option) => option.mode === authMode)?.hint}
|
||
</p>
|
||
|
||
{authMode === "custom_headers" ? (
|
||
<div className="space-y-3">
|
||
{headers.map((row) => (
|
||
<div key={row.id} className="flex items-start gap-2">
|
||
<Input
|
||
value={row.name}
|
||
onChange={(e) => updateHeader(row.id, { name: e.target.value })}
|
||
placeholder="Header name"
|
||
aria-label="Header name"
|
||
className="h-10 font-mono"
|
||
/>
|
||
<Input
|
||
type="password"
|
||
autoComplete="off"
|
||
value={row.value}
|
||
onChange={(e) => 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"
|
||
/>
|
||
<Button
|
||
type="button"
|
||
variant="ghost"
|
||
size="sm"
|
||
className="h-10 shrink-0"
|
||
onClick={() => onHeadersChange(headers.filter((candidate) => candidate.id !== row.id))}
|
||
disabled={headers.length === 1}
|
||
>
|
||
Remove
|
||
</Button>
|
||
</div>
|
||
))}
|
||
<Button
|
||
type="button"
|
||
variant="outline"
|
||
size="sm"
|
||
onClick={() => onHeadersChange([...headers, newCustomHeaderRow()])}
|
||
>
|
||
Add another header
|
||
</Button>
|
||
{headerError ? <p className="text-xs text-destructive">{headerError}</p> : null}
|
||
</div>
|
||
) : null}
|
||
|
||
{authMode === "oauth" ? (
|
||
<div className="space-y-4">
|
||
<p className="text-xs text-muted-foreground">
|
||
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.
|
||
</p>
|
||
<div>
|
||
<label className="text-sm font-medium text-foreground" htmlFor="generic-mcp-client-id">
|
||
Client ID
|
||
</label>
|
||
<Input
|
||
id="generic-mcp-client-id"
|
||
value={oauthClientId}
|
||
onChange={(e) => onOAuthClientIdChange(e.target.value)}
|
||
autoComplete="off"
|
||
placeholder="Optional"
|
||
className="mt-2 h-11 font-mono"
|
||
/>
|
||
</div>
|
||
<div>
|
||
<label className="text-sm font-medium text-foreground" htmlFor="generic-mcp-client-secret">
|
||
Client secret
|
||
</label>
|
||
<Input
|
||
id="generic-mcp-client-secret"
|
||
type="password"
|
||
autoComplete="off"
|
||
value={oauthClientSecret}
|
||
onChange={(e) => onOAuthClientSecretChange(e.target.value)}
|
||
placeholder="Optional"
|
||
className="mt-2 h-11 font-mono"
|
||
/>
|
||
</div>
|
||
</div>
|
||
) : null}
|
||
</CollapsibleContent>
|
||
</Collapsible>
|
||
</div>
|
||
|
||
{defaults}
|
||
|
||
<div className="mt-8 flex items-center justify-between">
|
||
<Button variant="ghost" onClick={onBack} disabled={submitting}>
|
||
Back
|
||
</Button>
|
||
<Button onClick={onConnect} disabled={submitting || !canSubmit || Boolean(headerError)}>
|
||
{submitting && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||
{submitting ? "Checking…" : "Check link"}
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
/**
|
||
* 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 (
|
||
<button
|
||
type="button"
|
||
onClick={onClick}
|
||
aria-pressed={selected}
|
||
className={cn(
|
||
"min-w-(--sz-64px) rounded-md px-4 py-1.5 text-sm font-medium transition-colors",
|
||
selected
|
||
? "bg-background text-foreground shadow-sm"
|
||
: "text-muted-foreground hover:text-foreground",
|
||
)}
|
||
>
|
||
{label}
|
||
</button>
|
||
);
|
||
}
|
||
|
||
function KeyStep({
|
||
settingsValid = true,
|
||
entry,
|
||
error,
|
||
values,
|
||
onChange,
|
||
oauthClientId,
|
||
onOAuthClientIdChange,
|
||
oauthClientSecret,
|
||
oauthCallbackUrl: serverCallbackUrl,
|
||
canReuseOAuthClientSecret,
|
||
onOAuthClientSecretChange,
|
||
credentialSource,
|
||
vercelConnector,
|
||
onVercelConnectorChange,
|
||
vercelConnectAvailability,
|
||
methodKey,
|
||
onMethodChange,
|
||
configValues,
|
||
onConfigChange,
|
||
googleSheetsLinks,
|
||
googleSheetsError,
|
||
onGoogleSheetsLinksChange,
|
||
submitting,
|
||
renderDefaults,
|
||
onBack,
|
||
onConnect,
|
||
}: {
|
||
settingsValid?: boolean;
|
||
entry: AppDefinition;
|
||
error?: string | null;
|
||
values: Record<string, string>;
|
||
onChange: (next: Record<string, string>) => void;
|
||
oauthClientId: string;
|
||
onOAuthClientIdChange: (next: string) => void;
|
||
oauthClientSecret: string;
|
||
oauthCallbackUrl: string;
|
||
canReuseOAuthClientSecret: boolean;
|
||
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<string, string | boolean>;
|
||
onConfigChange: (next: Record<string, string | boolean>) => void;
|
||
googleSheetsLinks: string;
|
||
googleSheetsError: string | null;
|
||
onGoogleSheetsLinksChange: (next: string) => void;
|
||
submitting: boolean;
|
||
/**
|
||
* Builds the one Advanced disclosure this screen has (PAP-659 C0). The key
|
||
* step contributes its own settings to it rather than opening a second one.
|
||
*/
|
||
renderDefaults?: (extra?: ReactNode, forceOpen?: boolean) => ReactNode;
|
||
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<string, {
|
||
key: string;
|
||
label: string;
|
||
description: string;
|
||
methods: ConnectionMethodDef[];
|
||
}>()),
|
||
).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, method),
|
||
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
|
||
&& (!method?.oauthClientSecretRequired || canReuseOAuthClientSecret || oauthClientSecret.trim().length > 0)
|
||
);
|
||
const vercelConnectorFilled = !usingVercel || vercelConnector.trim().length > 0;
|
||
const oauthCallbackUrl = method?.auth === "oauth" && acceptsCustomerOAuthClient
|
||
? serverCallbackUrl
|
||
: 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 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 hasAlternateMethods = capabilityGroups.length > 1 || capabilityMethods.length > 1;
|
||
const capabilitySelection = capabilityGroups.length > 1 ? (
|
||
<div>
|
||
<label className="text-sm font-medium text-foreground">What should Paperclip be able to do?</label>
|
||
<RadioCardGroup
|
||
ariaLabel={`Access level for ${entry.name}`}
|
||
className="mt-2"
|
||
value={capabilityKey}
|
||
onValueChange={(nextKey) => {
|
||
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 && <p className="mt-2 text-xs text-muted-foreground">Choose an access level to continue.</p>}
|
||
</div>
|
||
) : null;
|
||
const managedOAuthMethod = capabilityMethods.find((candidate) =>
|
||
candidate.oauthStrategy === "paperclip_cloud_connector",
|
||
);
|
||
const customerOAuthMethod = managedOAuthMethod && capabilityMethods.find((candidate) =>
|
||
connectionMethodAcceptsCustomerOAuthClient(candidate)
|
||
&& !connectionMethodSupportsAutomaticOAuth(candidate),
|
||
);
|
||
const usingCustomOAuth = method?.key === customerOAuthMethod?.key;
|
||
const customOAuthFieldsId = useId();
|
||
const authenticationSelection = managedOAuthMethod && customerOAuthMethod && capabilityMethods.length === 2 ? (
|
||
<Button
|
||
type="button"
|
||
variant="link"
|
||
className="h-auto p-0 text-xs text-muted-foreground underline underline-offset-2 hover:text-foreground"
|
||
aria-expanded={usingCustomOAuth}
|
||
aria-controls={customOAuthFieldsId}
|
||
disabled={submitting}
|
||
onClick={() => onMethodChange(usingCustomOAuth ? managedOAuthMethod : customerOAuthMethod)}
|
||
>
|
||
{usingCustomOAuth ? "Use Paperclip instead" : `Use your own ${isGoogleWorkspaceConnectorProfileId(managedOAuthMethod.connectorProfile ?? "") ? "Google" : entry.name} OAuth app`}
|
||
</Button>
|
||
) : capabilityMethods.length > 1 ? (
|
||
// PAP-659 C1: the ranked default is already selected. This stays as the
|
||
// way to pick something else, one disclosure away, rather than a question
|
||
// the screen opens with.
|
||
<div>
|
||
<label className="text-sm font-medium text-foreground">How do you want to connect?</label>
|
||
<RadioCardGroup
|
||
ariaLabel={`How to connect ${entry.name}`}
|
||
className="mt-2"
|
||
value={methodKey}
|
||
onValueChange={(nextKey) => {
|
||
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 && <p className="mt-2 text-xs text-muted-foreground">Choose a connection method to continue.</p>}
|
||
</div>
|
||
) : null;
|
||
const hasAdvancedSettings = advancedConfigFields.length > 0
|
||
|| optionalCustomerOAuthClient
|
||
|| hasAlternateMethods;
|
||
// Keep the disclosure open when what is inside it is load-bearing right now:
|
||
// a non-default method in use, or a selection the connector still needs.
|
||
const forceAdvancedOpen = usingCustomOAuth || !hasMethodSelection;
|
||
const advancedSettings = hasAdvancedSettings ? (
|
||
<div className="space-y-6">
|
||
{capabilitySelection}
|
||
{authenticationSelection}
|
||
{advancedConfigFields.map((field) => (
|
||
<MethodConfigField
|
||
key={field.key}
|
||
field={field}
|
||
value={configValues[field.key]}
|
||
onChange={(value) => onConfigChange({ ...configValues, [field.key]: value })}
|
||
/>
|
||
))}
|
||
{optionalCustomerOAuthClient ? (
|
||
<OAuthClientFields
|
||
entry={entry}
|
||
method={method!}
|
||
callbackUrl={oauthCallbackUrl}
|
||
clientId={oauthClientId}
|
||
onClientIdChange={onOAuthClientIdChange}
|
||
clientSecret={oauthClientSecret}
|
||
onClientSecretChange={onOAuthClientSecretChange}
|
||
required={false}
|
||
/>
|
||
) : null}
|
||
</div>
|
||
) : null;
|
||
const defaults = renderDefaults?.(advancedSettings, forceAdvancedOpen) ?? null;
|
||
|
||
if (isGoogleSheetsRobotMethod(entry, method)) {
|
||
const parsed = parseGoogleSheetIds(googleSheetsLinks);
|
||
const canConnect = settingsValid && !unavailable && Boolean(robotEmail) && googleSheetsLinks.trim().length > 0;
|
||
return (
|
||
<div className="mx-auto max-w-xl">
|
||
<div className="space-y-6">
|
||
{capabilitySelection}
|
||
|
||
{robotEmail ? (
|
||
<div>
|
||
<label className="text-sm font-medium text-foreground">Share each sheet with this email</label>
|
||
<div className="mt-2 flex min-w-0 flex-col gap-2 sm:flex-row">
|
||
<div
|
||
title={robotEmail}
|
||
className="min-h-11 min-w-0 flex-1 rounded-md border border-input bg-muted/40 px-3 py-2.5 font-mono text-xs leading-tight text-foreground break-all"
|
||
>
|
||
{robotEmail}
|
||
</div>
|
||
<CopyValueButton value={robotEmail} ariaLabel="Copy sharing email" />
|
||
</div>
|
||
<p className="mt-2 text-xs text-muted-foreground">
|
||
In Google Sheets, click Share and add this email as an Editor. Then paste the sheet links below.
|
||
</p>
|
||
</div>
|
||
) : (
|
||
<div className="rounded-lg bg-muted/50 p-4 text-sm text-muted-foreground">
|
||
Google Sheets is not available on this instance yet.
|
||
</div>
|
||
)}
|
||
|
||
<div>
|
||
<label className="text-sm font-medium text-foreground">Paste links to the sheets you shared</label>
|
||
<Textarea
|
||
value={googleSheetsLinks}
|
||
onChange={(e) => onGoogleSheetsLinksChange(e.target.value)}
|
||
placeholder="https://docs.google.com/spreadsheets/d/..."
|
||
className="mt-2 min-h-28"
|
||
/>
|
||
<div className="mt-2 text-xs text-muted-foreground">
|
||
{parsed.ids.length > 0
|
||
? `${parsed.ids.length} ${parsed.ids.length === 1 ? "sheet" : "sheets"} ready to connect.`
|
||
: "Paste one link per line. Both .../edit and .../edit#gid=... links work."}
|
||
</div>
|
||
{googleSheetsError && <div className="mt-2 text-xs text-destructive">{googleSheetsError}</div>}
|
||
</div>
|
||
</div>
|
||
|
||
{defaults}
|
||
|
||
<div className="mt-8 flex items-center justify-between">
|
||
<Button variant="ghost" onClick={onBack} disabled={submitting}>
|
||
Back
|
||
</Button>
|
||
<Button onClick={onConnect} disabled={submitting || !canConnect}>
|
||
{submitting && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||
{submitting ? "Checking…" : "Connect"}
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
const managedAsana = method?.connectorProfile === "asana.mcp";
|
||
const requirementsUrl = managedAsana ? null : method?.consoleLinks?.docs ?? entry.docsUrl;
|
||
|
||
return (
|
||
<div className="mx-auto max-w-xl">
|
||
{requirementsUrl ? (
|
||
<div className="mb-4 flex justify-end">
|
||
<a
|
||
href={requirementsUrl}
|
||
target="_blank"
|
||
rel="noreferrer"
|
||
className="inline-flex items-center gap-1 text-xs font-medium text-muted-foreground underline underline-offset-2 hover:text-foreground"
|
||
>
|
||
Review requirements
|
||
<ArrowUpRight className="h-3 w-3" />
|
||
</a>
|
||
</div>
|
||
) : null}
|
||
|
||
<div className="space-y-6">
|
||
{managedAsana ? (
|
||
<p className="text-sm text-muted-foreground">Sign in with Asana to choose your workspace and connect it to Paperclip.</p>
|
||
) : null}
|
||
{method?.capabilityProfile && (
|
||
<p className="text-sm text-muted-foreground">
|
||
{method.capabilityProfile.label}: {method.capabilityProfile.description}
|
||
</p>
|
||
)}
|
||
{usingVercel && vercelReview && vercelConnectAvailability ? (
|
||
<div className="space-y-4 rounded-lg border border-border p-4">
|
||
<div>
|
||
<div className="text-sm font-medium text-foreground">Create or attach the connector in Vercel</div>
|
||
<p className="mt-1 text-xs text-muted-foreground">
|
||
Paperclip does not copy Vercel’s setup forms. Finish connector setup there, then paste its UID below.
|
||
</p>
|
||
<a
|
||
href={vercelConnectAvailability.manageUrl}
|
||
target="_blank"
|
||
rel="noreferrer"
|
||
className="mt-2 inline-flex items-center gap-1 text-xs font-semibold text-foreground underline underline-offset-2"
|
||
>
|
||
Open Vercel Connect
|
||
<ArrowUpRight className="h-3 w-3" />
|
||
</a>
|
||
</div>
|
||
<div>
|
||
<label className="text-sm font-medium text-foreground" htmlFor="vercel-connect-connector">
|
||
Connector UID or ID
|
||
</label>
|
||
<Input
|
||
id="vercel-connect-connector"
|
||
value={vercelConnector}
|
||
onChange={(event) => onVercelConnectorChange(event.target.value)}
|
||
autoComplete="off"
|
||
placeholder="service/my-connector or scl_…"
|
||
className="mt-2 h-11 font-mono"
|
||
/>
|
||
<p className="mt-2 text-xs text-muted-foreground">
|
||
Paperclip validates the connector and stores only its reference and redacted verification metadata.
|
||
</p>
|
||
</div>
|
||
</div>
|
||
) : null}
|
||
|
||
{error ? <div role="alert"><InlineBanner tone="danger">{error}</InlineBanner></div> : null}
|
||
|
||
{standardConfigFields.map((field) => (
|
||
<MethodConfigField
|
||
key={field.key}
|
||
field={field}
|
||
value={configValues[field.key]}
|
||
onChange={(value) => onConfigChange({ ...configValues, [field.key]: value })}
|
||
/>
|
||
))}
|
||
|
||
|
||
{!usingVercel && method?.auth === "oauth" && customerOAuthClientRequired ? (
|
||
<div id={customOAuthFieldsId} role="region" aria-label="Your OAuth app">
|
||
<OAuthClientFields
|
||
entry={entry}
|
||
method={method}
|
||
callbackUrl={oauthCallbackUrl}
|
||
clientId={oauthClientId}
|
||
onClientIdChange={onOAuthClientIdChange}
|
||
clientSecret={oauthClientSecret}
|
||
onClientSecretChange={onOAuthClientSecretChange}
|
||
required
|
||
/>
|
||
</div>
|
||
) : null}
|
||
|
||
{usingVercel || !method || fields.length === 0 ? null : (
|
||
fields.map((field) => (
|
||
<div key={field.configPath}>
|
||
<label className="text-sm font-medium text-foreground">
|
||
{credentialFieldLabel(entry.name, field.label, fields.length)}
|
||
</label>
|
||
<Input
|
||
type={field.type === "text" && field.secret === false ? "text" : "password"}
|
||
aria-label={credentialFieldLabel(entry.name, field.label, fields.length)}
|
||
autoComplete="off"
|
||
value={values[field.configPath] ?? ""}
|
||
onChange={(e) => onChange({ ...values, [field.configPath]: e.target.value })}
|
||
placeholder={field.type === "text" && field.secret === false ? field.placeholder : "••••••••••••••••"}
|
||
className="mt-2 h-11 font-mono"
|
||
/>
|
||
{field.helperMd && <p className="mt-2 text-xs text-muted-foreground">
|
||
{field.helperMd}
|
||
</p>}
|
||
{field.helpUrl && (
|
||
<a
|
||
href={field.helpUrl}
|
||
target="_blank"
|
||
rel="noreferrer"
|
||
className="mt-2 inline-flex items-center gap-1 text-xs font-semibold text-foreground underline underline-offset-2"
|
||
>
|
||
Where do I find this?
|
||
<ArrowUpRight className="h-3 w-3" />
|
||
</a>
|
||
)}
|
||
</div>
|
||
))
|
||
)}
|
||
|
||
</div>
|
||
|
||
{defaults}
|
||
|
||
<div className="mt-8 flex items-center justify-between">
|
||
<Button variant="ghost" onClick={onBack} disabled={submitting}>
|
||
Back
|
||
</Button>
|
||
<Button onClick={onConnect} disabled={submitting || !settingsValid || !hasMethodSelection || !allFilled || !oauthClientFilled || !vercelConnectorFilled || !configFilled || !configRequirementMet}>
|
||
{submitting && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||
{submitting
|
||
? "Checking…"
|
||
: usingVercel
|
||
? method?.auth === "oauth" ? "Validate and continue" : "Validate and connect"
|
||
: method?.auth === "oauth"
|
||
? entry.slug === "github" ? "Continue to GitHub" : "Continue to sign in"
|
||
: "Connect"}
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
/**
|
||
* The Copy button beside a value the operator has to paste into another
|
||
* product's console. Both of those values are long and opaque, so the button
|
||
* has to say whether the clipboard actually took it.
|
||
*/
|
||
function CopyValueButton({ value, ariaLabel }: { value: string; ariaLabel: string }) {
|
||
const { copied, failed, copy } = useCopyAction();
|
||
return (
|
||
<Button
|
||
type="button"
|
||
variant="outline"
|
||
className="shrink-0"
|
||
aria-label={ariaLabel}
|
||
onClick={() => void copy(value)}
|
||
>
|
||
{copied ? <Check className="mr-2 h-4 w-4" /> : <Copy className="mr-2 h-4 w-4" />}
|
||
{copied ? "Copied" : failed ? "Copy failed" : "Copy"}
|
||
</Button>
|
||
);
|
||
}
|
||
|
||
function OAuthClientFields({
|
||
entry,
|
||
method,
|
||
callbackUrl,
|
||
clientId,
|
||
onClientIdChange,
|
||
clientSecret,
|
||
onClientSecretChange,
|
||
required,
|
||
}: {
|
||
entry: AppDefinition;
|
||
method: ConnectionMethodDef;
|
||
callbackUrl: string | null;
|
||
clientId: string;
|
||
onClientIdChange: (next: string) => void;
|
||
clientSecret: string;
|
||
onClientSecretChange: (next: string) => void;
|
||
required: boolean;
|
||
}) {
|
||
return (
|
||
<div className="space-y-4 rounded-lg border border-border p-4">
|
||
<div>
|
||
<div className="text-sm font-medium text-foreground">
|
||
{required ? method.oauthClientSecretRequired ? "Your OAuth app" : `${entry.name} needs its own OAuth app` : "Use your own OAuth app"}
|
||
</div>
|
||
{/*
|
||
When these fields are required it is a provider limitation, not a step
|
||
Paperclip chose to add. Say so, so the extra work reads as the
|
||
exception it is rather than as this connector's normal path.
|
||
*/}
|
||
<p className="mt-1 text-xs text-muted-foreground">
|
||
{method.oauthClientSecretRequired ? method.guidanceMd : required
|
||
? `${entry.name} does not let Paperclip register itself automatically, so this connector needs an OAuth app you create. Add the callback URL below in ${entry.name}, then paste the client details back here.`
|
||
: `Register Paperclip's callback URI in ${entry.name}, then enter the customer-owned client details.`}
|
||
</p>
|
||
{method.consoleLinks?.register ? (
|
||
<a
|
||
href={method.consoleLinks.register}
|
||
target="_blank"
|
||
rel="noreferrer"
|
||
className="mt-2 inline-flex items-center gap-1 text-xs font-semibold text-foreground underline underline-offset-2"
|
||
>
|
||
Open {entry.name} app settings
|
||
<ArrowUpRight className="h-3 w-3" />
|
||
</a>
|
||
) : null}
|
||
</div>
|
||
{callbackUrl ? (
|
||
<div>
|
||
<label className="text-sm font-medium text-foreground">Paperclip callback URL</label>
|
||
<div className="mt-2 flex min-w-0 flex-col gap-2 sm:flex-row">
|
||
<div
|
||
title={callbackUrl}
|
||
className="min-h-11 min-w-0 flex-1 rounded-md border border-input bg-muted/40 px-3 py-2.5 font-mono text-xs leading-tight text-foreground break-all"
|
||
>
|
||
{callbackUrl}
|
||
</div>
|
||
<CopyValueButton value={callbackUrl} ariaLabel="Copy callback URL" />
|
||
</div>
|
||
<p className="mt-2 text-xs text-muted-foreground">
|
||
Add this exact URL to {entry.name} before continuing. It must match the authorization request.
|
||
</p>
|
||
</div>
|
||
) : null}
|
||
<div>
|
||
<label className="text-sm font-medium text-foreground" htmlFor="curated-oauth-client-id">
|
||
Client ID
|
||
</label>
|
||
<Input
|
||
id="curated-oauth-client-id"
|
||
value={clientId}
|
||
onChange={(event) => onClientIdChange(event.target.value)}
|
||
autoComplete="off"
|
||
placeholder={required ? "Required" : "Optional"}
|
||
className="mt-2 h-11 font-mono"
|
||
/>
|
||
</div>
|
||
<div>
|
||
<label className="text-sm font-medium text-foreground" htmlFor="curated-oauth-client-secret">
|
||
Client secret
|
||
</label>
|
||
<Input
|
||
id="curated-oauth-client-secret"
|
||
type="password"
|
||
value={clientSecret}
|
||
onChange={(event) => onClientSecretChange(event.target.value)}
|
||
autoComplete="off"
|
||
placeholder={method.oauthClientSecretRequired ? "Required" : "Optional for public clients"}
|
||
className="mt-2 h-11 font-mono"
|
||
/>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function MethodConfigField({
|
||
field,
|
||
value,
|
||
onChange,
|
||
}: {
|
||
field: FieldDef;
|
||
value: string | boolean | undefined;
|
||
onChange: (value: string | boolean) => void;
|
||
}) {
|
||
if (field.type === "checkbox") {
|
||
return (
|
||
<div className="flex items-start justify-between gap-4 rounded-lg border border-border p-4">
|
||
<div>
|
||
<div className="text-sm font-medium text-foreground">{field.label}</div>
|
||
{field.helperMd && <div className="mt-1 text-xs text-muted-foreground">{field.helperMd}</div>}
|
||
</div>
|
||
<ToggleSwitch checked={value === true} onCheckedChange={onChange} />
|
||
</div>
|
||
);
|
||
}
|
||
return (
|
||
<div>
|
||
<label className="text-sm font-medium text-foreground">{field.label}</label>
|
||
{field.type === "textarea" ? (
|
||
<Textarea
|
||
value={typeof value === "string" ? value : ""}
|
||
onChange={(event) => onChange(event.target.value)}
|
||
placeholder={field.placeholder}
|
||
className="mt-2 min-h-24"
|
||
/>
|
||
) : field.type === "select" ? (
|
||
<select
|
||
value={typeof value === "string" ? value : ""}
|
||
onChange={(event) => onChange(event.target.value)}
|
||
className="mt-2 h-11 w-full rounded-md border border-input bg-background px-3 text-sm text-foreground outline-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/30"
|
||
>
|
||
<option value="" disabled>Select an option</option>
|
||
{(field.options ?? []).map((option) => <option key={option.value} value={option.value}>{option.label}</option>)}
|
||
</select>
|
||
) : (
|
||
<Input
|
||
value={typeof value === "string" ? value : ""}
|
||
onChange={(event) => onChange(event.target.value)}
|
||
placeholder={field.placeholder}
|
||
aria-label={field.label}
|
||
maxLength={field.validation?.maxLength}
|
||
className="mt-2 h-11"
|
||
/>
|
||
)}
|
||
{field.required && (typeof value !== "string" || !value.trim()) && <p className="mt-2 text-xs text-muted-foreground">{field.label} is required.</p>}
|
||
{field.helperMd && <p className="mt-2 text-xs text-muted-foreground">{field.helperMd}</p>}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
/**
|
||
* Access step (PAP-17835 Surface A).
|
||
*
|
||
* Two binary questions, asked together and *before* any credential is entered,
|
||
* so the reader understands the identity and the reach the secret is about to
|
||
* get. Hick's Law: two choices, not a matrix. Both use full-row radio targets.
|
||
*/
|
||
export function AccessStep({ companyId, ...props }: Omit<Parameters<typeof AccessStepContent>[0], "agents" | "agentsLoading"> & { companyId: string }) {
|
||
const agentsQuery = useQuery({
|
||
queryKey: queryKeys.agents.list(companyId),
|
||
queryFn: () => agentsApi.list(companyId),
|
||
});
|
||
return <AccessStepContent {...props} agents={(agentsQuery.data ?? []).filter((agent) => agent.status !== "terminated")} agentsLoading={agentsQuery.isLoading} />;
|
||
}
|
||
|
||
/** Shared Gmail access presentation; callers supply agents so review stories stay offline. */
|
||
export function AccessStepContent({
|
||
agents: allAgents,
|
||
agentsLoading = false,
|
||
authKind,
|
||
grantKinds,
|
||
grantKind,
|
||
setGrantKind,
|
||
installChoice,
|
||
setInstallChoice,
|
||
installAgentIds,
|
||
setInstallAgentIds,
|
||
lockedAgentId,
|
||
capabilities,
|
||
githubIdentity = false,
|
||
submitLabel,
|
||
continuesToProvider = false,
|
||
identityLoading = false,
|
||
preserveAgentAccess = false,
|
||
pending = false,
|
||
/** Embedded in the Advanced disclosure: no wizard footer to press. */
|
||
hideFooter = false,
|
||
/** Embedded: drop the page-level width cap so it fits its container. */
|
||
bare = false,
|
||
onBack,
|
||
onContinue,
|
||
}: {
|
||
agents: AgentMultiSelectOption[];
|
||
agentsLoading?: boolean;
|
||
authKind: ToolConnectionAuthKind;
|
||
grantKinds?: ConnectionGrantKind[];
|
||
grantKind: ConnectionGrantKind;
|
||
setGrantKind: (kind: ConnectionGrantKind) => void;
|
||
installChoice: "specific" | "all";
|
||
setInstallChoice: (choice: "specific" | "all") => void;
|
||
installAgentIds: Set<string>;
|
||
setInstallAgentIds: (ids: Set<string>) => void;
|
||
/** Task-hosted intents grant reach only to the agent that requested it. */
|
||
lockedAgentId?: string;
|
||
capabilities?: Pick<ToolConnectionCreateCapabilities, "canCreateOrganizationGrant" | "canSetCompanyInstall"> & {
|
||
organizationGrantReason?: string | null;
|
||
companyInstallReason?: string | null;
|
||
editableAgentIds?: string[];
|
||
} | null;
|
||
githubIdentity?: boolean;
|
||
submitLabel: string;
|
||
/** Only show an external-handoff cue when this action starts provider OAuth. */
|
||
continuesToProvider?: boolean;
|
||
/** Wait for a durable OAuth connection before showing a reconnect identity. */
|
||
identityLoading?: boolean;
|
||
/** Reconnect changes credentials only; existing install reach stays intact. */
|
||
preserveAgentAccess?: boolean;
|
||
pending?: boolean;
|
||
hideFooter?: boolean;
|
||
bare?: boolean;
|
||
onBack: () => void;
|
||
onContinue: () => void;
|
||
}) {
|
||
// "Only agents I choose" / "Just agents I pick" means agents this person may actually edit. When the server
|
||
// has not told us, fall back to every live agent rather than an empty list —
|
||
// an empty picker would read as "you have no agents".
|
||
const editableAgentIds = capabilities?.editableAgentIds;
|
||
const agents = editableAgentIds
|
||
? allAgents.filter((agent) => editableAgentIds.includes(agent.id))
|
||
: allAgents;
|
||
// Company-wide install is the connection creator's to give. When it is not
|
||
// available the option stays visible and disabled with the reason, so the
|
||
// scope stays legible instead of quietly disappearing.
|
||
const canSetCompanyInstall = capabilities?.canSetCompanyInstall ?? true;
|
||
const canCreateOrganizationGrant = capabilities?.canCreateOrganizationGrant ?? true;
|
||
const needsIdentityChoice = authKind !== "none";
|
||
const allowedGrantKinds = grantKinds ?? (["user", "organization"] satisfies ConnectionGrantKind[]);
|
||
const identityChoiceAllowed = !needsIdentityChoice
|
||
|| grantKind === "user"
|
||
|| grantKind === "agent"
|
||
|| canCreateOrganizationGrant;
|
||
const canContinue = identityChoiceAllowed && (preserveAgentAccess
|
||
? true
|
||
: lockedAgentId
|
||
? installAgentIds.has(lockedAgentId)
|
||
: grantKind === "agent"
|
||
? installChoice === "specific" && installAgentIds.size === 1
|
||
: installChoice === "all"
|
||
? canSetCompanyInstall
|
||
: installAgentIds.size > 0);
|
||
const lockedAgentName = lockedAgentId
|
||
? allAgents.find((agent) => agent.id === lockedAgentId)?.name ?? "the requesting agent"
|
||
: null;
|
||
const identityHeading = githubIdentity ? "Connect GitHub as" : "Which humans can use this credential?";
|
||
const agentAccessHeading = grantKind === "agent"
|
||
? "Which agent owns this GitHub account?"
|
||
: githubIdentity && grantKind === "user"
|
||
? "Which agents may use your GitHub when you’re responsible?"
|
||
: githubIdentity
|
||
? "Which agents may use the shared GitHub account?"
|
||
: "Which agents can use this connection?";
|
||
const agentAccessLabel = githubIdentity ? agentAccessHeading : "Which agents can use this connection?";
|
||
|
||
return (
|
||
<div className={bare ? "" : "mx-auto max-w-2xl"}>
|
||
<div className="overflow-hidden rounded-xl border border-border bg-background">
|
||
<div className="divide-y divide-border">
|
||
<section className="p-6">
|
||
<h2 className="text-sm font-semibold text-foreground">{identityHeading}</h2>
|
||
{githubIdentity && grantKind === "agent" ? (
|
||
<p className="mt-2 text-sm text-muted-foreground">This agent uses this GitHub account for everyone’s work, instead of the person giving instructions.</p>
|
||
) : null}
|
||
{identityLoading ? (
|
||
<div className="mt-4 grid gap-2 sm:grid-cols-2" aria-label="Loading connection identity">
|
||
<Skeleton className="h-20 w-full rounded-md" />
|
||
<Skeleton className="h-20 w-full rounded-md" />
|
||
</div>
|
||
) : needsIdentityChoice && allowedGrantKinds.length === 1 ? (
|
||
<div className="mt-4 flex items-center gap-3 rounded-md border border-border p-4">
|
||
{allowedGrantKinds[0] === "user" ? (
|
||
<UserRound className="h-4 w-4 shrink-0 text-muted-foreground" aria-hidden="true" />
|
||
) : allowedGrantKinds[0] === "agent" ? (
|
||
<Bot className="h-4 w-4 shrink-0 text-muted-foreground" aria-hidden="true" />
|
||
) : (
|
||
<UsersRound className="h-4 w-4 shrink-0 text-muted-foreground" aria-hidden="true" />
|
||
)}
|
||
<div>
|
||
<div className="text-sm font-medium text-foreground">
|
||
{allowedGrantKinds[0] === "user"
|
||
? githubIdentity ? "My GitHub account" : "Just me"
|
||
: allowedGrantKinds[0] === "agent"
|
||
? "A dedicated account for an agent"
|
||
: githubIdentity ? "Shared organization GitHub account (advanced)" : "Any human in the organization"}
|
||
</div>
|
||
{githubIdentity ? (
|
||
<p className="mt-1 text-xs text-muted-foreground">
|
||
{allowedGrantKinds[0] === "user"
|
||
? "Agents use it only for runs where you are the responsible person."
|
||
: allowedGrantKinds[0] === "agent"
|
||
? "That agent always uses this account, regardless of who starts the run."
|
||
: "Eligible agents use one shared credential, regardless of who starts the run."}
|
||
</p>
|
||
) : null}
|
||
</div>
|
||
</div>
|
||
) : needsIdentityChoice ? (
|
||
<RadioCardGroup
|
||
ariaLabel={githubIdentity ? identityHeading : "Which humans can use this credential?"}
|
||
className="mt-4 sm:grid-cols-2"
|
||
value={grantKind}
|
||
onValueChange={(next) => {
|
||
const kind = next as ConnectionGrantKind;
|
||
setGrantKind(kind);
|
||
if (kind === "agent") {
|
||
setInstallChoice("specific");
|
||
if (installAgentIds.size > 1) setInstallAgentIds(new Set([[...installAgentIds][0]!]));
|
||
}
|
||
}}
|
||
options={[
|
||
{
|
||
value: "user",
|
||
title: githubIdentity ? "My GitHub account" : "Just me",
|
||
description: githubIdentity
|
||
? "Agents use it only for runs where you are the responsible person."
|
||
: undefined,
|
||
icon: <UserRound className="h-4 w-4" aria-hidden="true" />,
|
||
},
|
||
{
|
||
value: "agent",
|
||
title: "A dedicated account for an agent",
|
||
description: githubIdentity
|
||
? "That agent always uses this account, regardless of who starts the run."
|
||
: undefined,
|
||
icon: <Bot className="h-4 w-4" aria-hidden="true" />,
|
||
},
|
||
{
|
||
value: "organization",
|
||
title: githubIdentity ? "Shared organization GitHub account (advanced)" : "Any human in the organization",
|
||
description: githubIdentity
|
||
? "Eligible agents use one shared credential, regardless of who starts the run."
|
||
: undefined,
|
||
icon: <UsersRound className="h-4 w-4" aria-hidden="true" />,
|
||
accessibleLabel: canCreateOrganizationGrant
|
||
? githubIdentity ? "Shared organization GitHub account (advanced)" : "Any human in the organization"
|
||
: `${githubIdentity ? "Shared organization GitHub account (advanced)" : "Any human in the organization"}. Unavailable: ${capabilities?.organizationGrantReason ??
|
||
"Only a connection manager can share this credential with the organization."}`,
|
||
tooltip: canCreateOrganizationGrant
|
||
? undefined
|
||
: capabilities?.organizationGrantReason ??
|
||
"Only a connection manager can share this credential with the organization.",
|
||
disabled: !canCreateOrganizationGrant,
|
||
},
|
||
].filter((option) => allowedGrantKinds.includes(option.value as ConnectionGrantKind))}
|
||
/>
|
||
) : (
|
||
// A connection with no credential has no identity to choose, so
|
||
// asking would be a meaningless decision.
|
||
<p className="mt-4 text-sm text-muted-foreground">No identity required</p>
|
||
)}
|
||
</section>
|
||
|
||
<section className="p-6">
|
||
<h2 className="text-sm font-semibold text-foreground">{agentAccessHeading}</h2>
|
||
{preserveAgentAccess ? (
|
||
<div className="mt-4 flex items-start gap-3 rounded-md border border-border bg-muted/40 p-4">
|
||
<UsersRound className="mt-0.5 h-4 w-4 shrink-0 text-primary" aria-hidden="true" />
|
||
<div>
|
||
<div className="text-sm font-medium text-foreground">Existing agent access stays the same</div>
|
||
<p className="mt-1 text-xs text-muted-foreground">
|
||
Reconnecting replaces the credential without changing which agents can use it.
|
||
</p>
|
||
</div>
|
||
</div>
|
||
) : lockedAgentId ? (
|
||
<p className="mt-2 text-sm text-muted-foreground">
|
||
This task grants access only to <span className="font-medium text-foreground">{lockedAgentName}</span>.
|
||
Existing connection access is left unchanged.
|
||
</p>
|
||
) : (
|
||
grantKind === "agent" ? (
|
||
<p className="mt-2 text-sm text-muted-foreground">Choose exactly one agent. This identity cannot be shared with other agents.</p>
|
||
) : <RadioCardGroup
|
||
ariaLabel={agentAccessLabel}
|
||
className="mt-4 sm:grid-cols-2"
|
||
value={installChoice}
|
||
onValueChange={(next) => setInstallChoice(next as "specific" | "all")}
|
||
options={[
|
||
{
|
||
value: "specific",
|
||
title: githubIdentity ? "Only agents I choose" : "Just agents I pick",
|
||
description: githubIdentity
|
||
? grantKind === "user"
|
||
? "Only selected agents may use your GitHub when you’re responsible."
|
||
: "Only selected agents may use the shared account."
|
||
: undefined,
|
||
icon: <Bot className="h-4 w-4" aria-hidden="true" />,
|
||
},
|
||
{
|
||
value: "all",
|
||
title: "Any agent",
|
||
description: githubIdentity
|
||
? grantKind === "user"
|
||
? "Every agent may use your GitHub when you’re responsible."
|
||
: "Every agent may use the shared account."
|
||
: undefined,
|
||
icon: <BotGroupIcon />,
|
||
accessibleLabel: canSetCompanyInstall
|
||
? "Any agent"
|
||
: `Any agent. Unavailable: ${capabilities?.companyInstallReason ??
|
||
"Only someone who can configure this connection can choose this."}`,
|
||
tooltip: canSetCompanyInstall
|
||
? undefined
|
||
: capabilities?.companyInstallReason ??
|
||
"Only someone who can configure this connection can choose this.",
|
||
disabled: !canSetCompanyInstall,
|
||
},
|
||
]}
|
||
/>
|
||
)}
|
||
{!preserveAgentAccess && !lockedAgentId && installChoice === "specific" ? (
|
||
<div className="mt-3">
|
||
<AgentMultiSelect
|
||
agents={agents}
|
||
selectedAgentIds={installAgentIds}
|
||
onChange={(next) => setInstallAgentIds(
|
||
grantKind === "agent" && next.size > 1 ? new Set([[...next].at(-1)!]) : next,
|
||
)}
|
||
loading={agentsLoading}
|
||
emptyMessage="You cannot edit any agents yet."
|
||
showSelectionPreview={false}
|
||
/>
|
||
</div>
|
||
) : null}
|
||
</section>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Mobile stacks actions full-width with the primary action first in
|
||
reading order; desktop keeps Back on the left. */}
|
||
{hideFooter ? null : <div className="mt-6 flex flex-col-reverse gap-2 sm:flex-row sm:items-center sm:justify-between">
|
||
<Button variant="ghost" className="w-full sm:w-auto" onClick={onBack} disabled={pending}>
|
||
Back
|
||
</Button>
|
||
<Button
|
||
className="w-full sm:w-auto"
|
||
onClick={onContinue}
|
||
disabled={!canContinue || identityLoading || pending}
|
||
>
|
||
{pending ? <Loader2 className="h-4 w-4 animate-spin" aria-hidden="true" /> : null}
|
||
{submitLabel}
|
||
{!pending && continuesToProvider ? <ArrowUpRight className="h-4 w-4" aria-hidden="true" /> : null}
|
||
</Button>
|
||
</div>}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
/**
|
||
* The stated default (PAP-659 C0).
|
||
*
|
||
* The Access step used to ask two questions whose answers were already correct
|
||
* on arrival. Instead of asking, the resolved answer is printed in one line
|
||
* above the primary action and repeated on the completion screen, so the reach
|
||
* being granted is said twice on the happy path without costing a click.
|
||
*/
|
||
export function connectionDefaultSummarySentence(input: {
|
||
grantKind: ConnectionGrantKind;
|
||
authKind: ToolConnectionAuthKind;
|
||
installChoice: "specific" | "all";
|
||
installCount: number;
|
||
lockedAgentId?: string | null;
|
||
preserveAgentAccess?: boolean;
|
||
}): string {
|
||
const identity = input.authKind === "none"
|
||
? "No sign-in needed"
|
||
: input.grantKind === "user"
|
||
? "Connects as you"
|
||
: input.grantKind === "agent"
|
||
? "Connects as a dedicated agent account"
|
||
: "Connects for everyone in your organization";
|
||
const reach = input.preserveAgentAccess
|
||
? "agent access stays as it is"
|
||
: input.lockedAgentId
|
||
? "available to the agent that asked for it"
|
||
: input.installChoice === "all"
|
||
? "available to all agents"
|
||
: input.installCount === 0
|
||
? "no agents selected yet"
|
||
: `available to ${input.installCount} selected ${input.installCount === 1 ? "agent" : "agents"}`;
|
||
return `${identity}, ${reach}.`;
|
||
}
|
||
|
||
/**
|
||
* The stated default plus the collapsed Change disclosure that replaced the
|
||
* Access step. It sits in the same place on every connector and never blocks
|
||
* the primary action: opening it is optional, and everything inside it can
|
||
* also be changed on the Permissions tab after connecting.
|
||
*/
|
||
export function ConnectionAccessDefaults({
|
||
companyId,
|
||
agents,
|
||
sentence,
|
||
notice,
|
||
extra,
|
||
forceOpen = false,
|
||
disabled = false,
|
||
...accessProps
|
||
}: Omit<Parameters<typeof AccessStepContent>[0], "agents" | "agentsLoading" | "submitLabel" | "onBack" | "onContinue" | "pending" | "continuesToProvider" | "hideFooter" | "bare"> & {
|
||
companyId: string;
|
||
/**
|
||
* Supplied by callers that already hold the agent list — design specimens and
|
||
* review stories, which must not reach the network. Omitted in the product,
|
||
* where the disclosure fetches its own.
|
||
*/
|
||
agents?: AgentMultiSelectOption[];
|
||
sentence: string;
|
||
/** Shown when the resolved default genuinely cannot apply. Never a step. */
|
||
notice?: string[];
|
||
/**
|
||
* Connector-specific advanced settings — alternate methods, access level,
|
||
* provider config, a customer-owned OAuth client. They share this one
|
||
* disclosure rather than opening a second one on the same screen.
|
||
*/
|
||
extra?: ReactNode;
|
||
/** Something inside is load-bearing right now, so do not hide it. */
|
||
forceOpen?: boolean;
|
||
disabled?: boolean;
|
||
}) {
|
||
const [open, setOpen] = useState(false);
|
||
const expanded = open || forceOpen;
|
||
return (
|
||
<Collapsible open={expanded} onOpenChange={setOpen} className="mt-6 rounded-lg border border-border bg-muted/30 px-4 py-3">
|
||
<div className="flex flex-wrap items-baseline justify-between gap-x-3 gap-y-1">
|
||
<p className="text-sm text-muted-foreground">{sentence}</p>
|
||
<CollapsibleTrigger asChild><Button
|
||
type="button"
|
||
variant="link"
|
||
className="h-auto p-0 text-xs font-semibold underline underline-offset-2"
|
||
disabled={disabled}
|
||
>
|
||
Change
|
||
</Button></CollapsibleTrigger>
|
||
</div>
|
||
{(notice ?? []).map((reason) => (
|
||
<p key={reason} className="mt-2 text-xs text-muted-foreground">{reason}</p>
|
||
))}
|
||
<CollapsibleContent>
|
||
<div className="mt-3 space-y-6">
|
||
{extra}
|
||
{agents
|
||
? <AccessStepContent {...accessProps} agents={agents} bare hideFooter submitLabel="" onBack={() => {}} onContinue={() => {}} />
|
||
: <AccessStep {...accessProps} companyId={companyId} bare hideFooter submitLabel="" onBack={() => {}} onContinue={() => {}} />}
|
||
</div>
|
||
</CollapsibleContent>
|
||
</Collapsible>
|
||
);
|
||
}
|
||
|
||
function BotGroupIcon() {
|
||
return (
|
||
<span className="relative block h-4 w-5" aria-hidden="true">
|
||
<Bot className="absolute left-0 top-0 h-3.5 w-3.5" />
|
||
<Bot className="absolute bottom-0 right-0 h-3.5 w-3.5" />
|
||
</span>
|
||
);
|
||
}
|
||
|
||
/**
|
||
* Summary of what the Access step committed. Three lines, not badges: identity,
|
||
* reach, and the existing action summary each said once.
|
||
*/
|
||
export function accessSummaryLines(input: {
|
||
grantKind: ConnectionGrantKind;
|
||
authKind: ToolConnectionAuthKind;
|
||
installChoice: "specific" | "all";
|
||
installCount: number;
|
||
enabledCount: number;
|
||
}): Array<{ label: string; value: string }> {
|
||
const identity = input.authKind === "none"
|
||
? "No identity required"
|
||
: input.grantKind === "user"
|
||
? "Your identity"
|
||
: input.grantKind === "agent"
|
||
? "Dedicated agent identity"
|
||
: "Organization identity";
|
||
const availableTo = input.installChoice === "all"
|
||
? "Any agent"
|
||
: `${input.installCount} selected ${input.installCount === 1 ? "agent" : "agents"}`;
|
||
return [
|
||
{ label: "Identity", value: identity },
|
||
{ label: "Available to", value: availableTo },
|
||
{
|
||
label: "Actions",
|
||
value: `${input.enabledCount} ${input.enabledCount === 1 ? "action" : "actions"} on`,
|
||
},
|
||
];
|
||
}
|
||
|
||
function Radio({ selected }: { selected: boolean }) {
|
||
return (
|
||
<span
|
||
className={cn(
|
||
"mt-0.5 inline-flex h-4 w-4 shrink-0 items-center justify-center rounded-full border-2",
|
||
selected ? "border-foreground" : "border-muted-foreground/40",
|
||
)}
|
||
>
|
||
{selected && <span className="h-2 w-2 rounded-full bg-foreground" />}
|
||
</span>
|
||
);
|
||
}
|
||
|
||
export function ConnectionSetupCompletionScreen({
|
||
appName,
|
||
logoUrl,
|
||
darkLogoUrl,
|
||
summary,
|
||
statedDefault,
|
||
onDone,
|
||
}: {
|
||
appName: string;
|
||
logoUrl?: string | null;
|
||
darkLogoUrl?: string | null;
|
||
/** Identity / Available to / Actions, as three lines rather than badges. */
|
||
summary: Array<{ label: string; value: string }>;
|
||
/**
|
||
* The same sentence the connect screen printed above its primary action, so
|
||
* the reach that was granted is stated twice on the happy path (PAP-659 C0).
|
||
*/
|
||
statedDefault?: string;
|
||
onDone: () => void;
|
||
}) {
|
||
return (
|
||
<div className="mx-auto max-w-md py-10 text-center">
|
||
<div className="mx-auto flex h-20 w-20 items-center justify-center rounded-full border-2 border-emerald-500 bg-emerald-500/10">
|
||
<Check className="h-9 w-9 text-emerald-600 dark:text-emerald-400" />
|
||
</div>
|
||
<div className="mt-6 flex items-center justify-center gap-2">
|
||
<AppLogo name={appName} logoUrl={logoUrl} darkLogoUrl={darkLogoUrl} size={28} />
|
||
<h2 className="text-2xl font-bold tracking-tight">{appName} is ready.</h2>
|
||
</div>
|
||
{statedDefault ? <p className="mt-3 text-sm text-muted-foreground">{statedDefault}</p> : null}
|
||
<dl className="mx-auto mt-6 max-w-xs space-y-1 text-left">
|
||
{summary.map((line) => (
|
||
<div key={line.label} className="flex items-baseline justify-between gap-4">
|
||
<dt className="text-xs font-medium text-muted-foreground">{line.label}</dt>
|
||
<dd className="text-sm text-foreground">{line.value}</dd>
|
||
</div>
|
||
))}
|
||
</dl>
|
||
<div className="mt-8">
|
||
<Button size="lg" className="px-10" onClick={onDone}>
|
||
View connection
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|