mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-24 01:41:42 +02:00
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:
1 parent
53aad90b9e
commit
c2205a88b4
4 files changed
+400
-228
No files matched your search
Generated
+4
-5
@@ -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>}
|
||||
|
||||
@@ -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 }));
|
||||
|
||||
Reference in new issue
Block a user