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, + ]); +}