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: {