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 @@