diff --git a/.env.example b/.env.example index 2ab2933dc..3ff349ffc 100644 --- a/.env.example +++ b/.env.example @@ -6,6 +6,13 @@ # Use `rake secret` to generate this variable SECRET_KEY_BASE=replace_with_lengthy_secure_hex +# Active Record Encryption keys (required for MFA/2FA functionality) +# Generate these keys by running: rails db:encryption:init +# IMPORTANT: Use different keys for each environment (development, staging, production) +# ACTIVE_RECORD_ENCRYPTION_PRIMARY_KEY= +# ACTIVE_RECORD_ENCRYPTION_DETERMINISTIC_KEY= +# ACTIVE_RECORD_ENCRYPTION_KEY_DERIVATION_SALT= + # Replace with the URL you are planning to use for your app FRONTEND_URL=http://0.0.0.0:3000 # To use a dedicated URL for help center pages @@ -249,6 +256,8 @@ AZURE_APP_SECRET= ## Change these values to fine tune performance # control the concurrency setting of sidekiq # SIDEKIQ_CONCURRENCY=10 +# Enable verbose logging each time a job is dequeued in Sidekiq +# ENABLE_SIDEKIQ_DEQUEUE_LOGGER=false # AI powered features diff --git a/.github/workflows/run_mfa_spec.yml b/.github/workflows/run_mfa_spec.yml new file mode 100644 index 000000000..69d019cc9 --- /dev/null +++ b/.github/workflows/run_mfa_spec.yml @@ -0,0 +1,100 @@ +name: Run MFA Tests +permissions: + contents: read + +on: + pull_request: + +# If two pushes happen within a short time in the same PR, cancel the run of the oldest push +concurrency: + group: pr-${{ github.workflow }}-${{ github.head_ref }} + cancel-in-progress: true + +jobs: + test: + runs-on: ubuntu-22.04 + # Only run if MFA test keys are available + if: github.event_name == 'workflow_dispatch' || (github.repository == 'chatwoot/chatwoot' && github.actor != 'dependabot[bot]') + + services: + postgres: + image: pgvector/pgvector:pg15 + env: + POSTGRES_USER: postgres + POSTGRES_PASSWORD: '' + POSTGRES_DB: postgres + POSTGRES_HOST_AUTH_METHOD: trust + ports: + - 5432:5432 + options: >- + --mount type=tmpfs,destination=/var/lib/postgresql/data + --health-cmd pg_isready + --health-interval 10s + --health-timeout 5s + --health-retries 5 + redis: + image: redis + ports: + - 6379:6379 + options: --entrypoint redis-server + + env: + RAILS_ENV: test + POSTGRES_HOST: localhost + # Active Record encryption keys required for MFA - test keys only, not for production use + ACTIVE_RECORD_ENCRYPTION_PRIMARY_KEY: 'test_key_a6cde8f7b9c2d3e4f5a6b7c8d9e0f1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7' + ACTIVE_RECORD_ENCRYPTION_DETERMINISTIC_KEY: 'test_key_b7def9a8c0d3e4f5a6b7c8d9e0f1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d8' + ACTIVE_RECORD_ENCRYPTION_KEY_DERIVATION_SALT: 'test_salt_c8efa0b9d1e4f5a6b7c8d9e0f1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d9' + + steps: + - uses: actions/checkout@v4 + + - uses: ruby/setup-ruby@v1 + with: + bundler-cache: true + + - name: Create database + run: bundle exec rake db:create + + - name: Install pgvector extension + run: | + PGPASSWORD="" psql -h localhost -U postgres -d chatwoot_test -c "CREATE EXTENSION IF NOT EXISTS vector;" + + - name: Seed database + run: bundle exec rake db:schema:load + + - name: Run MFA-related backend tests + run: | + bundle exec rspec \ + spec/services/mfa/token_service_spec.rb \ + 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: + NODE_OPTIONS: --openssl-legacy-provider + + - name: Run MFA-related tests in user_spec + run: | + # Run specific MFA-related tests from user_spec + bundle exec rspec spec/models/user_spec.rb \ + -e "two factor" \ + -e "2FA" \ + -e "MFA" \ + -e "otp" \ + -e "backup code" \ + --profile=10 \ + --format documentation + env: + NODE_OPTIONS: --openssl-legacy-provider + + - name: Upload test logs + uses: actions/upload-artifact@v4 + if: failure() + with: + name: mfa-test-logs + path: | + log/test.log + tmp/screenshots/ diff --git a/.gitignore b/.gitignore index bb0df62a8..7ca033f87 100644 --- a/.gitignore +++ b/.gitignore @@ -95,3 +95,7 @@ yarn-debug.log* .claude/settings.local.json .cursor CLAUDE.local.md + +# Histoire deployment +.netlify +.histoire diff --git a/.rubocop.yml b/.rubocop.yml index e30a71ee9..ea688792b 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -23,7 +23,7 @@ Metrics/MethodLength: - 'enterprise/lib/captain/agent.rb' RSpec/ExampleLength: - Max: 25 + Max: 50 Style/Documentation: Enabled: false @@ -336,4 +336,4 @@ FactoryBot/RedundantFactoryOption: Enabled: false FactoryBot/FactoryAssociationWithStrategy: - Enabled: false \ No newline at end of file + Enabled: false diff --git a/Gemfile b/Gemfile index 03d0b62b9..abbd3332f 100644 --- a/Gemfile +++ b/Gemfile @@ -21,6 +21,7 @@ gem 'telephone_number' gem 'time_diff' gem 'tzinfo-data' gem 'valid_email2' +gem 'email-provider-info' # compress javascript config.assets.js_compressor gem 'uglifier' ##-- used for single column multiple binary flags in notification settings/feature flagging --## @@ -78,9 +79,12 @@ gem 'barnes' gem 'devise', '>= 4.9.4' gem 'devise-secure_password', git: 'https://github.com/chatwoot/devise-secure_password', branch: 'chatwoot' gem 'devise_token_auth', '>= 1.2.3' +# two-factor authentication +gem 'devise-two-factor', '>= 5.0.0' # authorization gem 'jwt' gem 'pundit' + # super admin gem 'administrate', '>= 0.20.1' gem 'administrate-field-active_storage', '>= 1.0.3' @@ -100,7 +104,7 @@ gem 'twitty', '~> 0.1.5' # facebook client gem 'koala' # slack client -gem 'slack-ruby-client', '~> 2.5.2' +gem 'slack-ruby-client', '~> 2.7.0' # for dialogflow integrations gem 'google-cloud-dialogflow-v2', '>= 0.24.0' gem 'grpc' @@ -171,6 +175,7 @@ gem 'audited', '~> 5.4', '>= 5.4.1' # need for google auth gem 'omniauth', '>= 2.1.2' +gem 'omniauth-saml' gem 'omniauth-google-oauth2', '>= 1.1.3' gem 'omniauth-rails_csrf_protection', '~> 1.0', '>= 1.0.2' diff --git a/Gemfile.lock b/Gemfile.lock index e8bccf54e..99e75b33c 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -212,6 +212,11 @@ GEM railties (>= 4.1.0) responders warden (~> 1.2.3) + devise-two-factor (6.1.0) + activesupport (>= 7.0, < 8.1) + devise (~> 4.0) + railties (>= 7.0, < 8.1) + rotp (~> 6.0) devise_token_auth (1.2.5) bcrypt (~> 3.0) devise (> 3.5.2, < 5) @@ -265,6 +270,7 @@ GEM concurrent-ruby (~> 1.0) http (>= 3.0) ruby2_keywords + email-provider-info (0.0.1) email_reply_trimmer (0.1.13) erubi (1.13.0) et-orbi (1.2.11) @@ -287,7 +293,7 @@ GEM logger faraday-follow_redirects (0.3.0) faraday (>= 1, < 3) - faraday-mashify (0.1.1) + faraday-mashify (1.0.0) faraday (~> 2.0) hashie faraday-multipart (1.0.4) @@ -589,8 +595,9 @@ GEM oj (3.16.10) bigdecimal (>= 3.0) ostruct (>= 0.2) - omniauth (2.1.2) + omniauth (2.1.4) hashie (>= 3.4.6) + logger rack (>= 2.2.3) rack-protection omniauth-google-oauth2 (1.1.3) @@ -604,6 +611,9 @@ GEM omniauth-rails_csrf_protection (1.0.2) actionpack (>= 4.2) omniauth (~> 2.0) + omniauth-saml (2.2.4) + omniauth (~> 2.1) + ruby-saml (~> 1.18) opensearch-ruby (3.4.0) faraday (>= 1.0, < 3) multi_json (>= 1.0) @@ -635,7 +645,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) @@ -644,7 +654,7 @@ GEM rack (>= 2.0.0) rack-mini-profiler (3.2.0) rack (>= 1.2.0) - rack-protection (4.1.1) + rack-protection (4.2.1) base64 (>= 0.1.0) logger (>= 1.6.0) rack (>= 3.0.0, < 4) @@ -718,7 +728,8 @@ GEM retriable (3.1.2) reverse_markdown (2.1.1) nokogiri - rexml (3.4.1) + rexml (3.4.4) + rotp (6.3.0) rspec-core (3.13.0) rspec-support (~> 3.13.0) rspec-expectations (3.13.2) @@ -773,6 +784,9 @@ GEM faraday (>= 1) faraday-multipart (>= 1) ruby-progressbar (1.13.0) + ruby-saml (1.18.1) + nokogiri (>= 1.13.10) + rexml ruby-vips (2.1.4) ffi (~> 1.12) ruby2_keywords (0.0.5) @@ -863,8 +877,8 @@ GEM simplecov_json_formatter (~> 0.1) simplecov-html (0.13.2) simplecov_json_formatter (0.1.4) - slack-ruby-client (2.5.2) - faraday (>= 2.0) + slack-ruby-client (2.7.0) + faraday (>= 2.0.1) faraday-mashify faraday-multipart gli @@ -922,7 +936,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) @@ -998,10 +1012,12 @@ DEPENDENCIES debug (~> 1.8) devise (>= 4.9.4) devise-secure_password! + devise-two-factor (>= 5.0.0) devise_token_auth (>= 1.2.3) dotenv-rails (>= 3.0.0) down elastic-apm + email-provider-info email_reply_trimmer facebook-messenger factory_bot_rails (>= 6.4.3) @@ -1047,6 +1063,7 @@ DEPENDENCIES omniauth-google-oauth2 (>= 1.1.3) omniauth-oauth2 omniauth-rails_csrf_protection (~> 1.0, >= 1.0.2) + omniauth-saml opensearch-ruby pg pg_search @@ -1088,7 +1105,7 @@ DEPENDENCIES sidekiq_alive simplecov (>= 0.21) simplecov_json_formatter - slack-ruby-client (~> 2.5.2) + slack-ruby-client (~> 2.7.0) spring spring-watcher-listen squasher diff --git a/Rakefile b/Rakefile index 591d2c4c6..2e996417e 100644 --- a/Rakefile +++ b/Rakefile @@ -2,9 +2,8 @@ # for example lib/tasks/capistrano.rake, and they will automatically be available to Rake. require_relative 'config/application' +# Load Enterprise Edition rake tasks if they exist +enterprise_tasks_path = Rails.root.join('enterprise/tasks_railtie.rb').to_s +require enterprise_tasks_path if File.exist?(enterprise_tasks_path) Rails.application.load_tasks - -# Load Enterprise Edition rake tasks if they exist -enterprise_tasks_path = Rails.root.join('enterprise/lib/tasks.rb').to_s -require enterprise_tasks_path if File.exist?(enterprise_tasks_path) diff --git a/app.json b/app.json index 08e725c8e..91fb0fbd5 100644 --- a/app.json +++ b/app.json @@ -36,6 +36,10 @@ "REDIS_OPENSSL_VERIFY_MODE":{ "description": "OpenSSL verification mode for Redis connections. ref https://help.heroku.com/HC0F8CUS/redis-connection-issues", "value": "none" + }, + "NODE_OPTIONS": { + "description": "Increase V8 heap for Vite build to avoid OOM", + "value": "--max-old-space-size=4096" } }, "formation": { diff --git a/app/builders/agent_builder.rb b/app/builders/agent_builder.rb index 54f478920..2fe11cae0 100644 --- a/app/builders/agent_builder.rb +++ b/app/builders/agent_builder.rb @@ -52,3 +52,5 @@ class AgentBuilder }.compact)) end end + +AgentBuilder.prepend_mod_with('AgentBuilder') diff --git a/app/builders/email/base_builder.rb b/app/builders/email/base_builder.rb new file mode 100644 index 000000000..731b1b0f5 --- /dev/null +++ b/app/builders/email/base_builder.rb @@ -0,0 +1,54 @@ +class Email::BaseBuilder + pattr_initialize [:inbox!] + + private + + def channel + @channel ||= inbox.channel + end + + def account + @account ||= inbox.account + end + + def conversation + @conversation ||= message.conversation + end + + def custom_sender_name + message&.sender&.available_name || I18n.t('conversations.reply.email.header.notifications') + end + + def sender_name(sender_email) + # Friendly: from + # Professional: + if inbox.friendly? + I18n.t( + 'conversations.reply.email.header.friendly_name', + sender_name: custom_sender_name, + business_name: business_name, + from_email: sender_email + ) + else + I18n.t( + 'conversations.reply.email.header.professional_name', + business_name: business_name, + from_email: sender_email + ) + end + end + + def business_name + inbox.business_name || inbox.sanitized_name + end + + def account_support_email + # Parse the email to ensure it's in the correct format, the user + # can save it in the format "Name " + parse_email(account.support_email) + end + + def parse_email(email_string) + Mail::Address.new(email_string).address + end +end diff --git a/app/builders/email/from_builder.rb b/app/builders/email/from_builder.rb new file mode 100644 index 000000000..fff33dc0a --- /dev/null +++ b/app/builders/email/from_builder.rb @@ -0,0 +1,51 @@ +class Email::FromBuilder < Email::BaseBuilder + pattr_initialize [:inbox!, :message!] + + def build + return sender_name(account_support_email) unless inbox.email? + + from_email = case email_channel_type + when :standard_imap_smtp, + :google_oauth, + :microsoft_oauth, + :forwarding_own_smtp + channel.email + when :imap_chatwoot_smtp, + :forwarding_chatwoot_smtp + channel.verified_for_sending ? channel.email : account_support_email + else + account_support_email + end + + sender_name(from_email) + end + + private + + def email_channel_type + return :google_oauth if channel.google? + return :microsoft_oauth if channel.microsoft? + return :standard_imap_smtp if imap_and_smtp_enabled? + return :imap_chatwoot_smtp if imap_enabled_without_smtp? + return :forwarding_own_smtp if forwarding_with_own_smtp? + return :forwarding_chatwoot_smtp if forwarding_without_smtp? + + :unknown + end + + def imap_and_smtp_enabled? + channel.imap_enabled && channel.smtp_enabled + end + + def imap_enabled_without_smtp? + channel.imap_enabled && !channel.smtp_enabled + end + + def forwarding_with_own_smtp? + !channel.imap_enabled && channel.smtp_enabled + end + + def forwarding_without_smtp? + !channel.imap_enabled && !channel.smtp_enabled + end +end diff --git a/app/builders/email/reply_to_builder.rb b/app/builders/email/reply_to_builder.rb new file mode 100644 index 000000000..d330c922a --- /dev/null +++ b/app/builders/email/reply_to_builder.rb @@ -0,0 +1,21 @@ +class Email::ReplyToBuilder < Email::BaseBuilder + pattr_initialize [:inbox!, :message!] + + def build + reply_to = if inbox.email? + channel.email + elsif inbound_email_enabled? + "reply+#{conversation.uuid}@#{account.inbound_email_domain}" + else + account_support_email + end + + sender_name(reply_to) + end + + private + + def inbound_email_enabled? + account.feature_enabled?('inbound_emails') && account.inbound_email_domain.present? + end +end diff --git a/app/builders/messages/message_builder.rb b/app/builders/messages/message_builder.rb index e1087b19f..857d901e5 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 @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/builders/v2/reports/base_summary_builder.rb b/app/builders/v2/reports/base_summary_builder.rb index 4de65926d..d4a9e7c0b 100644 --- a/app/builders/v2/reports/base_summary_builder.rb +++ b/app/builders/v2/reports/base_summary_builder.rb @@ -10,10 +10,28 @@ class V2::Reports::BaseSummaryBuilder def load_data @conversations_count = fetch_conversations_count - @resolved_count = fetch_resolved_count - @avg_resolution_time = fetch_average_time('conversation_resolved') - @avg_first_response_time = fetch_average_time('first_response') - @avg_reply_time = fetch_average_time('reply_time') + load_reporting_events_data + end + + def load_reporting_events_data + # Extract the column name for indexing (e.g., 'conversations.team_id' -> 'team_id') + index_key = group_by_key.to_s.split('.').last + + results = reporting_events + .select( + "#{group_by_key} as #{index_key}", + "COUNT(CASE WHEN name = 'conversation_resolved' THEN 1 END) as resolved_count", + "AVG(CASE WHEN name = 'conversation_resolved' THEN #{average_value_key} END) as avg_resolution_time", + "AVG(CASE WHEN name = 'first_response' THEN #{average_value_key} END) as avg_first_response_time", + "AVG(CASE WHEN name = 'reply_time' THEN #{average_value_key} END) as avg_reply_time" + ) + .group(group_by_key) + .index_by { |record| record.public_send(index_key) } + + @resolved_count = results.transform_values(&:resolved_count) + @avg_resolution_time = results.transform_values(&:avg_resolution_time) + @avg_first_response_time = results.transform_values(&:avg_first_response_time) + @avg_reply_time = results.transform_values(&:avg_reply_time) end def reporting_events @@ -24,14 +42,6 @@ class V2::Reports::BaseSummaryBuilder # Override this method end - def fetch_average_time(event_name) - get_grouped_average(reporting_events.where(name: event_name)) - end - - def fetch_resolved_count - reporting_events.where(name: 'conversation_resolved').group(group_by_key).count - end - def group_by_key # Override this method end @@ -40,10 +50,6 @@ class V2::Reports::BaseSummaryBuilder # Override this method end - def get_grouped_average(events) - events.group(group_by_key).average(average_value_key) - end - def average_value_key ActiveModel::Type::Boolean.new.cast(params[:business_hours]).present? ? :value_in_business_hours : :value end diff --git a/app/builders/v2/reports/inbox_summary_builder.rb b/app/builders/v2/reports/inbox_summary_builder.rb index e27385856..935afeb82 100644 --- a/app/builders/v2/reports/inbox_summary_builder.rb +++ b/app/builders/v2/reports/inbox_summary_builder.rb @@ -13,10 +13,7 @@ class V2::Reports::InboxSummaryBuilder < V2::Reports::BaseSummaryBuilder def load_data @conversations_count = fetch_conversations_count - @resolved_count = fetch_resolved_count - @avg_resolution_time = fetch_average_time('conversation_resolved') - @avg_first_response_time = fetch_average_time('first_response') - @avg_reply_time = fetch_average_time('reply_time') + load_reporting_events_data end def fetch_conversations_count diff --git a/app/builders/v2/reports/label_summary_builder.rb b/app/builders/v2/reports/label_summary_builder.rb index caa5a04d8..8b7c21e8e 100644 --- a/app/builders/v2/reports/label_summary_builder.rb +++ b/app/builders/v2/reports/label_summary_builder.rb @@ -28,7 +28,7 @@ class V2::Reports::LabelSummaryBuilder < V2::Reports::BaseSummaryBuilder { conversation_counts: fetch_conversation_counts(conversation_filter), - resolved_counts: fetch_resolved_counts(conversation_filter), + resolved_counts: fetch_resolved_counts, resolution_metrics: fetch_metrics(conversation_filter, 'conversation_resolved', use_business_hours), first_response_metrics: fetch_metrics(conversation_filter, 'first_response', use_business_hours), reply_metrics: fetch_metrics(conversation_filter, 'reply_time', use_business_hours) @@ -62,10 +62,21 @@ class V2::Reports::LabelSummaryBuilder < V2::Reports::BaseSummaryBuilder fetch_counts(conversation_filter) end - def fetch_resolved_counts(conversation_filter) - # since the base query is ActsAsTaggableOn, - # the status :resolved won't automatically be converted to integer status - fetch_counts(conversation_filter.merge(status: Conversation.statuses[:resolved])) + def fetch_resolved_counts + # Count resolution events, not conversations currently in resolved status + # Filter by reporting_event.created_at, not conversation.created_at + reporting_event_filter = { name: 'conversation_resolved', account_id: account.id } + reporting_event_filter[:created_at] = range if range.present? + + ReportingEvent + .joins(conversation: { taggings: :tag }) + .where( + reporting_event_filter.merge( + taggings: { taggable_type: 'Conversation', context: 'labels' } + ) + ) + .group('tags.name') + .count end def fetch_counts(conversation_filter) @@ -84,9 +95,7 @@ class V2::Reports::LabelSummaryBuilder < V2::Reports::BaseSummaryBuilder def fetch_metrics(conversation_filter, event_name, use_business_hours) ReportingEvent - .joins('INNER JOIN conversations ON reporting_events.conversation_id = conversations.id') - .joins('INNER JOIN taggings ON taggings.taggable_id = conversations.id') - .joins('INNER JOIN tags ON taggings.tag_id = tags.id') + .joins(conversation: { taggings: :tag }) .where( conversations: conversation_filter, name: event_name, diff --git a/app/builders/v2/reports/timeseries/count_report_builder.rb b/app/builders/v2/reports/timeseries/count_report_builder.rb index 03a87a6fa..bb3b1250c 100644 --- a/app/builders/v2/reports/timeseries/count_report_builder.rb +++ b/app/builders/v2/reports/timeseries/count_report_builder.rb @@ -38,27 +38,34 @@ class V2::Reports::Timeseries::CountReportBuilder < V2::Reports::Timeseries::Bas end def scope_for_resolutions_count - scope.reporting_events.joins(:conversation).select(:conversation_id).where( + scope.reporting_events.where( name: :conversation_resolved, - conversations: { status: :resolved }, created_at: range - ).distinct + account_id: account.id, + created_at: range + ) end def scope_for_bot_resolutions_count - scope.reporting_events.joins(:conversation).select(:conversation_id).where( + scope.reporting_events.where( name: :conversation_bot_resolved, - conversations: { status: :resolved }, created_at: range - ).distinct + account_id: account.id, + created_at: range + ) end def scope_for_bot_handoffs_count scope.reporting_events.joins(:conversation).select(:conversation_id).where( name: :conversation_bot_handoff, + account_id: account.id, created_at: range ).distinct end def grouped_count + # IMPORTANT: time_zone parameter affects both data grouping AND output timestamps + # It converts timestamps to the target timezone before grouping, which means + # the same event can fall into different day buckets depending on timezone + # Example: 2024-01-15 00:00 UTC becomes 2024-01-14 16:00 PST (falls on different day) @grouped_values = object_scope.group_by_period( group_by, :created_at, diff --git a/app/controllers/api/v1/accounts/bulk_actions_controller.rb b/app/controllers/api/v1/accounts/bulk_actions_controller.rb index 34db47861..222c66714 100644 --- a/app/controllers/api/v1/accounts/bulk_actions_controller.rb +++ b/app/controllers/api/v1/accounts/bulk_actions_controller.rb @@ -1,13 +1,12 @@ class Api::V1::Accounts::BulkActionsController < Api::V1::Accounts::BaseController - before_action :type_matches? - def create - if type_matches? - ::BulkActionsJob.perform_later( - account: @current_account, - user: current_user, - params: permitted_params - ) + case normalized_type + when 'Conversation' + enqueue_conversation_job + head :ok + when 'Contact' + check_authorization_for_contact_action + enqueue_contact_job head :ok else render json: { success: false }, status: :unprocessable_entity @@ -16,11 +15,54 @@ class Api::V1::Accounts::BulkActionsController < Api::V1::Accounts::BaseControll private - def type_matches? - ['Conversation'].include?(params[:type]) + def normalized_type + params[:type].to_s.camelize end - def permitted_params - params.permit(:type, :snoozed_until, ids: [], fields: [:status, :assignee_id, :team_id], labels: [add: [], remove: []]) + def enqueue_conversation_job + ::BulkActionsJob.perform_later( + account: @current_account, + user: current_user, + params: conversation_params + ) + end + + def enqueue_contact_job + Contacts::BulkActionJob.perform_later( + @current_account.id, + current_user.id, + contact_params + ) + end + + def delete_contact_action? + params[:action_name] == 'delete' + end + + def check_authorization_for_contact_action + authorize(Contact, :destroy?) if delete_contact_action? + end + + def conversation_params + # TODO: Align conversation payloads with the `{ action_name, action_attributes }` + # and then remove this method in favor of a common params method. + base = params.permit( + :snoozed_until, + fields: [:status, :assignee_id, :team_id] + ) + append_common_bulk_attributes(base) + end + + def contact_params + # TODO: remove this method in favor of a common params method. + # once legacy conversation payloads are migrated. + append_common_bulk_attributes({}) + end + + def append_common_bulk_attributes(base_params) + # NOTE: Conversation payloads historically diverged per action. Going forward we + # want all objects to share a common contract: `{ action_name, action_attributes }` + common = params.permit(:type, :action_name, ids: [], labels: [add: [], remove: []]) + base_params.merge(common) end end diff --git a/app/controllers/api/v1/accounts/contacts_controller.rb b/app/controllers/api/v1/accounts/contacts_controller.rb index 039786905..e6270c807 100644 --- a/app/controllers/api/v1/accounts/contacts_controller.rb +++ b/app/controllers/api/v1/accounts/contacts_controller.rb @@ -17,8 +17,8 @@ class Api::V1::Accounts::ContactsController < Api::V1::Accounts::BaseController before_action :set_include_contact_inboxes, only: [:index, :active, :search, :filter, :show, :update] def index - @contacts_count = resolved_contacts.count @contacts = fetch_contacts(resolved_contacts) + @contacts_count = @contacts.total_count end def search @@ -29,8 +29,8 @@ class Api::V1::Accounts::ContactsController < Api::V1::Accounts::BaseController OR contacts.additional_attributes->>\'company_name\' ILIKE :search', search: "%#{params[:q].strip}%" ) - @contacts_count = contacts.count @contacts = fetch_contacts(contacts) + @contacts_count = @contacts.total_count end def import @@ -55,8 +55,8 @@ class Api::V1::Accounts::ContactsController < Api::V1::Accounts::BaseController def active contacts = Current.account.contacts.where(id: ::OnlineStatusTracker .get_available_contact_ids(Current.account.id)) - @contacts_count = contacts.count @contacts = fetch_contacts(contacts) + @contacts_count = @contacts.total_count end def show; end @@ -133,13 +133,14 @@ class Api::V1::Accounts::ContactsController < Api::V1::Accounts::BaseController end def fetch_contacts(contacts) - contacts_with_avatar = filtrate(contacts) - .includes([{ avatar_attachment: [:blob] }]) - .page(@current_page).per(RESULTS_PER_PAGE) + # Build includes hash to avoid separate query when contact_inboxes are needed + includes_hash = { avatar_attachment: [:blob] } + includes_hash[:contact_inboxes] = { inbox: :channel } if @include_contact_inboxes - return contacts_with_avatar.includes([{ contact_inboxes: [:inbox] }]) if @include_contact_inboxes - - contacts_with_avatar + filtrate(contacts) + .includes(includes_hash) + .page(@current_page) + .per(RESULTS_PER_PAGE) end def build_contact_inbox diff --git a/app/controllers/api/v1/accounts/conversations/base_controller.rb b/app/controllers/api/v1/accounts/conversations/base_controller.rb index 500c7772f..223530e27 100644 --- a/app/controllers/api/v1/accounts/conversations/base_controller.rb +++ b/app/controllers/api/v1/accounts/conversations/base_controller.rb @@ -5,6 +5,6 @@ class Api::V1::Accounts::Conversations::BaseController < Api::V1::Accounts::Base def conversation @conversation ||= Current.account.conversations.find_by!(display_id: params[:conversation_id]) - authorize @conversation.inbox, :show? + authorize @conversation, :show? end end diff --git a/app/controllers/api/v1/accounts/conversations_controller.rb b/app/controllers/api/v1/accounts/conversations_controller.rb index e27869d82..4301eaa4a 100644 --- a/app/controllers/api/v1/accounts/conversations_controller.rb +++ b/app/controllers/api/v1/accounts/conversations_controller.rb @@ -160,7 +160,7 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro def conversation @conversation ||= Current.account.conversations.find_by!(display_id: params[:id]) - authorize @conversation.inbox, :show? + authorize @conversation, :show? end def inbox 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/api/v1/accounts/integrations/dyte_controller.rb b/app/controllers/api/v1/accounts/integrations/dyte_controller.rb index c5f795d34..845caab5e 100644 --- a/app/controllers/api/v1/accounts/integrations/dyte_controller.rb +++ b/app/controllers/api/v1/accounts/integrations/dyte_controller.rb @@ -22,7 +22,7 @@ class Api::V1::Accounts::Integrations::DyteController < Api::V1::Accounts::BaseC private def authorize_request - authorize @conversation.inbox, :show? + authorize @conversation, :show? end def render_response(response) diff --git a/app/controllers/api/v1/accounts/portals_controller.rb b/app/controllers/api/v1/accounts/portals_controller.rb index af96441f8..57344cc1e 100644 --- a/app/controllers/api/v1/accounts/portals_controller.rb +++ b/app/controllers/api/v1/accounts/portals_controller.rb @@ -85,7 +85,8 @@ class Api::V1::Accounts::PortalsController < Api::V1::Accounts::BaseController def live_chat_widget_params permitted_params = params.permit(:inbox_id) - return {} if permitted_params[:inbox_id].blank? + return {} unless permitted_params.key?(:inbox_id) + return { channel_web_widget_id: nil } if permitted_params[:inbox_id].blank? inbox = Inbox.find(permitted_params[:inbox_id]) return {} unless inbox.web_widget? diff --git a/app/controllers/api/v1/accounts/whatsapp/authorizations_controller.rb b/app/controllers/api/v1/accounts/whatsapp/authorizations_controller.rb index 3e7d876c3..d52f396fc 100644 --- a/app/controllers/api/v1/accounts/whatsapp/authorizations_controller.rb +++ b/app/controllers/api/v1/accounts/whatsapp/authorizations_controller.rb @@ -1,5 +1,4 @@ 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 @@ -65,15 +64,6 @@ class Api::V1::Accounts::Whatsapp::AuthorizationsController < Api::V1::Accounts: }, status: :unprocessable_entity end - def validate_feature_enabled! - return if Current.account.feature_whatsapp_embedded_signup? - - render json: { - success: false, - error: 'WhatsApp embedded signup is not enabled for this account' - }, status: :forbidden - end - def validate_embedded_signup_params! missing_params = [] missing_params << 'code' if params[:code].blank? diff --git a/app/controllers/api/v1/profile/mfa_controller.rb b/app/controllers/api/v1/profile/mfa_controller.rb new file mode 100644 index 000000000..dd874f222 --- /dev/null +++ b/app/controllers/api/v1/profile/mfa_controller.rb @@ -0,0 +1,68 @@ +class Api::V1::Profile::MfaController < Api::BaseController + before_action :check_mfa_feature_available + before_action :check_mfa_enabled, only: [:destroy, :backup_codes] + before_action :check_mfa_disabled, only: [:create, :verify] + before_action :validate_otp, only: [:verify, :backup_codes, :destroy] + before_action :validate_password, only: [:destroy] + + def show; end + + def create + mfa_service.enable_two_factor! + end + + def verify + @backup_codes = mfa_service.verify_and_activate! + end + + def destroy + mfa_service.disable_two_factor! + end + + def backup_codes + @backup_codes = mfa_service.generate_backup_codes! + end + + private + + def mfa_service + @mfa_service ||= Mfa::ManagementService.new(user: current_user) + end + + def check_mfa_enabled + render_could_not_create_error(I18n.t('errors.mfa.not_enabled')) unless current_user.mfa_enabled? + end + + def check_mfa_feature_available + return if Chatwoot.mfa_enabled? + + render json: { + error: I18n.t('errors.mfa.feature_unavailable') + }, status: :forbidden + end + + def check_mfa_disabled + render_could_not_create_error(I18n.t('errors.mfa.already_enabled')) if current_user.mfa_enabled? + end + + def validate_otp + authenticated = Mfa::AuthenticationService.new( + user: current_user, + otp_code: mfa_params[:otp_code] + ).authenticate + + return if authenticated + + render_could_not_create_error(I18n.t('errors.mfa.invalid_code')) + end + + def validate_password + return if current_user.valid_password?(mfa_params[:password]) + + render_could_not_create_error(I18n.t('errors.mfa.invalid_credentials')) + end + + def mfa_params + params.permit(:otp_code, :password) + end +end diff --git a/app/controllers/api/v1/widget/configs_controller.rb b/app/controllers/api/v1/widget/configs_controller.rb index ac88c595a..ecbddd905 100644 --- a/app/controllers/api/v1/widget/configs_controller.rb +++ b/app/controllers/api/v1/widget/configs_controller.rb @@ -9,7 +9,7 @@ class Api::V1::Widget::ConfigsController < Api::V1::Widget::BaseController private def set_global_config - @global_config = GlobalConfig.get('LOGO_THUMBNAIL', 'BRAND_NAME', 'WIDGET_BRAND_URL') + @global_config = GlobalConfig.get('LOGO_THUMBNAIL', 'BRAND_NAME', 'WIDGET_BRAND_URL', 'INSTALLATION_NAME') end def set_contact diff --git a/app/controllers/concerns/access_token_auth_helper.rb b/app/controllers/concerns/access_token_auth_helper.rb index 9b0f9021f..338b290da 100644 --- a/app/controllers/concerns/access_token_auth_helper.rb +++ b/app/controllers/concerns/access_token_auth_helper.rb @@ -14,6 +14,7 @@ module AccessTokenAuthHelper ensure_access_token render_unauthorized('Invalid Access Token') && return if @access_token.blank? + # NOTE: This ensures that current_user is set and available for the rest of the controller actions @resource = @access_token.owner Current.user = @resource if allowed_current_user_type?(@resource) end diff --git a/app/controllers/concerns/switch_locale.rb b/app/controllers/concerns/switch_locale.rb index a8ea8ae05..1221d7155 100644 --- a/app/controllers/concerns/switch_locale.rb +++ b/app/controllers/concerns/switch_locale.rb @@ -4,17 +4,28 @@ module SwitchLocale private def switch_locale(&) - # priority is for locale set in query string (mostly for widget/from js sdk) + # Priority is for locale set in query string (mostly for widget/from js sdk) locale ||= params[:locale] + # Use the user's locale if available + locale ||= locale_from_user + + # Use the locale from a custom domain if applicable locale ||= locale_from_custom_domain + # if locale is not set in account, let's use DEFAULT_LOCALE env variable locale ||= ENV.fetch('DEFAULT_LOCALE', nil) + set_locale(locale, &) end def switch_locale_using_account_locale(&) - locale = locale_from_account(@current_account) + # Get the locale from the user first + locale = locale_from_user + + # Fallback to the account's locale if the user's locale is not set + locale ||= locale_from_account(@current_account) + set_locale(locale, &) end @@ -32,6 +43,12 @@ module SwitchLocale @portal.default_locale end + def locale_from_user + return unless @user + + @user.ui_settings&.dig('locale') + end + def set_locale(locale, &) safe_locale = validate_and_get_locale(locale) # Ensure locale won't bleed into other requests diff --git a/app/controllers/dashboard_controller.rb b/app/controllers/dashboard_controller.rb index 4a2df5ee5..d81b4c9da 100644 --- a/app/controllers/dashboard_controller.rb +++ b/app/controllers/dashboard_controller.rb @@ -66,7 +66,7 @@ class DashboardController < ActionController::Base ENABLE_ACCOUNT_SIGNUP: GlobalConfigService.load('ENABLE_ACCOUNT_SIGNUP', 'false'), FB_APP_ID: GlobalConfigService.load('FB_APP_ID', ''), INSTAGRAM_APP_ID: GlobalConfigService.load('INSTAGRAM_APP_ID', ''), - FACEBOOK_API_VERSION: GlobalConfigService.load('FACEBOOK_API_VERSION', 'v17.0'), + FACEBOOK_API_VERSION: GlobalConfigService.load('FACEBOOK_API_VERSION', 'v18.0'), WHATSAPP_APP_ID: GlobalConfigService.load('WHATSAPP_APP_ID', ''), WHATSAPP_CONFIGURATION_ID: GlobalConfigService.load('WHATSAPP_CONFIGURATION_ID', ''), IS_ENTERPRISE: ChatwootApp.enterprise?, diff --git a/app/controllers/devise_overrides/omniauth_callbacks_controller.rb b/app/controllers/devise_overrides/omniauth_callbacks_controller.rb index db312e94f..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? @@ -47,10 +60,8 @@ class DeviseOverrides::OmniauthCallbacksController < DeviseTokenAuth::OmniauthCa end def get_resource_from_auth_hash # rubocop:disable Naming/AccessorMethodName - # find the user with their email instead of UID and token - @resource = resource_class.where( - email: auth_hash['info']['email'] - ).first + email = auth_hash.dig('info', 'email') + @resource = resource_class.from_email(email) end def validate_signup_email_is_business_domain? @@ -75,3 +86,5 @@ class DeviseOverrides::OmniauthCallbacksController < DeviseTokenAuth::OmniauthCa 'user' end end + +DeviseOverrides::OmniauthCallbacksController.prepend_mod_with('DeviseOverrides::OmniauthCallbacksController') diff --git a/app/controllers/devise_overrides/passwords_controller.rb b/app/controllers/devise_overrides/passwords_controller.rb index 17dd32086..00976c3cd 100644 --- a/app/controllers/devise_overrides/passwords_controller.rb +++ b/app/controllers/devise_overrides/passwords_controller.rb @@ -44,3 +44,5 @@ class DeviseOverrides::PasswordsController < Devise::PasswordsController }, status: status end end + +DeviseOverrides::PasswordsController.prepend_mod_with('DeviseOverrides::PasswordsController') diff --git a/app/controllers/devise_overrides/sessions_controller.rb b/app/controllers/devise_overrides/sessions_controller.rb index fc7b12767..974fb05e4 100644 --- a/app/controllers/devise_overrides/sessions_controller.rb +++ b/app/controllers/devise_overrides/sessions_controller.rb @@ -9,14 +9,14 @@ class DeviseOverrides::SessionsController < DeviseTokenAuth::SessionsController end def create - # Authenticate user via the temporary sso auth token - if params[:sso_auth_token].present? && @resource.present? - authenticate_resource_with_sso_token - yield @resource if block_given? - render_create_success - else - super - end + return handle_mfa_verification if mfa_verification_request? + return handle_sso_authentication if sso_authentication_request? + + user = find_user_for_authentication + return handle_mfa_required(user) if user&.mfa_enabled? + + # Only proceed with standard authentication if no MFA is required + super end def render_create_success @@ -25,6 +25,31 @@ class DeviseOverrides::SessionsController < DeviseTokenAuth::SessionsController private + def find_user_for_authentication + return nil unless params[:email].present? && params[:password].present? + + normalized_email = params[:email].strip.downcase + user = User.from_email(normalized_email) + return nil unless user&.valid_password?(params[:password]) + return nil unless user.active_for_authentication? + + user + end + + def mfa_verification_request? + params[:mfa_token].present? + end + + def sso_authentication_request? + params[:sso_auth_token].present? && @resource.present? + end + + def handle_sso_authentication + authenticate_resource_with_sso_token + yield @resource if block_given? + render_create_success + end + def login_page_url(error: nil) frontend_url = ENV.fetch('FRONTEND_URL', nil) @@ -46,6 +71,41 @@ class DeviseOverrides::SessionsController < DeviseTokenAuth::SessionsController user = User.from_email(params[:email]) @resource = user if user&.valid_sso_auth_token?(params[:sso_auth_token]) end + + def handle_mfa_required(user) + render json: { + mfa_required: true, + mfa_token: Mfa::TokenService.new(user: user).generate_token + }, status: :partial_content + end + + def handle_mfa_verification + user = Mfa::TokenService.new(token: params[:mfa_token]).verify_token + return render_mfa_error('errors.mfa.invalid_token', :unauthorized) unless user + + authenticated = Mfa::AuthenticationService.new( + user: user, + otp_code: params[:otp_code], + backup_code: params[:backup_code] + ).authenticate + + return render_mfa_error('errors.mfa.invalid_code') unless authenticated + + sign_in_mfa_user(user) + end + + def sign_in_mfa_user(user) + @resource = user + @token = @resource.create_token + @resource.save! + + sign_in(:user, @resource, store: false, bypass: false) + render_create_success + end + + def render_mfa_error(message_key, status = :bad_request) + render json: { error: I18n.t(message_key) }, status: status + end end DeviseOverrides::SessionsController.prepend_mod_with('DeviseOverrides::SessionsController') diff --git a/app/controllers/public/api/v1/inboxes/conversations_controller.rb b/app/controllers/public/api/v1/inboxes/conversations_controller.rb index 4e3b5dca9..242dcde77 100644 --- a/app/controllers/public/api/v1/inboxes/conversations_controller.rb +++ b/app/controllers/public/api/v1/inboxes/conversations_controller.rb @@ -3,7 +3,7 @@ class Public::Api::V1::Inboxes::ConversationsController < Public::Api::V1::Inbox before_action :set_conversation, only: [:toggle_typing, :update_last_seen, :show, :toggle_status] def index - @conversations = @contact_inbox.hmac_verified? ? @contact.conversations : @contact_inbox.conversations + @conversations = @contact_inbox.hmac_verified? ? @contact_inbox.contact.conversations : @contact_inbox.conversations end def show; end diff --git a/app/controllers/public/api/v1/portals/base_controller.rb b/app/controllers/public/api/v1/portals/base_controller.rb index 66b052b1e..46158bce9 100644 --- a/app/controllers/public/api/v1/portals/base_controller.rb +++ b/app/controllers/public/api/v1/portals/base_controller.rb @@ -58,6 +58,6 @@ class Public::Api::V1::Portals::BaseController < PublicController end def set_global_config - @global_config = GlobalConfig.get('LOGO_THUMBNAIL', 'BRAND_NAME', 'BRAND_URL') + @global_config = GlobalConfig.get('LOGO_THUMBNAIL', 'BRAND_NAME', 'BRAND_URL', 'INSTALLATION_NAME') end end diff --git a/app/controllers/super_admin/users_controller.rb b/app/controllers/super_admin/users_controller.rb index ff242030a..e98e61c95 100644 --- a/app/controllers/super_admin/users_controller.rb +++ b/app/controllers/super_admin/users_controller.rb @@ -13,11 +13,11 @@ class SuperAdmin::UsersController < SuperAdmin::ApplicationController redirect_to new_super_admin_user_path, notice: notice end end - # - # def update - # super - # send_foo_updated_email(requested_resource) - # end + + def update + requested_resource.skip_reconfirmation! if resource_params[:confirmed_at].present? + super + end # Override this method to specify custom lookup behavior. # This will be used to set the resource for the `show`, `edit`, and `update` diff --git a/app/controllers/survey/responses_controller.rb b/app/controllers/survey/responses_controller.rb index 8bbd0fe88..afcb3f4c0 100644 --- a/app/controllers/survey/responses_controller.rb +++ b/app/controllers/survey/responses_controller.rb @@ -5,6 +5,6 @@ class Survey::ResponsesController < ActionController::Base private def set_global_config - @global_config = GlobalConfig.get('LOGO_THUMBNAIL', 'BRAND_NAME', 'WIDGET_BRAND_URL') + @global_config = GlobalConfig.get('LOGO_THUMBNAIL', 'BRAND_NAME', 'WIDGET_BRAND_URL', 'INSTALLATION_NAME') end end diff --git a/app/controllers/widgets_controller.rb b/app/controllers/widgets_controller.rb index 70e4c967b..9a6a376f7 100644 --- a/app/controllers/widgets_controller.rb +++ b/app/controllers/widgets_controller.rb @@ -14,7 +14,7 @@ class WidgetsController < ActionController::Base private def set_global_config - @global_config = GlobalConfig.get('LOGO_THUMBNAIL', 'BRAND_NAME', 'WIDGET_BRAND_URL', 'DIRECT_UPLOADS_ENABLED') + @global_config = GlobalConfig.get('LOGO_THUMBNAIL', 'BRAND_NAME', 'WIDGET_BRAND_URL', 'DIRECT_UPLOADS_ENABLED', 'INSTALLATION_NAME') end def set_web_widget @@ -70,7 +70,12 @@ class WidgetsController < ActionController::Base end def allow_iframe_requests - response.headers.delete('X-Frame-Options') + if @web_widget.allowed_domains.blank? + 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 end diff --git a/app/dashboards/user_dashboard.rb b/app/dashboards/user_dashboard.rb index 8abdefd1a..753b617ef 100644 --- a/app/dashboards/user_dashboard.rb +++ b/app/dashboards/user_dashboard.rb @@ -59,11 +59,11 @@ class UserDashboard < Administrate::BaseDashboard SHOW_PAGE_ATTRIBUTES = %i[ id avatar_url - unconfirmed_email name type display_name email + unconfirmed_email created_at updated_at confirmed_at diff --git a/app/finders/email_channel_finder.rb b/app/finders/email_channel_finder.rb index 41cd8e910..1b6d6f844 100644 --- a/app/finders/email_channel_finder.rb +++ b/app/finders/email_channel_finder.rb @@ -6,19 +6,54 @@ class EmailChannelFinder end def perform - channel = nil - - recipient_mails.each do |email| - normalized_email = normalize_email_with_plus_addressing(email) - channel = Channel::Email.find_by('lower(email) = ? OR lower(forward_to_email) = ?', normalized_email, normalized_email) - - break if channel.present? - end - channel + channel_from_primary_recipients || channel_from_bcc_recipients end - def recipient_mails - recipient_addresses = @email_object.to.to_a + @email_object.cc.to_a + @email_object.bcc.to_a + [@email_object['X-Original-To'].try(:value)] - recipient_addresses.flatten.compact + private + + def channel_from_primary_recipients + primary_recipient_emails.each do |email| + channel = channel_from_email(email) + return channel if channel.present? + end + + nil + end + + def channel_from_bcc_recipients + bcc_recipient_emails.each do |email| + channel = channel_from_email(email) + + # Skip if BCC processing is disabled for this account + next if channel && !allow_bcc_processing?(channel.account_id) + + return channel if channel.present? + end + + nil + end + + def primary_recipient_emails + (@email_object.to.to_a + @email_object.cc.to_a + [@email_object['X-Original-To'].try(:value)]).flatten.compact + end + + def bcc_recipient_emails + @email_object.bcc.to_a.flatten.compact + end + + def channel_from_email(email) + normalized_email = normalize_email_with_plus_addressing(email) + Channel::Email.find_by('lower(email) = ? OR lower(forward_to_email) = ?', normalized_email, normalized_email) + end + + def bcc_processing_skipped_accounts + config_value = GlobalConfigService.load('SKIP_INCOMING_BCC_PROCESSING', '') + return [] if config_value.blank? + + config_value.split(',').map(&:to_i) + end + + def allow_bcc_processing?(account_id) + bcc_processing_skipped_accounts.exclude?(account_id) end end diff --git a/app/helpers/portal_helper.rb b/app/helpers/portal_helper.rb index e98f0a72d..15de0fbd7 100644 --- a/app/helpers/portal_helper.rb +++ b/app/helpers/portal_helper.rb @@ -1,4 +1,5 @@ module PortalHelper + include UrlHelper def set_og_image_url(portal_name, title) cdn_url = GlobalConfig.get('OG_IMAGE_CDN_URL')['OG_IMAGE_CDN_URL'] return if cdn_url.blank? @@ -79,7 +80,7 @@ module PortalHelper query_params = Rack::Utils.parse_query(url.query) query_params['utm_medium'] = 'helpcenter' query_params['utm_campaign'] = 'branding' - query_params['utm_source'] = URI.parse(referer).host if referer.present? && referer.match?(URI::DEFAULT_PARSER.make_regexp) + query_params['utm_source'] = URI.parse(referer).host if url_valid?(referer) url.query = query_params.to_query url.to_s diff --git a/app/helpers/report_helper.rb b/app/helpers/report_helper.rb index 99f3fd36b..09a84b110 100644 --- a/app/helpers/report_helper.rb +++ b/app/helpers/report_helper.rb @@ -53,13 +53,13 @@ module ReportHelper end def resolutions - scope.reporting_events.joins(:conversation).select(:conversation_id).where(account_id: account.id, name: :conversation_resolved, - conversations: { status: :resolved }, created_at: range).distinct + scope.reporting_events.where(account_id: account.id, name: :conversation_resolved, + created_at: range) end def bot_resolutions - scope.reporting_events.joins(:conversation).select(:conversation_id).where(account_id: account.id, name: :conversation_bot_resolved, - conversations: { status: :resolved }, created_at: range).distinct + scope.reporting_events.where(account_id: account.id, name: :conversation_bot_resolved, + created_at: range) end def bot_handoffs diff --git a/app/javascript/dashboard/App.vue b/app/javascript/dashboard/App.vue index 8da7e7476..0fcb8c9fe 100644 --- a/app/javascript/dashboard/App.vue +++ b/app/javascript/dashboard/App.vue @@ -19,6 +19,7 @@ import { verifyServiceWorkerExistence, } from './helper/pushHelper'; import ReconnectService from 'dashboard/helper/ReconnectService'; +import { useUISettings } from 'dashboard/composables/useUISettings'; export default { name: 'App', @@ -38,12 +39,14 @@ export default { const { accountId } = useAccount(); // Use the font size composable (it automatically sets up the watcher) const { currentFontSize } = useFontSize(); + const { uiSettings } = useUISettings(); return { router, store, currentAccountId: accountId, currentFontSize, + uiSettings, }; }, data() { @@ -88,7 +91,10 @@ export default { mounted() { this.initializeColorTheme(); this.listenToThemeChanges(); - this.setLocale(window.chatwootConfig.selectedLocale); + // If user locale is set, use it; otherwise use account locale + this.setLocale( + this.uiSettings?.locale || window.chatwootConfig.selectedLocale + ); }, unmounted() { if (this.reconnectService) { @@ -114,7 +120,8 @@ export default { const { locale, latest_chatwoot_version: latestChatwootVersion } = this.getAccount(this.currentAccountId); const { pubsub_token: pubsubToken } = this.currentUser || {}; - this.setLocale(locale); + // If user locale is set, use it; otherwise use account locale + this.setLocale(this.uiSettings?.locale || locale); this.latestChatwootVersion = latestChatwootVersion; vueActionCable.init(this.store, pubsubToken); this.reconnectService = new ReconnectService(this.store, this.router); diff --git a/app/javascript/dashboard/api/agentCapacityPolicies.js b/app/javascript/dashboard/api/agentCapacityPolicies.js new file mode 100644 index 000000000..7792ce469 --- /dev/null +++ b/app/javascript/dashboard/api/agentCapacityPolicies.js @@ -0,0 +1,43 @@ +/* global axios */ + +import ApiClient from './ApiClient'; + +class AgentCapacityPolicies extends ApiClient { + constructor() { + super('agent_capacity_policies', { accountScoped: true }); + } + + getUsers(policyId) { + return axios.get(`${this.url}/${policyId}/users`); + } + + addUser(policyId, userData) { + return axios.post(`${this.url}/${policyId}/users`, { + user_id: userData.id, + capacity: userData.capacity, + }); + } + + removeUser(policyId, userId) { + return axios.delete(`${this.url}/${policyId}/users/${userId}`); + } + + createInboxLimit(policyId, limitData) { + return axios.post(`${this.url}/${policyId}/inbox_limits`, { + inbox_id: limitData.inboxId, + conversation_limit: limitData.conversationLimit, + }); + } + + updateInboxLimit(policyId, limitId, limitData) { + return axios.put(`${this.url}/${policyId}/inbox_limits/${limitId}`, { + conversation_limit: limitData.conversationLimit, + }); + } + + deleteInboxLimit(policyId, limitId) { + return axios.delete(`${this.url}/${policyId}/inbox_limits/${limitId}`); + } +} + +export default new AgentCapacityPolicies(); diff --git a/app/javascript/dashboard/api/assignmentPolicies.js b/app/javascript/dashboard/api/assignmentPolicies.js new file mode 100644 index 000000000..e6baca97a --- /dev/null +++ b/app/javascript/dashboard/api/assignmentPolicies.js @@ -0,0 +1,36 @@ +/* global axios */ + +import ApiClient from './ApiClient'; + +class AssignmentPolicies extends ApiClient { + constructor() { + super('assignment_policies', { accountScoped: true }); + } + + getInboxes(policyId) { + return axios.get(`${this.url}/${policyId}/inboxes`); + } + + setInboxPolicy(inboxId, policyId) { + return axios.post( + `/api/v1/accounts/${this.accountIdFromRoute}/inboxes/${inboxId}/assignment_policy`, + { + assignment_policy_id: policyId, + } + ); + } + + getInboxPolicy(inboxId) { + return axios.get( + `/api/v1/accounts/${this.accountIdFromRoute}/inboxes/${inboxId}/assignment_policy` + ); + } + + removeInboxPolicy(inboxId) { + return axios.delete( + `/api/v1/accounts/${this.accountIdFromRoute}/inboxes/${inboxId}/assignment_policy` + ); + } +} + +export default new AssignmentPolicies(); 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/captain/response.js b/app/javascript/dashboard/api/captain/response.js index e3c42757a..d48bd81c7 100644 --- a/app/javascript/dashboard/api/captain/response.js +++ b/app/javascript/dashboard/api/captain/response.js @@ -6,11 +6,11 @@ class CaptainResponses extends ApiClient { super('captain/assistant_responses', { accountScoped: true }); } - get({ page = 1, searchKey, assistantId, documentId, status } = {}) { + get({ page = 1, search, assistantId, documentId, status } = {}) { return axios.get(this.url, { params: { page, - searchKey, + search, assistant_id: assistantId, document_id: documentId, status, diff --git a/app/javascript/dashboard/api/changelog.js b/app/javascript/dashboard/api/changelog.js new file mode 100644 index 000000000..8cf0cdea1 --- /dev/null +++ b/app/javascript/dashboard/api/changelog.js @@ -0,0 +1,16 @@ +import axios from 'axios'; +import ApiClient from './ApiClient'; +import { CHANGELOG_API_URL } from 'shared/constants/links'; + +class ChangelogApi extends ApiClient { + constructor() { + super('changelog', { apiVersion: 'v1' }); + } + + // eslint-disable-next-line class-methods-use-this + fetchFromHub() { + return axios.get(CHANGELOG_API_URL); + } +} + +export default new ChangelogApi(); 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/api/mfa.js b/app/javascript/dashboard/api/mfa.js new file mode 100644 index 000000000..c18bea3e9 --- /dev/null +++ b/app/javascript/dashboard/api/mfa.js @@ -0,0 +1,28 @@ +/* global axios */ +import ApiClient from './ApiClient'; + +class MfaAPI extends ApiClient { + constructor() { + super('profile/mfa', { accountScoped: false }); + } + + enable() { + return axios.post(`${this.url}`); + } + + verify(otpCode) { + return axios.post(`${this.url}/verify`, { otp_code: otpCode }); + } + + disable(password, otpCode) { + return axios.delete(this.url, { + data: { password, otp_code: otpCode }, + }); + } + + regenerateBackupCodes(otpCode) { + return axios.post(`${this.url}/backup_codes`, { otp_code: otpCode }); + } +} + +export default new MfaAPI(); diff --git a/app/javascript/dashboard/api/samlSettings.js b/app/javascript/dashboard/api/samlSettings.js new file mode 100644 index 000000000..7c0f5b266 --- /dev/null +++ b/app/javascript/dashboard/api/samlSettings.js @@ -0,0 +1,26 @@ +/* global axios */ +import ApiClient from './ApiClient'; + +class SamlSettingsAPI extends ApiClient { + constructor() { + super('saml_settings', { accountScoped: true }); + } + + get() { + return axios.get(this.url); + } + + create(data) { + return axios.post(this.url, { saml_settings: data }); + } + + update(data) { + return axios.put(this.url, { saml_settings: data }); + } + + delete() { + return axios.delete(this.url); + } +} + +export default new SamlSettingsAPI(); diff --git a/app/javascript/dashboard/api/specs/agentCapacityPolicies.spec.js b/app/javascript/dashboard/api/specs/agentCapacityPolicies.spec.js new file mode 100644 index 000000000..43932aa71 --- /dev/null +++ b/app/javascript/dashboard/api/specs/agentCapacityPolicies.spec.js @@ -0,0 +1,98 @@ +import agentCapacityPolicies from '../agentCapacityPolicies'; +import ApiClient from '../ApiClient'; + +describe('#AgentCapacityPoliciesAPI', () => { + it('creates correct instance', () => { + expect(agentCapacityPolicies).toBeInstanceOf(ApiClient); + expect(agentCapacityPolicies).toHaveProperty('get'); + expect(agentCapacityPolicies).toHaveProperty('show'); + expect(agentCapacityPolicies).toHaveProperty('create'); + expect(agentCapacityPolicies).toHaveProperty('update'); + expect(agentCapacityPolicies).toHaveProperty('delete'); + expect(agentCapacityPolicies).toHaveProperty('getUsers'); + expect(agentCapacityPolicies).toHaveProperty('addUser'); + expect(agentCapacityPolicies).toHaveProperty('removeUser'); + expect(agentCapacityPolicies).toHaveProperty('createInboxLimit'); + expect(agentCapacityPolicies).toHaveProperty('updateInboxLimit'); + expect(agentCapacityPolicies).toHaveProperty('deleteInboxLimit'); + }); + + describe('API calls', () => { + const originalAxios = window.axios; + const axiosMock = { + get: vi.fn(() => Promise.resolve()), + post: vi.fn(() => Promise.resolve()), + put: vi.fn(() => Promise.resolve()), + delete: vi.fn(() => Promise.resolve()), + }; + + beforeEach(() => { + window.axios = axiosMock; + // Mock accountIdFromRoute + Object.defineProperty(agentCapacityPolicies, 'accountIdFromRoute', { + get: () => '1', + configurable: true, + }); + }); + + afterEach(() => { + window.axios = originalAxios; + }); + + it('#getUsers', () => { + agentCapacityPolicies.getUsers(123); + expect(axiosMock.get).toHaveBeenCalledWith( + '/api/v1/accounts/1/agent_capacity_policies/123/users' + ); + }); + + it('#addUser', () => { + const userData = { id: 456, capacity: 20 }; + agentCapacityPolicies.addUser(123, userData); + expect(axiosMock.post).toHaveBeenCalledWith( + '/api/v1/accounts/1/agent_capacity_policies/123/users', + { + user_id: 456, + capacity: 20, + } + ); + }); + + it('#removeUser', () => { + agentCapacityPolicies.removeUser(123, 456); + expect(axiosMock.delete).toHaveBeenCalledWith( + '/api/v1/accounts/1/agent_capacity_policies/123/users/456' + ); + }); + + it('#createInboxLimit', () => { + const limitData = { inboxId: 1, conversationLimit: 10 }; + agentCapacityPolicies.createInboxLimit(123, limitData); + expect(axiosMock.post).toHaveBeenCalledWith( + '/api/v1/accounts/1/agent_capacity_policies/123/inbox_limits', + { + inbox_id: 1, + conversation_limit: 10, + } + ); + }); + + it('#updateInboxLimit', () => { + const limitData = { conversationLimit: 15 }; + agentCapacityPolicies.updateInboxLimit(123, 789, limitData); + expect(axiosMock.put).toHaveBeenCalledWith( + '/api/v1/accounts/1/agent_capacity_policies/123/inbox_limits/789', + { + conversation_limit: 15, + } + ); + }); + + it('#deleteInboxLimit', () => { + agentCapacityPolicies.deleteInboxLimit(123, 789); + expect(axiosMock.delete).toHaveBeenCalledWith( + '/api/v1/accounts/1/agent_capacity_policies/123/inbox_limits/789' + ); + }); + }); +}); diff --git a/app/javascript/dashboard/api/specs/assignmentPolicies.spec.js b/app/javascript/dashboard/api/specs/assignmentPolicies.spec.js new file mode 100644 index 000000000..8d0aea7d0 --- /dev/null +++ b/app/javascript/dashboard/api/specs/assignmentPolicies.spec.js @@ -0,0 +1,70 @@ +import assignmentPolicies from '../assignmentPolicies'; +import ApiClient from '../ApiClient'; + +describe('#AssignmentPoliciesAPI', () => { + it('creates correct instance', () => { + expect(assignmentPolicies).toBeInstanceOf(ApiClient); + expect(assignmentPolicies).toHaveProperty('get'); + expect(assignmentPolicies).toHaveProperty('show'); + expect(assignmentPolicies).toHaveProperty('create'); + expect(assignmentPolicies).toHaveProperty('update'); + expect(assignmentPolicies).toHaveProperty('delete'); + expect(assignmentPolicies).toHaveProperty('getInboxes'); + expect(assignmentPolicies).toHaveProperty('setInboxPolicy'); + expect(assignmentPolicies).toHaveProperty('getInboxPolicy'); + expect(assignmentPolicies).toHaveProperty('removeInboxPolicy'); + }); + + describe('API calls', () => { + const originalAxios = window.axios; + const axiosMock = { + get: vi.fn(() => Promise.resolve()), + post: vi.fn(() => Promise.resolve()), + delete: vi.fn(() => Promise.resolve()), + }; + + beforeEach(() => { + window.axios = axiosMock; + // Mock accountIdFromRoute + Object.defineProperty(assignmentPolicies, 'accountIdFromRoute', { + get: () => '1', + configurable: true, + }); + }); + + afterEach(() => { + window.axios = originalAxios; + }); + + it('#getInboxes', () => { + assignmentPolicies.getInboxes(123); + expect(axiosMock.get).toHaveBeenCalledWith( + '/api/v1/accounts/1/assignment_policies/123/inboxes' + ); + }); + + it('#setInboxPolicy', () => { + assignmentPolicies.setInboxPolicy(456, 123); + expect(axiosMock.post).toHaveBeenCalledWith( + '/api/v1/accounts/1/inboxes/456/assignment_policy', + { + assignment_policy_id: 123, + } + ); + }); + + it('#getInboxPolicy', () => { + assignmentPolicies.getInboxPolicy(456); + expect(axiosMock.get).toHaveBeenCalledWith( + '/api/v1/accounts/1/inboxes/456/assignment_policy' + ); + }); + + it('#removeInboxPolicy', () => { + assignmentPolicies.removeInboxPolicy(456); + expect(axiosMock.delete).toHaveBeenCalledWith( + '/api/v1/accounts/1/inboxes/456/assignment_policy' + ); + }); + }); +}); diff --git a/app/javascript/dashboard/components-next/AssignmentPolicy/AgentCapacityPolicyCard/AgentCapacityPolicyCard.story.vue b/app/javascript/dashboard/components-next/AssignmentPolicy/AgentCapacityPolicyCard/AgentCapacityPolicyCard.story.vue new file mode 100644 index 000000000..10f301230 --- /dev/null +++ b/app/javascript/dashboard/components-next/AssignmentPolicy/AgentCapacityPolicyCard/AgentCapacityPolicyCard.story.vue @@ -0,0 +1,116 @@ + + + diff --git a/app/javascript/dashboard/components-next/AssignmentPolicy/AgentCapacityPolicyCard/AgentCapacityPolicyCard.vue b/app/javascript/dashboard/components-next/AssignmentPolicy/AgentCapacityPolicyCard/AgentCapacityPolicyCard.vue new file mode 100644 index 000000000..3c749e751 --- /dev/null +++ b/app/javascript/dashboard/components-next/AssignmentPolicy/AgentCapacityPolicyCard/AgentCapacityPolicyCard.vue @@ -0,0 +1,86 @@ + + + diff --git a/app/javascript/dashboard/components-next/AssignmentPolicy/AssignmentCard/AssignmentCard.story.vue b/app/javascript/dashboard/components-next/AssignmentPolicy/AssignmentCard/AssignmentCard.story.vue new file mode 100644 index 000000000..e35be8155 --- /dev/null +++ b/app/javascript/dashboard/components-next/AssignmentPolicy/AssignmentCard/AssignmentCard.story.vue @@ -0,0 +1,63 @@ + + + diff --git a/app/javascript/dashboard/components-next/AssignmentPolicy/AssignmentCard/AssignmentCard.vue b/app/javascript/dashboard/components-next/AssignmentPolicy/AssignmentCard/AssignmentCard.vue new file mode 100644 index 000000000..1e477eafe --- /dev/null +++ b/app/javascript/dashboard/components-next/AssignmentPolicy/AssignmentCard/AssignmentCard.vue @@ -0,0 +1,49 @@ + + + diff --git a/app/javascript/dashboard/components-next/AssignmentPolicy/AssignmentPolicyCard/AssignmentPolicyCard.story.vue b/app/javascript/dashboard/components-next/AssignmentPolicy/AssignmentPolicyCard/AssignmentPolicyCard.story.vue new file mode 100644 index 000000000..cd6f1d49b --- /dev/null +++ b/app/javascript/dashboard/components-next/AssignmentPolicy/AssignmentPolicyCard/AssignmentPolicyCard.story.vue @@ -0,0 +1,104 @@ + + + diff --git a/app/javascript/dashboard/components-next/AssignmentPolicy/AssignmentPolicyCard/AssignmentPolicyCard.vue b/app/javascript/dashboard/components-next/AssignmentPolicy/AssignmentPolicyCard/AssignmentPolicyCard.vue new file mode 100644 index 000000000..fe9965777 --- /dev/null +++ b/app/javascript/dashboard/components-next/AssignmentPolicy/AssignmentPolicyCard/AssignmentPolicyCard.vue @@ -0,0 +1,133 @@ + + + diff --git a/app/javascript/dashboard/components-next/AssignmentPolicy/components/AddDataDropdown.vue b/app/javascript/dashboard/components-next/AssignmentPolicy/components/AddDataDropdown.vue new file mode 100644 index 000000000..a078b9cc7 --- /dev/null +++ b/app/javascript/dashboard/components-next/AssignmentPolicy/components/AddDataDropdown.vue @@ -0,0 +1,169 @@ + + + diff --git a/app/javascript/dashboard/components-next/AssignmentPolicy/components/BaseInfo.vue b/app/javascript/dashboard/components-next/AssignmentPolicy/components/BaseInfo.vue new file mode 100644 index 000000000..b430b3f97 --- /dev/null +++ b/app/javascript/dashboard/components-next/AssignmentPolicy/components/BaseInfo.vue @@ -0,0 +1,127 @@ + + + diff --git a/app/javascript/dashboard/components-next/AssignmentPolicy/components/CardPopover.vue b/app/javascript/dashboard/components-next/AssignmentPolicy/components/CardPopover.vue new file mode 100644 index 000000000..50d7794c9 --- /dev/null +++ b/app/javascript/dashboard/components-next/AssignmentPolicy/components/CardPopover.vue @@ -0,0 +1,121 @@ + + + diff --git a/app/javascript/dashboard/components-next/AssignmentPolicy/components/DataTable.vue b/app/javascript/dashboard/components-next/AssignmentPolicy/components/DataTable.vue new file mode 100644 index 000000000..aeea0cbdd --- /dev/null +++ b/app/javascript/dashboard/components-next/AssignmentPolicy/components/DataTable.vue @@ -0,0 +1,90 @@ + + + diff --git a/app/javascript/dashboard/components-next/AssignmentPolicy/components/ExclusionRules.vue b/app/javascript/dashboard/components-next/AssignmentPolicy/components/ExclusionRules.vue new file mode 100644 index 000000000..351e3240f --- /dev/null +++ b/app/javascript/dashboard/components-next/AssignmentPolicy/components/ExclusionRules.vue @@ -0,0 +1,149 @@ + + + diff --git a/app/javascript/dashboard/components-next/AssignmentPolicy/components/FairDistribution.vue b/app/javascript/dashboard/components-next/AssignmentPolicy/components/FairDistribution.vue new file mode 100644 index 000000000..be5b26a7e --- /dev/null +++ b/app/javascript/dashboard/components-next/AssignmentPolicy/components/FairDistribution.vue @@ -0,0 +1,86 @@ + + + diff --git a/app/javascript/dashboard/components-next/AssignmentPolicy/components/InboxCapacityLimits.vue b/app/javascript/dashboard/components-next/AssignmentPolicy/components/InboxCapacityLimits.vue new file mode 100644 index 000000000..b31248653 --- /dev/null +++ b/app/javascript/dashboard/components-next/AssignmentPolicy/components/InboxCapacityLimits.vue @@ -0,0 +1,177 @@ + + + diff --git a/app/javascript/dashboard/components-next/AssignmentPolicy/components/RadioCard.vue b/app/javascript/dashboard/components-next/AssignmentPolicy/components/RadioCard.vue new file mode 100644 index 000000000..3d0c8a8b3 --- /dev/null +++ b/app/javascript/dashboard/components-next/AssignmentPolicy/components/RadioCard.vue @@ -0,0 +1,60 @@ + + + diff --git a/app/javascript/dashboard/components-next/AssignmentPolicy/components/story/AddDataDropdown.story.vue b/app/javascript/dashboard/components-next/AssignmentPolicy/components/story/AddDataDropdown.story.vue new file mode 100644 index 000000000..e69aa798f --- /dev/null +++ b/app/javascript/dashboard/components-next/AssignmentPolicy/components/story/AddDataDropdown.story.vue @@ -0,0 +1,92 @@ + + + diff --git a/app/javascript/dashboard/components-next/AssignmentPolicy/components/story/BaseInfo.story.vue b/app/javascript/dashboard/components-next/AssignmentPolicy/components/story/BaseInfo.story.vue new file mode 100644 index 000000000..a3bfe9bee --- /dev/null +++ b/app/javascript/dashboard/components-next/AssignmentPolicy/components/story/BaseInfo.story.vue @@ -0,0 +1,33 @@ + + + diff --git a/app/javascript/dashboard/components-next/AssignmentPolicy/components/story/CardPopover.story.vue b/app/javascript/dashboard/components-next/AssignmentPolicy/components/story/CardPopover.story.vue new file mode 100644 index 000000000..9694a26c7 --- /dev/null +++ b/app/javascript/dashboard/components-next/AssignmentPolicy/components/story/CardPopover.story.vue @@ -0,0 +1,89 @@ + + + diff --git a/app/javascript/dashboard/components-next/AssignmentPolicy/components/story/DataTable.story.vue b/app/javascript/dashboard/components-next/AssignmentPolicy/components/story/DataTable.story.vue new file mode 100644 index 000000000..a81a29976 --- /dev/null +++ b/app/javascript/dashboard/components-next/AssignmentPolicy/components/story/DataTable.story.vue @@ -0,0 +1,87 @@ + + + diff --git a/app/javascript/dashboard/components-next/AssignmentPolicy/components/story/ExclusionRules.story.vue b/app/javascript/dashboard/components-next/AssignmentPolicy/components/story/ExclusionRules.story.vue new file mode 100644 index 000000000..7e0dbd595 --- /dev/null +++ b/app/javascript/dashboard/components-next/AssignmentPolicy/components/story/ExclusionRules.story.vue @@ -0,0 +1,67 @@ + + + diff --git a/app/javascript/dashboard/components-next/AssignmentPolicy/components/story/FairDistribution.story.vue b/app/javascript/dashboard/components-next/AssignmentPolicy/components/story/FairDistribution.story.vue new file mode 100644 index 000000000..edec5fc92 --- /dev/null +++ b/app/javascript/dashboard/components-next/AssignmentPolicy/components/story/FairDistribution.story.vue @@ -0,0 +1,25 @@ + + + diff --git a/app/javascript/dashboard/components-next/AssignmentPolicy/components/story/InboxCapacityLimits.story.vue b/app/javascript/dashboard/components-next/AssignmentPolicy/components/story/InboxCapacityLimits.story.vue new file mode 100644 index 000000000..9d90112a1 --- /dev/null +++ b/app/javascript/dashboard/components-next/AssignmentPolicy/components/story/InboxCapacityLimits.story.vue @@ -0,0 +1,108 @@ + + + diff --git a/app/javascript/dashboard/components-next/AssignmentPolicy/components/story/RadioCard.story.vue b/app/javascript/dashboard/components-next/AssignmentPolicy/components/story/RadioCard.story.vue new file mode 100644 index 000000000..df1f8655c --- /dev/null +++ b/app/javascript/dashboard/components-next/AssignmentPolicy/components/story/RadioCard.story.vue @@ -0,0 +1,61 @@ + + + diff --git a/app/javascript/dashboard/components-next/Contacts/ContactLabels/ContactLabels.vue b/app/javascript/dashboard/components-next/Contacts/ContactLabels/ContactLabels.vue index af04de9a7..bc1370a0a 100644 --- a/app/javascript/dashboard/components-next/Contacts/ContactLabels/ContactLabels.vue +++ b/app/javascript/dashboard/components-next/Contacts/ContactLabels/ContactLabels.vue @@ -86,8 +86,8 @@ const handleLabelAction = async ({ value }) => { } }; -const handleRemoveLabel = labelId => { - return handleLabelAction({ value: labelId }); +const handleRemoveLabel = label => { + return handleLabelAction({ value: label.id }); }; watch( diff --git a/app/javascript/dashboard/components-next/Contacts/ContactsCard/ContactDeleteSection.vue b/app/javascript/dashboard/components-next/Contacts/ContactsCard/ContactDeleteSection.vue index 47b779b61..041f06410 100644 --- a/app/javascript/dashboard/components-next/Contacts/ContactsCard/ContactDeleteSection.vue +++ b/app/javascript/dashboard/components-next/Contacts/ContactsCard/ContactDeleteSection.vue @@ -5,6 +5,7 @@ import { useToggle } from '@vueuse/core'; import Button from 'dashboard/components-next/button/Button.vue'; import ConfirmContactDeleteDialog from 'dashboard/components-next/Contacts/ContactsForm/ConfirmContactDeleteDialog.vue'; +import Policy from 'dashboard/components/policy.vue'; defineProps({ selectedContact: { @@ -24,42 +25,44 @@ const openConfirmDeleteContactDialog = () => { diff --git a/app/javascript/dashboard/components-next/Contacts/ContactsCard/ContactsCard.vue b/app/javascript/dashboard/components-next/Contacts/ContactsCard/ContactsCard.vue index 0e893b767..12bed151d 100644 --- a/app/javascript/dashboard/components-next/Contacts/ContactsCard/ContactsCard.vue +++ b/app/javascript/dashboard/components-next/Contacts/ContactsCard/ContactsCard.vue @@ -8,6 +8,7 @@ import Button from 'dashboard/components-next/button/Button.vue'; import Avatar from 'dashboard/components-next/avatar/Avatar.vue'; import Flag from 'dashboard/components-next/flag/Flag.vue'; import ContactDeleteSection from 'dashboard/components-next/Contacts/ContactsCard/ContactDeleteSection.vue'; +import Checkbox from 'dashboard/components-next/checkbox/Checkbox.vue'; import countries from 'shared/constants/countries'; const props = defineProps({ @@ -20,9 +21,17 @@ const props = defineProps({ availabilityStatus: { type: String, default: null }, isExpanded: { type: Boolean, default: false }, isUpdating: { type: Boolean, default: false }, + selectable: { type: Boolean, default: false }, + isSelected: { type: Boolean, default: false }, }); -const emit = defineEmits(['toggle', 'updateContact', 'showContact']); +const emit = defineEmits([ + 'toggle', + 'updateContact', + 'showContact', + 'select', + 'avatarHover', +]); const { t } = useI18n(); @@ -88,111 +97,148 @@ const onClickExpand = () => { }; const onClickViewDetails = () => emit('showContact', props.id); + +const toggleSelect = checked => { + emit('select', checked); +}; + +const handleAvatarHover = isHovered => { + emit('avatarHover', isHovered); +}; diff --git a/app/javascript/dashboard/components-next/Contacts/Pages/ContactDetails.vue b/app/javascript/dashboard/components-next/Contacts/Pages/ContactDetails.vue index aec21a976..b7014c42f 100644 --- a/app/javascript/dashboard/components-next/Contacts/Pages/ContactDetails.vue +++ b/app/javascript/dashboard/components-next/Contacts/Pages/ContactDetails.vue @@ -10,6 +10,7 @@ import Button from 'dashboard/components-next/button/Button.vue'; import ContactLabels from 'dashboard/components-next/Contacts/ContactLabels/ContactLabels.vue'; import ContactsForm from 'dashboard/components-next/Contacts/ContactsForm/ContactsForm.vue'; import ConfirmContactDeleteDialog from 'dashboard/components-next/Contacts/ContactsForm/ConfirmContactDeleteDialog.vue'; +import Policy from 'dashboard/components/policy.vue'; const props = defineProps({ selectedContact: { @@ -174,27 +175,29 @@ const handleAvatarDelete = async () => { @click="updateContact" /> -
-
-
- {{ t('CONTACTS_LAYOUT.DETAILS.DELETE_CONTACT') }} -
- - {{ t('CONTACTS_LAYOUT.DETAILS.DELETE_CONTACT_DESCRIPTION') }} - + +
+
+
+ {{ t('CONTACTS_LAYOUT.DETAILS.DELETE_CONTACT') }} +
+ + {{ t('CONTACTS_LAYOUT.DETAILS.DELETE_CONTACT_DESCRIPTION') }} + +
+
-
- +
diff --git a/app/javascript/dashboard/components-next/Contacts/Pages/ContactsList.vue b/app/javascript/dashboard/components-next/Contacts/Pages/ContactsList.vue index 7acc2ff55..72bc2c7dd 100644 --- a/app/javascript/dashboard/components-next/Contacts/Pages/ContactsList.vue +++ b/app/javascript/dashboard/components-next/Contacts/Pages/ContactsList.vue @@ -10,7 +10,15 @@ import { } from 'shared/helpers/CustomErrors'; import ContactsCard from 'dashboard/components-next/Contacts/ContactsCard/ContactsCard.vue'; -defineProps({ contacts: { type: Array, required: true } }); +const props = defineProps({ + contacts: { type: Array, required: true }, + selectedContactIds: { + type: Array, + default: () => [], + }, +}); + +const emit = defineEmits(['toggleContact']); const { t } = useI18n(); const store = useStore(); @@ -20,6 +28,9 @@ const route = useRoute(); const uiFlags = useMapGetter('contacts/getUIFlags'); const isUpdating = computed(() => uiFlags.value.isUpdating); const expandedCardId = ref(null); +const hoveredAvatarId = ref(null); + +const selectedIdsSet = computed(() => new Set(props.selectedContactIds || [])); const updateContact = async updatedData => { try { @@ -58,25 +69,43 @@ const onClickViewDetails = async id => { const toggleExpanded = id => { expandedCardId.value = expandedCardId.value === id ? null : id; }; + +const isSelected = id => selectedIdsSet.value.has(id); + +const shouldShowSelection = id => { + return hoveredAvatarId.value === id || isSelected(id); +}; + +const handleSelect = (id, value) => { + emit('toggleContact', { id, value }); +}; + +const handleAvatarHover = (id, isHovered) => { + hoveredAvatarId.value = isHovered ? id : null; +}; diff --git a/app/javascript/dashboard/components-next/EmptyStateLayout.vue b/app/javascript/dashboard/components-next/EmptyStateLayout.vue index f2744dde5..39eda2d85 100644 --- a/app/javascript/dashboard/components-next/EmptyStateLayout.vue +++ b/app/javascript/dashboard/components-next/EmptyStateLayout.vue @@ -14,6 +14,10 @@ defineProps({ type: Array, default: () => [], }, + showBackdrop: { + type: Boolean, + default: true, + }, }); @@ -25,14 +29,24 @@ defineProps({ class="relative w-full max-w-[60rem] mx-auto overflow-hidden h-full max-h-[28rem]" >
-
+

{{ subtitle }} diff --git a/app/javascript/dashboard/components-next/HelpCenter/Pages/PortalSettingsPage/PortalBaseSettings.vue b/app/javascript/dashboard/components-next/HelpCenter/Pages/PortalSettingsPage/PortalBaseSettings.vue index f74bf95e2..b99f08a29 100644 --- a/app/javascript/dashboard/components-next/HelpCenter/Pages/PortalSettingsPage/PortalBaseSettings.vue +++ b/app/javascript/dashboard/components-next/HelpCenter/Pages/PortalSettingsPage/PortalBaseSettings.vue @@ -51,12 +51,20 @@ const originalState = reactive({ ...state }); const liveChatWidgets = computed(() => { const inboxes = store.getters['inboxes/getInboxes']; - return inboxes + const widgetOptions = inboxes .filter(inbox => inbox.channel_type === 'Channel::WebWidget') .map(inbox => ({ value: inbox.id, label: inbox.name, })); + + return [ + { + value: '', + label: t('HELP_CENTER.PORTAL_SETTINGS.FORM.LIVE_CHAT_WIDGET.NONE_OPTION'), + }, + ...widgetOptions, + ]; }); const rules = { @@ -108,7 +116,7 @@ watch( widgetColor: newVal.color, homePageLink: newVal.homepage_link, slug: newVal.slug, - liveChatWidgetInboxId: newVal.inbox?.id, + liveChatWidgetInboxId: newVal.inbox?.id || '', }); if (newVal.logo) { const { diff --git a/app/javascript/dashboard/components-next/Label/LabelItem.vue b/app/javascript/dashboard/components-next/Label/LabelItem.vue index 65c2600b1..e0917f954 100644 --- a/app/javascript/dashboard/components-next/Label/LabelItem.vue +++ b/app/javascript/dashboard/components-next/Label/LabelItem.vue @@ -15,7 +15,7 @@ const props = defineProps({ const emit = defineEmits(['remove', 'hover']); const handleRemoveLabel = () => { - emit('remove', props.label?.id); + emit('remove', props.label); }; const handleMouseEnter = () => { @@ -45,6 +45,7 @@ const handleMouseEnter = () => {

+
diff --git a/app/javascript/dashboard/components-next/captain/assistant/BulkSelectBar.vue b/app/javascript/dashboard/components-next/captain/assistant/BulkSelectBar.vue index 94cab9a33..0bf2007ff 100644 --- a/app/javascript/dashboard/components-next/captain/assistant/BulkSelectBar.vue +++ b/app/javascript/dashboard/components-next/captain/assistant/BulkSelectBar.vue @@ -64,20 +64,23 @@ const bulkCheckboxState = computed({ class="flex items-center gap-3 py-1 ltr:pl-3 rtl:pr-3 ltr:pr-4 rtl:pl-4 rounded-lg bg-n-solid-2 outline outline-1 outline-n-container shadow" >
-
+
- + {{ selectAllLabel }}
- + {{ selectedCountLabel }} +
+
-
+ +
+
- - + + {{ assistant?.name || '' }} + +
+ + + + {{ documentable.name }} - - - {{ documentable.available_name }} - - - - + {{ t(`CAPTAIN.RESPONSES.DOCUMENTABLE.CONVERSATION`, { id: documentable.display_id, }) }} - - +
- - {{ t(`CAPTAIN.RESPONSES.STATUS.${status.toUpperCase()}`) }} + + {{ timestamp }}
-
- - {{ timestamp }} -
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 @@ + + +