diff --git a/.circleci/config.yml b/.circleci/config.yml index 99795db91..65ceda04c 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -1,6 +1,7 @@ version: 2.1 orbs: node: circleci/node@6.1.0 + qlty-orb: qltysh/qlty-orb@0.0 defaults: &defaults working_directory: ~/build @@ -89,14 +90,6 @@ jobs: command: | source ~/.rvm/scripts/rvm bundle install - # pnpm install - - - run: - name: Download cc-test-reporter - command: | - mkdir -p ~/tmp - curl -L https://codeclimate.com/downloads/test-reporter/test-reporter-latest-linux-amd64 > ~/tmp/cc-test-reporter - chmod +x ~/tmp/cc-test-reporter # Swagger verification - run: @@ -108,10 +101,11 @@ jobs: echo "ERROR: The swagger.json file is not in sync with the yaml specification. Run 'rake swagger:build' and commit 'swagger/swagger.json'." exit 1 fi + mkdir -p ~/tmp curl -L https://repo1.maven.org/maven2/org/openapitools/openapi-generator-cli/6.3.0/openapi-generator-cli-6.3.0.jar > ~/tmp/openapi-generator-cli-6.3.0.jar java -jar ~/tmp/openapi-generator-cli-6.3.0.jar validate -i swagger/swagger.json - # we remove the FRONTED_URL from the .env before running the tests + # Configure environment and database - run: name: Database Setup and Configure Environment Variables command: | @@ -149,17 +143,11 @@ jobs: command: pnpm run eslint - run: - name: Run frontend tests + name: Run frontend tests (with coverage) command: | mkdir -p ~/build/coverage/frontend - ~/tmp/cc-test-reporter before-build pnpm run test:coverage - - run: - name: Code Climate Test Coverage (Frontend) - command: | - ~/tmp/cc-test-reporter format-coverage -t lcov -o "~/build/coverage/frontend/codeclimate.frontend_$CIRCLE_NODE_INDEX.json" - # Run backend tests - run: name: Run backend tests @@ -167,18 +155,18 @@ jobs: mkdir -p ~/tmp/test-results/rspec mkdir -p ~/tmp/test-artifacts mkdir -p ~/build/coverage/backend - ~/tmp/cc-test-reporter before-build TESTFILES=$(circleci tests glob "spec/**/*_spec.rb" | circleci tests split --split-by=timings) - bundle exec rspec --format progress \ + bundle exec rspec -I ./spec --require coverage_helper --require spec_helper --format progress \ --format RspecJunitFormatter \ --out ~/tmp/test-results/rspec.xml \ -- ${TESTFILES} no_output_timeout: 30m - - run: - name: Code Climate Test Coverage (Backend) - command: | - ~/tmp/cc-test-reporter format-coverage -t simplecov -o "~/build/coverage/backend/codeclimate.$CIRCLE_NODE_INDEX.json" + # Qlty coverage publish + - qlty-orb/coverage_publish: + files: | + coverage/coverage.json + coverage/lcov.info - run: name: List coverage directory contents @@ -189,3 +177,7 @@ jobs: root: ~/build paths: - coverage + + - store_artifacts: + path: coverage + destination: coverage diff --git a/.env.example b/.env.example index 2ab2933dc..de671599c 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 diff --git a/.github/workflows/run_mfa_spec.yml b/.github/workflows/run_mfa_spec.yml new file mode 100644 index 000000000..61b406f8a --- /dev/null +++ b/.github/workflows/run_mfa_spec.yml @@ -0,0 +1,99 @@ +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 \ + --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/AGENTS.md b/AGENTS.md index ad374799c..e3b022a2e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -55,4 +55,21 @@ ## Ruby Best Practices -- Use compact `module/class` definitions; avoid nested styles \ No newline at end of file +- Use compact `module/class` definitions; avoid nested styles + +## Enterprise Edition Notes + +- Chatwoot has an Enterprise overlay under `enterprise/` that extends/overrides OSS code. +- When you add or modify core functionality, always check for corresponding files in `enterprise/` and keep behavior compatible. +- Follow the Enterprise development practices documented here: + - https://chatwoot.help/hc/handbook/articles/developing-enterprise-edition-features-38 + +Practical checklist for any change impacting core logic or public APIs +- Search for related files in both trees before editing (e.g., `rg -n "FooService|ControllerName|ModelName" app enterprise`). +- If adding new endpoints, services, or models, consider whether Enterprise needs: + - An override (e.g., `enterprise/app/...`), or + - An extension point (e.g., `prepend_mod_with`, hooks, configuration) to avoid hard forks. +- Avoid hardcoding instance- or plan-specific behavior in OSS; prefer configuration, feature flags, or extension points consumed by Enterprise. +- Keep request/response contracts stable across OSS and Enterprise; update both sets of routes/controllers when introducing new APIs. +- When renaming/moving shared code, mirror the change in `enterprise/` to prevent drift. +- Tests: Add Enterprise-specific specs under `spec/enterprise`, mirroring OSS spec layout where applicable. diff --git a/Gemfile b/Gemfile index 9929575d4..265c609c1 100644 --- a/Gemfile +++ b/Gemfile @@ -62,6 +62,10 @@ gem 'redis-namespace' # super fast record imports in bulk gem 'activerecord-import' +gem 'searchkick' +gem 'opensearch-ruby' +gem 'faraday_middleware-aws-sigv4' + ##--- gems for server & infra configuration ---## gem 'dotenv-rails', '>= 3.0.0' gem 'foreman' @@ -74,9 +78,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' @@ -89,7 +96,7 @@ gem 'wisper', '2.0.0' ##--- gems for channels ---## gem 'facebook-messenger' gem 'line-bot-api' -gem 'twilio-ruby', '~> 5.66' +gem 'twilio-ruby' # twitty will handle subscription of twitter account events # gem 'twitty', git: 'https://github.com/chatwoot/twitty' gem 'twitty', '~> 0.1.5' @@ -108,7 +115,7 @@ gem 'google-cloud-translate-v3', '>= 0.7.0' ##-- apm and error monitoring ---# # loaded only when environment variables are set. # ref application.rb -gem 'ddtrace', require: false +gem 'datadog', '~> 2.0', require: false gem 'elastic-apm', require: false gem 'newrelic_rpm', require: false gem 'newrelic-sidekiq-metrics', '>= 1.6.2', require: false @@ -167,6 +174,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' @@ -212,6 +220,8 @@ group :development do gem 'stackprof' # Should install the associated chrome extension to view query logs gem 'meta_request', '>= 0.8.3' + + gem 'tidewave' end group :test do @@ -221,6 +231,7 @@ group :test do gem 'webmock' # test profiling gem 'test-prof' + gem 'simplecov_json_formatter', require: false end group :development, :test do @@ -245,7 +256,7 @@ group :development, :test do gem 'rubocop-factory_bot', require: false gem 'seed_dump' gem 'shoulda-matchers' - gem 'simplecov', '0.17.1', require: false + gem 'simplecov', '>= 0.21', require: false gem 'spring' gem 'spring-watcher-listen' end diff --git a/Gemfile.lock b/Gemfile.lock index 8a0eb4c48..16e57d4f8 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -194,10 +194,14 @@ GEM activerecord (>= 5.a) database_cleaner-core (~> 2.0.0) database_cleaner-core (2.0.1) - date (3.4.1) - ddtrace (0.48.0) - ffi (~> 1.0) + datadog (2.19.0) + datadog-ruby_core_source (~> 3.4, >= 3.4.1) + libdatadog (~> 18.1.0.1.0) + libddwaf (~> 1.24.1.0.3) + logger msgpack + datadog-ruby_core_source (3.4.1) + date (3.4.1) debug (1.8.0) irb (>= 1.5.0) reline (>= 0.3.1) @@ -208,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) @@ -215,7 +224,7 @@ GEM diff-lcs (1.5.1) digest-crc (0.6.5) rake (>= 12.0.0, < 14.0.0) - docile (1.4.0) + docile (1.4.1) domain_name (0.5.20190701) unf (>= 0.0.5, < 1.0.0) dotenv (3.1.2) @@ -226,6 +235,35 @@ GEM addressable (~> 2.8) drb (2.2.3) dry-cli (1.1.0) + dry-configurable (1.3.0) + dry-core (~> 1.1) + zeitwerk (~> 2.6) + dry-core (1.1.0) + concurrent-ruby (~> 1.0) + logger + zeitwerk (~> 2.6) + dry-inflector (1.2.0) + dry-initializer (3.2.0) + dry-logic (1.6.0) + bigdecimal + concurrent-ruby (~> 1.0) + dry-core (~> 1.1) + zeitwerk (~> 2.6) + dry-schema (1.14.1) + concurrent-ruby (~> 1.0) + dry-configurable (~> 1.0, >= 1.0.1) + dry-core (~> 1.1) + dry-initializer (~> 3.2) + dry-logic (~> 1.5) + dry-types (~> 1.8) + zeitwerk (~> 2.6) + dry-types (1.8.3) + bigdecimal (~> 3.0) + concurrent-ruby (~> 1.0) + dry-core (~> 1.0) + dry-inflector (~> 1.0) + dry-logic (~> 1.4) + zeitwerk (~> 2.6) ecma-re-validator (0.4.0) regexp_parser (~> 2.2) elastic-apm (4.6.2) @@ -248,8 +286,10 @@ GEM railties (>= 5.0.0) faker (3.2.0) i18n (>= 1.8.11, < 2) - faraday (2.9.0) - faraday-net_http (>= 2.0, < 3.2) + faraday (2.13.1) + faraday-net_http (>= 2.0, < 3.5) + json + logger faraday-follow_redirects (0.3.0) faraday (>= 1, < 3) faraday-mashify (0.1.1) @@ -257,13 +297,23 @@ GEM hashie faraday-multipart (1.0.4) multipart-post (~> 2) - faraday-net_http (3.1.0) - net-http + faraday-net_http (3.4.0) + net-http (>= 0.5.0) faraday-net_http_persistent (2.1.0) faraday (~> 2.5) net-http-persistent (~> 4.0) faraday-retry (2.2.1) faraday (~> 2.0) + faraday_middleware-aws-sigv4 (1.0.1) + aws-sigv4 (~> 1.0) + faraday (>= 2.0, < 3) + fast-mcp (1.5.0) + addressable (~> 2.8) + base64 + dry-schema (~> 1.14) + json (~> 2.0) + mime-types (~> 3.4) + rack (~> 3.1) fcm (1.0.8) faraday (>= 1.0.0, < 3.0) googleauth (~> 1) @@ -402,7 +452,7 @@ GEM rails-dom-testing (>= 1, < 3) railties (>= 4.2.0) thor (>= 0.14, < 2.0) - json (2.12.0) + json (2.13.2) json_refs (0.1.8) hana json_schemer (0.2.24) @@ -417,7 +467,7 @@ GEM judoscale-sidekiq (1.8.2) judoscale-ruby (= 1.8.2) sidekiq (>= 5.0) - jwt (2.8.1) + jwt (2.10.1) base64 kaminari (1.2.2) activesupport (>= 4.1.0) @@ -444,6 +494,16 @@ GEM logger (~> 1.6) letter_opener (1.10.0) launchy (>= 2.2, < 4) + libdatadog (18.1.0.1.0) + libdatadog (18.1.0.1.0-x86_64-linux) + libddwaf (1.24.1.0.3) + ffi (~> 1.0) + libddwaf (1.24.1.0.3-arm64-darwin) + ffi (~> 1.0) + libddwaf (1.24.1.0.3-x86_64-darwin) + ffi (~> 1.0) + libddwaf (1.24.1.0.3-x86_64-linux) + ffi (~> 1.0) line-bot-api (1.28.0) lint_roller (1.1.0) liquid (5.4.0) @@ -489,7 +549,7 @@ GEM mutex_m (0.3.0) neighbor (0.2.3) activerecord (>= 5.2) - net-http (0.4.1) + net-http (0.6.0) uri net-http-persistent (4.0.2) connection_pool (~> 2.2) @@ -534,8 +594,9 @@ GEM oj (3.16.10) bigdecimal (>= 3.0) ostruct (>= 0.2) - omniauth (2.1.2) + omniauth (2.1.3) hashie (>= 3.4.6) + logger rack (>= 2.2.3) rack-protection omniauth-google-oauth2 (1.1.3) @@ -549,6 +610,12 @@ 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) openssl (3.2.0) orm_adapter (0.5.0) os (1.1.4) @@ -577,7 +644,7 @@ GEM activesupport (>= 3.0.0) raabro (1.4.0) racc (1.8.1) - rack (2.2.15) + rack (3.2.0) rack-attack (6.7.0) rack (>= 1.0, < 4) rack-contrib (2.5.0) @@ -586,19 +653,20 @@ GEM rack (>= 2.0.0) rack-mini-profiler (3.2.0) rack (>= 1.2.0) - rack-protection (3.2.0) + rack-protection (4.1.1) base64 (>= 0.1.0) - rack (~> 2.2, >= 2.2.4) + logger (>= 1.6.0) + rack (>= 3.0.0, < 4) rack-proxy (0.7.7) rack - rack-session (1.0.2) - rack (< 3) + rack-session (2.1.1) + base64 (>= 0.1.0) + rack (>= 3.0.0) rack-test (2.1.0) rack (>= 1.3) rack-timeout (0.6.3) - rackup (1.0.1) - rack (< 3) - webrick + rackup (2.2.1) + rack (>= 3) rails (7.1.5.2) actioncable (= 7.1.5.2) actionmailbox (= 7.1.5.2) @@ -659,7 +727,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) @@ -714,6 +783,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) @@ -749,6 +821,9 @@ GEM parser scss_lint (0.60.0) sass (~> 3.5, >= 3.5.5) + searchkick (5.5.2) + activemodel (>= 7.1) + hashie securerandom (0.4.1) seed_dump (3.3.1) activerecord (>= 4) @@ -795,11 +870,12 @@ GEM faraday (>= 0.17.5, < 3.a) jwt (>= 1.5, < 3.0) multi_json (~> 1.10) - simplecov (0.17.1) + simplecov (0.22.0) docile (~> 1.1) - json (>= 1.8, < 3) - simplecov-html (~> 0.10.0) - simplecov-html (0.10.2) + simplecov-html (~> 0.11) + 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) faraday-mashify @@ -829,13 +905,17 @@ GEM telephone_number (1.4.20) test-prof (1.2.1) thor (1.4.0) + tidewave (0.2.0) + fast-mcp (~> 1.5.0) + rack (>= 2.0) + rails (>= 7.1.0) tilt (2.3.0) time_diff (0.3.0) activesupport i18n timeout (0.4.3) trailblazer-option (0.1.2) - twilio-ruby (5.77.0) + twilio-ruby (7.6.0) faraday (>= 0.9, < 3.0) jwt (>= 1.5, < 3.0) nokogiri (>= 1.6, < 2.0) @@ -882,7 +962,6 @@ GEM addressable (>= 2.8.0) crack (>= 0.3.2) hashdiff (>= 0.4.0, < 2.0.0) - webrick (1.9.1) websocket-driver (0.7.7) base64 websocket-extensions (>= 0.1.0) @@ -928,10 +1007,11 @@ DEPENDENCIES commonmarker csv-safe database_cleaner - ddtrace + datadog (~> 2.0) 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 @@ -940,6 +1020,7 @@ DEPENDENCIES facebook-messenger factory_bot_rails (>= 6.4.3) faker + faraday_middleware-aws-sigv4 fcm flag_shih_tzu foreman @@ -980,6 +1061,8 @@ 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 pgvector @@ -1008,6 +1091,7 @@ DEPENDENCIES ruby_llm-schema scout_apm scss_lint + searchkick seed_dump sentry-rails (>= 5.19.0) sentry-ruby @@ -1017,7 +1101,8 @@ DEPENDENCIES sidekiq (>= 7.3.1) sidekiq-cron (>= 1.12.0) sidekiq_alive - simplecov (= 0.17.1) + simplecov (>= 0.21) + simplecov_json_formatter slack-ruby-client (~> 2.5.2) spring spring-watcher-listen @@ -1026,8 +1111,9 @@ DEPENDENCIES stripe telephone_number test-prof + tidewave time_diff - twilio-ruby (~> 5.66) + twilio-ruby twitty (~> 0.1.5) tzinfo-data uglifier diff --git a/Rakefile b/Rakefile index e85f91391..2e996417e 100644 --- a/Rakefile +++ b/Rakefile @@ -2,5 +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 diff --git a/VERSION_CWCTL b/VERSION_CWCTL index 4d9d11cf5..6cb9d3dd0 100644 --- a/VERSION_CWCTL +++ b/VERSION_CWCTL @@ -1 +1 @@ -3.4.2 +3.4.3 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/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/assignment_policies/inboxes_controller.rb b/app/controllers/api/v1/accounts/assignment_policies/inboxes_controller.rb new file mode 100644 index 000000000..ac1d0a712 --- /dev/null +++ b/app/controllers/api/v1/accounts/assignment_policies/inboxes_controller.rb @@ -0,0 +1,20 @@ +class Api::V1::Accounts::AssignmentPolicies::InboxesController < Api::V1::Accounts::BaseController + before_action :fetch_assignment_policy + before_action -> { check_authorization(AssignmentPolicy) } + + def index + @inboxes = @assignment_policy.inboxes + end + + private + + def fetch_assignment_policy + @assignment_policy = Current.account.assignment_policies.find( + params[:assignment_policy_id] + ) + end + + def permitted_params + params.permit(:assignment_policy_id) + end +end diff --git a/app/controllers/api/v1/accounts/assignment_policies_controller.rb b/app/controllers/api/v1/accounts/assignment_policies_controller.rb new file mode 100644 index 000000000..1807d6afb --- /dev/null +++ b/app/controllers/api/v1/accounts/assignment_policies_controller.rb @@ -0,0 +1,36 @@ +class Api::V1::Accounts::AssignmentPoliciesController < Api::V1::Accounts::BaseController + before_action :fetch_assignment_policy, only: [:show, :update, :destroy] + before_action :check_authorization + + def index + @assignment_policies = Current.account.assignment_policies + end + + def show; end + + def create + @assignment_policy = Current.account.assignment_policies.create!(assignment_policy_params) + end + + def update + @assignment_policy.update!(assignment_policy_params) + end + + def destroy + @assignment_policy.destroy! + head :ok + end + + private + + def fetch_assignment_policy + @assignment_policy = Current.account.assignment_policies.find(params[:id]) + end + + def assignment_policy_params + params.require(:assignment_policy).permit( + :name, :description, :assignment_order, :conversation_priority, + :fair_distribution_limit, :fair_distribution_window, :enabled + ) + end +end diff --git a/app/controllers/api/v1/accounts/inboxes/assignment_policies_controller.rb b/app/controllers/api/v1/accounts/inboxes/assignment_policies_controller.rb new file mode 100644 index 000000000..cf52951a5 --- /dev/null +++ b/app/controllers/api/v1/accounts/inboxes/assignment_policies_controller.rb @@ -0,0 +1,46 @@ +class Api::V1::Accounts::Inboxes::AssignmentPoliciesController < Api::V1::Accounts::BaseController + before_action :fetch_inbox + before_action :fetch_assignment_policy, only: [:create] + before_action -> { check_authorization(AssignmentPolicy) } + before_action :validate_assignment_policy, only: [:show, :destroy] + + def show + @assignment_policy = @inbox.assignment_policy + end + + def create + # There should be only one assignment policy for an inbox. + # If there is a new request to add an assignment policy, we will + # delete the old one and attach the new policy + remove_inbox_assignment_policy + @inbox_assignment_policy = @inbox.create_inbox_assignment_policy!(assignment_policy: @assignment_policy) + @assignment_policy = @inbox.assignment_policy + end + + def destroy + remove_inbox_assignment_policy + head :ok + end + + private + + def remove_inbox_assignment_policy + @inbox.inbox_assignment_policy&.destroy + end + + def fetch_inbox + @inbox = Current.account.inboxes.find(permitted_params[:inbox_id]) + end + + def fetch_assignment_policy + @assignment_policy = Current.account.assignment_policies.find(permitted_params[:assignment_policy_id]) + end + + def permitted_params + params.permit(:assignment_policy_id, :inbox_id) + end + + def validate_assignment_policy + return render_not_found_error(I18n.t('errors.assignment_policy.not_found')) unless @inbox.assignment_policy + end +end diff --git a/app/controllers/api/v1/accounts/inboxes_controller.rb b/app/controllers/api/v1/accounts/inboxes_controller.rb index 78b4b9e2f..4750e3b4a 100644 --- a/app/controllers/api/v1/accounts/inboxes_controller.rb +++ b/app/controllers/api/v1/accounts/inboxes_controller.rb @@ -70,11 +70,9 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController end def sync_templates - unless @inbox.channel.is_a?(Channel::Whatsapp) - return render status: :unprocessable_entity, json: { error: 'Template sync is only available for WhatsApp channels' } - end + return render status: :unprocessable_entity, json: { error: 'Template sync is only available for WhatsApp channels' } unless whatsapp_channel? - Channels::Whatsapp::TemplatesSyncJob.perform_later(@inbox.channel) + trigger_template_sync render status: :ok, json: { message: 'Template sync initiated successfully' } rescue StandardError => e render status: :internal_server_error, json: { error: e.message } @@ -185,6 +183,18 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController [] end end + + def whatsapp_channel? + @inbox.whatsapp? || (@inbox.twilio? && @inbox.channel.whatsapp?) + end + + def trigger_template_sync + if @inbox.whatsapp? + Channels::Whatsapp::TemplatesSyncJob.perform_later(@inbox.channel) + elsif @inbox.twilio? && @inbox.channel.whatsapp? + Channels::Twilio::TemplatesSyncJob.perform_later(@inbox.channel) + end + end end Api::V1::Accounts::InboxesController.prepend_mod_with('Api::V1::Accounts::InboxesController') diff --git a/app/controllers/api/v1/accounts/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/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..fd3dba87c 100644 --- a/app/controllers/devise_overrides/omniauth_callbacks_controller.rb +++ b/app/controllers/devise_overrides/omniauth_callbacks_controller.rb @@ -47,10 +47,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 +73,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..bf3a7f221 100644 --- a/app/controllers/devise_overrides/sessions_controller.rb +++ b/app/controllers/devise_overrides/sessions_controller.rb @@ -9,13 +9,11 @@ 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 + return handle_mfa_verification if mfa_verification_request? + return handle_sso_authentication if sso_authentication_request? + + super do |resource| + return handle_mfa_required(resource) if resource&.mfa_enabled? end end @@ -25,6 +23,20 @@ class DeviseOverrides::SessionsController < DeviseTokenAuth::SessionsController private + 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 +58,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(resource) + render json: { + mfa_required: true, + mfa_token: Mfa::TokenService.new(user: resource).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/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/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/helpers/portal_helper.rb b/app/helpers/portal_helper.rb index 3ed303556..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? @@ -74,6 +75,17 @@ module PortalHelper end end + def generate_portal_brand_url(brand_url, referer) + url = URI.parse(brand_url.to_s) + 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 url_valid?(referer) + + url.query = query_params.to_query + url.to_s + end + def render_category_content(content) ChatwootMarkdownRenderer.new(content).render_markdown_to_plain_text end 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/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/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/ContactsDetailsLayout.vue b/app/javascript/dashboard/components-next/Contacts/ContactsDetailsLayout.vue index bb4327816..4c7b9249e 100644 --- a/app/javascript/dashboard/components-next/Contacts/ContactsDetailsLayout.vue +++ b/app/javascript/dashboard/components-next/Contacts/ContactsDetailsLayout.vue @@ -1,11 +1,13 @@