feat(connections): one connect screen per connector with a stated default

PAP-659 step 1 (C0 + bucket A) and step 2 (C1 + C2) of the approved plan.

- Delete the Access step. Identity and agent reach resolve to a default that
  is printed above the primary action and repeated on the completion screen,
  with the same controls one click away in an Advanced disclosure.
- A selected connector is a single screen, so its stepper is gone rather than
  showing one lonely dot.
- Always have a default connection method: the ranking in
  getRecommendedConnectionMethod is now used instead of discarded for
  multi-method apps, and the alternates plus the capability choice move into
  the same Advanced disclosure.
- The one primary action carries the external-handoff cue.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
scotttongandClaude Opus 5 committed 2026-09-28 20:58:37 -07:00
1 parent 53aad90b9e
commit c2205a88b4
4 files changed
+400 -228

No files matched your search

+4 -5
View File
@@ -43,7 +43,7 @@ patchedDependencies:
hash: gtvuasrdnibltgd6jvdqufvsdy
path: patches/acpx@0.12.0.patch
acpx@0.13.1:
hash: ikfco27aeue26z63kex2z5d5ce
hash: jxpmkytjzjh3vghha3pybg7k54
path: patches/acpx@0.13.1.patch
embedded-postgres@18.1.0-beta.16:
hash: 55uhvnotpqyiy37rn3pqpukhei
@@ -508,7 +508,7 @@ importers:
version: 0.156.0
acpx:
specifier: 0.13.1
version: 0.13.1(patch_hash=ikfco27aeue26z63kex2z5d5ce)
version: 0.13.1(patch_hash=jxpmkytjzjh3vghha3pybg7k54)
ajv:
specifier: ^8.20.0
version: 8.20.0
@@ -960,7 +960,7 @@ importers:
version: 8.18.0
acpx:
specifier: 0.13.1
version: 0.13.1(patch_hash=ikfco27aeue26z63kex2z5d5ce)
version: 0.13.1(patch_hash=jxpmkytjzjh3vghha3pybg7k54)
ajv:
specifier: ^8.20.0
version: 8.20.0
@@ -7656,7 +7656,6 @@ packages:
opencode-ai@1.18.32:
resolution: {integrity: sha512-SCrZWdq44y/EoH2+fE4HLcXS+DzpVqHPzmXk3p2RrufYy8LWvpfhRhKtijb5ktvx8r94ScqToqwHxR0BwS65OQ==}
cpu: [arm64, x64]
os: [darwin, linux, win32]
hasBin: true
@@ -13348,7 +13347,7 @@ snapshots:
- bare-buffer
- react-native-b4a
acpx@0.13.1(patch_hash=ikfco27aeue26z63kex2z5d5ce):
acpx@0.13.1(patch_hash=jxpmkytjzjh3vghha3pybg7k54):
dependencies:
'@agentclientprotocol/sdk': 1.4.0(zod@4.4.3)
commander: 15.0.0
@@ -102,7 +102,7 @@ import {
} from "@/pages/apps/generic-mcp-connect";
import { autoExtendNotice, INSTALL_ALL_WARNING, installInfoNotice, installPayload } from "@/lib/tool-installs";
type Step = "gallery" | "access" | "key" | "success";
type Step = "gallery" | "key" | "success";
export type OAuthConnectPhase = "entry" | "starting" | "redirecting" | "error";
type EnrollmentAccessState = {
@@ -184,7 +184,6 @@ function oauthCallbackErrorMessage(outcome: string | null, code: string | null):
}
const ROUTE_STAGE_BY_STEP: Partial<Record<Step, string>> = {
access: "access",
key: "setup",
success: "complete",
};
@@ -196,10 +195,8 @@ export function requestedConnectionInitialStep(input: {
hasPrefilledLink: boolean;
zapierSource: boolean;
}): Step {
if (input.requestedAppKey) {
return input.resumeConnectionId || input.routeStage === "setup" ? "key" : "access";
}
return input.hasPrefilledLink || input.zapierSource ? "access" : "gallery";
if (input.requestedAppKey) return "key";
return input.hasPrefilledLink || input.zapierSource ? "key" : "gallery";
}
export function requestedConnectionEntry(input: {
@@ -305,24 +302,23 @@ function withConnectionIntent(href: string, interactionId?: string | null): stri
type AppAccessSelection = "all_agents" | { agentIds: string[] };
// Access comes before credentials so the reader knows what identity and reach
// the secret is about to get before they share it (PAP-17835).
const STEP_LABELS = ["Pick app", "Access", "Add your key"];
// 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,
access: 1,
key: 2,
};
const SELECTED_APP_STEP_INDEX: Record<Exclude<Step, "gallery" | "success">, number> = {
access: 0,
key: 1,
};
const ZAPIER_STEP_LABELS = ["Access", "Add MCP URL"];
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 two
// dots to three the moment you press Connect reads as a step you missed.
const OAUTH_SIGN_IN_STEP_LABELS = ["Access", "Sign in"];
// 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).
@@ -394,16 +390,13 @@ function availableToolConnectionMethod(
function recommendedSetupConnectionMethod(
methods: readonly ConnectionMethodDef[],
): ConnectionMethodDef | null {
const recommended = getRecommendedConnectionMethod(methods);
// Capability choices (for example Google Workspace read versus write) have
// an intentional default. Unrelated region/authentication variants should
// still ask the operator to choose unless only one is available. A method
// that supports an agent-owned identity must also be selected before the
// Access step: that ownership decision cannot be represented by a legacy
// compatibility method such as GitHub's advanced PAT option.
return methods.length === 1 || recommended?.capabilityProfile || recommended?.grantKinds?.includes("agent")
? recommended
: null;
// 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(
@@ -901,7 +894,7 @@ function StandardConnectionSetupFlow({
resetGenericAuthState();
setCredentials({});
setConnectResult(null);
setStep("access");
setStep("key");
navigate(withConnectionIntent("/apps/connect?source=zapier", connectionIntentId));
return;
}
@@ -930,7 +923,7 @@ function StandardConnectionSetupFlow({
setInstallAgentIds(new Set(requestedAgentId ? [requestedAgentId] : []));
setInstallChoice(requestedAgentId ? "specific" : "all");
setGrantKind(reconnectGrantKind ?? defaultGrantKindFor(initialMethod, Boolean(requestedAgentId)));
setStep("access");
setStep("key");
navigate(
credentialSource === "vercel_connect"
? withConnectionIntent(vercelConnectSourceHref(picked.slug), connectionIntentId)
@@ -1598,10 +1591,6 @@ function StandardConnectionSetupFlow({
zapierSource,
]);
useEffect(() => {
if (reconnectConnection?.connectionPurpose === "ai" && step === "access") setStep("key");
}, [reconnectConnection?.connectionPurpose, step]);
// 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.
@@ -1723,9 +1712,9 @@ function StandardConnectionSetupFlow({
},
onError: (error) => {
// Creation must feel transactional: a failed commit returns the operator
// to Access with their identity and agent selections intact rather than
// stranding them on a half-made connection.
setAppStep("access");
// 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.",
@@ -1908,6 +1897,66 @@ function StandardConnectionSetupFlow({
);
}
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 connectionDefaults = step === "key" ? (
<ConnectionAccessDefaults
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."
: galleryQuery.data?.capabilities?.canSetCompanyInstall === false
? galleryQuery.data.capabilities.companyInstallReason
?? "Only someone who can configure this connection can give every agent access."
: requestedAgentId
? "This task grants access only to the agent that asked for it."
: null
}
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}
/>
) : null;
const showCuratedOAuthState = Boolean(
automaticOAuthEntry
&& step === "key"
@@ -1947,6 +1996,15 @@ function StandardConnectionSetupFlow({
} : 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={connectionDefaults}
onOpenAuthorization={openAuthorizationTab}
onRetry={async () => {
setOAuthError(null);
@@ -1985,20 +2043,16 @@ function StandardConnectionSetupFlow({
? { applicationId: prefill.applicationId, draftOnly: true }
: {},
);
if (!directOAuthAccessConfirmedRef.current && !resumeConnectionId) {
if (refreshedConnection) {
setGrantKind(
refreshedConnection.credentialPolicy === "per_user"
? "user"
: refreshedConnection.credentialPolicy === "per_agent"
? "agent"
: "organization",
);
}
setOAuthPhase("entry");
setOAuthError(null);
setStep("access");
return;
if (refreshedConnection && !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);
@@ -2013,7 +2067,7 @@ function StandardConnectionSetupFlow({
oauthHandoffAbortRef.current?.abort();
setOAuthPhase("entry");
setOAuthError(null);
setAppStep("access");
backToGallery();
}}
onCancel={() => {
oauthHandoffAbortRef.current?.abort();
@@ -2039,6 +2093,7 @@ function StandardConnectionSetupFlow({
error={oauthError}
authorizationHost={authorizationHost}
authorizationUrl={authorizationFallbackUrl}
defaults={connectionDefaults}
onOpenAuthorization={openAuthorizationTab}
onRetry={() => {
setOAuthError(null);
@@ -2071,10 +2126,6 @@ function StandardConnectionSetupFlow({
connectResult?.application.name ??
entry?.name ??
(linkName.trim() || defaultGenericMcpName(linkUrl) || "this app");
const credentialSourceMethods = connectionMethodsForCredentialSource(entry, credentialSource);
const setupCredentialSourceMethods = preEnrollmentManagedMethod
? [preEnrollmentManagedMethod]
: credentialSourceMethods;
const credentialSourceApps = vercelConnectMode
? visibleGalleryApps.filter(
(app) => connectionMethodsForCredentialSource(app, credentialSource).length > 0,
@@ -2088,7 +2139,7 @@ function StandardConnectionSetupFlow({
: 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: () => setAppStep("access") }) ?? (aiMethod && selectedCompanyId ? <><AiConnectionCredentialStep
const credentialStep = entry ? renderCredentialStep?.({ app: entry, name: galleryName || entry.name, grantKind: effectiveGrantKind, agentIds: [...installAgentIds], allAgents: installChoice === "all", onBack: () => backToGallery() }) ?? (aiMethod && selectedCompanyId ? <><AiConnectionCredentialStep
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"}`}
@@ -2097,40 +2148,21 @@ function StandardConnectionSetupFlow({
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
? ["Access", "Connect account"]
? ["Connect account"]
: zapierSource
? ZAPIER_STEP_LABELS
: entry && setupCredentialSourceMethods.length > 1
? ["Access", "Choose connection"]
? ["Choose connection"]
: entry && setupCredentialSourceMethods[0]?.auth === "oauth"
? ["Access", "Sign in"]
? ["Sign in"]
: isGoogleSheetsRobotMethod(entry, connectionMethodKey)
? ["Access", "Share sheet"]
? ["Share sheet"]
: entry
? ["Access", "Add your key"]
? ["Add your key"]
: STEP_LABELS;
// The Access step's identity question only makes sense when there *is* a
// credential, so it reads the selected method's auth kind.
const accessStepMethod = entry
? (connectionMethodKey
? setupCredentialSourceMethods.find((m) => m.key === connectionMethodKey) ?? null
: setupCredentialSourceMethods[0] ?? null)
: null;
const accessStepAuthKind: ToolConnectionAuthKind = entry
? accessStepMethod?.auth ?? "none"
: linkAuthMode === "none"
? "none"
: linkAuthMode === "oauth"
? "oauth"
: "api_key";
// Name the actual next effect: multi-method apps and enrollment still have
// a local setup screen, even when OAuth is already the selected method.
const accessContinuesToProvider = Boolean(directOAuthEntry);
const accessSubmitLabel = accessContinuesToProvider
? `Continue to ${entry?.name ?? "sign-in"}`
: accessStepAuthKind === "oauth" ? "Continue" : "Save and continue";
const stepIndex = reconnectConnection?.connectionPurpose === "ai" ? 0 : (zapierSource || entry) && step !== "gallery" && step !== "success"
? SELECTED_APP_STEP_INDEX[step]
: step === "success"
@@ -2147,7 +2179,9 @@ function StandardConnectionSetupFlow({
? vercelConnectMode
? "Choose a reviewed app to connect through Vercel."
: "Pick the app you want your agents to use."
: `Step ${stepIndex + 1} of ${stepLabels.length}`
: stepLabels.length <= 1
? "Connect now — permissions and access are yours to change afterwards."
: `Step ${stepIndex + 1} of ${stepLabels.length}`
}
step={step}
activeIndex={stepIndex}
@@ -2190,7 +2224,7 @@ function StandardConnectionSetupFlow({
setInstallAgentIds(new Set(requestedAgentId ? [requestedAgentId] : []));
setInstallChoice(requestedAgentId ? "specific" : "all");
setGrantKind(reconnectGrantKind ?? (requestedAgentId ? "user" : "organization"));
setStep("access");
setStep("key");
}}
/>
)}
@@ -2202,7 +2236,7 @@ function StandardConnectionSetupFlow({
This instance is connected to Paperclip, but {entry.name} sign-in is not currently available. Try again shortly or contact your instance administrator.
</p>
<div className="mt-6 flex items-center justify-between gap-3">
<Button type="button" variant="ghost" onClick={() => setAppStep("access")}>Back</Button>
<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
@@ -2241,7 +2275,7 @@ function StandardConnectionSetupFlow({
</p>
) : null}
<div className="mt-6 flex items-center justify-between gap-3">
<Button type="button" variant="ghost" onClick={() => setAppStep("access")}>
<Button type="button" variant="ghost" onClick={backToGallery}>
Back
</Button>
<Button
@@ -2305,9 +2339,10 @@ function StandardConnectionSetupFlow({
setGoogleSheetsError(null);
}}
submitting={connectMutation.isPending}
// Back returns to Access for new, resumed, and reconnected accounts.
// Cancel is the separate exit to the connector list.
onBack={() => setAppStep("access")}
defaults={connectionDefaults}
// 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);
@@ -2387,7 +2422,8 @@ function StandardConnectionSetupFlow({
matchedEntry={linkMatchedEntry}
onUseMatchedEntry={linkMatchedEntry ? () => useMatchedGalleryEntry(linkMatchedEntry) : undefined}
submitting={connectMutation.isPending || genericOAuthPending}
onBack={() => setStep("access")}
defaults={connectionDefaults}
onBack={backToGallery}
onConnect={() => {
setLinkGuidance(null);
connectMutation.mutate(undefined);
@@ -2400,57 +2436,12 @@ function StandardConnectionSetupFlow({
link={linkUrl}
onLinkChange={setLinkUrl}
submitting={connectMutation.isPending}
onBack={() => setStep("access")}
defaults={connectionDefaults}
onBack={backToGallery}
onConnect={() => connectMutation.mutate(undefined)}
/>
)}
{step === "access" && (
<>
{entry?.slug === "railway" && (
<div className="mb-6 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>
)}
<AccessStep
companyId={selectedCompanyId}
authKind={accessStepAuthKind}
grantKinds={fixedGrantKind ? [fixedGrantKind] : accessStepMethod?.grantKinds}
grantKind={effectiveGrantKind}
setGrantKind={setGrantKind}
installChoice={installChoice}
setInstallChoice={setInstallChoice}
installAgentIds={installAgentIds}
setInstallAgentIds={setInstallAgentIds}
lockedAgentId={requestedAgentId}
capabilities={galleryQuery.data?.capabilities}
githubIdentity={entry?.slug === "github"}
submitLabel={accessSubmitLabel}
continuesToProvider={accessContinuesToProvider}
identityLoading={Boolean(automaticOAuthEntry) && directOAuthLookupPending}
preserveAgentAccess={Boolean(automaticOAuthEntry && (resumableOAuthConnection || reconnectConnection))}
pending={connectMutation.isPending || oauthStartMutation.isPending || Boolean(requestedAppKey && !entry)}
onBack={backToGallery}
onContinue={() => {
if (directOAuthEntry) {
directOAuthAccessConfirmedRef.current = true;
setOAuthError(null);
setOAuthPhase("starting");
setAppStep("key");
if (resumableOAuthConnection) startOAuth(resumableOAuthConnection);
else connectApp(directOAuthEntry);
return;
}
if (entry) setAppStep("key");
else setStep("key");
}}
/>
</>
)}
{step === "success" && (
<ConnectionSetupCompletionScreen
appName={appName}
@@ -2463,6 +2454,13 @@ function StandardConnectionSetupFlow({
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("/apps"))}
/>
)}
@@ -2515,7 +2513,7 @@ export function StepHeader({
Cancel
</Button>}
</div>
{step !== "gallery" && (
{step !== "gallery" && labels.length > 1 && (
// A landmark with stable hooks, so the step model can be read without
// guessing at Tailwind classes. The dots are decoration — the label
// line below already says the same thing, so announcing both would
@@ -2549,7 +2547,9 @@ export function OAuthConnectStateScreen({
recoveryActions,
authorizationHost,
authorizationUrl,
steps = { labels: OAUTH_SIGN_IN_STEP_LABELS, activeIndex: 1 },
steps = { labels: OAUTH_SIGN_IN_STEP_LABELS, activeIndex: 0 },
guidance,
defaults,
onRetry,
onOpenAuthorization,
onBack,
@@ -2578,6 +2578,10 @@ export function OAuthConnectStateScreen({
* 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;
onOpenAuthorization?: () => void;
onRetry: () => void;
onBack: () => void;
@@ -2640,6 +2644,9 @@ export function OAuthConnectStateScreen({
</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 ? (
@@ -2666,6 +2673,7 @@ export function OAuthConnectStateScreen({
{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>
@@ -2684,12 +2692,15 @@ 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;
}) {
@@ -2719,6 +2730,8 @@ function ZapierConnectStep({
)}
</div>
{defaults}
<div className="mt-6 flex items-center justify-between">
<Button variant="ghost" onClick={onBack} disabled={submitting}>
Back
@@ -3032,6 +3045,7 @@ function LinkConnectStep({
matchedEntry,
onUseMatchedEntry,
submitting,
defaults,
onBack,
onConnect,
}: {
@@ -3056,6 +3070,8 @@ function LinkConnectStep({
matchedEntry?: AppDefinition | null;
onUseMatchedEntry?: () => void;
submitting: boolean;
/** The stated default and its Advanced disclosure (PAP-659 C0). */
defaults?: ReactNode;
onBack: () => void;
onConnect: () => void;
}) {
@@ -3253,6 +3269,8 @@ function LinkConnectStep({
</Collapsible>
</div>
{defaults}
<div className="mt-8 flex items-center justify-between">
<Button variant="ghost" onClick={onBack} disabled={submitting}>
Back
@@ -3347,6 +3365,7 @@ function KeyStep({
googleSheetsError,
onGoogleSheetsLinksChange,
submitting,
defaults,
onBack,
onConnect,
}: {
@@ -3374,6 +3393,8 @@ function KeyStep({
googleSheetsError: string | null;
onGoogleSheetsLinksChange: (next: string) => void;
submitting: boolean;
/** The stated default and its Advanced disclosure (PAP-659 C0). */
defaults?: ReactNode;
onBack: () => void;
onConnect: () => void;
}) {
@@ -3470,7 +3491,7 @@ function KeyStep({
const optionalCustomerOAuthClient = !usingVercel
&& acceptsCustomerOAuthClient
&& !customerOAuthClientRequired;
const hasAdvancedSettings = advancedConfigFields.length > 0 || optionalCustomerOAuthClient;
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>
@@ -3516,6 +3537,9 @@ function KeyStep({
{usingCustomGoogleOAuth ? "Use Paperclip instead" : "Use your own Google 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
@@ -3534,6 +3558,13 @@ function KeyStep({
{!method && <p className="mt-2 text-xs text-muted-foreground">Choose a connection method to continue.</p>}
</div>
) : null;
const advancedMethodSelection = authenticationSelection;
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 = usingCustomGoogleOAuth || !hasMethodSelection;
if (isGoogleSheetsRobotMethod(entry, method)) {
const parsed = parseGoogleSheetIds(googleSheetsLinks);
@@ -3582,6 +3613,8 @@ function KeyStep({
</div>
</div>
{defaults}
<div className="mt-8 flex items-center justify-between">
<Button variant="ghost" onClick={onBack} disabled={submitting}>
Back
@@ -3614,9 +3647,6 @@ function KeyStep({
) : null}
<div className="space-y-6">
{capabilitySelection}
{authenticationSelection}
{usingVercel && vercelReview && vercelConnectAvailability ? (
<div className="space-y-4 rounded-lg border border-border p-4">
<div>
@@ -3665,13 +3695,15 @@ function KeyStep({
))}
{hasAdvancedSettings && (
<Collapsible open={advancedOpen} onOpenChange={setAdvancedOpen}>
<Collapsible open={advancedOpen || forceAdvancedOpen} onOpenChange={setAdvancedOpen}>
<CollapsibleTrigger className="flex items-center gap-1.5 text-sm text-muted-foreground hover:text-foreground">
<ChevronDown className={cn("h-4 w-4 transition-transform", advancedOpen && "rotate-180")} />
Advanced
</CollapsibleTrigger>
<CollapsibleContent className="pt-4">
<div className="space-y-6">
{capabilitySelection}
{advancedMethodSelection}
{advancedConfigFields.map((field) => (
<MethodConfigField
key={field.key}
@@ -3744,6 +3776,8 @@ function KeyStep({
</div>
{defaults}
<div className="mt-8 flex items-center justify-between">
<Button variant="ghost" onClick={onBack} disabled={submitting}>
Back
@@ -3959,6 +3993,10 @@ export function AccessStepContent({
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,
}: {
@@ -3988,6 +4026,8 @@ export function AccessStepContent({
/** Reconnect changes credentials only; existing install reach stays intact. */
preserveAgentAccess?: boolean;
pending?: boolean;
hideFooter?: boolean;
bare?: boolean;
onBack: () => void;
onContinue: () => void;
}) {
@@ -4032,8 +4072,8 @@ export function AccessStepContent({
const agentAccessLabel = githubIdentity ? agentAccessHeading : "Which agents can use this connection?";
return (
<div className="mx-auto max-w-2xl">
<div className="overflow-hidden rounded-xl border border-border">
<div className={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>
@@ -4207,7 +4247,7 @@ export function AccessStepContent({
{/* Mobile stacks actions full-width with the primary action first in
reading order; desktop keeps Back on the left. */}
<div className="mt-6 flex flex-col-reverse gap-2 sm:flex-row sm:items-center sm:justify-between">
{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>
@@ -4220,7 +4260,93 @@ export function AccessStepContent({
{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 Advanced 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,
sentence,
notice,
disabled = false,
...accessProps
}: Omit<Parameters<typeof AccessStepContent>[0], "agents" | "agentsLoading" | "submitLabel" | "onBack" | "onContinue" | "pending" | "continuesToProvider" | "hideFooter" | "bare"> & {
companyId: string;
sentence: string;
/** Shown when the resolved default genuinely cannot apply. Never a step. */
notice?: string | null;
disabled?: boolean;
}) {
const [open, setOpen] = useState(false);
return (
<div 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>
<Button
type="button"
variant="link"
className="h-auto p-0 text-xs font-semibold underline underline-offset-2"
aria-expanded={open}
disabled={disabled}
onClick={() => setOpen((previous) => !previous)}
>
Change
</Button>
</div>
{notice ? <p className="mt-2 text-xs text-muted-foreground">{notice}</p> : null}
<Collapsible open={open} onOpenChange={setOpen}>
<CollapsibleTrigger className="mt-1 inline-flex items-center gap-1 text-xs font-semibold text-muted-foreground hover:text-foreground">
{open ? <ChevronDown className="h-3.5 w-3.5" aria-hidden="true" /> : <ChevronRight className="h-3.5 w-3.5" aria-hidden="true" />}
Advanced
</CollapsibleTrigger>
<CollapsibleContent>
<div className="mt-3">
<AccessStep {...accessProps} companyId={companyId} bare hideFooter submitLabel="" onBack={() => {}} onContinue={() => {}} />
</div>
</CollapsibleContent>
</Collapsible>
</div>
);
}
@@ -4283,6 +4409,7 @@ export function ConnectionSetupCompletionScreen({
logoUrl,
darkLogoUrl,
summary,
statedDefault,
onDone,
}: {
appName: string;
@@ -4290,6 +4417,11 @@ export function ConnectionSetupCompletionScreen({
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 (
@@ -4301,6 +4433,7 @@ export function ConnectionSetupCompletionScreen({
<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">
@@ -63,7 +63,7 @@ export function RemoteMcpConnectionSetup({ provider, state: s, actions: a, agent
<StepHeader headingRef={heading} appIdentity={{ name: provider.name, logoUrl: null }}
title={upstreamServiceName ? `Connect ${upstreamServiceName} through ${provider.name}` : s.step === "draft" ? "Continue your setup" : s.setupComplete ? s.step === "access" ? "Who can use this connection" : s.step === "connect" ? `Reconnect ${provider.name}` : provider.name : undefined}
subtitle={currentStep >= 0 && !s.setupComplete ? `Step ${currentStep + 1} of 2` : s.step === "draft" ? `Your ${provider.name} setup is ready to resume.` : s.step === "permissions" ? `Connected${s.identity ? ` as ${s.identity}` : ""} · ${s.tools.length} actions available` : `Manage this ${provider.name} connection.`}
step={currentStep >= 0 && !s.setupComplete ? "access" : "gallery"} activeIndex={currentStep} labels={["Access", "Connect"]} onCancel={busy || s.step === "management" || s.step === "permissions" || s.step === "draft" ? undefined : a.saveExit} />
step={currentStep >= 0 && !s.setupComplete ? "key" : "gallery"} activeIndex={currentStep} labels={["Access", "Connect"]} onCancel={busy || s.step === "management" || s.step === "permissions" || s.step === "draft" ? undefined : a.saveExit} />
<main className="space-y-6">
{upstreamServiceName && <InlineBanner compact>{provider.name} is an external service that handles the connection and requests to {upstreamServiceName}. After connecting, the agent will verify the app and guide you through any additional authorization.</InlineBanner>}
{s.notice && <p role="status" className="text-sm text-muted-foreground">{s.notice}</p>}
+114 -74
View File
@@ -171,11 +171,55 @@ function stepLabelsOnScreen(): string[] {
}
/**
* Advance past the Access step (PAP-17835), which now sits between picking a
* curated app and entering its credential. Picks "Any agent" so Continue is
* enabled without depending on the agent list.
* Open the Advanced disclosure that now holds the identity and agent-reach
* controls the Access step used to own (PAP-659 C0). Closed by default and
* unmounted while closed, so a test that asserts on those controls has to open
* it first — which is itself the assertion that they are one click away.
*/
async function openAccessAdvanced() {
const change = Array.from(document.body.querySelectorAll("button")).find(
(b) => b.textContent?.trim() === "Change" && b.getAttribute("aria-expanded") === "false",
);
if (!change) return;
await act(async () => {
change.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
await flushReact();
}
/**
* PAP-659 deleted the Access step: picking a connector now opens its connect
* screen directly, and identity/agent reach are a stated default with an
* Advanced disclosure rather than a screen to pass.
*
* What remains is the one-click handoff the Access step used to perform for
* automatic-OAuth definitions, which is now the primary action of the sign-in
* screen itself. Everywhere else this is a no-op, so the call sites keep
* reading as "get to the part this test is about".
*/
async function passAccessStep() {
const onOAuthEntryScreen = Boolean(
document.body.textContent?.includes("Paperclip will open")
|| document.body.textContent?.includes("Your connection is saved."),
);
if (onOAuthEntryScreen) {
const handoff = Array.from(document.body.querySelectorAll("button")).find((b) => {
const label = b.textContent?.trim() ?? "";
return label.startsWith("Continue to") || label.startsWith("Finish with");
});
await act(async () => {
handoff?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
await flushReact();
return;
}
// The remote-MCP aggregator setup (arcade, composio, executor, zapier) keeps
// its own Access step until PAP-659's no-auth pass replaces it. Recognise it
// by its own stepper rather than by any control the shared flow also has.
if (!stepLabelsOnScreen().includes("Access")) {
await flushReact();
return;
}
const anyAgent = Array.from(document.body.querySelectorAll('[role="radio"]'))
.find((option) => option.textContent?.includes("Any agent"));
if (anyAgent) {
@@ -184,11 +228,10 @@ async function passAccessStep() {
});
await flushReact();
}
const submit = Array.from(document.body.querySelectorAll("button")).find(
(b) => b.textContent?.trim() === "Save and continue"
|| b.textContent?.trim() === "Continue"
|| b.textContent?.trim().startsWith("Continue to"),
);
const submit = Array.from(document.body.querySelectorAll("button")).find((b) => {
const label = b.textContent?.trim() ?? "";
return label === "Save and continue" || label === "Continue";
});
await act(async () => {
submit?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
@@ -579,6 +622,7 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => {
buttonByText("Continue")?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
await flushReact();
await openAccessAdvanced();
expect(container.textContent).toContain("Which humans can use this credential?");
expect(container.textContent).toContain("Which agents can use this connection?");
@@ -678,12 +722,19 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => {
expect(container.textContent).not.toContain("Connect for tool access instead");
});
it("asks for a GitHub identity and defaults to the current user and every agent", async () => {
it("states the GitHub identity default and keeps its controls one click away", async () => {
mockParams.appKey = "github";
listGalleryMock.mockResolvedValue({ apps: [GITHUB_MANAGED] });
await render();
expect(container.textContent).toContain("Access");
// PAP-659 C0: the default is printed, not asked. Nothing about identity or
// reach blocks the primary action.
expect(container.textContent).toContain("Connects as you, available to all agents.");
expect(container.textContent).not.toContain("Connect GitHub as");
expect(container.querySelector('[role="radio"]')).toBeNull();
await openAccessAdvanced();
expect(container.textContent).toContain("Connect GitHub as");
expect(container.textContent).toContain("Which agents may use your GitHub when you’re responsible?");
expect(container.textContent).not.toContain("Choose access before adding credentials");
@@ -715,11 +766,12 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => {
expect(anyAgent?.getAttribute("aria-checked")).toBe("true");
});
it("keeps provider guidance and card styling out of the shared access step", async () => {
it("keeps provider guidance and card styling out of the access controls", async () => {
mockParams.appKey = "pagerduty";
listGalleryMock.mockResolvedValue({ apps: [PAGERDUTY] });
await render();
await openAccessAdvanced();
expect(container.textContent).toContain("Which humans can use this credential?");
expect(container.textContent).toContain("Which agents can use this connection?");
@@ -736,11 +788,14 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => {
expect(accessCard?.classList.contains("shadow-sm")).toBe(false);
});
it("defaults to every agent and only blocks an empty explicit selection", async () => {
it("defaults to every agent and states an empty explicit selection", async () => {
mockParams.appKey = "github";
await render();
await openAccessAdvanced();
expect(buttonByText("Save and continue")?.disabled).toBe(false);
// PAP-659 C0: narrowing reach never blocks Connect. An empty selection is
// reported in the stated default instead of disabling the primary action.
expect(container.textContent).toContain("available to all agents.");
await act(async () => {
Array.from(document.body.querySelectorAll('[role="radio"]'))
@@ -749,43 +804,28 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => {
});
await flushReact();
expect(buttonByText("Save and continue")?.disabled).toBe(true);
expect(container.textContent).toContain("no agents selected yet.");
});
it("keeps the access selections when the wizard moves backward", async () => {
it("reflects a changed access selection in the stated default", async () => {
mockParams.appKey = "github";
await render();
await openAccessAdvanced();
await act(async () => {
Array.from(document.body.querySelectorAll('[role="radio"]'))
.find((r) => r.textContent?.includes("My GitHub account"))
?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
await flushReact();
await act(async () => {
Array.from(document.body.querySelectorAll('[role="radio"]'))
.find((r) => r.textContent?.includes("Any agent"))
.find((r) => r.textContent?.includes("A dedicated account for an agent"))
?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
await flushReact();
await act(async () => {
buttonByText("Save and continue")?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
await flushReact();
expect(container.textContent).toContain("Connect GitHub");
// The sentence above the primary action is the only place this is said, so
// it has to track the controls rather than print a fixed default.
expect(container.textContent).toContain("Connects as a dedicated agent account");
await act(async () => {
buttonByText("Back")?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
await flushReact();
// Moving backward must not silently reset the identity the operator chose.
const radios = Array.from(document.body.querySelectorAll('[role="radio"]'));
expect(radios.find((r) => r.textContent?.includes("My GitHub account"))?.getAttribute("aria-checked"))
.toBe("true");
expect(radios.find((r) => r.textContent?.includes("Any agent"))?.getAttribute("aria-checked"))
.toBe("true");
expect(radios.find((r) => r.textContent?.includes("A dedicated account for an agent"))
?.getAttribute("aria-checked")).toBe("true");
});
it("uses Cancel to exit while the bottom Back button stays in the wizard", async () => {
@@ -824,6 +864,8 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => {
});
await flushReact();
await openAccessAdvanced();
const github = identityChoices();
expect(github.wholeOrg?.getAttribute("aria-checked")).toBe("true");
expect(github.justMe?.getAttribute("aria-checked")).toBe("false");
@@ -844,10 +886,6 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => {
?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
await flushReact();
await act(async () => {
buttonByText("Save and continue")?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
await flushReact();
const keyField = container.querySelector<HTMLInputElement>("input[type=password]");
await act(async () => setInputValue(keyField!, "posthog-personal-token"));
@@ -986,6 +1024,7 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => {
mockSearch.value = "source=asana";
await render();
await openAccessAdvanced();
expect(document.body.textContent).toContain("Which humans can use this credential?");
expect(document.body.textContent).toContain("Which agents can use this connection?");
@@ -1011,9 +1050,12 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => {
await render();
expect(document.body.textContent).toContain("Step 1 of 2");
expect(document.body.textContent).toContain("Access · Choose connection");
// PAP-659: a selected app is one screen, so there is no stepper and no
// Access stage to deep-link into; a legacy stage=access URL lands here.
expect(document.body.textContent).not.toContain("Step 1 of 2");
expect(document.body.textContent).not.toContain("Pick app ·");
expect(document.body.textContent).toContain("Connects for everyone in your organization");
await openAccessAdvanced();
expect(document.body.textContent).toContain("Which humans can use this credential?");
expect(document.body.textContent).toContain("Just me");
expect(mockNavigate).not.toHaveBeenCalledWith("/apps/connect", { replace: true });
@@ -1647,9 +1689,9 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => {
mockParams.appKey = "posthog";
listGalleryMock.mockResolvedValueOnce({ apps: [POSTHOG] });
await render();
// Access comes first for a curated app; the method chooser shares a screen
// with the credential fields, so it sits behind it (PAP-17835).
expect(container.textContent).toContain("Which humans can use this credential?");
// PAP-659: identity is stated, not asked, and the method chooser moved
// into the same Advanced disclosure.
expect(container.textContent).toContain("Connects for everyone in your organization");
await passAccessStep();
expect(container.textContent).toContain("How do you want to connect?");
@@ -1838,7 +1880,7 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => {
requestedAgentId="agent-1"
/>
));
expect(container.textContent).toContain("This task grants access only to Ada");
expect(container.textContent).toContain("available to the agent that asked for it");
const continueButton = Array.from(container.querySelectorAll("button")).find(
(button) => button.textContent?.trim() === "Continue",
);
@@ -1995,6 +2037,8 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => {
expect(connectAppMock).not.toHaveBeenCalled();
expect(navigateTopLevelMock).not.toHaveBeenCalled();
expect(container.textContent).toContain("Connects for everyone in your organization");
await openAccessAdvanced();
expect(container.textContent).toContain("Which humans can use this credential?");
expect(container.textContent).not.toContain("Choose access before sign-in");
const identityRadios = Array.from(document.body.querySelectorAll('[role="radio"]'));
@@ -2041,19 +2085,20 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => {
await render();
const stepsOnAccess = stepLabelsOnScreen();
expect(stepsOnAccess).toEqual(["Access", "Sign in"]);
// PAP-659: a curated connector is a single screen, so it shows no stepper
// at all. Connecting must not conjure one.
expect(stepLabelsOnScreen()).toEqual([]);
expect(stepDotCount()).toBe(0);
await passAccessStep();
await submitCuratedOAuthSetup();
expect(container.textContent).toContain("Preparing secure sign-in");
// Connecting must not grow the stepper a step the flow never lands on.
expect(stepLabelsOnScreen()).toEqual(stepsOnAccess);
expect(stepDotCount()).toBe(stepsOnAccess.length);
expect(stepLabelsOnScreen()).toEqual([]);
expect(stepDotCount()).toBe(0);
});
it("backs from the sign-in checkpoint to Access without exiting the wizard", async () => {
it("backs from the sign-in checkpoint out to the connector gallery", async () => {
mockSearch.value = "source=notion";
listGalleryMock.mockResolvedValueOnce({ apps: [NOTION] });
connectAppMock.mockReturnValueOnce(new Promise(() => {}));
@@ -2067,9 +2112,7 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => {
});
await flushReact();
expect(container.textContent).toContain("Which humans can use this credential?");
expect(mockNavigate).toHaveBeenCalledWith("/apps/connect?source=notion&stage=access");
expect(mockNavigate).not.toHaveBeenCalledWith("/apps");
expect(mockNavigate).toHaveBeenCalledWith("/apps");
});
it("resumes an existing Notion OAuth connection instead of creating another draft", async () => {
@@ -2103,7 +2146,7 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => {
await render();
expect(container.textContent).not.toContain("Reconnect keeps this identity type");
expect(container.textContent).toContain("Existing agent access stays the same");
expect(container.textContent).toContain("agent access stays as it is");
expect(startOAuthMock).not.toHaveBeenCalled();
await passAccessStep();
await submitCuratedOAuthSetup();
@@ -2269,9 +2312,9 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => {
await render();
expect(container.textContent).toContain(
credentialPolicy === "per_user" ? "Just me" : "Any human in the organization",
credentialPolicy === "per_user" ? "Connects as you" : "Connects for everyone in your organization",
);
expect(container.textContent).toContain("Existing agent access stays the same");
expect(container.textContent).toContain("agent access stays as it is");
await passAccessStep();
await submitCuratedOAuthSetup();
@@ -2529,7 +2572,7 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => {
expect(connectAppMock).not.toHaveBeenCalled();
expect(startOAuthMock).not.toHaveBeenCalled();
expect(container.textContent).not.toContain("Reconnect keeps this identity type");
expect(container.textContent).toContain("Existing agent access stays the same");
expect(container.textContent).toContain("agent access stays as it is");
await passAccessStep();
await submitCuratedOAuthSetup();
expect(startOAuthMock).toHaveBeenCalledWith("conn-refreshed", {
@@ -2575,7 +2618,7 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => {
expect(connectAppMock).not.toHaveBeenCalled();
expect(startOAuthMock).not.toHaveBeenCalled();
expect(container.textContent).not.toContain("Reconnect keeps this identity type");
expect(container.textContent).toContain("Existing agent access stays the same");
expect(container.textContent).toContain("agent access stays as it is");
await passAccessStep();
await submitCuratedOAuthSetup();
expect(startOAuthMock).toHaveBeenCalledWith("conn-after-retry", {
@@ -2708,6 +2751,7 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => {
await render();
expect(connectAppMock).not.toHaveBeenCalled();
await openAccessAdvanced();
expect(container.textContent).toContain("Which humans can use this credential?");
expect(mockNavigate).not.toHaveBeenCalledWith("/apps/connect", { replace: true });
});
@@ -2798,6 +2842,7 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => {
buttonByText("Continue")?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
await flushReact();
await openAccessAdvanced();
expect(container.textContent).toContain("Which humans can use this credential?");
expect(container.textContent).toContain("Which agents can use this connection?");
@@ -2941,6 +2986,7 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => {
mockSearch.value =
"link=https%3A%2F%2Fwww.example.com%2Factions&name=Bla&applicationId=app-77";
await render();
await openAccessAdvanced();
expect(container.textContent).toContain("Which humans can use this credential?");
await passAccessStep();
@@ -3051,21 +3097,15 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => {
);
});
it("steps back from the key step to Access, and from Access to Connectors", async () => {
it("steps back from the single connect screen straight to Connectors", async () => {
mockSearch.value = "";
mockParams.appKey = "github";
await render();
await passAccessStep();
expect(container.textContent).toContain("Connect GitHub");
// Back from the credential goes to Access, not all the way out: the
// selections made there have to survive (PAP-17835).
await act(async () => {
buttonByText("Back")?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
await flushReact();
expect(container.textContent).toContain("Connect GitHub as");
// PAP-659: there is no Access step to return to, so Back is a single hop
// out of the connector rather than a two-hop wizard retreat.
await act(async () => {
buttonByText("Back")?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
@@ -3510,10 +3550,10 @@ describe("AppsConnect — guided generic MCP flow (PAP-17087)", () => {
});
// The curated flow has its own regression test for this. The generic flow is
// a separate three-step model with a separate override, so it needs its own —
// a separate two-step model with a separate override, so it needs its own —
// otherwise the waiting screen could quietly drop back to the curated
// two-step labels here and nothing would catch it.
it("keeps the generic three-step model when a pasted endpoint hands off to sign-in", async () => {
// single-step label here and nothing would catch it.
it("keeps the generic two-step model when a pasted endpoint hands off to sign-in", async () => {
connectAppMock.mockResolvedValue({
connectionId: "conn-1",
application: { id: "app-1", name: "mcp.example.test" },
@@ -3526,7 +3566,7 @@ describe("AppsConnect — guided generic MCP flow (PAP-17087)", () => {
await gotoLinkFrame(container, "https://mcp.example.test/mcp");
const stepsBeforeHandoff = stepLabelsOnScreen();
expect(stepsBeforeHandoff).toEqual(["Pick app", "Access", "Add your key"]);
expect(stepsBeforeHandoff).toEqual(["Pick app", "Add your key"]);
await act(async () => {
buttonByText("Check link")?.dispatchEvent(new MouseEvent("click", { bubbles: true }));