diff --git a/dev/nextjs/app/HubWrapper.tsx b/dev/nextjs/app/HubWrapper.tsx index 5c3d537..fe61f89 100644 --- a/dev/nextjs/app/HubWrapper.tsx +++ b/dev/nextjs/app/HubWrapper.tsx @@ -16,6 +16,9 @@ export default function HubWrapper({ initialToken, apiUrl, appUrl }: HubWrapperP return (
+

+ Environment — api: {apiUrl} · app: {appUrl} +

+
+ Environment — api: {apiUrl} · app: {appUrl} +
Paste a connect session token (the /connect_sessions endpoint is CORS-protected on dev and production so it cannot be automatically created diff --git a/dev/vite/main.tsx b/dev/vite/main.tsx index 99bc68a..769c9d6 100644 --- a/dev/vite/main.tsx +++ b/dev/vite/main.tsx @@ -100,6 +100,9 @@ const HubWrapper: React.FC = () => {

Current mode: {mode || 'No mode selected'}

{isCorsProtected && ( <> +

+ Environment — api: {apiUrl} · app: {appUrl} +

Paste a connect session token (the /connect_sessions endpoint is CORS-protected on dev and production so it cannot be automatically created diff --git a/src/modules/integration-picker/IntegrationPicker.tsx b/src/modules/integration-picker/IntegrationPicker.tsx index 0f4a945..1336275 100644 --- a/src/modules/integration-picker/IntegrationPicker.tsx +++ b/src/modules/integration-picker/IntegrationPicker.tsx @@ -1,4 +1,4 @@ -import { Card } from '@stackone/malachite'; +import { Alert, Card } from '@stackone/malachite'; import { useCallback, useMemo, useState } from 'react'; import { IntegrationPickerContent } from './components/IntegrationPickerContent'; import { IntegrationPickerTitle } from './components/IntegrationPickerTitle'; @@ -144,6 +144,9 @@ export const IntegrationPicker: React.FC = ({ } } > + {connectionState.timedOut && ( + + )} { @@ -52,10 +49,10 @@ interface UseIntegrationPickerProps { export enum EventType { AccountConnected = 'AccountConnected', - CloseModal = 'CloseModal', - CloseOAuth2 = 'CloseOAuth2', } +const POLL_TIMEOUT_MS = 5 * 60 * 1000; + export const useIntegrationPicker = ({ token, baseUrl, @@ -72,16 +69,14 @@ export const useIntegrationPicker = ({ setFormData(data); }, []); const connectWindow = useRef(null); - const checkStateTimeoutRef = useRef(null); - const oauthChannelRef = useRef(null); - const storageListenerRef = useRef<((event: StorageEvent) => void) | null>(null); const connectionAttemptIdRef = useRef(null); const pollingIntervalRef = useRef(null); + const popupWatcherRef = useRef(null); const coopDetectedRef = useRef(false); - const oauthResolvedRef = useRef(false); const [connectionState, setConnectionState] = useState<{ loading: boolean; success: boolean; + timedOut?: boolean; error?: { message: string; provider_response: string; @@ -106,157 +101,31 @@ export const useIntegrationPicker = ({ [onSuccess], ); - const allowedOrigins = useMemo(() => { - const origins = new Set(); - if (typeof window !== 'undefined') { - origins.add(window.location.origin); - } - if (dashboardUrl) { - try { - origins.add(new URL(dashboardUrl).origin); - } catch { - // ignore invalid URL - } - } - return origins; - }, [dashboardUrl]); - const debugRef = useRef(debug); debugRef.current = debug; - const processMessageCallback = useCallback( - (event: MessageEvent) => { - if (!allowedOrigins.has(event.origin)) { - if (debugRef.current) { - console.debug('[hub] postMessage ignored: untrusted origin', event.origin); - } - return; - } - - if (!event.data?.type) { - return; - } - - if (debugRef.current) { - console.debug('[hub] OAuth result received via postMessage', event.data); - } - - if (event.data.type === EventType.AccountConnected) { - handleSuccess({ id: event.data.account.id, provider: event.data.account.provider }); - parent.postMessage(event.data, '*'); - } else if (event.data.type === EventType.CloseOAuth2) { - if (event.data.error) { - setConnectionState({ - loading: false, - success: false, - error: { - message: event.data.error, - provider_response: event.data.errorDescription || 'No description', - }, - }); - } else { - setConnectionState({ loading: false, success: false, error: undefined }); - } - } - - if (connectWindow.current) { - connectWindow.current.close(); - connectWindow.current = null; - } - - window.removeEventListener('message', processMessageCallback, false); - }, - [handleSuccess, allowedOrigins], - ); + const stopPopupWatcher = useCallback(() => { + if (popupWatcherRef.current !== null) { + clearTimeout(popupWatcherRef.current); + popupWatcherRef.current = null; + } + }, []); - const teardownOAuth = useCallback(() => { + const closeOAuthPopup = useCallback(() => { stopPolling(); + stopPopupWatcher(); if (connectWindow.current) { connectWindow.current.close(); connectWindow.current = null; } - window.removeEventListener('message', processMessageCallback, false); - }, [stopPolling, processMessageCallback]); - - const handleOAuthResultFromAnyChannel = useCallback( - (data: { type: string; error?: string; errorDescription?: string; account?: unknown }) => { - oauthResolvedRef.current = true; - teardownOAuth(); - - if (data.type === EventType.AccountConnected) { - handleSuccess({ - id: (data.account as { id: string; provider: string }).id, - provider: (data.account as { id: string; provider: string }).provider, - }); - parent.postMessage(data, '*'); - } else if (data.type === EventType.CloseOAuth2) { - if (data.error) { - setConnectionState({ - loading: false, - success: false, - error: { - message: data.error, - provider_response: data.errorDescription || 'No description', - }, - }); - } else { - setConnectionState({ loading: false, success: false, error: undefined }); - } - } - }, - [handleSuccess, teardownOAuth], - ); + }, [stopPolling, stopPopupWatcher]); useEffect(() => { - if (typeof BroadcastChannel !== 'undefined') { - oauthChannelRef.current = new BroadcastChannel(OAUTH_CHANNEL_NAME); - oauthChannelRef.current.onmessage = (event) => { - if (event.data?.type) { - if (debugRef.current) { - console.debug( - '[hub] OAuth result received via BroadcastChannel', - event.data, - ); - } - handleOAuthResultFromAnyChannel(event.data); - } - }; - } - - const storageListener = (event: StorageEvent) => { - if (event.key !== OAUTH_STORAGE_KEY || !event.newValue) { - return; - } - try { - const data = JSON.parse(event.newValue); - if (debugRef.current) { - console.debug('[hub] OAuth result received via localStorage', data); - } - handleOAuthResultFromAnyChannel(data); - localStorage.removeItem(OAUTH_STORAGE_KEY); - } catch (error) { - console.error('Failed to parse OAuth result from localStorage:', error); - } - }; - - storageListenerRef.current = storageListener; - window.addEventListener('storage', storageListener, false); - return () => { - if (checkStateTimeoutRef.current !== null) { - clearTimeout(checkStateTimeoutRef.current); - } - if (oauthChannelRef.current) { - oauthChannelRef.current.close(); - oauthChannelRef.current = null; - } - if (storageListenerRef.current) { - window.removeEventListener('storage', storageListenerRef.current, false); - storageListenerRef.current = null; - } stopPolling(); + stopPopupWatcher(); }; - }, [handleOAuthResultFromAnyChannel, stopPolling]); + }, [stopPolling, stopPopupWatcher]); const { data: accountData, @@ -536,13 +405,34 @@ export const useIntegrationPicker = ({ const startPolling = useCallback( (attemptId: string, provider: string) => { + const startedAt = Date.now(); + const poll = async () => { if (!pollingIntervalRef.current) return; + if (Date.now() - startedAt > POLL_TIMEOUT_MS) { + if (debugRef.current) { + console.debug('[hub] poll timed out', { attemptId }); + } + closeOAuthPopup(); + cancelConnectionAttempt(baseUrl, token, attemptId).catch((error) => { + if (debugRef.current) { + console.debug('[hub] cancelConnectionAttempt failed', { + attemptId, + error, + }); + } + }); + setConnectionState({ loading: false, success: false, timedOut: true }); + return; + } + const result = await pollConnectionAttempt(baseUrl, token, attemptId).catch( () => null, ); + if (!pollingIntervalRef.current) return; + if (!result) { if (debugRef.current) { console.debug('[hub] poll failed (network error), retrying'); @@ -556,8 +446,7 @@ export const useIntegrationPicker = ({ } if (result.status === 'authenticated' && result.account) { - oauthResolvedRef.current = true; - teardownOAuth(); + closeOAuthPopup(); handleSuccess({ id: result.account.id, provider }); parent.postMessage( { @@ -567,8 +456,7 @@ export const useIntegrationPicker = ({ '*', ); } else if (result.status === 'error') { - oauthResolvedRef.current = true; - teardownOAuth(); + closeOAuthPopup(); setConnectionState({ loading: false, success: false, @@ -578,7 +466,7 @@ export const useIntegrationPicker = ({ }, }); } else if (result.status === 'cancelled' || result.status === 'expired') { - teardownOAuth(); + closeOAuthPopup(); setConnectionState({ loading: false, success: false }); } else { pollingIntervalRef.current = window.setTimeout(poll, 2000); @@ -587,48 +475,92 @@ export const useIntegrationPicker = ({ pollingIntervalRef.current = window.setTimeout(poll, 2000); }, - [baseUrl, token, teardownOAuth, handleSuccess], + [baseUrl, token, closeOAuthPopup, handleSuccess], ); - const startPopupWatcher = useCallback(() => { - const check = () => { - if (!connectWindow.current?.closed) { - if (connectWindow.current) { - checkStateTimeoutRef.current = window.setTimeout(check, 1000); + const startPopupWatcher = useCallback( + (provider: string) => { + let firstTick = true; + const check = async () => { + if (firstTick) { + firstTick = false; + if (!connectWindow.current || connectWindow.current.closed) { + if (debugRef.current) { + console.debug( + '[hub] popup reads as closed on first tick, assuming COOP isolation — popup-close detection disabled', + ); + } + coopDetectedRef.current = true; + popupWatcherRef.current = null; + return; + } } - return; - } - if (debugRef.current) { - console.debug('[hub] OAuth popup closed', { - resolved: oauthResolvedRef.current, - pollingActive: !!pollingIntervalRef.current, - coopDetected: coopDetectedRef.current, - }); - } - connectWindow.current = null; - if (checkStateTimeoutRef.current !== null) { - clearTimeout(checkStateTimeoutRef.current); - checkStateTimeoutRef.current = null; - } - if (oauthResolvedRef.current) return; + if (connectWindow.current && !connectWindow.current.closed) { + popupWatcherRef.current = window.setTimeout(check, 1000); + return; + } - window.removeEventListener('message', processMessageCallback, false); + popupWatcherRef.current = null; - if (pollingIntervalRef.current) return; + if (!pollingIntervalRef.current) return; + if (coopDetectedRef.current) return; - const connectionAttemptId = connectionAttemptIdRef.current; - teardownOAuth(); - if (connectionAttemptId) { - void cancelConnectionAttempt(baseUrl, token, connectionAttemptId); - } - if (debugRef.current) { - console.debug('[hub] popup closed, resetting state'); - } - setConnectionState({ loading: false, success: false }); - }; - checkStateTimeoutRef.current = window.setTimeout(check, 1000); - }, [processMessageCallback, teardownOAuth, baseUrl, token]); + const attemptId = connectionAttemptIdRef.current; + if (!attemptId) { + closeOAuthPopup(); + setConnectionState({ loading: false, success: false }); + return; + } + + const final = await pollConnectionAttempt(baseUrl, token, attemptId).catch( + (error) => { + if (debugRef.current) { + console.debug('[hub] final poll failed after popup closed', { + attemptId, + error, + }); + } + return null; + }, + ); + + if (!pollingIntervalRef.current) return; + + if (final?.status === 'authenticated' && final.account) { + if (debugRef.current) { + console.debug('[hub] popup closed but auth completed', { attemptId }); + } + closeOAuthPopup(); + handleSuccess({ id: final.account.id, provider }); + parent.postMessage( + { + type: EventType.AccountConnected, + account: { id: final.account.id, provider }, + }, + '*', + ); + return; + } + + if (debugRef.current) { + console.debug('[hub] popup closed by user, cancelling attempt', { attemptId }); + } + closeOAuthPopup(); + cancelConnectionAttempt(baseUrl, token, attemptId).catch((error) => { + if (debugRef.current) { + console.debug('[hub] cancelConnectionAttempt failed', { + attemptId, + error, + }); + } + }); + setConnectionState({ loading: false, success: false }); + }; + popupWatcherRef.current = window.setTimeout(check, 1000); + }, + [baseUrl, token, closeOAuthPopup, handleSuccess], + ); const handleConnect = useCallback(async () => { if (!selectedIntegration) { @@ -683,31 +615,36 @@ export const useIntegrationPicker = ({ } if (shouldRedirectForOAuth) { - oauthResolvedRef.current = false; - const attemptResult = await createConnectionAttempt(baseUrl, token).catch( () => null, ); const attemptId = attemptResult?.id ?? null; connectionAttemptIdRef.current = attemptId; - if (debugRef.current) { - if (attemptId) { - console.debug('[hub] connection attempt created', { attemptId }); - } else { - console.debug('[hub] connection attempt creation failed, polling disabled'); + if (!attemptId) { + if (debugRef.current) { + console.debug('[hub] connection attempt creation failed'); } + setConnectionState({ + loading: false, + success: false, + error: { + message: 'Failed to start OAuth flow', + provider_response: + 'Could not create a connection attempt. Please try again.', + }, + }); + return; } - window.addEventListener('message', processMessageCallback, false); + if (debugRef.current) { + console.debug('[hub] connection attempt created', { attemptId }); + } const callbackEmbeddedAccountsUrl = encodeURIComponent( `${dashboardUrl}/embedded/accounts/callback`, ); - let windowUrl = `${baseUrl}/connect/oauth2/${selectedIntegration.integration_id}?redirect_uri=${callbackEmbeddedAccountsUrl}&token=${token}`; - if (attemptId) { - windowUrl += `&connection_attempt_id=${attemptId}`; - } + let windowUrl = `${baseUrl}/connect/oauth2/${selectedIntegration.integration_id}?redirect_uri=${callbackEmbeddedAccountsUrl}&token=${token}&connection_attempt_id=${attemptId}`; Object.keys(cleanedFormData).forEach((key) => { windowUrl += `&${key}=${encodeURIComponent(cleanedFormData[key])}`; }); @@ -736,10 +673,15 @@ export const useIntegrationPicker = ({ if (debugRef.current) { console.debug('[hub] popup was blocked by browser'); } - teardownOAuth(); - if (attemptId) { - void cancelConnectionAttempt(baseUrl, token, attemptId); - } + closeOAuthPopup(); + cancelConnectionAttempt(baseUrl, token, attemptId).catch((error) => { + if (debugRef.current) { + console.debug('[hub] cancelConnectionAttempt failed', { + attemptId, + error, + }); + } + }); setConnectionState({ loading: false, success: false, @@ -752,18 +694,13 @@ export const useIntegrationPicker = ({ return; } - coopDetectedRef.current = connectWindow.current.closed === true; - if (debugRef.current && coopDetectedRef.current) { - console.debug('[hub] COOP detected: popup appears closed immediately'); - } + coopDetectedRef.current = false; if (typeof connectWindow.current.focus === 'function') { connectWindow.current.focus(); } - if (attemptId) { - startPolling(attemptId, selectedIntegration.provider); - } - startPopupWatcher(); + startPolling(attemptId, selectedIntegration.provider); + startPopupWatcher(selectedIntegration.provider); return; } @@ -839,8 +776,7 @@ export const useIntegrationPicker = ({ fields, accountId, authConfig, - processMessageCallback, - teardownOAuth, + closeOAuthPopup, startPolling, startPopupWatcher, isFormValid, @@ -851,12 +787,16 @@ export const useIntegrationPicker = ({ if (debugRef.current) { console.debug('[hub] OAuth cancelled by user', { attemptId }); } - teardownOAuth(); + closeOAuthPopup(); setConnectionState({ loading: false, success: false }); if (attemptId) { - void cancelConnectionAttempt(baseUrl, token, attemptId); + cancelConnectionAttempt(baseUrl, token, attemptId).catch((error) => { + if (debugRef.current) { + console.debug('[hub] cancelConnectionAttempt failed', { attemptId, error }); + } + }); } - }, [baseUrl, token, teardownOAuth]); + }, [baseUrl, token, closeOAuthPopup]); const isLoading = isLoadingHubData || isLoadingConnectorData || isLoadingAccountData; const hasError = !!(errorHubData || errorConnectorData || errorAccountData);