From 13918c046d5cca7dc836f6ff639b271de9b60010 Mon Sep 17 00:00:00 2001 From: Tanmay Deep Sharma <32020192+tds-1@users.noreply.github.com> Date: Wed, 9 Jul 2025 17:23:48 +0700 Subject: [PATCH] add reauth flow for wa embedded signup --- .../whatsapp/embedded_controller.rb | 43 +++ .../composables/useWhatsappReauthorization.js | 317 ++++++++++++++++++ .../dashboard/i18n/locale/en/inboxMgmt.json | 21 +- .../dashboard/settings/inbox/Settings.vue | 8 + .../inbox/channels/whatsapp/Reauthorize.vue | 38 +++ .../whatsapp/embedded_signup_service.rb | 52 ++- app/views/api/v1/models/_inbox.json.jbuilder | 1 + config/routes.rb | 1 + get_business_account_path.rb | 99 ++++++ 9 files changed, 578 insertions(+), 2 deletions(-) create mode 100644 app/javascript/dashboard/composables/useWhatsappReauthorization.js create mode 100644 app/javascript/dashboard/routes/dashboard/settings/inbox/channels/whatsapp/Reauthorize.vue create mode 100755 get_business_account_path.rb diff --git a/app/controllers/whatsapp/embedded_controller.rb b/app/controllers/whatsapp/embedded_controller.rb index 625a11f3e..feb582cc2 100644 --- a/app/controllers/whatsapp/embedded_controller.rb +++ b/app/controllers/whatsapp/embedded_controller.rb @@ -32,6 +32,28 @@ class Whatsapp::EmbeddedController < ApplicationController handle_signup_error(e) end + def reauthorize + # Reauthorize existing WhatsApp inbox using embedded signup flow + validate_authorization_code! + return if performed? + + validate_required_parameters! + return if performed? + + validate_inbox_id! + return if performed? + + channel = process_reauthorization + @inbox = channel.inbox + + # Clear reauthorization required flag + channel.reauthorized! + + render json: { message: 'WhatsApp channel reauthorized successfully' }, status: :ok + rescue StandardError => e + handle_signup_error(e) + end + private def validate_authorization_code! @@ -51,6 +73,14 @@ class Whatsapp::EmbeddedController < ApplicationController }, status: :bad_request end + def validate_inbox_id! + return if params[:inbox_id].present? + + render json: { + error: 'Missing inbox_id parameter' + }, status: :bad_request + end + def process_signup service = Whatsapp::EmbeddedSignupService.new( account: Current.account, @@ -63,6 +93,19 @@ class Whatsapp::EmbeddedController < ApplicationController service.perform end + def process_reauthorization + service = Whatsapp::EmbeddedSignupService.new( + account: Current.account, + code: params[:code], + business_id: params[:business_id], + waba_id: params[:waba_id], + phone_number_id: params[:phone_number_id], + inbox_id: params[:inbox_id] + ) + + service.perform_reauthorization + end + def handle_signup_error(error) Rails.logger.error("WhatsApp embedded signup processing error: #{error.message}") Rails.logger.error(error.backtrace.join("\n")) diff --git a/app/javascript/dashboard/composables/useWhatsappReauthorization.js b/app/javascript/dashboard/composables/useWhatsappReauthorization.js new file mode 100644 index 000000000..47ca43b18 --- /dev/null +++ b/app/javascript/dashboard/composables/useWhatsappReauthorization.js @@ -0,0 +1,317 @@ +import { ref, computed } from 'vue'; +import { useStore } from 'vuex'; +import { useRouter } from 'vue-router'; +import { useI18n } from 'vue-i18n'; +import { useAlert } from 'dashboard/composables'; +import Auth from 'dashboard/api/auth'; + +export function useWhatsappReauthorization(inboxId) { + const store = useStore(); + const router = useRouter(); + const { t } = useI18n(); + + // State + const fbSdkLoaded = ref(false); + const isProcessing = ref(false); + const processingMessage = ref(''); + const authCodeReceived = ref(false); + const authCode = ref(null); + const businessData = ref(null); + const isAuthenticating = ref(false); + + // Computed + const authHeaders = computed(() => { + if (Auth.hasAuthCookie()) { + const { + 'access-token': accessToken, + 'token-type': tokenType, + client, + expiry, + uid, + } = Auth.getAuthData(); + return { + 'access-token': accessToken, + 'token-type': tokenType, + client, + expiry, + uid, + }; + } + return {}; + }); + + const showLoader = computed( + () => isAuthenticating.value || isProcessing.value + ); + + // Error handling + const handleReauthorizationError = data => { + isProcessing.value = false; + authCodeReceived.value = false; + isAuthenticating.value = false; + + const errorMessage = + data.error || + data.message || + t('INBOX_MGMT.SETTINGS.WHATSAPP_REAUTHORIZE.ERROR'); + useAlert(errorMessage); + }; + + const handleReauthorizationCancellation = () => { + isProcessing.value = false; + authCodeReceived.value = false; + isAuthenticating.value = false; + + useAlert(t('INBOX_MGMT.SETTINGS.WHATSAPP_REAUTHORIZE.CANCELLED')); + }; + + const handleReauthorizationSuccess = async () => { + isProcessing.value = false; + isAuthenticating.value = false; + + // Refresh the inbox to update reauthorization status + await store.dispatch('inboxes/get'); + useAlert(t('INBOX_MGMT.SETTINGS.WHATSAPP_REAUTHORIZE.SUCCESS')); + + // Reload the page to reflect the changes + router.go(0); + }; + + // Reauthorization flow + const completeReauthorizationFlow = async businessDataParam => { + if (!authCodeReceived.value || !authCode.value) { + handleReauthorizationError({ + error: t('INBOX_MGMT.SETTINGS.WHATSAPP_REAUTHORIZE.AUTH_NOT_COMPLETED'), + }); + return; + } + + isProcessing.value = true; + processingMessage.value = t( + 'INBOX_MGMT.SETTINGS.WHATSAPP_REAUTHORIZE.PROCESSING' + ); + + try { + // Send reauthorization request + const accountId = store.getters.getCurrentAccountId; + const response = await fetch('/whatsapp/reauthorize', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-CSRF-Token': document + .querySelector('meta[name="csrf-token"]') + ?.getAttribute('content'), + ...authHeaders.value, + }, + body: JSON.stringify({ + account_id: accountId, + inbox_id: inboxId, + code: authCode.value, + business_id: businessDataParam.business_id, + waba_id: businessDataParam.waba_id, + phone_number_id: businessDataParam.phone_number_id, + }), + }); + + const responseData = await response.json(); + + if (response.ok) { + // Clear the stored auth code for security + authCode.value = null; + + // Handle synchronous success response + handleReauthorizationSuccess(); + } else { + throw new Error(responseData.message || responseData.error); + } + } catch (error) { + handleReauthorizationError({ error: error.message }); + } + }; + + const isValidBusinessData = businessDataLocal => { + return ( + businessDataLocal && + (businessDataLocal.business_id || businessDataLocal.businessId) && + (businessDataLocal.waba_id || businessDataLocal.wabaId) + ); + }; + + // Message handling + const handleEmbeddedSignupData = async data => { + // Handle different embedded signup events per Facebook documentation + if (data.event === 'FINISH') { + // Facebook might send business data in different structures + let businessDataLocal = data.data; + + // If data.data doesn't exist, try other possible structures + if (!businessDataLocal) { + businessDataLocal = data.business_data || data.details || data; + } + + // Validate we have the required business information + if (isValidBusinessData(businessDataLocal)) { + // Normalize the data structure to match our backend expectations + const normalizedData = { + business_id: + businessDataLocal.business_id || businessDataLocal.businessId, + waba_id: businessDataLocal.waba_id || businessDataLocal.wabaId, + phone_number_id: + businessDataLocal.phone_number_id || + businessDataLocal.phoneNumberId || + businessDataLocal.phone_id, + }; + + // Store business data + businessData.value = normalizedData; + // Check if we already have auth code and process immediately + if (authCodeReceived.value && authCode.value) { + await completeReauthorizationFlow(normalizedData); + } else { + processingMessage.value = t( + 'INBOX_MGMT.SETTINGS.WHATSAPP_REAUTHORIZE.WAITING_FOR_AUTH' + ); + } + } else { + handleReauthorizationError({ + error: t( + 'INBOX_MGMT.SETTINGS.WHATSAPP_REAUTHORIZE.INVALID_BUSINESS_DATA' + ), + }); + } + } else if (data.event === 'CANCEL') { + handleReauthorizationCancellation(); + } else if (data.event === 'error') { + handleReauthorizationError({ + error: + data.error_message || + t('INBOX_MGMT.SETTINGS.WHATSAPP_REAUTHORIZE.ERROR'), + }); + } + }; + + const fbLoginCallback = response => { + if (response.authResponse && response.authResponse.code) { + // Authorization code received from Facebook + authCode.value = response.authResponse.code; + authCodeReceived.value = true; + processingMessage.value = t( + 'INBOX_MGMT.SETTINGS.WHATSAPP_REAUTHORIZE.WAITING_FOR_BUSINESS_INFO' + ); + + // Check if we already have business data and process immediately + if (businessData.value) { + completeReauthorizationFlow(businessData.value); + } + } else if (response.error) { + handleReauthorizationError({ error: response.error }); + } else { + isProcessing.value = false; + isAuthenticating.value = false; + useAlert(t('INBOX_MGMT.SETTINGS.WHATSAPP_REAUTHORIZE.CANCELLED')); + } + }; + + const handleSignupMessage = event => { + // Handle Facebook embedded signup message events + try { + const originUrl = new URL(event.origin); + const allowedHosts = ['facebook.com', 'www.facebook.com']; + if (!allowedHosts.includes(originUrl.hostname)) return; + } catch (error) { + // Invalid origin URL, reject the event + return; + } + + try { + const data = JSON.parse(event.data); + + if (data.type === 'WA_EMBEDDED_SIGNUP') { + handleEmbeddedSignupData(data); + } + } catch (error) { + // Handle non-JSON messages silently + } + }; + + // Facebook SDK + const loadFacebookSdk = () => { + if (window.FB) { + fbSdkLoaded.value = true; + return; + } + const script = document.createElement('script'); + script.src = 'https://connect.facebook.net/en_US/sdk.js'; + script.async = true; + script.defer = true; + script.onload = () => { + window.FB.init({ + appId: window.chatwootConfig?.whatsappAppId, + status: true, + xfbml: true, + version: window.chatwootConfig?.whatsappApiVersion || 'v22.0', + }); + fbSdkLoaded.value = true; + }; + document.body.appendChild(script); + }; + + const launchReauthorization = () => { + processingMessage.value = t( + 'INBOX_MGMT.SETTINGS.WHATSAPP_REAUTHORIZE.AUTH_PROCESSING' + ); + + if (!window.FB) { + loadFacebookSdk(); + setTimeout(() => launchReauthorization(), 1000); + return; + } + + isAuthenticating.value = true; + + // Following Facebook's embedded signup documentation + // Using auth_type: 'reauthorize' to force reauthorization + window.FB.login(fbLoginCallback, { + config_id: window.chatwootConfig?.whatsappConfigurationId, + response_type: 'code', + override_default_response_type: true, + auth_type: 'reauthorize', + extras: { + setup: {}, + featureType: '', // Leave empty for default flow + sessionInfoVersion: '3', + }, + }); + }; + + // Lifecycle + const setupMessageListener = () => { + window.addEventListener('message', handleSignupMessage); + }; + + const cleanupMessageListener = () => { + window.removeEventListener('message', handleSignupMessage); + }; + + // Initialize + const initialize = () => { + loadFacebookSdk(); + setupMessageListener(); + }; + + return { + // State + fbSdkLoaded, + isProcessing, + processingMessage, + isAuthenticating, + + // Computed + showLoader, + + // Methods + launchReauthorization, + initialize, + cleanupMessageListener, + }; +} diff --git a/app/javascript/dashboard/i18n/locale/en/inboxMgmt.json b/app/javascript/dashboard/i18n/locale/en/inboxMgmt.json index eb999a0e5..9a986523d 100644 --- a/app/javascript/dashboard/i18n/locale/en/inboxMgmt.json +++ b/app/javascript/dashboard/i18n/locale/en/inboxMgmt.json @@ -600,7 +600,20 @@ "WHATSAPP_SECTION_UPDATE_BUTTON": "Update", "WHATSAPP_WEBHOOK_TITLE": "Webhook Verification Token", "WHATSAPP_WEBHOOK_SUBHEADER": "This token is used to verify the authenticity of the webhook endpoint.", - "UPDATE_PRE_CHAT_FORM_SETTINGS": "Update Pre Chat Form Settings" + "UPDATE_PRE_CHAT_FORM_SETTINGS": "Update Pre Chat Form Settings", + "WHATSAPP_REAUTHORIZE": { + "TITLE": "Reauthorize WhatsApp Connection", + "DESCRIPTION": "Your WhatsApp Business account needs to be reauthorized to continue receiving messages. Click the button below to reconnect your account.", + "AUTH_PROCESSING": "Processing WhatsApp reauthorization...", + "WAITING_FOR_BUSINESS_INFO": "Waiting for business information...", + "WAITING_FOR_AUTH": "Waiting for authentication...", + "PROCESSING": "Updating your WhatsApp configuration...", + "SUCCESS": "WhatsApp successfully reauthorized!", + "ERROR": "Failed to reauthorize WhatsApp. Please try again.", + "CANCELLED": "WhatsApp reauthorization was cancelled.", + "AUTH_NOT_COMPLETED": "Authentication not completed. Please try again.", + "INVALID_BUSINESS_DATA": "Invalid business data received. Please try again." + } }, "HELP_CENTER": { "LABEL": "Help Center", @@ -620,6 +633,12 @@ "MESSAGE_SUCCESS": "Reconnection successful", "MESSAGE_ERROR": "There was an error, please try again" }, + "WHATSAPP_REAUTHORIZE": { + "TITLE": "Reauthorize WhatsApp", + "SUBTITLE": "Your WhatsApp connection has expired, please reconnect your WhatsApp Business account to continue services", + "MESSAGE_SUCCESS": "WhatsApp reconnection successful", + "MESSAGE_ERROR": "There was an error reconnecting WhatsApp, please try again" + }, "PRE_CHAT_FORM": { "DESCRIPTION": "Pre chat forms enable you to capture user information before they start conversation with you.", "SET_FIELDS": "Pre chat form fields", diff --git a/app/javascript/dashboard/routes/dashboard/settings/inbox/Settings.vue b/app/javascript/dashboard/routes/dashboard/settings/inbox/Settings.vue index a3f60c6d5..783e0b881 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'; @@ -45,6 +46,7 @@ export default { NextButton, InstagramReauthorize, DuplicateInboxBanner, + WhatsappReauthorize, Editor, }, mixins: [inboxMixin], @@ -231,6 +233,11 @@ export default { facebookUnauthorized() { return this.isAFacebookInbox && this.inbox.reauthorization_required; }, + whatsappUnauthorized() { + return ( + this.isAWhatsAppCloudChannel && this.inbox.reauthorization_required + ); + }, googleUnauthorized() { const isLegacyInbox = ['imap.gmail.com', 'imap.google.com'].includes( this.inbox.imap_address @@ -410,6 +417,7 @@ export default { + +import { onMounted, onBeforeUnmount } from 'vue'; +import InboxReconnectionRequired from '../../components/InboxReconnectionRequired.vue'; +import LoadingState from 'dashboard/components/widgets/LoadingState.vue'; +import { useWhatsappReauthorization } from 'dashboard/composables/useWhatsappReauthorization'; + +const props = defineProps({ + inbox: { + type: Object, + required: true, + }, +}); +const { + processingMessage, + showLoader, + launchReauthorization, + initialize, + cleanupMessageListener, +} = useWhatsappReauthorization(props.inbox.id); + +onMounted(() => { + initialize(); +}); + +onBeforeUnmount(() => { + cleanupMessageListener(); +}); + + + diff --git a/app/services/whatsapp/embedded_signup_service.rb b/app/services/whatsapp/embedded_signup_service.rb index 95ecfac2b..d99b526dd 100644 --- a/app/services/whatsapp/embedded_signup_service.rb +++ b/app/services/whatsapp/embedded_signup_service.rb @@ -1,12 +1,13 @@ class Whatsapp::EmbeddedSignupService include Rails.application.routes.url_helpers - def initialize(account:, code:, business_id:, waba_id:, phone_number_id:) + def initialize(account:, code:, business_id:, waba_id:, phone_number_id:, inbox_id: nil) @account = account @code = code @business_id = business_id @waba_id = waba_id @phone_number_id = phone_number_id + @inbox_id = inbox_id end def perform @@ -33,6 +34,43 @@ class Whatsapp::EmbeddedSignupService raise e end + def perform_reauthorization + # Validate required parameters + unless @code.present? && @business_id.present? && @waba_id.present? && @phone_number_id.present? && @inbox_id.present? + raise ArgumentError, 'Code, business_id, waba_id, phone_number_id, and inbox_id are all required for reauthorization' + end + + # Find the existing inbox and channel + inbox = @account.inboxes.find_by(id: @inbox_id) + raise ActiveRecord::RecordNotFound, 'Inbox not found' unless inbox + raise ArgumentError, 'Inbox is not a WhatsApp channel' unless inbox.channel_type == 'Channel::Whatsapp' + + channel = inbox.channel + raise ArgumentError, 'Channel is not WhatsApp Cloud provider' unless channel.provider == 'whatsapp_cloud' + + GlobalConfig.clear_cache + # Exchange code for new access token + access_token = exchange_code_for_token + + # Use the provided business info directly + phone_info = fetch_phone_info_via_waba(@waba_id, @phone_number_id, access_token) + + # Validate that the token has access to the provided WABA + validate_token_waba_access(access_token, @waba_id) + + # Update the channel with new access token and configuration + update_channel_for_reauthorization(channel, phone_info, access_token) + + # Re-register webhook with new token + register_phone_number(phone_info[:phone_number_id], access_token) + override_waba_webhook(@waba_id, channel, access_token) + + channel + rescue StandardError => e + Rails.logger.error("[WHATSAPP] Reauthorization failed: #{e.message}") + raise e + end + private def whatsapp_api_version @@ -138,6 +176,18 @@ class Whatsapp::EmbeddedSignupService ) end + def update_channel_for_reauthorization(channel, phone_info, access_token) + # Update channel with new access token and configuration + channel.update!( + provider_config: channel.provider_config.merge( + 'api_key' => access_token, + 'phone_number_id' => phone_info[:phone_number_id], + 'business_account_id' => @waba_id, + 'reauthorized_at' => Time.current.iso8601 + ) + ) + end + def sanitize_phone_number(phone_number) return phone_number if phone_number.blank? 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/routes.rb b/config/routes.rb index 75119414c..578e15e71 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -486,6 +486,7 @@ Rails.application.routes.draw do get 'signup', to: 'embedded#new' get 'signup/callback', to: 'embedded#callback' post 'embedded_signup', to: 'embedded#embedded_signup' + post 'reauthorize', to: 'embedded#reauthorize' end namespace :twitter do diff --git a/get_business_account_path.rb b/get_business_account_path.rb new file mode 100755 index 000000000..aef6ed500 --- /dev/null +++ b/get_business_account_path.rb @@ -0,0 +1,99 @@ +#!/usr/bin/env ruby + +# Script to get business account path for a specific inbox ID +# Usage: ruby get_business_account_path.rb + +# Set the inbox ID to query +INBOX_ID = 1090 + +# Load Rails environment (assuming this script is run from the Rails app root) +require_relative 'config/environment' + +def get_business_account_path(inbox_id) + # Find the inbox by ID + inbox = Inbox.find(inbox_id) + + # Check if this is a WhatsApp inbox + unless inbox.whatsapp? + puts "Error: Inbox #{inbox_id} is not a WhatsApp inbox. Channel type: #{inbox.channel_type}" + return nil + end + + # Get the WhatsApp channel + whatsapp_channel = inbox.channel + + # Check if it's a WhatsApp Cloud provider + unless whatsapp_channel.provider == 'whatsapp_cloud' + puts "Error: Inbox #{inbox_id} is not using WhatsApp Cloud provider. Provider: #{whatsapp_channel.provider}" + return nil + end + + # Get the business_account_id and api_key from provider_config + business_account_id = whatsapp_channel.provider_config['business_account_id'] + api_key = whatsapp_channel.provider_config['api_key'] + + if business_account_id.blank? + puts "Error: No business_account_id found in provider_config for inbox #{inbox_id}" + return nil + end + + if api_key.blank? + puts "Error: No api_key (access token) found in provider_config for inbox #{inbox_id}" + return nil + end + + # Construct the business account path (following the pattern from WhatsappCloudService) + api_base_path = ENV.fetch('WHATSAPP_CLOUD_BASE_URL', 'https://graph.facebook.com') + business_account_path = "#{api_base_path}/v14.0/#{business_account_id}" + + return { + inbox_id: inbox_id, + inbox_name: inbox.name, + business_account_id: business_account_id, + business_account_path: business_account_path, + provider: whatsapp_channel.provider, + phone_number: whatsapp_channel.phone_number, + api_key: api_key, + access_token: api_key # alias for clarity + } + +rescue ActiveRecord::RecordNotFound + puts "Error: Inbox with ID #{inbox_id} not found" + return nil +rescue StandardError => e + puts "Error: #{e.message}" + puts e.backtrace.first(5).join("\n") if ENV['DEBUG'] + return nil +end + +# Main execution +puts "Getting business account path for inbox ID: #{INBOX_ID}" +puts '=' * 50 + +result = get_business_account_path(INBOX_ID) + +if result + puts 'Success! Found WhatsApp Business Account details:' + puts + puts "Inbox ID: #{result[:inbox_id]}" + puts "Inbox Name: #{result[:inbox_name]}" + puts "Phone Number: #{result[:phone_number]}" + puts "Provider: #{result[:provider]}" + puts "Business Account ID: #{result[:business_account_id]}" + puts + puts 'Business Account Path:' + puts result[:business_account_path] + puts + puts 'Access Token (API Key):' + puts result[:access_token] + puts + puts 'Complete API Endpoint Examples:' + puts "• Message Templates: #{result[:business_account_path]}/message_templates?access_token=#{result[:access_token]}" + puts "• Phone Numbers: #{result[:business_account_path]}/phone_numbers?access_token=#{result[:access_token]}" + puts + puts 'Authorization Header Format:' + puts "Authorization: Bearer #{result[:access_token]}" +else + puts "Failed to get business account path for inbox ID #{INBOX_ID}" + exit 1 +end \ No newline at end of file