Compare commits

...
Author SHA1 Message Date
Muhsin KelothandGitHub d98eae0b2d Merge branch 'feat/whatsapp-embedded-signup' into feat/whatsapp-es-reauth-flow 2025-07-10 12:02:21 +05:30
Muhsin KelothandGitHub e86f9c3005 Merge branch 'develop' into feat/whatsapp-embedded-signup 2025-07-10 12:00:33 +05:30
66ce599e4e feat: Whatsapp es reauth flow fe (#11915)
# Pull Request Template

## Description

Please include a summary of the change and issue(s) fixed. Also, mention
relevant motivation, context, and any dependencies that this change
requires.
Fixes # (issue)

## Type of change

Please delete options that are not relevant.

- [ ] Bug fix (non-breaking change which fixes an issue)
- [ ] New feature (non-breaking change which adds functionality)
- [ ] Breaking change (fix or feature that would cause existing
functionality not to work as expected)
- [ ] This change requires a documentation update

## How Has This Been Tested?

Please describe the tests that you ran to verify your changes. Provide
instructions so we can reproduce. Please also list any relevant details
for your test configuration.


## Checklist:

- [ ] My code follows the style guidelines of this project
- [ ] I have performed a self-review of my code
- [ ] I have commented on my code, particularly in hard-to-understand
areas
- [ ] I have made corresponding changes to the documentation
- [ ] My changes generate no new warnings
- [ ] I have added tests that prove my fix is effective or that my
feature works
- [ ] New and existing unit tests pass locally with my changes
- [ ] Any dependent changes have been merged and published in downstream
modules

---------

Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
2025-07-10 11:52:25 +05:30
Muhsin KelothandGitHub b9d3a34a73 Merge branch 'develop' into feat/whatsapp-embedded-signup 2025-07-10 10:03:46 +05:30
Tanmay Deep Sharma 50d023caed remove extra committed file 2025-07-10 09:06:05 +07:00
Tanmay Deep Sharma 9007bf1ecf feat: Add WhatsApp reauthorization bankend flow 2025-07-10 03:09:16 +07:00
Tanmay Deep SharmaandGitHub 358ec8120b Merge branch 'develop' into feat/whatsapp-embedded-signup 2025-07-09 10:01:37 +07:00
Muhsin KelothandGitHub c73f33619d Merge branch 'develop' into feat/whatsapp-embedded-signup 2025-07-08 17:32:22 +05:30
Tanmay Deep Sharma c8f0cbbfa1 Merge remote-tracking branch 'origin/develop' into feat/whatsapp-embedded-signup 2025-07-08 11:56:52 +07:00
Muhsin KelothandGitHub 340fcb6152 Merge branch 'develop' into feat/whatsapp-embedded-signup 2025-06-26 11:08:43 +05:30
Sivin VargheseandGitHub cacd192714 Update app/javascript/dashboard/routes/dashboard/settings/inbox/channels/Whatsapp.vue 2025-06-12 22:57:15 +05:30
iamsivin b531dd0461 chore: Update png 2025-06-12 22:37:44 +05:30
iamsivin cb3721a425 chore: Fix styles 2025-06-12 22:25:59 +05:30
Muhsin KelothandGitHub 23ed1cc17f Merge branch 'develop' into feat/whatsapp-embedded-signup 2025-06-12 11:28:41 +05:30
Muhsin Keloth d32d47b41b chore: hide webhook details for ES 2025-06-12 11:27:52 +05:30
Muhsin Keloth a1a3f62d1b Update inboxMgmt.json 2025-06-11 17:51:09 +05:30
Muhsin Keloth ccff83e253 chore: remove unused variables 2025-06-11 17:46:48 +05:30
Muhsin Keloth fdbf8b52f3 chore: revert codeclimate.yml 2025-06-11 17:23:43 +05:30
Muhsin Keloth 8544c87cfd Update .codeclimate.yml 2025-06-11 17:19:51 +05:30
Muhsin Keloth b04ae3e251 Update .codeclimate.yml 2025-06-11 17:13:52 +05:30
Muhsin Keloth f0f3e615b3 fix: complex logical expression 2025-06-11 17:12:52 +05:30
Tanmay Deep Sharma a1ce805fa5 Merge remote-tracking branch 'origin/feat/whatsapp-embedded-signup' into feat/whatsapp-embedded-signup 2025-06-11 15:39:40 +05:30
Tanmay Deep Sharma e326a7a8c8 revert the linting of db file 2025-06-11 15:39:04 +05:30
Muhsin Keloth be8a441bfc chore: revert the changes 2025-06-11 15:26:35 +05:30
Muhsin Keloth 212f07711d Update .codeclimate.yml 2025-06-11 15:21:19 +05:30
Muhsin Keloth e6012d7e86 Update .codeclimate.yml 2025-06-11 15:21:06 +05:30
Muhsin Keloth 9d86792468 chore: final fixes 2025-06-11 15:20:41 +05:30
Muhsin Keloth 6e1c3ee176 chore: add more helpers 2025-06-11 15:07:43 +05:30
Muhsin Keloth 2f8214eb87 chore: more fixes 2025-06-11 15:01:53 +05:30
Muhsin Keloth 48293001ed chore: fix issues 2025-06-11 14:52:48 +05:30
Muhsin Keloth 83cd7b6788 Merge branch 'feat/whatsapp-embedded-signup' of https://github.com/chatwoot/chatwoot into feat/whatsapp-embedded-signup 2025-06-11 14:48:32 +05:30
Muhsin Keloth 65bb584f48 chore: more code cleanups 2025-06-11 14:48:15 +05:30
Muhsin KelothandGitHub 9ad1b219a3 Merge branch 'develop' into feat/whatsapp-embedded-signup 2025-06-11 14:41:20 +05:30
Muhsin Keloth f812ca3b99 fix: code complexity issues 2025-06-11 14:40:16 +05:30
Muhsin KelothandGitHub c6df54cc29 Merge branch 'develop' into feat/whatsapp-embedded-signup 2025-06-11 14:24:30 +05:30
Muhsin Keloth 384e616c1b Merge branch 'feat/whatsapp-embedded-signup' of https://github.com/chatwoot/chatwoot into feat/whatsapp-embedded-signup 2025-06-11 14:22:58 +05:30
Tanmay Deep Sharma 34a2eff135 make whatsapp embedded signup api version dynamic 2025-06-11 13:00:19 +05:30
Muhsin KelothandTanmay Deep Sharma 1c09cc5aa5 chore: design fixes 2025-06-11 12:30:48 +05:30
Muhsin Keloth 5bf5a0e0d0 chore: design fixes 2025-06-11 10:12:57 +05:30
Muhsin Keloth e3d76fa622 chore: add composable 2025-06-11 00:12:53 +05:30
Tanmay Deep Sharma 4f123e68a8 add spec for embedded signup 2025-06-10 20:31:24 +05:30
Muhsin KelothandGitHub 27284dd69e Merge branch 'develop' into feat/whatsapp-embedded-signup 2025-06-10 20:11:07 +05:30
Muhsin Keloth a5321376e7 chore: front end improvments 2025-06-10 16:06:22 +05:30
Muhsin Keloth 20f63cf957 chore: make WhatsApp component composition styles 2025-06-10 15:04:14 +05:30
Muhsin Keloth 712054a8b7 Update WhatsappEmbeddedSignup.vue 2025-06-10 14:48:22 +05:30
Muhsin KelothandGitHub 7a4dfd5d4b Merge branch 'develop' into feat/whatsapp-embedded-signup 2025-06-10 14:29:55 +05:30
Tanmay Deep Sharma a17b7815e3 Merge remote-tracking branch 'origin/feat/whatsapp-embedded-signup' into feat/whatsapp-embedded-signup 2025-06-10 14:23:42 +05:30
Tanmay Deep Sharma 8025cd5aab sanitise the phone number before inserting to the database 2025-06-10 14:23:17 +05:30
Muhsin Keloth 7319d822fd chore: minor fixes 2025-06-10 09:54:42 +05:30
Muhsin Keloth 23a06739fc Update WhatsappEmbeddedSignup.vue 2025-06-09 16:00:35 +05:30
Muhsin Keloth 0771d0f269 chore: style fixes 2025-06-09 15:54:58 +05:30
Muhsin Keloth eb6c4a258a chore: code cleanups 2025-06-09 14:53:35 +05:30
Tanmay Deep Sharma 317a8ee4d5 Merge remote-tracking branch 'origin/feat/whatsapp-embedded-signup' into feat/whatsapp-embedded-signup 2025-06-09 10:26:22 +05:30
Tanmay Deep Sharma 4a7a68e6e7 fix: update WhatsApp GlobalConfigService.load calls 2025-06-09 10:24:26 +05:30
Tanmay Deep SharmaandGitHub 4c21a8d7b4 Merge branch 'develop' into feat/whatsapp-embedded-signup 2025-06-09 09:47:18 +05:30
Tanmay Deep Sharma 71ddd6c2c2 update the callback url, dynamically 2025-06-09 02:38:19 +05:30
Tanmay Deep Sharma 34852fccc1 update step four content in case of embedded signup 2025-06-09 02:35:13 +05:30
Tanmay Deep Sharma 149085e3d8 add webhook registration to embedded signup 2025-06-09 02:08:32 +05:30
Tanmay Deep Sharma e0b52c3fc2 add webhook registration page as well 2025-06-09 02:02:21 +05:30
Tanmay Deep Sharma 806ddaa9ab remove developer docs files 2025-06-08 18:17:13 +05:30
Tanmay Deep Sharma f24514433b register phone number on cloud, for sending messages 2025-06-08 16:33:14 +05:30
Tanmay Deep Sharma 700ce0540a Merge remote-tracking branch 'origin/feat/whatsapp-embedded-signup' into feat/whatsapp-embedded-signup 2025-06-08 16:16:32 +05:30
Tanmay Deep Sharma 7cc082e586 incorporate the new UI designs for whatsapp inbox 2025-06-08 16:11:23 +05:30
Tanmay Deep Sharma 392fa50aee Merge branch 'develop' into feat/whatsapp-embedded-signup 2025-06-06 13:34:39 +05:30
Muhsin KelothandGitHub 3582d47299 Merge branch 'develop' into feat/whatsapp-embedded-signup 2025-05-29 17:21:39 +05:30
Muhsin KelothandGitHub 06e4708e12 Merge branch 'develop' into feat/whatsapp-embedded-signup 2025-05-29 13:27:39 +05:30
Tanmay Deep Sharma 9e40a79958 Merge remote-tracking branch 'origin/feat/whatsapp-embedded-signup' into feat/whatsapp-embedded-signup 2025-05-29 13:05:15 +05:30
Tanmay Deep Sharma 947e4b59e8 remove manual whastapp registeration 2025-05-29 13:02:49 +05:30
Muhsin KelothandGitHub 8816e8651b Merge branch 'develop' into feat/whatsapp-embedded-signup 2025-05-29 10:20:04 +05:30
Tanmay Deep Sharma f429db89b4 add mintlify intro docs 2025-05-28 23:42:34 +05:30
Tanmay Deep Sharma 203c010846 Merge remote-tracking branch 'origin/develop' into feat/whatsapp-embedded-signup 2025-05-28 22:23:06 +05:30
Tanmay Deep Sharma e1fdc30c31 fix circleci checks 2025-05-28 20:59:08 +05:30
Tanmay Deep Sharma 1eca659b93 Merge remote-tracking branch 'origin/feat/whatsapp-embedded-signup' into feat/whatsapp-embedded-signup 2025-05-28 20:23:12 +05:30
Tanmay Deep Sharma 7a70cea36b fix circleci checks 2025-05-28 20:22:37 +05:30
Tanmay Deep SharmaGitHubCopilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
7c4e8346f8 Potential fix for code scanning alert no. 97: Incomplete URL substring sanitization
Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
2025-05-28 20:22:03 +05:30
Tanmay Deep SharmaGitHubCopilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
e4cd585513 Potential fix for code scanning alert no. 95: Clear-text storage of sensitive information
Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
2025-05-28 20:20:37 +05:30
Tanmay Deep Sharma 9e5e90c258 remove un-necessary changes 2025-05-28 20:10:56 +05:30
Tanmay Deep Sharma c8a66b9ebb feat: add Whatsapp embedded signup API 2025-05-28 20:05:57 +05:30
Muhsin Keloth 454686ab5d chore: add front end changes 2025-05-19 14:12:43 +05:30
26 changed files with 2278 additions and 38 deletions
+2
View File
@@ -67,6 +67,8 @@ class DashboardController < ActionController::Base
FB_APP_ID: GlobalConfigService.load('FB_APP_ID', ''),
INSTAGRAM_APP_ID: GlobalConfigService.load('INSTAGRAM_APP_ID', ''),
FACEBOOK_API_VERSION: GlobalConfigService.load('FACEBOOK_API_VERSION', 'v17.0'),
WHATSAPP_APP_ID: GlobalConfigService.load('WHATSAPP_APP_ID', ''),
WHATSAPP_CONFIGURATION_ID: GlobalConfigService.load('WHATSAPP_CONFIGURATION_ID', ''),
IS_ENTERPRISE: ChatwootApp.enterprise?,
AZURE_APP_ID: GlobalConfigService.load('AZURE_APP_ID', ''),
GIT_SHA: GIT_HASH
@@ -39,8 +39,9 @@ class SuperAdmin::AppConfigsController < SuperAdmin::ApplicationController
'email' => ['MAILER_INBOUND_EMAIL_DOMAIN'],
'linear' => %w[LINEAR_CLIENT_ID LINEAR_CLIENT_SECRET],
'slack' => %w[SLACK_CLIENT_ID SLACK_CLIENT_SECRET],
'notion' => %w[NOTION_CLIENT_ID NOTION_CLIENT_SECRET],
'instagram' => %w[INSTAGRAM_APP_ID INSTAGRAM_APP_SECRET INSTAGRAM_VERIFY_TOKEN INSTAGRAM_API_VERSION ENABLE_INSTAGRAM_CHANNEL_HUMAN_AGENT]
'instagram' => %w[INSTAGRAM_APP_ID INSTAGRAM_APP_SECRET INSTAGRAM_VERIFY_TOKEN INSTAGRAM_API_VERSION ENABLE_INSTAGRAM_CHANNEL_HUMAN_AGENT],
'whatsapp_embedded' => %w[WHATSAPP_APP_ID WHATSAPP_APP_SECRET WHATSAPP_CONFIGURATION_ID WHATSAPP_API_VERSION],
'notion' => %w[NOTION_CLIENT_ID NOTION_CLIENT_SECRET]
}
@allowed_configs = mapping.fetch(@config, %w[ENABLE_ACCOUNT_SIGNUP FIREBASE_PROJECT_ID FIREBASE_CREDENTIALS])
@@ -0,0 +1,125 @@
class Whatsapp::EmbeddedController < ApplicationController
include EnsureCurrentAccountHelper
before_action :authenticate_user!
before_action :current_account
before_action :check_authorization
def new
# Configuration endpoint for embedded signup initialization
render json: {
status: 'ready',
app_id: GlobalConfigService.load('WHATSAPP_APP_ID', ''),
config_id: GlobalConfigService.load('WHATSAPP_CONFIGURATION_ID', ''),
app_secret: GlobalConfigService.load('WHATSAPP_APP_SECRET', '')
}
end
def embedded_signup
# Complete embedded signup using auth code + business info from frontend
validate_authorization_code!
return if performed?
validate_required_parameters!
return if performed?
channel = process_signup
@inbox = channel.inbox
# Return the inbox object using the standard jbuilder template
render 'embedded_signup', formats: [:json]
rescue StandardError => e
handle_signup_error(e)
end
def reauthorize
# Reauthorize existing WhatsApp inbox using embedded signup flow
validate_authorization_code!
return if performed?
validate_required_parameters!
return if performed?
validate_inbox_id!
return if performed?
channel = process_reauthorization
@inbox = channel.inbox
# Clear reauthorization required flag
channel.reauthorized!
render json: { message: 'WhatsApp channel reauthorized successfully' }, status: :ok
rescue StandardError => e
handle_signup_error(e)
end
private
def validate_authorization_code!
return if params[:code].present?
render json: {
error: 'Missing authorization code',
message: 'Authorization code is required for embedded signup'
}, status: :bad_request
end
def validate_required_parameters!
return if params[:business_id].present? && params[:waba_id].present?
render json: {
error: 'Missing required parameters: business_id and waba_id are required'
}, status: :bad_request
end
def validate_inbox_id!
return if params[:inbox_id].present?
render json: {
error: 'Missing inbox_id parameter'
}, status: :bad_request
end
def process_signup
service = Whatsapp::EmbeddedSignupService.new(
{
account: Current.account,
code: params[:code],
business_id: params[:business_id],
waba_id: params[:waba_id],
phone_number_id: params[:phone_number_id]
}
)
service.perform
end
def process_reauthorization
service = Whatsapp::EmbeddedSignupService.new(
{
account: Current.account,
code: params[:code],
business_id: params[:business_id],
waba_id: params[:waba_id],
phone_number_id: params[:phone_number_id],
inbox_id: params[:inbox_id]
}
)
service.perform_reauthorization
end
def handle_signup_error(error)
Rails.logger.error("WhatsApp embedded signup processing error: #{error.message}")
Rails.logger.error(error.backtrace.join("\n"))
render json: {
error: 'Internal server error',
message: error.message
}, status: :internal_server_error
end
def check_authorization
authorize(Current.account, :update?)
end
end
Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

