From 78a6b2457d9f0873fa6639678c3c0e248b0ce0dc Mon Sep 17 00:00:00 2001 From: Shivam Mishra Date: Mon, 8 Jun 2026 13:13:20 +0530 Subject: [PATCH 01/27] refactor(onboarding): extract whatsapp and facebook channel connect into reusable composables (#14619) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extracts the Meta JS SDK logic for the WhatsApp embedded-signup and Facebook Page connect flows out of their settings components into two reusable composables. No behavior change — the settings inbox-creation flows work exactly as before; this just makes the SDK orchestration reusable (an upcoming onboarding PR consumes them) and adds unit coverage. ## What changed - `useWhatsappEmbeddedSignup` — owns the embedded-signup popup (SDK load, FB.login, and the auth-code / postMessage race). `WhatsappEmbeddedSignup.vue` now consumes it. - `useFacebookPageConnect` — owns FB.login (page scopes) + page fetch, with SDK preload split from login so the popup opens within the click's activation window. `Facebook.vue` now consumes it. - Adds unit specs for both composables. ## Related PRs - https://github.com/chatwoot/chatwoot/pull/14569 - https://github.com/chatwoot/chatwoot/pull/14568 - https://github.com/chatwoot/chatwoot/pull/14567 - https://github.com/chatwoot/chatwoot/pull/14649 - https://github.com/chatwoot/chatwoot/pull/14565 (Primary onboarding PR) --- .../spec/useFacebookPageConnect.spec.js | 148 +++++++++++++ .../spec/useWhatsappEmbeddedSignup.spec.js | 208 ++++++++++++++++++ .../composables/useFacebookPageConnect.js | 81 +++++++ .../composables/useWhatsappEmbeddedSignup.js | 96 ++++++++ .../settings/inbox/channels/Facebook.vue | 109 ++------- .../inbox/channels/WhatsappEmbeddedSignup.vue | 183 +++------------ 6 files changed, 575 insertions(+), 250 deletions(-) create mode 100644 app/javascript/dashboard/composables/spec/useFacebookPageConnect.spec.js create mode 100644 app/javascript/dashboard/composables/spec/useWhatsappEmbeddedSignup.spec.js create mode 100644 app/javascript/dashboard/composables/useFacebookPageConnect.js create mode 100644 app/javascript/dashboard/composables/useWhatsappEmbeddedSignup.js diff --git a/app/javascript/dashboard/composables/spec/useFacebookPageConnect.spec.js b/app/javascript/dashboard/composables/spec/useFacebookPageConnect.spec.js new file mode 100644 index 000000000..5d9edb993 --- /dev/null +++ b/app/javascript/dashboard/composables/spec/useFacebookPageConnect.spec.js @@ -0,0 +1,148 @@ +import { useFacebookPageConnect } from '../useFacebookPageConnect'; +import { useMapGetter } from 'dashboard/composables/store'; +import ChannelApi from 'dashboard/api/channels'; +import { setupFacebookSdk } from 'dashboard/routes/dashboard/settings/inbox/channels/whatsapp/utils'; + +vi.mock('dashboard/composables/store', () => ({ useMapGetter: vi.fn() })); +vi.mock('dashboard/api/channels', () => ({ + default: { fetchFacebookPages: vi.fn() }, +})); +vi.mock( + 'dashboard/routes/dashboard/settings/inbox/channels/whatsapp/utils', + () => ({ setupFacebookSdk: vi.fn() }) +); + +const flushPromises = () => + new Promise(resolve => { + setTimeout(resolve, 0); + }); + +const createDeferred = () => { + let resolve; + let reject; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + return { promise, resolve, reject }; +}; + +const ACCOUNT_ID = 42; +const PAGES = [ + { id: 'p1', name: 'Page One', access_token: 'page-token-1' }, + { id: 'p2', name: 'Page Two', access_token: 'page-token-2', exists: true }, +]; + +const pagesResponse = { + data: { data: { page_details: PAGES, user_access_token: 'long-token' } }, +}; + +// FB.login invokes its callback with the given response. +const stubLogin = response => { + window.FB = { login: vi.fn(callback => callback(response)) }; +}; + +const connected = { + status: 'connected', + authResponse: { accessToken: 'user-token' }, +}; + +describe('useFacebookPageConnect', () => { + beforeEach(() => { + vi.clearAllMocks(); + window.chatwootConfig = { fbAppId: 'fb-app', fbApiVersion: 'v22.0' }; + useMapGetter.mockReturnValue({ value: ACCOUNT_ID }); + setupFacebookSdk.mockResolvedValue(); + ChannelApi.fetchFacebookPages.mockResolvedValue(pagesResponse); + stubLogin(connected); + }); + + it('resolves the user token and pages on a connected login', async () => { + const { loginAndFetchPages } = useFacebookPageConnect(); + + await expect(loginAndFetchPages()).resolves.toEqual({ + userAccessToken: 'long-token', + pages: PAGES, + }); + expect(setupFacebookSdk).toHaveBeenCalledWith('fb-app', 'v22.0'); + expect(ChannelApi.fetchFacebookPages).toHaveBeenCalledWith( + 'user-token', + ACCOUNT_ID + ); + expect(window.FB.login).toHaveBeenCalledWith(expect.any(Function), { + scope: expect.stringContaining('pages_show_list'), + }); + }); + + it('resolves null when the user is not authorized', async () => { + stubLogin({ status: 'not_authorized' }); + const { loginAndFetchPages } = useFacebookPageConnect(); + + await expect(loginAndFetchPages()).resolves.toBeNull(); + expect(ChannelApi.fetchFacebookPages).not.toHaveBeenCalled(); + }); + + it('resolves null on an unknown login status', async () => { + stubLogin({ status: 'unknown' }); + const { loginAndFetchPages } = useFacebookPageConnect(); + + await expect(loginAndFetchPages()).resolves.toBeNull(); + }); + + it('rejects when fetching pages fails', async () => { + ChannelApi.fetchFacebookPages.mockRejectedValue(new Error('fetch failed')); + const { loginAndFetchPages } = useFacebookPageConnect(); + + await expect(loginAndFetchPages()).rejects.toThrow('fetch failed'); + }); + + it('rejects when the SDK fails to load', async () => { + const error = new Error('script load failed'); + error.name = 'ScriptLoaderError'; + setupFacebookSdk.mockRejectedValue(error); + const { loginAndFetchPages } = useFacebookPageConnect(); + + await expect(loginAndFetchPages()).rejects.toThrow('script load failed'); + }); + + it('ignores a second call while a run is in flight', async () => { + const pending = createDeferred(); + ChannelApi.fetchFacebookPages.mockReturnValue(pending.promise); + + const { loginAndFetchPages } = useFacebookPageConnect(); + const first = loginAndFetchPages(); + const second = loginAndFetchPages(); + + await expect(second).resolves.toBeNull(); + + pending.resolve(pagesResponse); + await first; + expect(window.FB.login).toHaveBeenCalledTimes(1); + }); + + it('toggles isAuthenticating across a run', async () => { + const pending = createDeferred(); + ChannelApi.fetchFacebookPages.mockReturnValue(pending.promise); + + const { isAuthenticating, loginAndFetchPages } = useFacebookPageConnect(); + expect(isAuthenticating.value).toBe(false); + + const result = loginAndFetchPages(); + await flushPromises(); + expect(isAuthenticating.value).toBe(true); + + pending.resolve(pagesResponse); + await result; + expect(isAuthenticating.value).toBe(false); + }); + + it('preloads the SDK once and reuses it for login', async () => { + const { preloadSdk, loginAndFetchPages } = useFacebookPageConnect(); + + preloadSdk(); + preloadSdk(); + await loginAndFetchPages(); + + expect(setupFacebookSdk).toHaveBeenCalledTimes(1); + }); +}); diff --git a/app/javascript/dashboard/composables/spec/useWhatsappEmbeddedSignup.spec.js b/app/javascript/dashboard/composables/spec/useWhatsappEmbeddedSignup.spec.js new file mode 100644 index 000000000..1efbad8a2 --- /dev/null +++ b/app/javascript/dashboard/composables/spec/useWhatsappEmbeddedSignup.spec.js @@ -0,0 +1,208 @@ +import { useWhatsappEmbeddedSignup } from '../useWhatsappEmbeddedSignup'; +import { + setupFacebookSdk, + initWhatsAppEmbeddedSignup, + createMessageHandler, +} from 'dashboard/routes/dashboard/settings/inbox/channels/whatsapp/utils'; + +vi.mock( + 'dashboard/routes/dashboard/settings/inbox/channels/whatsapp/utils', + () => ({ + setupFacebookSdk: vi.fn(), + initWhatsAppEmbeddedSignup: vi.fn(), + createMessageHandler: vi.fn(), + isValidBusinessData: vi.fn(data => + Boolean(data && data.business_id && data.waba_id) + ), + }) +); + +const flushPromises = () => + new Promise(resolve => { + setTimeout(resolve, 0); + }); + +const createDeferred = () => { + let resolve; + let reject; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + return { promise, resolve, reject }; +}; + +const VALID_BUSINESS = { + business_id: 'biz-1', + waba_id: 'waba-1', + phone_number_id: 'phone-1', +}; + +describe('useWhatsappEmbeddedSignup', () => { + // The mocked createMessageHandler captures the callback the composable + // registers, so tests can simulate Meta's WA_EMBEDDED_SIGNUP postMessages + // directly without the window-event + origin plumbing (that is covered by + // the utils' own tests). + let signupCallback; + let registeredListener; + + const emit = data => signupCallback(data); + + beforeEach(() => { + vi.clearAllMocks(); + + window.chatwootConfig = { + whatsappAppId: 'app-id', + whatsappConfigurationId: 'config-id', + whatsappApiVersion: 'v22.0', + }; + + setupFacebookSdk.mockResolvedValue(); + createMessageHandler.mockImplementation(callback => { + signupCallback = callback; + registeredListener = () => {}; + return registeredListener; + }); + }); + + it('resolves credentials when the auth code arrives before the business data', async () => { + initWhatsAppEmbeddedSignup.mockResolvedValue('auth-code'); + + const { runEmbeddedSignup } = useWhatsappEmbeddedSignup(); + const result = runEmbeddedSignup(); + + await flushPromises(); // SDK setup + FB.login resolve the code first + emit({ event: 'FINISH', data: VALID_BUSINESS }); + + await expect(result).resolves.toEqual({ + code: 'auth-code', + business_id: 'biz-1', + waba_id: 'waba-1', + phone_number_id: 'phone-1', + }); + expect(setupFacebookSdk).toHaveBeenCalledWith('app-id', 'v22.0'); + expect(initWhatsAppEmbeddedSignup).toHaveBeenCalledWith('config-id'); + }); + + it('resolves credentials when the business data arrives before the auth code', async () => { + const code = createDeferred(); + initWhatsAppEmbeddedSignup.mockReturnValue(code.promise); + + const { runEmbeddedSignup } = useWhatsappEmbeddedSignup(); + const result = runEmbeddedSignup(); + + // Business data lands first, while FB.login is still pending. + emit({ + event: 'FINISH_WHATSAPP_BUSINESS_APP_ONBOARDING', + data: VALID_BUSINESS, + }); + code.resolve('late-code'); + + await expect(result).resolves.toEqual({ + code: 'late-code', + business_id: 'biz-1', + waba_id: 'waba-1', + phone_number_id: 'phone-1', + }); + }); + + it('defaults phone_number_id to an empty string when absent', async () => { + initWhatsAppEmbeddedSignup.mockResolvedValue('auth-code'); + + const { runEmbeddedSignup } = useWhatsappEmbeddedSignup(); + const result = runEmbeddedSignup(); + + await flushPromises(); + emit({ + event: 'FINISH', + data: { business_id: 'biz-1', waba_id: 'waba-1' }, + }); + + await expect(result).resolves.toMatchObject({ phone_number_id: '' }); + }); + + it('resolves null when FB.login is cancelled', async () => { + initWhatsAppEmbeddedSignup.mockRejectedValue(new Error('Login cancelled')); + + const { runEmbeddedSignup } = useWhatsappEmbeddedSignup(); + + await expect(runEmbeddedSignup()).resolves.toBeNull(); + }); + + it('resolves null on a CANCEL event', async () => { + initWhatsAppEmbeddedSignup.mockReturnValue(createDeferred().promise); + + const { runEmbeddedSignup } = useWhatsappEmbeddedSignup(); + const result = runEmbeddedSignup(); + + emit({ event: 'CANCEL' }); + + await expect(result).resolves.toBeNull(); + }); + + it('rejects with the Meta error message on an error event', async () => { + initWhatsAppEmbeddedSignup.mockReturnValue(createDeferred().promise); + + const { runEmbeddedSignup } = useWhatsappEmbeddedSignup(); + const result = runEmbeddedSignup(); + + emit({ event: 'error', error_message: 'WABA not eligible' }); + + await expect(result).rejects.toThrow('WABA not eligible'); + }); + + it('rejects when the business data is invalid', async () => { + initWhatsAppEmbeddedSignup.mockReturnValue(createDeferred().promise); + + const { runEmbeddedSignup } = useWhatsappEmbeddedSignup(); + const result = runEmbeddedSignup(); + + emit({ event: 'FINISH', data: { business_id: 'biz-1' } }); // no waba_id + + await expect(result).rejects.toThrow('Invalid business data'); + }); + + it('rejects when the SDK or login fails for a non-cancel reason', async () => { + initWhatsAppEmbeddedSignup.mockRejectedValue(new Error('popup blocked')); + + const { runEmbeddedSignup } = useWhatsappEmbeddedSignup(); + + await expect(runEmbeddedSignup()).rejects.toThrow('popup blocked'); + }); + + it('ignores a second call while a run is in flight', async () => { + initWhatsAppEmbeddedSignup.mockResolvedValue('auth-code'); + + const { runEmbeddedSignup } = useWhatsappEmbeddedSignup(); + const first = runEmbeddedSignup(); + const second = runEmbeddedSignup(); + + await expect(second).resolves.toBeNull(); + + // Let the first run finish so it doesn't leak into the next test. + await flushPromises(); + emit({ event: 'FINISH', data: VALID_BUSINESS }); + await first; + + expect(setupFacebookSdk).toHaveBeenCalledTimes(1); + }); + + it('toggles isAuthenticating and removes the listener once settled', async () => { + initWhatsAppEmbeddedSignup.mockResolvedValue('auth-code'); + const removeSpy = vi.spyOn(window, 'removeEventListener'); + + const { isAuthenticating, runEmbeddedSignup } = useWhatsappEmbeddedSignup(); + expect(isAuthenticating.value).toBe(false); + + const result = runEmbeddedSignup(); + expect(isAuthenticating.value).toBe(true); + + await flushPromises(); + emit({ event: 'FINISH', data: VALID_BUSINESS }); + await result; + + expect(isAuthenticating.value).toBe(false); + expect(removeSpy).toHaveBeenCalledWith('message', registeredListener); + removeSpy.mockRestore(); + }); +}); diff --git a/app/javascript/dashboard/composables/useFacebookPageConnect.js b/app/javascript/dashboard/composables/useFacebookPageConnect.js new file mode 100644 index 000000000..46a177d58 --- /dev/null +++ b/app/javascript/dashboard/composables/useFacebookPageConnect.js @@ -0,0 +1,81 @@ +import { ref } from 'vue'; +import { useMapGetter } from 'dashboard/composables/store'; +import ChannelApi from 'dashboard/api/channels'; +import { setupFacebookSdk } from 'dashboard/routes/dashboard/settings/inbox/channels/whatsapp/utils'; + +// Page-management + messaging scopes required to list pages and create a +// Channel::FacebookPage inbox (mirrors the standalone settings flow). +const FB_PAGE_SCOPES = + 'pages_manage_metadata,business_management,pages_messaging,instagram_basic,pages_show_list,pages_read_engagement,instagram_manage_messages'; + +// Headless half of the Facebook Page connect flow: load the Meta SDK, run +// FB.login for page scopes, and fetch the user's pages. The caller owns the +// page-picker UI and the channel creation, because choosing a page is an +// interactive step (a user can manage several pages). +// +// Split into preloadSdk() + loginAndFetchPages() for popup safety: FB.login +// opens a popup and needs the click's transient activation. Preloading the SDK +// when the picker opens means the click-time `await` resolves within that +// activation window; a cold load resolves on the script's `load` task seconds +// later, after activation has expired, and the popup gets blocked. +export function useFacebookPageConnect() { + const accountId = useMapGetter('getCurrentAccountId'); + const isAuthenticating = ref(false); + + let sdkSetupPromise = null; + + // Idempotent — call this when the picker UI opens. A failed load clears the + // cache so a later attempt can retry instead of being stuck on a rejection. + const preloadSdk = () => { + if (!sdkSetupPromise) { + sdkSetupPromise = setupFacebookSdk( + window.chatwootConfig?.fbAppId, + window.chatwootConfig?.fbApiVersion + ).catch(error => { + sdkSetupPromise = null; + throw error; + }); + } + return sdkSetupPromise; + }; + + // FB.login never rejects; resolve the user access token on success and null + // for any other status (closed popup, not_authorized, unknown). + const login = () => + new Promise(resolve => { + window.FB.login( + response => { + resolve( + response.status === 'connected' + ? response.authResponse?.accessToken || null + : null + ); + }, + { scope: FB_PAGE_SCOPES } + ); + }); + + // Resolves { userAccessToken, pages } on success, null when the user cancels, + // and rejects on SDK-load or page-fetch failure (the caller maps it to UI). + const loginAndFetchPages = async () => { + if (isAuthenticating.value) return null; + isAuthenticating.value = true; + try { + await preloadSdk(); + const token = await login(); + if (!token) return null; + + const response = await ChannelApi.fetchFacebookPages( + token, + accountId.value + ); + const { page_details: pages, user_access_token: userAccessToken } = + response.data.data; + return { userAccessToken, pages }; + } finally { + isAuthenticating.value = false; + } + }; + + return { isAuthenticating, preloadSdk, loginAndFetchPages }; +} diff --git a/app/javascript/dashboard/composables/useWhatsappEmbeddedSignup.js b/app/javascript/dashboard/composables/useWhatsappEmbeddedSignup.js new file mode 100644 index 000000000..ead9c6d84 --- /dev/null +++ b/app/javascript/dashboard/composables/useWhatsappEmbeddedSignup.js @@ -0,0 +1,96 @@ +import { ref } from 'vue'; +import { + setupFacebookSdk, + initWhatsAppEmbeddedSignup, + createMessageHandler, + isValidBusinessData, +} from 'dashboard/routes/dashboard/settings/inbox/channels/whatsapp/utils'; + +// Drives Meta's WhatsApp embedded-signup popup (Facebook JS SDK). FB.login() +// resolves an auth `code` while the WABA identifiers (waba_id, phone_number_id) +// arrive separately over a postMessage event — order isn't guaranteed, so we +// hold both and resolve once both are present. +// +// `runEmbeddedSignup` returns the signup credentials; the caller exchanges them +// for an inbox via `inboxes/createWhatsAppEmbeddedSignup` and owns its own UX +// (alerts, navigation, etc). Resolves `null` when the user cancels the popup; +// rejects on SDK load or signup errors. The window listener is scoped to a +// single run, so this is safe to call from anywhere without lifecycle wiring. +export function useWhatsappEmbeddedSignup() { + const isAuthenticating = ref(false); + + const runEmbeddedSignup = () => { + if (isAuthenticating.value) return Promise.resolve(null); + isAuthenticating.value = true; + + return new Promise((resolve, reject) => { + let authCode = null; + let businessData = null; + let settled = false; + let messageHandler; + + const settle = (fn, value) => { + if (settled) return; + settled = true; + window.removeEventListener('message', messageHandler); + isAuthenticating.value = false; + fn(value); + }; + + // Both the auth code and the business data arrive asynchronously and in + // no fixed order; only resolve once we're holding both. + const resolveIfReady = () => { + if (!authCode || !businessData) return; + settle(resolve, { + code: authCode, + business_id: businessData.business_id, + waba_id: businessData.waba_id, + phone_number_id: businessData.phone_number_id || '', + }); + }; + + messageHandler = createMessageHandler(data => { + if ( + data.event === 'FINISH' || + data.event === 'FINISH_WHATSAPP_BUSINESS_APP_ONBOARDING' + ) { + if (!isValidBusinessData(data.data)) { + settle(reject, new Error('Invalid business data')); + return; + } + businessData = data.data; + resolveIfReady(); + } else if (data.event === 'CANCEL') { + settle(resolve, null); + } else if (data.event === 'error') { + settle(reject, new Error(data.error_message || 'Signup error')); + } + }); + + window.addEventListener('message', messageHandler); + + (async () => { + try { + await setupFacebookSdk( + window.chatwootConfig?.whatsappAppId, + window.chatwootConfig?.whatsappApiVersion + ); + authCode = await initWhatsAppEmbeddedSignup( + window.chatwootConfig?.whatsappConfigurationId + ); + resolveIfReady(); + } catch (error) { + // FB.login() rejects with 'Login cancelled' when the user dismisses + // the popup — treat it as a cancel rather than an error. + if (error.message === 'Login cancelled') { + settle(resolve, null); + } else { + settle(reject, error); + } + } + })(); + }); + }; + + return { isAuthenticating, runEmbeddedSignup }; +} diff --git a/app/javascript/dashboard/routes/dashboard/settings/inbox/channels/Facebook.vue b/app/javascript/dashboard/routes/dashboard/settings/inbox/channels/Facebook.vue index 39e0df818..d08c7e607 100644 --- a/app/javascript/dashboard/routes/dashboard/settings/inbox/channels/Facebook.vue +++ b/app/javascript/dashboard/routes/dashboard/settings/inbox/channels/Facebook.vue @@ -1,20 +1,16 @@