diff --git a/packages/plugins/google-discovery/src/api/handlers.ts b/packages/plugins/google-discovery/src/api/handlers.ts index 4a53f1c9af..f846ace4b2 100644 --- a/packages/plugins/google-discovery/src/api/handlers.ts +++ b/packages/plugins/google-discovery/src/api/handlers.ts @@ -60,7 +60,7 @@ const popupDocument = (payload: OAuthPopupResult): string => {

${escapeHtml(message)}

`; }; diff --git a/packages/plugins/google-discovery/src/react/AddGoogleDiscoverySource.tsx b/packages/plugins/google-discovery/src/react/AddGoogleDiscoverySource.tsx index 8c6bef7a3e..efbb29805c 100644 --- a/packages/plugins/google-discovery/src/react/AddGoogleDiscoverySource.tsx +++ b/packages/plugins/google-discovery/src/react/AddGoogleDiscoverySource.tsx @@ -363,50 +363,56 @@ type OAuthPopupResult = error: string; }; +const OAUTH_RESULT_CHANNEL = "executor:google-discovery-oauth-result"; + +const isOAuthPopupResult = (value: unknown): value is OAuthPopupResult => + typeof value === "object" && + value !== null && + (value as { type?: unknown }).type === "executor:oauth-result"; + function openOAuthPopup( url: string, onResult: (data: OAuthPopupResult) => void, - onClosed?: () => void, -): void { + onOpenFailed?: () => void, +): () => void { const w = 640; const h = 760; const left = window.screenX + (window.outerWidth - w) / 2; const top = window.screenY + (window.outerHeight - h) / 2; - const popup = window.open( - url, - "google-discovery-oauth", - `width=${w},height=${h},left=${left},top=${top},popup=1`, - ); let settled = false; + const channel = typeof BroadcastChannel !== "undefined" + ? new BroadcastChannel(OAUTH_RESULT_CHANNEL) + : null; const settle = () => { + if (settled) return; settled = true; window.removeEventListener("message", onMessage); + channel?.close(); + }; + + const handleResult = (data: unknown) => { + if (!isOAuthPopupResult(data) || settled) return; + settle(); + onResult(data); }; const onMessage = (event: MessageEvent) => { - if ( - event.origin === window.location.origin && - event.data?.type === "executor:oauth-result" && - !settled - ) { - settle(); - onResult(event.data as OAuthPopupResult); - } + if (event.origin === window.location.origin) handleResult(event.data); }; window.addEventListener("message", onMessage); + if (channel) channel.onmessage = (event) => handleResult(event.data); - if (popup) { - const interval = window.setInterval(() => { - if (popup.closed) { - window.clearInterval(interval); - if (!settled) { - settle(); - onClosed?.(); - } - } - }, 500); + const popup = window.open( + url, + "google-discovery-oauth", + `width=${w},height=${h},left=${left},top=${top},popup=1`, + ); + if (!popup && !settled) { + settle(); + queueMicrotask(() => onOpenFailed?.()); } + return settle; } export default function AddGoogleDiscoverySource(props: { @@ -502,8 +508,12 @@ export default function AddGoogleDiscoverySource(props: { } }, []); // eslint-disable-line react-hooks/exhaustive-deps + const oauthCleanup = useRef<(() => void) | null>(null); + const handleStartOAuth = useCallback(async () => { if (!probe) return; + oauthCleanup.current?.(); + oauthCleanup.current = null; setStartingOAuth(true); setError(null); try { @@ -519,9 +529,10 @@ export default function AddGoogleDiscoverySource(props: { }, }); - openOAuthPopup( + oauthCleanup.current = openOAuthPopup( response.authorizationUrl, (result) => { + oauthCleanup.current = null; setStartingOAuth(false); if (result.ok) { setOauthAuth({ @@ -541,14 +552,22 @@ export default function AddGoogleDiscoverySource(props: { } }, () => { + oauthCleanup.current = null; setStartingOAuth(false); + setError("OAuth popup was blocked"); }, ); } catch (e) { setStartingOAuth(false); setError(e instanceof Error ? e.message : "Failed to start OAuth"); } - }, [probe, doStartOAuth, name, discoveryUrl, clientId, clientSecretSecretId]); + }, [probe, doStartOAuth, scopeId, name, discoveryUrl, clientId, clientSecretSecretId]); + + const handleCancelOAuth = useCallback(() => { + oauthCleanup.current?.(); + oauthCleanup.current = null; + setStartingOAuth(false); + }, []); const handleAdd = useCallback(async () => { if (!probe) return; @@ -755,17 +774,29 @@ export default function AddGoogleDiscoverySource(props: { )} - +
+ + {startingOAuth && ( + + )} +
diff --git a/packages/plugins/mcp/src/api/handlers.ts b/packages/plugins/mcp/src/api/handlers.ts index 14131635b8..7e86cf5f25 100644 --- a/packages/plugins/mcp/src/api/handlers.ts +++ b/packages/plugins/mcp/src/api/handlers.ts @@ -73,7 +73,7 @@ const popupDocument = (payload: OAuthPopupResult): string => {

${escapeHtml(message)}

`; }; diff --git a/packages/plugins/mcp/src/react/AddMcpSource.tsx b/packages/plugins/mcp/src/react/AddMcpSource.tsx index ba61debfdf..92e3d2b09f 100644 --- a/packages/plugins/mcp/src/react/AddMcpSource.tsx +++ b/packages/plugins/mcp/src/react/AddMcpSource.tsx @@ -137,32 +137,51 @@ type OAuthPopupResult = | { type: "executor:oauth-result"; ok: true; sessionId: string } & OAuthTokens | { type: "executor:oauth-result"; ok: false; sessionId: null; error: string }; +const OAUTH_RESULT_CHANNEL = "executor:mcp-oauth-result"; + +const isOAuthPopupResult = (value: unknown): value is OAuthPopupResult => + typeof value === "object" && + value !== null && + (value as { type?: unknown }).type === "executor:oauth-result"; + function openOAuthPopup( url: string, onResult: (data: OAuthPopupResult) => void, - onClosed?: () => void, -): void { + onOpenFailed?: () => void, +): () => void { const w = 600, h = 700; const left = window.screenX + (window.outerWidth - w) / 2; const top = window.screenY + (window.outerHeight - h) / 2; - const popup = window.open(url, "mcp-oauth", `width=${w},height=${h},left=${left},top=${top},popup=1`); let settled = false; - const settle = () => { settled = true; window.removeEventListener("message", onMsg); }; + const channel = typeof BroadcastChannel !== "undefined" + ? new BroadcastChannel(OAUTH_RESULT_CHANNEL) + : null; + const settle = () => { + if (settled) return; + settled = true; + window.removeEventListener("message", onMsg); + channel?.close(); + }; + + const handleResult = (data: unknown) => { + if (!isOAuthPopupResult(data) || settled) return; + settle(); + onResult(data); + }; const onMsg = (e: MessageEvent) => { - if (e.origin === window.location.origin && e.data?.type === "executor:oauth-result" && !settled) { - settle(); - onResult(e.data as OAuthPopupResult); - } + if (e.origin === window.location.origin) handleResult(e.data); }; window.addEventListener("message", onMsg); + if (channel) channel.onmessage = (e) => handleResult(e.data); - if (popup) { - const iv = setInterval(() => { - if (popup.closed) { clearInterval(iv); if (!settled) { settle(); onClosed?.(); } } - }, 500); + const popup = window.open(url, "mcp-oauth", `width=${w},height=${h},left=${left},top=${top},popup=1`); + if (!popup && !settled) { + settle(); + queueMicrotask(() => onOpenFailed?.()); } + return settle; } // --------------------------------------------------------------------------- @@ -245,7 +264,11 @@ export default function AddMcpSource(props: { } }, []); // eslint-disable-line react-hooks/exhaustive-deps + const oauthCleanup = useRef<(() => void) | null>(null); + const handleOAuth = useCallback(async () => { + oauthCleanup.current?.(); + oauthCleanup.current = null; dispatch({ type: "oauth-start" }); try { const redirectUrl = `${window.location.origin}/api/mcp/oauth/callback`; @@ -254,9 +277,10 @@ export default function AddMcpSource(props: { payload: { endpoint: state.url.trim(), redirectUrl }, }); dispatch({ type: "oauth-waiting", sessionId: result.sessionId }); - openOAuthPopup( + oauthCleanup.current = openOAuthPopup( result.authorizationUrl, (data) => { + oauthCleanup.current = null; if (data.ok) { dispatch({ type: "oauth-ok", @@ -272,12 +296,21 @@ export default function AddMcpSource(props: { dispatch({ type: "oauth-fail", error: data.error }); } }, - () => dispatch({ type: "oauth-cancelled" }), + () => { + oauthCleanup.current = null; + dispatch({ type: "oauth-fail", error: "OAuth popup was blocked" }); + }, ); } catch (e) { dispatch({ type: "oauth-fail", error: e instanceof Error ? e.message : "Failed to start OAuth" }); } - }, [state.url, doStartOAuth]); + }, [state.url, scopeId, doStartOAuth]); + + const handleCancelOAuth = useCallback(() => { + oauthCleanup.current?.(); + oauthCleanup.current = null; + dispatch({ type: "oauth-cancelled" }); + }, []); const handleAddRemote = useCallback(async () => { if (!probe) return; @@ -474,6 +507,14 @@ export default function AddMcpSource(props: {
Waiting for authorization in popup… +
)}