From d2583d32e9c5a76dc980d959d8cd9982bc4b2230 Mon Sep 17 00:00:00 2001 From: Tanmay Deep Sharma <32020192+tds-1@users.noreply.github.com> Date: Fri, 8 Aug 2025 01:48:45 +0530 Subject: [PATCH] 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 --- .../whatsapp/authorizations_controller.rb | 35 ++- .../dashboard/api/channel/whatsappChannel.js | 7 + .../dashboard/i18n/locale/en/inbox.json | 18 ++ .../dashboard/i18n/locale/en/inboxMgmt.json | 15 + .../dashboard/settings/inbox/Settings.vue | 16 +- .../inbox/channels/WhatsappEmbeddedSignup.vue | 122 +++----- .../inbox/channels/whatsapp/Reauthorize.vue | 190 +++++++++++++ .../settings/inbox/channels/whatsapp/utils.js | 89 ++++++ .../inbox/settingsPage/ConfigurationPage.vue | 131 ++++++--- app/models/channel/whatsapp.rb | 8 +- .../whatsapp/embedded_signup_service.rb | 29 +- .../whatsapp/reauthorization_service.rb | 42 +++ app/views/api/v1/models/_inbox.json.jbuilder | 1 + config/locales/en.yml | 12 + .../authorizations_controller_spec.rb | 265 ++++++++++++++++-- .../whatsapp/embedded_signup_service_spec.rb | 113 +++++--- .../whatsapp/webhook_teardown_service_spec.rb | 3 + 17 files changed, 890 insertions(+), 206 deletions(-) create mode 100644 app/javascript/dashboard/routes/dashboard/settings/inbox/channels/whatsapp/Reauthorize.vue create mode 100644 app/javascript/dashboard/routes/dashboard/settings/inbox/channels/whatsapp/utils.js create mode 100644 app/services/whatsapp/reauthorization_service.rb diff --git a/app/controllers/api/v1/accounts/whatsapp/authorizations_controller.rb b/app/controllers/api/v1/accounts/whatsapp/authorizations_controller.rb index e7a1f3fa6..3e7d876c3 100644 --- a/app/controllers/api/v1/accounts/whatsapp/authorizations_controller.rb +++ b/app/controllers/api/v1/accounts/whatsapp/authorizations_controller.rb @@ -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) diff --git a/app/javascript/dashboard/api/channel/whatsappChannel.js b/app/javascript/dashboard/api/channel/whatsappChannel.js index e1003b123..8f51f4878 100644 --- a/app/javascript/dashboard/api/channel/whatsappChannel.js +++ b/app/javascript/dashboard/api/channel/whatsappChannel.js @@ -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(); diff --git a/app/javascript/dashboard/i18n/locale/en/inbox.json b/app/javascript/dashboard/i18n/locale/en/inbox.json index a07bae4af..385e9e4ce 100644 --- a/app/javascript/dashboard/i18n/locale/en/inbox.json +++ b/app/javascript/dashboard/i18n/locale/en/inbox.json @@ -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" + } } } } diff --git a/app/javascript/dashboard/i18n/locale/en/inboxMgmt.json b/app/javascript/dashboard/i18n/locale/en/inboxMgmt.json index 12580bcac..821e2ecbd 100644 --- a/app/javascript/dashboard/i18n/locale/en/inboxMgmt.json +++ b/app/javascript/dashboard/i18n/locale/en/inboxMgmt.json @@ -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", diff --git a/app/javascript/dashboard/routes/dashboard/settings/inbox/Settings.vue b/app/javascript/dashboard/routes/dashboard/settings/inbox/Settings.vue index 420d7b7b9..d69a59055 100644 --- a/app/javascript/dashboard/routes/dashboard/settings/inbox/Settings.vue +++ b/app/javascript/dashboard/routes/dashboard/settings/inbox/Settings.vue @@ -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 { + { } }; -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,79 +187,18 @@ const fbLoginCallback = response => { 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 => { - // 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) { - handleSignupError({ - error: t('INBOX_MGMT.ADD.WHATSAPP.EMBEDDED_SIGNUP.SDK_LOAD_ERROR'), - }); + if (error.message === 'Login cancelled') { + isProcessing.value = false; + isAuthenticating.value = false; + useAlert(t('INBOX_MGMT.ADD.WHATSAPP.EMBEDDED_SIGNUP.CANCELLED')); + } else { + handleSignupError({ + error: + error.message || + t('INBOX_MGMT.ADD.WHATSAPP.EMBEDDED_SIGNUP.SDK_LOAD_ERROR'), + }); + } } }; @@ -259,7 +212,6 @@ const cleanupMessageListener = () => { }; const initialize = () => { - window.fbAsyncInit = runFBInit; setupMessageListener(); }; diff --git a/app/javascript/dashboard/routes/dashboard/settings/inbox/channels/whatsapp/Reauthorize.vue b/app/javascript/dashboard/routes/dashboard/settings/inbox/channels/whatsapp/Reauthorize.vue new file mode 100644 index 000000000..08ddcaded --- /dev/null +++ b/app/javascript/dashboard/routes/dashboard/settings/inbox/channels/whatsapp/Reauthorize.vue @@ -0,0 +1,190 @@ + + + diff --git a/app/javascript/dashboard/routes/dashboard/settings/inbox/channels/whatsapp/utils.js b/app/javascript/dashboard/routes/dashboard/settings/inbox/channels/whatsapp/utils.js new file mode 100644 index 000000000..be2721bc4 --- /dev/null +++ b/app/javascript/dashboard/routes/dashboard/settings/inbox/channels/whatsapp/utils.js @@ -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); +}; diff --git a/app/javascript/dashboard/routes/dashboard/settings/inbox/settingsPage/ConfigurationPage.vue b/app/javascript/dashboard/routes/dashboard/settings/inbox/settingsPage/ConfigurationPage.vue index 88af2e13f..71aea3f59 100644 --- a/app/javascript/dashboard/routes/dashboard/settings/inbox/settingsPage/ConfigurationPage.vue +++ b/app/javascript/dashboard/routes/dashboard/settings/inbox/settingsPage/ConfigurationPage.vue @@ -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,45 +226,80 @@ export default {
- - - - - - - -
+ + + +
diff --git a/app/models/channel/whatsapp.rb b/app/models/channel/whatsapp.rb index 8d054a586..7471cf807 100644 --- a/app/models/channel/whatsapp.rb +++ b/app/models/channel/whatsapp.rb @@ -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? diff --git a/app/services/whatsapp/embedded_signup_service.rb b/app/services/whatsapp/embedded_signup_service.rb index 0adf073cd..e66506638 100644 --- a/app/services/whatsapp/embedded_signup_service.rb +++ b/app/services/whatsapp/embedded_signup_service.rb @@ -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 - 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 + # 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] } + 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 diff --git a/app/services/whatsapp/reauthorization_service.rb b/app/services/whatsapp/reauthorization_service.rb new file mode 100644 index 000000000..aeb6dfbef --- /dev/null +++ b/app/services/whatsapp/reauthorization_service.rb @@ -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 diff --git a/app/views/api/v1/models/_inbox.json.jbuilder b/app/views/api/v1/models/_inbox.json.jbuilder index c66a6eb78..8042ee47c 100644 --- a/app/views/api/v1/models/_inbox.json.jbuilder +++ b/app/views/api/v1/models/_inbox.json.jbuilder @@ -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 diff --git a/config/locales/en.yml b/config/locales/en.yml index 9faa0cd9d..1d8347679 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -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. diff --git a/spec/controllers/api/v1/accounts/whatsapp/authorizations_controller_spec.rb b/spec/controllers/api/v1/accounts/whatsapp/authorizations_controller_spec.rb index eca193c76..2e74817a7 100644 --- a/spec/controllers/api/v1/accounts/whatsapp/authorizations_controller_spec.rb +++ b/spec/controllers/api/v1/accounts/whatsapp/authorizations_controller_spec.rb @@ -119,19 +119,18 @@ RSpec.describe 'WhatsApp Authorization API', type: :request do expect(Whatsapp::EmbeddedSignupService).to receive(:new).with( account: account, - code: 'test_code', - business_id: 'test_business_id', - waba_id: 'test_waba_id', - phone_number_id: 'test_phone_id' + 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, - code: 'test_code', - business_id: 'test_business_id', - waba_id: 'test_waba_id', - phone_number_id: nil + params: { + code: 'test_code', + business_id: 'test_business_id', + 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 diff --git a/spec/services/whatsapp/embedded_signup_service_spec.rb b/spec/services/whatsapp/embedded_signup_service_spec.rb index 95af73523..12a4d32df 100644 --- a/spec/services/whatsapp/embedded_signup_service_spec.rb +++ b/spec/services/whatsapp/embedded_signup_service_spec.rb @@ -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 diff --git a/spec/services/whatsapp/webhook_teardown_service_spec.rb b/spec/services/whatsapp/webhook_teardown_service_spec.rb index 25d2ae374..2a7ba9fd0 100644 --- a/spec/services/whatsapp/webhook_teardown_service_spec.rb +++ b/spec/services/whatsapp/webhook_teardown_service_spec.rb @@ -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: {