feat: add reauth flow for wa embedded signup (#11940)

# 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>
This commit is contained in:
Tanmay Deep Sharma
2025-08-08 01:48:45 +05:30
committed by GitHub
parent 462ab5241c
commit d2583d32e9
17 changed files with 890 additions and 206 deletions

View File

@@ -1,8 +1,10 @@
class Api::V1::Accounts::Whatsapp::AuthorizationsController < Api::V1::Accounts::BaseController
before_action :validate_feature_enabled!
before_action :fetch_and_validate_inbox, if: -> { params[:inbox_id].present? }
# POST /api/v1/accounts/:account_id/whatsapp/authorization
# Handles the embedded signup callback data from the Facebook SDK
# Handles both initial authorization and reauthorization
# If inbox_id is present in params, it performs reauthorization
def create
validate_embedded_signup_params!
channel = process_embedded_signup
@@ -16,21 +18,42 @@ class Api::V1::Accounts::Whatsapp::AuthorizationsController < Api::V1::Accounts:
def process_embedded_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]
params: params.permit(:code, :business_id, :waba_id, :phone_number_id).to_h.symbolize_keys,
inbox_id: params[:inbox_id]
)
service.perform
end
def render_success_response(inbox)
def fetch_and_validate_inbox
@inbox = Current.account.inboxes.find(params[:inbox_id])
validate_reauthorization_required
end
def validate_reauthorization_required
return if @inbox.channel.reauthorization_required? || can_upgrade_to_embedded_signup?
render json: {
success: false,
message: I18n.t('inbox.reauthorization.not_required')
}, status: :unprocessable_entity
end
def can_upgrade_to_embedded_signup?
channel = @inbox.channel
return false unless channel.provider == 'whatsapp_cloud'
true
end
def render_success_response(inbox)
response = {
success: true,
id: inbox.id,
name: inbox.name,
channel_type: 'whatsapp'
}
response[:message] = I18n.t('inbox.reauthorization.success') if params[:inbox_id].present?
render json: response
end
def render_error_response(error)

View File

@@ -9,6 +9,13 @@ class WhatsappChannel extends ApiClient {
createEmbeddedSignup(params) {
return axios.post(`${this.baseUrl()}/whatsapp/authorization`, params);
}
reauthorizeWhatsApp({ inboxId, ...params }) {
return axios.post(`${this.baseUrl()}/whatsapp/authorization`, {
...params,
inbox_id: inboxId,
});
}
}
export default new WhatsappChannel();

View File

@@ -72,6 +72,24 @@
"MARK_ALL_READ": "All notifications marked as read",
"DELETE_ALL": "All notifications deleted",
"DELETE_ALL_READ": "All read notifications deleted"
},
"REAUTHORIZE": {
"TITLE": "Reauthorization Required",
"DESCRIPTION": "Your WhatsApp connection has expired. Please reconnect to continue receiving and sending messages.",
"BUTTON_TEXT": "Reconnect WhatsApp",
"LOADING_FACEBOOK": "Loading Facebook SDK...",
"SUCCESS": "WhatsApp reconnected successfully",
"ERROR": "Failed to reconnect WhatsApp. Please try again.",
"WHATSAPP_APP_ID_MISSING": "WhatsApp App ID is not configured. Please contact your administrator.",
"WHATSAPP_CONFIG_ID_MISSING": "WhatsApp Configuration ID is not configured. Please contact your administrator.",
"CONFIGURATION_ERROR": "Configuration error occurred during reauthorization.",
"FACEBOOK_LOAD_ERROR": "Failed to load Facebook SDK. Please try again.",
"TROUBLESHOOTING": {
"TITLE": "Troubleshooting",
"POPUP_BLOCKED": "Ensure pop-ups are allowed for this site",
"COOKIES": "Third-party cookies must be enabled",
"ADMIN_ACCESS": "You need admin access to the WhatsApp Business Account"
}
}
}
}

View File

@@ -598,6 +598,21 @@
"WHATSAPP_SECTION_UPDATE_TITLE": "Update API Key",
"WHATSAPP_SECTION_UPDATE_PLACEHOLDER": "Enter the new API Key here",
"WHATSAPP_SECTION_UPDATE_BUTTON": "Update",
"WHATSAPP_EMBEDDED_SIGNUP_TITLE": "WhatsApp Embedded Signup",
"WHATSAPP_EMBEDDED_SIGNUP_SUBHEADER": "This inbox is connected through WhatsApp embedded signup.",
"WHATSAPP_EMBEDDED_SIGNUP_DESCRIPTION": "You can reconfigure this inbox to update your WhatsApp Business settings.",
"WHATSAPP_RECONFIGURE_BUTTON": "Reconfigure",
"WHATSAPP_CONNECT_TITLE": "Connect to WhatsApp Business",
"WHATSAPP_CONNECT_SUBHEADER": "Upgrade to WhatsApp embedded signup for easier management.",
"WHATSAPP_CONNECT_DESCRIPTION": "Connect this inbox to WhatsApp Business for enhanced features and easier management.",
"WHATSAPP_CONNECT_BUTTON": "Connect",
"WHATSAPP_CONNECT_SUCCESS": "Successfully connected to WhatsApp Business!",
"WHATSAPP_CONNECT_ERROR": "Failed to connect to WhatsApp Business. Please try again.",
"WHATSAPP_RECONFIGURE_SUCCESS": "Successfully reconfigured WhatsApp Business!",
"WHATSAPP_RECONFIGURE_ERROR": "Failed to reconfigure WhatsApp Business. Please try again.",
"WHATSAPP_APP_ID_MISSING": "WhatsApp App ID is not configured. Please contact your administrator.",
"WHATSAPP_CONFIG_ID_MISSING": "WhatsApp Configuration ID is not configured. Please contact your administrator.",
"WHATSAPP_LOGIN_CANCELLED": "WhatsApp login was cancelled. Please try again.",
"WHATSAPP_WEBHOOK_TITLE": "Webhook Verification Token",
"WHATSAPP_WEBHOOK_SUBHEADER": "This token is used to verify the authenticity of the webhook endpoint.",
"WHATSAPP_TEMPLATES_SYNC_TITLE": "Sync Templates",