@@ -0,0 +1,355 @@
import { ref, computed } from 'vue';
import { useStore } from 'vuex';
import { useRouter } from 'vue-router';
import { useI18n } from 'vue-i18n';
import { useAlert } from 'dashboard/composables';
import Auth from 'dashboard/api/auth';
export function useWhatsappEmbeddedSignup() {
const store = useStore();
const router = useRouter();
const { t } = useI18n();
// 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);
// Computed
const authHeaders = computed(() => {
if (Auth.hasAuthCookie()) {
const {
'access-token': accessToken,
'token-type': tokenType,
client,
expiry,
uid,
} = Auth.getAuthData();
return {
'access-token': accessToken,
'token-type': tokenType,
client,
expiry,
uid,
};
}
return {};
});
const benefits = computed(() => [
{
key: 'EASY_SETUP',
text: t('INBOX_MGMT.ADD.WHATSAPP.EMBEDDED_SIGNUP.BENEFITS.EASY_SETUP'),
},
{
key: 'SECURE_AUTH',
text: t('INBOX_MGMT.ADD.WHATSAPP.EMBEDDED_SIGNUP.BENEFITS.SECURE_AUTH'),
},
{
key: 'AUTO_CONFIG',
text: t('INBOX_MGMT.ADD.WHATSAPP.EMBEDDED_SIGNUP.BENEFITS.AUTO_CONFIG'),
},
]);
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 = data => {
isProcessing.value = false;
authCodeReceived.value = false;
isAuthenticating.value = false;
let message = t('INBOX_MGMT.ADD.WHATSAPP.EMBEDDED_SIGNUP.CANCELLED');
if (data.data?.current_step) {
message += ` (Step: ${data.data.current_step})`;
}
useAlert(message);
};
const handleSignupSuccess = inboxData => {
isProcessing.value = false;
isAuthenticating.value = false;
// Update the store with the new inbox data
if (inboxData && inboxData.id) {
// Add the new inbox to the store
store.commit('inboxes/ADD_INBOXES', inboxData);
useAlert(t('INBOX_MGMT.FINISH.MESSAGE'));
router.replace({
name: 'settings_inboxes_add_agents',
params: {
page: 'new',
inbox_id: inboxData.id,
},
});
} else {
// Fallback if inbox data is not properly formatted
useAlert(t('INBOX_MGMT.ADD.WHATSAPP.EMBEDDED_SIGNUP.SUCCESS_FALLBACK'));
router.replace({
name: 'settings_inbox_list',
});
}
};
// Signup flow
const completeSignupFlow = async businessDataParam => {
if (!authCodeReceived.value || !authCode.value) {
handleSignupError({
error: t('INBOX_MGMT.ADD.WHATSAPP.EMBEDDED_SIGNUP.AUTH_NOT_COMPLETED'),
});
return;
}
isProcessing.value = true;
processingMessage.value = t(
'INBOX_MGMT.ADD.WHATSAPP.EMBEDDED_SIGNUP.PROCESSING'
);
try {
// Send both auth code and business info together (synchronous flow)
const accountId = store.getters.getCurrentAccountId;
const response = await fetch('/whatsapp/embedded_signup', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': document
.querySelector('meta[name="csrf-token"]')
?.getAttribute('content'),
...authHeaders.value,
},
body: JSON.stringify({
account_id: accountId,
code: authCode.value,
business_id: businessDataParam.business_id,
waba_id: businessDataParam.waba_id,
phone_number_id: businessDataParam.phone_number_id,
}),
});
const responseData = await response.json();
if (response.ok) {
// Clear the stored auth code for security
authCode.value = null;
// Handle synchronous success response
handleSignupSuccess(responseData);
} else {
throw new Error(responseData.message || responseData.error);
}
} catch (error) {
handleSignupError({ error: error.message });
}
};
const isValidBusinessData = businessDataLocal => {
return (
businessDataLocal &&
(businessDataLocal.business_id || businessDataLocal.businessId) &&
(businessDataLocal.waba_id || businessDataLocal.wabaId)
);
};
// Message handling
const handleEmbeddedSignupData = async data => {
// Handle different embedded signup events per Facebook documentation
if (data.event === 'FINISH') {
// Facebook might send business data in different structures
let businessDataLocal = data.data;
// If data.data doesn't exist, try other possible structures
if (!businessDataLocal) {
businessDataLocal = data.business_data || data.details || data;
}
// Validate we have the required business information
if (isValidBusinessData(businessDataLocal)) {
// Normalize the data structure to match our backend expectations
const normalizedData = {
business_id:
businessDataLocal.business_id || businessDataLocal.businessId,
waba_id: businessDataLocal.waba_id || businessDataLocal.wabaId,
phone_number_id:
businessDataLocal.phone_number_id ||
businessDataLocal.phoneNumberId ||
businessDataLocal.phone_id,
};
// Store business data
businessData.value = normalizedData;
// Check if we already have auth code and process immediately
if (authCodeReceived.value && authCode.value) {
await completeSignupFlow(normalizedData);
} 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(data);
} 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 fbLoginCallback = response => {
if (response.authResponse && response.authResponse.code) {
// Authorization code received from Facebook
authCode.value = response.authResponse.code;
authCodeReceived.value = true;
processingMessage.value = t(
'INBOX_MGMT.ADD.WHATSAPP.EMBEDDED_SIGNUP.WAITING_FOR_BUSINESS_INFO'
);
// Check if we already have business data and process immediately
if (businessData.value) {
completeSignupFlow(businessData.value);
}
} else if (response.error) {
handleSignupError({ error: response.error });
} else {
isProcessing.value = false;
isAuthenticating.value = false;
useAlert(t('INBOX_MGMT.ADD.WHATSAPP.EMBEDDED_SIGNUP.CANCELLED'));
}
};
const handleSignupMessage = event => {
// Handle Facebook embedded signup message events
try {
const originUrl = new URL(event.origin);
const allowedHosts = ['facebook.com', 'www.facebook.com'];
if (!allowedHosts.includes(originUrl.hostname)) return;
} catch (error) {
// Invalid origin URL, reject the event
return;
}
try {
const data = JSON.parse(event.data);
if (data.type === 'WA_EMBEDDED_SIGNUP') {
handleEmbeddedSignupData(data);
}
} catch (error) {
// Handle non-JSON messages silently
}
};
// Facebook SDK
const loadFacebookSdk = () => {
if (window.FB) {
fbSdkLoaded.value = true;
return;
}
const script = document.createElement('script');
script.src = 'https://connect.facebook.net/en_US/sdk.js';
script.async = true;
script.defer = true;
script.onload = () => {
window.FB.init({
appId: window.chatwootConfig?.whatsappAppId,
status: true,
xfbml: true,
version: window.chatwootConfig?.whatsappApiVersion || 'v22.0',
});
fbSdkLoaded.value = true;
};
document.body.appendChild(script);
};
const launchEmbeddedSignup = () => {
processingMessage.value = t(
'INBOX_MGMT.ADD.WHATSAPP.EMBEDDED_SIGNUP.AUTH_PROCESSING'
);
if (!window.FB) {
loadFacebookSdk();
setTimeout(() => launchEmbeddedSignup(), 1000);
return;
}
isAuthenticating.value = true;
// Following Facebook's embedded signup documentation
window.FB.login(fbLoginCallback, {
config_id: window.chatwootConfig?.whatsappConfigurationId,
response_type: 'code',
override_default_response_type: true,
extras: {
setup: {},
featureType: '', // Leave empty for default flow
sessionInfoVersion: '3',
},
});
};
// Lifecycle
const setupMessageListener = () => {
window.addEventListener('message', handleSignupMessage);
};
const cleanupMessageListener = () => {
window.removeEventListener('message', handleSignupMessage);
};
// Initialize
const initialize = () => {
loadFacebookSdk();
setupMessageListener();
};
return {
// State
fbSdkLoaded,
isProcessing,
processingMessage,
authCodeReceived,
authCode,
businessData,
isAuthenticating,
// Computed
benefits,
showLoader,
// Methods
launchEmbeddedSignup,
initialize,
cleanupMessageListener,
};
}
@@ -0,0 +1,317 @@
import { ref, computed } from 'vue';
import { useStore } from 'vuex';
import { useRouter } from 'vue-router';
import { useI18n } from 'vue-i18n';
import { useAlert } from 'dashboard/composables';
import Auth from 'dashboard/api/auth';
export function useWhatsappReauthorization(inboxId) {
const store = useStore();
const router = useRouter();
const { t } = useI18n();
// 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);
// Computed
const authHeaders = computed(() => {
if (Auth.hasAuthCookie()) {
const {
'access-token': accessToken,
'token-type': tokenType,
client,
expiry,
uid,
} = Auth.getAuthData();
return {
'access-token': accessToken,
'token-type': tokenType,
client,
expiry,
uid,
};
}
return {};
});
const showLoader = computed(
() => isAuthenticating.value || isProcessing.value
);
// Error handling
const handleReauthorizationError = data => {
isProcessing.value = false;
authCodeReceived.value = false;
isAuthenticating.value = false;
const errorMessage =
data.error ||
data.message ||
t('INBOX_MGMT.SETTINGS.WHATSAPP_REAUTHORIZE.ERROR');
useAlert(errorMessage);
};
const handleReauthorizationCancellation = () => {
isProcessing.value = false;
authCodeReceived.value = false;
isAuthenticating.value = false;
useAlert(t('INBOX_MGMT.SETTINGS.WHATSAPP_REAUTHORIZE.CANCELLED'));
};
const handleReauthorizationSuccess = async () => {
isProcessing.value = false;
isAuthenticating.value = false;
// Refresh the inbox to update reauthorization status
await store.dispatch('inboxes/get');
useAlert(t('INBOX_MGMT.SETTINGS.WHATSAPP_REAUTHORIZE.SUCCESS'));
// Reload the page to reflect the changes
router.go(0);
};
// Reauthorization flow
const completeReauthorizationFlow = async businessDataParam => {
if (!authCodeReceived.value || !authCode.value) {
handleReauthorizationError({
error: t('INBOX_MGMT.SETTINGS.WHATSAPP_REAUTHORIZE.AUTH_NOT_COMPLETED'),
});
return;
}
isProcessing.value = true;
processingMessage.value = t(
'INBOX_MGMT.SETTINGS.WHATSAPP_REAUTHORIZE.PROCESSING'
);
try {
// Send reauthorization request
const accountId = store.getters.getCurrentAccountId;
const response = await fetch('/whatsapp/reauthorize', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': document
.querySelector('meta[name="csrf-token"]')
?.getAttribute('content'),
...authHeaders.value,
},
body: JSON.stringify({
account_id: accountId,
inbox_id: inboxId,
code: authCode.value,
business_id: businessDataParam.business_id,
waba_id: businessDataParam.waba_id,
phone_number_id: businessDataParam.phone_number_id,
}),
});
const responseData = await response.json();
if (response.ok) {
// Clear the stored auth code for security
authCode.value = null;
// Handle synchronous success response
handleReauthorizationSuccess();
} else {
throw new Error(responseData.message || responseData.error);
}
} catch (error) {
handleReauthorizationError({ error: error.message });
}
};
const isValidBusinessData = businessDataLocal => {
return (
businessDataLocal &&
(businessDataLocal.business_id || businessDataLocal.businessId) &&
(businessDataLocal.waba_id || businessDataLocal.wabaId)
);
};
// Message handling
const handleEmbeddedSignupData = async data => {
// Handle different embedded signup events per Facebook documentation
if (data.event === 'FINISH') {
// Facebook might send business data in different structures
let businessDataLocal = data.data;
// If data.data doesn't exist, try other possible structures
if (!businessDataLocal) {
businessDataLocal = data.business_data || data.details || data;
}
// Validate we have the required business information
if (isValidBusinessData(businessDataLocal)) {
// Normalize the data structure to match our backend expectations
const normalizedData = {
business_id:
businessDataLocal.business_id || businessDataLocal.businessId,
waba_id: businessDataLocal.waba_id || businessDataLocal.wabaId,
phone_number_id:
businessDataLocal.phone_number_id ||
businessDataLocal.phoneNumberId ||
businessDataLocal.phone_id,
};
// Store business data
businessData.value = normalizedData;
// Check if we already have auth code and process immediately
if (authCodeReceived.value && authCode.value) {
await completeReauthorizationFlow(normalizedData);
} else {
processingMessage.value = t(
'INBOX_MGMT.SETTINGS.WHATSAPP_REAUTHORIZE.WAITING_FOR_AUTH'
);
}
} else {
handleReauthorizationError({
error: t(
'INBOX_MGMT.SETTINGS.WHATSAPP_REAUTHORIZE.INVALID_BUSINESS_DATA'
),
});
}
} else if (data.event === 'CANCEL') {
handleReauthorizationCancellation();
} else if (data.event === 'error') {
handleReauthorizationError({
error:
data.error_message ||
t('INBOX_MGMT.SETTINGS.WHATSAPP_REAUTHORIZE.ERROR'),
});
}
};
const fbLoginCallback = response => {
if (response.authResponse && response.authResponse.code) {
// Authorization code received from Facebook
authCode.value = response.authResponse.code;
authCodeReceived.value = true;
processingMessage.value = t(
'INBOX_MGMT.SETTINGS.WHATSAPP_REAUTHORIZE.WAITING_FOR_BUSINESS_INFO'
);
// Check if we already have business data and process immediately
if (businessData.value) {
completeReauthorizationFlow(businessData.value);
}
} else if (response.error) {
handleReauthorizationError({ error: response.error });
} else {
isProcessing.value = false;
isAuthenticating.value = false;
useAlert(t('INBOX_MGMT.SETTINGS.WHATSAPP_REAUTHORIZE.CANCELLED'));
}
};
const handleSignupMessage = event => {
// Handle Facebook embedded signup message events
try {
const originUrl = new URL(event.origin);
const allowedHosts = ['facebook.com', 'www.facebook.com'];
if (!allowedHosts.includes(originUrl.hostname)) return;
} catch (error) {
// Invalid origin URL, reject the event
return;
}
try {
const data = JSON.parse(event.data);
if (data.type === 'WA_EMBEDDED_SIGNUP') {
handleEmbeddedSignupData(data);
}
} catch (error) {
// Handle non-JSON messages silently
}
};
// Facebook SDK
const loadFacebookSdk = () => {
if (window.FB) {
fbSdkLoaded.value = true;
return;
}
const script = document.createElement('script');
script.src = 'https://connect.facebook.net/en_US/sdk.js';
script.async = true;
script.defer = true;
script.onload = () => {
window.FB.init({
appId: window.chatwootConfig?.whatsappAppId,
status: true,
xfbml: true,
version: window.chatwootConfig?.whatsappApiVersion || 'v22.0',
});
fbSdkLoaded.value = true;
};
document.body.appendChild(script);
};
const launchReauthorization = () => {
processingMessage.value = t(
'INBOX_MGMT.SETTINGS.WHATSAPP_REAUTHORIZE.AUTH_PROCESSING'
);
if (!window.FB) {
loadFacebookSdk();
setTimeout(() => launchReauthorization(), 1000);
return;
}
isAuthenticating.value = true;
// Following Facebook's embedded signup documentation
// Using auth_type: 'reauthorize' to force reauthorization
window.FB.login(fbLoginCallback, {
config_id: window.chatwootConfig?.whatsappConfigurationId,
response_type: 'code',
override_default_response_type: true,
auth_type: 'reauthorize',
extras: {
setup: {},
featureType: '', // Leave empty for default flow
sessionInfoVersion: '3',
},
});
};
// Lifecycle
const setupMessageListener = () => {
window.addEventListener('message', handleSignupMessage);
};
const cleanupMessageListener = () => {
window.removeEventListener('message', handleSignupMessage);
};
// Initialize
const initialize = () => {
loadFacebookSdk();
setupMessageListener();
};
return {
// State
fbSdkLoaded,
isProcessing,
processingMessage,
isAuthenticating,
// Computed
showLoader,
// Methods
launchReauthorization,
initialize,
cleanupMessageListener,
};
}
@@ -222,10 +222,17 @@
"DESC": "Start supporting your customers via WhatsApp.",
"PROVIDERS": {
"LABEL": "API Provider",
"WHATSAPP_EMBEDDED": "WhatsApp Business",
"TWILIO": "Twilio",
"WHATSAPP_CLOUD": "WhatsApp Cloud",
"WHATSAPP_CLOUD_DESC": "Quick setup through Meta",
"TWILIO_DESC": "Connect via Twilio credentials",
"360_DIALOG": "360Dialog"
},
"SELECT_PROVIDER": {
"TITLE": "Select your API provider",
"DESCRIPTION": "Choose your WhatsApp provider. You can connect directly through Meta which requires no setup, or connect through Twilio using your account credentials."
},
"INBOX_NAME": {
"LABEL": "Inbox Name",
"PLACEHOLDER": "Please enter an inbox name",
@@ -264,6 +271,28 @@
"WEBHOOK_VERIFICATION_TOKEN": "Webhook Verification Token"
},
"SUBMIT_BUTTON": "Create WhatsApp Channel",
"EMBEDDED_SIGNUP": {
"TITLE": "Quick Setup with Meta",
"DESC": "You will be redirected to Meta to log into your WhatsApp Business account. Having admin access will help make the setup smooth and easy.",
"BENEFITS": {
"TITLE": "Benefits of Embedded Signup:",
"EASY_SETUP": "No manual configuration required",
"SECURE_AUTH": "Secure OAuth based authentication",
"AUTO_CONFIG": "Automatic webhook and phone number configuration"
},
"SUBMIT_BUTTON": "Connect with WhatsApp Business",
"AUTH_PROCESSING": "Authenticating with Meta",
"WAITING_FOR_BUSINESS_INFO": "Please complete business setup in the Meta window...",
"PROCESSING": "Setting up your WhatsApp Business Account",
"LOADING_SDK": "Loading Facebook SDK...",
"CANCELLED": "WhatsApp Signup was cancelled",
"SUCCESS_TITLE": "WhatsApp Business Account Connected!",
"WAITING_FOR_AUTH": "Waiting for authentication...",
"INVALID_BUSINESS_DATA": "Invalid business data received from Facebook. Please try again.",
"SIGNUP_ERROR": "Signup error occurred",
"AUTH_NOT_COMPLETED": "Authentication not completed. Please restart the process.",
"SUCCESS_FALLBACK": "WhatsApp Business Account has been successfully configured"
},
"API": {
"ERROR_MESSAGE": "We were not able to save the WhatsApp channel"
}
@@ -571,7 +600,24 @@
"WHATSAPP_SECTION_UPDATE_BUTTON": "Update",
"WHATSAPP_WEBHOOK_TITLE": "Webhook Verification Token",
"WHATSAPP_WEBHOOK_SUBHEADER": "This token is used to verify the authenticity of the webhook endpoint.",
"UPDATE_PRE_CHAT_FORM_SETTINGS": "Update Pre Chat Form Settings"
"WHATSAPP_RECONFIGURE_TITLE": "Reconfigure WhatsApp",
"WHATSAPP_RECONFIGURE_SUBHEADER": "Update your WhatsApp Business account configuration using the embedded signup flow.",
"WHATSAPP_RECONFIGURE_BUTTON": "Reconfigure with WhatsApp",
"WHATSAPP_RECONFIGURE_ERROR": "Failed to initiate WhatsApp reconfiguration. Please try again.",
"UPDATE_PRE_CHAT_FORM_SETTINGS": "Update Pre Chat Form Settings",
"WHATSAPP_REAUTHORIZE": {
"TITLE": "Reauthorize WhatsApp Connection",
"DESCRIPTION": "Your WhatsApp Business account needs to be reauthorized to continue receiving messages. Click the button below to reconnect your account.",
"AUTH_PROCESSING": "Processing WhatsApp reauthorization...",
"WAITING_FOR_BUSINESS_INFO": "Waiting for business information...",
"WAITING_FOR_AUTH": "Waiting for authentication...",
"PROCESSING": "Updating your WhatsApp configuration...",
"SUCCESS": "WhatsApp successfully reauthorized!",
"ERROR": "Failed to reauthorize WhatsApp. Please try again.",
"CANCELLED": "WhatsApp reauthorization was cancelled.",
"AUTH_NOT_COMPLETED": "Authentication not completed. Please try again.",
"INVALID_BUSINESS_DATA": "Invalid business data received. Please try again."
}
},
"HELP_CENTER": {
"LABEL": "Help Center",
@@ -591,6 +637,12 @@
"MESSAGE_SUCCESS": "Reconnection successful",
"MESSAGE_ERROR": "There was an error, please try again"
},
"WHATSAPP_REAUTHORIZE": {
"TITLE": "Reauthorize WhatsApp",
"SUBTITLE": "Your WhatsApp connection has expired, please reconnect your WhatsApp Business account to continue services",
"MESSAGE_SUCCESS": "WhatsApp reconnection successful",
"MESSAGE_ERROR": "There was an error reconnecting WhatsApp, please try again"
},
"PRE_CHAT_FORM": {
"DESCRIPTION": "Pre chat forms enable you to capture user information before they start conversation with you.",
"SET_FIELDS": "Pre chat form fields",
@@ -47,6 +47,13 @@ export default {
this.currentInbox.provider === 'whatsapp_cloud'
);
},
// If the inbox is a whatsapp cloud inbox and the source is not embedded signup, then show the webhook details
shouldShowWhatsAppWebhookDetails() {
return (
this.isWhatsAppCloudInbox &&
this.currentInbox.provider_config?.source !== 'embedded_signup'
);
},
message() {
if (this.isATwilioInbox) {
return `${this.$t('INBOX_MGMT.FINISH.MESSAGE')}. ${this.$t(
@@ -66,7 +73,7 @@ export default {
)}`;
}
if (this.isWhatsAppCloudInbox) {
if (this.isWhatsAppCloudInbox && this.shouldShowWhatsAppWebhookDetails) {
return `${this.$t('INBOX_MGMT.FINISH.MESSAGE')}. ${this.$t(
'INBOX_MGMT.ADD.WHATSAPP.API_CALLBACK.SUBTITLE'
)}`;
@@ -113,8 +120,11 @@ export default {
:script="currentInbox.callback_webhook_url"
/>
</div>
<div v-if="isWhatsAppCloudInbox" class="w-[50%] max-w-[50%] ml-[25%]">
<p class="mt-8 font-medium text-n-slate-11">
<div
v-if="shouldShowWhatsAppWebhookDetails"
class="w-[50%] max-w-[50%] ml-[25%]"
>
<p class="mt-8 font-medium text-slate-700 dark:text-slate-200">
{{ $t('INBOX_MGMT.ADD.WHATSAPP.API_CALLBACK.WEBHOOK_URL') }}
</p>
<woot-code lang="html" :script="currentInbox.callback_webhook_url" />
@@ -11,6 +11,7 @@ import InstagramReauthorize from './channels/instagram/Reauthorize.vue';
import DuplicateInboxBanner from './channels/instagram/DuplicateInboxBanner.vue';
import MicrosoftReauthorize from './channels/microsoft/Reauthorize.vue';
import GoogleReauthorize from './channels/google/Reauthorize.vue';
import WhatsappReauthorize from './channels/whatsapp/Reauthorize.vue';
import PreChatFormSettings from './PreChatForm/Settings.vue';
import WeeklyAvailability from './components/WeeklyAvailability.vue';
import GreetingsEditor from 'shared/components/GreetingsEditor.vue';
@@ -45,6 +46,7 @@ export default {
NextButton,
InstagramReauthorize,
DuplicateInboxBanner,
WhatsappReauthorize,
Editor,
},
mixins: [inboxMixin],
@@ -231,6 +233,11 @@ export default {
facebookUnauthorized() {
return this.isAFacebookInbox && this.inbox.reauthorization_required;
},
whatsappUnauthorized() {
return (
this.isAWhatsAppCloudChannel && this.inbox.reauthorization_required
);
},
googleUnauthorized() {
const isLegacyInbox = ['imap.gmail.com', 'imap.google.com'].includes(
this.inbox.imap_address
@@ -410,6 +417,7 @@ export default {
<FacebookReauthorize v-if="facebookUnauthorized" :inbox="inbox" />
<GoogleReauthorize v-if="googleUnauthorized" :inbox="inbox" />
<InstagramReauthorize v-if="instagramUnauthorized" :inbox="inbox" />
<WhatsappReauthorize v-if="whatsappUnauthorized" :inbox="inbox" />
<DuplicateInboxBanner
v-if="hasDuplicateInstagramInbox"
:content="$t('INBOX_MGMT.ADD.INSTAGRAM.DUPLICATE_INBOX_BANNER')"
@@ -1,48 +1,140 @@
<script>
import PageHeader from '../../SettingsSubPageHeader.vue';
<script setup>
import { computed } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { useI18n } from 'vue-i18n';
import Twilio from './Twilio.vue';
import ThreeSixtyDialogWhatsapp from './360DialogWhatsapp.vue';
import CloudWhatsapp from './CloudWhatsapp.vue';
import WhatsappEmbeddedSignup from './WhatsappEmbeddedSignup.vue';
import whatsappIcon from 'dashboard/assets/images/whatsapp.png';
import twilioIcon from 'dashboard/assets/images/twilio.png';
export default {
components: {
PageHeader,
Twilio,
ThreeSixtyDialogWhatsapp,
CloudWhatsapp,
const route = useRoute();
const router = useRouter();
const { t } = useI18n();
const PROVIDER_TYPES = {
WHATSAPP: 'whatsapp',
TWILIO: 'twilio',
WHATSAPP_CLOUD: 'whatsapp_cloud',
WHATSAPP_EMBEDDED: 'whatsapp_embedded',
THREE_SIXTY_DIALOG: '360dialog',
};
const hasWhatsappAppId = computed(() => {
return (
window.chatwootConfig?.whatsappAppId &&
window.chatwootConfig.whatsappAppId !== 'none'
);
});
const selectedProvider = computed(() => route.query.provider);
const showProviderSelection = computed(() => !selectedProvider.value);
const showConfiguration = computed(() => Boolean(selectedProvider.value));
const availableProviders = computed(() => [
{
value: PROVIDER_TYPES.WHATSAPP,
label: t('INBOX_MGMT.ADD.WHATSAPP.PROVIDERS.WHATSAPP_CLOUD'),
description: t('INBOX_MGMT.ADD.WHATSAPP.PROVIDERS.WHATSAPP_CLOUD_DESC'),
icon: whatsappIcon,
},
data() {
return {
provider: 'whatsapp_cloud',
};
{
value: PROVIDER_TYPES.TWILIO,
label: t('INBOX_MGMT.ADD.WHATSAPP.PROVIDERS.TWILIO'),
description: t('INBOX_MGMT.ADD.WHATSAPP.PROVIDERS.TWILIO_DESC'),
icon: twilioIcon,
},
]);
const selectProvider = providerValue => {
router.push({
name: route.name,
params: route.params,
query: { provider: providerValue },
});
};
const shouldShowEmbeddedSignup = provider => {
return (
(provider === PROVIDER_TYPES.WHATSAPP && hasWhatsappAppId.value) ||
provider === PROVIDER_TYPES.WHATSAPP_EMBEDDED
);
};
const shouldShowCloudWhatsapp = provider => {
return provider === PROVIDER_TYPES.WHATSAPP && !hasWhatsappAppId.value;
};
</script>
<template>
<div
class="border border-n-weak bg-n-solid-1 rounded-t-lg border-b-0 h-full w-full p-6 col-span-6 overflow-auto"
class="w-full h-full col-span-6 p-6 overflow-auto border border-b-0 rounded-t-lg border-n-weak bg-n-solid-1"
>
<PageHeader
:header-title="$t('INBOX_MGMT.ADD.WHATSAPP.TITLE')"
:header-content="$t('INBOX_MGMT.ADD.WHATSAPP.DESC')"
/>
<div class="flex-shrink-0 flex-grow-0">
<label>
{{ $t('INBOX_MGMT.ADD.WHATSAPP.PROVIDERS.LABEL') }}
<select v-model="provider">
<option value="whatsapp_cloud">
{{ $t('INBOX_MGMT.ADD.WHATSAPP.PROVIDERS.WHATSAPP_CLOUD') }}
</option>
<option value="twilio">
{{ $t('INBOX_MGMT.ADD.WHATSAPP.PROVIDERS.TWILIO') }}
</option>
</select>
</label>
<!-- Provider Selection View -->
<div v-if="showProviderSelection">
<div class="mb-10 text-left">
<h1 class="mb-2 text-lg font-medium text-slate-12">
{{ $t('INBOX_MGMT.ADD.WHATSAPP.SELECT_PROVIDER.TITLE') }}
</h1>
<p class="text-sm leading-relaxed text-slate-11">
{{ $t('INBOX_MGMT.ADD.WHATSAPP.SELECT_PROVIDER.DESCRIPTION') }}
</p>
</div>
<!-- Dynamic Provider Cards -->
<div class="flex justify-start gap-6">
<div
v-for="provider in availableProviders"
:key="provider.value"
class="gap-6 px-5 py-6 transition-all duration-200 border cursor-pointer w-96 border-n-weak rounded-2xl hover:bg-n-slate-3"
@click="selectProvider(provider.value)"
>
<!-- Provider Icon -->
<div class="flex justify-start mb-5">
<div
class="flex items-center justify-center size-10 bg-n-alpha-2 rounded-full"
>
<img
:src="provider.icon"
:alt="provider.label"
class="object-contain size-[26px]"
/>
</div>
</div>
<!-- Card Content -->
<div class="text-start">
<h3 class="mb-1.5 text-sm font-medium text-slate-12">
{{ provider.label }}
</h3>
<p class="text-sm text-slate-11">
{{ provider.description }}
</p>
</div>
</div>
</div>
</div>
<Twilio v-if="provider === 'twilio'" type="whatsapp" />
<ThreeSixtyDialogWhatsapp v-else-if="provider === '360dialog'" />
<CloudWhatsapp v-else />
<!-- Configuration View -->
<div v-else-if="showConfiguration">
<div class="py-5 px-6 border bg-n-solid-2 border-n-weak rounded-2xl">
<!-- Provider Configuration Forms -->
<WhatsappEmbeddedSignup
v-if="shouldShowEmbeddedSignup(selectedProvider)"
/>
<CloudWhatsapp v-else-if="shouldShowCloudWhatsapp(selectedProvider)" />
<Twilio
v-else-if="selectedProvider === PROVIDER_TYPES.TWILIO"
type="whatsapp"
/>
<ThreeSixtyDialogWhatsapp
v-else-if="selectedProvider === PROVIDER_TYPES.THREE_SIXTY_DIALOG"
/>
<CloudWhatsapp v-else />
</div>
</div>
</div>
</template>
@@ -0,0 +1,85 @@
<script setup>
import { onMounted, onBeforeUnmount } from 'vue';
import Icon from 'next/icon/Icon.vue';
import NextButton from 'next/button/Button.vue';
import LoadingState from 'dashboard/components/widgets/LoadingState.vue';
import whatsappIcon from 'dashboard/assets/images/whatsapp.png';
import { useWhatsappEmbeddedSignup } from 'dashboard/composables/useWhatsappEmbeddedSignup';
const {
fbSdkLoaded,
processingMessage,
isAuthenticating,
benefits,
showLoader,
launchEmbeddedSignup,
initialize,
cleanupMessageListener,
} = useWhatsappEmbeddedSignup();
onMounted(() => {
initialize();
});
onBeforeUnmount(() => {
cleanupMessageListener();
});
</script>
<template>
<div class="h-full">
<LoadingState v-if="showLoader" :message="processingMessage" />
<div v-else>
<div class="flex flex-col items-start mb-6 text-start">
<div class="flex justify-start mb-6">
<div
class="flex items-center justify-center w-12 h-12 bg-n-alpha-2 rounded-full"
>
<img
:src="whatsappIcon"
:alt="$t('INBOX_MGMT.ADD.WHATSAPP.PROVIDERS.WHATSAPP_CLOUD')"
class="object-contain w-8 h-8"
draggable="false"
/>
</div>
</div>
<h3 class="mb-2 text-base font-medium text-n-slate-12">
{{ $t('INBOX_MGMT.ADD.WHATSAPP.EMBEDDED_SIGNUP.TITLE') }}
</h3>
<p class="text-sm leading-[24px] text-n-slate-12">
{{ $t('INBOX_MGMT.ADD.WHATSAPP.EMBEDDED_SIGNUP.DESC') }}
</p>
</div>
<div class="flex flex-col gap-2 mb-6">
<div
v-for="benefit in benefits"
:key="benefit.key"
class="flex items-center gap-2 text-sm text-n-slate-11"
>
<Icon icon="i-lucide-check" class="text-n-slate-11 size-4" />
{{ benefit.text }}
</div>
</div>
<div class="flex mt-4">
<NextButton
:disabled="!fbSdkLoaded || isAuthenticating"
:is-loading="isAuthenticating"
faded
slate
class="w-full"
@click="launchEmbeddedSignup"
>
{{ $t('INBOX_MGMT.ADD.WHATSAPP.EMBEDDED_SIGNUP.SUBMIT_BUTTON') }}
</NextButton>
</div>
<p v-if="!fbSdkLoaded" class="mt-3 text-xs text-start text-n-slate-11">
{{ $t('INBOX_MGMT.ADD.WHATSAPP.EMBEDDED_SIGNUP.LOADING_SDK') }}
</p>
</div>
</div>
</template>
@@ -0,0 +1,38 @@
<script setup>
import { onMounted, onBeforeUnmount } from 'vue';
import InboxReconnectionRequired from '../../components/InboxReconnectionRequired.vue';
import LoadingState from 'dashboard/components/widgets/LoadingState.vue';
import { useWhatsappReauthorization } from 'dashboard/composables/useWhatsappReauthorization';
const props = defineProps({
inbox: {
type: Object,
required: true,
},
});
const {
processingMessage,
showLoader,
launchReauthorization,
initialize,
cleanupMessageListener,
} = useWhatsappReauthorization(props.inbox.id);
onMounted(() => {
initialize();
});
onBeforeUnmount(() => {
cleanupMessageListener();
});
</script>
<template>
<div>
<InboxReconnectionRequired
class="mx-8 mt-5"
@reauthorize="launchReauthorization"
/>
<LoadingState v-if="showLoader" :message="processingMessage" />
</div>
</template>
@@ -7,6 +7,7 @@ import SmtpSettings from '../SmtpSettings.vue';
import { useVuelidate } from '@vuelidate/core';
import { required } from '@vuelidate/validators';
import NextButton from 'dashboard/components-next/button/Button.vue';
import { useWhatsappReauthorization } from 'dashboard/composables/useWhatsappReauthorization';
export default {
components: {
@@ -34,6 +35,20 @@ export default {
validations: {
whatsAppInboxAPIKey: { required },
},
computed: {
isWhatsAppCloudChannel() {
return this.inbox.provider === 'whatsapp_cloud';
},
hasWhatsappAppId() {
return (
window.chatwootConfig?.whatsappAppId &&
window.chatwootConfig.whatsappAppId !== 'none'
);
},
shouldShowReconfigureButton() {
return this.isWhatsAppCloudChannel && this.hasWhatsappAppId;
},
},
watch: {
inbox() {
this.setDefaults();
@@ -83,6 +98,18 @@ export default {
useAlert(this.$t('INBOX_MGMT.EDIT.API.ERROR_MESSAGE'));
}
},
reconfigureWhatsApp() {
// Initialize the WhatsApp reauthorization flow
const { launchReauthorization, initialize } = useWhatsappReauthorization(
this.inbox.id
);
// Initialize and launch
initialize();
launchReauthorization();
// Note: cleanup will happen when component unmounts or user navigates away
},
},
};
</script>
@@ -208,7 +235,21 @@ export default {
>
<woot-code :script="inbox.provider_config.api_key" />
</SettingsSection>
<!-- Show reconfigure button for WhatsApp Cloud with embedded signup and app ID configured -->
<SettingsSection
v-if="shouldShowReconfigureButton"
:title="$t('INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_RECONFIGURE_TITLE')"
:sub-title="
$t('INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_RECONFIGURE_SUBHEADER')
"
>
<NextButton class="mt-2" @click="reconfigureWhatsApp">
{{ $t('INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_RECONFIGURE_BUTTON') }}
</NextButton>
</SettingsSection>
<!-- Show API key update for non-embedded signup WhatsApp or when app ID is missing -->
<SettingsSection
v-else
:title="$t('INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_SECTION_UPDATE_TITLE')"
:sub-title="
$t('INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_SECTION_UPDATE_SUBHEADER')
+1
View File
@@ -5,6 +5,7 @@ class Webhooks::WhatsappEventsJob < ApplicationJob
channel = find_channel_from_whatsapp_business_payload(params)
if channel_is_inactive?(channel)
Rails.logger.info("Channel is inactive: #{channel.inspect}")
Rails.logger.warn("Inactive WhatsApp channel: #{channel&.phone_number || "unknown - #{params[:phone_number]}"}")
return
end
+97
View File
@@ -0,0 +1,97 @@
# Handles WhatsApp API communication
module Whatsapp::ApiService
extend ActiveSupport::Concern
def whatsapp_api_version
@whatsapp_api_version ||= GlobalConfigService.load('WHATSAPP_API_VERSION', 'v22.0')
end
def exchange_code_for_token
response = Faraday.get(
"https://graph.facebook.com/#{whatsapp_api_version}/oauth/access_token",
{
client_id: GlobalConfigService.load('WHATSAPP_APP_ID', ''),
client_secret: GlobalConfigService.load('WHATSAPP_APP_SECRET', ''),
code: @code
}
)
raise "Token exchange failed: #{response.body}" unless response.success?
data = JSON.parse(response.body)
raise "No access token in response: #{data}" unless data['access_token']
data['access_token']
end
def fetch_phone_info_via_waba(waba_id, phone_number_id, access_token)
response = Faraday.get(
"https://graph.facebook.com/#{whatsapp_api_version}/#{waba_id}/phone_numbers",
{ access_token: access_token }
)
raise "WABA phone numbers fetch failed: #{response.body}" unless response.success?
data = JSON.parse(response.body)
phone_numbers = data['data']
phone_data = phone_numbers.find { |phone| phone['id'] == phone_number_id } || phone_numbers.first
raise "No phone numbers found for WABA #{waba_id}" if phone_data.nil?
display_phone_number = sanitize_phone_number(phone_data['display_phone_number'])
{
phone_number_id: phone_data['id'],
phone_number: "+#{display_phone_number}",
verified: phone_data['code_verification_status'] == 'VERIFIED',
business_name: phone_data['verified_name'] || phone_data['display_phone_number']
}
end
def register_phone_number(phone_number_id, access_token)
HTTParty.post(
"https://graph.facebook.com/#{whatsapp_api_version}/#{phone_number_id}/register",
{
headers: { 'Authorization' => "Bearer #{access_token}", 'Content-Type' => 'application/json' },
body: { messaging_product: 'whatsapp', pin: '212834' }.to_json
}
)
end
def override_waba_webhook(waba_id, channel, access_token)
callback_url = "#{ENV.fetch('FRONTEND_URL', nil)}/webhooks/whatsapp/#{channel.phone_number}"
verify_token = channel.provider_config['webhook_verify_token']
response = HTTParty.post(
"https://graph.facebook.com/#{whatsapp_api_version}/#{waba_id}/subscribed_apps",
{
headers: {
'Authorization' => "Bearer #{access_token}",
'Content-Type' => 'application/json'
},
body: {
override_callback_uri: callback_url,
verify_token: verify_token
}.to_json
}
)
return if response.success?
Rails.logger.error("[WHATSAPP] Webhook override failed: #{response.body}")
raise "Webhook override failed: #{response.body}"
end
private
def sanitize_phone_number(phone_number)
return phone_number if phone_number.blank?
phone_number.gsub(/[\s\-\(\)\.\+]/, '').strip
end
def build_app_access_token
app_id = GlobalConfigService.load('WHATSAPP_APP_ID', '')
app_secret = GlobalConfigService.load('WHATSAPP_APP_SECRET', '')
"#{app_id}|#{app_secret}"
end
end
@@ -0,0 +1,135 @@
class Whatsapp::EmbeddedSignupService
include Rails.application.routes.url_helpers
include Whatsapp::ApiService
include Whatsapp::TokenValidator
def initialize(params)
@account = params[:account]
@code = params[:code]
@business_id = params[:business_id]
@waba_id = params[:waba_id]
@phone_number_id = params[:phone_number_id]
@inbox_id = params[:inbox_id]
end
def perform
# Validate required parameters
unless @code.present? && @business_id.present? && @waba_id.present? && @phone_number_id.present?
raise ArgumentError, 'Code, business_id, waba_id, and phone_number_id are all required'
end
GlobalConfig.clear_cache
# Exchange code for user access token
access_token = exchange_code_for_token
# Use the provided business info directly (more efficient)
phone_info = fetch_phone_info_via_waba(@waba_id, @phone_number_id, access_token)
# Validate that the token has access to the provided WABA (security check)
validate_token_waba_access(access_token, @waba_id)
waba_info = { waba_id: @waba_id, business_name: phone_info[:business_name] }
create_or_update_channel(waba_info, phone_info, access_token)
rescue StandardError => e
Rails.logger.error("[WHATSAPP] Signup failed: #{e.message}")
raise e
end
def perform_reauthorization
validate_reauthorization_params!
channel = find_and_validate_channel
GlobalConfig.clear_cache
access_token = exchange_code_for_token
phone_info = fetch_phone_info_via_waba(@waba_id, @phone_number_id, access_token)
validate_token_waba_access(access_token, @waba_id)
update_channel_for_reauthorization(channel, phone_info, access_token)
register_phone_number(phone_info[:phone_number_id], access_token)
override_waba_webhook(@waba_id, channel, access_token)
channel
rescue StandardError => e
Rails.logger.error("[WHATSAPP] Reauthorization failed: #{e.message}")
raise e
end
private
def validate_reauthorization_params!
return if @code.present? && @business_id.present? && @waba_id.present? && @phone_number_id.present? && @inbox_id.present?
raise ArgumentError, 'Code, business_id, waba_id, phone_number_id, and inbox_id are all required for reauthorization'
end
def find_and_validate_channel
inbox = @account.inboxes.find_by(id: @inbox_id)
raise ActiveRecord::RecordNotFound, 'Inbox not found' unless inbox
raise ArgumentError, 'Inbox is not a WhatsApp channel' unless inbox.channel_type == 'Channel::Whatsapp'
channel = inbox.channel
raise ArgumentError, 'Channel is not WhatsApp Cloud provider' unless channel.provider == 'whatsapp_cloud'
channel
end
def create_or_update_channel(waba_info, phone_info, access_token)
existing_channel = find_existing_channel(phone_info[:phone_number])
channel_attributes = build_channel_attributes(waba_info, phone_info, access_token)
if existing_channel
Rails.logger.error("Channel already exists: #{existing_channel.inspect}")
raise "Channel already exists: #{existing_channel.phone_number}"
else
channel = create_new_channel(channel_attributes, phone_info)
register_phone_number(phone_info[:phone_number_id], access_token)
override_waba_webhook(waba_info[:waba_id], channel, access_token)
channel
end
end
def find_existing_channel(phone_number)
Channel::Whatsapp.find_by(account: @account, phone_number: phone_number)
end
def build_channel_attributes(waba_info, phone_info, access_token)
{
phone_number: phone_info[:phone_number],
provider: 'whatsapp_cloud',
provider_config: {
api_key: access_token,
phone_number_id: phone_info[:phone_number_id],
business_account_id: waba_info[:waba_id],
source: 'embedded_signup'
}
}
end
def create_new_channel(attributes, phone_info)
channel = Channel::Whatsapp.create!(account: @account, **attributes)
create_inbox_for_channel(channel, phone_info)
channel.reload
channel
end
def create_inbox_for_channel(channel, phone_info)
Inbox.create!(
account: @account,
name: "#{phone_info[:business_name]} WhatsApp",
channel: channel
)
end
def update_channel_for_reauthorization(channel, phone_info, access_token)
# Update channel with new access token and configuration
channel.update!(
provider_config: channel.provider_config.merge(
'api_key' => access_token,
'phone_number_id' => phone_info[:phone_number_id],
'business_account_id' => @waba_id,
'reauthorized_at' => Time.current.iso8601
)
)
end
end
+43
View File
@@ -0,0 +1,43 @@
# Handles WhatsApp token validation
module Whatsapp::TokenValidator
extend ActiveSupport::Concern
def validate_token_waba_access(access_token, waba_id)
token_debug_data = fetch_token_debug_data(access_token)
waba_scope = extract_waba_scope(token_debug_data)
verify_waba_authorization(waba_scope, waba_id)
end
private
def fetch_token_debug_data(access_token)
response = Faraday.get(
"https://graph.facebook.com/#{whatsapp_api_version}/debug_token",
{
input_token: access_token,
access_token: build_app_access_token
}
)
raise "Token validation failed: #{response.body}" unless response.success?
JSON.parse(response.body)
end
def extract_waba_scope(token_data)
granular_scopes = token_data.dig('data', 'granular_scopes')
waba_scope = granular_scopes&.find { |scope| scope['scope'] == 'whatsapp_business_management' }
raise 'No WABA scope found in token' unless waba_scope
waba_scope
end
def verify_waba_authorization(waba_scope, waba_id)
authorized_waba_ids = waba_scope['target_ids'] || []
return if authorized_waba_ids.include?(waba_id)
raise "Token does not have access to WABA #{waba_id}. Authorized WABAs: #{authorized_waba_ids}"
end
end
@@ -116,4 +116,5 @@ json.provider resource.channel.try(:provider)
if resource.whatsapp?
json.message_templates resource.channel.try(:message_templates)
json.provider_config resource.channel.try(:provider_config) if Current.account_user&.administrator?
json.reauthorization_required resource.channel.try(:reauthorization_required?)
end
+3
View File
@@ -39,6 +39,9 @@
googleOAuthClientId: '<%= ENV.fetch('GOOGLE_OAUTH_CLIENT_ID', nil) %>',
googleOAuthCallbackUrl: '<%= ENV.fetch('GOOGLE_OAUTH_CALLBACK_URL', nil) %>',
fbApiVersion: '<%= @global_config['FACEBOOK_API_VERSION'] %>',
whatsappAppId: '<%= @global_config['WHATSAPP_APP_ID'] %>',
whatsappConfigurationId: '<%= @global_config['WHATSAPP_CONFIGURATION_ID'] %>',
whatsappApiVersion: '<%= @global_config['WHATSAPP_API_VERSION'] %>',
signupEnabled: '<%= @global_config['ENABLE_ACCOUNT_SIGNUP'] %>',
isEnterprise: '<%= @global_config['IS_ENTERPRISE'] %>',
<% if @global_config['IS_ENTERPRISE'] %>
@@ -0,0 +1 @@
json.partial! 'api/v1/models/inbox', formats: [:json], resource: @inbox
+20
View File
@@ -126,6 +126,26 @@
type: boolean
# ------- End of Facebook Channel Related Config ------- #
# ------- WhatsApp Channel Related Config ------- #
- name: WHATSAPP_APP_ID
display_title: 'WhatsApp App ID'
description: 'The Facebook App ID for WhatsApp Business API integration'
locked: false
- name: WHATSAPP_CONFIGURATION_ID
display_title: 'WhatsApp Configuration ID'
description: 'The Configuration ID for WhatsApp Embedded Signup flow (required for embedded signup)'
locked: false
- name: WHATSAPP_APP_SECRET
display_title: 'WhatsApp App Secret'
description: 'The App Secret for WhatsApp Embedded Signup flow (required for embedded signup)'
locked: false
- name: WHATSAPP_API_VERSION
display_title: 'WhatsApp API Version'
description: 'Configure this if you want to use a different WhatsApp API version. Make sure its prefixed with `v`'
value: 'v22.0'
locked: false
# ------- End of WhatsApp Channel Related Config ------- #
# MARK: Microsoft Email Channel Config
- name: AZURE_APP_ID
display_title: 'Azure App ID'
+7
View File
@@ -482,6 +482,13 @@ Rails.application.routes.draw do
get 'webhooks/instagram', to: 'webhooks/instagram#verify'
post 'webhooks/instagram', to: 'webhooks/instagram#events'
namespace :whatsapp do
get 'signup', to: 'embedded#new'
get 'signup/callback', to: 'embedded#callback'
post 'embedded_signup', to: 'embedded#embedded_signup'
post 'reauthorize', to: 'embedded#reauthorize'
end
namespace :twitter do
resource :callback, only: [:show]
end
@@ -103,6 +103,12 @@ slack:
enabled: true
icon: 'icon-slack'
config_key: 'slack'
whatsapp_embedded:
name: 'WhatsApp Embedded'
description: 'Configuration for setting up WhatsApp Embedded Integration'
enabled: true
icon: 'icon-whatsapp-line'
config_key: 'whatsapp_embedded'
shopify:
name: 'Shopify'
description: 'Configuration for setting up Shopify Integration'
@@ -0,0 +1,100 @@
require 'rails_helper'
RSpec.describe 'WhatsApp Embedded API', type: :request do
let(:account) { create(:account) }
let(:admin) { create(:user, account: account, role: :administrator) }
describe 'GET /whatsapp/signup' do
before do
allow(GlobalConfigService).to receive(:load).with('WHATSAPP_APP_ID', '').and_return('test_app_id')
allow(GlobalConfigService).to receive(:load).with('WHATSAPP_CONFIGURATION_ID', '').and_return('test_config_id')
allow(GlobalConfigService).to receive(:load).with('WHATSAPP_APP_SECRET', '').and_return('test_app_secret')
allow(GlobalConfigService).to receive(:load).with('WHATSAPP_API_VERSION', '').and_return('v22.0')
end
context 'when user is authenticated' do
it 'returns configuration for embedded signup' do
get '/whatsapp/signup',
headers: admin.create_new_auth_token,
params: { account_id: account.id }
expect(response).to have_http_status(:success)
json_response = response.parsed_body
expect(json_response).to include(
'status' => 'ready',
'app_id' => 'test_app_id',
'config_id' => 'test_config_id',
'app_secret' => 'test_app_secret'
)
end
end
context 'when user is not authenticated' do
it 'returns unauthorized' do
get '/whatsapp/signup', params: { account_id: account.id }
expect(response).to have_http_status(:unauthorized)
end
end
end
describe 'POST /whatsapp/embedded_signup' do
let(:params) do
{
account_id: account.id,
code: 'auth_code_123',
business_id: '123456789',
waba_id: '987654321',
phone_number_id: '555444333'
}
end
context 'when user is authenticated' do
context 'with missing authorization code' do
it 'returns bad request error' do
params_without_code = params.except(:code)
post '/whatsapp/embedded_signup',
headers: admin.create_new_auth_token,
params: params_without_code
expect(response).to have_http_status(:bad_request)
json_response = response.parsed_body
expect(json_response['error']).to eq('Missing authorization code')
end
end
context 'with missing business parameters' do
it 'returns bad request when business_id is missing' do
params_without_business = params.except(:business_id)
post '/whatsapp/embedded_signup',
headers: admin.create_new_auth_token,
params: params_without_business
expect(response).to have_http_status(:bad_request)
json_response = response.parsed_body
expect(json_response['error']).to include('Missing required parameters')
end
it 'returns bad request when waba_id is missing' do
params_without_waba = params.except(:waba_id)
post '/whatsapp/embedded_signup',
headers: admin.create_new_auth_token,
params: params_without_waba
expect(response).to have_http_status(:bad_request)
json_response = response.parsed_body
expect(json_response['error']).to include('Missing required parameters')
end
end
end
context 'when user is not authenticated' do
it 'returns unauthorized' do
post '/whatsapp/embedded_signup', params: params
expect(response).to have_http_status(:unauthorized)
end
end
end
end
@@ -0,0 +1,700 @@
require 'rails_helper'
describe Whatsapp::EmbeddedSignupService do
let(:account) { create(:account) }
let(:code) { 'test_authorization_code' }
let(:business_id) { 'test_business_id' }
let(:waba_id) { 'test_waba_id' }
let(:phone_number_id) { 'test_phone_number_id' }
let(:access_token) { 'test_access_token' }
let(:app_id) { 'test_app_id' }
let(:app_secret) { 'test_app_secret' }
let(:api_version) { 'v22.0' }
let(:service) do
described_class.new(
account: account,
code: code,
business_id: business_id,
waba_id: waba_id,
phone_number_id: phone_number_id
)
end
before do
# Mock global configuration
allow(GlobalConfigService).to receive(:load).with('WHATSAPP_APP_ID', '').and_return(app_id)
allow(GlobalConfigService).to receive(:load).with('WHATSAPP_APP_SECRET', '').and_return(app_secret)
allow(GlobalConfigService).to receive(:load).with('WHATSAPP_API_VERSION', 'v22.0').and_return(api_version)
allow(GlobalConfig).to receive(:clear_cache)
# Mock environment variables - allow any calls to ENV.fetch
allow(ENV).to receive(:fetch).and_call_original
allow(ENV).to receive(:fetch).with('FRONTEND_URL', nil).and_return('https://app.chatwoot.com')
allow(ENV).to receive(:fetch).with('DISABLE_ENTERPRISE', false).and_return(true)
# Mock ChatwootApp enterprise checks
allow(ChatwootApp).to receive(:enterprise?).and_return(false)
# NOTE: Specific HTTP request stubs are defined in individual test contexts
end
describe '#perform' do
context 'when all parameters are valid' do
before do
# Stub the token exchange
stub_request(:get, "https://graph.facebook.com/#{api_version}/oauth/access_token")
.with(query: hash_including(
'client_id' => app_id,
'client_secret' => app_secret,
'code' => code
))
.to_return(
status: 200,
body: { access_token: access_token }.to_json,
headers: { 'Content-Type' => 'application/json' }
)
# Stub the phone numbers fetch
stub_request(:get, "https://graph.facebook.com/#{api_version}/#{waba_id}/phone_numbers")
.with(query: hash_including('access_token' => access_token))
.to_return(
status: 200,
body: {
data: [
{
id: phone_number_id,
display_phone_number: '1234567890',
verified_name: 'Test Business',
code_verification_status: 'VERIFIED'
}
]
}.to_json,
headers: { 'Content-Type' => 'application/json' }
)
# Stub the token validation
stub_request(:get, "https://graph.facebook.com/#{api_version}/debug_token")
.with(query: hash_including(
'input_token' => access_token,
'access_token' => "#{app_id}|#{app_secret}"
))
.to_return(
status: 200,
body: {
data: {
granular_scopes: [
{
scope: 'whatsapp_business_management',
target_ids: [waba_id]
}
]
}
}.to_json,
headers: { 'Content-Type' => 'application/json' }
)
# Stub the provider validation request (WhatsApp Cloud)
stub_request(:get, "https://graph.facebook.com/v14.0/#{waba_id}/message_templates")
.with(query: hash_including('access_token' => access_token))
.to_return(
status: 200,
body: { data: [] }.to_json,
headers: { 'Content-Type' => 'application/json' }
)
# Stub the phone number registration
stub_request(:post, "https://graph.facebook.com/#{api_version}/#{phone_number_id}/register")
.to_return(
status: 200,
body: { success: true }.to_json,
headers: { 'Content-Type' => 'application/json' }
)
# Stub the webhook subscription
stub_request(:post, "https://graph.facebook.com/#{api_version}/#{waba_id}/subscribed_apps")
.to_return(
status: 200,
body: { success: true }.to_json,
headers: { 'Content-Type' => 'application/json' }
)
end
it 'successfully creates a new WhatsApp channel' do
expect { service.perform }.not_to raise_error
channel = Channel::Whatsapp.find_by(account: account, phone_number: '+1234567890')
expect(channel).not_to be_nil
expect(channel.provider).to eq('whatsapp_cloud')
expect(channel.provider_config['api_key']).to eq(access_token)
expect(channel.provider_config['phone_number_id']).to eq(phone_number_id)
expect(channel.provider_config['business_account_id']).to eq(waba_id)
expect(channel.provider_config['source']).to eq('embedded_signup')
end
it 'creates an inbox for the channel' do
service.perform
channel = Channel::Whatsapp.find_by(account: account, phone_number: '+1234567890')
inbox = Inbox.find_by(account: account, channel: channel)
expect(inbox).not_to be_nil
expect(inbox.name).to eq('Test Business WhatsApp')
end
it 'registers the phone number' do
service.perform
expect(WebMock).to have_requested(:post, "https://graph.facebook.com/#{api_version}/#{phone_number_id}/register")
.with(
body: {
messaging_product: 'whatsapp',
pin: '212834'
}.to_json
)
end
it 'sets up webhook subscription' do
service.perform
channel = Channel::Whatsapp.find_by(account: account, phone_number: '+1234567890')
callback_url = "https://app.chatwoot.com/webhooks/whatsapp/#{channel.phone_number}"
expect(WebMock).to have_requested(:post, "https://graph.facebook.com/#{api_version}/#{waba_id}/subscribed_apps")
.with(
body: hash_including(
override_callback_uri: callback_url,
verify_token: channel.provider_config['webhook_verify_token']
)
)
end
end
context 'when required parameters are missing' do
it 'raises an error when code is missing' do
service = described_class.new(
account: account,
code: '',
business_id: business_id,
waba_id: waba_id,
phone_number_id: phone_number_id
)
expect { service.perform }.to raise_error(ArgumentError, /Code, business_id, waba_id, and phone_number_id are all required/)
end
it 'raises an error when business_id is missing' do
service = described_class.new(
account: account,
code: code,
business_id: '',
waba_id: waba_id,
phone_number_id: phone_number_id
)
expect { service.perform }.to raise_error(ArgumentError, /Code, business_id, waba_id, and phone_number_id are all required/)
end
it 'raises an error when waba_id is missing' do
service = described_class.new(
account: account,
code: code,
business_id: business_id,
waba_id: '',
phone_number_id: phone_number_id
)
expect { service.perform }.to raise_error(ArgumentError, /Code, business_id, waba_id, and phone_number_id are all required/)
end
it 'raises an error when phone_number_id is missing' do
service = described_class.new(
account: account,
code: code,
business_id: business_id,
waba_id: waba_id,
phone_number_id: ''
)
expect { service.perform }.to raise_error(ArgumentError, /Code, business_id, waba_id, and phone_number_id are all required/)
end
end
context 'when channel already exists' do
before do
# Stub all the required requests for successful flow
stub_request(:get, "https://graph.facebook.com/#{api_version}/oauth/access_token")
.with(query: hash_including(
'client_id' => app_id,
'client_secret' => app_secret,
'code' => code
))
.to_return(
status: 200,
body: { access_token: access_token }.to_json,
headers: { 'Content-Type' => 'application/json' }
)
stub_request(:get, "https://graph.facebook.com/#{api_version}/#{waba_id}/phone_numbers")
.with(query: hash_including('access_token' => access_token))
.to_return(
status: 200,
body: {
data: [
{
id: phone_number_id,
display_phone_number: '1234567890',
verified_name: 'Test Business',
code_verification_status: 'VERIFIED'
}
]
}.to_json,
headers: { 'Content-Type' => 'application/json' }
)
stub_request(:get, "https://graph.facebook.com/#{api_version}/debug_token")
.with(query: hash_including(
'input_token' => access_token,
'access_token' => "#{app_id}|#{app_secret}"
))
.to_return(
status: 200,
body: {
data: {
granular_scopes: [
{
scope: 'whatsapp_business_management',
target_ids: [waba_id]
}
]
}
}.to_json,
headers: { 'Content-Type' => 'application/json' }
)
# Stub 360Dialog provider validation (for existing channel creation)
stub_request(:post, 'https://waba.360dialog.io/v1/configs/webhook')
.to_return(
status: 200,
body: { success: true }.to_json,
headers: { 'Content-Type' => 'application/json' }
)
stub_request(:get, 'https://waba.360dialog.io/v1/configs/templates')
.to_return(
status: 200,
body: { templates: [] }.to_json,
headers: { 'Content-Type' => 'application/json' }
)
# Create existing channel
create(:channel_whatsapp, account: account, phone_number: '+1234567890')
end
it 'raises an error' do
expect { service.perform }.to raise_error(/Channel already exists/)
end
end
context 'when token exchange fails' do
before do
stub_request(:get, "https://graph.facebook.com/#{api_version}/oauth/access_token")
.with(query: hash_including(
'client_id' => app_id,
'client_secret' => app_secret,
'code' => code
))
.to_return(status: 400, body: { error: 'Invalid code' }.to_json)
end
it 'raises an error' do
expect { service.perform }.to raise_error(/Token exchange failed/)
end
end
context 'when token has no access to WABA' do
before do
stub_request(:get, "https://graph.facebook.com/#{api_version}/oauth/access_token")
.with(query: hash_including(
'client_id' => app_id,
'client_secret' => app_secret,
'code' => code
))
.to_return(
status: 200,
body: { access_token: access_token }.to_json,
headers: { 'Content-Type' => 'application/json' }
)
stub_request(:get, "https://graph.facebook.com/#{api_version}/#{waba_id}/phone_numbers")
.with(query: hash_including('access_token' => access_token))
.to_return(
status: 200,
body: {
data: [
{
id: phone_number_id,
display_phone_number: '1234567890',
verified_name: 'Test Business',
code_verification_status: 'VERIFIED'
}
]
}.to_json,
headers: { 'Content-Type' => 'application/json' }
)
stub_request(:get, "https://graph.facebook.com/#{api_version}/debug_token")
.with(query: hash_including(
'input_token' => access_token,
'access_token' => "#{app_id}|#{app_secret}"
))
.to_return(
status: 200,
body: {
data: {
granular_scopes: [
{
scope: 'whatsapp_business_management',
target_ids: ['different_waba_id']
}
]
}
}.to_json,
headers: { 'Content-Type' => 'application/json' }
)
end
it 'raises an error' do
expect { service.perform }.to raise_error(/Token does not have access to WABA/)
end
end
context 'when phone numbers fetch fails' do
before do
stub_request(:get, "https://graph.facebook.com/#{api_version}/oauth/access_token")
.with(query: hash_including(
'client_id' => app_id,
'client_secret' => app_secret,
'code' => code
))
.to_return(
status: 200,
body: { access_token: access_token }.to_json,
headers: { 'Content-Type' => 'application/json' }
)
stub_request(:get, "https://graph.facebook.com/#{api_version}/#{waba_id}/phone_numbers")
.with(query: hash_including('access_token' => access_token))
.to_return(status: 400, body: { error: 'Phone numbers fetch failed' }.to_json)
end
it 'raises an error' do
expect { service.perform }.to raise_error(/WABA phone numbers fetch failed/)
end
end
context 'when webhook override fails' do
before do
# Stub all the successful requests
stub_request(:get, "https://graph.facebook.com/#{api_version}/oauth/access_token")
.with(query: hash_including(
'client_id' => app_id,
'client_secret' => app_secret,
'code' => code
))
.to_return(
status: 200,
body: { access_token: access_token }.to_json,
headers: { 'Content-Type' => 'application/json' }
)
stub_request(:get, "https://graph.facebook.com/#{api_version}/#{waba_id}/phone_numbers")
.with(query: hash_including('access_token' => access_token))
.to_return(
status: 200,
body: {
data: [
{
id: phone_number_id,
display_phone_number: '1234567890',
verified_name: 'Test Business',
code_verification_status: 'VERIFIED'
}
]
}.to_json,
headers: { 'Content-Type' => 'application/json' }
)
stub_request(:get, "https://graph.facebook.com/#{api_version}/debug_token")
.with(query: hash_including(
'input_token' => access_token,
'access_token' => "#{app_id}|#{app_secret}"
))
.to_return(
status: 200,
body: {
data: {
granular_scopes: [
{
scope: 'whatsapp_business_management',
target_ids: [waba_id]
}
]
}
}.to_json,
headers: { 'Content-Type' => 'application/json' }
)
stub_request(:get, "https://graph.facebook.com/v14.0/#{waba_id}/message_templates")
.with(query: hash_including('access_token' => access_token))
.to_return(
status: 200,
body: { data: [] }.to_json,
headers: { 'Content-Type' => 'application/json' }
)
stub_request(:post, "https://graph.facebook.com/#{api_version}/#{phone_number_id}/register")
.to_return(
status: 200,
body: { success: true }.to_json,
headers: { 'Content-Type' => 'application/json' }
)
# Stub the failing webhook request
stub_request(:post, "https://graph.facebook.com/#{api_version}/#{waba_id}/subscribed_apps")
.to_return(status: 400, body: { error: 'Webhook failed' }.to_json)
end
it 'raises an error' do
expect { service.perform }.to raise_error(/Webhook override failed/)
end
end
end
describe 'private methods' do
describe '#exchange_code_for_token' do
context 'when token exchange is successful' do
before do
stub_request(:get, "https://graph.facebook.com/#{api_version}/oauth/access_token")
.with(query: hash_including(
'client_id' => app_id,
'client_secret' => app_secret,
'code' => code
))
.to_return(
status: 200,
body: { access_token: access_token }.to_json,
headers: { 'Content-Type' => 'application/json' }
)
end
it 'returns the access token' do
result = service.send(:exchange_code_for_token)
expect(result).to eq(access_token)
end
end
context 'when response has no access token' do
before do
stub_request(:get, "https://graph.facebook.com/#{api_version}/oauth/access_token")
.with(query: hash_including(
'client_id' => app_id,
'client_secret' => app_secret,
'code' => code
))
.to_return(
status: 200,
body: { some_other_field: 'value' }.to_json,
headers: { 'Content-Type' => 'application/json' }
)
end
it 'raises an error' do
expect { service.send(:exchange_code_for_token) }.to raise_error(/No access token in response/)
end
end
end
describe '#fetch_phone_info_via_waba' do
before do
stub_request(:get, "https://graph.facebook.com/#{api_version}/#{waba_id}/phone_numbers")
.with(query: hash_including('access_token' => access_token))
.to_return(
status: 200,
body: {
data: [
{
id: phone_number_id,
display_phone_number: '1234567890',
verified_name: 'Test Business',
code_verification_status: 'VERIFIED'
}
]
}.to_json,
headers: { 'Content-Type' => 'application/json' }
)
end
it 'returns formatted phone info' do
result = service.send(:fetch_phone_info_via_waba, waba_id, phone_number_id, access_token)
expect(result).to eq({
phone_number_id: phone_number_id,
phone_number: '+1234567890',
verified: true,
business_name: 'Test Business'
})
end
context 'when specific phone number is not found' do
before do
stub_request(:get, "https://graph.facebook.com/#{api_version}/#{waba_id}/phone_numbers")
.with(query: hash_including('access_token' => access_token))
.to_return(
status: 200,
body: {
data: [
{
id: 'different_phone_id',
display_phone_number: '9876543210',
verified_name: 'Different Business',
code_verification_status: 'VERIFIED'
}
]
}.to_json,
headers: { 'Content-Type' => 'application/json' }
)
end
it 'uses the first available phone number' do
result = service.send(:fetch_phone_info_via_waba, waba_id, phone_number_id, access_token)
expect(result[:phone_number_id]).to eq('different_phone_id')
expect(result[:phone_number]).to eq('+9876543210')
end
end
context 'when no phone numbers are available' do
before do
stub_request(:get, "https://graph.facebook.com/#{api_version}/#{waba_id}/phone_numbers")
.with(query: hash_including('access_token' => access_token))
.to_return(
status: 200,
body: { data: [] }.to_json,
headers: { 'Content-Type' => 'application/json' }
)
end
it 'raises an error' do
expect do
service.send(:fetch_phone_info_via_waba, waba_id, phone_number_id, access_token)
end.to raise_error(/No phone numbers found for WABA/)
end
end
end
describe '#validate_token_waba_access' do
context 'when token has access to WABA' do
before do
stub_request(:get, "https://graph.facebook.com/#{api_version}/debug_token")
.with(query: hash_including(
'input_token' => access_token,
'access_token' => "#{app_id}|#{app_secret}"
))
.to_return(
status: 200,
body: {
data: {
granular_scopes: [
{
scope: 'whatsapp_business_management',
target_ids: [waba_id]
}
]
}
}.to_json,
headers: { 'Content-Type' => 'application/json' }
)
end
it 'validates successfully when token has access' do
expect { service.send(:validate_token_waba_access, access_token, waba_id) }.not_to raise_error
end
end
context 'when token validation fails' do
before do
stub_request(:get, "https://graph.facebook.com/#{api_version}/debug_token")
.with(query: hash_including(
'input_token' => access_token,
'access_token' => "#{app_id}|#{app_secret}"
))
.to_return(status: 400, body: { error: 'Invalid token' }.to_json)
end
it 'raises an error' do
expect do
service.send(:validate_token_waba_access, access_token, waba_id)
end.to raise_error(/Token validation failed/)
end
end
context 'when token does not have access to WABA' do
before do
stub_request(:get, "https://graph.facebook.com/#{api_version}/debug_token")
.with(query: hash_including(
'input_token' => access_token,
'access_token' => "#{app_id}|#{app_secret}"
))
.to_return(
status: 200,
body: {
data: {
granular_scopes: [
{
scope: 'whatsapp_business_management',
target_ids: ['different_waba_id']
}
]
}
}.to_json,
headers: { 'Content-Type' => 'application/json' }
)
end
it 'raises an error when WABA ID is not in target_ids' do
expect do
service.send(:validate_token_waba_access, access_token, waba_id)
end.to raise_error(/Token does not have access to WABA/)
end
end
context 'when no WABA scope is found' do
before do
stub_request(:get, "https://graph.facebook.com/#{api_version}/debug_token")
.with(query: hash_including(
'input_token' => access_token,
'access_token' => "#{app_id}|#{app_secret}"
))
.to_return(
status: 200,
body: {
data: {
granular_scopes: [
{
scope: 'some_other_scope',
target_ids: ['some_id']
}
]
}
}.to_json,
headers: { 'Content-Type' => 'application/json' }
)
end
it 'raises an error' do
expect do
service.send(:validate_token_waba_access, access_token, waba_id)
end.to raise_error(/No WABA scope found in token/)
end
end
end
end
end