diff --git a/app/actions/contact_identify_action.rb b/app/actions/contact_identify_action.rb index bcf5a93c3..9afa5d019 100644 --- a/app/actions/contact_identify_action.rb +++ b/app/actions/contact_identify_action.rb @@ -104,7 +104,7 @@ class ContactIdentifyAction # blank identifier or email will throw unique index error # TODO: replace reject { |_k, v| v.blank? } with compact_blank when rails is upgraded @contact.discard_invalid_attrs if discard_invalid_attrs - @contact.save! + @contact.save! if @contact.changed? enqueue_avatar_job end diff --git a/app/controllers/api/v1/accounts/concerns/whatsapp_health_management.rb b/app/controllers/api/v1/accounts/concerns/whatsapp_health_management.rb new file mode 100644 index 000000000..795d7f2a9 --- /dev/null +++ b/app/controllers/api/v1/accounts/concerns/whatsapp_health_management.rb @@ -0,0 +1,55 @@ +module Api::V1::Accounts::Concerns::WhatsappHealthManagement + extend ActiveSupport::Concern + + included do + skip_before_action :check_authorization, only: [:health, :register_webhook] + before_action :check_admin_authorization?, only: [:register_webhook] + before_action :validate_whatsapp_cloud_channel, only: [:health, :register_webhook] + end + + def sync_templates + return render status: :unprocessable_entity, json: { error: 'Template sync is only available for WhatsApp channels' } unless whatsapp_channel? + + trigger_template_sync + render status: :ok, json: { message: 'Template sync initiated successfully' } + rescue StandardError => e + render status: :internal_server_error, json: { error: e.message } + end + + def health + health_data = Whatsapp::HealthService.new(@inbox.channel).fetch_health_status + render json: health_data + rescue StandardError => e + Rails.logger.error "[INBOX HEALTH] Error fetching health data: #{e.message}" + render json: { error: e.message }, status: :unprocessable_entity + end + + def register_webhook + Whatsapp::WebhookSetupService.new(@inbox.channel).register_callback + + render json: { message: 'Webhook registered successfully' }, status: :ok + rescue StandardError => e + Rails.logger.error "[INBOX WEBHOOK] Webhook registration failed: #{e.message}" + render json: { error: e.message }, status: :unprocessable_entity + end + + private + + def validate_whatsapp_cloud_channel + return if @inbox.channel.is_a?(Channel::Whatsapp) && @inbox.channel.provider == 'whatsapp_cloud' + + render json: { error: 'Health data only available for WhatsApp Cloud API channels' }, status: :bad_request + end + + def whatsapp_channel? + @inbox.whatsapp? || (@inbox.twilio? && @inbox.channel.whatsapp?) + end + + def trigger_template_sync + if @inbox.whatsapp? + Channels::Whatsapp::TemplatesSyncJob.perform_later(@inbox.channel) + elsif @inbox.twilio? && @inbox.channel.whatsapp? + Channels::Twilio::TemplatesSyncJob.perform_later(@inbox.channel) + end + end +end diff --git a/app/controllers/api/v1/accounts/inboxes_controller.rb b/app/controllers/api/v1/accounts/inboxes_controller.rb index 322c7c7fe..4ca9a6af8 100644 --- a/app/controllers/api/v1/accounts/inboxes_controller.rb +++ b/app/controllers/api/v1/accounts/inboxes_controller.rb @@ -4,8 +4,9 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController before_action :fetch_agent_bot, only: [:set_agent_bot] before_action :validate_limit, only: [:create] # we are already handling the authorization in fetch inbox - before_action :check_authorization, except: [:show, :health] - before_action :validate_whatsapp_cloud_channel, only: [:health] + before_action :check_authorization, except: [:show] + + include Api::V1::Accounts::Concerns::WhatsappHealthManagement def index @inboxes = policy_scope(Current.account.inboxes.order_by_name.includes(:channel, { avatar_attachment: [:blob] })) @@ -70,23 +71,6 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController render status: :ok, json: { message: I18n.t('messages.inbox_deletetion_response') } end - def sync_templates - return render status: :unprocessable_entity, json: { error: 'Template sync is only available for WhatsApp channels' } unless whatsapp_channel? - - trigger_template_sync - render status: :ok, json: { message: 'Template sync initiated successfully' } - rescue StandardError => e - render status: :internal_server_error, json: { error: e.message } - end - - def health - health_data = Whatsapp::HealthService.new(@inbox.channel).fetch_health_status - render json: health_data - rescue StandardError => e - Rails.logger.error "[INBOX HEALTH] Error fetching health data: #{e.message}" - render json: { error: e.message }, status: :unprocessable_entity - end - private def fetch_inbox @@ -98,12 +82,6 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController @agent_bot = AgentBot.find(params[:agent_bot]) if params[:agent_bot] end - def validate_whatsapp_cloud_channel - return if @inbox.channel.is_a?(Channel::Whatsapp) && @inbox.channel.provider == 'whatsapp_cloud' - - render json: { error: 'Health data only available for WhatsApp Cloud API channels' }, status: :bad_request - end - def create_channel return unless allowed_channel_types.include?(permitted_params[:channel][:type]) @@ -200,18 +178,6 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController def get_channel_attributes(channel_type) channel_type.constantize.const_defined?(:EDITABLE_ATTRS) ? channel_type.constantize::EDITABLE_ATTRS.presence : [] end - - def whatsapp_channel? - @inbox.whatsapp? || (@inbox.twilio? && @inbox.channel.whatsapp?) - end - - def trigger_template_sync - if @inbox.whatsapp? - Channels::Whatsapp::TemplatesSyncJob.perform_later(@inbox.channel) - elsif @inbox.twilio? && @inbox.channel.whatsapp? - Channels::Twilio::TemplatesSyncJob.perform_later(@inbox.channel) - end - end end Api::V1::Accounts::InboxesController.prepend_mod_with('Api::V1::Accounts::InboxesController') diff --git a/app/controllers/api/v1/accounts/integrations/linear_controller.rb b/app/controllers/api/v1/accounts/integrations/linear_controller.rb index eb6525bb1..9ca0c72fd 100644 --- a/app/controllers/api/v1/accounts/integrations/linear_controller.rb +++ b/app/controllers/api/v1/accounts/integrations/linear_controller.rb @@ -126,7 +126,7 @@ class Api::V1::Accounts::Integrations::LinearController < Api::V1::Accounts::Bas return unless @hook&.access_token begin - linear_client = Linear.new(@hook.access_token) + linear_client = Linear.new(@hook.access_token, refresh_token: @hook.settings&.[]('refresh_token')) linear_client.revoke_token rescue StandardError => e Rails.logger.error "Failed to revoke Linear token: #{e.message}" diff --git a/app/controllers/api/v1/accounts/portals_controller.rb b/app/controllers/api/v1/accounts/portals_controller.rb index 8eb24b757..972b244fa 100644 --- a/app/controllers/api/v1/accounts/portals_controller.rb +++ b/app/controllers/api/v1/accounts/portals_controller.rb @@ -79,7 +79,7 @@ class Api::V1::Accounts::PortalsController < Api::V1::Accounts::BaseController def portal_params params.require(:portal).permit( :id, :color, :custom_domain, :header_text, :homepage_link, - :name, :page_title, :slug, :archived, { config: [:default_locale, { allowed_locales: [] }] } + :name, :page_title, :slug, :archived, { config: [:default_locale, { allowed_locales: [] }, { draft_locales: [] }] } ) end diff --git a/app/controllers/api/v1/widget/contacts_controller.rb b/app/controllers/api/v1/widget/contacts_controller.rb index 5138fe675..6c595ab59 100644 --- a/app/controllers/api/v1/widget/contacts_controller.rb +++ b/app/controllers/api/v1/widget/contacts_controller.rb @@ -19,7 +19,7 @@ class Api::V1::Widget::ContactsController < Api::V1::Widget::BaseController contact = @contact end - @contact_inbox.update(hmac_verified: true) if should_verify_hmac? && valid_hmac? + @contact_inbox.update(hmac_verified: true) if should_verify_hmac? identify_contact(contact) end diff --git a/app/controllers/linear/callbacks_controller.rb b/app/controllers/linear/callbacks_controller.rb index 2eea49333..cdf6f630e 100644 --- a/app/controllers/linear/callbacks_controller.rb +++ b/app/controllers/linear/callbacks_controller.rb @@ -2,6 +2,8 @@ class Linear::CallbacksController < ApplicationController include Linear::IntegrationHelper def show + return redirect_to(safe_linear_redirect_uri) if params[:code].blank? || account_id.blank? + @response = oauth_client.auth_code.get_token( params[:code], redirect_uri: "#{base_url}/linear/callback" @@ -10,7 +12,7 @@ class Linear::CallbacksController < ApplicationController handle_response rescue StandardError => e Rails.logger.error("Linear callback error: #{e.message}") - redirect_to linear_redirect_uri + redirect_to safe_linear_redirect_uri end private @@ -31,22 +33,19 @@ class Linear::CallbacksController < ApplicationController end def handle_response - hook = account.hooks.new( + raise ArgumentError, 'Missing access token in Linear OAuth response' if parsed_body['access_token'].blank? + + hook = account.hooks.find_or_initialize_by(app_id: 'linear') + hook.assign_attributes( access_token: parsed_body['access_token'], status: 'enabled', - app_id: 'linear', - settings: { - token_type: parsed_body['token_type'], - expires_in: parsed_body['expires_in'], - scope: parsed_body['scope'] - } + settings: merged_integration_settings(hook.settings) ) - # You may wonder why we're not handling the refresh token update, since the token will expire only after 10 years, https://github.com/linear/linear/issues/251 hook.save! redirect_to linear_redirect_uri rescue StandardError => e Rails.logger.error("Linear callback error: #{e.message}") - redirect_to linear_redirect_uri + redirect_to safe_linear_redirect_uri end def account @@ -54,19 +53,47 @@ class Linear::CallbacksController < ApplicationController end def account_id - return unless params[:state] + return @account_id if instance_variable_defined?(:@account_id) - verify_linear_token(params[:state]) + @account_id = params[:state].present? ? verify_linear_token(params[:state]) : nil end def linear_redirect_uri "#{ENV.fetch('FRONTEND_URL', nil)}/app/accounts/#{account.id}/settings/integrations/linear" end + def safe_linear_redirect_uri + return base_url if account_id.blank? + + linear_redirect_uri + rescue StandardError + base_url + end + def parsed_body @parsed_body ||= @response.response.parsed end + def integration_settings + { + token_type: parsed_body['token_type'], + expires_in: parsed_body['expires_in'], + expires_on: expires_on, + scope: parsed_body['scope'], + refresh_token: parsed_body['refresh_token'] + }.compact + end + + def merged_integration_settings(existing_settings) + existing_settings.to_h.with_indifferent_access.merge(integration_settings) + end + + def expires_on + return if parsed_body['expires_in'].blank? + + (Time.current.utc + parsed_body['expires_in'].to_i.seconds).to_s + end + def base_url ENV.fetch('FRONTEND_URL', 'http://localhost:3000') end diff --git a/app/controllers/public/api/v1/portals/articles_controller.rb b/app/controllers/public/api/v1/portals/articles_controller.rb index e6cc2aa69..664a1964f 100644 --- a/app/controllers/public/api/v1/portals/articles_controller.rb +++ b/app/controllers/public/api/v1/portals/articles_controller.rb @@ -6,6 +6,7 @@ class Public::Api::V1::Portals::ArticlesController < Public::Api::V1::Portals::B layout 'portal' def index + @search_query = list_params[:query] @articles = @portal.articles.published.includes(:category, :author) @articles = @articles.where(locale: permitted_params[:locale]) if permitted_params[:locale].present? @@ -73,7 +74,9 @@ class Public::Api::V1::Portals::ArticlesController < Public::Api::V1::Portals::B end def list_params - params.permit(:query, :locale, :sort, :status, :page, :per_page) + @list_params ||= params.permit(:query, :locale, :sort, :status, :page, :per_page).tap do |permitted| + permitted[:query] = permitted[:query].to_s.strip.presence + end end def permitted_params diff --git a/app/controllers/widgets_controller.rb b/app/controllers/widgets_controller.rb index 7f45ce636..913319303 100644 --- a/app/controllers/widgets_controller.rb +++ b/app/controllers/widgets_controller.rb @@ -77,13 +77,23 @@ class WidgetsController < ActionController::Base end def allow_iframe_requests - if @web_widget.allowed_domains.blank? + if @web_widget.allowed_domains.blank? || embedded_from_non_web_origin? response.headers.delete('X-Frame-Options') else domains = @web_widget.allowed_domains.split(',').map(&:strip).join(' ') response.headers['Content-Security-Policy'] = "frame-ancestors #{domains}" end end + + # Mobile WebViews (iOS/Android) load content from file:// or null origins, + # which cannot match any domain in frame-ancestors. When the per-inbox flag + # is enabled, skip frame-ancestors for these requests. + def embedded_from_non_web_origin? + return false unless @web_widget.allow_mobile_webview? + + origin = request.headers['Origin'] + origin.blank? || origin == 'null' || origin&.start_with?('file://') + end end WidgetsController.prepend_mod_with('WidgetsController') diff --git a/app/javascript/dashboard/api/inboxHealth.js b/app/javascript/dashboard/api/inboxHealth.js index 181b041ba..b8f69fcfe 100644 --- a/app/javascript/dashboard/api/inboxHealth.js +++ b/app/javascript/dashboard/api/inboxHealth.js @@ -9,6 +9,10 @@ class InboxHealthAPI extends ApiClient { getHealthStatus(inboxId) { return axios.get(`${this.url}/${inboxId}/health`); } + + registerWebhook(inboxId) { + return axios.post(`${this.url}/${inboxId}/register_webhook`); + } } export default new InboxHealthAPI(); diff --git a/app/javascript/dashboard/components-next/HelpCenter/EmptyState/Portal/PortalEmptyState.vue b/app/javascript/dashboard/components-next/HelpCenter/EmptyState/Portal/PortalEmptyState.vue index b7a986254..192e1d5ee 100644 --- a/app/javascript/dashboard/components-next/HelpCenter/EmptyState/Portal/PortalEmptyState.vue +++ b/app/javascript/dashboard/components-next/HelpCenter/EmptyState/Portal/PortalEmptyState.vue @@ -26,6 +26,7 @@ const onPortalCreate = ({ slug: portalSlug, locale }) => {