View File

@@ -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';
@@ -44,6 +45,7 @@ export default {
GoogleReauthorize,
NextButton,
InstagramReauthorize,
WhatsappReauthorize,
DuplicateInboxBanner,
Editor,
},
@@ -87,10 +89,7 @@ export default {
return this.tabs[this.selectedTabIndex]?.key;
},
shouldShowWhatsAppConfiguration() {
return !!(
this.isAWhatsAppCloudChannel &&
this.inbox.provider_config?.source !== 'embedded_signup'
);
return this.isAWhatsAppCloudChannel;
},
whatsAppAPIProviderName() {
if (this.isAWhatsAppCloudChannel) {
@@ -247,6 +246,14 @@ export default {
this.inbox.reauthorization_required
);
},
whatsappUnauthorized() {
return (
this.isAWhatsAppChannel &&
this.inbox.provider === 'whatsapp_cloud' &&
this.inbox.provider_config?.source === 'embedded_signup' &&
this.inbox.reauthorization_required
);
},
},
watch: {
$route(to) {
@@ -416,6 +423,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')"

View File

@@ -7,8 +7,13 @@ import { useAlert } from 'dashboard/composables';
import Icon from 'next/icon/Icon.vue';
import NextButton from 'next/button/Button.vue';
import LoadingState from 'dashboard/components/widgets/LoadingState.vue';
import { loadScript } from 'dashboard/helper/DOMHelpers';
import { parseAPIErrorResponse } from 'dashboard/store/utils/api';
import {
setupFacebookSdk,
initWhatsAppEmbeddedSignup,
createMessageHandler,
isValidBusinessData,
} from './whatsapp/utils';
const store = useStore();
const router = useRouter();
@@ -120,14 +125,6 @@ const completeSignupFlow = async businessDataParam => {
}
};
const isValidBusinessData = businessDataLocal => {
return (
businessDataLocal &&
businessDataLocal.business_id &&
businessDataLocal.waba_id
);
};
// Message handling
const handleEmbeddedSignupData = async data => {
if (data.event === 'FINISH') {
@@ -162,9 +159,26 @@ const handleEmbeddedSignupData = async data => {
}
};
const fbLoginCallback = response => {
if (response.authResponse && response.authResponse.code) {
authCode.value = response.authResponse.code;
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'
@@ -173,80 +187,19 @@ const fbLoginCallback = response => {
if (businessData.value) {
completeSignupFlow(businessData.value);
}
} else if (response.error) {
handleSignupError({ error: response.error });
} else {
} catch (error) {
if (error.message === 'Login cancelled') {
isProcessing.value = false;
isAuthenticating.value = false;
useAlert(t('INBOX_MGMT.ADD.WHATSAPP.EMBEDDED_SIGNUP.CANCELLED'));
}
};
const handleSignupMessage = event => {
// Validate origin for security - following Facebook documentation
// https://developers.facebook.com/docs/whatsapp/embedded-signup/implementation#step-3--add-embedded-signup-to-your-website
if (!event.origin.endsWith('facebook.com')) return;
// Parse and handle WhatsApp embedded signup events
try {
const data = JSON.parse(event.data);
if (data.type === 'WA_EMBEDDED_SIGNUP') {
handleEmbeddedSignupData(data);
}
} catch {
// Ignore non-JSON or irrelevant messages
}
};
const runFBInit = () => {
window.FB.init({
appId: window.chatwootConfig?.whatsappAppId,
autoLogAppEvents: true,
xfbml: true,
version: window.chatwootConfig?.whatsappApiVersion || 'v22.0',
});
fbSdkLoaded.value = true;
};
const loadFacebookSdk = async () => {
return loadScript('https://connect.facebook.net/en_US/sdk.js', {
async: true,
defer: true,
crossOrigin: 'anonymous',
});
};
const tryWhatsAppLogin = () => {
isAuthenticating.value = true;
processingMessage.value = t(
'INBOX_MGMT.ADD.WHATSAPP.EMBEDDED_SIGNUP.AUTH_PROCESSING'
);
window.FB.login(fbLoginCallback, {
config_id: window.chatwootConfig?.whatsappConfigurationId,
response_type: 'code',
override_default_response_type: true,
extras: {
setup: {},
featureType: '',
sessionInfoVersion: '3',
},
});
};
const launchEmbeddedSignup = async () => {
try {
// Load SDK first if not loaded, following Facebook.vue pattern exactly
await loadFacebookSdk();
runFBInit(); // Initialize FB after loading
// Now proceed with login
tryWhatsAppLogin();
} catch (error) {
} else {
handleSignupError({
error: t('INBOX_MGMT.ADD.WHATSAPP.EMBEDDED_SIGNUP.SDK_LOAD_ERROR'),
error:
error.message ||
t('INBOX_MGMT.ADD.WHATSAPP.EMBEDDED_SIGNUP.SDK_LOAD_ERROR'),
});
}
}
};
// Lifecycle
@@ -259,7 +212,6 @@ const cleanupMessageListener = () => {
};
const initialize = () => {
window.fbAsyncInit = runFBInit;
setupMessageListener();
};

View File

@@ -0,0 +1,190 @@
<script setup>
import { ref, computed, onMounted } from 'vue';
import { useI18n } from 'vue-i18n';
import { useAlert } from 'dashboard/composables';
import InboxReconnectionRequired from '../../components/InboxReconnectionRequired.vue';
import whatsappChannel from 'dashboard/api/channel/whatsappChannel';
import {
setupFacebookSdk,
initWhatsAppEmbeddedSignup,
createMessageHandler,
isValidBusinessData,
} from './utils';
const props = defineProps({
inbox: {
type: Object,
required: true,
},
});
const { t } = useI18n();
const isRequestingAuthorization = ref(false);
const isLoadingFacebook = ref(true);
const whatsappAppId = computed(() => window.chatwootConfig.whatsappAppId);
const whatsappConfigurationId = computed(
() => window.chatwootConfig.whatsappConfigurationId
);
const reauthorizeWhatsApp = async params => {
isRequestingAuthorization.value = true;
try {
const response = await whatsappChannel.reauthorizeWhatsApp({
inboxId: props.inbox.id,
...params,
});
if (response.data.success) {
useAlert(t('INBOX.REAUTHORIZE.SUCCESS'));
} else {
useAlert(response.data.message || t('INBOX.REAUTHORIZE.ERROR'));
}
} catch (error) {
useAlert(error.message || t('INBOX.REAUTHORIZE.ERROR'));
} finally {
isRequestingAuthorization.value = false;
}
};
const handleEmbeddedSignupEvents = async (data, authCode) => {
if (!data || typeof data !== 'object') {
return;
}
// Handle different event types
if (data.event === 'FINISH') {
const businessData = data.data;
if (isValidBusinessData(businessData) && businessData.phone_number_id) {
await reauthorizeWhatsApp({
code: authCode,
business_id: businessData.business_id,
waba_id: businessData.waba_id,
phone_number_id: businessData.phone_number_id,
});
} else {
useAlert(
t('INBOX_MGMT.ADD.WHATSAPP.EMBEDDED_SIGNUP.INVALID_BUSINESS_DATA')
);
}
} else if (data.event === 'CANCEL') {
isRequestingAuthorization.value = false;
useAlert(t('INBOX_MGMT.ADD.WHATSAPP.EMBEDDED_SIGNUP.CANCELLED'));
} else if (data.event === 'error') {
isRequestingAuthorization.value = false;
useAlert(
data.error_message ||
t('INBOX_MGMT.ADD.WHATSAPP.EMBEDDED_SIGNUP.SIGNUP_ERROR')
);
}
};
const startEmbeddedSignup = authCode => {
const messageHandler = createMessageHandler(data =>
handleEmbeddedSignupEvents(data, authCode)
);
window.addEventListener('message', messageHandler);
};
const handleLoginAndReauthorize = async () => {
// Validate required configuration
if (!whatsappAppId.value) {
throw new Error('WhatsApp App ID is required');
}
if (!whatsappConfigurationId.value) {
throw new Error('WhatsApp Configuration ID is required');
}
try {
const authCode = await initWhatsAppEmbeddedSignup(
whatsappConfigurationId.value
);
// Check if this is a reauthorization scenario where we already have the business data
const existingConfig = props.inbox.provider_config;
if (
existingConfig &&
existingConfig.business_account_id &&
existingConfig.phone_number_id
) {
await reauthorizeWhatsApp({
code: authCode,
business_id: existingConfig.business_account_id,
waba_id: existingConfig.business_account_id,
phone_number_id: existingConfig.phone_number_id,
});
} else {
startEmbeddedSignup(authCode);
}
} catch (error) {
if (error.message === 'Login cancelled') {
useAlert(t('INBOX_MGMT.ADD.WHATSAPP.EMBEDDED_SIGNUP.CANCELLED'));
} else {
useAlert(
error.message ||
t('INBOX_MGMT.ADD.WHATSAPP.EMBEDDED_SIGNUP.AUTH_NOT_COMPLETED')
);
}
throw error;
}
};
const requestAuthorization = async () => {
if (isLoadingFacebook.value) {
useAlert(t('INBOX.REAUTHORIZE.LOADING_FACEBOOK'));
return;
}
isRequestingAuthorization.value = true;
try {
await handleLoginAndReauthorize();
} catch (error) {
useAlert(error.message || t('INBOX.REAUTHORIZE.CONFIGURATION_ERROR'));
} finally {
// Reset only if not already processing through embedded signup
if (!window.FB || !window.FB.getLoginStatus) {
isRequestingAuthorization.value = false;
}
}
};
onMounted(async () => {
try {
// Validate required configuration
if (!whatsappAppId.value) {
useAlert(t('INBOX.REAUTHORIZE.WHATSAPP_APP_ID_MISSING'));
return;
}
if (!whatsappConfigurationId.value) {
useAlert(t('INBOX.REAUTHORIZE.WHATSAPP_CONFIG_ID_MISSING'));
return;
}
// Load Facebook SDK and initialize
await setupFacebookSdk(
whatsappAppId.value,
window.chatwootConfig?.whatsappApiVersion
);
} catch (error) {
useAlert(t('INBOX.REAUTHORIZE.FACEBOOK_LOAD_ERROR'));
} finally {
isLoadingFacebook.value = false;
}
});
// Expose requestAuthorization function for parent components
defineExpose({
requestAuthorization,
});
</script>
<template>
<InboxReconnectionRequired
class="mx-8 mt-5"
:is-loading="isRequestingAuthorization"
@reauthorize="requestAuthorization"
/>
</template>

View File

@@ -0,0 +1,89 @@
import { loadScript } from 'dashboard/helper/DOMHelpers';
export const loadFacebookSdk = async () => {
return loadScript('https://connect.facebook.net/en_US/sdk.js', {
async: true,
defer: true,
crossOrigin: 'anonymous',
});
};
export const initializeFacebook = (appId, apiVersion) => {
const version = apiVersion || 'v22.0';
return new Promise(resolve => {
const init = () => {
window.FB.init({
appId,
autoLogAppEvents: true,
xfbml: true,
version,
});
resolve();
};
if (window.FB) {
init();
} else {
window.fbAsyncInit = init;
}
});
};
export const isValidBusinessData = businessData => {
return businessData && businessData.business_id && businessData.waba_id;
};
export const createMessageHandler = onEmbeddedSignupData => {
return event => {
if (!event.origin.endsWith('facebook.com')) return;
try {
let data;
if (typeof event.data === 'string') {
data = JSON.parse(event.data);
} else if (typeof event.data === 'object' && event.data !== null) {
data = event.data;
} else {
return;
}
if (data.type === 'WA_EMBEDDED_SIGNUP') {
onEmbeddedSignupData(data);
}
} catch {
// Ignore non-JSON or irrelevant messages
}
};
};
export const initWhatsAppEmbeddedSignup = configId => {
return new Promise((resolve, reject) => {
window.FB.login(
response => {
if (response.authResponse && response.authResponse.code) {
resolve(response.authResponse.code);
} else if (response.error) {
reject(new Error(response.error));
} else {
reject(new Error('Login cancelled'));
}
},
{
config_id: configId,
response_type: 'code',
override_default_response_type: true,
extras: {
setup: {},
featureType: '',
sessionInfoVersion: '3',
},
}
);
});
};
export const setupFacebookSdk = async (appId, apiVersion) => {
const version = apiVersion || 'v22.0';
await loadFacebookSdk();
await initializeFacebook(appId, version);
};

View File

@@ -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 WhatsappReauthorize from '../channels/whatsapp/Reauthorize.vue';
export default {
components: {
@@ -14,6 +15,7 @@ export default {
ImapSettings,
SmtpSettings,
NextButton,
WhatsappReauthorize,
},
mixins: [inboxMixin],
props: {
@@ -29,12 +31,21 @@ export default {
return {
hmacMandatory: false,
whatsAppInboxAPIKey: '',
isRequestingReauthorization: false,
isSyncingTemplates: false,
};
},
validations: {
whatsAppInboxAPIKey: { required },
},
computed: {
isEmbeddedSignupWhatsApp() {
return this.inbox.provider_config?.source === 'embedded_signup';
},
whatsappAppId() {
return window.chatwootConfig?.whatsappAppId;
},
},
watch: {
inbox() {
this.setDefaults();
@@ -84,6 +95,11 @@ export default {
useAlert(this.$t('INBOX_MGMT.EDIT.API.ERROR_MESSAGE'));
}
},
async handleReconfigure() {
if (this.$refs.whatsappReauth) {
await this.$refs.whatsappReauth.requestAuthorization();
}
},
async syncTemplates() {
this.isSyncingTemplates = true;
try {
@@ -210,15 +226,47 @@ export default {
</div>
<div v-else-if="isAWhatsAppChannel && !isATwilioChannel">
<div v-if="inbox.provider_config" class="mx-8">
<!-- Embedded Signup Section -->
<template v-if="isEmbeddedSignupWhatsApp">
<SettingsSection
v-if="whatsappAppId"
:title="
$t('INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_EMBEDDED_SIGNUP_TITLE')
"
:sub-title="
$t('INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_EMBEDDED_SIGNUP_SUBHEADER')
"
>
<div class="flex gap-4 items-center">
<p class="text-sm text-slate-600">
{{
$t(
'INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_EMBEDDED_SIGNUP_DESCRIPTION'
)
}}
</p>
<NextButton @click="handleReconfigure">
{{ $t('INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_RECONFIGURE_BUTTON') }}
</NextButton>
</div>
</SettingsSection>
</template>
<!-- Manual Setup Section -->
<template v-else>
<SettingsSection
:title="$t('INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_WEBHOOK_TITLE')"
:sub-title="$t('INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_WEBHOOK_SUBHEADER')"
:sub-title="
$t('INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_WEBHOOK_SUBHEADER')
"
>
<woot-code :script="inbox.provider_config.webhook_verify_token" />
</SettingsSection>
<SettingsSection
:title="$t('INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_SECTION_TITLE')"
:sub-title="$t('INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_SECTION_SUBHEADER')"
:sub-title="
$t('INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_SECTION_SUBHEADER')
"
>
<woot-code :script="inbox.provider_config.api_key" />
</SettingsSection>
@@ -245,10 +293,13 @@ export default {
:disabled="v$.whatsAppInboxAPIKey.$invalid"
@click="updateWhatsAppInboxAPIKey"
>
{{ $t('INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_SECTION_UPDATE_BUTTON') }}
{{
$t('INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_SECTION_UPDATE_BUTTON')
}}
</NextButton>
</div>
</SettingsSection>
</template>
<SettingsSection
:title="$t('INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_TEMPLATES_SYNC_TITLE')"
:sub-title="
@@ -262,6 +313,12 @@ export default {
</div>
</SettingsSection>
</div>
<WhatsappReauthorize
v-if="isEmbeddedSignupWhatsApp"
ref="whatsappReauth"
:inbox="inbox"
class="hidden"
/>
</div>
</template>

View File

@@ -32,8 +32,8 @@ class Channel::Whatsapp < ApplicationRecord
validates :phone_number, presence: true, uniqueness: true
validate :validate_provider_config
before_save :setup_webhooks
after_create :sync_templates
after_create_commit :setup_webhooks
before_destroy :teardown_webhooks
def name
@@ -78,8 +78,12 @@ class Channel::Whatsapp < ApplicationRecord
handle_webhook_setup_error(e)
end
def provider_config_changed?
will_save_change_to_provider_config?
end
def should_setup_webhooks?
whatsapp_cloud_provider? && embedded_signup_source? && webhook_config_present?
whatsapp_cloud_provider? && embedded_signup_source? && webhook_config_present? && provider_config_changed?
end
def whatsapp_cloud_provider?

View File

@@ -1,10 +1,11 @@
class Whatsapp::EmbeddedSignupService
def initialize(account:, code:, business_id:, waba_id:, phone_number_id:)
def initialize(account:, params:, inbox_id: nil)
@account = account
@code = code
@business_id = business_id
@waba_id = waba_id
@phone_number_id = phone_number_id
@code = params[:code]
@business_id = params[:business_id]
@waba_id = params[:waba_id]
@phone_number_id = params[:phone_number_id]
@inbox_id = inbox_id
end
def perform
@@ -19,11 +20,19 @@ class Whatsapp::EmbeddedSignupService
# Validate token has access to the WABA
Whatsapp::TokenValidationService.new(access_token, @waba_id).perform
# Create channel
# Reauthorization flow if inbox_id is present
if @inbox_id.present?
Whatsapp::ReauthorizationService.new(
account: @account,
inbox_id: @inbox_id,
phone_number_id: @phone_number_id,
business_id: @business_id
).perform(access_token, phone_info)
else
# Create channel for new authorization
waba_info = { waba_id: @waba_id, business_name: phone_info[:business_name] }
# Webhook setup is now handled in the channel after_create_commit callback
Whatsapp::ChannelCreationService.new(@account, waba_info, phone_info, access_token).perform
end
rescue StandardError => e
Rails.logger.error("[WHATSAPP] Embedded signup failed: #{e.message}")
raise e

View File

@@ -0,0 +1,42 @@
class Whatsapp::ReauthorizationService
def initialize(account:, inbox_id:, phone_number_id:, business_id:)
@account = account
@inbox_id = inbox_id
@phone_number_id = phone_number_id
@business_id = business_id
end
def perform(access_token, phone_info)
inbox = @account.inboxes.find(@inbox_id)
channel = inbox.channel
# Validate phone number matches for reauthorization
if phone_info[:phone_number] != channel.phone_number
raise StandardError, "Phone number mismatch. Expected #{channel.phone_number}, got #{phone_info[:phone_number]}"
end
# Update channel configuration
update_channel_config(channel, access_token, phone_info)
# Mark as reauthorized
channel.reauthorized! if channel.respond_to?(:reauthorized!)
channel
end
private
def update_channel_config(channel, access_token, phone_info)
current_config = channel.provider_config || {}
channel.provider_config = current_config.merge(
'api_key' => access_token,
'phone_number_id' => @phone_number_id,
'business_account_id' => @business_id,
'source' => 'embedded_signup'
)
channel.save!
# Update inbox name if business name changed
business_name = phone_info[:business_name] || phone_info[:verified_name]
channel.inbox.update!(name: business_name) if business_name.present?
end
end

View File

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

View File

@@ -31,6 +31,11 @@
en:
hello: 'Hello world'
inbox:
reauthorization:
success: 'Channel reauthorized successfully'
not_required: 'Reauthorization is not required for this inbox'
invalid_channel: 'Invalid channel type for reauthorization'
messages:
reset_password_success: Woot! Request for password reset is successful. Check your mail for instructions.
reset_password_failure: Uh ho! We could not find any user with the specified email.
@@ -67,6 +72,13 @@ en:
invalid_message_type: 'Invalid message type. Action not permitted'
slack:
invalid_channel_id: 'Invalid slack channel. Please try again'
whatsapp:
token_exchange_failed: 'Failed to exchange code for access token. Please try again.'
invalid_token_permissions: 'The access token does not have the required permissions for WhatsApp.'
phone_info_fetch_failed: 'Failed to fetch phone number information. Please try again.'
reauthorization:
generic: 'Failed to reauthorize WhatsApp. Please try again.'
not_supported: 'Reauthorization is not supported for this type of WhatsApp channel.'
inboxes:
imap:
socket_error: Please check the network connection, IMAP address and try again.

View File

@@ -119,19 +119,18 @@ RSpec.describe 'WhatsApp Authorization API', type: :request do
expect(Whatsapp::EmbeddedSignupService).to receive(:new).with(
account: account,
params: {
code: 'test_code',
business_id: 'test_business_id',
waba_id: 'test_waba_id',
phone_number_id: 'test_phone_id'
},
inbox_id: nil
).and_return(embedded_signup_service)
allow(embedded_signup_service).to receive(:perform).and_return(whatsapp_channel)
allow(whatsapp_channel).to receive(:inbox).and_return(inbox)
# Stub webhook setup service
webhook_service = instance_double(Whatsapp::WebhookSetupService)
allow(Whatsapp::WebhookSetupService).to receive(:new).and_return(webhook_service)
allow(webhook_service).to receive(:perform)
allow(Whatsapp::WebhookSetupService).to receive(:new).and_return(instance_double(Whatsapp::WebhookSetupService, perform: true))
post "/api/v1/accounts/#{account.id}/whatsapp/authorization",
params: {
@@ -151,19 +150,17 @@ RSpec.describe 'WhatsApp Authorization API', type: :request do
expect(Whatsapp::EmbeddedSignupService).to receive(:new).with(
account: account,
params: {
code: 'test_code',
business_id: 'test_business_id',
waba_id: 'test_waba_id',
phone_number_id: nil
waba_id: 'test_waba_id'
},
inbox_id: nil
).and_return(embedded_signup_service)
allow(embedded_signup_service).to receive(:perform).and_return(whatsapp_channel)
allow(whatsapp_channel).to receive(:inbox).and_return(inbox)
# Stub webhook setup service
webhook_service = instance_double(Whatsapp::WebhookSetupService)
allow(Whatsapp::WebhookSetupService).to receive(:new).and_return(webhook_service)
allow(webhook_service).to receive(:perform)
allow(Whatsapp::WebhookSetupService).to receive(:new).and_return(instance_double(Whatsapp::WebhookSetupService, perform: true))
post "/api/v1/accounts/#{account.id}/whatsapp/authorization",
params: {
@@ -300,4 +297,236 @@ RSpec.describe 'WhatsApp Authorization API', type: :request do
end
end
end
describe 'POST /api/v1/accounts/{account.id}/whatsapp/authorization with inbox_id (reauthorization)' do
let(:whatsapp_channel) do
channel = build(:channel_whatsapp, account: account, provider: 'whatsapp_cloud',
provider_config: {
'api_key' => 'test_token',
'phone_number_id' => '123456',
'business_account_id' => '654321',
'source' => 'embedded_signup'
})
allow(channel).to receive(:validate_provider_config).and_return(true)
allow(channel).to receive(:sync_templates).and_return(true)
allow(channel).to receive(:setup_webhooks).and_return(true)
channel.save!
# Call authorization_error! twice to reach the threshold
channel.authorization_error!
channel.authorization_error!
channel
end
let(:whatsapp_inbox) { create(:inbox, channel: whatsapp_channel, account: account) }
context 'when user is an administrator' do
let(:administrator) { create(:user, account: account, role: :administrator) }
before do
account.enable_features!(:whatsapp_embedded_signup)
end
context 'with valid parameters' do
let(:valid_params) do
{
code: 'auth_code_123',
business_id: 'business_123',
waba_id: 'waba_123',
phone_number_id: 'phone_123'
}
end
it 'reauthorizes the WhatsApp channel successfully' do
allow(whatsapp_channel).to receive(:reauthorization_required?).and_return(true)
embedded_signup_service = instance_double(Whatsapp::EmbeddedSignupService)
allow(Whatsapp::EmbeddedSignupService).to receive(:new).with(
account: account,
params: {
code: 'auth_code_123',
business_id: 'business_123',
waba_id: 'waba_123',
phone_number_id: 'phone_123'
},
inbox_id: whatsapp_inbox.id
).and_return(embedded_signup_service)
allow(embedded_signup_service).to receive(:perform).and_return(whatsapp_channel)
allow(whatsapp_channel).to receive(:inbox).and_return(whatsapp_inbox)
post "/api/v1/accounts/#{account.id}/whatsapp/authorization",
params: valid_params.merge(inbox_id: whatsapp_inbox.id),
headers: administrator.create_new_auth_token,
as: :json
expect(response).to have_http_status(:success)
json_response = response.parsed_body
expect(json_response['success']).to be true
expect(json_response['id']).to eq(whatsapp_inbox.id)
end
it 'handles reauthorization failure' do
embedded_signup_service = instance_double(Whatsapp::EmbeddedSignupService)
allow(Whatsapp::EmbeddedSignupService).to receive(:new).with(
account: account,
params: {
code: 'auth_code_123',
business_id: 'business_123',
waba_id: 'waba_123',
phone_number_id: 'phone_123'
},
inbox_id: whatsapp_inbox.id
).and_return(embedded_signup_service)
allow(embedded_signup_service).to receive(:perform)
.and_raise(StandardError, 'Token exchange failed')
post "/api/v1/accounts/#{account.id}/whatsapp/authorization",
params: valid_params.merge(inbox_id: whatsapp_inbox.id),
headers: administrator.create_new_auth_token,
as: :json
expect(response).to have_http_status(:unprocessable_entity)
json_response = response.parsed_body
expect(json_response['success']).to be false
expect(json_response['error']).to eq('Token exchange failed')
end
it 'handles phone number mismatch error' do
embedded_signup_service = instance_double(Whatsapp::EmbeddedSignupService)
allow(Whatsapp::EmbeddedSignupService).to receive(:new).with(
account: account,
params: {
code: 'auth_code_123',
business_id: 'business_123',
waba_id: 'waba_123',
phone_number_id: 'phone_123'
},
inbox_id: whatsapp_inbox.id
).and_return(embedded_signup_service)
allow(embedded_signup_service).to receive(:perform)
.and_raise(StandardError, 'Phone number mismatch. The new phone number (+1234567890) does not match ' \
'the existing phone number (+15551234567). Please use the same WhatsApp ' \
'Business Account that was originally connected.')
post "/api/v1/accounts/#{account.id}/whatsapp/authorization",
params: valid_params.merge(inbox_id: whatsapp_inbox.id),
headers: administrator.create_new_auth_token,
as: :json
expect(response).to have_http_status(:unprocessable_entity)
json_response = response.parsed_body
expect(json_response['success']).to be false
expect(json_response['error']).to include('Phone number mismatch')
end
end
context 'when inbox does not exist' do
it 'returns not found error' do
post "/api/v1/accounts/#{account.id}/whatsapp/authorization",
params: { inbox_id: 0, code: 'test', business_id: 'test', waba_id: 'test' },
headers: administrator.create_new_auth_token,
as: :json
expect(response).to have_http_status(:not_found)
end
end
context 'when reauthorization is not required' do
let(:fresh_channel) do
channel = build(:channel_whatsapp, account: account, provider: 'whatsapp_cloud',
provider_config: {
'api_key' => 'test_token',
'phone_number_id' => '123456',
'business_account_id' => '654321',
'source' => 'embedded_signup'
})
allow(channel).to receive(:validate_provider_config).and_return(true)
allow(channel).to receive(:sync_templates).and_return(true)
allow(channel).to receive(:setup_webhooks).and_return(true)
channel.save!
# Do NOT call authorization_error! - channel is working fine
channel
end
let(:fresh_inbox) { create(:inbox, channel: fresh_channel, account: account) }
it 'returns unprocessable entity error' do
post "/api/v1/accounts/#{account.id}/whatsapp/authorization",
params: { inbox_id: fresh_inbox.id },
headers: administrator.create_new_auth_token,
as: :json
expect(response).to have_http_status(:unprocessable_entity)
json_response = response.parsed_body
expect(json_response['success']).to be false
end
end
context 'when channel is not WhatsApp' do
let(:facebook_channel) do
stub_request(:post, 'https://graph.facebook.com/v3.2/me/subscribed_apps')
.to_return(status: 200, body: '{}', headers: {})
channel = create(:channel_facebook_page, account: account)
# Call authorization_error! twice to reach the threshold
channel.authorization_error!
channel.authorization_error!
channel
end
let(:facebook_inbox) { create(:inbox, channel: facebook_channel, account: account) }
it 'returns unprocessable entity error' do
post "/api/v1/accounts/#{account.id}/whatsapp/authorization",
params: { inbox_id: facebook_inbox.id },
headers: administrator.create_new_auth_token,
as: :json
expect(response).to have_http_status(:unprocessable_entity)
json_response = response.parsed_body
expect(json_response['success']).to be false
end
end
end
context 'when user is an agent' do
let(:agent) { create(:user, account: account, role: :agent) }
before do
account.enable_features!(:whatsapp_embedded_signup)
create(:inbox_member, inbox: whatsapp_inbox, user: agent)
end
it 'returns unprocessable_entity error' do
allow(whatsapp_channel).to receive(:reauthorization_required?).and_return(true)
# Stub the embedded signup service to prevent HTTP calls
embedded_signup_service = instance_double(Whatsapp::EmbeddedSignupService)
allow(Whatsapp::EmbeddedSignupService).to receive(:new).with(
account: account,
params: {
code: 'test',
business_id: 'test',
waba_id: 'test'
},
inbox_id: whatsapp_inbox.id
).and_return(embedded_signup_service)
allow(embedded_signup_service).to receive(:perform).and_return(whatsapp_channel)
post "/api/v1/accounts/#{account.id}/whatsapp/authorization",
params: { inbox_id: whatsapp_inbox.id, code: 'test', business_id: 'test', waba_id: 'test' },
headers: agent.create_new_auth_token,
as: :json
# Agents should get unprocessable_entity since they can find the inbox but channel doesn't need reauth
expect(response).to have_http_status(:unprocessable_entity)
end
end
context 'when user is not authenticated' do
it 'returns unauthorized error' do
post "/api/v1/accounts/#{account.id}/whatsapp/authorization",
params: { inbox_id: whatsapp_inbox.id },
as: :json
expect(response).to have_http_status(:unauthorized)
end
end
end
end

View File

@@ -2,17 +2,18 @@ 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(:params) do
{
code: 'test_authorization_code',
business_id: 'test_business_id',
waba_id: 'test_waba_id',
phone_number_id: 'test_phone_number_id'
}
end
let(:service) do
described_class.new(
account: account,
code: code,
business_id: business_id,
waba_id: waba_id,
phone_number_id: phone_number_id
params: params
)
end
@@ -20,37 +21,40 @@ describe Whatsapp::EmbeddedSignupService do
let(:access_token) { 'test_access_token' }
let(:phone_info) do
{
phone_number_id: phone_number_id,
phone_number_id: params[:phone_number_id],
phone_number: '+1234567890',
verified: true,
business_name: 'Test Business'
}
end
let(:channel) { instance_double(Channel::Whatsapp) }
let(:token_exchange_service) { instance_double(Whatsapp::TokenExchangeService) }
let(:phone_info_service) { instance_double(Whatsapp::PhoneInfoService) }
let(:token_validation_service) { instance_double(Whatsapp::TokenValidationService) }
let(:channel_creation_service) { instance_double(Whatsapp::ChannelCreationService) }
let(:service_doubles) do
{
token_exchange: instance_double(Whatsapp::TokenExchangeService),
phone_info: instance_double(Whatsapp::PhoneInfoService),
token_validation: instance_double(Whatsapp::TokenValidationService),
channel_creation: instance_double(Whatsapp::ChannelCreationService)
}
end
before do
allow(GlobalConfig).to receive(:clear_cache)
allow(Whatsapp::TokenExchangeService).to receive(:new).with(code).and_return(token_exchange_service)
allow(token_exchange_service).to receive(:perform).and_return(access_token)
allow(Whatsapp::TokenExchangeService).to receive(:new).with(params[:code]).and_return(service_doubles[:token_exchange])
allow(service_doubles[:token_exchange]).to receive(:perform).and_return(access_token)
allow(Whatsapp::PhoneInfoService).to receive(:new)
.with(waba_id, phone_number_id, access_token).and_return(phone_info_service)
allow(phone_info_service).to receive(:perform).and_return(phone_info)
.with(params[:waba_id], params[:phone_number_id], access_token).and_return(service_doubles[:phone_info])
allow(service_doubles[:phone_info]).to receive(:perform).and_return(phone_info)
allow(Whatsapp::TokenValidationService).to receive(:new)
.with(access_token, waba_id).and_return(token_validation_service)
allow(token_validation_service).to receive(:perform)
.with(access_token, params[:waba_id]).and_return(service_doubles[:token_validation])
allow(service_doubles[:token_validation]).to receive(:perform)
allow(Whatsapp::ChannelCreationService).to receive(:new)
.with(account, { waba_id: waba_id, business_name: 'Test Business' }, phone_info, access_token)
.and_return(channel_creation_service)
allow(channel_creation_service).to receive(:perform).and_return(channel)
.with(account, { waba_id: params[:waba_id], business_name: 'Test Business' }, phone_info, access_token)
.and_return(service_doubles[:channel_creation])
allow(service_doubles[:channel_creation]).to receive(:perform).and_return(channel)
# Webhook setup is now handled in the channel after_create callback
# So we stub it at the model level
@@ -60,10 +64,10 @@ describe Whatsapp::EmbeddedSignupService do
end
it 'orchestrates all services in the correct order' do
expect(token_exchange_service).to receive(:perform).ordered
expect(phone_info_service).to receive(:perform).ordered
expect(token_validation_service).to receive(:perform).ordered
expect(channel_creation_service).to receive(:perform).ordered
expect(service_doubles[:token_exchange]).to receive(:perform).ordered
expect(service_doubles[:phone_info]).to receive(:perform).ordered
expect(service_doubles[:token_validation]).to receive(:perform).ordered
expect(service_doubles[:channel_creation]).to receive(:perform).ordered
result = service.perform
expect(result).to eq(channel)
@@ -73,10 +77,7 @@ describe Whatsapp::EmbeddedSignupService do
it 'raises error when code is blank' do
service = described_class.new(
account: account,
code: '',
business_id: business_id,
waba_id: waba_id,
phone_number_id: phone_number_id
params: params.merge(code: '')
)
expect { service.perform }.to raise_error(ArgumentError, /Required parameters are missing: code/)
end
@@ -84,10 +85,7 @@ describe Whatsapp::EmbeddedSignupService do
it 'raises error when business_id is blank' do
service = described_class.new(
account: account,
code: code,
business_id: '',
waba_id: waba_id,
phone_number_id: phone_number_id
params: params.merge(business_id: '')
)
expect { service.perform }.to raise_error(ArgumentError, /Required parameters are missing: business_id/)
end
@@ -95,10 +93,7 @@ describe Whatsapp::EmbeddedSignupService do
it 'raises error when waba_id is blank' do
service = described_class.new(
account: account,
code: code,
business_id: business_id,
waba_id: '',
phone_number_id: phone_number_id
params: params.merge(waba_id: '')
)
expect { service.perform }.to raise_error(ArgumentError, /Required parameters are missing: waba_id/)
end
@@ -106,10 +101,7 @@ describe Whatsapp::EmbeddedSignupService do
it 'raises error when multiple parameters are blank' do
service = described_class.new(
account: account,
code: '',
business_id: '',
waba_id: waba_id,
phone_number_id: phone_number_id
params: params.merge(code: '', business_id: '')
)
expect { service.perform }.to raise_error(ArgumentError, /Required parameters are missing: code, business_id/)
end
@@ -117,11 +109,44 @@ describe Whatsapp::EmbeddedSignupService do
context 'when any service fails' do
it 'logs and re-raises the error' do
allow(token_exchange_service).to receive(:perform).and_raise('Token error')
allow(service_doubles[:token_exchange]).to receive(:perform).and_raise('Token error')
expect(Rails.logger).to receive(:error).with('[WHATSAPP] Embedded signup failed: Token error')
expect { service.perform }.to raise_error('Token error')
end
end
context 'when inbox_id is provided (reauthorization flow)' do
let(:inbox_id) { 123 }
let(:reauth_service) { instance_double(Whatsapp::ReauthorizationService) }
let(:service_with_inbox) do
described_class.new(
account: account,
params: params,
inbox_id: inbox_id
)
end
before do
allow(Whatsapp::ReauthorizationService).to receive(:new).with(
account: account,
inbox_id: inbox_id,
phone_number_id: params[:phone_number_id],
business_id: params[:business_id]
).and_return(reauth_service)
allow(reauth_service).to receive(:perform).with(access_token, phone_info).and_return(channel)
end
it 'uses ReauthorizationService instead of ChannelCreationService' do
expect(service_doubles[:token_exchange]).to receive(:perform).ordered
expect(service_doubles[:phone_info]).to receive(:perform).ordered
expect(service_doubles[:token_validation]).to receive(:perform).ordered
expect(reauth_service).to receive(:perform).with(access_token, phone_info).ordered
expect(service_doubles[:channel_creation]).not_to receive(:perform)
result = service_with_inbox.perform
expect(result).to eq(channel)
end
end
end
end

View File

@@ -7,6 +7,9 @@ RSpec.describe Whatsapp::WebhookTeardownService do
context 'when channel is whatsapp_cloud with embedded_signup' do
before do
# Stub webhook setup to prevent HTTP calls during channel update
allow(channel).to receive(:setup_webhooks).and_return(true)
channel.update!(
provider: 'whatsapp_cloud',
provider_config: {