refactor(onboarding): extract whatsapp and facebook channel connect into reusable composables (#14619)
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)
This commit is contained in:
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -1,20 +1,16 @@
|
||||
<script>
|
||||
/* eslint-env browser */
|
||||
/* global FB */
|
||||
import { useVuelidate } from '@vuelidate/core';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
import { useAccount } from 'dashboard/composables/useAccount';
|
||||
import { useFacebookPageConnect } from 'dashboard/composables/useFacebookPageConnect';
|
||||
import { required } from '@vuelidate/validators';
|
||||
import LoadingState from 'dashboard/components/widgets/LoadingState.vue';
|
||||
|
||||
import ChannelApi from '../../../../../api/channels';
|
||||
import PageHeader from '../../SettingsSubPageHeader.vue';
|
||||
import router from '../../../../index';
|
||||
import { useBranding } from 'shared/composables/useBranding';
|
||||
import NextButton from 'dashboard/components-next/button/Button.vue';
|
||||
import ComboBox from 'dashboard/components-next/combobox/ComboBox.vue';
|
||||
|
||||
import { loadScript } from 'dashboard/helper/DOMHelpers';
|
||||
import * as Sentry from '@sentry/vue';
|
||||
|
||||
export default {
|
||||
@@ -25,11 +21,12 @@ export default {
|
||||
ComboBox,
|
||||
},
|
||||
setup() {
|
||||
const { accountId } = useAccount();
|
||||
const { replaceInstallationName } = useBranding();
|
||||
const { preloadSdk, loginAndFetchPages } = useFacebookPageConnect();
|
||||
return {
|
||||
accountId,
|
||||
replaceInstallationName,
|
||||
preloadSdk,
|
||||
loginAndFetchPages,
|
||||
v$: useVuelidate(),
|
||||
};
|
||||
},
|
||||
@@ -37,9 +34,7 @@ export default {
|
||||
return {
|
||||
isCreating: false,
|
||||
hasError: false,
|
||||
omniauth_token: '',
|
||||
user_access_token: '',
|
||||
channel: 'facebook',
|
||||
selectedPage: { name: null, id: null },
|
||||
pageName: '',
|
||||
pageList: [],
|
||||
@@ -78,24 +73,29 @@ export default {
|
||||
},
|
||||
|
||||
mounted() {
|
||||
window.fbAsyncInit = this.runFBInit;
|
||||
// Warm the SDK so the login click opens its popup within the gesture's
|
||||
// activation window (see useFacebookPageConnect).
|
||||
this.preloadSdk();
|
||||
},
|
||||
|
||||
methods: {
|
||||
async startLogin() {
|
||||
this.hasLoginStarted = true;
|
||||
try {
|
||||
// this will load the SDK in a promise, and resolve it when the sdk is loaded
|
||||
// in case the SDK is already present, it will resolve immediately
|
||||
await this.loadFBsdk();
|
||||
this.runFBInit(); // run init anyway, `tryFBlogin` won't wait for `fbAsyncInit` otherwise.
|
||||
this.tryFBlogin(); // make an attempt to login
|
||||
const result = await this.loginAndFetchPages();
|
||||
if (!result) {
|
||||
// Cancelled popup / not authorized — surface a generic auth error.
|
||||
this.hasError = true;
|
||||
this.errorStateMessage = this.$t('INBOX_MGMT.DETAILS.ERROR_FB_AUTH');
|
||||
this.errorStateDescription = '';
|
||||
return;
|
||||
}
|
||||
this.pageList = result.pages;
|
||||
this.user_access_token = result.userAccessToken;
|
||||
} catch (error) {
|
||||
if (error.name === 'ScriptLoaderError') {
|
||||
// if the error was related to script loading, we show a toast
|
||||
useAlert(this.$t('INBOX_MGMT.DETAILS.ERROR_FB_LOADING'));
|
||||
} else {
|
||||
// if the error was anything else, we capture it and show a toast
|
||||
Sentry.captureException(error);
|
||||
useAlert(this.$t('INBOX_MGMT.DETAILS.ERROR_FB_AUTH'));
|
||||
}
|
||||
@@ -114,81 +114,6 @@ export default {
|
||||
this.v$.selectedPage.$touch();
|
||||
},
|
||||
|
||||
initChannelAuth(channel) {
|
||||
if (channel === 'facebook') {
|
||||
this.loadFBsdk();
|
||||
}
|
||||
},
|
||||
|
||||
runFBInit() {
|
||||
FB.init({
|
||||
appId: window.chatwootConfig.fbAppId,
|
||||
xfbml: true,
|
||||
version: window.chatwootConfig.fbApiVersion,
|
||||
status: true,
|
||||
});
|
||||
window.fbSDKLoaded = true;
|
||||
FB.AppEvents.logPageView();
|
||||
},
|
||||
|
||||
async loadFBsdk() {
|
||||
return loadScript('https://connect.facebook.net/en_US/sdk.js', {
|
||||
id: 'facebook-jssdk',
|
||||
});
|
||||
},
|
||||
|
||||
tryFBlogin() {
|
||||
FB.login(
|
||||
response => {
|
||||
this.hasError = false;
|
||||
if (response.status === 'connected') {
|
||||
this.fetchPages(response.authResponse.accessToken);
|
||||
} else if (response.status === 'not_authorized') {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error('FACEBOOK AUTH ERROR', response);
|
||||
this.hasError = true;
|
||||
// The person is logged into Facebook, but not your app.
|
||||
this.errorStateMessage = this.$t(
|
||||
'INBOX_MGMT.DETAILS.ERROR_FB_UNAUTHORIZED'
|
||||
);
|
||||
this.errorStateDescription = this.$t(
|
||||
'INBOX_MGMT.DETAILS.ERROR_FB_UNAUTHORIZED_HELP'
|
||||
);
|
||||
} else {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error('FACEBOOK AUTH ERROR', response);
|
||||
this.hasError = true;
|
||||
// The person is not logged into Facebook, so we're not sure if
|
||||
// they are logged into this app or not.
|
||||
this.errorStateMessage = this.$t(
|
||||
'INBOX_MGMT.DETAILS.ERROR_FB_AUTH'
|
||||
);
|
||||
this.errorStateDescription = '';
|
||||
}
|
||||
},
|
||||
{
|
||||
scope:
|
||||
'pages_manage_metadata,business_management,pages_messaging,instagram_basic,pages_show_list,pages_read_engagement,instagram_manage_messages',
|
||||
}
|
||||
);
|
||||
},
|
||||
|
||||
async fetchPages(_token) {
|
||||
try {
|
||||
const response = await ChannelApi.fetchFacebookPages(
|
||||
_token,
|
||||
this.accountId
|
||||
);
|
||||
const {
|
||||
data: { data },
|
||||
} = response;
|
||||
this.pageList = data.page_details;
|
||||
this.user_access_token = data.user_access_token;
|
||||
} catch (error) {
|
||||
// Ignore error
|
||||
}
|
||||
},
|
||||
|
||||
channelParams() {
|
||||
return {
|
||||
user_access_token: this.user_access_token,
|
||||
|
||||
+25
-158
@@ -1,21 +1,16 @@
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, onBeforeUnmount } from 'vue';
|
||||
import { ref, computed } from 'vue';
|
||||
import { useStore } from 'vuex';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { useI18n, I18nT } from 'vue-i18n';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
import { useWhatsappEmbeddedSignup } from 'dashboard/composables/useWhatsappEmbeddedSignup';
|
||||
import Icon from 'next/icon/Icon.vue';
|
||||
import NextButton from 'next/button/Button.vue';
|
||||
import LoadingState from 'dashboard/components/widgets/LoadingState.vue';
|
||||
import InboxesAPI from 'dashboard/api/inboxes';
|
||||
import { parseAPIErrorResponse } from 'dashboard/store/utils/api';
|
||||
import globalConstants from 'dashboard/constants/globals.js';
|
||||
import {
|
||||
setupFacebookSdk,
|
||||
initWhatsAppEmbeddedSignup,
|
||||
createMessageHandler,
|
||||
isValidBusinessData,
|
||||
} from './whatsapp/utils';
|
||||
|
||||
const props = defineProps({
|
||||
enableCallingOnComplete: {
|
||||
@@ -27,15 +22,10 @@ const props = defineProps({
|
||||
const store = useStore();
|
||||
const router = useRouter();
|
||||
const { t } = useI18n();
|
||||
const { isAuthenticating, runEmbeddedSignup } = useWhatsappEmbeddedSignup();
|
||||
|
||||
// State
|
||||
const fbSdkLoaded = ref(false);
|
||||
const isProcessing = ref(false);
|
||||
const processingMessage = ref('');
|
||||
const authCodeReceived = ref(false);
|
||||
const authCode = ref(null);
|
||||
const businessData = ref(null);
|
||||
const isAuthenticating = ref(false);
|
||||
|
||||
const benefits = computed(() => [
|
||||
{
|
||||
@@ -54,25 +44,6 @@ const benefits = computed(() => [
|
||||
|
||||
const showLoader = computed(() => isAuthenticating.value || isProcessing.value);
|
||||
|
||||
// Error handling
|
||||
const handleSignupError = data => {
|
||||
isProcessing.value = false;
|
||||
authCodeReceived.value = false;
|
||||
isAuthenticating.value = false;
|
||||
|
||||
const errorMessage =
|
||||
data.error ||
|
||||
data.message ||
|
||||
t('INBOX_MGMT.ADD.WHATSAPP.API.ERROR_MESSAGE');
|
||||
useAlert(errorMessage);
|
||||
};
|
||||
|
||||
const handleSignupCancellation = () => {
|
||||
isProcessing.value = false;
|
||||
authCodeReceived.value = false;
|
||||
isAuthenticating.value = false;
|
||||
};
|
||||
|
||||
const enableCallingForInbox = async inboxId => {
|
||||
try {
|
||||
await InboxesAPI.enableWhatsappCalling(inboxId);
|
||||
@@ -86,14 +57,12 @@ const enableCallingForInbox = async inboxId => {
|
||||
const handleSignupSuccess = async inboxData => {
|
||||
if (inboxData && inboxData.id) {
|
||||
if (props.enableCallingOnComplete) {
|
||||
isProcessing.value = true;
|
||||
processingMessage.value = t(
|
||||
'INBOX_MGMT.ADD.WHATSAPP.EMBEDDED_SIGNUP.ENABLING_CALLING'
|
||||
);
|
||||
await enableCallingForInbox(inboxData.id);
|
||||
}
|
||||
isProcessing.value = false;
|
||||
isAuthenticating.value = false;
|
||||
useAlert(t('INBOX_MGMT.FINISH.MESSAGE'));
|
||||
router.replace({
|
||||
name: 'settings_inboxes_add_agents',
|
||||
@@ -104,7 +73,6 @@ const handleSignupSuccess = async inboxData => {
|
||||
});
|
||||
} else {
|
||||
isProcessing.value = false;
|
||||
isAuthenticating.value = false;
|
||||
useAlert(t('INBOX_MGMT.ADD.WHATSAPP.EMBEDDED_SIGNUP.SUCCESS_FALLBACK'));
|
||||
router.replace({
|
||||
name: 'settings_inbox_list',
|
||||
@@ -112,12 +80,21 @@ const handleSignupSuccess = async inboxData => {
|
||||
}
|
||||
};
|
||||
|
||||
// Signup flow
|
||||
const completeSignupFlow = async businessDataParam => {
|
||||
if (!authCodeReceived.value || !authCode.value) {
|
||||
handleSignupError({
|
||||
error: t('INBOX_MGMT.ADD.WHATSAPP.EMBEDDED_SIGNUP.AUTH_NOT_COMPLETED'),
|
||||
});
|
||||
const launchEmbeddedSignup = async () => {
|
||||
let credentials;
|
||||
try {
|
||||
credentials = await runEmbeddedSignup();
|
||||
} catch (error) {
|
||||
useAlert(
|
||||
error.message ||
|
||||
t('INBOX_MGMT.ADD.WHATSAPP.EMBEDDED_SIGNUP.SDK_LOAD_ERROR')
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Resolves null when the user dismisses the Meta popup.
|
||||
if (!credentials) {
|
||||
useAlert(t('INBOX_MGMT.ADD.WHATSAPP.EMBEDDED_SIGNUP.CANCELLED'));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -125,130 +102,20 @@ const completeSignupFlow = async businessDataParam => {
|
||||
processingMessage.value = t(
|
||||
'INBOX_MGMT.ADD.WHATSAPP.EMBEDDED_SIGNUP.PROCESSING'
|
||||
);
|
||||
|
||||
try {
|
||||
const params = {
|
||||
code: authCode.value,
|
||||
business_id: businessDataParam.business_id,
|
||||
waba_id: businessDataParam.waba_id,
|
||||
phone_number_id: businessDataParam?.phone_number_id || '',
|
||||
};
|
||||
|
||||
const responseData = await store.dispatch(
|
||||
const inboxData = await store.dispatch(
|
||||
'inboxes/createWhatsAppEmbeddedSignup',
|
||||
params
|
||||
credentials
|
||||
);
|
||||
|
||||
authCode.value = null;
|
||||
handleSignupSuccess(responseData);
|
||||
await handleSignupSuccess(inboxData);
|
||||
} catch (error) {
|
||||
const errorMessage =
|
||||
isProcessing.value = false;
|
||||
useAlert(
|
||||
parseAPIErrorResponse(error) ||
|
||||
t('INBOX_MGMT.ADD.WHATSAPP.API.ERROR_MESSAGE');
|
||||
handleSignupError({ error: errorMessage });
|
||||
t('INBOX_MGMT.ADD.WHATSAPP.API.ERROR_MESSAGE')
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
// Message handling
|
||||
const handleEmbeddedSignupData = async data => {
|
||||
if (
|
||||
data.event === 'FINISH' ||
|
||||
data.event === 'FINISH_WHATSAPP_BUSINESS_APP_ONBOARDING'
|
||||
) {
|
||||
const businessDataLocal = data.data;
|
||||
|
||||
if (isValidBusinessData(businessDataLocal)) {
|
||||
businessData.value = businessDataLocal;
|
||||
if (authCodeReceived.value && authCode.value) {
|
||||
await completeSignupFlow(businessDataLocal);
|
||||
} else {
|
||||
processingMessage.value = t(
|
||||
'INBOX_MGMT.ADD.WHATSAPP.EMBEDDED_SIGNUP.WAITING_FOR_AUTH'
|
||||
);
|
||||
}
|
||||
} else {
|
||||
handleSignupError({
|
||||
error: t(
|
||||
'INBOX_MGMT.ADD.WHATSAPP.EMBEDDED_SIGNUP.INVALID_BUSINESS_DATA'
|
||||
),
|
||||
});
|
||||
}
|
||||
} else if (data.event === 'CANCEL') {
|
||||
handleSignupCancellation();
|
||||
} else if (data.event === 'error') {
|
||||
handleSignupError({
|
||||
error:
|
||||
data.error_message ||
|
||||
t('INBOX_MGMT.ADD.WHATSAPP.EMBEDDED_SIGNUP.SIGNUP_ERROR'),
|
||||
error_id: data.error_id,
|
||||
session_id: data.session_id,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleSignupMessage = createMessageHandler(handleEmbeddedSignupData);
|
||||
|
||||
const launchEmbeddedSignup = async () => {
|
||||
try {
|
||||
isAuthenticating.value = true;
|
||||
processingMessage.value = t(
|
||||
'INBOX_MGMT.ADD.WHATSAPP.EMBEDDED_SIGNUP.AUTH_PROCESSING'
|
||||
);
|
||||
|
||||
await setupFacebookSdk(
|
||||
window.chatwootConfig?.whatsappAppId,
|
||||
window.chatwootConfig?.whatsappApiVersion
|
||||
);
|
||||
fbSdkLoaded.value = true;
|
||||
|
||||
const code = await initWhatsAppEmbeddedSignup(
|
||||
window.chatwootConfig?.whatsappConfigurationId
|
||||
);
|
||||
|
||||
authCode.value = code;
|
||||
authCodeReceived.value = true;
|
||||
processingMessage.value = t(
|
||||
'INBOX_MGMT.ADD.WHATSAPP.EMBEDDED_SIGNUP.WAITING_FOR_BUSINESS_INFO'
|
||||
);
|
||||
|
||||
if (businessData.value) {
|
||||
completeSignupFlow(businessData.value);
|
||||
}
|
||||
} catch (error) {
|
||||
if (error.message === 'Login cancelled') {
|
||||
isProcessing.value = false;
|
||||
isAuthenticating.value = false;
|
||||
useAlert(t('INBOX_MGMT.ADD.WHATSAPP.EMBEDDED_SIGNUP.CANCELLED'));
|
||||
} else {
|
||||
handleSignupError({
|
||||
error:
|
||||
error.message ||
|
||||
t('INBOX_MGMT.ADD.WHATSAPP.EMBEDDED_SIGNUP.SDK_LOAD_ERROR'),
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Lifecycle
|
||||
const setupMessageListener = () => {
|
||||
window.addEventListener('message', handleSignupMessage);
|
||||
};
|
||||
|
||||
const cleanupMessageListener = () => {
|
||||
window.removeEventListener('message', handleSignupMessage);
|
||||
};
|
||||
|
||||
const initialize = () => {
|
||||
setupMessageListener();
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
initialize();
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
cleanupMessageListener();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
||||
Reference in New Issue
Block a user