3946 lines
156 KiB
TypeScript
3946 lines
156 KiB
TypeScript
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||
import { useMutation, useQuery } from "@tanstack/react-query";
|
||
import {
|
||
ArrowUpRight,
|
||
Bot,
|
||
Building2,
|
||
Check,
|
||
ChevronDown,
|
||
ChevronRight,
|
||
Cloud,
|
||
Copy,
|
||
Link2,
|
||
Loader2,
|
||
Lock,
|
||
Search,
|
||
UserRound,
|
||
UsersRound,
|
||
} from "lucide-react";
|
||
import type {
|
||
Agent,
|
||
AppDefinition,
|
||
ConnectionGrantKind,
|
||
ConnectionMethodDef,
|
||
ConnectToolAppResult,
|
||
FieldDef,
|
||
ToolApplication,
|
||
ToolConnection,
|
||
ToolConnectionAuthKind,
|
||
ToolConnectionCredentialSource,
|
||
ToolConnectionCreateCapabilities,
|
||
ToolOAuthStartResult,
|
||
} from "@paperclipai/shared";
|
||
import {
|
||
connectionMethodAcceptsCustomerOAuthClient,
|
||
connectionMethodRequiresConfiguration,
|
||
connectionMethodSupportsAutomaticOAuth,
|
||
credentialConfigPath,
|
||
getAppDefinitionForUrl,
|
||
getConnectableAppDefinition,
|
||
getAvailableConnectionMethod,
|
||
getAvailableConnectionMethods,
|
||
getRecommendedConnectionMethod,
|
||
} from "@paperclipai/shared";
|
||
import { useNavigate, useParams, useSearchParams } from "@/lib/router";
|
||
import { useCompany } from "@/context/CompanyContext";
|
||
import { useBreadcrumbs } from "@/context/BreadcrumbContext";
|
||
import { useToast } from "@/context/ToastContext";
|
||
import { queryKeys } from "@/lib/queryKeys";
|
||
import { RadioCardGroup } from "@/components/ui/radio-card";
|
||
import { ApiError } from "@/api/client";
|
||
import { toolsApi } from "@/api/tools";
|
||
import { agentsApi } from "@/api/agents";
|
||
import { appCopyFor, credentialFieldLabel } from "@/lib/app-gallery-copy";
|
||
import { AgentIcon } from "@/components/AgentIconPicker";
|
||
import { AgentMultiSelect } from "@/components/AgentMultiSelect";
|
||
import { InlineBanner } from "@/components/InlineBanner";
|
||
import { Button } from "@/components/ui/button";
|
||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
|
||
import { Input } from "@/components/ui/input";
|
||
import { Textarea } from "@/components/ui/textarea";
|
||
import { ToggleSwitch } from "@/components/ui/toggle-switch";
|
||
import { Skeleton } from "@/components/ui/skeleton";
|
||
import { cn } from "@/lib/utils";
|
||
import { copyTextToClipboard } from "@/lib/clipboard";
|
||
import { resolveAuthorizationTarget } from "@/lib/authorizationUrl";
|
||
import { navigateTopLevel } from "@/lib/browserNavigation";
|
||
import { prepareOAuthNavigation, savePendingCloudHandoff } from "@/lib/oauthHandoff";
|
||
import { redactUrlSecrets } from "@/lib/redact-url-secrets";
|
||
import { AppLogo } from "@/pages/apps/AppLogo";
|
||
import { appApplicationSourceSlug } from "@/pages/apps/app-definition-display";
|
||
import { UnverifiedServerBadge } from "@/pages/apps/UnverifiedServerBadge";
|
||
import {
|
||
appSourceConnectHref,
|
||
isMcpDirectOAuthConnectSlug,
|
||
resolveAppsConnectRouteKey,
|
||
vercelConnectSourceHref,
|
||
} from "@/pages/apps/app-connect-policy";
|
||
import { parseGoogleSheetIds } from "@/pages/apps/google-sheets";
|
||
import { connectionNameForGrantKind } from "@/pages/apps/connection-identity";
|
||
import {
|
||
canSubmitGenericConnect,
|
||
customHeaderError,
|
||
defaultGenericMcpName,
|
||
endpointHost,
|
||
genericConnectGuidance,
|
||
genericConnectPayload,
|
||
newCustomHeaderRow,
|
||
oauthCallbackUrlForBrowser,
|
||
type CustomHeaderRow,
|
||
type GenericConnectDraft,
|
||
type GenericConnectGuidance,
|
||
type GenericMcpAuthMode,
|
||
} from "@/pages/apps/generic-mcp-connect";
|
||
import { autoExtendNotice, INSTALL_ALL_WARNING, installInfoNotice, installPayload } from "@/lib/tool-installs";
|
||
|
||
type Step = "gallery" | "access" | "key" | "success";
|
||
export type OAuthConnectPhase = "entry" | "starting" | "redirecting" | "error";
|
||
|
||
type EnrollmentAccessState = {
|
||
companyId: string;
|
||
grantKind: ConnectionGrantKind;
|
||
installChoice: "specific" | "all";
|
||
agentIds: string[];
|
||
};
|
||
|
||
function enrollmentAccessStorageKey(appKey: string): string {
|
||
return `paperclip.connector-enrollment-access:${appKey}`;
|
||
}
|
||
|
||
function validEnrollmentAccessState(value: unknown): value is EnrollmentAccessState {
|
||
if (!value || typeof value !== "object" || Array.isArray(value)) return false;
|
||
const candidate = value as Record<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>> = {
|
||
access: "access",
|
||
key: "setup",
|
||
success: "complete",
|
||
};
|
||
|
||
export function requestedConnectionInitialStep(input: {
|
||
requestedAppKey: string | undefined;
|
||
routeStage: string | null;
|
||
resumeConnectionId: string | null;
|
||
hasPrefilledLink: boolean;
|
||
zapierSource: boolean;
|
||
}): Step {
|
||
if (input.requestedAppKey) {
|
||
return input.resumeConnectionId || input.routeStage === "setup" ? "key" : "access";
|
||
}
|
||
return input.hasPrefilledLink || input.zapierSource ? "access" : "gallery";
|
||
}
|
||
|
||
export function requestedConnectionEntry(input: {
|
||
requestedAppKey: string;
|
||
galleryApps: readonly AppDefinition[];
|
||
reconnectConnection: ToolConnection | null;
|
||
applications: readonly ToolApplication[];
|
||
}): AppDefinition | null {
|
||
const visible = input.galleryApps.find((candidate) => candidate.slug === input.requestedAppKey);
|
||
if (visible) return visible;
|
||
if (!input.reconnectConnection) return null;
|
||
const application = input.applications.find(
|
||
(candidate) => candidate.id === input.reconnectConnection?.applicationId,
|
||
);
|
||
if (appApplicationSourceSlug(application) !== input.requestedAppKey) return null;
|
||
return getConnectableAppDefinition(input.requestedAppKey);
|
||
}
|
||
|
||
export function retainedReconnectMatches(input: {
|
||
requestedAppKey: string | undefined;
|
||
byo: boolean;
|
||
applicationId: string | undefined;
|
||
reconnectConnection: ToolConnection | null;
|
||
reconnectApplication: ToolApplication | null;
|
||
}): boolean {
|
||
if (
|
||
!input.reconnectConnection
|
||
|| !input.reconnectApplication
|
||
|| input.reconnectConnection.applicationId !== input.reconnectApplication.id
|
||
) return false;
|
||
if (input.requestedAppKey) {
|
||
return appApplicationSourceSlug(input.reconnectApplication) === input.requestedAppKey;
|
||
}
|
||
// Generic MCP applications intentionally have no provider slug. Their
|
||
// reconnect URL instead carries the exact retained application identity.
|
||
return input.byo && input.applicationId === input.reconnectApplication.id;
|
||
}
|
||
|
||
export function isVercelConnectUnavailable(input: {
|
||
credentialSource: ToolConnectionCredentialSource;
|
||
available: boolean;
|
||
retainedReconnectMatches: boolean;
|
||
}): boolean {
|
||
return input.credentialSource === "vercel_connect"
|
||
&& !input.available
|
||
&& !input.retainedReconnectMatches;
|
||
}
|
||
|
||
export function isConnectionDefinitionUnavailable(input: {
|
||
available: boolean | undefined;
|
||
reconnectConnectionId: string | null | undefined;
|
||
reconnectSourceMatches: boolean;
|
||
}): boolean {
|
||
return input.available === false
|
||
&& !(input.reconnectConnectionId && input.reconnectSourceMatches);
|
||
}
|
||
|
||
export function requestedConnectionSetupResolution(input: {
|
||
reconnectConnectionId: string | null | undefined;
|
||
hasRequestedEntry: boolean;
|
||
supportedMethodCount: number;
|
||
unsupportedOAuth: boolean;
|
||
vercelUnavailable: boolean;
|
||
definitionUnavailable: boolean;
|
||
}): "ready" | "fallback" | "reconnect_unavailable" {
|
||
const unavailable = !input.hasRequestedEntry
|
||
|| input.supportedMethodCount === 0
|
||
|| input.unsupportedOAuth
|
||
|| input.vercelUnavailable
|
||
|| input.definitionUnavailable;
|
||
if (!unavailable) return "ready";
|
||
return input.reconnectConnectionId ? "reconnect_unavailable" : "fallback";
|
||
}
|
||
|
||
function appConnectHref(
|
||
appKey: string,
|
||
step: Step,
|
||
credentialSource: ToolConnectionCredentialSource,
|
||
existing?: {
|
||
resumeConnectionId?: string | null;
|
||
reconnectConnectionId?: string | null;
|
||
interactionId?: string | null;
|
||
},
|
||
): string {
|
||
const stage = ROUTE_STAGE_BY_STEP[step] ?? "setup";
|
||
const params = new URLSearchParams({ source: appKey, stage });
|
||
if (existing?.resumeConnectionId) params.set("resume", existing.resumeConnectionId);
|
||
if (existing?.reconnectConnectionId) params.set("reconnect", existing.reconnectConnectionId);
|
||
if (existing?.interactionId) params.set("intent", existing.interactionId);
|
||
const path = credentialSource === "vercel_connect" ? "/apps/vercel-connect" : "/apps/connect";
|
||
return `${path}?${params.toString()}`;
|
||
}
|
||
|
||
function withConnectionIntent(href: string, interactionId?: string | null): string {
|
||
if (!interactionId) return href;
|
||
const [path, query = ""] = href.split("?", 2);
|
||
const params = new URLSearchParams(query);
|
||
params.set("intent", interactionId);
|
||
return `${path}?${params.toString()}`;
|
||
}
|
||
|
||
type AppAccessSelection = "all_agents" | { agentIds: string[] };
|
||
|
||
// Access comes before credentials so the reader knows what identity and reach
|
||
// the secret is about to get before they share it (PAP-17835).
|
||
const STEP_LABELS = ["Pick app", "Access", "Add your key"];
|
||
const STEP_INDEX: Record<Exclude<Step, "success">, number> = {
|
||
gallery: 0,
|
||
access: 1,
|
||
key: 2,
|
||
};
|
||
const SELECTED_APP_STEP_INDEX: Record<Exclude<Step, "gallery" | "success">, number> = {
|
||
access: 0,
|
||
key: 1,
|
||
};
|
||
const ZAPIER_STEP_LABELS = ["Access", "Add MCP URL"];
|
||
|
||
/**
|
||
* Which identity a fresh connection should default to (PAP-17835).
|
||
*
|
||
* Company identity is the product default whenever the selected method permits
|
||
* it. Personal-only methods still stay personal, and reconnects preserve their
|
||
* original identity through the explicit reconnect hint.
|
||
*/
|
||
function defaultGrantKindFor(method: ConnectionMethodDef | null): ConnectionGrantKind {
|
||
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 = getAvailableConnectionMethods(entry);
|
||
return credentialSource === "vercel_connect"
|
||
? methods.filter((method) => Boolean(method.credentialSources?.vercelConnect))
|
||
: methods;
|
||
}
|
||
|
||
function recommendedSetupConnectionMethod(
|
||
methods: readonly ConnectionMethodDef[],
|
||
): ConnectionMethodDef | null {
|
||
const recommended = getRecommendedConnectionMethod(methods);
|
||
// Capability choices (for example Google Workspace read versus write) have
|
||
// an intentional default. Unrelated region/authentication variants should
|
||
// still ask the operator to choose unless only one is available. A method
|
||
// that supports an agent-owned identity must also be selected before the
|
||
// Access step: that ownership decision cannot be represented by a legacy
|
||
// compatibility method such as GitHub's advanced PAT option.
|
||
return methods.length === 1 || recommended?.capabilityProfile || recommended?.grantKinds?.includes("agent")
|
||
? recommended
|
||
: null;
|
||
}
|
||
|
||
function recommendedManagedConnectorMethod(
|
||
entry: AppDefinition | null | undefined,
|
||
): ConnectionMethodDef | null {
|
||
return recommendedSetupConnectionMethod(
|
||
(entry?.methods ?? []).filter((candidate) =>
|
||
candidate.oauthStrategy === "paperclip_cloud_connector"
|
||
|| candidate.oauthStrategy === "paperclip_id_connector",
|
||
),
|
||
);
|
||
}
|
||
|
||
function canUseAutomaticOAuthFastPath(entry: AppDefinition | null | undefined): boolean {
|
||
if (!entry) return false;
|
||
const methods = getAvailableConnectionMethods(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 = getAvailableConnectionMethods(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 {
|
||
byoOnly?: boolean;
|
||
credentialSource?: ToolConnectionCredentialSource;
|
||
host?: "page" | "dialog";
|
||
serviceSlug?: string;
|
||
requestedAgentId?: string;
|
||
interactionId?: string;
|
||
existingConnections?: ToolConnection[];
|
||
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({
|
||
byoOnly = false,
|
||
credentialSource = "paperclip_vault",
|
||
host = "page",
|
||
serviceSlug,
|
||
requestedAgentId,
|
||
interactionId,
|
||
existingConnections = [],
|
||
onUseExisting,
|
||
onComplete,
|
||
onOAuthDeclined,
|
||
onPhaseChange,
|
||
onCancel,
|
||
}: ConnectionSetupFlowProps = {}) {
|
||
const routeNavigate = useNavigate();
|
||
const navigate = useCallback((to: string, options?: { replace?: boolean }) => {
|
||
if (host !== "page") return;
|
||
if (options) routeNavigate(to, options);
|
||
else routeNavigate(to);
|
||
}, [host, routeNavigate]);
|
||
const routeParams = useParams<{ appKey?: string }>();
|
||
const { selectedCompany, selectedCompanyId } = useCompany();
|
||
const { 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 = searchParams.get("new") === "1";
|
||
const routeStage = searchParams.get("stage")?.trim() || null;
|
||
const resumeConnectionId = searchParams.get("resume")?.trim() || null;
|
||
const oauthCallbackOutcome = searchParams.get("oauth");
|
||
const oauthCallbackCode = searchParams.get("code");
|
||
const githubInstallationUrl = githubRecoveryUrl(searchParams.get("installation_url"));
|
||
const githubManagementUrl = githubRecoveryUrl(searchParams.get("management_url"));
|
||
const reconnectConnectionId = 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 = 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: searchParams.get("name")?.trim() ?? "",
|
||
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("");
|
||
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 ?? "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);
|
||
const hydratedResumeConnectionIdRef = useRef<string | null>(null);
|
||
const [hydratedResumeConnectionId, setHydratedResumeConnectionId] = useState<string | null>(null);
|
||
const oauthPopupRef = useRef<Window | 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;
|
||
}
|
||
const popup = oauthPopupRef.current;
|
||
if (!popup || popup.closed) {
|
||
setOAuthPhase("error");
|
||
setOAuthError("Paperclip couldn’t open the sign-in window. Allow popups for this site and try again.");
|
||
onPhaseChange?.("needs_retry");
|
||
return;
|
||
}
|
||
popup.location.assign(url);
|
||
popup.focus();
|
||
}, [host, 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") {
|
||
onComplete?.({ resolvedByCallback: true });
|
||
return;
|
||
}
|
||
if (outcome === "declined") {
|
||
onOAuthDeclined?.();
|
||
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]);
|
||
|
||
const resetGenericAuthState = () => {
|
||
setLinkAuthMode("auto");
|
||
setLinkHeaders([newCustomHeaderRow()]);
|
||
setLinkOAuthClientId("");
|
||
setLinkOAuthClientSecret("");
|
||
setLinkAdvancedOpen(false);
|
||
setLinkGuidance(null);
|
||
setGenericOAuthPending(false);
|
||
};
|
||
|
||
/**
|
||
* Switch to a curated app's branded setup. Reached from the gallery grid and, as
|
||
* a convenience, from the guided generic flow when the pasted endpoint matches a
|
||
* definition — the generic path stays available either way.
|
||
*/
|
||
const useMatchedGalleryEntry = (picked: AppDefinition) => {
|
||
if (picked.slug === "zapier") {
|
||
setEntry(null);
|
||
setGalleryName("");
|
||
setLinkUrl("");
|
||
setLinkName("Zapier");
|
||
setLinkNeedsKey(false);
|
||
setLinkKey("");
|
||
resetGenericAuthState();
|
||
setCredentials({});
|
||
setConnectResult(null);
|
||
setStep("access");
|
||
navigate(withConnectionIntent("/apps/connect?source=zapier", connectionIntentId));
|
||
return;
|
||
}
|
||
if (credentialSource === "paperclip_vault" && canUseAutomaticOAuthFastPath(picked)) {
|
||
navigate(appSourceConnectHref(picked.slug, connectionIntentId));
|
||
return;
|
||
}
|
||
setEntry(picked);
|
||
setGalleryName(picked.name);
|
||
setLinkUrl("");
|
||
setLinkName("");
|
||
setLinkNeedsKey(false);
|
||
setLinkKey("");
|
||
resetGenericAuthState();
|
||
setCredentials({});
|
||
setCuratedOAuthClientId("");
|
||
setCuratedOAuthClientSecret("");
|
||
setVercelConnector("");
|
||
const methods = connectionMethodsForCredentialSource(picked, credentialSource);
|
||
const initialMethod = recommendedSetupConnectionMethod(methods);
|
||
setConnectionMethodKey(initialMethod?.key ?? "");
|
||
setConfigValues(defaultMethodConfig(initialMethod));
|
||
setGoogleSheetsLinks("");
|
||
setGoogleSheetsError(null);
|
||
setConnectResult(null);
|
||
setInstallAgentIds(new Set(requestedAgentId ? [requestedAgentId] : []));
|
||
setInstallChoice(requestedAgentId ? "specific" : "all");
|
||
setGrantKind(reconnectGrantKind ?? defaultGrantKindFor(initialMethod));
|
||
setStep("access");
|
||
navigate(
|
||
credentialSource === "vercel_connect"
|
||
? withConnectionIntent(vercelConnectSourceHref(picked.slug), connectionIntentId)
|
||
: appSourceConnectHref(picked.slug, connectionIntentId),
|
||
);
|
||
};
|
||
|
||
const backToGallery = () => {
|
||
// Back is a wizard transition, so keep the selected app and entered draft
|
||
// intact. Picking another connector will replace that state explicitly.
|
||
setStep("gallery");
|
||
navigate(withConnectionIntent(
|
||
credentialSource === "vercel_connect"
|
||
? vercelConnectSourceHref()
|
||
: byoOnly ? "/apps/byo" : "/apps",
|
||
connectionIntentId,
|
||
));
|
||
};
|
||
|
||
useEffect(() => {
|
||
if (host !== "page") return;
|
||
setBreadcrumbs([
|
||
{ label: "Connectors", href: "/apps" },
|
||
{ label: vercelConnectMode ? "Vercel Connect" : byoOnly ? "Connect your own tool" : "Connect an app" },
|
||
]);
|
||
return () => setBreadcrumbs([]);
|
||
}, [byoOnly, host, setBreadcrumbs, vercelConnectMode]);
|
||
|
||
const galleryQuery = useQuery({
|
||
queryKey: queryKeys.apps.gallery(selectedCompanyId ?? "__none__"),
|
||
queryFn: () => toolsApi.listGallery(selectedCompanyId!),
|
||
enabled: !!selectedCompanyId,
|
||
});
|
||
const fullRequestedDefinition = requestedAppKey
|
||
? getConnectableAppDefinition(requestedAppKey)
|
||
: null;
|
||
const requestedDefinitionUsesManagedConnector = Boolean(
|
||
fullRequestedDefinition?.methods.some((candidate) =>
|
||
candidate.oauthStrategy === "paperclip_cloud_connector"
|
||
|| candidate.oauthStrategy === "paperclip_id_connector"
|
||
),
|
||
);
|
||
const entryAdvertisesManagedConnector = Boolean(
|
||
entry?.methods.some((candidate) =>
|
||
candidate.oauthStrategy === "paperclip_cloud_connector"
|
||
|| candidate.oauthStrategy === "paperclip_id_connector"
|
||
),
|
||
);
|
||
// Before a self-hosted instance enrolls, the server intentionally withholds
|
||
// platform-managed methods from the advertised gallery. The setup route still
|
||
// needs the managed method's identity model, labels, and defaults because the
|
||
// next step is enrollment for that exact method—not the visible PAT/BYO
|
||
// compatibility fallback.
|
||
const preEnrollmentManagedMethod = entry
|
||
&& requestedDefinitionUsesManagedConnector
|
||
&& !entryAdvertisesManagedConnector
|
||
? recommendedManagedConnectorMethod(fullRequestedDefinition)
|
||
: null;
|
||
const connectorEnrollmentQuery = useQuery({
|
||
queryKey: ["cloud-connector", "enrollment"],
|
||
queryFn: () => toolsApi.getCloudConnectorEnrollment(),
|
||
enabled: Boolean(
|
||
selectedCompanyId
|
||
&& requestedDefinitionUsesManagedConnector
|
||
&& !entryAdvertisesManagedConnector
|
||
),
|
||
});
|
||
const [connectorEnrollmentError, setConnectorEnrollmentError] = useState<string | null>(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) {
|
||
setConnectorEnrollmentError(target.message);
|
||
return;
|
||
}
|
||
navigateTopLevel(target.url);
|
||
}, []);
|
||
const startConnectorEnrollment = useMutation({
|
||
mutationFn: () => toolsApi.startCloudConnectorEnrollment(
|
||
selectedCompanyId!,
|
||
selectedCompany?.name,
|
||
requestedAppKey
|
||
? appConnectHref(requestedAppKey, "key", credentialSource, {
|
||
resumeConnectionId,
|
||
reconnectConnectionId,
|
||
interactionId: connectionIntentId,
|
||
})
|
||
: undefined,
|
||
),
|
||
onSuccess: (status) => {
|
||
if (!status.verificationUrl) {
|
||
setConnectorEnrollmentError("Paperclip Cloud did not return an enrollment link. Try again.");
|
||
return;
|
||
}
|
||
openConnectorEnrollment(status.verificationUrl);
|
||
},
|
||
onError: (error) => {
|
||
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(
|
||
() => reusableOAuthConnection(
|
||
directOAuthSource,
|
||
applicationsQuery.data?.applications ?? [],
|
||
connectionsQuery.data?.connections ?? [],
|
||
createNewConnection
|
||
? { applicationId: prefill.applicationId, draftOnly: true }
|
||
: {},
|
||
),
|
||
[applicationsQuery.data, connectionsQuery.data, createNewConnection, directOAuthSource, prefill.applicationId],
|
||
);
|
||
const reconnectConnection = useMemo(
|
||
() => reconnectConnectionId
|
||
? (connectionsQuery.data?.connections ?? []).find((connection) => connection.id === reconnectConnectionId) ?? null
|
||
: null,
|
||
[connectionsQuery.data, reconnectConnectionId],
|
||
);
|
||
const reconnectApplication = useMemo(
|
||
() => reconnectConnection
|
||
? (applicationsQuery.data?.applications ?? []).find(
|
||
(application) => application.id === reconnectConnection.applicationId,
|
||
) ?? null
|
||
: null,
|
||
[applicationsQuery.data, reconnectConnection],
|
||
);
|
||
const reconnectSourceMatches = retainedReconnectMatches({
|
||
requestedAppKey,
|
||
byo,
|
||
applicationId: prefill.applicationId,
|
||
reconnectConnection,
|
||
reconnectApplication,
|
||
});
|
||
const resumeConnection = useMemo(
|
||
() => resumeConnectionId
|
||
? (connectionsQuery.data?.connections ?? []).find((connection) => connection.id === resumeConnectionId) ?? null
|
||
: null,
|
||
[connectionsQuery.data, resumeConnectionId],
|
||
);
|
||
const identityConnection = resumeConnection ?? reconnectConnection;
|
||
const reconnectGrantKind: ConnectionGrantKind | null = identityConnection
|
||
? identityConnection.credentialPolicy === "per_user"
|
||
? "user"
|
||
: identityConnection.credentialPolicy === "per_agent"
|
||
? "agent"
|
||
: "organization"
|
||
: reconnectGrantKindHint;
|
||
const resumableOAuthConnection = resumeConnection?.authKind === "oauth"
|
||
? resumeConnection
|
||
: existingOAuthConnection;
|
||
const existingOAuthGrantKind: ConnectionGrantKind | null = existingOAuthConnection
|
||
? existingOAuthConnection.credentialPolicy === "per_user"
|
||
? "user"
|
||
: existingOAuthConnection.credentialPolicy === "per_agent"
|
||
? "agent"
|
||
: "organization"
|
||
: null;
|
||
const fixedGrantKind = reconnectGrantKind ?? existingOAuthGrantKind;
|
||
|
||
useEffect(() => {
|
||
if (fixedGrantKind) setGrantKind(fixedGrantKind);
|
||
}, [fixedGrantKind]);
|
||
|
||
// A curated definition covering the pasted endpoint is offered as a branded
|
||
// convenience only; the generic flow remains the default and stays complete.
|
||
const linkMatchedEntry = useMemo(
|
||
() => (linkUrl && !entry ? getAppDefinitionForUrl(linkUrl, galleryQuery.data?.apps ?? []) : null),
|
||
[entry, galleryQuery.data, linkUrl],
|
||
);
|
||
|
||
const entryAutomaticOAuthMethod = automaticOAuthMethod(entry);
|
||
const automaticOAuthEntry = credentialSource === "paperclip_vault" && entryAutomaticOAuthMethod ? entry : null;
|
||
const directOAuthEntry = credentialSource === "paperclip_vault" && canUseAutomaticOAuthFastPath(entry) ? entry : null;
|
||
const directOAuthLookupPending = Boolean(directOAuthSource) && (
|
||
!applicationsQuery.isFetchedAfterMount || !connectionsQuery.isFetchedAfterMount
|
||
);
|
||
|
||
const setAppStep = (nextStep: Step) => {
|
||
setStep(nextStep);
|
||
if (entry) {
|
||
navigate(appConnectHref(entry.slug, nextStep, credentialSource, {
|
||
resumeConnectionId,
|
||
reconnectConnectionId,
|
||
interactionId: connectionIntentId,
|
||
}));
|
||
}
|
||
};
|
||
|
||
const oauthStartMutation = useMutation({
|
||
// Retry/reconnect reads identity from the durable connection. Provider is
|
||
// not identity: an organization Notion connection must stay organization-
|
||
// scoped, while a personal one must put its token back on that user grant.
|
||
mutationFn: (connection: ToolConnection) => toolsApi.startOAuth(connection.id, {
|
||
asCurrentUser: connection.credentialPolicy === "per_user",
|
||
...(connection.credentialPolicy === "per_agent"
|
||
? { asAgentId: configuredAgentIdentity(connection) ?? [...installAgentIds][0] }
|
||
: {}),
|
||
...(connectionIntentId ? { interactionId: connectionIntentId } : {}),
|
||
}),
|
||
onSuccess: (start) => void prepareAndOpenOAuth(start),
|
||
onError: (error) => {
|
||
const details = error instanceof ApiError && error.body && typeof error.body === "object"
|
||
? (error.body as { details?: { code?: unknown } }).details
|
||
: null;
|
||
setOAuthPhase("error");
|
||
onPhaseChange?.("needs_retry");
|
||
setOAuthError(
|
||
details?.code === "invalid_grant"
|
||
? "Your authorization expired or was revoked. Reconnect to continue."
|
||
: error instanceof Error
|
||
? error.message
|
||
: "Paperclip couldn’t start secure sign-in. Try again.",
|
||
);
|
||
},
|
||
});
|
||
const mutateOAuthStart = oauthStartMutation.mutate;
|
||
const startOAuth = useCallback((connection: ToolConnection) => {
|
||
onPhaseChange?.("authorizing");
|
||
reserveOAuthPopup();
|
||
mutateOAuthStart(connection);
|
||
}, [mutateOAuthStart, onPhaseChange, reserveOAuthPopup]);
|
||
|
||
/**
|
||
* 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) => {
|
||
const dedicatedIdentity = (fixedGrantKind ?? grantKind) === "agent";
|
||
const installState = !dedicatedIdentity && installChoice === "all"
|
||
? { onAll: true, agentIds: new Set<string>() }
|
||
: { onAll: false, agentIds: installAgentIds };
|
||
await toolsApi.putConnectionInstalls(connectionId, installPayload(selectedCompanyId!, installState));
|
||
};
|
||
|
||
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 = getAvailableConnectionMethod(connectEntry, connectionMethodKey || null);
|
||
const sheetIds = isGoogleSheetsRobotMethod(connectEntry, selectedMethod)
|
||
? parseGoogleSheetIds(googleSheetsLinks).ids
|
||
: [];
|
||
const trimmedGalleryName = galleryName.trim();
|
||
const connectionName = connectionNameForGrantKind(
|
||
trimmedGalleryName || connectEntry.name,
|
||
requestedGrantKind,
|
||
);
|
||
result = await toolsApi.connectApp(selectedCompanyId!, {
|
||
galleryKey: connectEntry.slug,
|
||
...(connectionMethodKey ? { connectionMethodKey } : {}),
|
||
name: connectionName,
|
||
credentialSource,
|
||
...(credentialSource === "paperclip_vault" ? { credentialValues: credentials } : {}),
|
||
...(credentialSource === "vercel_connect"
|
||
? { vercelConnect: { connector: vercelConnector.trim() } }
|
||
: {}),
|
||
...(curatedOAuthClientId.trim()
|
||
? {
|
||
oauthClient: {
|
||
clientId: curatedOAuthClientId.trim(),
|
||
...(curatedOAuthClientSecret ? { clientSecret: curatedOAuthClientSecret } : {}),
|
||
},
|
||
}
|
||
: {}),
|
||
configValues: isGoogleSheetsRobotMethod(connectEntry, selectedMethod)
|
||
? { allowedSpreadsheetIds: sheetIds }
|
||
: Object.keys(configValues).length > 0
|
||
? configValues
|
||
: undefined,
|
||
applicationId: prefill.applicationId,
|
||
...(resumeConnectionId ? { resumeConnectionId } : {}),
|
||
...(requestedGrantKind !== "organization" ? { grantKind: requestedGrantKind } : {}),
|
||
...(requestedGrantKind === "agent" ? { subjectAgentId: [...installAgentIds][0] } : {}),
|
||
});
|
||
} else {
|
||
const genericPayload = genericConnectPayload({
|
||
link: linkUrl,
|
||
name: linkName,
|
||
authMode: linkAuthMode,
|
||
needsKey: linkNeedsKey,
|
||
keyValue: linkKey,
|
||
headers: linkHeaders,
|
||
oauthClientId: linkOAuthClientId,
|
||
oauthClientSecret: linkOAuthClientSecret,
|
||
});
|
||
const connectionName = connectionNameForGrantKind(
|
||
genericPayload.name ?? defaultGenericMcpName(linkUrl) ?? "Custom app",
|
||
effectiveGrantKind,
|
||
);
|
||
result = await toolsApi.connectApp(selectedCompanyId!, {
|
||
...genericPayload,
|
||
// 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") {
|
||
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") 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
|
||
? getAvailableConnectionMethod(connectEntry, connectionMethodKey || null)
|
||
: null;
|
||
if (method?.auth === "oauth") {
|
||
reserveOAuthPopup();
|
||
}
|
||
mutateConnect(entryOverride);
|
||
}, [connectionMethodKey, entry, mutateConnect, reserveOAuthPopup]);
|
||
|
||
useEffect(() => {
|
||
if (!requestedAppKey || galleryQuery.isLoading || !galleryQuery.data) return;
|
||
|
||
if (reconnectConnectionId && (
|
||
!connectionsQuery.isFetchedAfterMount
|
||
|| !applicationsQuery.isFetchedAfterMount
|
||
)) return;
|
||
|
||
const requestedEntry = requestedConnectionEntry({
|
||
requestedAppKey,
|
||
galleryApps: galleryQuery.data.apps,
|
||
reconnectConnection,
|
||
applications: applicationsQuery.data?.applications ?? [],
|
||
});
|
||
const requestedEntryAdvertisesManagedConnector = Boolean(
|
||
requestedEntry?.methods.some((candidate) =>
|
||
candidate.oauthStrategy === "paperclip_cloud_connector"
|
||
|| candidate.oauthStrategy === "paperclip_id_connector"
|
||
),
|
||
);
|
||
// The enrollment lookup decides whether a hidden managed method means
|
||
// "enroll this instance" or "that Cloud profile is unavailable here".
|
||
// Do not choose a method until that distinction is known: retaining the
|
||
// hidden method key after an active enrollment produces an empty setup
|
||
// screen with a permanently disabled generic Connect button.
|
||
if (
|
||
requestedDefinitionUsesManagedConnector
|
||
&& !requestedEntryAdvertisesManagedConnector
|
||
&& connectorEnrollmentQuery.isLoading
|
||
) return;
|
||
const methods = connectionMethodsForCredentialSource(requestedEntry, credentialSource);
|
||
const initialMethod = (
|
||
requestedDefinitionUsesManagedConnector
|
||
&& !requestedEntryAdvertisesManagedConnector
|
||
&& connectorEnrollmentQuery.data?.configured !== true
|
||
? 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));
|
||
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 (
|
||
connectorEnrollmentQuery.data?.configured === true
|
||
&& connectionMethodKey
|
||
&& !methods.some((candidate) => candidate.key === connectionMethodKey)
|
||
) {
|
||
// A failed enrollment lookup can select the hidden pre-enrollment
|
||
// method. Replace it after a successful refetch proves that the instance
|
||
// is enrolled and the current Cloud gallery does not advertise it.
|
||
setConnectionMethodKey(initialMethod?.key ?? "");
|
||
setConfigValues(defaultMethodConfig(initialMethod));
|
||
}
|
||
|
||
if (automaticOAuth && (
|
||
!applicationsQuery.isFetchedAfterMount ||
|
||
!connectionsQuery.isFetchedAfterMount
|
||
)) return;
|
||
if (automaticOAuth && directOAuthRetryingRef.current) return;
|
||
if (automaticOAuth && (applicationsQuery.isError || connectionsQuery.isError)) {
|
||
setOAuthPhase("error");
|
||
setOAuthError("Paperclip couldn’t check for an existing connection. Try again.");
|
||
setStep("key");
|
||
return;
|
||
}
|
||
}, [
|
||
applicationsQuery.isError,
|
||
applicationsQuery.isFetchedAfterMount,
|
||
applicationsQuery.data,
|
||
connectionsQuery.isError,
|
||
connectionsQuery.isFetchedAfterMount,
|
||
connectorEnrollmentQuery.data?.configured,
|
||
connectorEnrollmentQuery.isLoading,
|
||
connectionMethodKey,
|
||
credentialSource,
|
||
entry?.slug,
|
||
galleryQuery.data,
|
||
galleryQuery.isLoading,
|
||
navigate,
|
||
reconnectGrantKind,
|
||
reconnectConnection,
|
||
reconnectConnectionId,
|
||
reconnectSourceMatches,
|
||
resumeConnectionId,
|
||
fullRequestedDefinition,
|
||
requestedAppKey,
|
||
requestedAgentId,
|
||
restoredEnrollmentAccess,
|
||
routeStage,
|
||
zapierSource,
|
||
]);
|
||
|
||
// Resume the exact method and non-secret provider configuration that the
|
||
// interrupted draft already chose. Secrets are intentionally never read back
|
||
// into the browser; credential-based methods ask for a replacement value.
|
||
useEffect(() => {
|
||
hydratedResumeConnectionIdRef.current = null;
|
||
setHydratedResumeConnectionId(null);
|
||
}, [resumeConnectionId]);
|
||
|
||
useEffect(() => {
|
||
if (
|
||
!resumeConnection
|
||
|| !entry
|
||
|| resumeConnection.status !== "draft"
|
||
|| hydratedResumeConnectionIdRef.current === resumeConnection.id
|
||
) return;
|
||
const storedConfig = resumeConnection.config && typeof resumeConnection.config === "object"
|
||
? resumeConnection.config
|
||
: {};
|
||
const storedSource = typeof storedConfig.sourceTemplateKey === "string"
|
||
? storedConfig.sourceTemplateKey
|
||
: null;
|
||
if (storedSource && storedSource !== entry.slug) return;
|
||
|
||
const storedMethodKey = typeof storedConfig.connectionMethodKey === "string"
|
||
? storedConfig.connectionMethodKey
|
||
: null;
|
||
const resumedMethod = storedMethodKey
|
||
? connectionMethodsForCredentialSource(entry, credentialSource).find(
|
||
(candidate) => candidate.key === storedMethodKey,
|
||
) ?? null
|
||
: null;
|
||
setGalleryName(resumeConnection.name || entry.name);
|
||
if (resumedMethod) {
|
||
setConnectionMethodKey(resumedMethod.key);
|
||
const storedMethodConfig = storedConfig.methodConfig && typeof storedConfig.methodConfig === "object"
|
||
? storedConfig.methodConfig as Record<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 = resumeConnection.id;
|
||
setHydratedResumeConnectionId(resumeConnection.id);
|
||
}, [credentialSource, entry, oauthCallbackCode, oauthCallbackOutcome, resumeConnection]);
|
||
|
||
/**
|
||
* Commit the connection: action defaults, agent reach, and installs.
|
||
*
|
||
* Takes the connect result and the enabled set as arguments rather than
|
||
* reading them from state. The Access step removed the separate who/install
|
||
* screens, so this now runs in the same tick as the `setConnectResult` /
|
||
* `setEnabled` that precede it, where that state has not been applied yet.
|
||
*/
|
||
const finishMutation = useMutation({
|
||
mutationFn: async (input: { result: ConnectToolAppResult; enabled: Record<string, boolean> }) => {
|
||
const { result: connected, enabled: enabledMap } = input;
|
||
const enabledIds = Object.entries(enabledMap)
|
||
.filter(([, on]) => on)
|
||
.map(([id]) => id);
|
||
const askFirstRiskLevels = new Set(
|
||
Array.isArray(connected.suggestedDefaults.askFirstRiskLevels)
|
||
? connected.suggestedDefaults.askFirstRiskLevels.filter(
|
||
(riskLevel): riskLevel is string => typeof riskLevel === "string",
|
||
)
|
||
: [],
|
||
);
|
||
const askFirstIds = connected.actions.canMakeChanges
|
||
.filter((action) => enabledMap[action.catalogEntryId] && askFirstRiskLevels.has(action.riskLevel))
|
||
.map((action) => action.catalogEntryId);
|
||
// The Access step asks one question about agent reach, so profile access
|
||
// and installs are committed to the same target set instead of drifting
|
||
// apart behind two separate wizard screens.
|
||
const selection: AppAccessSelection = installChoice === "all"
|
||
? "all_agents"
|
||
: { agentIds: Array.from(installAgentIds) };
|
||
const finished = await toolsApi.finishApp(selectedCompanyId!, connected.connectionId, {
|
||
enabledCatalogEntryIds: enabledIds,
|
||
askFirstCatalogEntryIds: askFirstIds,
|
||
access: selection,
|
||
});
|
||
await applyAccessInstalls(connected.connectionId);
|
||
return finished;
|
||
},
|
||
onSuccess: (_finished, input) => {
|
||
setAppStep("success");
|
||
onComplete?.({ connectionId: input.result.connectionId });
|
||
},
|
||
onError: (error) => {
|
||
// Creation must feel transactional: a failed commit returns the operator
|
||
// to Access with their identity and agent selections intact rather than
|
||
// stranding them on a half-made connection.
|
||
setAppStep("access");
|
||
pushToast({
|
||
title: "Couldn’t finish setup",
|
||
body: error instanceof Error ? error.message : "Please try again.",
|
||
tone: "error",
|
||
});
|
||
},
|
||
});
|
||
|
||
if (!selectedCompanyId) {
|
||
return <div className="p-6 text-sm text-muted-foreground">Select a company 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 company.
|
||
</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 company."
|
||
: "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>
|
||
<div className="space-y-2">
|
||
{existingConnections.map((connection) => (
|
||
<button
|
||
key={connection.id}
|
||
type="button"
|
||
className="flex w-full items-center justify-between gap-4 rounded-lg border border-border bg-card p-4 text-left transition-colors hover:bg-accent disabled:cursor-not-allowed disabled:opacity-60"
|
||
disabled={existingConnectionPendingId !== null}
|
||
onClick={async () => {
|
||
setExistingConnectionPendingId(connection.id);
|
||
setExistingConnectionError(null);
|
||
try {
|
||
await onUseExisting(connection.id);
|
||
} catch (error) {
|
||
setExistingConnectionError(error instanceof Error ? error.message : "Couldn’t use this connection.");
|
||
setExistingConnectionPendingId(null);
|
||
}
|
||
}}
|
||
>
|
||
<span>
|
||
<span className="block font-medium text-foreground">{connection.name}</span>
|
||
<span className="mt-1 block text-xs text-muted-foreground">
|
||
{connection.status === "active" && connection.enabled ? "Ready to use" : "Setup needs attention"}
|
||
</span>
|
||
</span>
|
||
{existingConnectionPendingId === connection.id ? (
|
||
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
|
||
) : (
|
||
<ChevronRight className="h-4 w-4 text-muted-foreground" />
|
||
)}
|
||
</button>
|
||
))}
|
||
</div>
|
||
{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)}>
|
||
Connect new
|
||
</Button>
|
||
{onCancel ? <Button type="button" variant="ghost" onClick={onCancel}>Cancel</Button> : null}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
const showCuratedOAuthState = Boolean(
|
||
automaticOAuthEntry
|
||
&& step === "key"
|
||
&& (directOAuthEntry || oauthPhase !== "entry"),
|
||
);
|
||
|
||
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}
|
||
onRetry={async () => {
|
||
setOAuthError(null);
|
||
setOAuthPhase("starting");
|
||
const connection = connectResult?.connection ?? resumableOAuthConnection;
|
||
if (connection) {
|
||
startOAuth(connection);
|
||
return;
|
||
}
|
||
|
||
// The create request may have reached the server even when its
|
||
// response did not reach the browser. Re-read both resources before
|
||
// creating again so Retry resumes that durable draft instead of
|
||
// duplicating it.
|
||
directOAuthRetryingRef.current = true;
|
||
try {
|
||
const [applicationsResult, connectionsResult] = await Promise.all([
|
||
applicationsQuery.refetch(),
|
||
connectionsQuery.refetch(),
|
||
]);
|
||
if (applicationsResult.isError || connectionsResult.isError) {
|
||
setOAuthPhase("error");
|
||
setOAuthError("Paperclip couldn’t check for an existing connection. Try again.");
|
||
return;
|
||
}
|
||
const refreshedResumeConnection = resumeConnectionId
|
||
? (connectionsResult.data?.connections ?? []).find(
|
||
(candidate) => candidate.id === resumeConnectionId && candidate.authKind === "oauth",
|
||
) ?? null
|
||
: null;
|
||
const refreshedConnection = refreshedResumeConnection ?? reusableOAuthConnection(
|
||
directOAuthSource,
|
||
applicationsResult.data?.applications ?? [],
|
||
connectionsResult.data?.connections ?? [],
|
||
createNewConnection
|
||
? { applicationId: prefill.applicationId, draftOnly: true }
|
||
: {},
|
||
);
|
||
if (!directOAuthAccessConfirmedRef.current && !resumeConnectionId) {
|
||
if (refreshedConnection) {
|
||
setGrantKind(
|
||
refreshedConnection.credentialPolicy === "per_user"
|
||
? "user"
|
||
: refreshedConnection.credentialPolicy === "per_agent"
|
||
? "agent"
|
||
: "organization",
|
||
);
|
||
}
|
||
setOAuthPhase("entry");
|
||
setOAuthError(null);
|
||
setStep("access");
|
||
return;
|
||
}
|
||
if (refreshedConnection) {
|
||
startOAuth(refreshedConnection);
|
||
} else {
|
||
connectMutation.mutate(automaticOAuthEntry);
|
||
}
|
||
} finally {
|
||
directOAuthRetryingRef.current = false;
|
||
}
|
||
}}
|
||
onBack={() => {
|
||
oauthHandoffAbortRef.current?.abort();
|
||
setOAuthPhase("entry");
|
||
setOAuthError(null);
|
||
setAppStep("access");
|
||
}}
|
||
onCancel={() => {
|
||
oauthHandoffAbortRef.current?.abort();
|
||
(onCancel ?? (() => navigate("/apps")))();
|
||
}}
|
||
/>
|
||
);
|
||
}
|
||
|
||
// A pasted endpoint that needs browser sign-in gets the same waiting/retry
|
||
// screen a curated OAuth app does, minus the branding it doesn't have.
|
||
if (genericOAuthPending && !entry && linkUrl && step === "key") {
|
||
return (
|
||
<OAuthConnectStateScreen
|
||
identity={{
|
||
name: linkName.trim() || endpointHost(linkUrl) || "this server",
|
||
unverifiedHost: endpointHost(linkUrl),
|
||
}}
|
||
phase={oauthPhase}
|
||
error={oauthError}
|
||
authorizationHost={authorizationHost}
|
||
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 credentialSourceMethods = connectionMethodsForCredentialSource(entry, credentialSource);
|
||
const setupCredentialSourceMethods = preEnrollmentManagedMethod
|
||
? [preEnrollmentManagedMethod]
|
||
: credentialSourceMethods;
|
||
const credentialSourceApps = vercelConnectMode
|
||
? (galleryQuery.data?.apps ?? []).filter(
|
||
(app) => connectionMethodsForCredentialSource(app, credentialSource).length > 0,
|
||
)
|
||
: galleryQuery.data?.apps ?? [];
|
||
const zapierEntry = zapierSource
|
||
? galleryQuery.data?.apps.find((app) => app.slug === "zapier") ?? null
|
||
: null;
|
||
const stepLabels = zapierSource
|
||
? ZAPIER_STEP_LABELS
|
||
: entry && setupCredentialSourceMethods.length > 1
|
||
? ["Access", "Choose connection"]
|
||
: entry && setupCredentialSourceMethods[0]?.auth === "oauth"
|
||
? ["Access", "Sign in"]
|
||
: isGoogleSheetsRobotMethod(entry, connectionMethodKey)
|
||
? ["Access", "Share sheet"]
|
||
: entry
|
||
? ["Access", "Add your key"]
|
||
: STEP_LABELS;
|
||
// The Access step's identity question only makes sense when there *is* a
|
||
// credential, so it reads the selected method's auth kind.
|
||
const accessStepMethod = entry
|
||
? (connectionMethodKey
|
||
? setupCredentialSourceMethods.find((m) => m.key === connectionMethodKey) ?? null
|
||
: setupCredentialSourceMethods[0] ?? null)
|
||
: null;
|
||
const accessStepAuthKind: ToolConnectionAuthKind = entry
|
||
? accessStepMethod?.auth ?? "none"
|
||
: linkAuthMode === "none"
|
||
? "none"
|
||
: linkAuthMode === "oauth"
|
||
? "oauth"
|
||
: "api_key";
|
||
// Name the actual next effect: multi-method apps and enrollment still have
|
||
// a local setup screen, even when OAuth is already the selected method.
|
||
const accessContinuesToProvider = Boolean(directOAuthEntry);
|
||
const accessSubmitLabel = accessContinuesToProvider
|
||
? `Continue to ${entry?.name ?? "sign-in"}`
|
||
: accessStepAuthKind === "oauth" ? "Continue" : "Save and continue";
|
||
|
||
const stepIndex = (zapierSource || entry) && step !== "gallery" && step !== "success"
|
||
? SELECTED_APP_STEP_INDEX[step]
|
||
: step === "success"
|
||
? stepLabels.length
|
||
: STEP_INDEX[step];
|
||
|
||
return (
|
||
<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."
|
||
: `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 === "gallery" && (
|
||
<GalleryStep
|
||
loading={galleryQuery.isLoading}
|
||
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, galleryQuery.data?.apps ?? []);
|
||
setEntry(null);
|
||
setGalleryName("");
|
||
setLinkUrl(url);
|
||
setLinkName(matchedEntry?.name ?? defaultGenericMcpName(url) ?? "");
|
||
setLinkNeedsKey(false);
|
||
setLinkKey("");
|
||
setCredentials({});
|
||
setGoogleSheetsLinks("");
|
||
setGoogleSheetsError(null);
|
||
setInstallAgentIds(new Set());
|
||
setInstallChoice("all");
|
||
setGrantKind(reconnectGrantKind ?? "organization");
|
||
setStep("access");
|
||
}}
|
||
/>
|
||
)}
|
||
|
||
{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>
|
||
|
||
{connectorEnrollmentQuery.isError || connectorEnrollmentError ? (
|
||
<InlineBanner tone="danger" className="mt-4">
|
||
{connectorEnrollmentError ?? "Paperclip couldn’t check Cloud registration. Try again."}
|
||
</InlineBanner>
|
||
) : null}
|
||
|
||
<div className="mt-6 flex items-center justify-between gap-3">
|
||
<Button type="button" variant="ghost" onClick={() => setAppStep("access")}>
|
||
Back
|
||
</Button>
|
||
<Button
|
||
type="button"
|
||
disabled={connectorEnrollmentQuery.isLoading || startConnectorEnrollment.isPending}
|
||
onClick={() => {
|
||
setConnectorEnrollmentError(null);
|
||
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 ? (
|
||
<KeyStep
|
||
entry={entry}
|
||
values={credentials}
|
||
onChange={setCredentials}
|
||
oauthClientId={curatedOAuthClientId}
|
||
onOAuthClientIdChange={setCuratedOAuthClientId}
|
||
oauthClientSecret={curatedOAuthClientSecret}
|
||
onOAuthClientSecretChange={setCuratedOAuthClientSecret}
|
||
credentialSource={credentialSource}
|
||
vercelConnector={vercelConnector}
|
||
onVercelConnectorChange={setVercelConnector}
|
||
vercelConnectAvailability={galleryQuery.data?.credentialSources?.vercelConnect ?? null}
|
||
methodKey={connectionMethodKey}
|
||
onMethodChange={(nextMethod) => {
|
||
setConnectionMethodKey(nextMethod?.key ?? "");
|
||
if (!reconnectGrantKind) {
|
||
setGrantKind(defaultGrantKindFor(nextMethod));
|
||
}
|
||
setCredentials({});
|
||
setCuratedOAuthClientId("");
|
||
setCuratedOAuthClientSecret("");
|
||
setVercelConnector("");
|
||
setConfigValues(defaultMethodConfig(nextMethod));
|
||
}}
|
||
configValues={configValues}
|
||
onConfigChange={setConfigValues}
|
||
googleSheetsLinks={googleSheetsLinks}
|
||
googleSheetsError={googleSheetsError}
|
||
onGoogleSheetsLinksChange={(next) => {
|
||
setGoogleSheetsLinks(next);
|
||
setGoogleSheetsError(null);
|
||
}}
|
||
submitting={connectMutation.isPending}
|
||
// Back returns to Access for new, resumed, and reconnected accounts.
|
||
// Cancel is the separate exit to the connector list.
|
||
onBack={() => setAppStep("access")}
|
||
onConnect={() => {
|
||
if (isGoogleSheetsRobotMethod(entry, connectionMethodKey)) {
|
||
const parsed = parseGoogleSheetIds(googleSheetsLinks);
|
||
if (parsed.invalidCount > 0) {
|
||
setGoogleSheetsError("That doesn't look like a Google Sheets link.");
|
||
return;
|
||
}
|
||
if (parsed.ids.length === 0) {
|
||
setGoogleSheetsError("Paste at least one Google Sheets link.");
|
||
return;
|
||
}
|
||
}
|
||
const selectedMethod = getAvailableConnectionMethod(entry, connectionMethodKey || null);
|
||
const selectedMethodHasProviderFields = Boolean(
|
||
selectedMethod?.credentialFields?.length
|
||
|| selectedMethod?.tenantFields?.some((field) => !field.hidden)
|
||
|| selectedMethod?.extensionFields?.some((field) => !field.hidden),
|
||
);
|
||
if (
|
||
selectedMethod
|
||
&& connectionMethodSupportsAutomaticOAuth(selectedMethod)
|
||
&& resumableOAuthConnection
|
||
&& !selectedMethodHasProviderFields
|
||
&& !curatedOAuthClientId.trim()
|
||
) {
|
||
directOAuthAccessConfirmedRef.current = true;
|
||
setOAuthError(null);
|
||
setOAuthPhase("starting");
|
||
startOAuth(resumableOAuthConnection);
|
||
return;
|
||
}
|
||
if (selectedMethod && connectionMethodSupportsAutomaticOAuth(selectedMethod)) {
|
||
directOAuthAccessConfirmedRef.current = true;
|
||
setOAuthError(null);
|
||
setOAuthPhase("starting");
|
||
}
|
||
connectApp();
|
||
}}
|
||
/>
|
||
) : null}
|
||
|
||
{step === "key" && !entry && linkUrl && !zapierSource && (
|
||
<LinkConnectStep
|
||
link={linkUrl}
|
||
name={linkName}
|
||
needsKey={linkNeedsKey}
|
||
onNeedsKeyChange={(next) => {
|
||
setLinkNeedsKey(next);
|
||
if (!next) setLinkKey("");
|
||
}}
|
||
keyValue={linkKey}
|
||
onKeyChange={setLinkKey}
|
||
authMode={linkAuthMode}
|
||
onAuthModeChange={(next) => {
|
||
setLinkAuthMode(next);
|
||
setLinkGuidance(null);
|
||
// Leaving the simple path means the explicit choice governs; drop the
|
||
// "does it need a key?" answer so the two can't disagree.
|
||
if (next !== "auto") setLinkNeedsKey(false);
|
||
if (next !== "bearer" && next !== "auto") setLinkKey("");
|
||
}}
|
||
headers={linkHeaders}
|
||
onHeadersChange={(next) => {
|
||
setLinkHeaders(next);
|
||
setLinkGuidance(null);
|
||
}}
|
||
oauthClientId={linkOAuthClientId}
|
||
onOAuthClientIdChange={setLinkOAuthClientId}
|
||
oauthClientSecret={linkOAuthClientSecret}
|
||
onOAuthClientSecretChange={setLinkOAuthClientSecret}
|
||
advancedOpen={linkAdvancedOpen}
|
||
onAdvancedOpenChange={setLinkAdvancedOpen}
|
||
guidance={linkGuidance}
|
||
matchedEntry={linkMatchedEntry}
|
||
onUseMatchedEntry={linkMatchedEntry ? () => useMatchedGalleryEntry(linkMatchedEntry) : undefined}
|
||
submitting={connectMutation.isPending || genericOAuthPending}
|
||
onBack={() => setStep("access")}
|
||
onConnect={() => {
|
||
setLinkGuidance(null);
|
||
connectMutation.mutate(undefined);
|
||
}}
|
||
/>
|
||
)}
|
||
|
||
{step === "key" && !entry && zapierSource && (
|
||
<ZapierConnectStep
|
||
link={linkUrl}
|
||
onLinkChange={setLinkUrl}
|
||
submitting={connectMutation.isPending}
|
||
onBack={() => setStep("access")}
|
||
onConnect={() => connectMutation.mutate(undefined)}
|
||
/>
|
||
)}
|
||
|
||
{step === "access" && (
|
||
<AccessStep
|
||
companyId={selectedCompanyId}
|
||
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"}
|
||
submitLabel={accessSubmitLabel}
|
||
continuesToProvider={accessContinuesToProvider}
|
||
identityLoading={Boolean(automaticOAuthEntry) && directOAuthLookupPending}
|
||
preserveAgentAccess={Boolean(automaticOAuthEntry && (resumableOAuthConnection || reconnectConnection))}
|
||
pending={connectMutation.isPending || oauthStartMutation.isPending}
|
||
onBack={backToGallery}
|
||
onContinue={() => {
|
||
if (directOAuthEntry) {
|
||
directOAuthAccessConfirmedRef.current = true;
|
||
setOAuthError(null);
|
||
setOAuthPhase("starting");
|
||
setAppStep("key");
|
||
if (resumableOAuthConnection) startOAuth(resumableOAuthConnection);
|
||
else connectApp(directOAuthEntry);
|
||
return;
|
||
}
|
||
if (entry) setAppStep("key");
|
||
else setStep("key");
|
||
}}
|
||
/>
|
||
)}
|
||
|
||
{step === "success" && (
|
||
<SuccessStep
|
||
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,
|
||
})}
|
||
onDone={onCancel ?? (() => navigate("/apps"))}
|
||
/>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function StepHeader({
|
||
subtitle,
|
||
step,
|
||
activeIndex,
|
||
labels,
|
||
appIdentity,
|
||
unverifiedHost,
|
||
onCancel,
|
||
}: {
|
||
subtitle: string;
|
||
step: Step;
|
||
activeIndex: number;
|
||
labels: string[];
|
||
appIdentity?: { name: string; logoUrl: string | null; darkLogoUrl?: string | null };
|
||
/**
|
||
* Host of an unknown remote MCP server. Present for the whole generic flow so
|
||
* the operator can see whose server they are configuring at every step, not
|
||
* just on the screen where they pasted the address.
|
||
*/
|
||
unverifiedHost?: string | null;
|
||
onCancel: () => void;
|
||
}) {
|
||
return (
|
||
<div className="mb-6">
|
||
<div className="flex items-start justify-between gap-4">
|
||
<div className="flex items-center gap-3">
|
||
{appIdentity ? (
|
||
<AppLogo name={appIdentity.name} logoUrl={appIdentity.logoUrl} darkLogoUrl={appIdentity.darkLogoUrl} size={44} />
|
||
) : null}
|
||
<div>
|
||
<h1 className="text-2xl font-bold tracking-tight">
|
||
{appIdentity ? `Connect ${appIdentity.name}` : "Connect your own MCP server"}
|
||
</h1>
|
||
<p className="mt-1 text-sm text-muted-foreground">{subtitle}</p>
|
||
{unverifiedHost ? <UnverifiedServerBadge host={unverifiedHost} className="mt-2" /> : null}
|
||
</div>
|
||
</div>
|
||
<Button variant="ghost" size="sm" onClick={onCancel}>
|
||
Cancel
|
||
</Button>
|
||
</div>
|
||
{step !== "gallery" && (
|
||
<div className="mt-4">
|
||
<div className="flex gap-2">
|
||
{labels.map((label, i) => (
|
||
<div
|
||
key={label}
|
||
className={cn("h-1 w-20 rounded-full", i <= activeIndex ? "bg-foreground" : "bg-border")}
|
||
/>
|
||
))}
|
||
</div>
|
||
<div className="mt-2 text-xs text-muted-foreground">{labels.join(" · ")}</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
export function OAuthConnectStateScreen({
|
||
entry,
|
||
identity,
|
||
resuming = false,
|
||
phase,
|
||
error,
|
||
recoveryActions,
|
||
authorizationHost,
|
||
onRetry,
|
||
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;
|
||
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={1}
|
||
labels={["Access", "Sign in", "Ready"]}
|
||
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 === "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}>
|
||
{phase === "entry"
|
||
? resuming ? `Finish with ${serverName}` : `Continue to ${serverName}`
|
||
: "Try again"}
|
||
</Button>
|
||
) : (
|
||
<Button type="button" disabled>
|
||
{phase === "redirecting" ? `Opening ${serverName}…` : "Preparing…"}
|
||
</Button>
|
||
)}
|
||
<Button type="button" variant="ghost" onClick={onBack}>Back</Button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function ZapierConnectStep({
|
||
link,
|
||
onLinkChange,
|
||
submitting,
|
||
onBack,
|
||
onConnect,
|
||
}: {
|
||
link: string;
|
||
onLinkChange: (next: string) => void;
|
||
submitting: boolean;
|
||
onBack: () => void;
|
||
onConnect: () => void;
|
||
}) {
|
||
const normalizedLink = normalizeAppLink(link);
|
||
const zapierHostname = normalizedLink ? new URL(normalizedLink).hostname : "";
|
||
const isZapierLink = zapierHostname === "zapier.com" || zapierHostname.endsWith(".zapier.com");
|
||
|
||
return (
|
||
<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>
|
||
|
||
<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,
|
||
apps,
|
||
byo = false,
|
||
byoOnly = false,
|
||
vercelConnect = false,
|
||
vercelConnectAvailability = null,
|
||
source = null,
|
||
onPick,
|
||
onUseLink,
|
||
}: {
|
||
loading: boolean;
|
||
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("");
|
||
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();
|
||
if (!q) return apps;
|
||
return apps.filter((a) => a.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 = getAvailableConnectionMethods(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) => {
|
||
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,
|
||
onNeedsKeyChange,
|
||
keyValue,
|
||
onKeyChange,
|
||
authMode,
|
||
onAuthModeChange,
|
||
headers,
|
||
onHeadersChange,
|
||
oauthClientId,
|
||
onOAuthClientIdChange,
|
||
oauthClientSecret,
|
||
onOAuthClientSecretChange,
|
||
advancedOpen,
|
||
onAdvancedOpenChange,
|
||
guidance,
|
||
matchedEntry,
|
||
onUseMatchedEntry,
|
||
submitting,
|
||
onBack,
|
||
onConnect,
|
||
}: {
|
||
link: string;
|
||
name: string;
|
||
needsKey: boolean;
|
||
onNeedsKeyChange: (next: boolean) => void;
|
||
keyValue: string;
|
||
onKeyChange: (next: string) => void;
|
||
authMode: GenericMcpAuthMode;
|
||
onAuthModeChange: (next: GenericMcpAuthMode) => void;
|
||
headers: CustomHeaderRow[];
|
||
onHeadersChange: (next: CustomHeaderRow[]) => void;
|
||
oauthClientId: string;
|
||
onOAuthClientIdChange: (next: string) => void;
|
||
oauthClientSecret: string;
|
||
onOAuthClientSecretChange: (next: string) => void;
|
||
advancedOpen: boolean;
|
||
onAdvancedOpenChange: (next: boolean) => void;
|
||
guidance: GenericConnectGuidance | null;
|
||
/** A curated app whose endpoint matches, offered as a convenience only. */
|
||
matchedEntry?: AppDefinition | null;
|
||
onUseMatchedEntry?: () => void;
|
||
submitting: boolean;
|
||
onBack: () => void;
|
||
onConnect: () => void;
|
||
}) {
|
||
const host = endpointHost(link);
|
||
const headerError = authMode === "custom_headers" ? customHeaderError(headers) : null;
|
||
const draft: GenericConnectDraft = {
|
||
link,
|
||
name,
|
||
authMode,
|
||
needsKey,
|
||
keyValue,
|
||
headers,
|
||
oauthClientId,
|
||
oauthClientSecret,
|
||
};
|
||
const canSubmit = canSubmitGenericConnect(draft);
|
||
const showSimpleKeyQuestion = authMode === "auto";
|
||
const displayedLink = matchedEntry?.slug === "zapier" ? redactUrlSecrets(link) : link;
|
||
|
||
const updateHeader = (id: string, patch: Partial<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">
|
||
{showSimpleKeyQuestion && (
|
||
<div>
|
||
<label className="mr-2 text-sm font-medium text-foreground">Does it need a key?</label>
|
||
<div className="mt-2 inline-flex rounded-lg border border-border bg-muted/50 p-1">
|
||
<SegmentedOption
|
||
label="No"
|
||
selected={!needsKey}
|
||
onClick={() => onNeedsKeyChange(false)}
|
||
/>
|
||
<SegmentedOption
|
||
label="Yes"
|
||
selected={needsKey}
|
||
onClick={() => onNeedsKeyChange(true)}
|
||
/>
|
||
</div>
|
||
<p className="mt-2 text-xs text-muted-foreground">
|
||
{needsKey
|
||
? "Paste the key this app gave you."
|
||
: "Most servers just work from the address — pick Yes only if the server gave you a key, or if it asks you to sign in."}
|
||
</p>
|
||
</div>
|
||
)}
|
||
|
||
{(showSimpleKeyQuestion && needsKey) || authMode === "bearer" ? (
|
||
<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"
|
||
/>
|
||
</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>
|
||
|
||
<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({
|
||
entry,
|
||
values,
|
||
onChange,
|
||
oauthClientId,
|
||
onOAuthClientIdChange,
|
||
oauthClientSecret,
|
||
onOAuthClientSecretChange,
|
||
credentialSource,
|
||
vercelConnector,
|
||
onVercelConnectorChange,
|
||
vercelConnectAvailability,
|
||
methodKey,
|
||
onMethodChange,
|
||
configValues,
|
||
onConfigChange,
|
||
googleSheetsLinks,
|
||
googleSheetsError,
|
||
onGoogleSheetsLinksChange,
|
||
submitting,
|
||
onBack,
|
||
onConnect,
|
||
}: {
|
||
entry: AppDefinition;
|
||
values: Record<string, string>;
|
||
onChange: (next: Record<string, string>) => void;
|
||
oauthClientId: string;
|
||
onOAuthClientIdChange: (next: string) => void;
|
||
oauthClientSecret: string;
|
||
onOAuthClientSecretChange: (next: string) => void;
|
||
credentialSource: ToolConnectionCredentialSource;
|
||
vercelConnector: string;
|
||
onVercelConnectorChange: (next: string) => void;
|
||
vercelConnectAvailability: {
|
||
available: boolean;
|
||
manageUrl: string;
|
||
reason: string | null;
|
||
} | null;
|
||
methodKey: string;
|
||
onMethodChange: (method: ConnectionMethodDef | null) => void;
|
||
configValues: Record<string, string | boolean>;
|
||
onConfigChange: (next: Record<string, string | boolean>) => void;
|
||
googleSheetsLinks: string;
|
||
googleSheetsError: string | null;
|
||
onGoogleSheetsLinksChange: (next: string) => void;
|
||
submitting: boolean;
|
||
onBack: () => void;
|
||
onConnect: () => void;
|
||
}) {
|
||
const methods = useMemo(
|
||
() => connectionMethodsForCredentialSource(entry, credentialSource),
|
||
[credentialSource, entry],
|
||
);
|
||
const method = methodKey
|
||
? methods.find((candidate) => candidate.key === methodKey) ?? null
|
||
: recommendedSetupConnectionMethod(methods);
|
||
const capabilityGroups = useMemo(() => {
|
||
if (!methods.some((candidate) => candidate.capabilityProfile)) return [];
|
||
return Array.from(methods.reduce((groups, candidate) => {
|
||
const key = candidate.capabilityProfile!.key;
|
||
const existing = groups.get(key);
|
||
if (existing) {
|
||
existing.methods.push(candidate);
|
||
} else {
|
||
groups.set(key, {
|
||
key,
|
||
label: candidate.capabilityProfile?.label ?? candidate.label ?? candidate.key,
|
||
description: candidate.capabilityProfile?.description ?? candidate.whenToUse,
|
||
methods: [candidate],
|
||
});
|
||
}
|
||
return groups;
|
||
}, new Map<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),
|
||
helpUrl: method?.consoleLinks?.keys ?? method?.consoleLinks?.docs ?? "",
|
||
}));
|
||
const vercelReview = method?.credentialSources?.vercelConnect ?? null;
|
||
const usingVercel = credentialSource === "vercel_connect";
|
||
const allFilled = usingVercel || fields.every(
|
||
(f) => f.required === false || (values[f.configPath]?.trim().length ?? 0) > 0,
|
||
);
|
||
const acceptsCustomerOAuthClient = connectionMethodAcceptsCustomerOAuthClient(method);
|
||
const customerOAuthClientRequired = acceptsCustomerOAuthClient
|
||
&& !connectionMethodSupportsAutomaticOAuth(method);
|
||
const oauthClientFilled = usingVercel || !customerOAuthClientRequired || oauthClientId.trim().length > 0;
|
||
const vercelConnectorFilled = !usingVercel || vercelConnector.trim().length > 0;
|
||
const oauthCallbackUrl = method?.auth === "oauth" && acceptsCustomerOAuthClient
|
||
? oauthCallbackUrlForBrowser()
|
||
: null;
|
||
const allConfigFields = [...(method?.tenantFields ?? []), ...(method?.extensionFields ?? [])];
|
||
const configFields = allConfigFields.filter((field) => !field.hidden);
|
||
const standardConfigFields = configFields.filter((field) => field.advanced !== true);
|
||
const advancedConfigFields = configFields.filter((field) => field.advanced === true);
|
||
const [advancedOpen, setAdvancedOpen] = useState(false);
|
||
const configFilled = allConfigFields.every((field) => {
|
||
if (!field.required) return true;
|
||
const value = configValues[field.key];
|
||
return typeof value === "boolean" || (typeof value === "string" && value.trim().length > 0);
|
||
});
|
||
const alternativeKeys = method?.configRequirements?.atLeastOneOf ?? [];
|
||
const configRequirementMet = alternativeKeys.length === 0 || alternativeKeys.some((key) => {
|
||
const value = configValues[key];
|
||
return typeof value === "string" && value.trim().length > 0;
|
||
});
|
||
const hasCapabilitySelection = capabilityGroups.length <= 1 || Boolean(capabilityKey);
|
||
const hasMethodSelection = hasCapabilitySelection
|
||
&& (capabilityMethods.length <= 1 ? Boolean(method) : Boolean(methodKey));
|
||
const robotEmail = entry.availability?.robotEmail ?? null;
|
||
const unavailable = entry.availability?.available === false;
|
||
const optionalCustomerOAuthClient = !usingVercel
|
||
&& acceptsCustomerOAuthClient
|
||
&& !customerOAuthClientRequired;
|
||
const hasAdvancedSettings = advancedConfigFields.length > 0 || optionalCustomerOAuthClient;
|
||
const capabilitySelection = capabilityGroups.length > 1 ? (
|
||
<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 authenticationSelection = capabilityMethods.length > 1 ? (
|
||
<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;
|
||
|
||
if (isGoogleSheetsRobotMethod(entry, method)) {
|
||
const parsed = parseGoogleSheetIds(googleSheetsLinks);
|
||
const canConnect = !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>
|
||
<Button
|
||
type="button"
|
||
variant="outline"
|
||
className="shrink-0"
|
||
onClick={() => void copyTextToClipboard(robotEmail).catch(() => {})}
|
||
>
|
||
<Copy className="mr-2 h-4 w-4" />
|
||
Copy
|
||
</Button>
|
||
</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>
|
||
|
||
<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 requirementsUrl = 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">
|
||
{capabilitySelection}
|
||
{authenticationSelection}
|
||
|
||
{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}
|
||
|
||
{standardConfigFields.map((field) => (
|
||
<MethodConfigField
|
||
key={field.key}
|
||
field={field}
|
||
value={configValues[field.key]}
|
||
onChange={(value) => onConfigChange({ ...configValues, [field.key]: value })}
|
||
/>
|
||
))}
|
||
|
||
{hasAdvancedSettings && (
|
||
<Collapsible open={advancedOpen} onOpenChange={setAdvancedOpen}>
|
||
<CollapsibleTrigger className="flex items-center gap-1.5 text-sm text-muted-foreground hover:text-foreground">
|
||
<ChevronDown className={cn("h-4 w-4 transition-transform", advancedOpen && "rotate-180")} />
|
||
Advanced
|
||
</CollapsibleTrigger>
|
||
<CollapsibleContent className="pt-4">
|
||
<div className="space-y-6">
|
||
{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>
|
||
</CollapsibleContent>
|
||
</Collapsible>
|
||
)}
|
||
|
||
{!usingVercel && method?.auth === "oauth" && customerOAuthClientRequired ? (
|
||
<OAuthClientFields
|
||
entry={entry}
|
||
method={method}
|
||
callbackUrl={oauthCallbackUrl}
|
||
clientId={oauthClientId}
|
||
onClientIdChange={onOAuthClientIdChange}
|
||
clientSecret={oauthClientSecret}
|
||
onClientSecretChange={onOAuthClientSecretChange}
|
||
required
|
||
/>
|
||
) : 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="password"
|
||
autoComplete="off"
|
||
value={values[field.configPath] ?? ""}
|
||
onChange={(e) => onChange({ ...values, [field.configPath]: e.target.value })}
|
||
placeholder="••••••••••••••••"
|
||
className="mt-2 h-11 font-mono"
|
||
/>
|
||
{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>
|
||
|
||
<div className="mt-8 flex items-center justify-between">
|
||
<Button variant="ghost" onClick={onBack} disabled={submitting}>
|
||
Back
|
||
</Button>
|
||
<Button onClick={onConnect} disabled={submitting || !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>
|
||
);
|
||
}
|
||
|
||
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 ? "Your OAuth app" : "Use your own OAuth app"}
|
||
</div>
|
||
<p className="mt-1 text-xs text-muted-foreground">
|
||
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>
|
||
<Button
|
||
type="button"
|
||
variant="outline"
|
||
className="shrink-0"
|
||
onClick={() => void copyTextToClipboard(callbackUrl).catch(() => {})}
|
||
>
|
||
<Copy className="mr-2 h-4 w-4" />
|
||
Copy
|
||
</Button>
|
||
</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="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}
|
||
className="mt-2 h-11"
|
||
/>
|
||
)}
|
||
{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,
|
||
authKind,
|
||
grantKinds,
|
||
grantKind,
|
||
setGrantKind,
|
||
installChoice,
|
||
setInstallChoice,
|
||
installAgentIds,
|
||
setInstallAgentIds,
|
||
lockedAgentId,
|
||
capabilities,
|
||
githubIdentity = false,
|
||
submitLabel,
|
||
continuesToProvider = false,
|
||
identityLoading = false,
|
||
preserveAgentAccess = false,
|
||
pending = false,
|
||
onBack,
|
||
onContinue,
|
||
}: {
|
||
companyId: string;
|
||
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;
|
||
onBack: () => void;
|
||
onContinue: () => void;
|
||
}) {
|
||
const agentsQuery = useQuery({
|
||
queryKey: queryKeys.agents.list(companyId),
|
||
queryFn: () => agentsApi.list(companyId),
|
||
});
|
||
const allAgents: Agent[] = (agentsQuery.data ?? []).filter((a) => a.status !== "terminated");
|
||
// "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="mx-auto max-w-2xl">
|
||
<div className="overflow-hidden rounded-xl border border-border">
|
||
<div className="divide-y divide-border">
|
||
<section className="p-6">
|
||
<h2 className="text-sm font-semibold text-foreground">{identityHeading}</h2>
|
||
{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 company GitHub account (advanced)" : "Any human in the company"}
|
||
</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 company GitHub account (advanced)" : "Any human in the company",
|
||
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 company GitHub account (advanced)" : "Any human in the company"
|
||
: `${githubIdentity ? "Shared company GitHub account (advanced)" : "Any human in the company"}. 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={agentsQuery.isLoading}
|
||
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. */}
|
||
<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>
|
||
);
|
||
}
|
||
|
||
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>
|
||
);
|
||
}
|
||
|
||
function SuccessStep({
|
||
appName,
|
||
logoUrl,
|
||
darkLogoUrl,
|
||
summary,
|
||
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 }>;
|
||
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>
|
||
<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>
|
||
);
|
||
}
|