diff --git a/.github/workflows/run_mfa_spec.yml b/.github/workflows/run_mfa_spec.yml index 61b406f8a..69d019cc9 100644 --- a/.github/workflows/run_mfa_spec.yml +++ b/.github/workflows/run_mfa_spec.yml @@ -70,6 +70,7 @@ jobs: spec/services/mfa/authentication_service_spec.rb \ spec/requests/api/v1/profile/mfa_controller_spec.rb \ spec/controllers/devise_overrides/sessions_controller_spec.rb \ + spec/models/application_record_external_credentials_encryption_spec.rb \ --profile=10 \ --format documentation env: diff --git a/Gemfile.lock b/Gemfile.lock index 105bf8c13..80a78c6b6 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -644,7 +644,7 @@ GEM activesupport (>= 3.0.0) raabro (1.4.0) racc (1.8.1) - rack (3.2.0) + rack (3.2.3) rack-attack (6.7.0) rack (>= 1.0, < 4) rack-contrib (2.5.0) @@ -935,7 +935,7 @@ GEM unicode-emoji (~> 4.0, >= 4.0.4) unicode-emoji (4.0.4) uniform_notifier (1.17.0) - uri (1.0.3) + uri (1.0.4) uri_template (0.7.0) valid_email2 (5.2.6) activemodel (>= 3.2) diff --git a/app/builders/messages/message_builder.rb b/app/builders/messages/message_builder.rb index e1087b19f..12a74ed9c 100644 --- a/app/builders/messages/message_builder.rb +++ b/app/builders/messages/message_builder.rb @@ -7,6 +7,7 @@ class Messages::MessageBuilder @private = params[:private] || false @conversation = conversation @user = user + @account = conversation.account @message_type = params[:message_type] || 'outgoing' @attachments = params[:attachments] @automation_rule = content_attributes&.dig(:automation_rule_id) @@ -20,6 +21,9 @@ class Messages::MessageBuilder @message = @conversation.messages.build(message_params) process_attachments process_emails + # When the message has no quoted content, it will just be rendered as a regular message + # The frontend is equipped to handle this case + process_email_content if @account.feature_enabled?(:quoted_email_reply) @message.save! @message end @@ -92,6 +96,14 @@ class Messages::MessageBuilder @message.content_attributes[:to_emails] = to_emails end + def process_email_content + return unless should_process_email_content? + + @message.content_attributes ||= {} + email_attributes = build_email_attributes + @message.content_attributes[:email] = email_attributes + end + def process_email_string(email_string) return [] if email_string.blank? @@ -153,4 +165,71 @@ class Messages::MessageBuilder source_id: @params[:source_id] }.merge(external_created_at).merge(automation_rule_id).merge(campaign_id).merge(template_params) end + + def email_inbox? + @conversation.inbox&.inbox_type == 'Email' + end + + def should_process_email_content? + email_inbox? && !@private && @message.content.present? + end + + def build_email_attributes + email_attributes = ensure_indifferent_access(@message.content_attributes[:email] || {}) + normalized_content = normalize_email_body(@message.content) + + # Use custom HTML content if provided, otherwise generate from message content + email_attributes[:html_content] = if custom_email_content_provided? + build_custom_html_content + else + build_html_content(normalized_content) + end + + email_attributes[:text_content] = build_text_content(normalized_content) + email_attributes + end + + def build_html_content(normalized_content) + html_content = ensure_indifferent_access(@message.content_attributes.dig(:email, :html_content) || {}) + rendered_html = render_email_html(normalized_content) + html_content[:full] = rendered_html + html_content[:reply] = rendered_html + html_content + end + + def build_text_content(normalized_content) + text_content = ensure_indifferent_access(@message.content_attributes.dig(:email, :text_content) || {}) + text_content[:full] = normalized_content + text_content[:reply] = normalized_content + text_content + end + + def ensure_indifferent_access(hash) + return {} if hash.blank? + + hash.respond_to?(:with_indifferent_access) ? hash.with_indifferent_access : hash + end + + def normalize_email_body(content) + content.to_s.gsub("\r\n", "\n") + end + + def render_email_html(content) + return '' if content.blank? + + ChatwootMarkdownRenderer.new(content).render_message.to_s + end + + def custom_email_content_provided? + @params[:email_html_content].present? + end + + def build_custom_html_content + html_content = ensure_indifferent_access(@message.content_attributes.dig(:email, :html_content) || {}) + + html_content[:full] = @params[:email_html_content] + html_content[:reply] = @params[:email_html_content] + + html_content + end end diff --git a/app/controllers/api/v1/accounts/inboxes_controller.rb b/app/controllers/api/v1/accounts/inboxes_controller.rb index 4750e3b4a..ae1d4369a 100644 --- a/app/controllers/api/v1/accounts/inboxes_controller.rb +++ b/app/controllers/api/v1/accounts/inboxes_controller.rb @@ -4,7 +4,8 @@ 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] + before_action :check_authorization, except: [:show, :health] + before_action :validate_whatsapp_cloud_channel, only: [:health] def index @inboxes = policy_scope(Current.account.inboxes.order_by_name.includes(:channel, { avatar_attachment: [:blob] })) @@ -78,6 +79,14 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController 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 @@ -89,6 +98,12 @@ 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]) diff --git a/app/controllers/devise_overrides/omniauth_callbacks_controller.rb b/app/controllers/devise_overrides/omniauth_callbacks_controller.rb index fd3dba87c..900125670 100644 --- a/app/controllers/devise_overrides/omniauth_callbacks_controller.rb +++ b/app/controllers/devise_overrides/omniauth_callbacks_controller.rb @@ -19,6 +19,19 @@ class DeviseOverrides::OmniauthCallbacksController < DeviseTokenAuth::OmniauthCa redirect_to login_page_url(email: encoded_email, sso_auth_token: @resource.generate_sso_auth_token) end + def sign_in_user_on_mobile + @resource.skip_confirmation! if confirmable_enabled? + + # once the resource is found and verified + # we can just send them to the login page again with the SSO params + # that will log them in + encoded_email = ERB::Util.url_encode(@resource.email) + params = { email: encoded_email, sso_auth_token: @resource.generate_sso_auth_token }.to_query + + mobile_deep_link_base = GlobalConfigService.load('MOBILE_DEEP_LINK_BASE', 'chatwootapp') + redirect_to "#{mobile_deep_link_base}://auth/saml?#{params}", allow_other_host: true + end + def sign_up_user return redirect_to login_page_url(error: 'no-account-found') unless account_signup_allowed? return redirect_to login_page_url(error: 'business-account-only') unless validate_signup_email_is_business_domain? diff --git a/app/javascript/dashboard/api/captain/customTools.js b/app/javascript/dashboard/api/captain/customTools.js new file mode 100644 index 000000000..d0818d941 --- /dev/null +++ b/app/javascript/dashboard/api/captain/customTools.js @@ -0,0 +1,36 @@ +/* global axios */ +import ApiClient from '../ApiClient'; + +class CaptainCustomTools extends ApiClient { + constructor() { + super('captain/custom_tools', { accountScoped: true }); + } + + get({ page = 1, searchKey } = {}) { + return axios.get(this.url, { + params: { page, searchKey }, + }); + } + + show(id) { + return axios.get(`${this.url}/${id}`); + } + + create(data = {}) { + return axios.post(this.url, { + custom_tool: data, + }); + } + + update(id, data = {}) { + return axios.put(`${this.url}/${id}`, { + custom_tool: data, + }); + } + + delete(id) { + return axios.delete(`${this.url}/${id}`); + } +} + +export default new CaptainCustomTools(); diff --git a/app/javascript/dashboard/api/inboxHealth.js b/app/javascript/dashboard/api/inboxHealth.js new file mode 100644 index 000000000..181b041ba --- /dev/null +++ b/app/javascript/dashboard/api/inboxHealth.js @@ -0,0 +1,14 @@ +/* global axios */ +import ApiClient from './ApiClient'; + +class InboxHealthAPI extends ApiClient { + constructor() { + super('inboxes', { accountScoped: true }); + } + + getHealthStatus(inboxId) { + return axios.get(`${this.url}/${inboxId}/health`); + } +} + +export default new InboxHealthAPI(); diff --git a/app/javascript/dashboard/components-next/captain/pageComponents/DeleteDialog.vue b/app/javascript/dashboard/components-next/captain/pageComponents/DeleteDialog.vue index 31e18394f..8d67344e1 100644 --- a/app/javascript/dashboard/components-next/captain/pageComponents/DeleteDialog.vue +++ b/app/javascript/dashboard/components-next/captain/pageComponents/DeleteDialog.vue @@ -10,6 +10,10 @@ const props = defineProps({ type: String, required: true, }, + translationKey: { + type: String, + required: true, + }, entity: { type: Object, required: true, @@ -25,7 +29,9 @@ const emit = defineEmits(['deleteSuccess']); const { t } = useI18n(); const store = useStore(); const deleteDialogRef = ref(null); -const i18nKey = computed(() => props.type.toUpperCase()); +const i18nKey = computed(() => { + return props.translationKey || props.type.toUpperCase(); +}); const deleteEntity = async payload => { if (!payload) return; diff --git a/app/javascript/dashboard/components-next/captain/pageComponents/customTool/AuthConfig.vue b/app/javascript/dashboard/components-next/captain/pageComponents/customTool/AuthConfig.vue new file mode 100644 index 000000000..208a94dba --- /dev/null +++ b/app/javascript/dashboard/components-next/captain/pageComponents/customTool/AuthConfig.vue @@ -0,0 +1,73 @@ + + + diff --git a/app/javascript/dashboard/components-next/captain/pageComponents/customTool/CreateCustomToolDialog.vue b/app/javascript/dashboard/components-next/captain/pageComponents/customTool/CreateCustomToolDialog.vue new file mode 100644 index 000000000..0745c6546 --- /dev/null +++ b/app/javascript/dashboard/components-next/captain/pageComponents/customTool/CreateCustomToolDialog.vue @@ -0,0 +1,87 @@ + + +