diff --git a/.changeset/tiny-donuts-travel.md b/.changeset/tiny-donuts-travel.md new file mode 100644 index 000000000..c1685a04c --- /dev/null +++ b/.changeset/tiny-donuts-travel.md @@ -0,0 +1,6 @@ +--- +"@knocklabs/react-core": patch +"@knocklabs/react": patch +--- + +fix: poll to check if OAuth connection succeeded in case popup communication fails during Slack and Microsoft Teams auth flows diff --git a/packages/react-core/src/index.ts b/packages/react-core/src/index.ts index 134994ac8..722918029 100644 --- a/packages/react-core/src/index.ts +++ b/packages/react-core/src/index.ts @@ -8,10 +8,14 @@ export { renderNodeOrFallback, slackProviderKey, toSentenceCase, + type AuthCheckResult, type ColorMode, + type ConnectionStatus, type KnockProviderProps, type KnockProviderState, useAuthenticatedKnockClient, + useAuthPolling, + useAuthPostMessageListener, useKnockClient, useStableOptions, } from "./modules/core"; diff --git a/packages/react-core/src/modules/core/hooks/index.ts b/packages/react-core/src/modules/core/hooks/index.ts index 5e5803537..4c2ce7d9b 100644 --- a/packages/react-core/src/modules/core/hooks/index.ts +++ b/packages/react-core/src/modules/core/hooks/index.ts @@ -1,2 +1,4 @@ export { default as useAuthenticatedKnockClient } from "./useAuthenticatedKnockClient"; export { default as useStableOptions } from "./useStableOptions"; +export { useAuthPostMessageListener } from "./useAuthPostMessageListener"; +export { useAuthPolling } from "./useAuthPolling"; diff --git a/packages/react-core/src/modules/core/hooks/useAuthPolling.ts b/packages/react-core/src/modules/core/hooks/useAuthPolling.ts new file mode 100644 index 000000000..c4862e623 --- /dev/null +++ b/packages/react-core/src/modules/core/hooks/useAuthPolling.ts @@ -0,0 +1,115 @@ +import { useEffect } from "react"; + +import { AuthCheckResult, ConnectionStatus } from "../types"; + +export interface UseAuthPollingOptions { + popupWindowRef: React.MutableRefObject; + setConnectionStatus: (status: ConnectionStatus) => void; + authCheckFn: () => Promise; + onAuthenticationComplete?: (authenticationResp: string) => void; +} + +/** + * Hook that polls an authentication check endpoint until success or timeout. + * + * Polls every 2 seconds for up to 3 minutes (90 iterations). Has three stop conditions: + * 1. Max timeout reached → sets error status + * 2. Popup closed + 10s grace period → stops silently + * 3. Success detected via authCheckFn → updates status and closes popup + * + * @param options - Configuration options for the polling mechanism + * + * @example + * ```tsx + * useAuthPolling({ + * popupWindowRef, + * setConnectionStatus, + * onAuthenticationComplete, + * authCheckFn: useCallback(async () => { + * return knock.slack.authCheck({ + * tenant: tenantId, + * knockChannelId: knockSlackChannelId, + * }); + * }, [knock.slack, tenantId, knockSlackChannelId]), + * }); + * ``` + */ +export function useAuthPolling(options: UseAuthPollingOptions): void { + const { + popupWindowRef, + setConnectionStatus, + onAuthenticationComplete, + authCheckFn, + } = options; + + useEffect( + () => { + let pollCount = 0; + const maxPolls = 90; + let popupClosedAt: number | null = null; + let isActive = true; + + const pollInterval = setInterval(async () => { + if (!isActive) { + clearInterval(pollInterval); + return; + } + + const popupWindow = popupWindowRef.current; + if (!popupWindow) { + return; + } + + pollCount++; + + const isPopupClosed = popupWindow.closed; + if (isPopupClosed && !popupClosedAt) { + popupClosedAt = Date.now(); + } + + // Stop condition 1: Max timeout reached + if (pollCount >= maxPolls) { + clearInterval(pollInterval); + setConnectionStatus("error"); + return; + } + + // Stop condition 2: Popup closed + grace period expired + if (popupClosedAt && Date.now() - popupClosedAt > 10000) { + clearInterval(pollInterval); + popupWindowRef.current = null; + return; + } + + try { + const authRes = await authCheckFn(); + + // Stop condition 3: Success detected + if (authRes.connection?.ok) { + clearInterval(pollInterval); + setConnectionStatus("connected"); + if (onAuthenticationComplete) { + onAuthenticationComplete("authComplete"); + } + if (popupWindow && !popupWindow.closed) { + popupWindow.close(); + } + popupWindowRef.current = null; + } + } catch (_error) { + // Continue polling on error + } + }, 2000); + + return () => { + isActive = false; + clearInterval(pollInterval); + }; + }, + // eslint-disable-next-line react-hooks/exhaustive-deps + [ + // Empty deps - run once on mount and keep polling + // This is intentionally simple/brute force + ], + ); +} diff --git a/packages/react-core/src/modules/core/hooks/useAuthPostMessageListener.ts b/packages/react-core/src/modules/core/hooks/useAuthPostMessageListener.ts new file mode 100644 index 000000000..40451af5d --- /dev/null +++ b/packages/react-core/src/modules/core/hooks/useAuthPostMessageListener.ts @@ -0,0 +1,70 @@ +import { useEffect } from "react"; + +import { ConnectionStatus } from "../types"; + +export interface UseAuthPostMessageListenerOptions { + knockHost: string; + popupWindowRef: React.MutableRefObject; + setConnectionStatus: (status: ConnectionStatus) => void; + onAuthenticationComplete?: (authenticationResp: string) => void; +} + +/** + * Hook that listens for postMessage events from OAuth popup windows. + * + * Handles "authComplete" and "authFailed" messages sent from the OAuth flow popup, + * validates the message origin, updates connection status, and closes the popup. + * + * @param options - Configuration options for the postMessage listener + * + * @example + * ```tsx + * useAuthPostMessageListener({ + * knockHost: knock.host, + * popupWindowRef, + * setConnectionStatus, + * onAuthenticationComplete, + * }); + * ``` + */ +export function useAuthPostMessageListener( + options: UseAuthPostMessageListenerOptions, +): void { + const { + knockHost, + popupWindowRef, + setConnectionStatus, + onAuthenticationComplete, + } = options; + + useEffect(() => { + const receiveMessage = (event: MessageEvent) => { + // Validate message origin for security + if (event.origin !== knockHost) { + return; + } + + if (event.data === "authComplete") { + setConnectionStatus("connected"); + onAuthenticationComplete?.(event.data); + // Clear popup ref so polling stops and doesn't trigger callback again + if (popupWindowRef.current && !popupWindowRef.current.closed) { + popupWindowRef.current.close(); + } + popupWindowRef.current = null; + } else if (event.data === "authFailed") { + setConnectionStatus("error"); + onAuthenticationComplete?.(event.data); + popupWindowRef.current = null; + } + }; + + window.addEventListener("message", receiveMessage, false); + return () => window.removeEventListener("message", receiveMessage); + }, [ + knockHost, + onAuthenticationComplete, + setConnectionStatus, + popupWindowRef, + ]); +} diff --git a/packages/react-core/src/modules/core/index.ts b/packages/react-core/src/modules/core/index.ts index 13fae233c..bc6d02d81 100644 --- a/packages/react-core/src/modules/core/index.ts +++ b/packages/react-core/src/modules/core/index.ts @@ -4,7 +4,12 @@ export { type KnockProviderProps, type KnockProviderState, } from "./context"; -export { useAuthenticatedKnockClient, useStableOptions } from "./hooks"; +export { + useAuthenticatedKnockClient, + useStableOptions, + useAuthPostMessageListener, + useAuthPolling, +} from "./hooks"; export { FilterStatus, type ColorMode } from "./constants"; export { formatBadgeCount, @@ -15,3 +20,4 @@ export { slackProviderKey, msTeamsProviderKey, } from "./utils"; +export { type ConnectionStatus, type AuthCheckResult } from "./types"; diff --git a/packages/react-core/src/modules/core/types.ts b/packages/react-core/src/modules/core/types.ts new file mode 100644 index 000000000..fd0e3b8d5 --- /dev/null +++ b/packages/react-core/src/modules/core/types.ts @@ -0,0 +1,25 @@ +/** + * Represents the connection status for OAuth-based integrations (Slack, MS Teams, etc.) + */ +export type ConnectionStatus = + | "connecting" + | "connected" + | "disconnected" + | "error" + | "disconnecting"; + +/** + * Result returned by authentication check API calls + */ +export interface AuthCheckResult { + connection?: { + ok?: boolean; + error?: string; + }; + code?: string; + response?: { + data?: { + message?: string; + }; + }; +} diff --git a/packages/react-core/src/modules/ms-teams/context/KnockMsTeamsProvider.tsx b/packages/react-core/src/modules/ms-teams/context/KnockMsTeamsProvider.tsx index bce03b7ff..3c9e6e573 100644 --- a/packages/react-core/src/modules/ms-teams/context/KnockMsTeamsProvider.tsx +++ b/packages/react-core/src/modules/ms-teams/context/KnockMsTeamsProvider.tsx @@ -1,10 +1,9 @@ import * as React from "react"; -import { PropsWithChildren } from "react"; +import { PropsWithChildren, useRef } from "react"; -import { useKnockClient } from "../../core"; +import { type ConnectionStatus, useKnockClient } from "../../core"; import { msTeamsProviderKey } from "../../core/utils"; import { useMsTeamsConnectionStatus } from "../hooks"; -import { ConnectionStatus } from "../hooks/useMsTeamsConnectionStatus"; export interface KnockMsTeamsProviderState { knockMsTeamsChannelId: string; @@ -15,6 +14,7 @@ export interface KnockMsTeamsProviderState { setErrorLabel: (label: string) => void; actionLabel: string | null; setActionLabel: (label: string | null) => void; + popupWindowRef: React.MutableRefObject; } const MsTeamsProviderStateContext = @@ -29,6 +29,7 @@ export const KnockMsTeamsProvider: React.FC< PropsWithChildren > = ({ knockMsTeamsChannelId, tenantId, children }) => { const knock = useKnockClient(); + const popupWindowRef = useRef(null); const { connectionStatus, @@ -56,6 +57,7 @@ export const KnockMsTeamsProvider: React.FC< setActionLabel, knockMsTeamsChannelId, tenantId, + popupWindowRef, }} > {children} diff --git a/packages/react-core/src/modules/ms-teams/hooks/useMsTeamsConnectionStatus.ts b/packages/react-core/src/modules/ms-teams/hooks/useMsTeamsConnectionStatus.ts index 644d96ad2..9c51295bc 100644 --- a/packages/react-core/src/modules/ms-teams/hooks/useMsTeamsConnectionStatus.ts +++ b/packages/react-core/src/modules/ms-teams/hooks/useMsTeamsConnectionStatus.ts @@ -1,15 +1,9 @@ import Knock from "@knocklabs/client"; import { useEffect, useState } from "react"; +import { type ConnectionStatus } from "../../core/types"; import { useTranslations } from "../../i18n"; -export type ConnectionStatus = - | "connecting" - | "connected" - | "disconnected" - | "error" - | "disconnecting"; - type UseMsTeamsConnectionStatusOutput = { connectionStatus: ConnectionStatus; setConnectionStatus: (status: ConnectionStatus) => void; diff --git a/packages/react-core/src/modules/slack/context/KnockSlackProvider.tsx b/packages/react-core/src/modules/slack/context/KnockSlackProvider.tsx index 0b44b95cc..1d3152f98 100644 --- a/packages/react-core/src/modules/slack/context/KnockSlackProvider.tsx +++ b/packages/react-core/src/modules/slack/context/KnockSlackProvider.tsx @@ -1,10 +1,9 @@ import { useSlackConnectionStatus } from ".."; import * as React from "react"; -import { PropsWithChildren } from "react"; +import { PropsWithChildren, useRef } from "react"; -import { slackProviderKey } from "../../core"; +import { type ConnectionStatus, slackProviderKey } from "../../core"; import { useKnockClient } from "../../core"; -import { ConnectionStatus } from "../hooks/useSlackConnectionStatus"; export interface KnockSlackProviderState { knockSlackChannelId: string; @@ -19,6 +18,7 @@ export interface KnockSlackProviderState { setErrorLabel: (label: string) => void; actionLabel: string | null; setActionLabel: (label: string | null) => void; + popupWindowRef: React.MutableRefObject; } const SlackProviderStateContext = @@ -44,6 +44,7 @@ export const KnockSlackProvider: React.FC< const tenantId = "tenantId" in props ? props.tenantId : props.tenant; const knock = useKnockClient(); + const popupWindowRef = useRef(null); const { connectionStatus, @@ -73,6 +74,7 @@ export const KnockSlackProvider: React.FC< // Assign the same value to both tenant and tenantId for backwards compatibility tenant: tenantId, tenantId, + popupWindowRef, }} > {children} diff --git a/packages/react-core/src/modules/slack/hooks/useSlackConnectionStatus.ts b/packages/react-core/src/modules/slack/hooks/useSlackConnectionStatus.ts index 6b7050a42..b374fdf23 100644 --- a/packages/react-core/src/modules/slack/hooks/useSlackConnectionStatus.ts +++ b/packages/react-core/src/modules/slack/hooks/useSlackConnectionStatus.ts @@ -1,15 +1,9 @@ import Knock from "@knocklabs/client"; import { useEffect, useState } from "react"; +import { type ConnectionStatus } from "../../core/types"; import { useTranslations } from "../../i18n"; -export type ConnectionStatus = - | "connecting" - | "connected" - | "disconnected" - | "error" - | "disconnecting"; - type UseSlackConnectionStatusOutput = { connectionStatus: ConnectionStatus; setConnectionStatus: (status: ConnectionStatus) => void; diff --git a/packages/react-core/test/core/hooks/useAuthPolling.test.ts b/packages/react-core/test/core/hooks/useAuthPolling.test.ts new file mode 100644 index 000000000..e4ee03609 --- /dev/null +++ b/packages/react-core/test/core/hooks/useAuthPolling.test.ts @@ -0,0 +1,216 @@ +import { renderHook } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { useAuthPolling } from "../../../src/modules/core/hooks/useAuthPolling"; +import type { + AuthCheckResult, + ConnectionStatus, +} from "../../../src/modules/core/types"; + +describe("useAuthPolling", () => { + let popupWindowRef: { current: Window | null }; + let setConnectionStatus: ReturnType>; + let onAuthenticationComplete: + | ReturnType> + | undefined; + let authCheckFn: ReturnType>>; + let mockPopup: Window; + + beforeEach(() => { + vi.clearAllMocks(); + vi.useFakeTimers(); + + // Create a mock popup window + mockPopup = { + close: vi.fn(), + closed: false, + } as unknown as Window; + + popupWindowRef = { current: mockPopup }; + setConnectionStatus = vi.fn(); + onAuthenticationComplete = vi.fn(); + authCheckFn = vi.fn(() => Promise.resolve({ connection: { ok: false } })); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it("should poll authCheckFn every 2 seconds", async () => { + renderHook(() => + useAuthPolling({ + popupWindowRef, + setConnectionStatus, + onAuthenticationComplete, + authCheckFn, + }), + ); + + // Fast-forward 2 seconds + await vi.advanceTimersByTimeAsync(2000); + expect(authCheckFn).toHaveBeenCalledTimes(1); + + // Fast-forward another 2 seconds + await vi.advanceTimersByTimeAsync(2000); + expect(authCheckFn).toHaveBeenCalledTimes(2); + + // Fast-forward another 2 seconds + await vi.advanceTimersByTimeAsync(2000); + expect(authCheckFn).toHaveBeenCalledTimes(3); + }); + + it("should stop polling and set connected status on success", async () => { + authCheckFn = vi.fn(() => Promise.resolve({ connection: { ok: true } })); + + renderHook(() => + useAuthPolling({ + popupWindowRef, + setConnectionStatus, + onAuthenticationComplete, + authCheckFn, + }), + ); + + await vi.advanceTimersByTimeAsync(2000); + + expect(setConnectionStatus).toHaveBeenCalledWith("connected"); + expect(onAuthenticationComplete).toHaveBeenCalledWith("authComplete"); + expect(mockPopup.close).toHaveBeenCalled(); + expect(popupWindowRef.current).toBeNull(); + + // Should not poll again after success + const callCount = authCheckFn.mock.calls.length; + await vi.advanceTimersByTimeAsync(2000); + expect(authCheckFn).toHaveBeenCalledTimes(callCount); + }); + + it("should set error status after max polls (90 iterations)", async () => { + renderHook(() => + useAuthPolling({ + popupWindowRef, + setConnectionStatus, + onAuthenticationComplete, + authCheckFn, + }), + ); + + // Fast-forward 90 iterations (180 seconds) + await vi.advanceTimersByTimeAsync(90 * 2000); + + expect(setConnectionStatus).toHaveBeenCalledWith("error"); + }); + + it("should stop polling after popup closed + 10s grace period", async () => { + renderHook(() => + useAuthPolling({ + popupWindowRef, + setConnectionStatus, + onAuthenticationComplete, + authCheckFn, + }), + ); + + // Poll once + await vi.advanceTimersByTimeAsync(2000); + const initialCallCount = authCheckFn.mock.calls.length; + + // Close the popup (starts grace period timer) + mockPopup.closed = true; + + // Advance past the grace period (10+ seconds) + await vi.advanceTimersByTimeAsync(12000); + + // Should not call setConnectionStatus with error (stops silently) + expect(setConnectionStatus).not.toHaveBeenCalledWith("error"); + expect(setConnectionStatus).not.toHaveBeenCalledWith("connected"); + + // Should still have polled during grace period + expect(authCheckFn.mock.calls.length).toBeGreaterThan(initialCallCount); + + // Should not poll again after grace period + some buffer + const callCountAfterGracePeriod = authCheckFn.mock.calls.length; + await vi.advanceTimersByTimeAsync(4000); + expect(authCheckFn).toHaveBeenCalledTimes(callCountAfterGracePeriod); + }); + + it("should not poll if popupWindowRef is null", async () => { + popupWindowRef.current = null; + + renderHook(() => + useAuthPolling({ + popupWindowRef, + setConnectionStatus, + onAuthenticationComplete, + authCheckFn, + }), + ); + + await vi.advanceTimersByTimeAsync(10000); + + expect(authCheckFn).not.toHaveBeenCalled(); + }); + + it("should continue polling on authCheckFn error", async () => { + authCheckFn = vi.fn(() => Promise.reject(new Error("Network error"))); + + renderHook(() => + useAuthPolling({ + popupWindowRef, + setConnectionStatus, + onAuthenticationComplete, + authCheckFn, + }), + ); + + // First poll (will error) + await vi.advanceTimersByTimeAsync(2000); + expect(authCheckFn).toHaveBeenCalledTimes(1); + + // Should continue polling despite error + await vi.advanceTimersByTimeAsync(2000); + expect(authCheckFn).toHaveBeenCalledTimes(2); + + // Should not set error status on individual poll errors + expect(setConnectionStatus).not.toHaveBeenCalledWith("error"); + }); + + it("should not close popup if already closed on success", async () => { + authCheckFn = vi.fn(() => Promise.resolve({ connection: { ok: true } })); + mockPopup.closed = true; + + renderHook(() => + useAuthPolling({ + popupWindowRef, + setConnectionStatus, + onAuthenticationComplete, + authCheckFn, + }), + ); + + await vi.advanceTimersByTimeAsync(2000); + + expect(setConnectionStatus).toHaveBeenCalledWith("connected"); + expect(mockPopup.close).not.toHaveBeenCalled(); + expect(popupWindowRef.current).toBeNull(); + }); + + it("should clean up interval on unmount", async () => { + const { unmount } = renderHook(() => + useAuthPolling({ + popupWindowRef, + setConnectionStatus, + onAuthenticationComplete, + authCheckFn, + }), + ); + + await vi.advanceTimersByTimeAsync(2000); + const callCountBeforeUnmount = authCheckFn.mock.calls.length; + + unmount(); + + // Should not poll after unmount + await vi.advanceTimersByTimeAsync(4000); + expect(authCheckFn).toHaveBeenCalledTimes(callCountBeforeUnmount); + }); +}); diff --git a/packages/react-core/test/core/hooks/useAuthPostMessageListener.test.ts b/packages/react-core/test/core/hooks/useAuthPostMessageListener.test.ts new file mode 100644 index 000000000..c84e24b70 --- /dev/null +++ b/packages/react-core/test/core/hooks/useAuthPostMessageListener.test.ts @@ -0,0 +1,114 @@ +import { renderHook } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { useAuthPostMessageListener } from "../../../src/modules/core/hooks/useAuthPostMessageListener"; + +describe("useAuthPostMessageListener", () => { + const knockHost = "https://api.knock.app"; + let popupWindowRef: { current: Window | null }; + let setConnectionStatus: ReturnType; + let onAuthenticationComplete: ReturnType | undefined; + let mockPopup: { close: ReturnType; closed: boolean }; + + beforeEach(() => { + vi.clearAllMocks(); + + // Create a mock popup window + mockPopup = { + close: vi.fn(), + closed: false, + }; + + popupWindowRef = { current: mockPopup }; + setConnectionStatus = vi.fn(); + onAuthenticationComplete = vi.fn(); + }); + + it("should handle authComplete message and update status", () => { + renderHook(() => + useAuthPostMessageListener({ + knockHost, + popupWindowRef, + setConnectionStatus, + onAuthenticationComplete, + }), + ); + + // Simulate postMessage event with authComplete + const event = new MessageEvent("message", { + data: "authComplete", + origin: knockHost, + }); + window.dispatchEvent(event); + + expect(setConnectionStatus).toHaveBeenCalledWith("connected"); + expect(onAuthenticationComplete).toHaveBeenCalledWith("authComplete"); + expect(mockPopup.close).toHaveBeenCalled(); + expect(popupWindowRef.current).toBeNull(); + }); + + it("should handle authFailed message and update status", () => { + renderHook(() => + useAuthPostMessageListener({ + knockHost, + popupWindowRef, + setConnectionStatus, + onAuthenticationComplete, + }), + ); + + // Simulate postMessage event with authFailed + const event = new MessageEvent("message", { + data: "authFailed", + origin: knockHost, + }); + window.dispatchEvent(event); + + expect(setConnectionStatus).toHaveBeenCalledWith("error"); + expect(popupWindowRef.current).toBeNull(); + }); + + it("should ignore messages from different origins", () => { + renderHook(() => + useAuthPostMessageListener({ + knockHost, + popupWindowRef, + setConnectionStatus, + onAuthenticationComplete, + }), + ); + + // Simulate postMessage event from different origin + const event = new MessageEvent("message", { + data: "authComplete", + origin: "https://evil.com", + }); + window.dispatchEvent(event); + + expect(setConnectionStatus).not.toHaveBeenCalled(); + expect(onAuthenticationComplete).not.toHaveBeenCalled(); + expect(mockPopup.close).not.toHaveBeenCalled(); + }); + + it("should clean up event listener on unmount", () => { + const removeEventListenerSpy = vi.spyOn(window, "removeEventListener"); + + const { unmount } = renderHook(() => + useAuthPostMessageListener({ + knockHost, + popupWindowRef, + setConnectionStatus, + onAuthenticationComplete, + }), + ); + + unmount(); + + expect(removeEventListenerSpy).toHaveBeenCalledWith( + "message", + expect.any(Function), + ); + + removeEventListenerSpy.mockRestore(); + }); +}); diff --git a/packages/react/src/modules/core/utils.ts b/packages/react/src/modules/core/utils.ts index e6fa0b0c3..274afb5ae 100644 --- a/packages/react/src/modules/core/utils.ts +++ b/packages/react/src/modules/core/utils.ts @@ -18,7 +18,7 @@ export const openPopupWindow = (url: string) => { // Window features const features = `width=${width},height=${height},top=${top},left=${left}`; - window.open(url, "_blank", features); + return window.open(url, "_blank", features); }; export const checkForWindow = () => { diff --git a/packages/react/src/modules/ms-teams/components/MsTeamsAuthButton/MsTeamsAuthButton.tsx b/packages/react/src/modules/ms-teams/components/MsTeamsAuthButton/MsTeamsAuthButton.tsx index 656f0e7b6..98e20c1fe 100644 --- a/packages/react/src/modules/ms-teams/components/MsTeamsAuthButton/MsTeamsAuthButton.tsx +++ b/packages/react/src/modules/ms-teams/components/MsTeamsAuthButton/MsTeamsAuthButton.tsx @@ -1,10 +1,12 @@ import { + useAuthPolling, + useAuthPostMessageListener, useKnockClient, useKnockMsTeamsClient, useMsTeamsAuth, useTranslations, } from "@knocklabs/react-core"; -import { FunctionComponent, useEffect } from "react"; +import { FunctionComponent, useCallback } from "react"; import { openPopupWindow } from "../../../core/utils"; import "../../theme.css"; @@ -48,6 +50,9 @@ export const MsTeamsAuthButton: FunctionComponent = ({ setActionLabel, actionLabel, errorLabel, + tenantId, + knockMsTeamsChannelId, + popupWindowRef, } = useKnockMsTeamsClient(); const { buildMsTeamsAuthUrl, disconnectFromMsTeams } = useMsTeamsAuth( @@ -55,34 +60,24 @@ export const MsTeamsAuthButton: FunctionComponent = ({ redirectUrl, ); - useEffect(() => { - const receiveMessage = (event: MessageEvent) => { - if (event.origin !== knock.host) { - return; - } - - try { - if (event.data === "authComplete") { - setConnectionStatus("connected"); - } - - if (event.data === "authFailed") { - setConnectionStatus("error"); - } - - onAuthenticationComplete?.(event.data); - } catch (_error) { - setConnectionStatus("error"); - } - }; - - window.addEventListener("message", receiveMessage, false); + useAuthPostMessageListener({ + knockHost: knock.host, + popupWindowRef, + setConnectionStatus, + onAuthenticationComplete, + }); - // Cleanup the event listener when the component unmounts - return () => { - window.removeEventListener("message", receiveMessage); - }; - }, [knock.host, onAuthenticationComplete, setConnectionStatus]); + useAuthPolling({ + popupWindowRef, + setConnectionStatus, + onAuthenticationComplete, + authCheckFn: useCallback(async () => { + return knock.msTeams.authCheck({ + tenant: tenantId, + knockChannelId: knockMsTeamsChannelId, + }); + }, [knock.msTeams, tenantId, knockMsTeamsChannelId]), + }); const disconnectLabel = t("msTeamsDisconnect") || null; const reconnectLabel = t("msTeamsReconnect") || null; @@ -108,7 +103,11 @@ export const MsTeamsAuthButton: FunctionComponent = ({ if (connectionStatus === "error") { return (