Files
PaperClipAI/ui/src/features/connections/ConnectionSetupFlow.tsx
T
DottaandPaperclip 22d05644f4 feat(ui): add Speko voice setup, task calls, and Storybook coverage (#15728)
Runtime #15727 is merged. This PR adds the UI. Qualification follows in
#15729.

## Thinking Path

> - Paperclip manages agent work through tasks and governed connections.
> - Chat connections already bind external conversations to those tasks.
> - Speko handles voice and telephony, while the selected Paperclip
agent does the work.
> - A call needs durable session authority, ordered follow-ups, and safe
result delivery.
> - This PR adds the Speko setup and call controls to the existing Apps
and task UI.
> - This PR depends on #15727. The qualification suite follows
separately. Speko stays experimental.

## Linked Issues or Issue Description

**Problem or motivation**

Phone and browser voice conversations cannot use the chat connection
task lifecycle. A separate voice runtime is needed to preserve task
authority, retries, and reply history.

**Proposed solution**

Add company-scoped voice sessions, signed callbacks, durable replies,
inbound intake, and an agent-facing outbound-call tool. Reuse existing
tasks, queues, secrets, and publications.

**Roadmap alignment**

This extends the experimental chat connector entry points under Agent
Chat and Connected Apps. The project owner requested this feature.

Refs #11418 for the original provider catalog proposal. This
implementation uses the newer chat connection infrastructure rather than
an MCP catalog-only integration. Refs #15721 for remote OpenCode
startup.

## What Changed

- Add the Speko Apps catalog entry, provider artwork, and browser SDK.
- Reuse existing agents in setup. Keep the optional Company Phone Agent
in the catalog, with connection instructions available to any assigned
agent.
- Add task voice controls, transcripts, phone callbacks, inbound-line
settings, and call Activity entries.
- Surface retryable connection and incoming-task inventory failures,
preserve active call controls and cached tasks, restore keyboard focus
after recovery, and bound notification polling so a stuck request cannot
hide later results.
- Put Save & exit and Connect Speko in the credential step’s shared
footer.
- Add deterministic production-component Storybook fixtures for setup,
lifecycle, failures, permissions, and phone surfaces.
- Include voice components in the design guide. Stories never request a
real microphone or place calls.

## Verification

- All 27 focused UI/controller regressions, UI typecheck, token gates,
and 28 map/catalog structural tests pass. The shared connection cache
test covers pause/resume invalidation during the 30-second fresh period.
- Storybook builds 157 Speko production-component stories. The complete
prior 156-story set passed all 624 checks on UI source 249cbc616a. After
the final cache repair, all 152 affected page/launcher checks passed
across both themes, 390/1200 widths and reduced motion. Real microphone
access and external provider requests are blocked.
- Source annotations follow the current generated-catalog contract. No
generated catalog or lockfile is included. Three inherited
GitHub-heading tests passed after master reconciliation.
- Latest-head CI and Greptile review must pass before merge. Local
fixture success does not establish provider or spoken-playback
acceptance.
- The separate integrated browser run passed two journeys and failed
browser answer retrieval. Real browser/telephone playback and timing
acceptance remain outstanding. See the dated qualification report and
E01–E16 runbook. Speko stays experimental.

## Risks

- Voice sessions add migrations and provider-dependent lifecycle
behavior. Migrations preserve existing chat and AI transports.
- Callback origins must be reachable HTTPS. Provider keys and signing
secrets remain vaulted.
- Unverified callers receive task-scoped low-trust authority. Caller ID
does not grant private task access.
- Outbound telephony incurs provider charges. Provider acceptance is not
proof that the caller heard an answer.
- Keep the feature behind the existing experimental chat setting until
the real browser, inbound, outbound, and delayed-result acceptance
matrix passes.

## Model Used

OpenAI Codex, GPT-6, with tool use and code execution. The exact serving
model ID and context window are not exposed in this session.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [ ] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] All Paperclip CI gates are green
- [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>
2026-10-13 16:26:38 -05:00

4646 lines
195 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { StepHeader } from "./ConnectionSetupHeader";
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 { 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,
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 {
if (preferPersonal && method?.auth !== "none" && (!method?.grantKinds || method.grantKinds.includes("user"))) return "user";
if (method?.grantKinds?.length === 1) return method.grantKinds[0]!;
if (method?.grantKinds && !method.grantKinds.includes("organization")) return method.grantKinds[0]!;
return "organization";
}
function configuredAgentIdentity(connection: ToolConnection): string | undefined {
const oauth = connection.config?.oauth;
if (!oauth || typeof oauth !== "object" || Array.isArray(oauth)) return undefined;
const value = (oauth as Record<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 interactionId = props.interactionId || searchParams.get("intent") || undefined;
const source = props.serviceSlug || searchParams.get("source") || params.appKey || searchParams.get("appKey");
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 lookup = Boolean(existingId && (!source || isRemoteMcpConnectorId(source) || isMemoryConnectorId(source)));
const existing = useQuery({ queryKey: ["tools", "connection", existingId], queryFn: () => toolsApi.getConnection(existingId!), enabled: lookup });
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 (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 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 { 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(requestedAgentId ? "specific" : "all");
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)).filter((app) =>
chatProviderVisible(app.slug, chatConnectorsEnabled, githubEnabled) ||
!app.methods.some((method) => method.purpose === "channel") ||
appSupportsToolCatalogSetup(app),
),
[galleryQuery.data, chatConnectorsEnabled, githubEnabled, memoryConnectorsEnabled],
);
const fullRequestedDefinition = requestedAppKey
? getConnectableAppDefinition(requestedAppKey)
: null;
const customOAuthMethod = fullRequestedDefinition?.methods.find((candidate) =>
connectionMethodAcceptsCustomerOAuthClient(candidate)
&& !connectionMethodSupportsAutomaticOAuth(candidate),
);
const selectedCustomOAuth = Boolean(customOAuthMethod && connectionMethodKey === customOAuthMethod.key);
const requestedDefinitionUsesManagedConnector = !selectedCustomOAuth && 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;
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, {
asCurrentUser: connection.credentialPolicy === "per_user",
...(connection.credentialPolicy === "per_agent"
? { asAgentId: configuredAgentIdentity(connection) ?? [...installAgentIds][0] }
: {}),
...(connectionIntentId ? { interactionId: connectionIntentId } : {}),
});
},
onSuccess: (start) => void prepareAndOpenOAuth(start),
onError: (error) => {
const details = error instanceof ApiError && error.body && typeof error.body === "object"
? (error.body as { details?: { code?: unknown } }).details
: null;
setOAuthPhase("error");
onPhaseChange?.("needs_retry");
setOAuthError(
details?.code === "invalid_grant"
? "Your authorization expired or was revoked. Reconnect to continue."
: error instanceof Error
? error.message
: "Paperclip couldn’t start secure sign-in. Try again.",
);
},
});
const mutateOAuthStart = oauthStartMutation.mutate;
const startOAuth = useCallback((connection: ToolConnection) => {
onPhaseChange?.("authorizing");
reserveOAuthPopup();
if (host === "dialog" && !connectionIntentId) setDialogOAuthConnectionId(connection.id);
mutateOAuthStart(connection);
}, [mutateOAuthStart, onPhaseChange, reserveOAuthPopup, host, connectionIntentId]);
/**
* Commit the Access step's agent reach for a connection. Shared by the
* key-path finish and the OAuth redirect, so both routes through the wizard
* apply the same selection.
*/
const applyAccessInstalls = async (connectionId: string) => {
// The task completion endpoint adds the requester atomically with resolution.
if (requestedAgentId) return;
const dedicatedIdentity = (fixedGrantKind ?? grantKind) === "agent";
const installState = !dedicatedIdentity && installChoice === "all"
? { onAll: true, agentIds: new Set<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!, {
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 (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 (method?.auth === "oauth") {
reserveOAuthPopup();
}
mutateConnect(entryOverride);
}, [connectionMethodKey, entry, 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 ?? (requestedAgentId ? "specific" : "all"));
// Route/service selection initializes the wizard once. Later renders must
// preserve the user's current step in both hosts instead of snapping back
// to Access after they continue.
setStep(requestedConnectionInitialStep({
requestedAppKey,
routeStage,
resumeConnectionId,
hasPrefilledLink: Boolean(prefill.link),
zapierSource,
}));
} else if (entryAdvertisesManagedConnector !== requestedEntryAdvertisesManagedConnector) {
// A capability refresh must replace the stale gallery entry as well as
// its method, while preserving the chosen audience and wizard step.
setEntry(requestedEntry);
setConnectionMethodKey(initialMethod?.key ?? "");
setConfigValues(defaultMethodConfig(initialMethod));
}
if (automaticOAuth && (
!applicationsQuery.isFetchedAfterMount ||
!connectionsQuery.isFetchedAfterMount
)) return;
if (automaticOAuth && directOAuthRetryingRef.current) return;
if (automaticOAuth && (applicationsQuery.isError || connectionsQuery.isError)) {
setOAuthPhase("error");
setOAuthError("Paperclip couldn’t check for an existing connection. Try again.");
setStep("key");
return;
}
}, [
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))}
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 (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: 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>
);
}