diff --git a/packages/react/src/components/add-account-modal.tsx b/packages/react/src/components/add-account-modal.tsx index a79eafe56..32bc14770 100644 --- a/packages/react/src/components/add-account-modal.tsx +++ b/packages/react/src/components/add-account-modal.tsx @@ -936,7 +936,12 @@ interface AddAccountModalProps { * which cancels a dangling server OAuth session. That is why abandoning an * OAuth popup can't wedge a later open: the stuck flow died with its instance. * The parent owns only open/route intent (deep links, the reconnect handoff). */ -export function AddAccountModal(props: AddAccountModalProps) { +export const hasDcr = (method: AuthMethod | undefined | null): boolean => { + if (!method || method.kind !== "oauth") return false; + return method.oauth?.supportsDynamicRegistration === true || method.oauth?.discoveryUrl != null; +}; + +export const AddAccountModal = (props: AddAccountModalProps) => { return props.open ? : null; } @@ -1485,10 +1490,7 @@ function AddAccountModalView(props: AddAccountModalProps) { // DCR-capable: the integration advertises dynamic registration (MCP oauth2), // OR carries a discovery URL we can probe at connect time. When DCR-capable // and not yet fallen back, we skip the app picker entirely (Option A). - const isDcr = - !cimdActive && - isOAuth && - (method?.oauth?.supportsDynamicRegistration === true || method?.oauth?.discoveryUrl != null); + const isDcr = !cimdActive && hasDcr(method); const dcrActive = isDcr && !dcrFailed; const automaticOAuthActive = cimdActive || dcrActive; @@ -1699,6 +1701,19 @@ function AddAccountModalView(props: AddAccountModalProps) { oauthReconnectOpenedKey.current = handoff.key; setMethodId(oauthMethod.id); + + if (hasDcr(oauthMethod)) { + void executeDcrConnect({ + method: oauthMethod, + connectionName: ConnectionName.make(connectionName), + identityLabel: handoff.identityLabel, + dcrOwner: connectionOwner, + isReconnect: true, + handoffKey: handoff.key, + }); + return; + } + void oauthPopup.start({ payload: { client: OAuthClientSlug.make(client), @@ -1728,6 +1743,7 @@ function AddAccountModalView(props: AddAccountModalProps) { close(); }, }); + // eslint-disable-next-line react-hooks/exhaustive-deps }, [initialState, allMethods, integration, oauthPopup, close]); const probeAndAutoNameOAuthConnection = async ( @@ -2128,19 +2144,19 @@ function AddAccountModalView(props: AddAccountModalProps) { } }; - // Transparent DCR connect: probe → register → start, no app picker. On any - // failure (probe error, no registration endpoint, or registration failure) we - // flip `dcrFailed` so the bring-your-own-app picker renders as the recovery - // path with name/owner kept. - const handleDcrConnect = async () => { - const discoveryUrl = method?.oauth?.discoveryUrl ?? method?.oauth?.tokenUrl; - if (!method || !discoveryUrl) { - setDcrFailed(true); + const executeDcrConnect = async (args: { + readonly method: AuthMethod; + readonly connectionName: string; + readonly identityLabel: string | undefined; + readonly dcrOwner: Owner; + readonly isReconnect: boolean; + readonly handoffKey?: string; + }) => { + const discoveryUrl = args.method.oauth?.discoveryUrl ?? args.method.oauth?.tokenUrl; + if (!discoveryUrl) { + if (!args.isReconnect) setDcrFailed(true); return; } - const dcrOwner = owner; - const connectionName = previewConnectionName(label, dcrOwner); - const identityLabel = typedIdentityLabel(label); setDcrBusy(true); const outcome = await runDcrConnect( { @@ -2150,22 +2166,22 @@ function AddAccountModalView(props: AddAccountModalProps) { return exit.value; }, register: async ( - args: DcrRegisterArgs, + rArgs: DcrRegisterArgs, ): Promise => { const exit = await doRegisterDynamic({ payload: { - owner: args.owner, - slug: args.slug, - issuer: args.issuer ?? null, - registrationEndpoint: args.registrationEndpoint, - authorizationUrl: args.authorizationUrl, - tokenUrl: args.tokenUrl, - resource: args.resource ?? null, - scopes: args.scopes, - tokenEndpointAuthMethodsSupported: args.tokenEndpointAuthMethodsSupported, - clientName: args.clientName, - redirectUri: args.redirectUri, - originIntegration: args.originIntegration, + owner: rArgs.owner, + slug: rArgs.slug, + issuer: rArgs.issuer ?? null, + registrationEndpoint: rArgs.registrationEndpoint, + authorizationUrl: rArgs.authorizationUrl, + tokenUrl: rArgs.tokenUrl, + resource: rArgs.resource ?? null, + scopes: rArgs.scopes, + tokenEndpointAuthMethodsSupported: rArgs.tokenEndpointAuthMethodsSupported, + clientName: rArgs.clientName, + redirectUri: rArgs.redirectUri, + originIntegration: rArgs.originIntegration, }, reactivityKeys: oauthClientWriteKeys, }); @@ -2176,60 +2192,117 @@ function AddAccountModalView(props: AddAccountModalProps) { } return exit.value.client; }, - start: (args: DcrStartArgs): void => { - void oauthPopup.start({ - payload: { - client: args.client, - // DCR registers the client under the connection owner, so the app - // and connection share one owner. - clientOwner: args.owner, - owner: args.owner, - name: connectionName, - integration, - template: method.template, - newConnection: true, - ...(identityLabel !== undefined ? { identityLabel } : {}), - }, - onSuccess: async (connection: OAuthCompletionPayload) => { - await probeAndAutoNameOAuthConnection(connection, label); - toast.success("Connection added"); - close(); - }, - }); + start: (sArgs: DcrStartArgs): void => { + if (args.isReconnect) { + void oauthPopup.start({ + payload: { + client: sArgs.client, + clientOwner: sArgs.owner, + owner: args.dcrOwner, + name: args.connectionName, + integration, + template: args.method.template, + ...(args.identityLabel !== undefined ? { identityLabel: args.identityLabel } : {}), + }, + onAuthorizationStarted: () => { + trackEvent("connection_reconnected", { + integration_slug: String(integration), + owner: args.dcrOwner, + success: true, + }); + }, + onError: () => { + trackEvent("connection_reconnected", { + integration_slug: String(integration), + owner: args.dcrOwner, + success: false, + }); + }, + onSuccess: () => { + toast.success("Reconnected"); + close(); + }, + }); + } else { + void oauthPopup.start({ + payload: { + client: sArgs.client, + clientOwner: sArgs.owner, + owner: args.dcrOwner, + name: args.connectionName, + integration, + template: args.method.template, + newConnection: true, + ...(args.identityLabel !== undefined ? { identityLabel: args.identityLabel } : {}), + }, + onSuccess: async (connection: OAuthCompletionPayload) => { + await probeAndAutoNameOAuthConnection(connection, label); + toast.success("Connection added"); + close(); + }, + }); + } }, }, { + owner: args.dcrOwner, + integrationName, + authorizationUrl: args.method.oauth?.authorizationUrl, + tokenUrl: args.method.oauth?.tokenUrl, discoveryUrl, - // Only a genuine discovery URL (MCP) seeds the RFC 8707 resource - // indicator; the token-endpoint fallback baked into `discoveryUrl` must - // not, so pass the un-collapsed method value here. - resourceFallback: method.oauth?.discoveryUrl, - owner: dcrOwner, - // DCR slugs are server-keyed (Part A): the connect path no longer depends - // on the picker's app list, so it need not be threaded here. - declaredScopes: method.oauth?.scopes, + resourceFallback: args.method.oauth?.discoveryUrl, + declaredScopes: args.method.oauth?.scopes, redirectUri: oauthCallbackUrl(), integration, }, ); setDcrBusy(false); - trackEvent("connection_oauth_started", { - integration_slug: String(integration), - owner: dcrOwner, - flow: "dcr", - success: outcome.kind === "started", - ...(outcome.kind === "fallback" ? { dcr_fallback: true } : {}), - }); - if (outcome.kind === "fallback") { - setOAuthFallbackProbe("probe" in outcome ? outcome.probe : null); - setDcrFailed(true); - // Surface the server's actionable rejection reason on the recovery view as - // an inline error card. Generic fallbacks (no message) fall through to the - // "register an app" empty state, which already guides the user. - setDcrFallbackMessage("message" in outcome ? (outcome.message ?? null) : null); + + if (args.isReconnect) { + if (outcome.kind === "fallback" || outcome.kind === "failed") { + if (args.handoffKey) { + oauthReconnectOpenedKey.current = null; + } + toast.error( + outcome.kind === "fallback" && "message" in outcome && outcome.message + ? outcome.message + : "Reconnect failed: automatic setup unavailable", + ); + } + } else { + trackEvent("connection_oauth_started", { + integration_slug: String(integration), + owner: args.dcrOwner, + flow: "dcr", + success: outcome.kind === "started", + ...(outcome.kind === "fallback" ? { dcr_fallback: true } : {}), + }); + if (outcome.kind === "fallback") { + setOAuthFallbackProbe("probe" in outcome ? outcome.probe : null); + setDcrFailed(true); + setDcrFallbackMessage("message" in outcome ? (outcome.message ?? null) : null); + } else if (outcome.kind === "failed") { + setDcrFailed(true); + toast.error("Automatic setup failed"); + } } }; + // Transparent DCR connect: probe → register → start, no app picker. On any + // failure (probe error, no registration endpoint, or registration failure) we + // flip `dcrFailed` so the bring-your-own-app picker renders as the recovery + // path with name/owner kept. + const handleDcrConnect = async () => { + if (!method) return; + await executeDcrConnect({ + method, + connectionName: previewConnectionName(label, owner), + identityLabel: typedIdentityLabel(label), + dcrOwner: owner, + isReconnect: false, + }); + }; + return ( // Non-modal for the same reason as the health-check editor sheet: a modal // dialog's react-remove-scroll locks the wheel to the dialog subtree, so