mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-21 00:05:49 +02:00
Runtime #15727 is merged. This PR adds the UI. Qualification follows in
#15729.
## Thinking Path
> - Paperclip manages agent work through tasks and governed connections.
> - Chat connections already bind external conversations to those tasks.
> - Speko handles voice and telephony, while the selected Paperclip
agent does the work.
> - A call needs durable session authority, ordered follow-ups, and safe
result delivery.
> - This PR adds the Speko setup and call controls to the existing Apps
and task UI.
> - This PR depends on #15727. The qualification suite follows
separately. Speko stays experimental.
## Linked Issues or Issue Description
**Problem or motivation**
Phone and browser voice conversations cannot use the chat connection
task lifecycle. A separate voice runtime is needed to preserve task
authority, retries, and reply history.
**Proposed solution**
Add company-scoped voice sessions, signed callbacks, durable replies,
inbound intake, and an agent-facing outbound-call tool. Reuse existing
tasks, queues, secrets, and publications.
**Roadmap alignment**
This extends the experimental chat connector entry points under Agent
Chat and Connected Apps. The project owner requested this feature.
Refs #11418 for the original provider catalog proposal. This
implementation uses the newer chat connection infrastructure rather than
an MCP catalog-only integration. Refs #15721 for remote OpenCode
startup.
## What Changed
- Add the Speko Apps catalog entry, provider artwork, and browser SDK.
- Reuse existing agents in setup. Keep the optional Company Phone Agent
in the catalog, with connection instructions available to any assigned
agent.
- Add task voice controls, transcripts, phone callbacks, inbound-line
settings, and call Activity entries.
- Surface retryable connection and incoming-task inventory failures,
preserve active call controls and cached tasks, restore keyboard focus
after recovery, and bound notification polling so a stuck request cannot
hide later results.
- Put Save & exit and Connect Speko in the credential step’s shared
footer.
- Add deterministic production-component Storybook fixtures for setup,
lifecycle, failures, permissions, and phone surfaces.
- Include voice components in the design guide. Stories never request a
real microphone or place calls.
## Verification
- All 27 focused UI/controller regressions, UI typecheck, token gates,
and 28 map/catalog structural tests pass. The shared connection cache
test covers pause/resume invalidation during the 30-second fresh period.
- Storybook builds 157 Speko production-component stories. The complete
prior 156-story set passed all 624 checks on UI source 249cbc616a. After
the final cache repair, all 152 affected page/launcher checks passed
across both themes, 390/1200 widths and reduced motion. Real microphone
access and external provider requests are blocked.
- Source annotations follow the current generated-catalog contract. No
generated catalog or lockfile is included. Three inherited
GitHub-heading tests passed after master reconciliation.
- Latest-head CI and Greptile review must pass before merge. Local
fixture success does not establish provider or spoken-playback
acceptance.
- The separate integrated browser run passed two journeys and failed
browser answer retrieval. Real browser/telephone playback and timing
acceptance remain outstanding. See the dated qualification report and
E01–E16 runbook. Speko stays experimental.
## Risks
- Voice sessions add migrations and provider-dependent lifecycle
behavior. Migrations preserve existing chat and AI transports.
- Callback origins must be reachable HTTPS. Provider keys and signing
secrets remain vaulted.
- Unverified callers receive task-scoped low-trust authority. Caller ID
does not grant private task access.
- Outbound telephony incurs provider charges. Provider acceptance is not
proof that the caller heard an answer.
- Keep the feature behind the existing experimental chat setting until
the real browser, inbound, outbound, and delayed-result acceptance
matrix passes.
## Model Used
OpenAI Codex, GPT-6, with tool use and code execution. The exact serving
model ID and context window are not exposed in this session.
## Checklist
- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [ ] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge
---------
Co-authored-by: Paperclip <noreply@paperclip.ing>
4646 lines
195 KiB
TypeScript
4646 lines
195 KiB
TypeScript
import { StepHeader } from "./ConnectionSetupHeader";
|
||
export { StepHeader } from "./ConnectionSetupHeader";
|
||
import { ConnectionInstructionsEditor } from "./ConnectionInstructions";
|
||
import { connectionInstructionsConfig, defaultConnectionAgentInstructions, type ConnectionAgentInstructions } from "@paperclipai/shared";
|
||
import { RemoteMcpProductionSetup } from "./remote-mcp/RemoteMcpProductionSetup";
|
||
import { findAggregatorApp } from "@paperclipai/shared/aggregator-app-catalog";
|
||
import { useMemoryConnectorsEnabled } from "@/hooks/useMemoryConnectorsEnabled";
|
||
import { AiConnectionCredentialStep } from "@/components/ai-connections/AiConnectionCredentialStep";
|
||
import { ConnectionChoiceList } from "./ConnectionChoiceList";
|
||
import { useCallback, useEffect, useId, useMemo, useRef, useState, type ReactNode, type Ref } from "react";
|
||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||
import {
|
||
ArrowUpRight,
|
||
Bot,
|
||
Building2,
|
||
Check,
|
||
ChevronDown,
|
||
ChevronRight,
|
||
Cloud,
|
||
Copy,
|
||
Link2,
|
||
Loader2,
|
||
Lock,
|
||
Search,
|
||
UserRound,
|
||
UsersRound,
|
||
} from "lucide-react";
|
||
import type {
|
||
AppDefinition,
|
||
ConnectionGrantKind,
|
||
ConnectionIntentSetupConnection,
|
||
ConnectionMethodDef,
|
||
ConnectToolAppResult,
|
||
FieldDef,
|
||
ToolApplication,
|
||
ToolConnection,
|
||
ToolConnectionAuthKind,
|
||
ToolConnectionCredentialSource,
|
||
ToolConnectionCreateCapabilities,
|
||
ToolOAuthStartResult,
|
||
} from "@paperclipai/shared";
|
||
import {
|
||
aiConnectionMetadataSchema,
|
||
isRemoteMcpConnectorId,
|
||
isMemoryConnectorId,
|
||
isRemoteMcpConnectorMethod,
|
||
connectionMethodAcceptsCustomerOAuthClient,
|
||
connectionMethodRequiresConfiguration,
|
||
connectionMethodSupportsAutomaticOAuth,
|
||
credentialConfigPath,
|
||
getAppDefinitionForUrl,
|
||
getConnectableAppDefinition,
|
||
getAvailableConnectionMethods,
|
||
getRecommendedConnectionMethod,
|
||
isGoogleWorkspaceConnectorProfileId,
|
||
} from "@paperclipai/shared";
|
||
import { useNavigate, useParams, useSearchParams } from "@/lib/router";
|
||
import { useCompany } from "@/context/CompanyContext";
|
||
import { useBreadcrumbs } from "@/context/BreadcrumbContext";
|
||
import { useToast } from "@/context/ToastContext";
|
||
import { useChatConnectorsEnabled, chatProviderVisible } from "@/hooks/useChatConnectorsEnabled";
|
||
import { queryKeys } from "@/lib/queryKeys";
|
||
import { RadioCardGroup } from "@/components/ui/radio-card";
|
||
import { ApiError } from "@/api/client";
|
||
import { toolsApi } from "@/api/tools";
|
||
import { agentsApi } from "@/api/agents";
|
||
import { appCopyFor, credentialFieldLabel } from "@/lib/app-gallery-copy";
|
||
import { AgentMultiSelect, type AgentMultiSelectOption } from "@/components/AgentMultiSelect";
|
||
import { InlineBanner } from "@/components/InlineBanner";
|
||
import { Button } from "@/components/ui/button";
|
||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
|
||
import { Input } from "@/components/ui/input";
|
||
import { Textarea } from "@/components/ui/textarea";
|
||
import { ToggleSwitch } from "@/components/ui/toggle-switch";
|
||
import { Skeleton } from "@/components/ui/skeleton";
|
||
import { cn } from "@/lib/utils";
|
||
import { useCopyAction } from "@/lib/use-copy-action";
|
||
import { resolveAuthorizationTarget } from "@/lib/authorizationUrl";
|
||
import { navigateTopLevel } from "@/lib/browserNavigation";
|
||
import { prepareOAuthNavigation, savePendingCloudHandoff } from "@/lib/oauthHandoff";
|
||
import { redactUrlSecrets } from "@/lib/redact-url-secrets";
|
||
import { askFirstCatalogEntryIdsFor } from "./connection-defaults";
|
||
import { AppLogo } from "@/pages/apps/AppLogo";
|
||
import { appApplicationSourceSlug, appConnectionSourceSlug } from "@/pages/apps/app-definition-display";
|
||
import { UnverifiedServerBadge } from "@/pages/apps/UnverifiedServerBadge";
|
||
import {
|
||
appSourceConnectHref,
|
||
appSupportsToolCatalogSetup,
|
||
isMcpDirectOAuthConnectSlug,
|
||
resolveAppsConnectRouteKey,
|
||
vercelConnectSourceHref,
|
||
} from "@/pages/apps/app-connect-policy";
|
||
import { parseGoogleSheetIds } from "@/pages/apps/google-sheets";
|
||
import { connectionNameForGrantKind } from "@/pages/apps/connection-identity";
|
||
import {
|
||
canSubmitGenericConnect,
|
||
customHeaderError,
|
||
defaultGenericMcpName,
|
||
endpointHost,
|
||
genericConnectGuidance,
|
||
genericConnectPayload,
|
||
newCustomHeaderRow,
|
||
oauthCallbackUrlForBrowser,
|
||
type CustomHeaderRow,
|
||
type GenericConnectDraft,
|
||
type GenericConnectGuidance,
|
||
type GenericMcpAuthMode,
|
||
} from "@/pages/apps/generic-mcp-connect";
|
||
import { autoExtendNotice, INSTALL_ALL_WARNING, installInfoNotice, installPayload } from "@/lib/tool-installs";
|
||
|
||
type Step = "gallery" | "key" | "success";
|
||
export type OAuthConnectPhase = "entry" | "starting" | "redirecting" | "error";
|
||
|
||
type EnrollmentAccessState = {
|
||
companyId: string;
|
||
grantKind: ConnectionGrantKind;
|
||
installChoice: "specific" | "all";
|
||
agentIds: string[];
|
||
};
|
||
|
||
function enrollmentAccessStorageKey(appKey: string): string {
|
||
return `paperclip.connector-enrollment-access:${appKey}`;
|
||
}
|
||
|
||
function validEnrollmentAccessState(value: unknown): value is EnrollmentAccessState {
|
||
if (!value || typeof value !== "object" || Array.isArray(value)) return false;
|
||
const candidate = value as Record<string, unknown>;
|
||
if (typeof candidate.companyId !== "string" || !candidate.companyId.trim()) return false;
|
||
if (!(candidate.grantKind === "user" || candidate.grantKind === "agent" || candidate.grantKind === "organization")) {
|
||
return false;
|
||
}
|
||
if (candidate.installChoice !== "specific" && candidate.installChoice !== "all") return false;
|
||
if (!Array.isArray(candidate.agentIds) || candidate.agentIds.some((id) => typeof id !== "string" || !id.trim())) {
|
||
return false;
|
||
}
|
||
const agentIds = new Set(candidate.agentIds);
|
||
if (agentIds.size !== candidate.agentIds.length) return false;
|
||
if (candidate.grantKind === "agent") {
|
||
return candidate.installChoice === "specific" && agentIds.size === 1;
|
||
}
|
||
return candidate.installChoice === "all" ? agentIds.size === 0 : agentIds.size > 0;
|
||
}
|
||
|
||
function saveEnrollmentAccessState(
|
||
companyId: string,
|
||
appKey: string,
|
||
state: Omit<EnrollmentAccessState, "companyId">,
|
||
): void {
|
||
try {
|
||
window.sessionStorage.setItem(enrollmentAccessStorageKey(appKey), JSON.stringify({ ...state, companyId }));
|
||
} catch {
|
||
// Browser storage can be unavailable under restrictive privacy settings.
|
||
// The callback will safely use the provider's defaults in that case.
|
||
}
|
||
}
|
||
|
||
function consumeEnrollmentAccessState(appKey: string): EnrollmentAccessState | null {
|
||
const key = enrollmentAccessStorageKey(appKey);
|
||
try {
|
||
const raw = window.sessionStorage.getItem(key);
|
||
window.sessionStorage.removeItem(key);
|
||
if (!raw) return null;
|
||
const parsed: unknown = JSON.parse(raw);
|
||
return validEnrollmentAccessState(parsed) ? parsed : null;
|
||
} catch {
|
||
return null;
|
||
}
|
||
}
|
||
|
||
function githubRecoveryUrl(value: string | null): string | null {
|
||
if (!value) return null;
|
||
try {
|
||
const url = new URL(value);
|
||
return url.protocol === "https:" && url.hostname.toLowerCase() === "github.com"
|
||
? url.toString()
|
||
: null;
|
||
} catch {
|
||
return null;
|
||
}
|
||
}
|
||
|
||
function oauthCallbackErrorMessage(outcome: string | null, code: string | null): string {
|
||
if (outcome === "denied") {
|
||
return "Authorization was cancelled or declined. Your saved connection was not changed.";
|
||
}
|
||
if (code === "github_installation_required") {
|
||
return "GitHub access is required. Install Paperclip and grant at least one repository, then try again.";
|
||
}
|
||
return "Authorization did not complete. Your saved connection is still here, so you can try again.";
|
||
}
|
||
|
||
const ROUTE_STAGE_BY_STEP: Partial<Record<Step, string>> = {
|
||
key: "setup",
|
||
success: "complete",
|
||
};
|
||
|
||
export function requestedConnectionInitialStep(input: {
|
||
requestedAppKey: string | undefined;
|
||
routeStage: string | null;
|
||
resumeConnectionId: string | null;
|
||
hasPrefilledLink: boolean;
|
||
zapierSource: boolean;
|
||
}): Step {
|
||
if (input.requestedAppKey) return "key";
|
||
return input.hasPrefilledLink || input.zapierSource ? "key" : "gallery";
|
||
}
|
||
|
||
export function requestedConnectionEntry(input: {
|
||
requestedAppKey: string;
|
||
galleryApps: readonly AppDefinition[];
|
||
reconnectConnection: ToolConnection | null;
|
||
resumeConnection: ToolConnection | null;
|
||
resumeApplication: ToolApplication | null;
|
||
completedResumeConnectionId: string | null;
|
||
applications: readonly ToolApplication[];
|
||
}): AppDefinition | null {
|
||
const visible = input.galleryApps.find((candidate) => candidate.slug === input.requestedAppKey);
|
||
if (visible) return visible;
|
||
if (retainedResumeMatches({
|
||
requestedAppKey: input.requestedAppKey,
|
||
resumeConnection: input.resumeConnection,
|
||
resumeApplication: input.resumeApplication,
|
||
completedResumeConnectionId: input.completedResumeConnectionId,
|
||
})) return getConnectableAppDefinition(input.requestedAppKey);
|
||
if (!input.reconnectConnection) return null;
|
||
const application = input.applications.find(
|
||
(candidate) => candidate.id === input.reconnectConnection?.applicationId,
|
||
);
|
||
if (appApplicationSourceSlug(application) !== input.requestedAppKey) return null;
|
||
return getConnectableAppDefinition(input.requestedAppKey);
|
||
}
|
||
|
||
export function retainedResumeMatches(input: {
|
||
requestedAppKey: string;
|
||
resumeConnection: ToolConnection | null;
|
||
resumeApplication: ToolApplication | null;
|
||
completedResumeConnectionId: string | null;
|
||
}): boolean {
|
||
const { requestedAppKey, resumeConnection, resumeApplication, completedResumeConnectionId } = input;
|
||
return Boolean(
|
||
resumeConnection
|
||
&& (
|
||
resumeConnection.status === "draft"
|
||
|| (resumeConnection.status === "active" && resumeConnection.id === completedResumeConnectionId)
|
||
)
|
||
&& resumeApplication
|
||
&& resumeConnection.applicationId === resumeApplication.id
|
||
&& resumeConnection.companyId === resumeApplication.companyId
|
||
&& appApplicationSourceSlug(resumeApplication) === requestedAppKey
|
||
&& appConnectionSourceSlug(resumeConnection) === requestedAppKey,
|
||
);
|
||
}
|
||
|
||
export function retainedReconnectMatches(input: {
|
||
requestedAppKey: string | undefined;
|
||
byo: boolean;
|
||
applicationId: string | undefined;
|
||
reconnectConnection: ToolConnection | null;
|
||
reconnectApplication: ToolApplication | null;
|
||
}): boolean {
|
||
if (
|
||
!input.reconnectConnection
|
||
|| !input.reconnectApplication
|
||
|| input.reconnectConnection.applicationId !== input.reconnectApplication.id
|
||
) return false;
|
||
if (input.requestedAppKey) {
|
||
return appApplicationSourceSlug(input.reconnectApplication) === input.requestedAppKey;
|
||
}
|
||
// Generic MCP applications intentionally have no provider slug. Their
|
||
// reconnect URL instead carries the exact retained application identity.
|
||
return input.byo && input.applicationId === input.reconnectApplication.id;
|
||
}
|
||
|
||
export function isVercelConnectUnavailable(input: {
|
||
credentialSource: ToolConnectionCredentialSource;
|
||
available: boolean;
|
||
retainedReconnectMatches: boolean;
|
||
}): boolean {
|
||
return input.credentialSource === "vercel_connect"
|
||
&& !input.available
|
||
&& !input.retainedReconnectMatches;
|
||
}
|
||
|
||
export function isConnectionDefinitionUnavailable(input: {
|
||
available: boolean | undefined;
|
||
reconnectConnectionId: string | null | undefined;
|
||
reconnectSourceMatches: boolean;
|
||
}): boolean {
|
||
return input.available === false
|
||
&& !(input.reconnectConnectionId && input.reconnectSourceMatches);
|
||
}
|
||
|
||
export function requestedConnectionSetupResolution(input: {
|
||
reconnectConnectionId: string | null | undefined;
|
||
hasRequestedEntry: boolean;
|
||
supportedMethodCount: number;
|
||
unsupportedOAuth: boolean;
|
||
vercelUnavailable: boolean;
|
||
definitionUnavailable: boolean;
|
||
}): "ready" | "fallback" | "reconnect_unavailable" {
|
||
const unavailable = !input.hasRequestedEntry
|
||
|| input.supportedMethodCount === 0
|
||
|| input.unsupportedOAuth
|
||
|| input.vercelUnavailable
|
||
|| input.definitionUnavailable;
|
||
if (!unavailable) return "ready";
|
||
return input.reconnectConnectionId ? "reconnect_unavailable" : "fallback";
|
||
}
|
||
|
||
function appConnectHref(
|
||
appKey: string,
|
||
step: Step,
|
||
credentialSource: ToolConnectionCredentialSource,
|
||
existing?: {
|
||
resumeConnectionId?: string | null;
|
||
reconnectConnectionId?: string | null;
|
||
interactionId?: string | null;
|
||
connectionMethodKey?: string | null;
|
||
},
|
||
): string {
|
||
const stage = ROUTE_STAGE_BY_STEP[step] ?? "setup";
|
||
const params = new URLSearchParams({ source: appKey, stage });
|
||
if (existing?.resumeConnectionId) params.set("resume", existing.resumeConnectionId);
|
||
if (existing?.reconnectConnectionId) params.set("reconnect", existing.reconnectConnectionId);
|
||
if (existing?.interactionId) params.set("intent", existing.interactionId);
|
||
if (existing?.connectionMethodKey) params.set("method", existing.connectionMethodKey);
|
||
const path = credentialSource === "vercel_connect" ? "/apps/vercel-connect" : "/apps/connect";
|
||
return `${path}?${params.toString()}`;
|
||
}
|
||
|
||
function withConnectionIntent(href: string, interactionId?: string | null): string {
|
||
if (!interactionId) return href;
|
||
const [path, query = ""] = href.split("?", 2);
|
||
const params = new URLSearchParams(query);
|
||
params.set("intent", interactionId);
|
||
return `${path}?${params.toString()}`;
|
||
}
|
||
|
||
type AppAccessSelection = "all_agents" | { agentIds: string[] };
|
||
|
||
// PAP-659: identity and agent reach are no longer a step. They are resolved to
|
||
// a default, stated in one line above the primary action, and changed either in
|
||
// the Advanced disclosure on this screen or on the Permissions tab afterwards.
|
||
const STEP_LABELS = ["Pick app", "Add your key"];
|
||
const STEP_INDEX: Record<Exclude<Step, "success">, number> = {
|
||
gallery: 0,
|
||
key: 1,
|
||
};
|
||
const SELECTED_APP_STEP_INDEX: Record<Exclude<Step, "gallery" | "success">, number> = {
|
||
key: 0,
|
||
};
|
||
const ZAPIER_STEP_LABELS = ["Add MCP URL"];
|
||
// Waiting for browser sign-in happens *inside* the flow's last step, so the
|
||
// waiting screen reuses the step model of the flow that opened it. It must not
|
||
// append a trailing step the flow never lands on: a stepper that grows from one
|
||
// dot to two the moment you press Connect reads as a step you missed.
|
||
const OAUTH_SIGN_IN_STEP_LABELS = ["Sign in"];
|
||
|
||
/**
|
||
* Which identity a fresh connection should default to (PAP-17835).
|
||
*
|
||
* Company identity is the product default whenever the selected method permits
|
||
* it. Personal-only methods still stay personal, and reconnects preserve their
|
||
* original identity through the explicit reconnect hint.
|
||
*/
|
||
function defaultGrantKindFor(method: ConnectionMethodDef | null, preferPersonal = false): ConnectionGrantKind {
|
||
if (preferPersonal && method?.auth !== "none" && (!method?.grantKinds || method.grantKinds.includes("user"))) return "user";
|
||
if (method?.grantKinds?.length === 1) return method.grantKinds[0]!;
|
||
if (method?.grantKinds && !method.grantKinds.includes("organization")) return method.grantKinds[0]!;
|
||
return "organization";
|
||
}
|
||
|
||
function configuredAgentIdentity(connection: ToolConnection): string | undefined {
|
||
const oauth = connection.config?.oauth;
|
||
if (!oauth || typeof oauth !== "object" || Array.isArray(oauth)) return undefined;
|
||
const value = (oauth as Record<string, unknown>).connectorSubjectAgentId;
|
||
return typeof value === "string" ? value : undefined;
|
||
}
|
||
|
||
function isGoogleSheetsRobotMethod(
|
||
entry: AppDefinition | null,
|
||
method: ConnectionMethodDef | string | null | undefined,
|
||
): boolean {
|
||
const methodKey = typeof method === "string" ? method : method?.key;
|
||
return entry?.slug === "google-sheets" && methodKey === "local";
|
||
}
|
||
|
||
function defaultMethodConfig(method: ConnectionMethodDef | null): Record<string, string | boolean> {
|
||
if (!method) return {};
|
||
return Object.fromEntries(
|
||
[...(method.tenantFields ?? []), ...(method.extensionFields ?? [])]
|
||
.filter((field) => field.defaultValue !== undefined)
|
||
.map((field) => [field.key, field.defaultValue!]),
|
||
);
|
||
}
|
||
|
||
function connectionMethodsForCredentialSource(
|
||
entry: AppDefinition | null | undefined,
|
||
credentialSource: ToolConnectionCredentialSource,
|
||
): ConnectionMethodDef[] {
|
||
if (!entry) return [];
|
||
const methods = availableToolConnectionMethods(entry);
|
||
return credentialSource === "vercel_connect"
|
||
? methods.filter((method) => Boolean(method.credentialSources?.vercelConnect))
|
||
: methods;
|
||
}
|
||
|
||
function availableToolConnectionMethods(
|
||
entry: AppDefinition,
|
||
): ConnectionMethodDef[] {
|
||
return getAvailableConnectionMethods(entry).filter(
|
||
(method) => (method.purpose ?? "tool") !== "channel",
|
||
);
|
||
}
|
||
|
||
function availableToolConnectionMethod(
|
||
entry: AppDefinition,
|
||
methodKey?: string | null,
|
||
): ConnectionMethodDef | null {
|
||
const methods = availableToolConnectionMethods(entry);
|
||
return methodKey
|
||
? methods.find((method) => method.key === methodKey) ?? null
|
||
: getRecommendedConnectionMethod(methods);
|
||
}
|
||
|
||
function recommendedSetupConnectionMethod(
|
||
methods: readonly ConnectionMethodDef[],
|
||
): ConnectionMethodDef | null {
|
||
// PAP-659 C1: every connector arrives with a method already chosen.
|
||
// `getRecommendedConnectionMethod` already ranks managed/one-click OAuth over
|
||
// customer-owned OAuth over API keys, and write/draft capability over read;
|
||
// this used to throw that ranking away for multi-method apps and ask the
|
||
// operator instead. The alternates are still reachable, in the Advanced
|
||
// disclosure, so nothing became unavailable — it just stopped blocking.
|
||
return getRecommendedConnectionMethod(methods);
|
||
}
|
||
|
||
function recommendedManagedConnectorMethod(
|
||
entry: AppDefinition | null | undefined,
|
||
): ConnectionMethodDef | null {
|
||
return recommendedSetupConnectionMethod(
|
||
entry
|
||
? entry.methods.filter(
|
||
(candidate) =>
|
||
(candidate.purpose ?? "tool") === "tool" &&
|
||
(candidate.oauthStrategy === "paperclip_cloud_connector" ||
|
||
candidate.oauthStrategy === "paperclip_id_connector"),
|
||
)
|
||
: [],
|
||
);
|
||
}
|
||
|
||
function canUseAutomaticOAuthFastPath(entry: AppDefinition | null | undefined): boolean {
|
||
if (!entry) return false;
|
||
const methods = availableToolConnectionMethods(entry);
|
||
const method = methods.length === 1 ? methods[0] : null;
|
||
return Boolean(
|
||
method
|
||
&& connectionMethodSupportsAutomaticOAuth(method)
|
||
&& !connectionMethodRequiresConfiguration(method),
|
||
);
|
||
}
|
||
|
||
function automaticOAuthMethod(entry: AppDefinition | null | undefined): ConnectionMethodDef | null {
|
||
if (!entry) return null;
|
||
const methods = availableToolConnectionMethods(entry);
|
||
const method = methods.length === 1 ? methods[0] : null;
|
||
return method && connectionMethodSupportsAutomaticOAuth(method) ? method : null;
|
||
}
|
||
|
||
function appSourceSlug(application: ToolApplication): string | null {
|
||
const metadata = application.metadata;
|
||
if (!metadata) return null;
|
||
const source = metadata.sourceTemplateKey ?? metadata.galleryKey;
|
||
return typeof source === "string" ? source : null;
|
||
}
|
||
|
||
function connectionSourceSlug(connection: ToolConnection): string | null {
|
||
const source = connection.config?.sourceTemplateKey ?? connection.transportConfig.sourceTemplateKey;
|
||
return typeof source === "string" ? source : null;
|
||
}
|
||
|
||
function reusableOAuthConnection(
|
||
sourceSlug: string | null,
|
||
applications: ToolApplication[],
|
||
connections: ToolConnection[],
|
||
options: { applicationId?: string; draftOnly?: boolean } = {},
|
||
): ToolConnection | null {
|
||
if (!sourceSlug) return null;
|
||
const matchingApplicationIds = new Set(
|
||
applications
|
||
.filter((application) =>
|
||
application.status !== "archived" &&
|
||
appSourceSlug(application) === sourceSlug &&
|
||
(!options.applicationId || application.id === options.applicationId)
|
||
)
|
||
.map((application) => application.id),
|
||
);
|
||
return connections.find((connection) => {
|
||
const matchesApplication = options.applicationId
|
||
? connection.applicationId === options.applicationId
|
||
: matchingApplicationIds.has(connection.applicationId) || connectionSourceSlug(connection) === sourceSlug;
|
||
return connection.status !== "archived" &&
|
||
(!options.draftOnly || connection.status === "draft") &&
|
||
connection.authKind === "oauth" &&
|
||
matchesApplication;
|
||
}) ?? null;
|
||
}
|
||
|
||
export type ConnectionSetupCompletion =
|
||
| { connectionId: string; resolvedByCallback?: false }
|
||
/** OAuth callbacks finalize server-side before notifying their opener. */
|
||
| { resolvedByCallback: true };
|
||
|
||
export type ConnectionIntentOAuthOutcome = "connected" | "declined" | "failed";
|
||
|
||
export function readConnectionIntentOAuthOutcome(
|
||
event: Pick<MessageEvent, "origin" | "data">,
|
||
expectedOrigin: string,
|
||
interactionId: string,
|
||
): ConnectionIntentOAuthOutcome | null {
|
||
if (event.origin !== expectedOrigin || !event.data || typeof event.data !== "object") return null;
|
||
const message = event.data as {
|
||
type?: unknown;
|
||
interactionId?: unknown;
|
||
outcome?: unknown;
|
||
};
|
||
if (
|
||
message.type !== "paperclip.connection-intent.oauth"
|
||
|| message.interactionId !== interactionId
|
||
) return null;
|
||
return message.outcome === "connected"
|
||
|| message.outcome === "declined"
|
||
|| message.outcome === "failed"
|
||
? message.outcome
|
||
: null;
|
||
}
|
||
|
||
export interface ConnectionSetupFlowProps {
|
||
/** Optional settings composed into the standard credential form above its footer. */
|
||
additionalSettings?: ReactNode;
|
||
/** Provider prerequisites shown before credentials in the standard setup flow. */
|
||
connectionSettings?: ReactNode;
|
||
additionalSettingsValid?: boolean;
|
||
upstreamServiceName?: string;
|
||
aiConnection?: import("@paperclipai/shared").AiConnectionBinding | Pick<import("@paperclipai/shared").AiConnectionBinding, "provider" | "method" | "mode">;
|
||
/** A company service requires a company-owned credential. Existing grants are never broadened. */
|
||
requiredAiOwnership?: "shared";
|
||
/** Provider-specific authentication inside the existing access/setup shell. Undefined retains the standard credential form. */
|
||
renderCredentialStep?: (context: { app: AppDefinition; name: string; grantKind: ConnectionGrantKind; agentIds: string[]; allAgents: boolean; onBack: () => void }) => ReactNode;
|
||
byoOnly?: boolean;
|
||
credentialSource?: ToolConnectionCredentialSource;
|
||
host?: "page" | "dialog";
|
||
serviceSlug?: string;
|
||
requestedAgentId?: string;
|
||
interactionId?: string;
|
||
forceNewConnection?: boolean;
|
||
existingConnections?: ConnectionIntentSetupConnection[];
|
||
configuredConnection?: ConnectionIntentSetupConnection;
|
||
onUseExisting?: (connectionId: string) => Promise<void>;
|
||
onComplete?: (result: ConnectionSetupCompletion) => void;
|
||
onOAuthDeclined?: () => void;
|
||
onPhaseChange?: (phase: "requested" | "authorizing" | "needs_retry") => void;
|
||
onCancel?: () => void;
|
||
}
|
||
|
||
/**
|
||
* The single connection setup implementation used by both the Apps store and
|
||
* task-hosted connection intents. Hosts supply presentation and completion
|
||
* callbacks; provider fields, validation, OAuth, access, and finishing remain
|
||
* here so a provider can never drift between entry points.
|
||
*/
|
||
export function ConnectionSetupFlow(props: ConnectionSetupFlowProps = {}) {
|
||
const [searchParams] = useSearchParams();
|
||
const params = useParams<{ appKey?: string }>();
|
||
const { selectedCompanyId } = useCompany();
|
||
const memory = useMemoryConnectorsEnabled();
|
||
const interactionId = props.interactionId || searchParams.get("intent") || undefined;
|
||
const source = props.serviceSlug || searchParams.get("source") || params.appKey || searchParams.get("appKey");
|
||
const draftId = useMemo(() => {
|
||
if (interactionId && isRemoteMcpConnectorId(source)) {
|
||
try { return sessionStorage.getItem(`paperclip:mcp-intent-draft:${selectedCompanyId}:${interactionId}`); } catch { /* Storage may be disabled. */ }
|
||
}
|
||
return null;
|
||
}, [interactionId, selectedCompanyId, source]);
|
||
const existingId = props.configuredConnection?.id || searchParams.get("resume") || searchParams.get("reconnect") || draftId;
|
||
const lookup = Boolean(existingId && (!source || isRemoteMcpConnectorId(source) || isMemoryConnectorId(source)));
|
||
const existing = useQuery({ queryKey: ["tools", "connection", existingId], queryFn: () => toolsApi.getConnection(existingId!), enabled: lookup });
|
||
const provider = source || existing.data?.config?.sourceTemplateKey;
|
||
const method = searchParams.get("method") || existing.data?.config?.connectionMethodKey;
|
||
if (lookup && existing.isPending) return <p className="p-6 text-sm text-muted-foreground">Loading connection…</p>;
|
||
if (lookup && existing.isError) return <div role="alert" className="space-y-3 p-6"><p>Could not load this connection. Your saved access and credentials have not changed.</p><Button variant="outline" onClick={() => void existing.refetch()}>Try again</Button></div>;
|
||
if (isMemoryConnectorId(provider) && !(existing.data && existing.data.status !== "draft" && existing.data.config?.sourceTemplateKey === provider)) {
|
||
if (!memory.loaded) return <p className="p-6 text-sm text-muted-foreground">Loading connection settings…</p>;
|
||
if (!memory.enabled) return <p role="status" className="p-6 text-sm text-muted-foreground">Enable memory connectors in Settings → Experimental to set up this connection.</p>;
|
||
}
|
||
if (!props.byoOnly && (props.credentialSource ?? "paperclip_vault") === "paperclip_vault"
|
||
&& isRemoteMcpConnectorId(provider) && (!method || isRemoteMcpConnectorMethod(provider, method))) {
|
||
const target = findAggregatorApp(provider, searchParams.get("targetToolkit"));
|
||
return <RemoteMcpProductionSetup key={`${interactionId || "page"}:${provider}`} {...props} upstreamServiceName={props.upstreamServiceName ?? target?.name} interactionId={interactionId} providerId={provider} connection={existing.data} />;
|
||
}
|
||
return <StandardConnectionSetupFlow {...props} />;
|
||
}
|
||
|
||
function StandardConnectionSetupFlow({
|
||
additionalSettings,
|
||
connectionSettings,
|
||
additionalSettingsValid = true,
|
||
byoOnly = false,
|
||
credentialSource = "paperclip_vault",
|
||
host = "page",
|
||
serviceSlug,
|
||
requestedAgentId,
|
||
interactionId,
|
||
forceNewConnection = false,
|
||
existingConnections = [],
|
||
configuredConnection,
|
||
onUseExisting,
|
||
onComplete,
|
||
onOAuthDeclined,
|
||
aiConnection,
|
||
requiredAiOwnership,
|
||
onPhaseChange,
|
||
onCancel,
|
||
renderCredentialStep,
|
||
}: ConnectionSetupFlowProps = {}) {
|
||
const queryClient = useQueryClient();
|
||
const routeNavigate = useNavigate();
|
||
const navigate = useCallback((to: string, options?: { replace?: boolean }) => {
|
||
if (host !== "page") return;
|
||
if (options) routeNavigate(to, options);
|
||
else routeNavigate(to);
|
||
}, [host, routeNavigate]);
|
||
const routeParams = useParams<{ appKey?: string }>();
|
||
const { selectedCompany, selectedCompanyId } = useCompany();
|
||
const { enabled: chatConnectorsEnabled, githubEnabled } = useChatConnectorsEnabled();
|
||
const { enabled: memoryConnectorsEnabled } = useMemoryConnectorsEnabled();
|
||
const { setBreadcrumbs } = useBreadcrumbs();
|
||
const { pushToast } = useToast();
|
||
const [searchParams] = useSearchParams();
|
||
const connectionIntentId = interactionId?.trim()
|
||
|| searchParams.get("intent")?.trim()
|
||
|| null;
|
||
const appKey = routeParams.appKey ?? searchParams.get("appKey") ?? undefined;
|
||
const sourceSlug = searchParams.get("source")?.trim() || null;
|
||
const createNewConnection = forceNewConnection || searchParams.get("new") === "1";
|
||
const routeStage = searchParams.get("stage")?.trim() || null;
|
||
const requestedMethodKey = searchParams.get("method")?.trim() || null;
|
||
const resumeConnectionId = searchParams.get("resume")?.trim() || null;
|
||
const oauthCallbackOutcome = searchParams.get("oauth");
|
||
const oauthCallbackCode = searchParams.get("code");
|
||
const githubInstallationUrl = githubRecoveryUrl(searchParams.get("installation_url"));
|
||
const githubManagementUrl = githubRecoveryUrl(searchParams.get("management_url"));
|
||
const reconnectConnectionId = configuredConnection?.id ?? (searchParams.get("reconnect")?.trim() || null);
|
||
const reconnectGrantKindHint: ConnectionGrantKind | null = searchParams.get("identity") === "user"
|
||
? "user"
|
||
: searchParams.get("identity") === "organization"
|
||
? "organization"
|
||
: null;
|
||
const vercelConnectMode = credentialSource === "vercel_connect";
|
||
const directOAuthCandidate = serviceSlug ?? sourceSlug;
|
||
const directOAuthSource = credentialSource === "paperclip_vault"
|
||
&& isMcpDirectOAuthConnectSlug(directOAuthCandidate)
|
||
? directOAuthCandidate
|
||
: null;
|
||
// `source` is the generic curated-app route contract, not an OAuth-only
|
||
// shortcut. Manual OAuth, API-key, no-auth, and configured MCP definitions
|
||
// must all enter the same branded setup flow when Browse links to them.
|
||
const routeAppKey = resolveAppsConnectRouteKey({ serviceSlug, appKey, sourceSlug });
|
||
const zapierSource = (serviceSlug ?? sourceSlug ?? appKey) === "zapier";
|
||
const requestedAppKey = zapierSource ? undefined : routeAppKey;
|
||
const byo = Boolean(configuredConnection) || (host === "page" && (byoOnly || searchParams.get("byo") === "1"));
|
||
const [restoredEnrollmentAccess] = useState<EnrollmentAccessState | null>(() =>
|
||
host === "page"
|
||
&& searchParams.get("cloud_connector") === "enrolled"
|
||
&& requestedAppKey
|
||
? consumeEnrollmentAccessState(requestedAppKey)
|
||
: null,
|
||
);
|
||
|
||
// Prefill arrives from the app page for reconnects; read once so later
|
||
// wizard navigation doesn't fight the URL.
|
||
const [prefill] = useState(() => {
|
||
const rawLink = searchParams.get("link")?.trim() ?? "";
|
||
return {
|
||
link: /^https?:\/\//i.test(rawLink) ? rawLink : "",
|
||
name: configuredConnection?.name ?? searchParams.get("name")?.trim() ?? "",
|
||
applicationId: configuredConnection?.applicationId ?? (searchParams.get("applicationId")?.trim() || undefined),
|
||
};
|
||
});
|
||
|
||
const [step, setStep] = useState<Step>(() => requestedConnectionInitialStep({
|
||
requestedAppKey,
|
||
routeStage,
|
||
resumeConnectionId,
|
||
hasPrefilledLink: Boolean(prefill.link),
|
||
zapierSource,
|
||
}));
|
||
const [entry, setEntry] = useState<AppDefinition | null>(null);
|
||
const [galleryName, setGalleryName] = useState("");
|
||
const [linkUrl, setLinkUrl] = useState(prefill.link);
|
||
const [linkName, setLinkName] = useState(prefill.name || (zapierSource ? "Zapier" : ""));
|
||
const [linkNeedsKey, setLinkNeedsKey] = useState(false);
|
||
const [linkKey, setLinkKey] = useState("");
|
||
// Generic ("connect your own MCP server") flow state. `authMode: auto` is the
|
||
// simple path: Paperclip probes the endpoint and branches on what it finds.
|
||
const [linkAuthMode, setLinkAuthMode] = useState<GenericMcpAuthMode>("auto");
|
||
const [linkHeaders, setLinkHeaders] = useState<CustomHeaderRow[]>(() => [newCustomHeaderRow()]);
|
||
const [linkOAuthClientId, setLinkOAuthClientId] = useState("");
|
||
const [linkOAuthClientSecret, setLinkOAuthClientSecret] = useState("");
|
||
const [linkAdvancedOpen, setLinkAdvancedOpen] = useState(false);
|
||
const [linkGuidance, setLinkGuidance] = useState<GenericConnectGuidance | null>(null);
|
||
const [genericOAuthPending, setGenericOAuthPending] = useState(false);
|
||
const [credentials, setCredentials] = useState<Record<string, string>>({});
|
||
const [curatedOAuthClientId, setCuratedOAuthClientId] = useState("");
|
||
const [curatedOAuthClientSecret, setCuratedOAuthClientSecret] = useState("");
|
||
const [vercelConnector, setVercelConnector] = useState("");
|
||
const [connectionMethodKey, setConnectionMethodKey] = useState(aiConnection && aiConnection.mode !== "responsible_user" ? `ai-${aiConnection.method}` : "");
|
||
const [instructionDraft, setInstructionDraft] = useState<{ slug: string; value: ConnectionAgentInstructions } | null>(null);
|
||
const [configValues, setConfigValues] = useState<Record<string, string | boolean>>({});
|
||
const [googleSheetsLinks, setGoogleSheetsLinks] = useState("");
|
||
const [googleSheetsError, setGoogleSheetsError] = useState<string | null>(null);
|
||
const [connectResult, setConnectResult] = useState<ConnectToolAppResult | null>(null);
|
||
const [enabled, setEnabled] = useState<Record<string, boolean>>({});
|
||
const [access, setAccess] = useState<"all" | "specific">("all");
|
||
const [agentIds, setAgentIds] = useState<Set<string>>(new Set());
|
||
const [installAgentIds, setInstallAgentIds] = useState<Set<string>>(
|
||
() => new Set(restoredEnrollmentAccess?.agentIds ?? (requestedAgentId ? [requestedAgentId] : [])),
|
||
);
|
||
/**
|
||
* Access-step selections (PAP-17835). These are chosen before the credential
|
||
* and committed with it, so they must survive a failed submit and a trip
|
||
* backwards through the wizard.
|
||
*/
|
||
const [grantKind, setGrantKind] = useState<ConnectionGrantKind>(
|
||
restoredEnrollmentAccess?.grantKind ?? reconnectGrantKindHint ?? (requestedAgentId ? "user" : "organization"),
|
||
);
|
||
const [installChoice, setInstallChoice] = useState<"specific" | "all">(
|
||
restoredEnrollmentAccess?.installChoice ?? (requestedAgentId ? "specific" : "all"),
|
||
);
|
||
const resumingAfterOAuthFailure = Boolean(
|
||
resumeConnectionId
|
||
&& (oauthCallbackOutcome === "failed" || oauthCallbackOutcome === "denied"),
|
||
);
|
||
const [oauthPhase, setOAuthPhase] = useState<OAuthConnectPhase>(
|
||
resumingAfterOAuthFailure ? "error" : "entry",
|
||
);
|
||
const [oauthError, setOAuthError] = useState<string | null>(() => {
|
||
if (!resumingAfterOAuthFailure) return null;
|
||
return oauthCallbackErrorMessage(oauthCallbackOutcome, oauthCallbackCode);
|
||
});
|
||
/** Host of the page the operator is about to be sent to, shown while redirecting. */
|
||
const [authorizationHost, setAuthorizationHost] = useState<string | null>(null);
|
||
const directOAuthAccessConfirmedRef = useRef(false);
|
||
const directOAuthRetryingRef = useRef(false);
|
||
// A draft that sign-in already created, to be resumed with an operator's own
|
||
// OAuth client when automatic registration was refused.
|
||
const customerClientResumeRef = useRef<string | null>(null);
|
||
const hydratedResumeConnectionIdRef = useRef<string | null>(null);
|
||
const [hydratedResumeConnectionId, setHydratedResumeConnectionId] = useState<string | null>(null);
|
||
// Finishing a resumed unauthenticated/API-key draft activates that exact row.
|
||
// Permit it through retained-draft validation only after this flow's own
|
||
// finish request succeeds; an initially active resume remains invalid.
|
||
const [completedResumeConnectionId, setCompletedResumeConnectionId] = useState<string | null>(null);
|
||
const oauthPopupRef = useRef<Window | null>(null);
|
||
const [dialogOAuthConnectionId, setDialogOAuthConnectionId] = useState<string | null>(null);
|
||
const [authorizationFallbackUrl, setAuthorizationFallbackUrl] = useState<string | null>(null);
|
||
const oauthHandoffAbortRef = useRef<AbortController | null>(null);
|
||
const [showConnectionChoice, setShowConnectionChoice] = useState(
|
||
existingConnections.length > 0 && Boolean(onUseExisting),
|
||
);
|
||
const [existingConnectionPendingId, setExistingConnectionPendingId] = useState<string | null>(null);
|
||
const [existingConnectionError, setExistingConnectionError] = useState<string | null>(null);
|
||
const [unavailableReconnectId, setUnavailableReconnectId] = useState<string | null>(null);
|
||
|
||
const reserveOAuthPopup = useCallback(() => {
|
||
if (host !== "dialog" || oauthPopupRef.current?.closed === false) return;
|
||
oauthPopupRef.current = window.open(
|
||
"about:blank",
|
||
"paperclip-connection-oauth",
|
||
"popup,width=720,height=760,resizable=yes,scrollbars=yes",
|
||
);
|
||
}, [host]);
|
||
|
||
const openAuthorization = useCallback((url: string) => {
|
||
if (host !== "dialog") {
|
||
navigateTopLevel(url);
|
||
return;
|
||
}
|
||
setAuthorizationFallbackUrl(url);
|
||
const popup = oauthPopupRef.current;
|
||
if (!popup || popup.closed) {
|
||
setOAuthPhase("error");
|
||
setOAuthError("Paperclip couldn’t open the sign-in window. Open sign-in in a new tab to continue.");
|
||
onPhaseChange?.("needs_retry");
|
||
return;
|
||
}
|
||
popup.location.assign(url);
|
||
popup.focus();
|
||
}, [host, onPhaseChange]);
|
||
|
||
const openAuthorizationTab = useCallback(() => {
|
||
// Let a real link own navigation. Some embedded browsers return a window
|
||
// proxy from window.open without opening a usable authorization tab.
|
||
oauthPopupRef.current = null;
|
||
setOAuthError(null);
|
||
setOAuthPhase("redirecting");
|
||
onPhaseChange?.("authorizing");
|
||
}, [onPhaseChange]);
|
||
|
||
useEffect(() => {
|
||
if (host !== "dialog" || oauthPhase !== "redirecting") return;
|
||
const timer = window.setInterval(() => {
|
||
if (!oauthPopupRef.current?.closed) return;
|
||
setOAuthPhase("error");
|
||
setOAuthError("The sign-in window closed. If authorization did not finish, try again.");
|
||
setAuthorizationFallbackUrl(null);
|
||
onPhaseChange?.("needs_retry");
|
||
}, 1_000);
|
||
return () => window.clearInterval(timer);
|
||
}, [host, oauthPhase, onPhaseChange]);
|
||
|
||
const prepareAndOpenOAuth = useCallback(async (
|
||
start: Pick<ToolOAuthStartResult, "authorizationUrl" | "handoff">,
|
||
) => {
|
||
oauthHandoffAbortRef.current?.abort();
|
||
const controller = new AbortController();
|
||
oauthHandoffAbortRef.current = controller;
|
||
try {
|
||
const target = await prepareOAuthNavigation(start, { signal: controller.signal });
|
||
if (target.kind === "reauthentication") {
|
||
const destination = host === "dialog" ? oauthPopupRef.current : window;
|
||
if (!destination || destination.closed || !start.handoff) {
|
||
throw new Error("Paperclip couldn’t preserve this sign-in while refreshing your account.");
|
||
}
|
||
savePendingCloudHandoff(start.handoff.session, destination.sessionStorage);
|
||
setOAuthPhase("starting");
|
||
} else {
|
||
setAuthorizationHost(target.host);
|
||
setOAuthPhase("redirecting");
|
||
}
|
||
openAuthorization(target.url);
|
||
} catch (error) {
|
||
if (controller.signal.aborted) return;
|
||
setOAuthPhase("error");
|
||
setOAuthError(error instanceof Error ? error.message : "Paperclip couldn’t start secure sign-in. Try again.");
|
||
onPhaseChange?.("needs_retry");
|
||
} finally {
|
||
if (oauthHandoffAbortRef.current === controller) oauthHandoffAbortRef.current = null;
|
||
}
|
||
}, [host, onPhaseChange, openAuthorization]);
|
||
|
||
useEffect(() => () => oauthHandoffAbortRef.current?.abort(), []);
|
||
|
||
useEffect(() => {
|
||
if (host !== "dialog" || !connectionIntentId) return;
|
||
const receiveOAuthOutcome = (event: MessageEvent) => {
|
||
const outcome = readConnectionIntentOAuthOutcome(event, window.location.origin, connectionIntentId);
|
||
if (outcome === "connected") {
|
||
setOAuthPhase("entry");
|
||
setAuthorizationFallbackUrl(null);
|
||
onComplete?.({ resolvedByCallback: true });
|
||
return;
|
||
}
|
||
if (outcome === "declined") {
|
||
setOAuthPhase("error");
|
||
setOAuthError("Authorization was cancelled. You can try again.");
|
||
onPhaseChange?.("needs_retry");
|
||
return;
|
||
}
|
||
if (outcome !== "failed") return;
|
||
setOAuthPhase("error");
|
||
setOAuthError("Authorization did not complete. Try again when you’re ready.");
|
||
};
|
||
window.addEventListener("message", receiveOAuthOutcome);
|
||
return () => window.removeEventListener("message", receiveOAuthOutcome);
|
||
}, [connectionIntentId, host, onComplete, onOAuthDeclined]);
|
||
|
||
// Standalone dialog hosts have no task interaction to receive a callback.
|
||
// Wait for this popup to return to our origin, then verify durable state via
|
||
// the API. Provider-window contents never determine the saved connection.
|
||
useEffect(() => {
|
||
if (host !== "dialog" || connectionIntentId || !dialogOAuthConnectionId) return;
|
||
let cancelled = false;
|
||
let checking = false;
|
||
const timer = window.setInterval(async () => {
|
||
if (checking || cancelled) return;
|
||
const popup = oauthPopupRef.current;
|
||
if (!popup || popup.closed) {
|
||
setDialogOAuthConnectionId(null);
|
||
setOAuthPhase("error");
|
||
setOAuthError("The sign-in window closed. Try again to finish connecting GitHub.");
|
||
return;
|
||
}
|
||
let returned: URL;
|
||
try { returned = new URL(popup.location.href); } catch { return; }
|
||
if (returned.origin !== window.location.origin || !returned.pathname.includes(dialogOAuthConnectionId)) return;
|
||
if (returned.searchParams.has("oauth")) {
|
||
setDialogOAuthConnectionId(null);
|
||
setOAuthPhase("error");
|
||
setOAuthError("Authorization did not complete. Finish setup in the sign-in window or try again.");
|
||
return;
|
||
}
|
||
if (returned.searchParams.get("success") !== "1") return;
|
||
checking = true;
|
||
try {
|
||
const connection = await toolsApi.getConnection(dialogOAuthConnectionId);
|
||
if (!cancelled && connection.status === "active") {
|
||
setDialogOAuthConnectionId(null);
|
||
popup.close();
|
||
onComplete?.({ connectionId: connection.id });
|
||
}
|
||
} catch {
|
||
if (!cancelled) {
|
||
setDialogOAuthConnectionId(null);
|
||
setOAuthPhase("error");
|
||
setOAuthError("Could not confirm the connection. Try again.");
|
||
}
|
||
} finally { checking = false; }
|
||
}, 1000);
|
||
return () => { cancelled = true; window.clearInterval(timer); };
|
||
}, [connectionIntentId, dialogOAuthConnectionId, host, onComplete]);
|
||
|
||
const resetGenericAuthState = () => {
|
||
setLinkAuthMode("auto");
|
||
setLinkHeaders([newCustomHeaderRow()]);
|
||
setLinkOAuthClientId("");
|
||
setLinkOAuthClientSecret("");
|
||
setLinkAdvancedOpen(false);
|
||
setLinkGuidance(null);
|
||
setGenericOAuthPending(false);
|
||
};
|
||
|
||
/**
|
||
* Switch to a curated app's branded setup. Reached from the gallery grid and, as
|
||
* a convenience, from the guided generic flow when the pasted endpoint matches a
|
||
* definition — the generic path stays available either way.
|
||
*/
|
||
const useMatchedGalleryEntry = (picked: AppDefinition) => {
|
||
if (host === "page" && !connectionIntentId && credentialSource === "paperclip_vault" && isRemoteMcpConnectorId(picked.slug)) {
|
||
navigate(`/apps/connect?source=${picked.slug}`);
|
||
return;
|
||
}
|
||
if (picked.slug === "zapier") {
|
||
setEntry(null);
|
||
setGalleryName("");
|
||
setLinkUrl("");
|
||
setLinkName("Zapier");
|
||
setLinkNeedsKey(false);
|
||
setLinkKey("");
|
||
resetGenericAuthState();
|
||
setCredentials({});
|
||
setConnectResult(null);
|
||
setStep("key");
|
||
navigate(withConnectionIntent("/apps/connect?source=zapier", connectionIntentId));
|
||
return;
|
||
}
|
||
if (credentialSource === "paperclip_vault" && canUseAutomaticOAuthFastPath(picked)) {
|
||
navigate(appSourceConnectHref(picked.slug, connectionIntentId));
|
||
return;
|
||
}
|
||
setEntry(picked);
|
||
setGalleryName(picked.name);
|
||
setLinkUrl("");
|
||
setLinkName("");
|
||
setLinkNeedsKey(false);
|
||
setLinkKey("");
|
||
resetGenericAuthState();
|
||
setCredentials({});
|
||
setCuratedOAuthClientId("");
|
||
setCuratedOAuthClientSecret("");
|
||
setVercelConnector("");
|
||
const methods = connectionMethodsForCredentialSource(picked, credentialSource);
|
||
const initialMethod = recommendedSetupConnectionMethod(methods);
|
||
setConnectionMethodKey(initialMethod?.key ?? "");
|
||
setConfigValues(defaultMethodConfig(initialMethod));
|
||
setGoogleSheetsLinks("");
|
||
setGoogleSheetsError(null);
|
||
setConnectResult(null);
|
||
setInstallAgentIds(new Set(requestedAgentId ? [requestedAgentId] : []));
|
||
setInstallChoice(requestedAgentId ? "specific" : "all");
|
||
setGrantKind(reconnectGrantKind ?? defaultGrantKindFor(initialMethod, Boolean(requestedAgentId)));
|
||
setStep("key");
|
||
navigate(
|
||
credentialSource === "vercel_connect"
|
||
? withConnectionIntent(vercelConnectSourceHref(picked.slug), connectionIntentId)
|
||
: appSourceConnectHref(picked.slug, connectionIntentId),
|
||
);
|
||
};
|
||
|
||
const backToGallery = () => {
|
||
// Back is a wizard transition, so keep the selected app and entered draft
|
||
// intact. Picking another connector will replace that state explicitly.
|
||
setStep("gallery");
|
||
navigate(withConnectionIntent(
|
||
credentialSource === "vercel_connect"
|
||
? vercelConnectSourceHref()
|
||
: byoOnly ? "/apps/byo" : "/apps",
|
||
connectionIntentId,
|
||
));
|
||
};
|
||
|
||
useEffect(() => {
|
||
if (host !== "page") return;
|
||
setBreadcrumbs([
|
||
{ label: "Connectors", href: "/apps" },
|
||
{ label: vercelConnectMode ? "Vercel Connect" : byoOnly ? "Connect your own tool" : "Connect an app" },
|
||
]);
|
||
return () => setBreadcrumbs([]);
|
||
}, [byoOnly, host, setBreadcrumbs, vercelConnectMode]);
|
||
|
||
const galleryQuery = useQuery({
|
||
queryKey: queryKeys.apps.gallery(selectedCompanyId ?? "__none__"),
|
||
queryFn: () => toolsApi.listGallery(selectedCompanyId!),
|
||
select: useCallback((data: Awaited<ReturnType<typeof toolsApi.listGallery>>) => connectionIntentId || aiConnection ? {
|
||
...data,
|
||
apps: data.apps.map(app => ({ ...app, methods: app.methods.filter(method => aiConnection ? method.ai?.provider === aiConnection.provider && (aiConnection.mode === "responsible_user" || method.ai.method === aiConnection.method) : method.transport !== "runtime_auth") })).filter(app => app.methods.length > 0),
|
||
} : data, [connectionIntentId, aiConnection?.provider, aiConnection?.method, aiConnection?.mode]),
|
||
enabled: !!selectedCompanyId,
|
||
});
|
||
const callbackUrlForSetup = oauthCallbackUrlForBrowser(
|
||
window.location.origin,
|
||
galleryQuery.data?.oauthCallbackUrl,
|
||
);
|
||
// Use the same visible catalog for cards and every branded URL shortcut.
|
||
// Generic custom URLs remain usable without selecting a hidden provider.
|
||
const visibleGalleryApps = useMemo(
|
||
() => (galleryQuery.data?.apps ?? []).filter((app) => memoryConnectorsEnabled || !isMemoryConnectorId(app.slug)).filter((app) =>
|
||
chatProviderVisible(app.slug, chatConnectorsEnabled, githubEnabled) ||
|
||
!app.methods.some((method) => method.purpose === "channel") ||
|
||
appSupportsToolCatalogSetup(app),
|
||
),
|
||
[galleryQuery.data, chatConnectorsEnabled, githubEnabled, memoryConnectorsEnabled],
|
||
);
|
||
const fullRequestedDefinition = requestedAppKey
|
||
? getConnectableAppDefinition(requestedAppKey)
|
||
: null;
|
||
const customOAuthMethod = fullRequestedDefinition?.methods.find((candidate) =>
|
||
connectionMethodAcceptsCustomerOAuthClient(candidate)
|
||
&& !connectionMethodSupportsAutomaticOAuth(candidate),
|
||
);
|
||
const selectedCustomOAuth = Boolean(customOAuthMethod && connectionMethodKey === customOAuthMethod.key);
|
||
const requestedDefinitionUsesManagedConnector = !selectedCustomOAuth && Boolean(
|
||
fullRequestedDefinition?.methods.some((candidate) =>
|
||
candidate.oauthStrategy === "paperclip_cloud_connector"
|
||
|| candidate.oauthStrategy === "paperclip_id_connector"
|
||
),
|
||
);
|
||
const entryAdvertisesManagedConnector = Boolean(
|
||
entry?.methods.some((candidate) =>
|
||
candidate.oauthStrategy === "paperclip_cloud_connector"
|
||
|| candidate.oauthStrategy === "paperclip_id_connector"
|
||
),
|
||
);
|
||
// Before a self-hosted instance enrolls, the server intentionally withholds
|
||
// platform-managed methods from the advertised gallery. The setup route still
|
||
// needs the managed method's identity model, labels, and defaults because the
|
||
// next step is enrollment for that exact method—not the visible PAT/BYO
|
||
// compatibility fallback.
|
||
const preEnrollmentManagedMethod = entry
|
||
&& requestedDefinitionUsesManagedConnector
|
||
&& !entryAdvertisesManagedConnector
|
||
? recommendedManagedConnectorMethod(fullRequestedDefinition)
|
||
: null;
|
||
const [enrollmentAuthorizationUrl, setEnrollmentAuthorizationUrl] = useState<string | null>(null);
|
||
const connectorEnrollmentQuery = useQuery({
|
||
queryKey: ["cloud-connector", "enrollment"],
|
||
queryFn: () => toolsApi.getCloudConnectorEnrollment(),
|
||
refetchInterval: enrollmentAuthorizationUrl ? 2_000 : false,
|
||
enabled: Boolean(
|
||
selectedCompanyId
|
||
&& requestedDefinitionUsesManagedConnector
|
||
&& !entryAdvertisesManagedConnector
|
||
),
|
||
});
|
||
const [connectorEnrollmentError, setConnectorEnrollmentError] = useState<string | null>(null);
|
||
const closeEnrollmentPopup = useCallback(() => {
|
||
oauthPopupRef.current?.close();
|
||
oauthPopupRef.current = null;
|
||
setEnrollmentAuthorizationUrl(null);
|
||
}, []);
|
||
const preserveEnrollmentAccess = useCallback(() => {
|
||
if (!selectedCompanyId || !requestedAppKey) return;
|
||
saveEnrollmentAccessState(selectedCompanyId, requestedAppKey, {
|
||
grantKind,
|
||
installChoice,
|
||
agentIds: installChoice === "specific" ? [...installAgentIds] : [],
|
||
});
|
||
}, [grantKind, installAgentIds, installChoice, requestedAppKey, selectedCompanyId]);
|
||
const openConnectorEnrollment = useCallback((verificationUrl: string) => {
|
||
const target = resolveAuthorizationTarget(verificationUrl);
|
||
if (!target.ok) {
|
||
closeEnrollmentPopup();
|
||
setConnectorEnrollmentError(target.message);
|
||
return;
|
||
}
|
||
if (host === "dialog") {
|
||
setEnrollmentAuthorizationUrl(target.url);
|
||
const popup = oauthPopupRef.current;
|
||
if (popup && !popup.closed) {
|
||
popup.location.assign(target.url);
|
||
popup.focus();
|
||
} else {
|
||
setConnectorEnrollmentError("Open authorization in a new tab to continue.");
|
||
}
|
||
return;
|
||
}
|
||
navigateTopLevel(target.url);
|
||
}, [host, closeEnrollmentPopup]);
|
||
useEffect(() => {
|
||
if (!enrollmentAuthorizationUrl || connectorEnrollmentQuery.data?.status !== "active") return;
|
||
// Enrollment is only a prerequisite. Re-read the server catalog and keep
|
||
// the task's access selection and interaction binding in this dialog.
|
||
closeEnrollmentPopup();
|
||
setConnectorEnrollmentError(null);
|
||
void galleryQuery.refetch();
|
||
}, [enrollmentAuthorizationUrl, connectorEnrollmentQuery.data?.status, galleryQuery.refetch, closeEnrollmentPopup]);
|
||
const startConnectorEnrollment = useMutation({
|
||
mutationFn: () => toolsApi.startCloudConnectorEnrollment(
|
||
selectedCompanyId!,
|
||
selectedCompany?.name,
|
||
requestedAppKey
|
||
? appConnectHref(requestedAppKey, "key", credentialSource, {
|
||
resumeConnectionId,
|
||
reconnectConnectionId,
|
||
interactionId: connectionIntentId,
|
||
}) + (host === "dialog" ? "&enrollment_host=dialog" : "")
|
||
: undefined,
|
||
),
|
||
onSuccess: (status) => {
|
||
if (!status.verificationUrl) {
|
||
closeEnrollmentPopup();
|
||
setConnectorEnrollmentError("Paperclip Cloud did not return an enrollment link. Try again.");
|
||
return;
|
||
}
|
||
openConnectorEnrollment(status.verificationUrl);
|
||
},
|
||
onError: (error) => {
|
||
closeEnrollmentPopup();
|
||
setConnectorEnrollmentError(
|
||
error instanceof Error ? error.message : "Paperclip couldn’t reach Paperclip Cloud. Try again.",
|
||
);
|
||
},
|
||
});
|
||
const applicationsQuery = useQuery({
|
||
queryKey: queryKeys.tools.applications(selectedCompanyId ?? "__none__"),
|
||
queryFn: () => toolsApi.listApplications(selectedCompanyId!),
|
||
enabled: !!selectedCompanyId && (!!directOAuthSource || !!resumeConnectionId || !!reconnectConnectionId),
|
||
refetchOnMount: "always",
|
||
});
|
||
const connectionsQuery = useQuery({
|
||
queryKey: queryKeys.tools.connections(selectedCompanyId ?? "__none__"),
|
||
queryFn: () => toolsApi.listConnections(selectedCompanyId!),
|
||
enabled: !!selectedCompanyId && (!!directOAuthSource || !!resumeConnectionId || !!reconnectConnectionId),
|
||
refetchOnMount: "always",
|
||
});
|
||
const existingOAuthConnection = useMemo(
|
||
() => forceNewConnection ? null : reusableOAuthConnection(
|
||
directOAuthSource,
|
||
applicationsQuery.data?.applications ?? [],
|
||
connectionsQuery.data?.connections ?? [],
|
||
createNewConnection
|
||
? { applicationId: prefill.applicationId, draftOnly: true }
|
||
: {},
|
||
),
|
||
[applicationsQuery.data, connectionsQuery.data, createNewConnection, directOAuthSource, prefill.applicationId, forceNewConnection],
|
||
);
|
||
const reconnectConnection = useMemo(
|
||
() => reconnectConnectionId
|
||
? (connectionsQuery.data?.connections ?? []).find((connection) => connection.id === reconnectConnectionId) ?? null
|
||
: null,
|
||
[connectionsQuery.data, reconnectConnectionId],
|
||
);
|
||
const seededReconnectEndpoint = useRef<string | null>(null);
|
||
useEffect(() => {
|
||
// Task choices contain selection metadata only. Load the endpoint through
|
||
// the existing authorized connection query, once, without overwriting edits.
|
||
if (!configuredConnection || !connectionsQuery.isFetchedAfterMount || !reconnectConnection
|
||
|| seededReconnectEndpoint.current === reconnectConnection.id) return;
|
||
const endpoint = reconnectConnection.config?.url
|
||
?? reconnectConnection.transportConfig?.url
|
||
?? reconnectConnection.transportConfig?.serverUrl
|
||
?? reconnectConnection.config?.serverUrl;
|
||
if (typeof endpoint !== "string" || !/^https?:\/\//i.test(endpoint)) return;
|
||
seededReconnectEndpoint.current = reconnectConnection.id;
|
||
setLinkUrl((current) => current || endpoint);
|
||
}, [configuredConnection, connectionsQuery.isFetchedAfterMount, reconnectConnection]);
|
||
const reconnectApplication = useMemo(
|
||
() => reconnectConnection
|
||
? (applicationsQuery.data?.applications ?? []).find(
|
||
(application) => application.id === reconnectConnection.applicationId,
|
||
) ?? null
|
||
: null,
|
||
[applicationsQuery.data, reconnectConnection],
|
||
);
|
||
const reconnectSourceMatches = retainedReconnectMatches({
|
||
requestedAppKey,
|
||
byo,
|
||
applicationId: prefill.applicationId,
|
||
reconnectConnection,
|
||
reconnectApplication,
|
||
});
|
||
const resumeConnection = useMemo(
|
||
() => resumeConnectionId
|
||
? (connectionsQuery.data?.connections ?? []).find((connection) => connection.id === resumeConnectionId) ?? null
|
||
: null,
|
||
[connectionsQuery.data, resumeConnectionId],
|
||
);
|
||
const resumeApplication = useMemo(
|
||
() => resumeConnection
|
||
? (applicationsQuery.data?.applications ?? []).find(
|
||
(application) => application.id === resumeConnection.applicationId,
|
||
) ?? null
|
||
: null,
|
||
[applicationsQuery.data, resumeConnection],
|
||
);
|
||
const completedRouteResumeConnectionId = routeStage === "complete"
|
||
&& resumeConnection?.status === "active"
|
||
? resumeConnection.id
|
||
: null;
|
||
const resumeSourceMatches = requestedAppKey
|
||
? retainedResumeMatches({
|
||
requestedAppKey,
|
||
resumeConnection,
|
||
resumeApplication,
|
||
completedResumeConnectionId: completedResumeConnectionId ?? completedRouteResumeConnectionId,
|
||
})
|
||
: false;
|
||
const identityConnection = resumeConnection ?? reconnectConnection;
|
||
const reconnectGrantKind: ConnectionGrantKind | null = identityConnection
|
||
? identityConnection.credentialPolicy === "per_user"
|
||
? "user"
|
||
: identityConnection.credentialPolicy === "per_agent"
|
||
? "agent"
|
||
: "organization"
|
||
: reconnectGrantKindHint;
|
||
const resumableOAuthConnection = resumeConnection?.authKind === "oauth"
|
||
? resumeConnection
|
||
: existingOAuthConnection;
|
||
const existingOAuthGrantKind: ConnectionGrantKind | null = existingOAuthConnection
|
||
? existingOAuthConnection.credentialPolicy === "per_user"
|
||
? "user"
|
||
: existingOAuthConnection.credentialPolicy === "per_agent"
|
||
? "agent"
|
||
: "organization"
|
||
: null;
|
||
const fixedGrantKind = reconnectGrantKind ?? existingOAuthGrantKind ?? (requiredAiOwnership === "shared" ? "organization" : null);
|
||
|
||
useEffect(() => {
|
||
if (fixedGrantKind) setGrantKind(fixedGrantKind);
|
||
}, [fixedGrantKind]);
|
||
|
||
// A curated definition covering the pasted endpoint is offered as a branded
|
||
// convenience only; the generic flow remains the default and stays complete.
|
||
const linkMatchedEntry = useMemo(
|
||
() => (linkUrl && !entry ? getAppDefinitionForUrl(linkUrl, visibleGalleryApps) : null),
|
||
[entry, visibleGalleryApps, linkUrl],
|
||
);
|
||
|
||
const selectedSetupMethod = entry
|
||
? availableToolConnectionMethod(entry, connectionMethodKey || null)
|
||
: null;
|
||
// Apps with an advanced PAT option still need OAuth progress and recovery
|
||
// screens when their selected method is managed sign-in.
|
||
const entryAutomaticOAuthMethod = selectedSetupMethod && connectionMethodSupportsAutomaticOAuth(selectedSetupMethod)
|
||
? selectedSetupMethod
|
||
: automaticOAuthMethod(entry);
|
||
const automaticOAuthEntry = credentialSource === "paperclip_vault" && entryAutomaticOAuthMethod ? entry : null;
|
||
const directOAuthEntry = credentialSource === "paperclip_vault" && canUseAutomaticOAuthFastPath(entry) ? entry : null;
|
||
const directOAuthLookupPending = Boolean(directOAuthSource) && (
|
||
!applicationsQuery.isFetchedAfterMount || !connectionsQuery.isFetchedAfterMount
|
||
);
|
||
|
||
const setAppStep = (nextStep: Step) => {
|
||
setStep(nextStep);
|
||
if (entry) {
|
||
navigate(appConnectHref(entry.slug, nextStep, credentialSource, {
|
||
connectionMethodKey: entry.methods.find(method => method.key === connectionMethodKey)?.ai ? connectionMethodKey : undefined,
|
||
resumeConnectionId,
|
||
reconnectConnectionId,
|
||
interactionId: connectionIntentId,
|
||
}));
|
||
}
|
||
};
|
||
|
||
const instructionConnection = resumeConnection ?? reconnectConnection ?? resumableOAuthConnection;
|
||
const instructionTemplate = entry?.agentInstructions;
|
||
const instructionValue = entry && instructionTemplate
|
||
? instructionDraft?.slug === entry.slug ? instructionDraft.value
|
||
: instructionConnection ? instructionConnection.agentInstructions ?? { ...defaultConnectionAgentInstructions(instructionTemplate)!, enabled: false }
|
||
: defaultConnectionAgentInstructions(instructionTemplate)
|
||
: null;
|
||
const instructionsValid = !instructionValue || Boolean(instructionValue.text.trim());
|
||
|
||
const oauthStartMutation = useMutation({
|
||
// Retry/reconnect reads identity from the durable connection. Provider is
|
||
// not identity: an organization Notion connection must stay organization-
|
||
// scoped, while a personal one must put its token back on that user grant.
|
||
mutationFn: async (connection: ToolConnection) => {
|
||
// Persist before navigation, including retries that reuse an existing OAuth draft.
|
||
if (instructionValue && JSON.stringify(instructionValue) !== JSON.stringify(connection.agentInstructions)) {
|
||
await toolsApi.updateConnection(connection.id, { agentInstructions: instructionValue });
|
||
}
|
||
return toolsApi.startOAuth(connection.id, {
|
||
asCurrentUser: connection.credentialPolicy === "per_user",
|
||
...(connection.credentialPolicy === "per_agent"
|
||
? { asAgentId: configuredAgentIdentity(connection) ?? [...installAgentIds][0] }
|
||
: {}),
|
||
...(connectionIntentId ? { interactionId: connectionIntentId } : {}),
|
||
});
|
||
},
|
||
onSuccess: (start) => void prepareAndOpenOAuth(start),
|
||
onError: (error) => {
|
||
const details = error instanceof ApiError && error.body && typeof error.body === "object"
|
||
? (error.body as { details?: { code?: unknown } }).details
|
||
: null;
|
||
setOAuthPhase("error");
|
||
onPhaseChange?.("needs_retry");
|
||
setOAuthError(
|
||
details?.code === "invalid_grant"
|
||
? "Your authorization expired or was revoked. Reconnect to continue."
|
||
: error instanceof Error
|
||
? error.message
|
||
: "Paperclip couldn’t start secure sign-in. Try again.",
|
||
);
|
||
},
|
||
});
|
||
const mutateOAuthStart = oauthStartMutation.mutate;
|
||
const startOAuth = useCallback((connection: ToolConnection) => {
|
||
onPhaseChange?.("authorizing");
|
||
reserveOAuthPopup();
|
||
if (host === "dialog" && !connectionIntentId) setDialogOAuthConnectionId(connection.id);
|
||
mutateOAuthStart(connection);
|
||
}, [mutateOAuthStart, onPhaseChange, reserveOAuthPopup, host, connectionIntentId]);
|
||
|
||
/**
|
||
* Commit the Access step's agent reach for a connection. Shared by the
|
||
* key-path finish and the OAuth redirect, so both routes through the wizard
|
||
* apply the same selection.
|
||
*/
|
||
const applyAccessInstalls = async (connectionId: string) => {
|
||
// The task completion endpoint adds the requester atomically with resolution.
|
||
if (requestedAgentId) return;
|
||
const dedicatedIdentity = (fixedGrantKind ?? grantKind) === "agent";
|
||
const installState = !dedicatedIdentity && installChoice === "all"
|
||
? { onAll: true, agentIds: new Set<string>() }
|
||
: { onAll: false, agentIds: installAgentIds };
|
||
const desired = installPayload(selectedCompanyId!, installState);
|
||
await toolsApi.putConnectionInstalls(connectionId, desired);
|
||
};
|
||
|
||
const effectiveGrantKind = fixedGrantKind ?? grantKind;
|
||
const connectMutation = useMutation({
|
||
mutationFn: async (entryOverride?: AppDefinition) => {
|
||
const connectEntry = entryOverride ?? entry;
|
||
let result: ConnectToolAppResult;
|
||
if (connectEntry) {
|
||
const requestedGrantKind = fixedGrantKind ?? grantKind;
|
||
const selectedMethod = availableToolConnectionMethod(
|
||
connectEntry,
|
||
connectionMethodKey || null,
|
||
);
|
||
const sheetIds = isGoogleSheetsRobotMethod(connectEntry, selectedMethod)
|
||
? parseGoogleSheetIds(googleSheetsLinks).ids
|
||
: [];
|
||
const trimmedGalleryName = galleryName.trim();
|
||
const connectionName = connectionNameForGrantKind(
|
||
trimmedGalleryName || connectEntry.name,
|
||
requestedGrantKind,
|
||
);
|
||
result = await toolsApi.connectApp(selectedCompanyId!, {
|
||
galleryKey: connectEntry.slug,
|
||
...(instructionValue ? { agentInstructions: instructionValue } : {}),
|
||
...(connectionMethodKey ? { connectionMethodKey } : {}),
|
||
name: connectionName,
|
||
credentialSource,
|
||
...(credentialSource === "paperclip_vault" ? { credentialValues: credentials } : {}),
|
||
...(credentialSource === "vercel_connect"
|
||
? { vercelConnect: { connector: vercelConnector.trim() } }
|
||
: {}),
|
||
...(curatedOAuthClientId.trim()
|
||
? {
|
||
oauthClient: {
|
||
clientId: curatedOAuthClientId.trim(),
|
||
...(curatedOAuthClientSecret ? { clientSecret: curatedOAuthClientSecret } : {}),
|
||
},
|
||
}
|
||
: {}),
|
||
configValues: isGoogleSheetsRobotMethod(connectEntry, selectedMethod)
|
||
? { allowedSpreadsheetIds: sheetIds }
|
||
: Object.keys(configValues).length > 0
|
||
? configValues
|
||
: undefined,
|
||
applicationId: prefill.applicationId,
|
||
...((resumeConnectionId ?? customerClientResumeRef.current)
|
||
? { resumeConnectionId: resumeConnectionId ?? customerClientResumeRef.current! }
|
||
: reconnectConnectionId ? { reconnectConnectionId } : {}),
|
||
...(requestedGrantKind !== "organization" ? { grantKind: requestedGrantKind } : {}),
|
||
...(requestedGrantKind === "agent" ? { subjectAgentId: [...installAgentIds][0] } : {}),
|
||
});
|
||
customerClientResumeRef.current = null;
|
||
} else {
|
||
const genericPayload = genericConnectPayload({
|
||
link: linkUrl,
|
||
name: linkName,
|
||
authMode: linkAuthMode,
|
||
needsKey: linkNeedsKey,
|
||
keyValue: linkKey,
|
||
headers: linkHeaders,
|
||
oauthClientId: linkOAuthClientId,
|
||
oauthClientSecret: linkOAuthClientSecret,
|
||
});
|
||
const connectionName = connectionNameForGrantKind(
|
||
genericPayload.name ?? defaultGenericMcpName(linkUrl) ?? "Custom app",
|
||
effectiveGrantKind,
|
||
);
|
||
result = await toolsApi.connectApp(selectedCompanyId!, {
|
||
...genericPayload,
|
||
...(reconnectConnectionId ? { reconnectConnectionId } : {}),
|
||
// Zapier issues a credential-bearing URL, so its branded setup keeps
|
||
// the compact pasted-URL step. It is still a curated app, though: the
|
||
// gallery identity must reach the server or Browse can only see a
|
||
// generic `link` application after setup succeeds.
|
||
...(zapierSource || linkMatchedEntry?.slug === "zapier"
|
||
? {
|
||
galleryKey: "zapier",
|
||
connectionMethodKey: "generated-url",
|
||
}
|
||
: {}),
|
||
name: connectionName,
|
||
applicationId: prefill.applicationId,
|
||
...(effectiveGrantKind !== "organization" ? { grantKind: effectiveGrantKind } : {}),
|
||
...(effectiveGrantKind === "agent" ? { subjectAgentId: [...installAgentIds][0] } : {}),
|
||
});
|
||
}
|
||
// A resumable draft already owns its identity and install reach. Replacing
|
||
// those choices with this page's defaults would turn "finish setup" into a
|
||
// silent access change. Fresh connections still persist the Access step
|
||
// before the browser leaves Paperclip.
|
||
if (result.auth?.kind === "oauth" && !resumeConnectionId && !reconnectConnectionId) {
|
||
await applyAccessInstalls(result.connectionId);
|
||
}
|
||
return result;
|
||
},
|
||
onSuccess: (result) => {
|
||
if (result.auth?.kind === "oauth") {
|
||
setConnectResult(result);
|
||
// The mutation saves the Access selection before this callback opens
|
||
// the provider, so the authorization handoff cannot outrun agent reach.
|
||
// Discovery worked but this authorization server insists on a client the
|
||
// operator registers themselves. Keep the draft and ask for it in place
|
||
// rather than sending them back to the start.
|
||
if (result.auth.manualClientRequired) {
|
||
setLinkGuidance(genericConnectGuidance("oauth_manual_client_required", null));
|
||
setLinkAuthMode("oauth");
|
||
setLinkAdvancedOpen(true);
|
||
setGenericOAuthPending(false);
|
||
return;
|
||
}
|
||
if (host === "dialog" || connectionIntentId) {
|
||
setOAuthPhase("starting");
|
||
setGenericOAuthPending(!entry);
|
||
startOAuth(result.connection);
|
||
return;
|
||
}
|
||
const startUrl = result.auth.startUrl?.trim();
|
||
if (!startUrl) {
|
||
setOAuthPhase("starting");
|
||
setGenericOAuthPending(true);
|
||
startOAuth(result.connection);
|
||
return;
|
||
}
|
||
setGenericOAuthPending(true);
|
||
void prepareAndOpenOAuth({
|
||
authorizationUrl: startUrl,
|
||
handoff: result.auth.handoff,
|
||
});
|
||
return;
|
||
}
|
||
setLinkGuidance(null);
|
||
setConnectResult(result);
|
||
const defaults: Record<string, boolean> = {};
|
||
for (const a of result.actions.readOnly) defaults[a.catalogEntryId] = true;
|
||
for (const a of result.actions.canMakeChanges) defaults[a.catalogEntryId] = true;
|
||
setEnabled(defaults);
|
||
finishMutation.mutate({ result, enabled: defaults });
|
||
},
|
||
onError: (error) => {
|
||
const details = error instanceof ApiError && error.body && typeof error.body === "object"
|
||
? (error.body as { details?: { code?: unknown } }).details
|
||
: null;
|
||
if (automaticOAuthEntry) {
|
||
setOAuthPhase("error");
|
||
onPhaseChange?.("needs_retry");
|
||
setOAuthError(
|
||
details?.code === "invalid_grant"
|
||
? "Your authorization expired or was revoked. Reconnect to continue."
|
||
: error instanceof Error
|
||
? error.message
|
||
: "Paperclip couldn’t start secure sign-in. Try again.",
|
||
);
|
||
return;
|
||
}
|
||
// The generic URL path explains the specific corrective action inline,
|
||
// beside the fields the operator has to change. A toast can't do that, and
|
||
// for a pasted address "check your key" is usually the wrong advice.
|
||
if (!entry && linkUrl) {
|
||
const code = typeof details?.code === "string" ? details.code : null;
|
||
const guidance = genericConnectGuidance(code, error instanceof Error ? error.message : null);
|
||
setLinkGuidance(guidance);
|
||
setGenericOAuthPending(false);
|
||
if (guidance.focus === "credentials") {
|
||
// The probe is the answer to "does it need a key?", so the field
|
||
// appears now rather than being offered as a guess beforehand.
|
||
setLinkNeedsKey(true);
|
||
setLinkAdvancedOpen(true);
|
||
}
|
||
return;
|
||
}
|
||
pushToast({
|
||
title: "Couldn’t connect",
|
||
body: error instanceof Error ? error.message : "Please check your key and try again.",
|
||
tone: "error",
|
||
});
|
||
},
|
||
});
|
||
const mutateConnect = connectMutation.mutate;
|
||
const connectApp = useCallback((entryOverride?: AppDefinition) => {
|
||
const connectEntry = entryOverride ?? entry;
|
||
const method = connectEntry
|
||
? availableToolConnectionMethod(connectEntry, connectionMethodKey || null)
|
||
: null;
|
||
if (method?.auth === "oauth") {
|
||
reserveOAuthPopup();
|
||
}
|
||
mutateConnect(entryOverride);
|
||
}, [connectionMethodKey, entry, mutateConnect, reserveOAuthPopup]);
|
||
|
||
useEffect(() => {
|
||
if (!requestedAppKey || galleryQuery.isLoading || !galleryQuery.data) return;
|
||
|
||
if ((resumeConnectionId || reconnectConnectionId) && (
|
||
!connectionsQuery.isFetchedAfterMount
|
||
|| !applicationsQuery.isFetchedAfterMount
|
||
)) return;
|
||
|
||
// A resume URL is only a recovery route. Invalid or cross-provider drafts
|
||
// must remain on the blocked recovery screen instead of falling through to
|
||
// fresh setup for a hidden provider.
|
||
if (
|
||
resumeConnectionId
|
||
&& routeStage === "complete"
|
||
&& resumeConnection?.status === "active"
|
||
&& completedResumeConnectionId !== resumeConnectionId
|
||
&& resumeSourceMatches
|
||
) {
|
||
navigate(`/apps/${resumeConnection.id}/permissions`, { replace: true });
|
||
return;
|
||
}
|
||
if (resumeConnectionId && !resumeSourceMatches) return;
|
||
|
||
const requestedEntry = requestedConnectionEntry({
|
||
requestedAppKey,
|
||
galleryApps: galleryQuery.data.apps,
|
||
reconnectConnection,
|
||
resumeConnection,
|
||
resumeApplication,
|
||
completedResumeConnectionId,
|
||
applications: applicationsQuery.data?.applications ?? [],
|
||
});
|
||
const requestedEntryAdvertisesManagedConnector = Boolean(
|
||
requestedEntry?.methods.some((candidate) =>
|
||
candidate.oauthStrategy === "paperclip_cloud_connector"
|
||
|| candidate.oauthStrategy === "paperclip_id_connector"
|
||
),
|
||
);
|
||
// The enrollment lookup decides whether a hidden managed method means
|
||
// "enroll this instance" or "that Cloud profile is unavailable here".
|
||
// Preserve managed sign-in intent in both cases; the setup screen explains
|
||
// unavailable profiles rather than downgrading to a credential form.
|
||
if (
|
||
requestedDefinitionUsesManagedConnector
|
||
&& !requestedEntryAdvertisesManagedConnector
|
||
&& connectorEnrollmentQuery.isLoading
|
||
) return;
|
||
const methods = connectionMethodsForCredentialSource(requestedEntry, credentialSource);
|
||
const requestedAi = aiConnection ?? (reconnectConnection?.connectionPurpose === "ai"
|
||
? aiConnectionMetadataSchema.safeParse(reconnectConnection.config?.ai).data
|
||
: undefined);
|
||
const explicitMethod = methods.find(candidate => requestedAi
|
||
? candidate.ai?.provider === requestedAi.provider && (!("mode" in requestedAi) || requestedAi.mode !== "responsible_user") && candidate.ai.method === requestedAi.method
|
||
: candidate.key === requestedMethodKey);
|
||
const initialMethod = explicitMethod ?? (
|
||
requestedDefinitionUsesManagedConnector
|
||
&& !requestedEntryAdvertisesManagedConnector
|
||
? recommendedManagedConnectorMethod(fullRequestedDefinition)
|
||
: null
|
||
) ?? recommendedSetupConnectionMethod(methods);
|
||
const method = methods.length === 1 ? methods[0]! : null;
|
||
const automaticOAuth = credentialSource === "paperclip_vault" && Boolean(automaticOAuthMethod(requestedEntry));
|
||
const vercelUnavailable = isVercelConnectUnavailable({
|
||
credentialSource,
|
||
available: galleryQuery.data.credentialSources?.vercelConnect.available === true,
|
||
retainedReconnectMatches: Boolean(reconnectConnectionId && reconnectSourceMatches),
|
||
});
|
||
const unsupportedOAuth = methods.length === 1
|
||
&& method?.auth === "oauth"
|
||
&& !connectionMethodSupportsAutomaticOAuth(method)
|
||
&& !connectionMethodAcceptsCustomerOAuthClient(method);
|
||
const definitionUnavailable = isConnectionDefinitionUnavailable({
|
||
available: requestedEntry?.availability?.available,
|
||
reconnectConnectionId,
|
||
reconnectSourceMatches,
|
||
});
|
||
const setupResolution = requestedConnectionSetupResolution({
|
||
reconnectConnectionId,
|
||
hasRequestedEntry: Boolean(requestedEntry),
|
||
supportedMethodCount: methods.length,
|
||
unsupportedOAuth,
|
||
vercelUnavailable,
|
||
definitionUnavailable,
|
||
});
|
||
if (setupResolution !== "ready") {
|
||
setEntry(null);
|
||
setStep("gallery");
|
||
if (setupResolution === "reconnect_unavailable" && reconnectConnectionId) {
|
||
setUnavailableReconnectId(reconnectConnectionId);
|
||
return;
|
||
}
|
||
navigate(withConnectionIntent(
|
||
credentialSource === "vercel_connect" ? vercelConnectSourceHref() : "/apps/connect",
|
||
connectionIntentId,
|
||
), { replace: true });
|
||
return;
|
||
}
|
||
setUnavailableReconnectId(null);
|
||
if (!requestedEntry) return;
|
||
|
||
if (entry?.slug !== requestedEntry.slug) {
|
||
setEntry(requestedEntry);
|
||
setGalleryName(requestedEntry.name);
|
||
setLinkUrl("");
|
||
setLinkName("");
|
||
setLinkNeedsKey(false);
|
||
setLinkKey("");
|
||
setCredentials({});
|
||
setCuratedOAuthClientId("");
|
||
setCuratedOAuthClientSecret("");
|
||
setVercelConnector("");
|
||
setConnectionMethodKey(initialMethod?.key ?? "");
|
||
setConfigValues(defaultMethodConfig(initialMethod));
|
||
setGoogleSheetsLinks("");
|
||
setGoogleSheetsError(null);
|
||
setConnectResult(null);
|
||
const matchingEnrollmentAccess = restoredEnrollmentAccess?.companyId === selectedCompanyId
|
||
? restoredEnrollmentAccess
|
||
: null;
|
||
setGrantKind(reconnectGrantKind ?? matchingEnrollmentAccess?.grantKind ?? defaultGrantKindFor(initialMethod, Boolean(requestedAgentId)));
|
||
setInstallAgentIds(new Set(
|
||
matchingEnrollmentAccess?.agentIds ?? (requestedAgentId ? [requestedAgentId] : []),
|
||
));
|
||
setInstallChoice(matchingEnrollmentAccess?.installChoice ?? (requestedAgentId ? "specific" : "all"));
|
||
// Route/service selection initializes the wizard once. Later renders must
|
||
// preserve the user's current step in both hosts instead of snapping back
|
||
// to Access after they continue.
|
||
setStep(requestedConnectionInitialStep({
|
||
requestedAppKey,
|
||
routeStage,
|
||
resumeConnectionId,
|
||
hasPrefilledLink: Boolean(prefill.link),
|
||
zapierSource,
|
||
}));
|
||
} else if (entryAdvertisesManagedConnector !== requestedEntryAdvertisesManagedConnector) {
|
||
// A capability refresh must replace the stale gallery entry as well as
|
||
// its method, while preserving the chosen audience and wizard step.
|
||
setEntry(requestedEntry);
|
||
setConnectionMethodKey(initialMethod?.key ?? "");
|
||
setConfigValues(defaultMethodConfig(initialMethod));
|
||
}
|
||
|
||
if (automaticOAuth && (
|
||
!applicationsQuery.isFetchedAfterMount ||
|
||
!connectionsQuery.isFetchedAfterMount
|
||
)) return;
|
||
if (automaticOAuth && directOAuthRetryingRef.current) return;
|
||
if (automaticOAuth && (applicationsQuery.isError || connectionsQuery.isError)) {
|
||
setOAuthPhase("error");
|
||
setOAuthError("Paperclip couldn’t check for an existing connection. Try again.");
|
||
setStep("key");
|
||
return;
|
||
}
|
||
}, [
|
||
aiConnection,
|
||
requestedMethodKey,
|
||
applicationsQuery.isError,
|
||
applicationsQuery.isFetchedAfterMount,
|
||
applicationsQuery.data,
|
||
connectionsQuery.isError,
|
||
connectionsQuery.isFetchedAfterMount,
|
||
connectorEnrollmentQuery.data?.configured,
|
||
connectorEnrollmentQuery.isLoading,
|
||
connectionMethodKey,
|
||
credentialSource,
|
||
entry?.slug,
|
||
entryAdvertisesManagedConnector,
|
||
galleryQuery.data,
|
||
galleryQuery.isLoading,
|
||
navigate,
|
||
reconnectGrantKind,
|
||
reconnectConnection,
|
||
reconnectConnectionId,
|
||
reconnectSourceMatches,
|
||
resumeConnection,
|
||
resumeApplication,
|
||
resumeSourceMatches,
|
||
completedResumeConnectionId,
|
||
completedRouteResumeConnectionId,
|
||
routeStage,
|
||
resumeConnectionId,
|
||
fullRequestedDefinition,
|
||
requestedAppKey,
|
||
requestedAgentId,
|
||
restoredEnrollmentAccess,
|
||
routeStage,
|
||
zapierSource,
|
||
]);
|
||
|
||
// Resume the exact method and non-secret provider configuration that the
|
||
// interrupted draft already chose. Secrets are intentionally never read back
|
||
// into the browser; credential-based methods ask for a replacement value.
|
||
useEffect(() => {
|
||
hydratedResumeConnectionIdRef.current = null;
|
||
setHydratedResumeConnectionId(null);
|
||
}, [resumeConnectionId]);
|
||
|
||
useEffect(() => {
|
||
const savedConnection = resumeConnection ?? reconnectConnection;
|
||
if (
|
||
!savedConnection
|
||
|| !entry
|
||
|| hydratedResumeConnectionIdRef.current === savedConnection.id
|
||
) return;
|
||
const storedConfig = connectionInstructionsConfig(savedConnection);
|
||
const storedSource = typeof storedConfig.sourceTemplateKey === "string"
|
||
? storedConfig.sourceTemplateKey
|
||
: null;
|
||
if (storedSource && storedSource !== entry.slug) return;
|
||
|
||
const storedMethodKey = typeof storedConfig.connectionMethodKey === "string"
|
||
? storedConfig.connectionMethodKey
|
||
: null;
|
||
const resumedMethod = storedMethodKey
|
||
? connectionMethodsForCredentialSource(entry, credentialSource).find(
|
||
(candidate) => candidate.key === storedMethodKey,
|
||
) ?? null
|
||
: null;
|
||
setGalleryName(savedConnection.name || entry.name);
|
||
if (resumedMethod) {
|
||
setConnectionMethodKey(resumedMethod.key);
|
||
const storedMethodConfig = storedConfig.methodConfig && typeof storedConfig.methodConfig === "object"
|
||
? storedConfig.methodConfig as Record<string, unknown>
|
||
: {};
|
||
setConfigValues({
|
||
...defaultMethodConfig(resumedMethod),
|
||
...Object.fromEntries(
|
||
Object.entries(storedMethodConfig).filter(
|
||
(entry): entry is [string, string | boolean] =>
|
||
typeof entry[1] === "string" || typeof entry[1] === "boolean",
|
||
),
|
||
),
|
||
});
|
||
}
|
||
const storedOAuth = storedConfig.oauth && typeof storedConfig.oauth === "object"
|
||
? storedConfig.oauth as Record<string, unknown>
|
||
: null;
|
||
// Only an operator-owned client belongs in the editable Advanced fields.
|
||
// Rehydrating a DCR/CIMD client id there would submit it as customer-owned
|
||
// and corrupt the automatic registration's provenance on the next click.
|
||
if (
|
||
storedOAuth?.clientRegistrationSource === "manual"
|
||
&& typeof storedOAuth.clientId === "string"
|
||
) {
|
||
setCuratedOAuthClientId(storedOAuth.clientId);
|
||
}
|
||
// A draft exists only after the first half of setup has been saved. Resume
|
||
// at the credential/provider checkpoint instead of asking for identity and
|
||
// agent reach again. Automatic OAuth renders the dedicated one-action
|
||
// screen; configured methods render their saved setup form with secrets
|
||
// intentionally blank.
|
||
if (oauthCallbackOutcome === "failed" || oauthCallbackOutcome === "denied") {
|
||
setOAuthPhase("error");
|
||
setOAuthError(oauthCallbackErrorMessage(oauthCallbackOutcome, oauthCallbackCode));
|
||
} else {
|
||
setOAuthPhase("entry");
|
||
setOAuthError(null);
|
||
}
|
||
setStep("key");
|
||
hydratedResumeConnectionIdRef.current = savedConnection.id;
|
||
setHydratedResumeConnectionId(savedConnection.id);
|
||
}, [credentialSource, entry, oauthCallbackCode, oauthCallbackOutcome, resumeConnection, reconnectConnection]);
|
||
|
||
/**
|
||
* Commit the connection: action defaults, agent reach, and installs.
|
||
*
|
||
* Takes the connect result and the enabled set as arguments rather than
|
||
* reading them from state. The Access step removed the separate who/install
|
||
* screens, so this now runs in the same tick as the `setConnectResult` /
|
||
* `setEnabled` that precede it, where that state has not been applied yet.
|
||
*/
|
||
const finishMutation = useMutation({
|
||
mutationFn: async (input: { result: ConnectToolAppResult; enabled: Record<string, boolean> }) => {
|
||
const { result: connected, enabled: enabledMap } = input;
|
||
const enabledIds = Object.entries(enabledMap)
|
||
.filter(([, on]) => on)
|
||
.map(([id]) => id);
|
||
const askFirstIds = askFirstCatalogEntryIdsFor(
|
||
connected,
|
||
(catalogEntryId) => Boolean(enabledMap[catalogEntryId]),
|
||
);
|
||
// The Access step asks one question about agent reach, so profile access
|
||
// and installs are committed to the same target set instead of drifting
|
||
// apart behind two separate wizard screens.
|
||
const selection: AppAccessSelection = installChoice === "all"
|
||
? "all_agents"
|
||
: { agentIds: Array.from(installAgentIds) };
|
||
const finished = await toolsApi.finishApp(selectedCompanyId!, connected.connectionId, {
|
||
enabledCatalogEntryIds: enabledIds,
|
||
askFirstCatalogEntryIds: askFirstIds,
|
||
...(instructionValue ? { agentInstructions: instructionValue } : {}),
|
||
access: selection,
|
||
...(requestedAgentId ? { preserveExistingAccess: true } : {}),
|
||
});
|
||
await applyAccessInstalls(connected.connectionId);
|
||
return finished;
|
||
},
|
||
onSuccess: async (_finished, input) => {
|
||
if (resumeConnectionId && input.result.connectionId === resumeConnectionId) {
|
||
setCompletedResumeConnectionId(resumeConnectionId);
|
||
}
|
||
await queryClient.invalidateQueries({ queryKey: ["tools"] });
|
||
await queryClient.invalidateQueries({ queryKey: queryKeys.apps.attention(selectedCompanyId!) });
|
||
setAppStep("success");
|
||
onComplete?.({ connectionId: input.result.connectionId });
|
||
},
|
||
onError: (error) => {
|
||
// Creation must feel transactional: a failed commit returns the operator
|
||
// to the connect screen with their identity and agent selections intact
|
||
// rather than stranding them on a half-made connection.
|
||
setAppStep("key");
|
||
pushToast({
|
||
title: "Couldn’t finish setup",
|
||
body: error instanceof Error ? error.message : "Please try again.",
|
||
tone: "error",
|
||
});
|
||
},
|
||
});
|
||
|
||
if (!selectedCompanyId) {
|
||
return <div className="p-6 text-sm text-muted-foreground">Select an organization to connect apps.</div>;
|
||
}
|
||
|
||
if (
|
||
(resumeConnectionId || reconnectConnectionId)
|
||
&& (connectionsQuery.isError || applicationsQuery.isError)
|
||
) {
|
||
return (
|
||
<div className="mx-auto max-w-xl rounded-xl border border-border bg-card p-6">
|
||
<h2 className="text-lg font-semibold text-foreground">Couldn’t load connection setup</h2>
|
||
<p className="mt-2 text-sm text-muted-foreground">
|
||
Paperclip couldn’t check the retained connection. The retained connection was not changed.
|
||
</p>
|
||
<div className="mt-5 flex flex-wrap gap-2">
|
||
<Button
|
||
type="button"
|
||
onClick={async () => {
|
||
const [connectionsResult, applicationsResult] = await Promise.all([
|
||
connectionsQuery.refetch(),
|
||
applicationsQuery.refetch(),
|
||
]);
|
||
if (!connectionsResult.isError && !applicationsResult.isError) {
|
||
setOAuthError(null);
|
||
setOAuthPhase("entry");
|
||
}
|
||
}}
|
||
>
|
||
Try again
|
||
</Button>
|
||
<Button type="button" variant="outline" onClick={() => navigate("/apps")}>
|
||
Back to apps
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
if (resumeConnectionId && connectionsQuery.isFetchedAfterMount && !resumeConnection) {
|
||
return (
|
||
<div className="mx-auto max-w-xl rounded-xl border border-border bg-card p-6">
|
||
<h2 className="text-lg font-semibold text-foreground">This setup can’t be resumed</h2>
|
||
<p className="mt-2 text-sm text-muted-foreground">
|
||
The saved connection no longer exists or is not available to this organization.
|
||
</p>
|
||
<Button type="button" variant="outline" className="mt-5" onClick={() => navigate("/apps")}>
|
||
Back to apps
|
||
</Button>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
if (
|
||
resumeConnectionId
|
||
&& connectionsQuery.isFetchedAfterMount
|
||
&& applicationsQuery.isFetchedAfterMount
|
||
&& resumeConnection
|
||
&& !resumeSourceMatches
|
||
) {
|
||
return (
|
||
<div className="mx-auto max-w-xl rounded-xl border border-border bg-card p-6">
|
||
<h2 className="text-lg font-semibold text-foreground">This setup can’t be resumed</h2>
|
||
<p className="mt-2 text-sm text-muted-foreground">
|
||
This saved draft does not match the requested provider. The connection was not changed.
|
||
</p>
|
||
<Button type="button" variant="outline" className="mt-5" onClick={() => navigate("/apps")}>
|
||
Back to apps
|
||
</Button>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
if (
|
||
reconnectConnectionId
|
||
&& connectionsQuery.isFetchedAfterMount
|
||
&& applicationsQuery.isFetchedAfterMount
|
||
&& (!reconnectConnection || !reconnectSourceMatches)
|
||
) {
|
||
return (
|
||
<div className="mx-auto max-w-xl rounded-xl border border-border bg-card p-6">
|
||
<h2 className="text-lg font-semibold text-foreground">This connection can’t be reconnected</h2>
|
||
<p className="mt-2 text-sm text-muted-foreground">
|
||
{!reconnectConnection
|
||
? "The retained connection no longer exists or is not available to this organization."
|
||
: "This reconnect link does not match the retained connection's provider."}
|
||
</p>
|
||
<Button type="button" variant="outline" className="mt-5" onClick={() => navigate("/apps")}>
|
||
Back to apps
|
||
</Button>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
if ((resumeConnectionId || reconnectConnectionId) && galleryQuery.isError) {
|
||
return (
|
||
<div className="mx-auto max-w-xl rounded-xl border border-border bg-card p-6">
|
||
<h2 className="text-lg font-semibold text-foreground">Couldn’t load connection setup</h2>
|
||
<p className="mt-2 text-sm text-muted-foreground">
|
||
Paperclip couldn’t load the provider details needed to restore this connection. The retained connection was not changed.
|
||
</p>
|
||
<div className="mt-5 flex flex-wrap gap-2">
|
||
<Button type="button" onClick={() => void galleryQuery.refetch()}>
|
||
Try again
|
||
</Button>
|
||
<Button type="button" variant="outline" onClick={() => navigate("/apps")}>
|
||
Back to apps
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
if (reconnectConnectionId && unavailableReconnectId === reconnectConnectionId) {
|
||
return (
|
||
<div className="mx-auto max-w-xl rounded-xl border border-border bg-card p-6">
|
||
<h2 className="text-lg font-semibold text-foreground">This connection can’t be reconnected</h2>
|
||
<p className="mt-2 text-sm text-muted-foreground">
|
||
Paperclip no longer has a supported setup method for this retained connection. The retained connection was not changed.
|
||
</p>
|
||
<Button type="button" variant="outline" className="mt-5" onClick={() => navigate("/apps")}>
|
||
Back to apps
|
||
</Button>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
if (resumeConnectionId && (
|
||
!connectionsQuery.isFetchedAfterMount
|
||
|| galleryQuery.isLoading
|
||
|| !entry
|
||
|| hydratedResumeConnectionId !== resumeConnection?.id
|
||
)) {
|
||
return (
|
||
<div className="mx-auto max-w-xl space-y-4" aria-label="Loading saved connection setup">
|
||
<Skeleton className="h-8 w-48" />
|
||
<Skeleton className="h-40 w-full rounded-xl" />
|
||
</div>
|
||
);
|
||
}
|
||
|
||
if (reconnectConnectionId && (
|
||
!connectionsQuery.isFetchedAfterMount
|
||
|| !applicationsQuery.isFetchedAfterMount
|
||
|| galleryQuery.isLoading
|
||
|| Boolean(requestedAppKey && !entry)
|
||
)) {
|
||
return (
|
||
<div className="mx-auto max-w-xl space-y-4" aria-label="Loading retained connection setup">
|
||
<Skeleton className="h-8 w-48" />
|
||
<Skeleton className="h-40 w-full rounded-xl" />
|
||
</div>
|
||
);
|
||
}
|
||
|
||
if (showConnectionChoice && onUseExisting) {
|
||
return (
|
||
<div className="max-w-5xl" data-testid="connection-existing-choice">
|
||
<div className="mb-6">
|
||
<h1 className="text-2xl font-bold tracking-tight">
|
||
Use an existing {requestedAppKey ? "connection" : "app connection"}
|
||
</h1>
|
||
<p className="mt-1 text-sm text-muted-foreground">
|
||
Reuse a connection without changing who already has access, or connect a new one.
|
||
</p>
|
||
</div>
|
||
<ConnectionChoiceList
|
||
choices={existingConnections.map((connection) => ({
|
||
id: connection.id, name: connection.name,
|
||
description: connection.status === "active" && connection.enabled ? "Ready to use" : "Setup needs attention",
|
||
}))}
|
||
pendingId={existingConnectionPendingId}
|
||
onSelect={async (id) => {
|
||
setExistingConnectionPendingId(id);
|
||
setExistingConnectionError(null);
|
||
try { await onUseExisting(id); }
|
||
catch (error) {
|
||
setExistingConnectionError(error instanceof Error ? error.message : "Couldn’t use this connection.");
|
||
setExistingConnectionPendingId(null);
|
||
}
|
||
}}
|
||
/>
|
||
{existingConnectionError ? (
|
||
<InlineBanner tone="danger" className="mt-4">{existingConnectionError}</InlineBanner>
|
||
) : null}
|
||
<div className="mt-5 flex flex-wrap gap-2">
|
||
<Button type="button" variant="outline" onClick={() => setShowConnectionChoice(false)}>
|
||
{configuredConnection ? "Review connection setup" : "Connect new"}
|
||
</Button>
|
||
{onCancel ? <Button type="button" variant="ghost" onClick={onCancel}>Cancel</Button> : null}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
const credentialSourceMethods = connectionMethodsForCredentialSource(entry, credentialSource);
|
||
const setupCredentialSourceMethods = preEnrollmentManagedMethod
|
||
? [preEnrollmentManagedMethod]
|
||
: credentialSourceMethods;
|
||
|
||
// The stated default's identity line only makes sense when there *is* a
|
||
// credential, so it reads the selected method's auth kind.
|
||
const accessStepMethod = entry
|
||
? (connectionMethodKey
|
||
? setupCredentialSourceMethods.find((m) => m.key === connectionMethodKey) ?? null
|
||
: setupCredentialSourceMethods[0] ?? null)
|
||
: null;
|
||
const accessStepAuthKind: ToolConnectionAuthKind = entry
|
||
? accessStepMethod?.auth ?? "none"
|
||
: linkAuthMode === "none"
|
||
? "none"
|
||
: linkAuthMode === "oauth"
|
||
? "oauth"
|
||
: "api_key";
|
||
// PAP-659 C0: the resolved default is stated, not asked. `Change` opens the
|
||
// same controls the deleted Access step owned, inline and never blocking.
|
||
const renderConnectionDefaults = step === "key" ? (
|
||
(extra?: ReactNode, forceOpen?: boolean) => (
|
||
<>
|
||
<ConnectionAccessDefaults
|
||
key="connection-defaults"
|
||
extra={extra}
|
||
forceOpen={forceOpen}
|
||
companyId={selectedCompanyId}
|
||
sentence={connectionDefaultSummarySentence({
|
||
grantKind: effectiveGrantKind,
|
||
authKind: accessStepAuthKind,
|
||
installChoice,
|
||
installCount: installAgentIds.size,
|
||
lockedAgentId: requestedAgentId ?? null,
|
||
preserveAgentAccess: Boolean(automaticOAuthEntry && (resumableOAuthConnection || reconnectConnection)),
|
||
})}
|
||
notice={[
|
||
galleryQuery.data?.capabilities?.canCreateOrganizationGrant === false
|
||
? galleryQuery.data.capabilities.organizationGrantReason
|
||
?? "Only a connection manager can share this credential with the organization."
|
||
: null,
|
||
galleryQuery.data?.capabilities?.canSetCompanyInstall === false
|
||
? galleryQuery.data.capabilities.companyInstallReason
|
||
?? "Only someone who can configure this connection can give every agent access."
|
||
: null,
|
||
requestedAgentId ? "This task grants access only to the agent that asked for it." : null,
|
||
].filter((reason): reason is string => Boolean(reason))}
|
||
authKind={accessStepAuthKind}
|
||
grantKinds={fixedGrantKind ? [fixedGrantKind] : accessStepMethod?.grantKinds}
|
||
grantKind={effectiveGrantKind}
|
||
setGrantKind={setGrantKind}
|
||
installChoice={installChoice}
|
||
setInstallChoice={setInstallChoice}
|
||
installAgentIds={installAgentIds}
|
||
setInstallAgentIds={setInstallAgentIds}
|
||
lockedAgentId={requestedAgentId}
|
||
capabilities={galleryQuery.data?.capabilities}
|
||
githubIdentity={entry?.slug === "github"}
|
||
identityLoading={Boolean(automaticOAuthEntry) && directOAuthLookupPending}
|
||
preserveAgentAccess={Boolean(automaticOAuthEntry && (resumableOAuthConnection || reconnectConnection))}
|
||
disabled={connectMutation.isPending || oauthStartMutation.isPending}
|
||
/>
|
||
{entry && instructionTemplate && instructionValue && <div className="mt-6"><ConnectionInstructionsEditor provider={entry.name} template={instructionTemplate} value={instructionValue} onChange={(value) => setInstructionDraft({ slug: entry.slug, value })} disabled={connectMutation.isPending || oauthStartMutation.isPending} /></div>}
|
||
{additionalSettings && <div className="mt-6">{additionalSettings}</div>}
|
||
</>
|
||
)
|
||
) : null;
|
||
// A provider can advertise registration and still refuse this deployment's
|
||
// callback. When the method also accepts an
|
||
// operator's own OAuth client, that client is the recovery path, so it sits
|
||
// in the same Advanced panel and opens itself once sign-in has failed.
|
||
const automaticCustomerClientMethod = automaticOAuthEntry
|
||
&& entryAutomaticOAuthMethod
|
||
&& connectionMethodAcceptsCustomerOAuthClient(entryAutomaticOAuthMethod)
|
||
? entryAutomaticOAuthMethod
|
||
: null;
|
||
const automaticCustomerClientFields = automaticCustomerClientMethod && automaticOAuthEntry ? (
|
||
<OAuthClientFields
|
||
entry={automaticOAuthEntry}
|
||
method={automaticCustomerClientMethod}
|
||
callbackUrl={callbackUrlForSetup}
|
||
clientId={curatedOAuthClientId}
|
||
onClientIdChange={setCuratedOAuthClientId}
|
||
clientSecret={curatedOAuthClientSecret}
|
||
onClientSecretChange={setCuratedOAuthClientSecret}
|
||
required={false}
|
||
/>
|
||
) : null;
|
||
const connectionDefaults = renderConnectionDefaults?.() ?? null;
|
||
const curatedOAuthDefaults = automaticCustomerClientFields
|
||
? renderConnectionDefaults?.(
|
||
automaticCustomerClientFields,
|
||
oauthPhase === "error" || curatedOAuthClientId.trim().length > 0,
|
||
) ?? null
|
||
: connectionDefaults;
|
||
|
||
const showCuratedOAuthState = Boolean(
|
||
automaticOAuthEntry
|
||
&& step === "key"
|
||
&& (directOAuthEntry || oauthPhase !== "entry"),
|
||
);
|
||
|
||
const managedConnectorUnavailable = Boolean(
|
||
step === "key"
|
||
&& entry
|
||
&& requestedDefinitionUsesManagedConnector
|
||
&& !entryAdvertisesManagedConnector
|
||
&& connectorEnrollmentQuery.data?.configured === true
|
||
);
|
||
|
||
const showConnectorEnrollmentStep = Boolean(
|
||
step === "key"
|
||
&& entry
|
||
&& requestedDefinitionUsesManagedConnector
|
||
&& !entryAdvertisesManagedConnector
|
||
&& (
|
||
connectorEnrollmentQuery.isLoading
|
||
|| connectorEnrollmentQuery.isError
|
||
|| connectorEnrollmentQuery.data?.configured !== true
|
||
)
|
||
);
|
||
|
||
if (showCuratedOAuthState && automaticOAuthEntry) {
|
||
return (
|
||
<OAuthConnectStateScreen
|
||
entry={automaticOAuthEntry}
|
||
resuming={Boolean(resumeConnectionId)}
|
||
phase={oauthPhase}
|
||
error={oauthError}
|
||
recoveryActions={oauthCallbackCode === "github_installation_required" ? {
|
||
installationUrl: githubInstallationUrl,
|
||
managementUrl: githubManagementUrl,
|
||
} : undefined}
|
||
authorizationHost={authorizationHost}
|
||
authorizationUrl={authorizationFallbackUrl}
|
||
guidance={entry?.slug === "railway" && accessStepMethod ? (
|
||
<div className="space-y-3 text-sm text-muted-foreground">
|
||
<p>{accessStepMethod.guidanceMd}</p>
|
||
<ul className="list-disc space-y-2 pl-5">
|
||
{accessStepMethod.warnings?.map((warning) => <li key={warning}>{warning}</li>)}
|
||
</ul>
|
||
</div>
|
||
) : null}
|
||
defaults={curatedOAuthDefaults}
|
||
settingsValid={additionalSettingsValid && instructionsValid}
|
||
onOpenAuthorization={openAuthorizationTab}
|
||
onRetry={async () => {
|
||
const firstAttempt = !directOAuthAccessConfirmedRef.current;
|
||
directOAuthAccessConfirmedRef.current = true;
|
||
setOAuthError(null);
|
||
setOAuthPhase("starting");
|
||
const connection = connectResult?.connection ?? resumableOAuthConnection;
|
||
if (connection && automaticCustomerClientFields && curatedOAuthClientId.trim()) {
|
||
// The draft exists but its client must change: resume it through
|
||
// connect so the operator's client replaces the refused registration.
|
||
customerClientResumeRef.current = connection.id;
|
||
connectApp(automaticOAuthEntry);
|
||
return;
|
||
}
|
||
if (connection) {
|
||
startOAuth(connection);
|
||
return;
|
||
}
|
||
if (
|
||
firstAttempt
|
||
&& !resumeConnectionId
|
||
&& !applicationsQuery.isError
|
||
&& !connectionsQuery.isError
|
||
) {
|
||
connectApp(automaticOAuthEntry);
|
||
return;
|
||
}
|
||
|
||
// The create request may have reached the server even when its
|
||
// response did not reach the browser. Re-read both resources before
|
||
// creating again so Retry resumes that durable draft instead of
|
||
// duplicating it.
|
||
directOAuthRetryingRef.current = true;
|
||
try {
|
||
const [applicationsResult, connectionsResult] = await Promise.all([
|
||
applicationsQuery.refetch(),
|
||
connectionsQuery.refetch(),
|
||
]);
|
||
if (applicationsResult.isError || connectionsResult.isError) {
|
||
setOAuthPhase("error");
|
||
setOAuthError("Paperclip couldn’t check for an existing connection. Try again.");
|
||
return;
|
||
}
|
||
const refreshedResumeConnection = resumeConnectionId
|
||
? (connectionsResult.data?.connections ?? []).find(
|
||
(candidate) => candidate.id === resumeConnectionId && candidate.authKind === "oauth",
|
||
) ?? null
|
||
: null;
|
||
const refreshedConnection = refreshedResumeConnection ?? reusableOAuthConnection(
|
||
directOAuthSource,
|
||
applicationsResult.data?.applications ?? [],
|
||
connectionsResult.data?.connections ?? [],
|
||
createNewConnection
|
||
? { applicationId: prefill.applicationId, draftOnly: true }
|
||
: {},
|
||
);
|
||
if (refreshedConnection && !resumeConnectionId) {
|
||
// Adopt the durable draft's identity before resuming it, so the
|
||
// stated default on screen matches what is about to be authorized.
|
||
setGrantKind(
|
||
refreshedConnection.credentialPolicy === "per_user"
|
||
? "user"
|
||
: refreshedConnection.credentialPolicy === "per_agent"
|
||
? "agent"
|
||
: "organization",
|
||
);
|
||
}
|
||
if (refreshedConnection) {
|
||
startOAuth(refreshedConnection);
|
||
} else {
|
||
connectMutation.mutate(automaticOAuthEntry);
|
||
}
|
||
} finally {
|
||
directOAuthRetryingRef.current = false;
|
||
}
|
||
}}
|
||
onBack={() => {
|
||
oauthHandoffAbortRef.current?.abort();
|
||
setOAuthPhase("entry");
|
||
setOAuthError(null);
|
||
backToGallery();
|
||
}}
|
||
onCancel={() => {
|
||
oauthHandoffAbortRef.current?.abort();
|
||
(onCancel ?? (() => navigate("/apps")))();
|
||
}}
|
||
/>
|
||
);
|
||
}
|
||
|
||
// A pasted endpoint that needs browser sign-in gets the same waiting/retry
|
||
// screen a curated OAuth app does, minus the branding it doesn't have.
|
||
if (genericOAuthPending && !entry && linkUrl && step === "key") {
|
||
return (
|
||
<OAuthConnectStateScreen
|
||
identity={{
|
||
name: linkName.trim() || endpointHost(linkUrl) || "this server",
|
||
unverifiedHost: endpointHost(linkUrl),
|
||
}}
|
||
// A pasted endpoint walks the generic three-step wizard, so keep that
|
||
// model rather than dropping to the curated two-step one.
|
||
steps={{ labels: STEP_LABELS, activeIndex: STEP_INDEX.key }}
|
||
phase={oauthPhase}
|
||
error={oauthError}
|
||
authorizationHost={authorizationHost}
|
||
authorizationUrl={authorizationFallbackUrl}
|
||
defaults={connectionDefaults}
|
||
onOpenAuthorization={openAuthorizationTab}
|
||
onRetry={() => {
|
||
setOAuthError(null);
|
||
const connection = connectResult?.connection;
|
||
if (connection) {
|
||
setOAuthPhase("starting");
|
||
startOAuth(connection);
|
||
return;
|
||
}
|
||
// No draft to resume, so fall back to the setup screen rather than
|
||
// creating a second connection for the same endpoint.
|
||
setGenericOAuthPending(false);
|
||
setOAuthPhase("entry");
|
||
}}
|
||
onBack={() => {
|
||
oauthHandoffAbortRef.current?.abort();
|
||
setGenericOAuthPending(false);
|
||
setOAuthPhase("entry");
|
||
setOAuthError(null);
|
||
}}
|
||
onCancel={() => {
|
||
oauthHandoffAbortRef.current?.abort();
|
||
(onCancel ?? (() => navigate("/apps")))();
|
||
}}
|
||
/>
|
||
);
|
||
}
|
||
|
||
const appName =
|
||
connectResult?.application.name ??
|
||
entry?.name ??
|
||
(linkName.trim() || defaultGenericMcpName(linkUrl) || "this app");
|
||
const credentialSourceApps = vercelConnectMode
|
||
? visibleGalleryApps.filter(
|
||
(app) => connectionMethodsForCredentialSource(app, credentialSource).length > 0,
|
||
)
|
||
: visibleGalleryApps;
|
||
const zapierEntry = zapierSource
|
||
? galleryQuery.data?.apps.find((app) => app.slug === "zapier") ?? null
|
||
: null;
|
||
const reconnectAiMethod = reconnectConnection?.connectionPurpose === "ai"
|
||
? aiConnectionMetadataSchema.safeParse(reconnectConnection.config?.ai).data
|
||
: undefined;
|
||
const aiMethod = reconnectAiMethod ?? entry?.methods.find(method => method.key === connectionMethodKey)?.ai
|
||
?? (!connectionMethodKey && entry?.methods.every(method => method.ai) ? entry.methods[0]?.ai : undefined);
|
||
const credentialStep = entry ? renderCredentialStep?.({ app: entry, name: galleryName || entry.name, grantKind: effectiveGrantKind, agentIds: [...installAgentIds], allAgents: installChoice === "all", onBack: () => backToGallery() }) ?? (aiMethod && selectedCompanyId ? <><AiConnectionCredentialStep
|
||
defaults={reconnectConnection ? undefined : connectionDefaults}
|
||
companyId={selectedCompanyId} provider={aiMethod.provider} fixedMethod={Boolean(aiConnection && aiConnection.mode !== "responsible_user")} initialMethod={reconnectConnection?.connectionPurpose === "ai" ? (reconnectConnection.config?.ai as { method: "subscription" | "api_key" }).method : aiMethod.method}
|
||
connectionId={reconnectConnection?.connectionPurpose === "ai" ? reconnectConnection.id : undefined}
|
||
name={reconnectConnection?.connectionPurpose === "ai" ? reconnectConnection.name : galleryName || `My ${entry.name} ${aiMethod.method === "subscription" ? "subscription" : "API"}`}
|
||
ownership={(reconnectConnection?.connectionPurpose === "ai" ? reconnectConnection.credentialPolicy === "shared" : effectiveGrantKind === "organization") ? "shared" : "personal"}
|
||
agentIds={[...installAgentIds]} allAgents={installChoice === "all"}
|
||
onCancel={() => onCancel ? onCancel() : navigate("/apps")}
|
||
onComplete={result => { onComplete?.({ connectionId: result.connectionId }); if (!onComplete) navigate(`/apps/${result.connectionId}/permissions`); }}
|
||
/></> : undefined) : undefined;
|
||
// One screen per connector (PAP-659): a selected app has a single step, so
|
||
// its stepper collapses to nothing rather than showing one lonely dot.
|
||
const stepLabels = reconnectConnection?.connectionPurpose === "ai" ? ["Reconnect account"] : credentialStep !== undefined
|
||
? ["Connect account"]
|
||
: zapierSource
|
||
? ZAPIER_STEP_LABELS
|
||
: entry && setupCredentialSourceMethods.length > 1
|
||
? ["Choose connection"]
|
||
: entry && setupCredentialSourceMethods[0]?.auth === "oauth"
|
||
? ["Sign in"]
|
||
: isGoogleSheetsRobotMethod(entry, connectionMethodKey)
|
||
? ["Share sheet"]
|
||
: entry
|
||
? ["Add your key"]
|
||
: STEP_LABELS;
|
||
const stepIndex = reconnectConnection?.connectionPurpose === "ai" ? 0 : (zapierSource || entry) && step !== "gallery" && step !== "success"
|
||
? SELECTED_APP_STEP_INDEX[step]
|
||
: step === "success"
|
||
? stepLabels.length
|
||
: STEP_INDEX[step];
|
||
|
||
return (
|
||
<div className="max-w-5xl">
|
||
{step !== "success" && (
|
||
!(byoOnly && step === "gallery") && (
|
||
<StepHeader
|
||
subtitle={
|
||
step === "gallery"
|
||
? vercelConnectMode
|
||
? "Choose a reviewed app to connect through Vercel."
|
||
: "Pick the app you want your agents to use."
|
||
: stepLabels.length <= 1
|
||
? undefined
|
||
: `Step ${stepIndex + 1} of ${stepLabels.length}`
|
||
}
|
||
step={step}
|
||
activeIndex={stepIndex}
|
||
labels={stepLabels}
|
||
appIdentity={
|
||
zapierSource
|
||
? { name: "Zapier", logoUrl: zapierEntry?.branding.logoUrl ?? null, darkLogoUrl: zapierEntry?.branding.darkLogoUrl ?? null }
|
||
: entry && step !== "gallery"
|
||
? { name: entry.name, logoUrl: entry.branding.logoUrl, darkLogoUrl: entry.branding.darkLogoUrl ?? null }
|
||
: undefined
|
||
}
|
||
unverifiedHost={!entry && !zapierSource && step !== "gallery" ? endpointHost(linkUrl) : null}
|
||
onCancel={onCancel ?? (() => navigate("/apps"))}
|
||
/>
|
||
)
|
||
)}
|
||
|
||
{step === "key" && connectionSettings && <div className="mx-auto mb-6 max-w-xl">{connectionSettings}</div>}
|
||
|
||
{step === "gallery" && (
|
||
<GalleryStep
|
||
loading={galleryQuery.isLoading}
|
||
initialLink={linkUrl}
|
||
apps={credentialSourceApps}
|
||
vercelConnect={vercelConnectMode}
|
||
vercelConnectAvailability={galleryQuery.data?.credentialSources?.vercelConnect ?? null}
|
||
byo={byo}
|
||
byoOnly={byoOnly}
|
||
source={searchParams.get("source")}
|
||
onPick={useMatchedGalleryEntry}
|
||
onUseLink={(url) => {
|
||
const matchedEntry = getAppDefinitionForUrl(url, visibleGalleryApps);
|
||
setEntry(null);
|
||
setGalleryName("");
|
||
setLinkUrl(url);
|
||
setLinkName(matchedEntry?.name ?? defaultGenericMcpName(url) ?? "");
|
||
setLinkNeedsKey(false);
|
||
setLinkKey("");
|
||
setCredentials({});
|
||
setGoogleSheetsLinks("");
|
||
setGoogleSheetsError(null);
|
||
setInstallAgentIds(new Set(requestedAgentId ? [requestedAgentId] : []));
|
||
setInstallChoice(requestedAgentId ? "specific" : "all");
|
||
setGrantKind(reconnectGrantKind ?? (requestedAgentId ? "user" : "organization"));
|
||
setStep("key");
|
||
}}
|
||
/>
|
||
)}
|
||
|
||
{managedConnectorUnavailable && entry ? (
|
||
<div className="mx-auto max-w-xl rounded-xl border border-border bg-card p-6">
|
||
<h2 className="text-lg font-semibold text-foreground">{entry.name} sign-in is unavailable</h2>
|
||
<p className="mt-2 text-sm text-muted-foreground">
|
||
This instance is connected to Paperclip, but {entry.name} sign-in is not currently available. Try again shortly or contact your instance administrator.
|
||
</p>
|
||
{customOAuthMethod ? (
|
||
<Button type="button" variant="link" className="mt-4 h-auto p-0 text-xs" onClick={() => setConnectionMethodKey(customOAuthMethod.key)}>
|
||
Use your own {entry.name} OAuth app
|
||
</Button>
|
||
) : null}
|
||
<div className="mt-6 flex items-center justify-between gap-3">
|
||
<Button type="button" variant="ghost" onClick={backToGallery}>Back</Button>
|
||
<Button type="button" disabled={galleryQuery.isFetching} onClick={() => void galleryQuery.refetch()}>
|
||
{galleryQuery.isFetching ? <Loader2 className="h-4 w-4 animate-spin" /> : null}
|
||
Try again
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
) : step === "key" && entry && showConnectorEnrollmentStep ? (
|
||
<div className="mx-auto max-w-xl">
|
||
<div className="rounded-xl border border-border bg-card p-6">
|
||
<div className="flex items-start gap-3">
|
||
<div className="rounded-lg bg-muted p-2 text-muted-foreground">
|
||
<Cloud className="h-5 w-5" />
|
||
</div>
|
||
<div className="min-w-0">
|
||
<h2 className="text-lg font-semibold text-foreground">
|
||
Connect with Paperclip
|
||
</h2>
|
||
<p className="mt-2 text-sm text-muted-foreground">
|
||
You must connect this instance to Paperclip to connect to {entry.name} (you only need to do this once).
|
||
</p>
|
||
</div>
|
||
</div>
|
||
|
||
{customOAuthMethod ? (
|
||
<Button type="button" variant="link" className="mt-4 h-auto p-0 text-xs" onClick={() => setConnectionMethodKey(customOAuthMethod.key)}>
|
||
Use your own {entry.name} OAuth app
|
||
</Button>
|
||
) : null}
|
||
|
||
{connectorEnrollmentQuery.isError || connectorEnrollmentError ? (
|
||
<InlineBanner tone="danger" className="mt-4">
|
||
{connectorEnrollmentError ?? "Paperclip couldn’t check Cloud registration. Try again."}
|
||
</InlineBanner>
|
||
) : null}
|
||
|
||
{enrollmentAuthorizationUrl ? (
|
||
<p className="mt-4 text-sm text-muted-foreground">
|
||
Finish authorization in the opened window.{' '}
|
||
<a className="underline" href={enrollmentAuthorizationUrl} target="_blank" rel="noopener noreferrer">
|
||
Open authorization in a new tab
|
||
</a>
|
||
</p>
|
||
) : null}
|
||
{connectionDefaults}
|
||
<div className="mt-6 flex items-center justify-between gap-3">
|
||
<Button type="button" variant="ghost" onClick={backToGallery}>
|
||
Back
|
||
</Button>
|
||
<Button
|
||
type="button"
|
||
disabled={connectorEnrollmentQuery.isLoading || startConnectorEnrollment.isPending}
|
||
onClick={() => {
|
||
setConnectorEnrollmentError(null);
|
||
reserveOAuthPopup();
|
||
preserveEnrollmentAccess();
|
||
// Let the server reuse a live enrollment or replace an expired
|
||
// one. A cached verification URL may expire while this page is open.
|
||
startConnectorEnrollment.mutate();
|
||
}}
|
||
>
|
||
{startConnectorEnrollment.isPending ? <Loader2 className="h-4 w-4 animate-spin" /> : null}
|
||
{connectorEnrollmentQuery.data?.status === "pending"
|
||
? "Continue"
|
||
: "Connect with Paperclip"}
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
) : step === "key" && entry && credentialStep !== undefined ? credentialStep : step === "key" && entry ? (
|
||
<KeyStep
|
||
settingsValid={additionalSettingsValid && instructionsValid}
|
||
entry={entry}
|
||
error={connectMutation.isError ? (connectMutation.error instanceof Error ? connectMutation.error.message : "Please check your key and try again.") : null}
|
||
values={credentials}
|
||
onChange={setCredentials}
|
||
oauthClientId={curatedOAuthClientId}
|
||
onOAuthClientIdChange={setCuratedOAuthClientId}
|
||
oauthClientSecret={curatedOAuthClientSecret}
|
||
oauthCallbackUrl={callbackUrlForSetup}
|
||
canReuseOAuthClientSecret={Boolean(
|
||
identityConnection?.config?.oauth
|
||
&& (identityConnection.config.oauth as Record<string, unknown>).clientId === curatedOAuthClientId.trim()
|
||
&& identityConnection.hasSavedOAuthClientSecret
|
||
)}
|
||
onOAuthClientSecretChange={setCuratedOAuthClientSecret}
|
||
credentialSource={credentialSource}
|
||
vercelConnector={vercelConnector}
|
||
onVercelConnectorChange={setVercelConnector}
|
||
vercelConnectAvailability={galleryQuery.data?.credentialSources?.vercelConnect ?? null}
|
||
methodKey={connectionMethodKey}
|
||
onMethodChange={(nextMethod) => {
|
||
setConnectionMethodKey(nextMethod?.key ?? "");
|
||
// Capability/auth changes must not broaden the audience selected
|
||
// on Access (including choices restored after Cloud enrollment).
|
||
if (
|
||
!reconnectGrantKind
|
||
&& nextMethod?.grantKinds
|
||
&& !nextMethod.grantKinds.includes(grantKind)
|
||
) {
|
||
setGrantKind(defaultGrantKindFor(nextMethod, Boolean(requestedAgentId)));
|
||
}
|
||
setCredentials({});
|
||
setCuratedOAuthClientId("");
|
||
setCuratedOAuthClientSecret("");
|
||
setVercelConnector("");
|
||
setConfigValues(defaultMethodConfig(nextMethod));
|
||
}}
|
||
configValues={configValues}
|
||
onConfigChange={setConfigValues}
|
||
googleSheetsLinks={googleSheetsLinks}
|
||
googleSheetsError={googleSheetsError}
|
||
onGoogleSheetsLinksChange={(next) => {
|
||
setGoogleSheetsLinks(next);
|
||
setGoogleSheetsError(null);
|
||
}}
|
||
submitting={connectMutation.isPending}
|
||
renderDefaults={renderConnectionDefaults ?? undefined}
|
||
// Back returns to the connector gallery for new, resumed, and
|
||
// reconnected accounts. Cancel is the separate exit to the app list.
|
||
onBack={backToGallery}
|
||
onConnect={() => {
|
||
if (isGoogleSheetsRobotMethod(entry, connectionMethodKey)) {
|
||
const parsed = parseGoogleSheetIds(googleSheetsLinks);
|
||
if (parsed.invalidCount > 0) {
|
||
setGoogleSheetsError("That doesn't look like a Google Sheets link.");
|
||
return;
|
||
}
|
||
if (parsed.ids.length === 0) {
|
||
setGoogleSheetsError("Paste at least one Google Sheets link.");
|
||
return;
|
||
}
|
||
}
|
||
const selectedMethod = availableToolConnectionMethod(
|
||
entry,
|
||
connectionMethodKey || null,
|
||
);
|
||
const selectedMethodHasProviderFields = Boolean(
|
||
selectedMethod?.credentialFields?.length
|
||
|| selectedMethod?.tenantFields?.some((field) => !field.hidden)
|
||
|| selectedMethod?.extensionFields?.some((field) => !field.hidden),
|
||
);
|
||
if (
|
||
selectedMethod
|
||
&& connectionMethodSupportsAutomaticOAuth(selectedMethod)
|
||
&& resumableOAuthConnection
|
||
&& !selectedMethodHasProviderFields
|
||
&& !curatedOAuthClientId.trim()
|
||
) {
|
||
directOAuthAccessConfirmedRef.current = true;
|
||
setOAuthError(null);
|
||
setOAuthPhase("starting");
|
||
startOAuth(resumableOAuthConnection);
|
||
return;
|
||
}
|
||
if (selectedMethod && connectionMethodSupportsAutomaticOAuth(selectedMethod)) {
|
||
directOAuthAccessConfirmedRef.current = true;
|
||
setOAuthError(null);
|
||
setOAuthPhase("starting");
|
||
}
|
||
connectApp();
|
||
}}
|
||
/>
|
||
) : null}
|
||
|
||
{/* A deep link can arrive before the gallery resolves its definition.
|
||
The Access step used to carry that wait; without it the connect screen
|
||
has to show the wait itself rather than render nothing. */}
|
||
{step === "key" && !entry && !linkUrl && !zapierSource && requestedAppKey && (
|
||
<div className="mx-auto max-w-xl" aria-busy="true" aria-label={`Loading ${requestedAppKey} setup`}>
|
||
<Skeleton className="h-6 w-2/3 rounded-md" />
|
||
<Skeleton className="mt-3 h-4 w-full rounded-md" />
|
||
<Skeleton className="mt-8 h-11 w-40 rounded-md" />
|
||
</div>
|
||
)}
|
||
|
||
{step === "key" && !entry && linkUrl && !zapierSource && (
|
||
<LinkConnectStep
|
||
link={linkUrl}
|
||
name={linkName}
|
||
needsKey={linkNeedsKey}
|
||
keyValue={linkKey}
|
||
onKeyChange={setLinkKey}
|
||
authMode={linkAuthMode}
|
||
onAuthModeChange={(next) => {
|
||
setLinkAuthMode(next);
|
||
setLinkGuidance(null);
|
||
// Leaving the simple path means the explicit choice governs; drop the
|
||
// "does it need a key?" answer so the two can't disagree.
|
||
if (next !== "auto") setLinkNeedsKey(false);
|
||
if (next !== "bearer" && next !== "auto") setLinkKey("");
|
||
}}
|
||
headers={linkHeaders}
|
||
onHeadersChange={(next) => {
|
||
setLinkHeaders(next);
|
||
setLinkGuidance(null);
|
||
}}
|
||
oauthClientId={linkOAuthClientId}
|
||
onOAuthClientIdChange={setLinkOAuthClientId}
|
||
oauthClientSecret={linkOAuthClientSecret}
|
||
onOAuthClientSecretChange={setLinkOAuthClientSecret}
|
||
advancedOpen={linkAdvancedOpen}
|
||
onAdvancedOpenChange={setLinkAdvancedOpen}
|
||
guidance={linkGuidance}
|
||
matchedEntry={linkMatchedEntry}
|
||
onUseMatchedEntry={linkMatchedEntry ? () => useMatchedGalleryEntry(linkMatchedEntry) : undefined}
|
||
submitting={connectMutation.isPending || genericOAuthPending}
|
||
defaults={connectionDefaults}
|
||
onBack={backToGallery}
|
||
onConnect={() => {
|
||
setLinkGuidance(null);
|
||
connectMutation.mutate(undefined);
|
||
}}
|
||
/>
|
||
)}
|
||
|
||
{step === "key" && !entry && zapierSource && (
|
||
<ZapierConnectStep
|
||
link={linkUrl}
|
||
onLinkChange={setLinkUrl}
|
||
submitting={connectMutation.isPending}
|
||
defaults={connectionDefaults}
|
||
onBack={backToGallery}
|
||
onConnect={() => connectMutation.mutate(undefined)}
|
||
/>
|
||
)}
|
||
|
||
{step === "success" && (
|
||
<ConnectionSetupCompletionScreen
|
||
appName={appName}
|
||
logoUrl={entry?.branding.logoUrl}
|
||
darkLogoUrl={entry?.branding.darkLogoUrl}
|
||
summary={accessSummaryLines({
|
||
grantKind: effectiveGrantKind,
|
||
authKind: accessStepAuthKind,
|
||
installChoice,
|
||
installCount: installAgentIds.size,
|
||
enabledCount: Object.values(enabled).filter(Boolean).length,
|
||
})}
|
||
statedDefault={connectionDefaultSummarySentence({
|
||
grantKind: effectiveGrantKind,
|
||
authKind: accessStepAuthKind,
|
||
installChoice,
|
||
installCount: installAgentIds.size,
|
||
lockedAgentId: requestedAgentId ?? null,
|
||
})}
|
||
onDone={onCancel ?? (() => navigate(
|
||
host === "page" && connectResult?.connectionId
|
||
? `/apps/${connectResult.connectionId}/permissions`
|
||
: "/apps",
|
||
))}
|
||
/>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
export function OAuthConnectStateScreen({
|
||
entry,
|
||
identity,
|
||
resuming = false,
|
||
phase,
|
||
error,
|
||
recoveryActions,
|
||
authorizationHost,
|
||
authorizationUrl,
|
||
steps = { labels: OAUTH_SIGN_IN_STEP_LABELS, activeIndex: 0 },
|
||
guidance,
|
||
defaults,
|
||
onRetry,
|
||
settingsValid = true,
|
||
onOpenAuthorization,
|
||
onBack,
|
||
onCancel,
|
||
}: {
|
||
/** A curated app. Omit for a generic endpoint and pass `identity` instead. */
|
||
entry?: AppDefinition | null;
|
||
/** Identity for an unknown remote MCP server: its own name plus its host. */
|
||
identity?: { name: string; unverifiedHost: string | null };
|
||
/** This screen is continuing a durable draft rather than creating a new one. */
|
||
resuming?: boolean;
|
||
phase: OAuthConnectPhase;
|
||
error?: string | null;
|
||
recoveryActions?: { installationUrl: string | null; managementUrl: string | null };
|
||
/**
|
||
* Host of the authorization page being opened. A valid HTTPS authorization
|
||
* page can still be a phishing page, so the operator sees exactly which host
|
||
* they are being handed to (PAP-17099).
|
||
*/
|
||
authorizationHost?: string | null;
|
||
/** Already validated by prepareOAuthNavigation; used for a native browser link. */
|
||
authorizationUrl?: string | null;
|
||
/**
|
||
* Step model of the flow that opened this screen, so the stepper keeps the
|
||
* shape it had on the previous screen. Defaults to the two-step curated
|
||
* sign-in model for hosts that have no wizard of their own.
|
||
*/
|
||
steps?: { labels: string[]; activeIndex: number };
|
||
/** Provider-specific warnings that must be read before authorizing. */
|
||
guidance?: ReactNode;
|
||
/** The stated default and its Advanced disclosure (PAP-659 C0). */
|
||
defaults?: ReactNode;
|
||
settingsValid?: boolean;
|
||
onOpenAuthorization?: () => void;
|
||
onRetry: () => void;
|
||
onBack: () => void;
|
||
onCancel: () => void;
|
||
}) {
|
||
const serverName = entry?.name ?? identity?.name ?? "this server";
|
||
const unverifiedHost = entry ? null : identity?.unverifiedHost ?? null;
|
||
const status = phase === "entry"
|
||
? {
|
||
title: resuming
|
||
? `Finish connecting ${serverName}`
|
||
: `Connect ${serverName} to Paperclip`,
|
||
body: resuming
|
||
? `Your connection is saved. Continue in ${serverName} to approve access; its identity and agent access will stay the same.`
|
||
: `Paperclip will open ${serverName} so you can choose a workspace and approve access.`,
|
||
}
|
||
: phase === "starting"
|
||
? {
|
||
title: "Preparing secure sign-in",
|
||
body: `Paperclip is creating a secure ${serverName} connection.`,
|
||
}
|
||
: phase === "redirecting"
|
||
? {
|
||
title: `Opening ${serverName}`,
|
||
body: authorizationHost
|
||
? `Continue at ${authorizationHost} to choose a workspace and approve access. Only approve access if you recognize that address.`
|
||
: `Continue in ${serverName} to choose a workspace and approve access.`,
|
||
}
|
||
: {
|
||
title: `${serverName} couldn’t connect`,
|
||
body: error ?? "Paperclip couldn’t start secure sign-in. Try again.",
|
||
};
|
||
|
||
return (
|
||
<div className="max-w-5xl">
|
||
<StepHeader
|
||
subtitle="Secure MCP sign-in"
|
||
step="key"
|
||
activeIndex={steps.activeIndex}
|
||
labels={steps.labels}
|
||
appIdentity={entry ? { name: entry.name, logoUrl: entry.branding.logoUrl, darkLogoUrl: entry.branding.darkLogoUrl } : undefined}
|
||
unverifiedHost={unverifiedHost}
|
||
onCancel={onCancel}
|
||
/>
|
||
<div className="mx-auto max-w-xl">
|
||
<div className="flex items-start gap-3">
|
||
<span className="mt-1 inline-flex h-10 w-10 shrink-0 items-center justify-center rounded-lg border border-border bg-background">
|
||
{phase === "error" ? (
|
||
<Link2 className="h-5 w-5 text-destructive" />
|
||
) : phase === "entry" ? (
|
||
<Lock className="h-5 w-5 text-muted-foreground" />
|
||
) : (
|
||
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
|
||
)}
|
||
</span>
|
||
<div className="min-w-0">
|
||
<h2 className="text-xl font-bold tracking-tight">{status.title}</h2>
|
||
<p className="mt-1 text-sm text-muted-foreground">{status.body}</p>
|
||
{unverifiedHost ? <UnverifiedServerBadge host={unverifiedHost} className="mt-2" /> : null}
|
||
</div>
|
||
</div>
|
||
|
||
{phase === "entry" && guidance ? <div className="mt-4">{guidance}</div> : null}
|
||
{phase === "entry" || phase === "error" ? defaults : null}
|
||
|
||
{phase === "error" && recoveryActions && (recoveryActions.installationUrl || recoveryActions.managementUrl) ? (
|
||
<div className="mt-4 flex flex-wrap gap-2">
|
||
{recoveryActions.installationUrl ? (
|
||
<Button type="button" variant="outline" asChild>
|
||
<a href={recoveryActions.installationUrl} target="_blank" rel="noreferrer">
|
||
Install Paperclip on GitHub
|
||
<ArrowUpRight className="ml-1.5 h-3.5 w-3.5" />
|
||
</a>
|
||
</Button>
|
||
) : null}
|
||
{recoveryActions.managementUrl ? (
|
||
<Button type="button" variant="ghost" asChild>
|
||
<a href={recoveryActions.managementUrl} target="_blank" rel="noreferrer">
|
||
Manage repositories on GitHub
|
||
</a>
|
||
</Button>
|
||
) : null}
|
||
</div>
|
||
) : null}
|
||
|
||
<div className="mt-6 flex items-center gap-2">
|
||
{phase === "error" || phase === "entry" ? (
|
||
<Button type="button" onClick={onRetry} disabled={!settingsValid}>
|
||
{phase === "entry"
|
||
? resuming ? `Finish with ${serverName}` : `Continue to ${serverName}`
|
||
: "Try again"}
|
||
{phase === "entry" ? <ArrowUpRight className="h-4 w-4" aria-hidden="true" /> : null}
|
||
</Button>
|
||
) : (
|
||
<Button type="button" disabled>
|
||
{phase === "redirecting" ? `Opening ${serverName}…` : "Preparing…"}
|
||
</Button>
|
||
)}
|
||
{authorizationUrl ? <Button variant="outline" asChild><a href={authorizationUrl} target="_blank" rel="noopener noreferrer" onClick={onOpenAuthorization}>Open sign-in in a new tab</a></Button> : null}
|
||
<Button type="button" variant="ghost" onClick={onBack}>Back</Button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function ZapierConnectStep({
|
||
link,
|
||
onLinkChange,
|
||
submitting,
|
||
defaults,
|
||
onBack,
|
||
onConnect,
|
||
}: {
|
||
link: string;
|
||
onLinkChange: (next: string) => void;
|
||
submitting: boolean;
|
||
/** The stated default and its Advanced disclosure (PAP-659 C0). */
|
||
defaults?: ReactNode;
|
||
onBack: () => void;
|
||
onConnect: () => void;
|
||
}) {
|
||
const normalizedLink = normalizeAppLink(link);
|
||
const zapierHostname = normalizedLink ? new URL(normalizedLink).hostname : "";
|
||
const isZapierLink = zapierHostname === "zapier.com" || zapierHostname.endsWith(".zapier.com");
|
||
|
||
return (
|
||
<div className="mx-auto max-w-xl">
|
||
<div>
|
||
<label className="text-sm font-medium text-foreground">Zapier MCP URL</label>
|
||
<Input
|
||
type="password"
|
||
autoComplete="off"
|
||
spellCheck={false}
|
||
value={link}
|
||
onChange={(event) => onLinkChange(event.target.value)}
|
||
onKeyDown={(event) => {
|
||
if (event.key === "Enter" && isZapierLink && !submitting) onConnect();
|
||
}}
|
||
placeholder="https://mcp.zapier.com/api/v1/connect?token=…"
|
||
className="mt-2 h-11"
|
||
autoFocus
|
||
/>
|
||
{link.trim() && !isZapierLink && (
|
||
<p className="mt-2 text-xs text-destructive">Paste a valid Zapier URL to continue.</p>
|
||
)}
|
||
</div>
|
||
|
||
{defaults}
|
||
|
||
<div className="mt-6 flex items-center justify-between">
|
||
<Button variant="ghost" onClick={onBack} disabled={submitting}>
|
||
Back
|
||
</Button>
|
||
<Button onClick={onConnect} disabled={submitting || !isZapierLink}>
|
||
{submitting && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||
{submitting ? "Checking…" : "Check link"}
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function GalleryStep({
|
||
loading,
|
||
initialLink = "",
|
||
apps,
|
||
byo = false,
|
||
byoOnly = false,
|
||
vercelConnect = false,
|
||
vercelConnectAvailability = null,
|
||
source = null,
|
||
onPick,
|
||
onUseLink,
|
||
}: {
|
||
loading: boolean;
|
||
initialLink?: string;
|
||
apps: AppDefinition[];
|
||
/** Entered via the "Connect your own MCP server" card (PAP-12371, Finding C): focus the link path. */
|
||
byo?: boolean;
|
||
/** Canonical BYO page: keep the focused URL setup without the app gallery. */
|
||
byoOnly?: boolean;
|
||
/** Isolated Vercel catalog: no native-provider or bring-your-own setup paths. */
|
||
vercelConnect?: boolean;
|
||
vercelConnectAvailability?: {
|
||
available: boolean;
|
||
manageUrl: string;
|
||
reason: string | null;
|
||
} | null;
|
||
source?: string | null;
|
||
onPick: (entry: AppDefinition) => void;
|
||
onUseLink: (link: string) => void;
|
||
}) {
|
||
const [search, setSearch] = useState("");
|
||
const [linkInput, setLinkInput] = useState(initialLink);
|
||
const linkInputEdited = useRef(false);
|
||
useEffect(() => {
|
||
if (!linkInputEdited.current) setLinkInput(initialLink);
|
||
}, [initialLink]);
|
||
const [linkError, setLinkError] = useState<string | null>(null);
|
||
const linkSectionRef = useRef<HTMLDivElement>(null);
|
||
const linkInputRef = useRef<HTMLInputElement>(null);
|
||
const linkInputSelectedRef = useRef(false);
|
||
|
||
// Arriving from the BYO card: scroll the URL section into view and select its
|
||
// input so the operator can paste immediately.
|
||
useEffect(() => {
|
||
if (!byo || (loading && !byoOnly) || linkInputSelectedRef.current) return;
|
||
if (!byoOnly) linkSectionRef.current?.scrollIntoView?.({ block: "center" });
|
||
linkInputRef.current?.focus();
|
||
linkInputRef.current?.select();
|
||
linkInputSelectedRef.current = true;
|
||
}, [byo, byoOnly, loading]);
|
||
const filtered = useMemo(() => {
|
||
const q = search.trim().toLowerCase();
|
||
return apps.filter((app) => !q || app.name.toLowerCase().includes(q));
|
||
}, [apps, search]);
|
||
const normalizedLink = normalizeAppLink(linkInput);
|
||
const matchedEntry = normalizedLink ? getAppDefinitionForUrl(normalizedLink, apps) : null;
|
||
const zapierSource = source === "zapier";
|
||
|
||
const continueWithLink = () => {
|
||
const next = normalizeAppLink(linkInput);
|
||
if (!next) {
|
||
setLinkError("Paste a full http or https link.");
|
||
return;
|
||
}
|
||
setLinkError(null);
|
||
onUseLink(next);
|
||
};
|
||
|
||
if (loading && !byoOnly) {
|
||
return (
|
||
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 lg:grid-cols-4">
|
||
{Array.from({ length: 8 }).map((_, i) => (
|
||
<Skeleton key={i} className="h-32 w-full rounded-xl" />
|
||
))}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<div className="space-y-6">
|
||
{vercelConnect ? (
|
||
<div className="rounded-xl border border-border bg-card p-5">
|
||
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
|
||
<div>
|
||
<h2 className="text-lg font-bold tracking-tight">Connect through Vercel</h2>
|
||
<p className="mt-1 max-w-2xl text-sm text-muted-foreground">
|
||
Create and manage the provider connector in Vercel. Paperclip stores its reference and applies agent access, policy, approval, and audit controls here.
|
||
</p>
|
||
</div>
|
||
{vercelConnectAvailability ? (
|
||
<Button asChild variant="outline" size="sm" className="shrink-0">
|
||
<a href={vercelConnectAvailability.manageUrl} target="_blank" rel="noreferrer">
|
||
Open Vercel Connect
|
||
<ArrowUpRight className="ml-2 h-3.5 w-3.5" />
|
||
</a>
|
||
</Button>
|
||
) : null}
|
||
</div>
|
||
{vercelConnectAvailability?.available === false ? (
|
||
<InlineBanner tone="danger" compact className="mt-4">
|
||
{vercelConnectAvailability.reason ?? "Vercel Connect is unavailable on this instance."}
|
||
</InlineBanner>
|
||
) : null}
|
||
</div>
|
||
) : null}
|
||
|
||
{!byoOnly && (
|
||
<>
|
||
<div className="relative">
|
||
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||
<Input
|
||
value={search}
|
||
onChange={(e) => setSearch(e.target.value)}
|
||
placeholder="Search apps…"
|
||
className="h-11 pl-9"
|
||
/>
|
||
</div>
|
||
|
||
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 lg:grid-cols-4">
|
||
{filtered.map((app) => {
|
||
const copy = appCopyFor(app.slug, app.description);
|
||
const methods = availableToolConnectionMethods(app);
|
||
const oauthBlocked = methods.length === 0 || methods.every((candidate) =>
|
||
candidate.auth === "oauth"
|
||
&& !connectionMethodSupportsAutomaticOAuth(candidate)
|
||
&& !connectionMethodAcceptsCustomerOAuthClient(candidate)
|
||
);
|
||
const unavailable = app.availability?.available === false
|
||
|| (vercelConnect && vercelConnectAvailability?.available !== true);
|
||
return (
|
||
<button
|
||
key={app.slug}
|
||
type="button"
|
||
disabled={oauthBlocked || unavailable}
|
||
title={
|
||
unavailable
|
||
? app.availability?.reason ?? vercelConnectAvailability?.reason ?? undefined
|
||
: undefined
|
||
}
|
||
onClick={() => onPick(app)}
|
||
className={cn(
|
||
"flex flex-col rounded-xl border border-border bg-card p-4 text-left transition-colors",
|
||
oauthBlocked || unavailable ? "cursor-not-allowed opacity-60" : "hover:border-foreground/30 hover:bg-accent/40",
|
||
)}
|
||
>
|
||
<AppLogo name={app.name} logoUrl={app.branding.logoUrl} darkLogoUrl={app.branding.darkLogoUrl} size={36} />
|
||
<div className="mt-3 text-sm font-bold text-foreground">{app.name}</div>
|
||
<div className="mt-1 line-clamp-2 text-xs text-muted-foreground">{copy.tagline}</div>
|
||
<div className="mt-3 text-xs font-semibold text-foreground">
|
||
{unavailable ? (
|
||
<span className="text-muted-foreground">
|
||
{app.availability?.reason ?? vercelConnectAvailability?.reason ?? "Unavailable on this instance."}
|
||
</span>
|
||
) : oauthBlocked ? (
|
||
<span className="text-muted-foreground">Unavailable</span>
|
||
) : (
|
||
<span>Connect →</span>
|
||
)}
|
||
</div>
|
||
</button>
|
||
);
|
||
})}
|
||
</div>
|
||
|
||
{filtered.length === 0 && (
|
||
<div className="py-10 text-center text-sm text-muted-foreground">No apps match “{search}”.</div>
|
||
)}
|
||
</>
|
||
)}
|
||
|
||
{!vercelConnect ? <div
|
||
ref={linkSectionRef}
|
||
className={cn(
|
||
"grid gap-4 border-t border-border pt-5 md:grid-cols-(--gtc-13)",
|
||
byo && "-mx-3 rounded-xl border border-primary/40 bg-primary/[0.04] px-3 pb-4 md:mx-0",
|
||
)}
|
||
>
|
||
<div>
|
||
<div className="flex items-center gap-2 text-sm font-semibold text-foreground">
|
||
<Link2 className="h-4 w-4 text-muted-foreground" />
|
||
{zapierSource ? "Connect Zapier" : byo ? "Connect your own MCP server" : "Connect with a link"}
|
||
</div>
|
||
<p className="mt-1 text-xs text-muted-foreground">
|
||
{zapierSource
|
||
? "Paste the complete MCP URL Zapier gives you, including its token."
|
||
: byo
|
||
? "Paste your MCP server’s URL and every discovered tool will be available immediately."
|
||
: "Paste a setup link from an app that is not listed here."}
|
||
</p>
|
||
{!zapierSource && (
|
||
<p className="mt-1 text-xs text-muted-foreground">
|
||
Any remote tool URL works here — including a local MCP server like{" "}
|
||
<code className="rounded bg-muted px-1 py-0.5 text-xs">http://127.0.0.1:8848/mcp</code>.
|
||
</p>
|
||
)}
|
||
{matchedEntry && (
|
||
<div className="mt-3 flex items-center justify-between gap-3 rounded-lg border border-border bg-muted/40 px-3 py-2">
|
||
<div className="flex min-w-0 items-center gap-2 text-sm">
|
||
<AppLogo name={matchedEntry.name} logoUrl={matchedEntry.branding.logoUrl} darkLogoUrl={matchedEntry.branding.darkLogoUrl} size={24} />
|
||
<span className="truncate">This looks like {matchedEntry.name}.</span>
|
||
</div>
|
||
<Button
|
||
type="button"
|
||
size="sm"
|
||
variant="outline"
|
||
disabled={matchedEntry.availability?.available === false}
|
||
onClick={() => {
|
||
setLinkError(null);
|
||
if (matchedEntry.slug === "zapier") {
|
||
continueWithLink();
|
||
return;
|
||
}
|
||
onPick(matchedEntry);
|
||
}}
|
||
>
|
||
{matchedEntry.availability?.available === false
|
||
? "Not available"
|
||
: matchedEntry.slug === "zapier"
|
||
? "Continue"
|
||
: `Use ${matchedEntry.name}`}
|
||
</Button>
|
||
</div>
|
||
)}
|
||
</div>
|
||
<div className="flex min-w-0 flex-col gap-2 sm:min-w-(--sz-360px)">
|
||
<div className="flex gap-2">
|
||
<Input
|
||
ref={linkInputRef}
|
||
type={zapierSource || matchedEntry?.slug === "zapier" ? "password" : "url"}
|
||
autoComplete="off"
|
||
spellCheck={false}
|
||
aria-label="MCP server URL"
|
||
value={linkInput}
|
||
onChange={(e) => {
|
||
linkInputEdited.current = true;
|
||
setLinkInput(e.target.value);
|
||
setLinkError(null);
|
||
}}
|
||
onKeyDown={(e) => {
|
||
if (e.key === "Enter") continueWithLink();
|
||
}}
|
||
placeholder={zapierSource ? "https://mcp.zapier.com/api/v1/connect?token=…" : "https://example.com/actions"}
|
||
className="h-10"
|
||
/>
|
||
<Button type="button" variant="outline" onClick={continueWithLink}>
|
||
Continue
|
||
</Button>
|
||
</div>
|
||
{linkError && <div className="text-xs text-destructive">{linkError}</div>}
|
||
</div>
|
||
</div> : null}
|
||
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function normalizeAppLink(value: string): string | null {
|
||
try {
|
||
const parsed = new URL(value.trim());
|
||
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") return null;
|
||
return parsed.toString();
|
||
} catch {
|
||
return null;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* The guided universal flow for an unknown remote MCP server (PAP-17087, plan 2).
|
||
*
|
||
* The simple path stays exactly as short as it was — URL, name, "does it need a
|
||
* key?" — because that is all most servers need. Everything protocol-shaped lives
|
||
* behind "Advanced authentication", and no OAuth/DCR/CIMD jargon appears on the
|
||
* consumer path: the operator picks how the server authenticates, not which RFC
|
||
* Paperclip will use to satisfy it.
|
||
*
|
||
* The endpoint host and the "Unverified server" label stay visible the whole way
|
||
* through, so the operator can always see whose server they are about to let
|
||
* agents call.
|
||
*/
|
||
function LinkConnectStep({
|
||
link,
|
||
name,
|
||
needsKey,
|
||
keyValue,
|
||
onKeyChange,
|
||
authMode,
|
||
onAuthModeChange,
|
||
headers,
|
||
onHeadersChange,
|
||
oauthClientId,
|
||
onOAuthClientIdChange,
|
||
oauthClientSecret,
|
||
onOAuthClientSecretChange,
|
||
advancedOpen,
|
||
onAdvancedOpenChange,
|
||
guidance,
|
||
matchedEntry,
|
||
onUseMatchedEntry,
|
||
submitting,
|
||
defaults,
|
||
onBack,
|
||
onConnect,
|
||
}: {
|
||
link: string;
|
||
name: string;
|
||
needsKey: boolean;
|
||
keyValue: string;
|
||
onKeyChange: (next: string) => void;
|
||
authMode: GenericMcpAuthMode;
|
||
onAuthModeChange: (next: GenericMcpAuthMode) => void;
|
||
headers: CustomHeaderRow[];
|
||
onHeadersChange: (next: CustomHeaderRow[]) => void;
|
||
oauthClientId: string;
|
||
onOAuthClientIdChange: (next: string) => void;
|
||
oauthClientSecret: string;
|
||
onOAuthClientSecretChange: (next: string) => void;
|
||
advancedOpen: boolean;
|
||
onAdvancedOpenChange: (next: boolean) => void;
|
||
guidance: GenericConnectGuidance | null;
|
||
/** A curated app whose endpoint matches, offered as a convenience only. */
|
||
matchedEntry?: AppDefinition | null;
|
||
onUseMatchedEntry?: () => void;
|
||
submitting: boolean;
|
||
/** The stated default and its Advanced disclosure (PAP-659 C0). */
|
||
defaults?: ReactNode;
|
||
onBack: () => void;
|
||
onConnect: () => void;
|
||
}) {
|
||
const host = endpointHost(link);
|
||
const headerError = authMode === "custom_headers" ? customHeaderError(headers) : null;
|
||
const draft: GenericConnectDraft = {
|
||
link,
|
||
name,
|
||
authMode,
|
||
needsKey,
|
||
keyValue,
|
||
headers,
|
||
oauthClientId,
|
||
oauthClientSecret,
|
||
};
|
||
const canSubmit = canSubmitGenericConnect(draft);
|
||
// PAP-659 bucket H: the address alone is the whole default path. `needsKey` is
|
||
// now set by the server's probe after a credential challenge, never guessed at
|
||
// up front, so the field appears exactly when it is known to be required.
|
||
const showKeyField = (authMode === "auto" && needsKey) || authMode === "bearer";
|
||
const displayedLink = matchedEntry?.slug === "zapier" ? redactUrlSecrets(link) : link;
|
||
|
||
const updateHeader = (id: string, patch: Partial<CustomHeaderRow>) => {
|
||
onHeadersChange(headers.map((row) => (row.id === id ? { ...row, ...patch } : row)));
|
||
};
|
||
|
||
return (
|
||
<div className="mx-auto max-w-xl">
|
||
<div className="flex items-center justify-between gap-3">
|
||
<p className="min-w-0 truncate font-mono text-sm text-muted-foreground" title={displayedLink}>{displayedLink}</p>
|
||
<UnverifiedServerBadge host={host} />
|
||
</div>
|
||
|
||
{matchedEntry && onUseMatchedEntry ? (
|
||
<div className="mt-6 flex flex-wrap items-center justify-between gap-3 rounded-lg border border-border bg-muted/40 px-3 py-2">
|
||
<div className="flex min-w-0 items-center gap-2 text-sm">
|
||
<AppLogo name={matchedEntry.name} logoUrl={matchedEntry.branding.logoUrl} darkLogoUrl={matchedEntry.branding.darkLogoUrl} size={24} />
|
||
<span className="truncate">Paperclip has a guided setup for {matchedEntry.name}.</span>
|
||
</div>
|
||
<Button type="button" size="sm" variant="outline" onClick={onUseMatchedEntry}>
|
||
Use {matchedEntry.name}
|
||
</Button>
|
||
</div>
|
||
) : null}
|
||
|
||
{guidance ? (
|
||
<div className="mt-6">
|
||
<InlineBanner tone="warning" title={guidance.title}>
|
||
{guidance.body}
|
||
</InlineBanner>
|
||
</div>
|
||
) : null}
|
||
|
||
<div className="mt-6 space-y-6">
|
||
{showKeyField ? (
|
||
<div className="space-y-4">
|
||
<div>
|
||
<label className="text-sm font-medium text-foreground" htmlFor="generic-mcp-key">App key</label>
|
||
<Input
|
||
id="generic-mcp-key"
|
||
type="password"
|
||
autoComplete="off"
|
||
value={keyValue}
|
||
onChange={(e) => onKeyChange(e.target.value)}
|
||
placeholder="••••••••••••••••"
|
||
className="mt-2 h-11 font-mono"
|
||
/>
|
||
<p className="mt-2 text-xs text-muted-foreground">
|
||
Grant this key read and write access to the resources your agents need. Paperclip cannot increase its permissions.
|
||
</p>
|
||
</div>
|
||
</div>
|
||
) : null}
|
||
|
||
<Collapsible open={advancedOpen} onOpenChange={onAdvancedOpenChange}>
|
||
<CollapsibleTrigger className="flex w-full items-center justify-between rounded-lg border border-border bg-background px-3 py-2 text-left text-sm font-medium text-foreground hover:bg-accent/40">
|
||
Advanced authentication
|
||
<ChevronDown className={cn("h-4 w-4 text-muted-foreground transition-transform", advancedOpen && "rotate-180")} />
|
||
</CollapsibleTrigger>
|
||
<CollapsibleContent className="space-y-5 pt-4">
|
||
<p className="text-xs text-muted-foreground">
|
||
Only needed when the server's docs are specific about how to authenticate.
|
||
</p>
|
||
<div className="flex flex-wrap gap-2">
|
||
{GENERIC_AUTH_MODE_OPTIONS.map((option) => (
|
||
<SegmentedOption
|
||
key={option.mode}
|
||
label={option.label}
|
||
selected={authMode === option.mode}
|
||
onClick={() => onAuthModeChange(option.mode)}
|
||
/>
|
||
))}
|
||
</div>
|
||
<p className="text-xs text-muted-foreground">
|
||
{GENERIC_AUTH_MODE_OPTIONS.find((option) => option.mode === authMode)?.hint}
|
||
</p>
|
||
|
||
{authMode === "custom_headers" ? (
|
||
<div className="space-y-3">
|
||
{headers.map((row) => (
|
||
<div key={row.id} className="flex items-start gap-2">
|
||
<Input
|
||
value={row.name}
|
||
onChange={(e) => updateHeader(row.id, { name: e.target.value })}
|
||
placeholder="Header name"
|
||
aria-label="Header name"
|
||
className="h-10 font-mono"
|
||
/>
|
||
<Input
|
||
type="password"
|
||
autoComplete="off"
|
||
value={row.value}
|
||
onChange={(e) => updateHeader(row.id, { value: e.target.value })}
|
||
placeholder="Value"
|
||
aria-label={row.name.trim() ? `Value for ${row.name.trim()}` : "Header value"}
|
||
className="h-10 font-mono"
|
||
/>
|
||
<Button
|
||
type="button"
|
||
variant="ghost"
|
||
size="sm"
|
||
className="h-10 shrink-0"
|
||
onClick={() => onHeadersChange(headers.filter((candidate) => candidate.id !== row.id))}
|
||
disabled={headers.length === 1}
|
||
>
|
||
Remove
|
||
</Button>
|
||
</div>
|
||
))}
|
||
<Button
|
||
type="button"
|
||
variant="outline"
|
||
size="sm"
|
||
onClick={() => onHeadersChange([...headers, newCustomHeaderRow()])}
|
||
>
|
||
Add another header
|
||
</Button>
|
||
{headerError ? <p className="text-xs text-destructive">{headerError}</p> : null}
|
||
</div>
|
||
) : null}
|
||
|
||
{authMode === "oauth" ? (
|
||
<div className="space-y-4">
|
||
<p className="text-xs text-muted-foreground">
|
||
Paperclip sets sign-in up on its own whenever the server allows it. Only fill these in when the
|
||
server's docs tell you to register Paperclip yourself first.
|
||
</p>
|
||
<div>
|
||
<label className="text-sm font-medium text-foreground" htmlFor="generic-mcp-client-id">
|
||
Client ID
|
||
</label>
|
||
<Input
|
||
id="generic-mcp-client-id"
|
||
value={oauthClientId}
|
||
onChange={(e) => onOAuthClientIdChange(e.target.value)}
|
||
autoComplete="off"
|
||
placeholder="Optional"
|
||
className="mt-2 h-11 font-mono"
|
||
/>
|
||
</div>
|
||
<div>
|
||
<label className="text-sm font-medium text-foreground" htmlFor="generic-mcp-client-secret">
|
||
Client secret
|
||
</label>
|
||
<Input
|
||
id="generic-mcp-client-secret"
|
||
type="password"
|
||
autoComplete="off"
|
||
value={oauthClientSecret}
|
||
onChange={(e) => onOAuthClientSecretChange(e.target.value)}
|
||
placeholder="Optional"
|
||
className="mt-2 h-11 font-mono"
|
||
/>
|
||
</div>
|
||
</div>
|
||
) : null}
|
||
</CollapsibleContent>
|
||
</Collapsible>
|
||
</div>
|
||
|
||
{defaults}
|
||
|
||
<div className="mt-8 flex items-center justify-between">
|
||
<Button variant="ghost" onClick={onBack} disabled={submitting}>
|
||
Back
|
||
</Button>
|
||
<Button onClick={onConnect} disabled={submitting || !canSubmit || Boolean(headerError)}>
|
||
{submitting && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||
{submitting ? "Checking…" : "Check link"}
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
/**
|
||
* What the operator is choosing is how the *server* authenticates, in its own
|
||
* terms. Paperclip decides internally whether that means a preconfigured client,
|
||
* a client ID metadata document, dynamic registration, or the credentials pasted
|
||
* below — none of which belongs on this screen.
|
||
*/
|
||
const GENERIC_AUTH_MODE_OPTIONS: Array<{ mode: GenericMcpAuthMode; label: string; hint: string }> = [
|
||
{
|
||
mode: "auto",
|
||
label: "Let Paperclip check",
|
||
hint: "Paperclip asks the server what it needs and walks you through it. Start here.",
|
||
},
|
||
{
|
||
mode: "none",
|
||
label: "No sign-in needed",
|
||
hint: "The server is open to anyone with the address.",
|
||
},
|
||
{
|
||
mode: "bearer",
|
||
label: "Key or token",
|
||
hint: "Paperclip sends your key as an Authorization header.",
|
||
},
|
||
{
|
||
mode: "custom_headers",
|
||
label: "Custom headers",
|
||
hint: "For servers that name their own headers. Values are stored as Paperclip secrets and can\u2019t be read back.",
|
||
},
|
||
{
|
||
mode: "oauth",
|
||
label: "Browser sign-in",
|
||
hint: "You\u2019ll sign in at the provider. Add a client ID and secret only if the provider requires you to register Paperclip first.",
|
||
},
|
||
];
|
||
|
||
function SegmentedOption({
|
||
label,
|
||
selected,
|
||
onClick,
|
||
}: {
|
||
label: string;
|
||
selected: boolean;
|
||
onClick: () => void;
|
||
}) {
|
||
return (
|
||
<button
|
||
type="button"
|
||
onClick={onClick}
|
||
aria-pressed={selected}
|
||
className={cn(
|
||
"min-w-(--sz-64px) rounded-md px-4 py-1.5 text-sm font-medium transition-colors",
|
||
selected
|
||
? "bg-background text-foreground shadow-sm"
|
||
: "text-muted-foreground hover:text-foreground",
|
||
)}
|
||
>
|
||
{label}
|
||
</button>
|
||
);
|
||
}
|
||
|
||
function KeyStep({
|
||
settingsValid = true,
|
||
entry,
|
||
error,
|
||
values,
|
||
onChange,
|
||
oauthClientId,
|
||
onOAuthClientIdChange,
|
||
oauthClientSecret,
|
||
oauthCallbackUrl: serverCallbackUrl,
|
||
canReuseOAuthClientSecret,
|
||
onOAuthClientSecretChange,
|
||
credentialSource,
|
||
vercelConnector,
|
||
onVercelConnectorChange,
|
||
vercelConnectAvailability,
|
||
methodKey,
|
||
onMethodChange,
|
||
configValues,
|
||
onConfigChange,
|
||
googleSheetsLinks,
|
||
googleSheetsError,
|
||
onGoogleSheetsLinksChange,
|
||
submitting,
|
||
renderDefaults,
|
||
onBack,
|
||
onConnect,
|
||
}: {
|
||
settingsValid?: boolean;
|
||
entry: AppDefinition;
|
||
error?: string | null;
|
||
values: Record<string, string>;
|
||
onChange: (next: Record<string, string>) => void;
|
||
oauthClientId: string;
|
||
onOAuthClientIdChange: (next: string) => void;
|
||
oauthClientSecret: string;
|
||
oauthCallbackUrl: string;
|
||
canReuseOAuthClientSecret: boolean;
|
||
onOAuthClientSecretChange: (next: string) => void;
|
||
credentialSource: ToolConnectionCredentialSource;
|
||
vercelConnector: string;
|
||
onVercelConnectorChange: (next: string) => void;
|
||
vercelConnectAvailability: {
|
||
available: boolean;
|
||
manageUrl: string;
|
||
reason: string | null;
|
||
} | null;
|
||
methodKey: string;
|
||
onMethodChange: (method: ConnectionMethodDef | null) => void;
|
||
configValues: Record<string, string | boolean>;
|
||
onConfigChange: (next: Record<string, string | boolean>) => void;
|
||
googleSheetsLinks: string;
|
||
googleSheetsError: string | null;
|
||
onGoogleSheetsLinksChange: (next: string) => void;
|
||
submitting: boolean;
|
||
/**
|
||
* Builds the one Advanced disclosure this screen has (PAP-659 C0). The key
|
||
* step contributes its own settings to it rather than opening a second one.
|
||
*/
|
||
renderDefaults?: (extra?: ReactNode, forceOpen?: boolean) => ReactNode;
|
||
onBack: () => void;
|
||
onConnect: () => void;
|
||
}) {
|
||
const methods = useMemo(
|
||
() => connectionMethodsForCredentialSource(entry, credentialSource),
|
||
[credentialSource, entry],
|
||
);
|
||
const method = methodKey
|
||
? methods.find((candidate) => candidate.key === methodKey) ?? null
|
||
: recommendedSetupConnectionMethod(methods);
|
||
const capabilityGroups = useMemo(() => {
|
||
if (!methods.some((candidate) => candidate.capabilityProfile)) return [];
|
||
return Array.from(methods.reduce((groups, candidate) => {
|
||
const key = candidate.capabilityProfile!.key;
|
||
const existing = groups.get(key);
|
||
if (existing) {
|
||
existing.methods.push(candidate);
|
||
} else {
|
||
groups.set(key, {
|
||
key,
|
||
label: candidate.capabilityProfile?.label ?? candidate.label ?? candidate.key,
|
||
description: candidate.capabilityProfile?.description ?? candidate.whenToUse,
|
||
methods: [candidate],
|
||
});
|
||
}
|
||
return groups;
|
||
}, new Map<string, {
|
||
key: string;
|
||
label: string;
|
||
description: string;
|
||
methods: ConnectionMethodDef[];
|
||
}>()),
|
||
).map(([, group]) => group);
|
||
}, [methods]);
|
||
const selectedMethodCapabilityKey = method?.capabilityProfile?.key ?? method?.key ?? "";
|
||
const capabilityGroupKeys = capabilityGroups.map((group) => group.key).join("|");
|
||
const [capabilityKey, setCapabilityKey] = useState(
|
||
selectedMethodCapabilityKey
|
||
|| recommendedSetupConnectionMethod(methods)?.capabilityProfile?.key
|
||
|| (capabilityGroups.length === 1 ? capabilityGroups[0]!.key : ""),
|
||
);
|
||
useEffect(() => {
|
||
if (selectedMethodCapabilityKey) {
|
||
setCapabilityKey(selectedMethodCapabilityKey);
|
||
return;
|
||
}
|
||
setCapabilityKey((current) => {
|
||
if (capabilityGroups.some((group) => group.key === current)) return current;
|
||
return recommendedSetupConnectionMethod(methods)?.capabilityProfile?.key
|
||
|| (capabilityGroups.length === 1 ? capabilityGroups[0]!.key : "");
|
||
});
|
||
}, [capabilityGroupKeys, capabilityGroups, methods, selectedMethodCapabilityKey]);
|
||
const capabilityMethods = capabilityGroups.length > 1
|
||
? capabilityGroups.find((group) => group.key === capabilityKey)?.methods ?? []
|
||
: methods;
|
||
const fields = (method?.credentialFields ?? []).map((field) => ({
|
||
...field,
|
||
configPath: credentialConfigPath(field, method),
|
||
helpUrl: method?.consoleLinks?.keys ?? method?.consoleLinks?.docs ?? "",
|
||
}));
|
||
const vercelReview = method?.credentialSources?.vercelConnect ?? null;
|
||
const usingVercel = credentialSource === "vercel_connect";
|
||
const allFilled = usingVercel || fields.every(
|
||
(f) => f.required === false || (values[f.configPath]?.trim().length ?? 0) > 0,
|
||
);
|
||
const acceptsCustomerOAuthClient = connectionMethodAcceptsCustomerOAuthClient(method);
|
||
const customerOAuthClientRequired = acceptsCustomerOAuthClient
|
||
&& !connectionMethodSupportsAutomaticOAuth(method);
|
||
const oauthClientFilled = usingVercel || !customerOAuthClientRequired || (
|
||
oauthClientId.trim().length > 0
|
||
&& (!method?.oauthClientSecretRequired || canReuseOAuthClientSecret || oauthClientSecret.trim().length > 0)
|
||
);
|
||
const vercelConnectorFilled = !usingVercel || vercelConnector.trim().length > 0;
|
||
const oauthCallbackUrl = method?.auth === "oauth" && acceptsCustomerOAuthClient
|
||
? serverCallbackUrl
|
||
: null;
|
||
const allConfigFields = [...(method?.tenantFields ?? []), ...(method?.extensionFields ?? [])];
|
||
const configFields = allConfigFields.filter((field) => !field.hidden);
|
||
const standardConfigFields = configFields.filter((field) => field.advanced !== true);
|
||
const advancedConfigFields = configFields.filter((field) => field.advanced === true);
|
||
const configFilled = allConfigFields.every((field) => {
|
||
if (!field.required) return true;
|
||
const value = configValues[field.key];
|
||
return typeof value === "boolean" || (typeof value === "string" && value.trim().length > 0);
|
||
});
|
||
const alternativeKeys = method?.configRequirements?.atLeastOneOf ?? [];
|
||
const configRequirementMet = alternativeKeys.length === 0 || alternativeKeys.some((key) => {
|
||
const value = configValues[key];
|
||
return typeof value === "string" && value.trim().length > 0;
|
||
});
|
||
const hasCapabilitySelection = capabilityGroups.length <= 1 || Boolean(capabilityKey);
|
||
const hasMethodSelection = hasCapabilitySelection
|
||
&& (capabilityMethods.length <= 1 ? Boolean(method) : Boolean(methodKey));
|
||
const robotEmail = entry.availability?.robotEmail ?? null;
|
||
const unavailable = entry.availability?.available === false;
|
||
const optionalCustomerOAuthClient = !usingVercel
|
||
&& acceptsCustomerOAuthClient
|
||
&& !customerOAuthClientRequired;
|
||
const hasAlternateMethods = capabilityGroups.length > 1 || capabilityMethods.length > 1;
|
||
const capabilitySelection = capabilityGroups.length > 1 ? (
|
||
<div>
|
||
<label className="text-sm font-medium text-foreground">What should Paperclip be able to do?</label>
|
||
<RadioCardGroup
|
||
ariaLabel={`Access level for ${entry.name}`}
|
||
className="mt-2"
|
||
value={capabilityKey}
|
||
onValueChange={(nextKey) => {
|
||
const nextGroup = capabilityGroups.find((group) => group.key === nextKey);
|
||
if (!nextGroup) return;
|
||
setCapabilityKey(nextKey);
|
||
onMethodChange(getRecommendedConnectionMethod(nextGroup.methods));
|
||
}}
|
||
options={capabilityGroups.map((group) => ({
|
||
value: group.key,
|
||
title: group.label,
|
||
description: group.description,
|
||
}))}
|
||
/>
|
||
{!capabilityKey && <p className="mt-2 text-xs text-muted-foreground">Choose an access level to continue.</p>}
|
||
</div>
|
||
) : null;
|
||
const managedOAuthMethod = capabilityMethods.find((candidate) =>
|
||
candidate.oauthStrategy === "paperclip_cloud_connector",
|
||
);
|
||
const customerOAuthMethod = managedOAuthMethod && capabilityMethods.find((candidate) =>
|
||
connectionMethodAcceptsCustomerOAuthClient(candidate)
|
||
&& !connectionMethodSupportsAutomaticOAuth(candidate),
|
||
);
|
||
const usingCustomOAuth = method?.key === customerOAuthMethod?.key;
|
||
const customOAuthFieldsId = useId();
|
||
const authenticationSelection = managedOAuthMethod && customerOAuthMethod && capabilityMethods.length === 2 ? (
|
||
<Button
|
||
type="button"
|
||
variant="link"
|
||
className="h-auto p-0 text-xs text-muted-foreground underline underline-offset-2 hover:text-foreground"
|
||
aria-expanded={usingCustomOAuth}
|
||
aria-controls={customOAuthFieldsId}
|
||
disabled={submitting}
|
||
onClick={() => onMethodChange(usingCustomOAuth ? managedOAuthMethod : customerOAuthMethod)}
|
||
>
|
||
{usingCustomOAuth ? "Use Paperclip instead" : `Use your own ${isGoogleWorkspaceConnectorProfileId(managedOAuthMethod.connectorProfile ?? "") ? "Google" : entry.name} OAuth app`}
|
||
</Button>
|
||
) : capabilityMethods.length > 1 ? (
|
||
// PAP-659 C1: the ranked default is already selected. This stays as the
|
||
// way to pick something else, one disclosure away, rather than a question
|
||
// the screen opens with.
|
||
<div>
|
||
<label className="text-sm font-medium text-foreground">How do you want to connect?</label>
|
||
<RadioCardGroup
|
||
ariaLabel={`How to connect ${entry.name}`}
|
||
className="mt-2"
|
||
value={methodKey}
|
||
onValueChange={(nextKey) => {
|
||
const nextMethod = capabilityMethods.find((candidate) => candidate.key === nextKey);
|
||
if (nextMethod) onMethodChange(nextMethod);
|
||
}}
|
||
options={capabilityMethods.map((candidate) => ({
|
||
value: candidate.key,
|
||
title: candidate.label ?? (candidate.auth === "oauth" ? `Sign in with ${entry.name}` : "Use an API key"),
|
||
}))}
|
||
/>
|
||
{!method && <p className="mt-2 text-xs text-muted-foreground">Choose a connection method to continue.</p>}
|
||
</div>
|
||
) : null;
|
||
const hasAdvancedSettings = advancedConfigFields.length > 0
|
||
|| optionalCustomerOAuthClient
|
||
|| hasAlternateMethods;
|
||
// Keep the disclosure open when what is inside it is load-bearing right now:
|
||
// a non-default method in use, or a selection the connector still needs.
|
||
const forceAdvancedOpen = usingCustomOAuth || !hasMethodSelection;
|
||
const advancedSettings = hasAdvancedSettings ? (
|
||
<div className="space-y-6">
|
||
{capabilitySelection}
|
||
{authenticationSelection}
|
||
{advancedConfigFields.map((field) => (
|
||
<MethodConfigField
|
||
key={field.key}
|
||
field={field}
|
||
value={configValues[field.key]}
|
||
onChange={(value) => onConfigChange({ ...configValues, [field.key]: value })}
|
||
/>
|
||
))}
|
||
{optionalCustomerOAuthClient ? (
|
||
<OAuthClientFields
|
||
entry={entry}
|
||
method={method!}
|
||
callbackUrl={oauthCallbackUrl}
|
||
clientId={oauthClientId}
|
||
onClientIdChange={onOAuthClientIdChange}
|
||
clientSecret={oauthClientSecret}
|
||
onClientSecretChange={onOAuthClientSecretChange}
|
||
required={false}
|
||
/>
|
||
) : null}
|
||
</div>
|
||
) : null;
|
||
const defaults = renderDefaults?.(advancedSettings, forceAdvancedOpen) ?? null;
|
||
|
||
if (isGoogleSheetsRobotMethod(entry, method)) {
|
||
const parsed = parseGoogleSheetIds(googleSheetsLinks);
|
||
const canConnect = settingsValid && !unavailable && Boolean(robotEmail) && googleSheetsLinks.trim().length > 0;
|
||
return (
|
||
<div className="mx-auto max-w-xl">
|
||
<div className="space-y-6">
|
||
{capabilitySelection}
|
||
|
||
{robotEmail ? (
|
||
<div>
|
||
<label className="text-sm font-medium text-foreground">Share each sheet with this email</label>
|
||
<div className="mt-2 flex min-w-0 flex-col gap-2 sm:flex-row">
|
||
<div
|
||
title={robotEmail}
|
||
className="min-h-11 min-w-0 flex-1 rounded-md border border-input bg-muted/40 px-3 py-2.5 font-mono text-xs leading-tight text-foreground break-all"
|
||
>
|
||
{robotEmail}
|
||
</div>
|
||
<CopyValueButton value={robotEmail} ariaLabel="Copy sharing email" />
|
||
</div>
|
||
<p className="mt-2 text-xs text-muted-foreground">
|
||
In Google Sheets, click Share and add this email as an Editor. Then paste the sheet links below.
|
||
</p>
|
||
</div>
|
||
) : (
|
||
<div className="rounded-lg bg-muted/50 p-4 text-sm text-muted-foreground">
|
||
Google Sheets is not available on this instance yet.
|
||
</div>
|
||
)}
|
||
|
||
<div>
|
||
<label className="text-sm font-medium text-foreground">Paste links to the sheets you shared</label>
|
||
<Textarea
|
||
value={googleSheetsLinks}
|
||
onChange={(e) => onGoogleSheetsLinksChange(e.target.value)}
|
||
placeholder="https://docs.google.com/spreadsheets/d/..."
|
||
className="mt-2 min-h-28"
|
||
/>
|
||
<div className="mt-2 text-xs text-muted-foreground">
|
||
{parsed.ids.length > 0
|
||
? `${parsed.ids.length} ${parsed.ids.length === 1 ? "sheet" : "sheets"} ready to connect.`
|
||
: "Paste one link per line. Both .../edit and .../edit#gid=... links work."}
|
||
</div>
|
||
{googleSheetsError && <div className="mt-2 text-xs text-destructive">{googleSheetsError}</div>}
|
||
</div>
|
||
</div>
|
||
|
||
{defaults}
|
||
|
||
<div className="mt-8 flex items-center justify-between">
|
||
<Button variant="ghost" onClick={onBack} disabled={submitting}>
|
||
Back
|
||
</Button>
|
||
<Button onClick={onConnect} disabled={submitting || !canConnect}>
|
||
{submitting && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||
{submitting ? "Checking…" : "Connect"}
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
const managedAsana = method?.connectorProfile === "asana.mcp";
|
||
const requirementsUrl = managedAsana ? null : method?.consoleLinks?.docs ?? entry.docsUrl;
|
||
|
||
return (
|
||
<div className="mx-auto max-w-xl">
|
||
{requirementsUrl ? (
|
||
<div className="mb-4 flex justify-end">
|
||
<a
|
||
href={requirementsUrl}
|
||
target="_blank"
|
||
rel="noreferrer"
|
||
className="inline-flex items-center gap-1 text-xs font-medium text-muted-foreground underline underline-offset-2 hover:text-foreground"
|
||
>
|
||
Review requirements
|
||
<ArrowUpRight className="h-3 w-3" />
|
||
</a>
|
||
</div>
|
||
) : null}
|
||
|
||
<div className="space-y-6">
|
||
{managedAsana ? (
|
||
<p className="text-sm text-muted-foreground">Sign in with Asana to choose your workspace and connect it to Paperclip.</p>
|
||
) : null}
|
||
{method?.capabilityProfile && (
|
||
<p className="text-sm text-muted-foreground">
|
||
{method.capabilityProfile.label}: {method.capabilityProfile.description}
|
||
</p>
|
||
)}
|
||
{usingVercel && vercelReview && vercelConnectAvailability ? (
|
||
<div className="space-y-4 rounded-lg border border-border p-4">
|
||
<div>
|
||
<div className="text-sm font-medium text-foreground">Create or attach the connector in Vercel</div>
|
||
<p className="mt-1 text-xs text-muted-foreground">
|
||
Paperclip does not copy Vercel’s setup forms. Finish connector setup there, then paste its UID below.
|
||
</p>
|
||
<a
|
||
href={vercelConnectAvailability.manageUrl}
|
||
target="_blank"
|
||
rel="noreferrer"
|
||
className="mt-2 inline-flex items-center gap-1 text-xs font-semibold text-foreground underline underline-offset-2"
|
||
>
|
||
Open Vercel Connect
|
||
<ArrowUpRight className="h-3 w-3" />
|
||
</a>
|
||
</div>
|
||
<div>
|
||
<label className="text-sm font-medium text-foreground" htmlFor="vercel-connect-connector">
|
||
Connector UID or ID
|
||
</label>
|
||
<Input
|
||
id="vercel-connect-connector"
|
||
value={vercelConnector}
|
||
onChange={(event) => onVercelConnectorChange(event.target.value)}
|
||
autoComplete="off"
|
||
placeholder="service/my-connector or scl_…"
|
||
className="mt-2 h-11 font-mono"
|
||
/>
|
||
<p className="mt-2 text-xs text-muted-foreground">
|
||
Paperclip validates the connector and stores only its reference and redacted verification metadata.
|
||
</p>
|
||
</div>
|
||
</div>
|
||
) : null}
|
||
|
||
{error ? <div role="alert"><InlineBanner tone="danger">{error}</InlineBanner></div> : null}
|
||
|
||
{standardConfigFields.map((field) => (
|
||
<MethodConfigField
|
||
key={field.key}
|
||
field={field}
|
||
value={configValues[field.key]}
|
||
onChange={(value) => onConfigChange({ ...configValues, [field.key]: value })}
|
||
/>
|
||
))}
|
||
|
||
|
||
{!usingVercel && method?.auth === "oauth" && customerOAuthClientRequired ? (
|
||
<div id={customOAuthFieldsId} role="region" aria-label="Your OAuth app">
|
||
<OAuthClientFields
|
||
entry={entry}
|
||
method={method}
|
||
callbackUrl={oauthCallbackUrl}
|
||
clientId={oauthClientId}
|
||
onClientIdChange={onOAuthClientIdChange}
|
||
clientSecret={oauthClientSecret}
|
||
onClientSecretChange={onOAuthClientSecretChange}
|
||
required
|
||
/>
|
||
</div>
|
||
) : null}
|
||
|
||
{usingVercel || !method || fields.length === 0 ? null : (
|
||
fields.map((field) => (
|
||
<div key={field.configPath}>
|
||
<label className="text-sm font-medium text-foreground">
|
||
{credentialFieldLabel(entry.name, field.label, fields.length)}
|
||
</label>
|
||
<Input
|
||
type={field.type === "text" && field.secret === false ? "text" : "password"}
|
||
aria-label={credentialFieldLabel(entry.name, field.label, fields.length)}
|
||
autoComplete="off"
|
||
value={values[field.configPath] ?? ""}
|
||
onChange={(e) => onChange({ ...values, [field.configPath]: e.target.value })}
|
||
placeholder={field.type === "text" && field.secret === false ? field.placeholder : "••••••••••••••••"}
|
||
className="mt-2 h-11 font-mono"
|
||
/>
|
||
{field.helperMd && <p className="mt-2 text-xs text-muted-foreground">
|
||
{field.helperMd}
|
||
</p>}
|
||
{field.helpUrl && (
|
||
<a
|
||
href={field.helpUrl}
|
||
target="_blank"
|
||
rel="noreferrer"
|
||
className="mt-2 inline-flex items-center gap-1 text-xs font-semibold text-foreground underline underline-offset-2"
|
||
>
|
||
Where do I find this?
|
||
<ArrowUpRight className="h-3 w-3" />
|
||
</a>
|
||
)}
|
||
</div>
|
||
))
|
||
)}
|
||
|
||
</div>
|
||
|
||
{defaults}
|
||
|
||
<div className="mt-8 flex items-center justify-between">
|
||
<Button variant="ghost" onClick={onBack} disabled={submitting}>
|
||
Back
|
||
</Button>
|
||
<Button onClick={onConnect} disabled={submitting || !settingsValid || !hasMethodSelection || !allFilled || !oauthClientFilled || !vercelConnectorFilled || !configFilled || !configRequirementMet}>
|
||
{submitting && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||
{submitting
|
||
? "Checking…"
|
||
: usingVercel
|
||
? method?.auth === "oauth" ? "Validate and continue" : "Validate and connect"
|
||
: method?.auth === "oauth"
|
||
? entry.slug === "github" ? "Continue to GitHub" : "Continue to sign in"
|
||
: "Connect"}
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
/**
|
||
* The Copy button beside a value the operator has to paste into another
|
||
* product's console. Both of those values are long and opaque, so the button
|
||
* has to say whether the clipboard actually took it.
|
||
*/
|
||
function CopyValueButton({ value, ariaLabel }: { value: string; ariaLabel: string }) {
|
||
const { copied, failed, copy } = useCopyAction();
|
||
return (
|
||
<Button
|
||
type="button"
|
||
variant="outline"
|
||
className="shrink-0"
|
||
aria-label={ariaLabel}
|
||
onClick={() => void copy(value)}
|
||
>
|
||
{copied ? <Check className="mr-2 h-4 w-4" /> : <Copy className="mr-2 h-4 w-4" />}
|
||
{copied ? "Copied" : failed ? "Copy failed" : "Copy"}
|
||
</Button>
|
||
);
|
||
}
|
||
|
||
function OAuthClientFields({
|
||
entry,
|
||
method,
|
||
callbackUrl,
|
||
clientId,
|
||
onClientIdChange,
|
||
clientSecret,
|
||
onClientSecretChange,
|
||
required,
|
||
}: {
|
||
entry: AppDefinition;
|
||
method: ConnectionMethodDef;
|
||
callbackUrl: string | null;
|
||
clientId: string;
|
||
onClientIdChange: (next: string) => void;
|
||
clientSecret: string;
|
||
onClientSecretChange: (next: string) => void;
|
||
required: boolean;
|
||
}) {
|
||
return (
|
||
<div className="space-y-4 rounded-lg border border-border p-4">
|
||
<div>
|
||
<div className="text-sm font-medium text-foreground">
|
||
{required ? method.oauthClientSecretRequired ? "Your OAuth app" : `${entry.name} needs its own OAuth app` : "Use your own OAuth app"}
|
||
</div>
|
||
{/*
|
||
When these fields are required it is a provider limitation, not a step
|
||
Paperclip chose to add. Say so, so the extra work reads as the
|
||
exception it is rather than as this connector's normal path.
|
||
*/}
|
||
<p className="mt-1 text-xs text-muted-foreground">
|
||
{method.oauthClientSecretRequired ? method.guidanceMd : required
|
||
? `${entry.name} does not let Paperclip register itself automatically, so this connector needs an OAuth app you create. Add the callback URL below in ${entry.name}, then paste the client details back here.`
|
||
: `Register Paperclip's callback URI in ${entry.name}, then enter the customer-owned client details.`}
|
||
</p>
|
||
{method.consoleLinks?.register ? (
|
||
<a
|
||
href={method.consoleLinks.register}
|
||
target="_blank"
|
||
rel="noreferrer"
|
||
className="mt-2 inline-flex items-center gap-1 text-xs font-semibold text-foreground underline underline-offset-2"
|
||
>
|
||
Open {entry.name} app settings
|
||
<ArrowUpRight className="h-3 w-3" />
|
||
</a>
|
||
) : null}
|
||
</div>
|
||
{callbackUrl ? (
|
||
<div>
|
||
<label className="text-sm font-medium text-foreground">Paperclip callback URL</label>
|
||
<div className="mt-2 flex min-w-0 flex-col gap-2 sm:flex-row">
|
||
<div
|
||
title={callbackUrl}
|
||
className="min-h-11 min-w-0 flex-1 rounded-md border border-input bg-muted/40 px-3 py-2.5 font-mono text-xs leading-tight text-foreground break-all"
|
||
>
|
||
{callbackUrl}
|
||
</div>
|
||
<CopyValueButton value={callbackUrl} ariaLabel="Copy callback URL" />
|
||
</div>
|
||
<p className="mt-2 text-xs text-muted-foreground">
|
||
Add this exact URL to {entry.name} before continuing. It must match the authorization request.
|
||
</p>
|
||
</div>
|
||
) : null}
|
||
<div>
|
||
<label className="text-sm font-medium text-foreground" htmlFor="curated-oauth-client-id">
|
||
Client ID
|
||
</label>
|
||
<Input
|
||
id="curated-oauth-client-id"
|
||
value={clientId}
|
||
onChange={(event) => onClientIdChange(event.target.value)}
|
||
autoComplete="off"
|
||
placeholder={required ? "Required" : "Optional"}
|
||
className="mt-2 h-11 font-mono"
|
||
/>
|
||
</div>
|
||
<div>
|
||
<label className="text-sm font-medium text-foreground" htmlFor="curated-oauth-client-secret">
|
||
Client secret
|
||
</label>
|
||
<Input
|
||
id="curated-oauth-client-secret"
|
||
type="password"
|
||
value={clientSecret}
|
||
onChange={(event) => onClientSecretChange(event.target.value)}
|
||
autoComplete="off"
|
||
placeholder={method.oauthClientSecretRequired ? "Required" : "Optional for public clients"}
|
||
className="mt-2 h-11 font-mono"
|
||
/>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function MethodConfigField({
|
||
field,
|
||
value,
|
||
onChange,
|
||
}: {
|
||
field: FieldDef;
|
||
value: string | boolean | undefined;
|
||
onChange: (value: string | boolean) => void;
|
||
}) {
|
||
if (field.type === "checkbox") {
|
||
return (
|
||
<div className="flex items-start justify-between gap-4 rounded-lg border border-border p-4">
|
||
<div>
|
||
<div className="text-sm font-medium text-foreground">{field.label}</div>
|
||
{field.helperMd && <div className="mt-1 text-xs text-muted-foreground">{field.helperMd}</div>}
|
||
</div>
|
||
<ToggleSwitch checked={value === true} onCheckedChange={onChange} />
|
||
</div>
|
||
);
|
||
}
|
||
return (
|
||
<div>
|
||
<label className="text-sm font-medium text-foreground">{field.label}</label>
|
||
{field.type === "textarea" ? (
|
||
<Textarea
|
||
value={typeof value === "string" ? value : ""}
|
||
onChange={(event) => onChange(event.target.value)}
|
||
placeholder={field.placeholder}
|
||
className="mt-2 min-h-24"
|
||
/>
|
||
) : field.type === "select" ? (
|
||
<select
|
||
value={typeof value === "string" ? value : ""}
|
||
onChange={(event) => onChange(event.target.value)}
|
||
className="mt-2 h-11 w-full rounded-md border border-input bg-background px-3 text-sm text-foreground outline-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/30"
|
||
>
|
||
<option value="" disabled>Select an option</option>
|
||
{(field.options ?? []).map((option) => <option key={option.value} value={option.value}>{option.label}</option>)}
|
||
</select>
|
||
) : (
|
||
<Input
|
||
value={typeof value === "string" ? value : ""}
|
||
onChange={(event) => onChange(event.target.value)}
|
||
placeholder={field.placeholder}
|
||
aria-label={field.label}
|
||
maxLength={field.validation?.maxLength}
|
||
className="mt-2 h-11"
|
||
/>
|
||
)}
|
||
{field.required && (typeof value !== "string" || !value.trim()) && <p className="mt-2 text-xs text-muted-foreground">{field.label} is required.</p>}
|
||
{field.helperMd && <p className="mt-2 text-xs text-muted-foreground">{field.helperMd}</p>}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
/**
|
||
* Access step (PAP-17835 Surface A).
|
||
*
|
||
* Two binary questions, asked together and *before* any credential is entered,
|
||
* so the reader understands the identity and the reach the secret is about to
|
||
* get. Hick's Law: two choices, not a matrix. Both use full-row radio targets.
|
||
*/
|
||
export function AccessStep({ companyId, ...props }: Omit<Parameters<typeof AccessStepContent>[0], "agents" | "agentsLoading"> & { companyId: string }) {
|
||
const agentsQuery = useQuery({
|
||
queryKey: queryKeys.agents.list(companyId),
|
||
queryFn: () => agentsApi.list(companyId),
|
||
});
|
||
return <AccessStepContent {...props} agents={(agentsQuery.data ?? []).filter((agent) => agent.status !== "terminated")} agentsLoading={agentsQuery.isLoading} />;
|
||
}
|
||
|
||
/** Shared Gmail access presentation; callers supply agents so review stories stay offline. */
|
||
export function AccessStepContent({
|
||
agents: allAgents,
|
||
agentsLoading = false,
|
||
authKind,
|
||
grantKinds,
|
||
grantKind,
|
||
setGrantKind,
|
||
installChoice,
|
||
setInstallChoice,
|
||
installAgentIds,
|
||
setInstallAgentIds,
|
||
lockedAgentId,
|
||
capabilities,
|
||
githubIdentity = false,
|
||
submitLabel,
|
||
continuesToProvider = false,
|
||
identityLoading = false,
|
||
preserveAgentAccess = false,
|
||
pending = false,
|
||
/** Embedded in the Advanced disclosure: no wizard footer to press. */
|
||
hideFooter = false,
|
||
/** Embedded: drop the page-level width cap so it fits its container. */
|
||
bare = false,
|
||
onBack,
|
||
onContinue,
|
||
}: {
|
||
agents: AgentMultiSelectOption[];
|
||
agentsLoading?: boolean;
|
||
authKind: ToolConnectionAuthKind;
|
||
grantKinds?: ConnectionGrantKind[];
|
||
grantKind: ConnectionGrantKind;
|
||
setGrantKind: (kind: ConnectionGrantKind) => void;
|
||
installChoice: "specific" | "all";
|
||
setInstallChoice: (choice: "specific" | "all") => void;
|
||
installAgentIds: Set<string>;
|
||
setInstallAgentIds: (ids: Set<string>) => void;
|
||
/** Task-hosted intents grant reach only to the agent that requested it. */
|
||
lockedAgentId?: string;
|
||
capabilities?: Pick<ToolConnectionCreateCapabilities, "canCreateOrganizationGrant" | "canSetCompanyInstall"> & {
|
||
organizationGrantReason?: string | null;
|
||
companyInstallReason?: string | null;
|
||
editableAgentIds?: string[];
|
||
} | null;
|
||
githubIdentity?: boolean;
|
||
submitLabel: string;
|
||
/** Only show an external-handoff cue when this action starts provider OAuth. */
|
||
continuesToProvider?: boolean;
|
||
/** Wait for a durable OAuth connection before showing a reconnect identity. */
|
||
identityLoading?: boolean;
|
||
/** Reconnect changes credentials only; existing install reach stays intact. */
|
||
preserveAgentAccess?: boolean;
|
||
pending?: boolean;
|
||
hideFooter?: boolean;
|
||
bare?: boolean;
|
||
onBack: () => void;
|
||
onContinue: () => void;
|
||
}) {
|
||
// "Only agents I choose" / "Just agents I pick" means agents this person may actually edit. When the server
|
||
// has not told us, fall back to every live agent rather than an empty list —
|
||
// an empty picker would read as "you have no agents".
|
||
const editableAgentIds = capabilities?.editableAgentIds;
|
||
const agents = editableAgentIds
|
||
? allAgents.filter((agent) => editableAgentIds.includes(agent.id))
|
||
: allAgents;
|
||
// Company-wide install is the connection creator's to give. When it is not
|
||
// available the option stays visible and disabled with the reason, so the
|
||
// scope stays legible instead of quietly disappearing.
|
||
const canSetCompanyInstall = capabilities?.canSetCompanyInstall ?? true;
|
||
const canCreateOrganizationGrant = capabilities?.canCreateOrganizationGrant ?? true;
|
||
const needsIdentityChoice = authKind !== "none";
|
||
const allowedGrantKinds = grantKinds ?? (["user", "organization"] satisfies ConnectionGrantKind[]);
|
||
const identityChoiceAllowed = !needsIdentityChoice
|
||
|| grantKind === "user"
|
||
|| grantKind === "agent"
|
||
|| canCreateOrganizationGrant;
|
||
const canContinue = identityChoiceAllowed && (preserveAgentAccess
|
||
? true
|
||
: lockedAgentId
|
||
? installAgentIds.has(lockedAgentId)
|
||
: grantKind === "agent"
|
||
? installChoice === "specific" && installAgentIds.size === 1
|
||
: installChoice === "all"
|
||
? canSetCompanyInstall
|
||
: installAgentIds.size > 0);
|
||
const lockedAgentName = lockedAgentId
|
||
? allAgents.find((agent) => agent.id === lockedAgentId)?.name ?? "the requesting agent"
|
||
: null;
|
||
const identityHeading = githubIdentity ? "Connect GitHub as" : "Which humans can use this credential?";
|
||
const agentAccessHeading = grantKind === "agent"
|
||
? "Which agent owns this GitHub account?"
|
||
: githubIdentity && grantKind === "user"
|
||
? "Which agents may use your GitHub when you’re responsible?"
|
||
: githubIdentity
|
||
? "Which agents may use the shared GitHub account?"
|
||
: "Which agents can use this connection?";
|
||
const agentAccessLabel = githubIdentity ? agentAccessHeading : "Which agents can use this connection?";
|
||
|
||
return (
|
||
<div className={bare ? "" : "mx-auto max-w-2xl"}>
|
||
<div className="overflow-hidden rounded-xl border border-border bg-background">
|
||
<div className="divide-y divide-border">
|
||
<section className="p-6">
|
||
<h2 className="text-sm font-semibold text-foreground">{identityHeading}</h2>
|
||
{githubIdentity && grantKind === "agent" ? (
|
||
<p className="mt-2 text-sm text-muted-foreground">This agent uses this GitHub account for everyone’s work, instead of the person giving instructions.</p>
|
||
) : null}
|
||
{identityLoading ? (
|
||
<div className="mt-4 grid gap-2 sm:grid-cols-2" aria-label="Loading connection identity">
|
||
<Skeleton className="h-20 w-full rounded-md" />
|
||
<Skeleton className="h-20 w-full rounded-md" />
|
||
</div>
|
||
) : needsIdentityChoice && allowedGrantKinds.length === 1 ? (
|
||
<div className="mt-4 flex items-center gap-3 rounded-md border border-border p-4">
|
||
{allowedGrantKinds[0] === "user" ? (
|
||
<UserRound className="h-4 w-4 shrink-0 text-muted-foreground" aria-hidden="true" />
|
||
) : allowedGrantKinds[0] === "agent" ? (
|
||
<Bot className="h-4 w-4 shrink-0 text-muted-foreground" aria-hidden="true" />
|
||
) : (
|
||
<UsersRound className="h-4 w-4 shrink-0 text-muted-foreground" aria-hidden="true" />
|
||
)}
|
||
<div>
|
||
<div className="text-sm font-medium text-foreground">
|
||
{allowedGrantKinds[0] === "user"
|
||
? githubIdentity ? "My GitHub account" : "Just me"
|
||
: allowedGrantKinds[0] === "agent"
|
||
? "A dedicated account for an agent"
|
||
: githubIdentity ? "Shared organization GitHub account (advanced)" : "Any human in the organization"}
|
||
</div>
|
||
{githubIdentity ? (
|
||
<p className="mt-1 text-xs text-muted-foreground">
|
||
{allowedGrantKinds[0] === "user"
|
||
? "Agents use it only for runs where you are the responsible person."
|
||
: allowedGrantKinds[0] === "agent"
|
||
? "That agent always uses this account, regardless of who starts the run."
|
||
: "Eligible agents use one shared credential, regardless of who starts the run."}
|
||
</p>
|
||
) : null}
|
||
</div>
|
||
</div>
|
||
) : needsIdentityChoice ? (
|
||
<RadioCardGroup
|
||
ariaLabel={githubIdentity ? identityHeading : "Which humans can use this credential?"}
|
||
className="mt-4 sm:grid-cols-2"
|
||
value={grantKind}
|
||
onValueChange={(next) => {
|
||
const kind = next as ConnectionGrantKind;
|
||
setGrantKind(kind);
|
||
if (kind === "agent") {
|
||
setInstallChoice("specific");
|
||
if (installAgentIds.size > 1) setInstallAgentIds(new Set([[...installAgentIds][0]!]));
|
||
}
|
||
}}
|
||
options={[
|
||
{
|
||
value: "user",
|
||
title: githubIdentity ? "My GitHub account" : "Just me",
|
||
description: githubIdentity
|
||
? "Agents use it only for runs where you are the responsible person."
|
||
: undefined,
|
||
icon: <UserRound className="h-4 w-4" aria-hidden="true" />,
|
||
},
|
||
{
|
||
value: "agent",
|
||
title: "A dedicated account for an agent",
|
||
description: githubIdentity
|
||
? "That agent always uses this account, regardless of who starts the run."
|
||
: undefined,
|
||
icon: <Bot className="h-4 w-4" aria-hidden="true" />,
|
||
},
|
||
{
|
||
value: "organization",
|
||
title: githubIdentity ? "Shared organization GitHub account (advanced)" : "Any human in the organization",
|
||
description: githubIdentity
|
||
? "Eligible agents use one shared credential, regardless of who starts the run."
|
||
: undefined,
|
||
icon: <UsersRound className="h-4 w-4" aria-hidden="true" />,
|
||
accessibleLabel: canCreateOrganizationGrant
|
||
? githubIdentity ? "Shared organization GitHub account (advanced)" : "Any human in the organization"
|
||
: `${githubIdentity ? "Shared organization GitHub account (advanced)" : "Any human in the organization"}. Unavailable: ${capabilities?.organizationGrantReason ??
|
||
"Only a connection manager can share this credential with the organization."}`,
|
||
tooltip: canCreateOrganizationGrant
|
||
? undefined
|
||
: capabilities?.organizationGrantReason ??
|
||
"Only a connection manager can share this credential with the organization.",
|
||
disabled: !canCreateOrganizationGrant,
|
||
},
|
||
].filter((option) => allowedGrantKinds.includes(option.value as ConnectionGrantKind))}
|
||
/>
|
||
) : (
|
||
// A connection with no credential has no identity to choose, so
|
||
// asking would be a meaningless decision.
|
||
<p className="mt-4 text-sm text-muted-foreground">No identity required</p>
|
||
)}
|
||
</section>
|
||
|
||
<section className="p-6">
|
||
<h2 className="text-sm font-semibold text-foreground">{agentAccessHeading}</h2>
|
||
{preserveAgentAccess ? (
|
||
<div className="mt-4 flex items-start gap-3 rounded-md border border-border bg-muted/40 p-4">
|
||
<UsersRound className="mt-0.5 h-4 w-4 shrink-0 text-primary" aria-hidden="true" />
|
||
<div>
|
||
<div className="text-sm font-medium text-foreground">Existing agent access stays the same</div>
|
||
<p className="mt-1 text-xs text-muted-foreground">
|
||
Reconnecting replaces the credential without changing which agents can use it.
|
||
</p>
|
||
</div>
|
||
</div>
|
||
) : lockedAgentId ? (
|
||
<p className="mt-2 text-sm text-muted-foreground">
|
||
This task grants access only to <span className="font-medium text-foreground">{lockedAgentName}</span>.
|
||
Existing connection access is left unchanged.
|
||
</p>
|
||
) : (
|
||
grantKind === "agent" ? (
|
||
<p className="mt-2 text-sm text-muted-foreground">Choose exactly one agent. This identity cannot be shared with other agents.</p>
|
||
) : <RadioCardGroup
|
||
ariaLabel={agentAccessLabel}
|
||
className="mt-4 sm:grid-cols-2"
|
||
value={installChoice}
|
||
onValueChange={(next) => setInstallChoice(next as "specific" | "all")}
|
||
options={[
|
||
{
|
||
value: "specific",
|
||
title: githubIdentity ? "Only agents I choose" : "Just agents I pick",
|
||
description: githubIdentity
|
||
? grantKind === "user"
|
||
? "Only selected agents may use your GitHub when you’re responsible."
|
||
: "Only selected agents may use the shared account."
|
||
: undefined,
|
||
icon: <Bot className="h-4 w-4" aria-hidden="true" />,
|
||
},
|
||
{
|
||
value: "all",
|
||
title: "Any agent",
|
||
description: githubIdentity
|
||
? grantKind === "user"
|
||
? "Every agent may use your GitHub when you’re responsible."
|
||
: "Every agent may use the shared account."
|
||
: undefined,
|
||
icon: <BotGroupIcon />,
|
||
accessibleLabel: canSetCompanyInstall
|
||
? "Any agent"
|
||
: `Any agent. Unavailable: ${capabilities?.companyInstallReason ??
|
||
"Only someone who can configure this connection can choose this."}`,
|
||
tooltip: canSetCompanyInstall
|
||
? undefined
|
||
: capabilities?.companyInstallReason ??
|
||
"Only someone who can configure this connection can choose this.",
|
||
disabled: !canSetCompanyInstall,
|
||
},
|
||
]}
|
||
/>
|
||
)}
|
||
{!preserveAgentAccess && !lockedAgentId && installChoice === "specific" ? (
|
||
<div className="mt-3">
|
||
<AgentMultiSelect
|
||
agents={agents}
|
||
selectedAgentIds={installAgentIds}
|
||
onChange={(next) => setInstallAgentIds(
|
||
grantKind === "agent" && next.size > 1 ? new Set([[...next].at(-1)!]) : next,
|
||
)}
|
||
loading={agentsLoading}
|
||
emptyMessage="You cannot edit any agents yet."
|
||
showSelectionPreview={false}
|
||
/>
|
||
</div>
|
||
) : null}
|
||
</section>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Mobile stacks actions full-width with the primary action first in
|
||
reading order; desktop keeps Back on the left. */}
|
||
{hideFooter ? null : <div className="mt-6 flex flex-col-reverse gap-2 sm:flex-row sm:items-center sm:justify-between">
|
||
<Button variant="ghost" className="w-full sm:w-auto" onClick={onBack} disabled={pending}>
|
||
Back
|
||
</Button>
|
||
<Button
|
||
className="w-full sm:w-auto"
|
||
onClick={onContinue}
|
||
disabled={!canContinue || identityLoading || pending}
|
||
>
|
||
{pending ? <Loader2 className="h-4 w-4 animate-spin" aria-hidden="true" /> : null}
|
||
{submitLabel}
|
||
{!pending && continuesToProvider ? <ArrowUpRight className="h-4 w-4" aria-hidden="true" /> : null}
|
||
</Button>
|
||
</div>}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
/**
|
||
* The stated default (PAP-659 C0).
|
||
*
|
||
* The Access step used to ask two questions whose answers were already correct
|
||
* on arrival. Instead of asking, the resolved answer is printed in one line
|
||
* above the primary action and repeated on the completion screen, so the reach
|
||
* being granted is said twice on the happy path without costing a click.
|
||
*/
|
||
export function connectionDefaultSummarySentence(input: {
|
||
grantKind: ConnectionGrantKind;
|
||
authKind: ToolConnectionAuthKind;
|
||
installChoice: "specific" | "all";
|
||
installCount: number;
|
||
lockedAgentId?: string | null;
|
||
preserveAgentAccess?: boolean;
|
||
}): string {
|
||
const identity = input.authKind === "none"
|
||
? "No sign-in needed"
|
||
: input.grantKind === "user"
|
||
? "Connects as you"
|
||
: input.grantKind === "agent"
|
||
? "Connects as a dedicated agent account"
|
||
: "Connects for everyone in your organization";
|
||
const reach = input.preserveAgentAccess
|
||
? "agent access stays as it is"
|
||
: input.lockedAgentId
|
||
? "available to the agent that asked for it"
|
||
: input.installChoice === "all"
|
||
? "available to all agents"
|
||
: input.installCount === 0
|
||
? "no agents selected yet"
|
||
: `available to ${input.installCount} selected ${input.installCount === 1 ? "agent" : "agents"}`;
|
||
return `${identity}, ${reach}.`;
|
||
}
|
||
|
||
/**
|
||
* The stated default plus the collapsed Change disclosure that replaced the
|
||
* Access step. It sits in the same place on every connector and never blocks
|
||
* the primary action: opening it is optional, and everything inside it can
|
||
* also be changed on the Permissions tab after connecting.
|
||
*/
|
||
export function ConnectionAccessDefaults({
|
||
companyId,
|
||
agents,
|
||
sentence,
|
||
notice,
|
||
extra,
|
||
forceOpen = false,
|
||
disabled = false,
|
||
...accessProps
|
||
}: Omit<Parameters<typeof AccessStepContent>[0], "agents" | "agentsLoading" | "submitLabel" | "onBack" | "onContinue" | "pending" | "continuesToProvider" | "hideFooter" | "bare"> & {
|
||
companyId: string;
|
||
/**
|
||
* Supplied by callers that already hold the agent list — design specimens and
|
||
* review stories, which must not reach the network. Omitted in the product,
|
||
* where the disclosure fetches its own.
|
||
*/
|
||
agents?: AgentMultiSelectOption[];
|
||
sentence: string;
|
||
/** Shown when the resolved default genuinely cannot apply. Never a step. */
|
||
notice?: string[];
|
||
/**
|
||
* Connector-specific advanced settings — alternate methods, access level,
|
||
* provider config, a customer-owned OAuth client. They share this one
|
||
* disclosure rather than opening a second one on the same screen.
|
||
*/
|
||
extra?: ReactNode;
|
||
/** Something inside is load-bearing right now, so do not hide it. */
|
||
forceOpen?: boolean;
|
||
disabled?: boolean;
|
||
}) {
|
||
const [open, setOpen] = useState(false);
|
||
const expanded = open || forceOpen;
|
||
return (
|
||
<Collapsible open={expanded} onOpenChange={setOpen} className="mt-6 rounded-lg border border-border bg-muted/30 px-4 py-3">
|
||
<div className="flex flex-wrap items-baseline justify-between gap-x-3 gap-y-1">
|
||
<p className="text-sm text-muted-foreground">{sentence}</p>
|
||
<CollapsibleTrigger asChild><Button
|
||
type="button"
|
||
variant="link"
|
||
className="h-auto p-0 text-xs font-semibold underline underline-offset-2"
|
||
disabled={disabled}
|
||
>
|
||
Change
|
||
</Button></CollapsibleTrigger>
|
||
</div>
|
||
{(notice ?? []).map((reason) => (
|
||
<p key={reason} className="mt-2 text-xs text-muted-foreground">{reason}</p>
|
||
))}
|
||
<CollapsibleContent>
|
||
<div className="mt-3 space-y-6">
|
||
{extra}
|
||
{agents
|
||
? <AccessStepContent {...accessProps} agents={agents} bare hideFooter submitLabel="" onBack={() => {}} onContinue={() => {}} />
|
||
: <AccessStep {...accessProps} companyId={companyId} bare hideFooter submitLabel="" onBack={() => {}} onContinue={() => {}} />}
|
||
</div>
|
||
</CollapsibleContent>
|
||
</Collapsible>
|
||
);
|
||
}
|
||
|
||
function BotGroupIcon() {
|
||
return (
|
||
<span className="relative block h-4 w-5" aria-hidden="true">
|
||
<Bot className="absolute left-0 top-0 h-3.5 w-3.5" />
|
||
<Bot className="absolute bottom-0 right-0 h-3.5 w-3.5" />
|
||
</span>
|
||
);
|
||
}
|
||
|
||
/**
|
||
* Summary of what the Access step committed. Three lines, not badges: identity,
|
||
* reach, and the existing action summary each said once.
|
||
*/
|
||
export function accessSummaryLines(input: {
|
||
grantKind: ConnectionGrantKind;
|
||
authKind: ToolConnectionAuthKind;
|
||
installChoice: "specific" | "all";
|
||
installCount: number;
|
||
enabledCount: number;
|
||
}): Array<{ label: string; value: string }> {
|
||
const identity = input.authKind === "none"
|
||
? "No identity required"
|
||
: input.grantKind === "user"
|
||
? "Your identity"
|
||
: input.grantKind === "agent"
|
||
? "Dedicated agent identity"
|
||
: "Organization identity";
|
||
const availableTo = input.installChoice === "all"
|
||
? "Any agent"
|
||
: `${input.installCount} selected ${input.installCount === 1 ? "agent" : "agents"}`;
|
||
return [
|
||
{ label: "Identity", value: identity },
|
||
{ label: "Available to", value: availableTo },
|
||
{
|
||
label: "Actions",
|
||
value: `${input.enabledCount} ${input.enabledCount === 1 ? "action" : "actions"} on`,
|
||
},
|
||
];
|
||
}
|
||
|
||
function Radio({ selected }: { selected: boolean }) {
|
||
return (
|
||
<span
|
||
className={cn(
|
||
"mt-0.5 inline-flex h-4 w-4 shrink-0 items-center justify-center rounded-full border-2",
|
||
selected ? "border-foreground" : "border-muted-foreground/40",
|
||
)}
|
||
>
|
||
{selected && <span className="h-2 w-2 rounded-full bg-foreground" />}
|
||
</span>
|
||
);
|
||
}
|
||
|
||
export function ConnectionSetupCompletionScreen({
|
||
appName,
|
||
logoUrl,
|
||
darkLogoUrl,
|
||
summary,
|
||
statedDefault,
|
||
onDone,
|
||
}: {
|
||
appName: string;
|
||
logoUrl?: string | null;
|
||
darkLogoUrl?: string | null;
|
||
/** Identity / Available to / Actions, as three lines rather than badges. */
|
||
summary: Array<{ label: string; value: string }>;
|
||
/**
|
||
* The same sentence the connect screen printed above its primary action, so
|
||
* the reach that was granted is stated twice on the happy path (PAP-659 C0).
|
||
*/
|
||
statedDefault?: string;
|
||
onDone: () => void;
|
||
}) {
|
||
return (
|
||
<div className="mx-auto max-w-md py-10 text-center">
|
||
<div className="mx-auto flex h-20 w-20 items-center justify-center rounded-full border-2 border-emerald-500 bg-emerald-500/10">
|
||
<Check className="h-9 w-9 text-emerald-600 dark:text-emerald-400" />
|
||
</div>
|
||
<div className="mt-6 flex items-center justify-center gap-2">
|
||
<AppLogo name={appName} logoUrl={logoUrl} darkLogoUrl={darkLogoUrl} size={28} />
|
||
<h2 className="text-2xl font-bold tracking-tight">{appName} is ready.</h2>
|
||
</div>
|
||
{statedDefault ? <p className="mt-3 text-sm text-muted-foreground">{statedDefault}</p> : null}
|
||
<dl className="mx-auto mt-6 max-w-xs space-y-1 text-left">
|
||
{summary.map((line) => (
|
||
<div key={line.label} className="flex items-baseline justify-between gap-4">
|
||
<dt className="text-xs font-medium text-muted-foreground">{line.label}</dt>
|
||
<dd className="text-sm text-foreground">{line.value}</dd>
|
||
</div>
|
||
))}
|
||
</dl>
|
||
<div className="mt-8">
|
||
<Button size="lg" className="px-10" onClick={onDone}>
|
||
View connection
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|