From 08fd04ebf2bdc9f911b6c30460d5114746c06a0c Mon Sep 17 00:00:00 2001 From: Jaehyun Lim Date: Fri, 11 Sep 2026 18:07:19 -0700 Subject: [PATCH] [ENG-3723] feat: opt-in resubscribe after reauthentication Adds a `resubscribeOnReAuth` prop to InstallIntegration. When set, reauthenticating an existing connection also restarts subscribe setup for the installation if it never finished -- the case where credentials were invalid at install time, so registration failed and no events ever flowed. Off by default, so nothing changes for builders who do not opt in. The prop is threaded through the installation context, where the reauthentication components read it. All four reauthentication components -- OAuth, API key, basic auth, and client credentials -- converge on one success handler, so this is a single hook called from each, after the credentials are persisted. The consumer sees nothing. Reauthentication looks exactly as it does today and the reconnect happens behind it. The call is fire-and-forget: it must never block the reauthentication or make a successful credential update look like a failure. It also no-ops without an installation in context, since the update-connection UI can render inside ConnectProvider. Blocked on amp-labs/openapi#406 for the generated client method; server side is amp-labs/server#7297. Co-Authored-By: Claude Opus 5 --- .../Configure/InstallIntegration.tsx | 8 ++++ .../updateConnection/UpdateApiKeyConnect.tsx | 5 ++ .../UpdateBasicAuthConnect.tsx | 5 ++ .../UpdateClientCredentialsConnect.tsx | 5 ++ .../updateConnection/UpdateOauthConnect.tsx | 5 ++ .../InstallIntegrationContextProvider.tsx | 7 +++ .../useResubscribeInstallationMutation.ts | 28 +++++++++++ src/hooks/useResubscribeOnReauth.ts | 48 +++++++++++++++++++ 8 files changed, 111 insertions(+) create mode 100644 src/hooks/mutation/useResubscribeInstallationMutation.ts create mode 100644 src/hooks/useResubscribeOnReauth.ts diff --git a/src/components/Configure/InstallIntegration.tsx b/src/components/Configure/InstallIntegration.tsx index 36b2cd11b..e560d7936 100644 --- a/src/components/Configure/InstallIntegration.tsx +++ b/src/components/Configure/InstallIntegration.tsx @@ -59,6 +59,12 @@ export interface InstallIntegrationProps { * @experimental */ fieldMapping?: FieldMapping; + /** + * When true, reauthenticating an existing connection also restarts subscribe setup for the + * installation if it never finished. Off by default. The consumer sees no difference; the + * reconnect happens behind the reauthentication flow. + */ + resubscribeOnReAuth?: boolean; onInstallSuccess?: (installationId: string, config: Config) => void; onUpdateSuccess?: (installationId: string, config: Config) => void; onUninstallSuccess?: (installationId: string) => void; @@ -86,6 +92,7 @@ const InstallIntegrationContent = ({ onUpdateSuccess, onUninstallSuccess, fieldMapping, + resubscribeOnReAuth, variant, }: InstallIntegrationInternalProps) => { const { installation, isPending: isInstallationPending } = useInstallation(); @@ -173,6 +180,7 @@ const InstallIntegrationContent = ({ onUpdateSuccess={onUpdateSuccess} onUninstallSuccess={onUninstallSuccess} fieldMapping={fieldMapping} + resubscribeOnReAuth={resubscribeOnReAuth} resetComponent={reset} > diff --git a/src/components/Configure/content/manage/updateConnection/UpdateApiKeyConnect.tsx b/src/components/Configure/content/manage/updateConnection/UpdateApiKeyConnect.tsx index c784341a0..4078f1914 100644 --- a/src/components/Configure/content/manage/updateConnection/UpdateApiKeyConnect.tsx +++ b/src/components/Configure/content/manage/updateConnection/UpdateApiKeyConnect.tsx @@ -7,6 +7,7 @@ import { useAmpersandProviderProps } from "src/context/AmpersandContextProvider" import { useConnections } from "src/context/ConnectionsContextProvider"; import { useUpdateConnectionMutation } from "src/hooks/mutation/useUpdateConnectionMutation"; import { useProvider } from "src/hooks/useProvider"; +import { useResubscribeOnReauth } from "src/hooks/useResubscribeOnReauth"; import { handleServerError } from "src/utils/handleServerError"; import { FieldHeader } from "../../fields/FieldHeader"; @@ -32,9 +33,13 @@ export function UpdateApiKeyConnect({ provider }: { provider?: string }) { const resetSuccessConnect = () => setSuccessConnect(false); + const startResubscribe = useResubscribeOnReauth(); + const handleSuccessConnect = () => { setSuccessConnect(true); setError(null); + // Opt-in only; no-ops unless the builder set resubscribeOnReAuth. + startResubscribe(); }; const error = updateError?.message || localError || null; diff --git a/src/components/Configure/content/manage/updateConnection/UpdateBasicAuthConnect.tsx b/src/components/Configure/content/manage/updateConnection/UpdateBasicAuthConnect.tsx index b8737d0d0..6b5596862 100644 --- a/src/components/Configure/content/manage/updateConnection/UpdateBasicAuthConnect.tsx +++ b/src/components/Configure/content/manage/updateConnection/UpdateBasicAuthConnect.tsx @@ -7,6 +7,7 @@ import { useAmpersandProviderProps } from "src/context/AmpersandContextProvider" import { useConnections } from "src/context/ConnectionsContextProvider"; import { useUpdateConnectionMutation } from "src/hooks/mutation/useUpdateConnectionMutation"; import { useProvider } from "src/hooks/useProvider"; +import { useResubscribeOnReauth } from "src/hooks/useResubscribeOnReauth"; import { handleServerError } from "src/utils/handleServerError"; import { FieldHeader } from "../../fields/FieldHeader"; @@ -32,9 +33,13 @@ export function UpdateBasicAuthConnect({ provider }: { provider?: string }) { const resetSuccessConnect = () => setSuccessConnect(false); + const startResubscribe = useResubscribeOnReauth(); + const handleSuccessConnect = () => { setSuccessConnect(true); setError(null); + // Opt-in only; no-ops unless the builder set resubscribeOnReAuth. + startResubscribe(); }; const error = updateError?.message || localError || null; diff --git a/src/components/Configure/content/manage/updateConnection/UpdateClientCredentialsConnect.tsx b/src/components/Configure/content/manage/updateConnection/UpdateClientCredentialsConnect.tsx index 258f3f8a3..f21835efe 100644 --- a/src/components/Configure/content/manage/updateConnection/UpdateClientCredentialsConnect.tsx +++ b/src/components/Configure/content/manage/updateConnection/UpdateClientCredentialsConnect.tsx @@ -7,6 +7,7 @@ import { useAmpersandProviderProps } from "src/context/AmpersandContextProvider" import { useConnections } from "src/context/ConnectionsContextProvider"; import { useUpdateConnectionMutation } from "src/hooks/mutation/useUpdateConnectionMutation"; import { useProvider } from "src/hooks/useProvider"; +import { useResubscribeOnReauth } from "src/hooks/useResubscribeOnReauth"; import { handleServerError } from "src/utils/handleServerError"; import { FieldHeader } from "../../fields/FieldHeader"; @@ -37,9 +38,13 @@ export function UpdateClientCredentialsConnect({ setSuccessConnect(false); }; + const startResubscribe = useResubscribeOnReauth(); + const handleSuccessConnect = () => { setSuccessConnect(true); setError(null); + // Opt-in only; no-ops unless the builder set resubscribeOnReAuth. + startResubscribe(); }; const explicitScopesRequired = diff --git a/src/components/Configure/content/manage/updateConnection/UpdateOauthConnect.tsx b/src/components/Configure/content/manage/updateConnection/UpdateOauthConnect.tsx index c1d4ca617..56eb45f84 100644 --- a/src/components/Configure/content/manage/updateConnection/UpdateOauthConnect.tsx +++ b/src/components/Configure/content/manage/updateConnection/UpdateOauthConnect.tsx @@ -6,6 +6,7 @@ import { Button } from "src/components/ui-base/Button"; import { useConnections } from "src/context/ConnectionsContextProvider"; import { useUpdateOauthConnectMutation } from "src/hooks/mutation/useUpdateOauthConnectMutation"; import { useProvider } from "src/hooks/useProvider"; +import { useResubscribeOnReauth } from "src/hooks/useResubscribeOnReauth"; import { handleServerError } from "src/utils/handleServerError"; import { FieldHeader } from "../../fields/FieldHeader"; @@ -63,10 +64,14 @@ export function UpdateOauthConnect({ provider }: { provider?: string }) { setSuccessConnect(false); }; + const startResubscribe = useResubscribeOnReauth(); + const handleSuccessConnect = () => { setSuccessConnect(true); setError(null); setUrl(null); + // Opt-in only; no-ops unless the builder set resubscribeOnReAuth. + startResubscribe(); }; const error = updateOauthConnectError?.message || localError || null; diff --git a/src/context/InstallIIntegrationContextProvider/InstallIntegrationContextProvider.tsx b/src/context/InstallIIntegrationContextProvider/InstallIntegrationContextProvider.tsx index 173ff263d..77a6d94f0 100644 --- a/src/context/InstallIIntegrationContextProvider/InstallIntegrationContextProvider.tsx +++ b/src/context/InstallIIntegrationContextProvider/InstallIntegrationContextProvider.tsx @@ -39,6 +39,8 @@ interface InstallIntegrationContextValue { isIntegrationDeleted: boolean; setIntegrationDeleted: () => void; fieldMapping?: FieldMapping; + /** Builder opt-in: restart subscribe setup after a successful reauthentication. */ + resubscribeOnReAuth: boolean; resetComponent: () => void; } // Create a context to pass down the props @@ -59,6 +61,7 @@ export const InstallIntegrationContext = onUninstallSuccess: undefined, isIntegrationDeleted: false, setIntegrationDeleted: () => {}, + resubscribeOnReAuth: false, resetComponent: () => {}, }); @@ -84,6 +87,7 @@ interface InstallIntegrationProviderProps { onUpdateSuccess?: (installationId: string, config: Config) => void; onUninstallSuccess?: (installationId: string) => void; fieldMapping?: FieldMapping; + resubscribeOnReAuth?: boolean; resetComponent: () => void; } @@ -99,6 +103,7 @@ export function InstallIntegrationProvider({ onUpdateSuccess, onUninstallSuccess, fieldMapping, + resubscribeOnReAuth = false, resetComponent, }: InstallIntegrationProviderProps) { const { data: integrations } = useListIntegrationsQuery(); @@ -194,6 +199,7 @@ export function InstallIntegrationProvider({ isIntegrationDeleted, setIntegrationDeleted, fieldMapping, + resubscribeOnReAuth, resetComponent, }), [ @@ -211,6 +217,7 @@ export function InstallIntegrationProvider({ isIntegrationDeleted, setIntegrationDeleted, fieldMapping, + resubscribeOnReAuth, resetComponent, ], ); diff --git a/src/hooks/mutation/useResubscribeInstallationMutation.ts b/src/hooks/mutation/useResubscribeInstallationMutation.ts new file mode 100644 index 000000000..612eb7190 --- /dev/null +++ b/src/hooks/mutation/useResubscribeInstallationMutation.ts @@ -0,0 +1,28 @@ +import { useMutation } from "@tanstack/react-query"; +import { useAPI } from "services/api"; + +export interface ResubscribeInstallationRequest { + projectIdOrName: string; + integrationId: string; + installationId: string; +} + +/** + * Asks the server to restart subscribe setup for an installation. + * + * The server decides whether anything needs doing and returns 200 either way, so this is safe to + * call without inspecting subscription state first. The work runs asynchronously; there is nothing + * to await beyond the request being accepted. + */ +export const useResubscribeInstallationMutation = () => { + const getAPI = useAPI(); + + return useMutation({ + mutationKey: ["resubscribeInstallation"], + mutationFn: async (request: ResubscribeInstallationRequest) => { + const api = await getAPI(); + + return api.installationApi.resubscribeInstallation(request); + }, + }); +}; diff --git a/src/hooks/useResubscribeOnReauth.ts b/src/hooks/useResubscribeOnReauth.ts new file mode 100644 index 000000000..317a39883 --- /dev/null +++ b/src/hooks/useResubscribeOnReauth.ts @@ -0,0 +1,48 @@ +import { useCallback } from "react"; +import { useInstallIntegrationProps } from "context/InstallIIntegrationContextProvider/InstallIntegrationContextProvider"; +import { useProjectQuery } from "src/hooks/query"; + +import { useResubscribeInstallationMutation } from "./mutation/useResubscribeInstallationMutation"; + +/** + * Returns a function to call once a reauthentication has persisted new credentials, which restarts + * subscribe setup for the installation. + * + * Reconnecting is opt-in: it does nothing unless the builder passed `resubscribeOnReAuth` to + * `InstallIntegration`. It also does nothing without an installation in context, since the + * update-connection UI can render inside `ConnectProvider`, where there is no installation to + * reconnect. + * + * The call is deliberately fire-and-forget and invisible to the consumer. The credential update has + * already succeeded by this point, so a failure here must not be surfaced as a failed + * reauthentication; builders see failures through the operations API and the subscribe error + * notification instead. + */ +export function useResubscribeOnReauth() { + const { resubscribeOnReAuth, integrationId, installation } = + useInstallIntegrationProps(); + const { projectIdOrName } = useProjectQuery(); + const { mutate } = useResubscribeInstallationMutation(); + + const installationId = installation?.id; + + return useCallback(() => { + if (!resubscribeOnReAuth) return; + if (!projectIdOrName || !integrationId || !installationId) return; + + mutate( + { projectIdOrName, integrationId, installationId }, + { + onError: (error) => { + console.error("Failed to start subscription reconnect", error); + }, + }, + ); + }, [ + resubscribeOnReAuth, + projectIdOrName, + integrationId, + installationId, + mutate, + ]); +}