diff --git a/.circleci/config.yml b/.circleci/config.yml index fbb4fea90..e6394ccde 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -7,7 +7,7 @@ defaults: &defaults working_directory: ~/build docker: # specify the version you desire here - - image: cimg/ruby:3.0.4-browsers + - image: cimg/ruby:3.1.3-browsers # Specify service dependencies here if necessary # CircleCI maintains a library of pre-built images @@ -38,6 +38,18 @@ jobs: name: Which bundler? command: bundle -v + - run: + name: Swap node versions + command: | + set +e + wget -qO- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.1/install.sh | bash + export NVM_DIR="$HOME/.nvm" + [ -s "$NVM_DIR/nvm.sh" ] && \. "$NVM_DIR/nvm.sh" + [ -s "$NVM_DIR/bash_completion" ] && \. "$NVM_DIR/bash_completion" + nvm install v16 + echo 'export NVM_DIR="$HOME/.nvm"' >> $BASH_ENV + echo '[ -s "$NVM_DIR/nvm.sh" ] && \. "$NVM_DIR/nvm.sh"' >> $BASH_ENV + # Run bundler # Load installed gems from cache if possible, bundle install then save cache # Multiple caches are used to increase the chance of a cache hit @@ -193,4 +205,3 @@ workflows: - upload-coverage: requires: - build - diff --git a/.env.example b/.env.example index 824a96285..149d1f6e6 100644 --- a/.env.example +++ b/.env.example @@ -131,6 +131,11 @@ TWITTER_ENVIRONMENT= SLACK_CLIENT_ID= SLACK_CLIENT_SECRET= +# Google OAuth +GOOGLE_OAUTH_CLIENT_ID= +GOOGLE_OAUTH_CLIENT_SECRET= +GOOGLE_OAUTH_CALLBACK_URL= + ### Change this env variable only if you are using a custom build mobile app ## Mobile app env variables IOS_APP_ID=L7YLMN4634.com.chatwoot.app diff --git a/.github/workflows/lock.yml b/.github/workflows/lock.yml index 790a322fd..c42909856 100644 --- a/.github/workflows/lock.yml +++ b/.github/workflows/lock.yml @@ -19,6 +19,7 @@ concurrency: jobs: action: runs-on: ubuntu-latest + if: ${{ github.repository == 'chatwoot/chatwoot' }} steps: - uses: dessant/lock-threads@v3 with: diff --git a/.github/workflows/run_foss_spec.yml b/.github/workflows/run_foss_spec.yml index 39df7aa3d..98fae5c8e 100644 --- a/.github/workflows/run_foss_spec.yml +++ b/.github/workflows/run_foss_spec.yml @@ -47,7 +47,6 @@ jobs: - uses: ruby/setup-ruby@v1 with: - ruby-version: 3.0.4 # Not needed with a .ruby-version file bundler-cache: true # runs 'bundle install' and caches installed gems automatically - name: yarn diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml new file mode 100644 index 000000000..7a7564ecb --- /dev/null +++ b/.github/workflows/stale.yml @@ -0,0 +1,28 @@ +# This workflow warns and then closes PRs that have had no activity for a specified amount of time. +# +# You can adjust the behavior by modifying this file. +# For more information, see: +# https://github.com/actions/stale +name: Mark stale issues and pull requests + +on: + schedule: + - cron: '28 3 * * *' + +jobs: + stale: + + runs-on: ubuntu-latest + permissions: + pull-requests: write + + steps: + - uses: actions/stale@v5 + with: + repo-token: ${{ secrets.GITHUB_TOKEN }} + days-before-issue-close: -1, + days-before-issue-stale: -1 + days-before-pr-close: -1, + days-before-pr-stale: 30, + stale-pr-message: '🐢 Turtley slow progress alert! This pull request has been idle for over 30 days. Can we please speed things up and either merge it or release it back into the wild?' + stale-pr-label: 'stale' diff --git a/.gitignore b/.gitignore index fc77a7b55..4182a0ac7 100644 --- a/.gitignore +++ b/.gitignore @@ -62,3 +62,7 @@ test/cypress/videos/* /config/*.enc .vscode/settings.json + +# yalc for local testing +.yalc +yalc.lock \ No newline at end of file diff --git a/.husky/pre-commit b/.husky/pre-commit index 7774f34f5..9984ac506 100755 --- a/.husky/pre-commit +++ b/.husky/pre-commit @@ -1,5 +1,11 @@ #!/bin/sh . "$(dirname "$0")/_/husky.sh" -npm run eslint -bundle exec rubocop -a -git add + +# lint js and vue files +npx --no-install lint-staged + +# lint only staged ruby files +git diff --name-only --cached | xargs ls -1 2>/dev/null | grep '\.rb$' | xargs bundle exec rubocop -a + +# stage rubocop changes to files +git diff --name-only --cached | xargs git add diff --git a/.rubocop.yml b/.rubocop.yml index dafd9a620..8b41dd5fd 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -17,7 +17,6 @@ Metrics/ClassLength: - 'app/builders/messages/facebook/message_builder.rb' - 'app/controllers/api/v1/accounts/contacts_controller.rb' - 'app/listeners/action_cable_listener.rb' - - 'app/models/conversation.rb' RSpec/ExampleLength: Max: 25 Style/Documentation: @@ -86,6 +85,10 @@ Style/ClassAndModuleChildren: - 'config/application.rb' Style/MapToHash: Enabled: false +Style/HashSyntax: + Enabled: true + EnforcedStyle: no_mixed_keys + EnforcedShorthandSyntax: never RSpec/NestedGroups: Enabled: true Max: 4 @@ -159,7 +162,7 @@ RSpec/NamedSubject: Enabled: false # we should bring this down RSpec/MultipleMemoizedHelpers: - Max: 12 + Max: 14 AllCops: NewCops: enable @@ -184,4 +187,3 @@ AllCops: - db/migrate/20200927135222_add_last_activity_at_to_conversation.rb - db/migrate/20210306170117_add_last_activity_at_to_contacts.rb - db/migrate/20220809104508_revert_cascading_indexes.rb - diff --git a/.rubocop_todo.yml b/.rubocop_todo.yml index 4d66828c3..48e714dd6 100644 --- a/.rubocop_todo.yml +++ b/.rubocop_todo.yml @@ -68,7 +68,6 @@ Naming/AccessorMethodName: - 'app/controllers/api/v1/accounts_controller.rb' - 'app/controllers/api/v1/callbacks_controller.rb' - 'app/controllers/api/v1/conversations_controller.rb' - - 'app/controllers/passwords_controller.rb' # Offense count: 9 # Configuration parameters: EnforcedStyleForLeadingUnderscores. diff --git a/.ruby-version b/.ruby-version index b0f2dcb32..ff365e06b 100644 --- a/.ruby-version +++ b/.ruby-version @@ -1 +1 @@ -3.0.4 +3.1.3 diff --git a/Gemfile b/Gemfile index 1d9c50cb6..011acf584 100644 --- a/Gemfile +++ b/Gemfile @@ -1,10 +1,10 @@ source 'https://rubygems.org' -ruby '3.0.4' +ruby '3.1.3' ##-- base gems for rails --## gem 'rack-cors', require: 'rack/cors' -gem 'rails', '~> 6.1', '>= 6.1.6.1' +gem 'rails', '~> 6.1', '>= 6.1.7.1' # Reduces boot times through caching; required in config/boot.rb gem 'bootsnap', require: false @@ -39,6 +39,10 @@ gem 'rack-attack' gem 'down', '~> 5.0' # authentication type to fetch and send mail over oauth2.0 gem 'gmail_xoauth' +# Prevent CSV injection +gem 'csv-safe' +# Support message translation +gem 'google-cloud-translate' ##-- for active storage --## gem 'aws-sdk-s3', require: false @@ -98,16 +102,16 @@ gem 'newrelic_rpm' gem 'scout_apm' gem 'sentry-rails', '~> 5.3', '>= 5.3.1' gem 'sentry-ruby', '~> 5.3' -gem 'sentry-sidekiq', '~> 5.3' +gem 'sentry-sidekiq', '~> 5.3', '>= 5.3.1' ##-- background job processing --## -gem 'sidekiq', '~> 6.4.0' +gem 'sidekiq', '~> 6.4.2' # We want cron jobs -gem 'sidekiq-cron', '~> 1.3' +gem 'sidekiq-cron', '~> 1.6', '>= 1.6.0' ##-- Push notification service --## gem 'fcm' -gem 'webpush' +gem 'web-push' ##-- geocoding / parse location from ip --## # http://www.rubygeocoder.com/ @@ -137,6 +141,11 @@ gem 'stripe' ## to populate db with sample data gem 'faker' +# Can remove this in rails 7 +gem 'net-imap', require: false +gem 'net-pop', require: false +gem 'net-smtp', require: false + group :production, :staging do # we dont want request timing out in development while using byebug gem 'rack-timeout' @@ -157,7 +166,7 @@ end group :test do # Cypress in rails. - gem 'cypress-on-rails', '~> 1.0' + gem 'cypress-on-rails', '~> 1.13', '>= 1.13.1' # fast cleaning of database gem 'database_cleaner' # mock http calls @@ -190,5 +199,11 @@ group :development, :test do gem 'spring' gem 'spring-watcher-listen' end + # worked with microsoft refresh token gem 'omniauth-oauth2' + +# need for google auth +gem 'omniauth' +gem 'omniauth-google-oauth2' +gem 'omniauth-rails_csrf_protection', '~> 1.0' diff --git a/Gemfile.lock b/Gemfile.lock index 4b805b109..112e09e7f 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -9,63 +9,63 @@ GIT GEM remote: https://rubygems.org/ specs: - actioncable (6.1.6.1) - actionpack (= 6.1.6.1) - activesupport (= 6.1.6.1) + actioncable (6.1.7.1) + actionpack (= 6.1.7.1) + activesupport (= 6.1.7.1) nio4r (~> 2.0) websocket-driver (>= 0.6.1) - actionmailbox (6.1.6.1) - actionpack (= 6.1.6.1) - activejob (= 6.1.6.1) - activerecord (= 6.1.6.1) - activestorage (= 6.1.6.1) - activesupport (= 6.1.6.1) + actionmailbox (6.1.7.1) + actionpack (= 6.1.7.1) + activejob (= 6.1.7.1) + activerecord (= 6.1.7.1) + activestorage (= 6.1.7.1) + activesupport (= 6.1.7.1) mail (>= 2.7.1) - actionmailer (6.1.6.1) - actionpack (= 6.1.6.1) - actionview (= 6.1.6.1) - activejob (= 6.1.6.1) - activesupport (= 6.1.6.1) + actionmailer (6.1.7.1) + actionpack (= 6.1.7.1) + actionview (= 6.1.7.1) + activejob (= 6.1.7.1) + activesupport (= 6.1.7.1) mail (~> 2.5, >= 2.5.4) rails-dom-testing (~> 2.0) - actionpack (6.1.6.1) - actionview (= 6.1.6.1) - activesupport (= 6.1.6.1) + actionpack (6.1.7.1) + actionview (= 6.1.7.1) + activesupport (= 6.1.7.1) rack (~> 2.0, >= 2.0.9) rack-test (>= 0.6.3) rails-dom-testing (~> 2.0) rails-html-sanitizer (~> 1.0, >= 1.2.0) - actiontext (6.1.6.1) - actionpack (= 6.1.6.1) - activerecord (= 6.1.6.1) - activestorage (= 6.1.6.1) - activesupport (= 6.1.6.1) + actiontext (6.1.7.1) + actionpack (= 6.1.7.1) + activerecord (= 6.1.7.1) + activestorage (= 6.1.7.1) + activesupport (= 6.1.7.1) nokogiri (>= 1.8.5) - actionview (6.1.6.1) - activesupport (= 6.1.6.1) + actionview (6.1.7.1) + activesupport (= 6.1.7.1) builder (~> 3.1) erubi (~> 1.4) rails-dom-testing (~> 2.0) rails-html-sanitizer (~> 1.1, >= 1.2.0) active_record_query_trace (1.8) - activejob (6.1.6.1) - activesupport (= 6.1.6.1) + activejob (6.1.7.1) + activesupport (= 6.1.7.1) globalid (>= 0.3.6) - activemodel (6.1.6.1) - activesupport (= 6.1.6.1) - activerecord (6.1.6.1) - activemodel (= 6.1.6.1) - activesupport (= 6.1.6.1) + activemodel (6.1.7.1) + activesupport (= 6.1.7.1) + activerecord (6.1.7.1) + activemodel (= 6.1.7.1) + activesupport (= 6.1.7.1) activerecord-import (1.4.0) activerecord (>= 4.2) - activestorage (6.1.6.1) - actionpack (= 6.1.6.1) - activejob (= 6.1.6.1) - activerecord (= 6.1.6.1) - activesupport (= 6.1.6.1) + activestorage (6.1.7.1) + actionpack (= 6.1.7.1) + activejob (= 6.1.7.1) + activerecord (= 6.1.7.1) + activesupport (= 6.1.7.1) marcel (~> 1.0) mini_mime (>= 1.1.0) - activesupport (6.1.6.1) + activesupport (6.1.7.1) concurrent-ruby (~> 1.0, >= 1.0.2) i18n (>= 1.6, < 2) minitest (>= 5.1) @@ -73,8 +73,8 @@ GEM zeitwerk (~> 2.3) acts-as-taggable-on (9.0.1) activerecord (>= 6.0, < 7.1) - addressable (2.8.0) - public_suffix (>= 2.0.2, < 5.0) + addressable (2.8.1) + public_suffix (>= 2.0.2, < 6.0) administrate (0.17.0) actionpack (>= 5.0) actionview (>= 5.0) @@ -135,12 +135,13 @@ GEM byebug (11.1.3) climate_control (1.1.1) coderay (1.1.3) - commonmarker (0.23.6) + commonmarker (0.23.7) concurrent-ruby (1.1.10) connection_pool (2.2.5) crack (0.4.5) rexml crass (1.0.6) + csv-safe (3.1.1) cypress-on-rails (1.13.1) rack database_cleaner (2.0.1) @@ -247,7 +248,7 @@ GEM grpc (~> 1.36) geocoder (1.8.0) gli (2.21.0) - globalid (1.0.0) + globalid (1.0.1) activesupport (>= 5.0) gmail_xoauth (0.4.2) oauth (>= 0.3.6) @@ -288,6 +289,19 @@ GEM google-cloud-core (~> 1.6) googleauth (>= 0.16.2, < 2.a) mini_mime (~> 1.0) + google-cloud-translate (3.3.0) + google-cloud-core (~> 1.6) + google-cloud-translate-v2 (>= 0.0, < 2.a) + google-cloud-translate-v3 (>= 0.0, < 2.a) + google-cloud-translate-v2 (0.4.0) + faraday (>= 0.17.3, < 2.a) + google-cloud-core (~> 1.6) + googleapis-common-protos (>= 1.3.10, < 2.a) + googleapis-common-protos-types (>= 1.0.5, < 2.a) + googleauth (>= 0.16.2, < 2.a) + google-cloud-translate-v3 (0.5.0) + gapic-common (>= 0.10, < 2.a) + google-cloud-errors (~> 1.0) google-protobuf (3.21.7) google-protobuf (3.21.7-x86_64-darwin) google-protobuf (3.21.7-x86_64-linux) @@ -323,7 +337,7 @@ GEM hana (1.3.7) hashdiff (1.0.1) hashie (5.0.0) - hkdf (0.3.0) + hkdf (1.0.0) html2text (0.2.1) nokogiri (~> 1.6) http (5.1.0) @@ -360,7 +374,7 @@ GEM hana (~> 1.3) regexp_parser (~> 2.0) uri_template (~> 0.7) - jwt (2.4.1) + jwt (2.5.0) kaminari (1.2.2) activesupport (>= 4.1.0) kaminari-actionview (= 1.2.2) @@ -426,8 +440,16 @@ GEM multipart-post (2.2.3) net-http-persistent (4.0.1) connection_pool (~> 2.2) + net-imap (0.3.1) + net-protocol + net-pop (0.1.2) + net-protocol + net-protocol (0.2.1) + timeout + net-smtp (0.3.3) + net-protocol netrc (0.11.0) - newrelic_rpm (8.9.0) + newrelic_rpm (8.15.0) nio4r (2.5.8) nokogiri (1.13.10) mini_portile2 (~> 2.8.0) @@ -450,9 +472,18 @@ GEM hashie (>= 3.4.6) rack (>= 2.2.3) rack-protection + omniauth-google-oauth2 (1.1.1) + jwt (>= 2.0) + oauth2 (~> 2.0.6) + omniauth (~> 2.0) + omniauth-oauth2 (~> 1.8.0) omniauth-oauth2 (1.8.0) oauth2 (>= 1.4, < 3) omniauth (~> 2.0) + omniauth-rails_csrf_protection (1.0.1) + actionpack (>= 4.2) + omniauth (~> 2.0) + openssl (3.1.0) orm_adapter (0.5.0) os (1.1.4) parallel (1.22.1) @@ -469,14 +500,14 @@ GEM method_source (~> 1.0) pry-rails (0.3.9) pry (>= 0.10.4) - public_suffix (4.0.7) + public_suffix (5.0.1) puma (5.6.4) nio4r (~> 2.0) pundit (2.2.0) activesupport (>= 3.0.0) raabro (1.4.0) racc (1.6.1) - rack (2.2.4) + rack (2.2.6.2) rack-attack (6.6.1) rack (>= 1.0, < 3) rack-cors (1.1.1) @@ -488,29 +519,29 @@ GEM rack-test (2.0.2) rack (>= 1.3) rack-timeout (0.6.3) - rails (6.1.6.1) - actioncable (= 6.1.6.1) - actionmailbox (= 6.1.6.1) - actionmailer (= 6.1.6.1) - actionpack (= 6.1.6.1) - actiontext (= 6.1.6.1) - actionview (= 6.1.6.1) - activejob (= 6.1.6.1) - activemodel (= 6.1.6.1) - activerecord (= 6.1.6.1) - activestorage (= 6.1.6.1) - activesupport (= 6.1.6.1) + rails (6.1.7.1) + actioncable (= 6.1.7.1) + actionmailbox (= 6.1.7.1) + actionmailer (= 6.1.7.1) + actionpack (= 6.1.7.1) + actiontext (= 6.1.7.1) + actionview (= 6.1.7.1) + activejob (= 6.1.7.1) + activemodel (= 6.1.7.1) + activerecord (= 6.1.7.1) + activestorage (= 6.1.7.1) + activesupport (= 6.1.7.1) bundler (>= 1.15.0) - railties (= 6.1.6.1) + railties (= 6.1.7.1) sprockets-rails (>= 2.0.0) rails-dom-testing (2.0.3) activesupport (>= 4.2.0) nokogiri (>= 1.6) rails-html-sanitizer (1.4.4) loofah (~> 2.19, >= 2.19.1) - railties (6.1.6.1) - actionpack (= 6.1.6.1) - activesupport (= 6.1.6.1) + railties (6.1.7.1) + actionpack (= 6.1.7.1) + activesupport (= 6.1.7.1) method_source rake (>= 12.2) thor (~> 1.0) @@ -662,6 +693,7 @@ GEM time_diff (0.3.0) activesupport i18n + timeout (0.3.1) trailblazer-option (0.1.2) twilio-ruby (5.68.0) faraday (>= 0.9, < 3.0) @@ -693,7 +725,11 @@ GEM activemodel (>= 6.0.0) bindex (>= 0.4.0) railties (>= 6.0.0) - webmock (3.14.0) + web-push (3.0.0) + hkdf (~> 1.0) + jwt (~> 2.0) + openssl (~> 3.0) + webmock (3.18.1) addressable (>= 2.8.0) crack (>= 0.3.2) hashdiff (>= 0.4.0, < 2.0.0) @@ -702,9 +738,6 @@ GEM rack-proxy (>= 0.6.1) railties (>= 5.2) semantic_range (>= 2.3.0) - webpush (1.1.0) - hkdf (~> 0.2) - jwt (~> 2.0) webrick (1.7.0) websocket-driver (0.7.5) websocket-extensions (>= 0.1.0) @@ -741,7 +774,8 @@ DEPENDENCIES byebug climate_control commonmarker - cypress-on-rails (~> 1.0) + csv-safe + cypress-on-rails (~> 1.13, >= 1.13.1) database_cleaner ddtrace devise @@ -761,6 +795,7 @@ DEPENDENCIES gmail_xoauth google-cloud-dialogflow google-cloud-storage + google-cloud-translate groupdate haikunator hairtrigger @@ -779,8 +814,14 @@ DEPENDENCIES listen maxminddb mock_redis + net-imap + net-pop + net-smtp newrelic_rpm + omniauth + omniauth-google-oauth2 omniauth-oauth2 + omniauth-rails_csrf_protection (~> 1.0) pg pg_search procore-sift @@ -790,7 +831,7 @@ DEPENDENCIES rack-attack rack-cors rack-timeout - rails (~> 6.1, >= 6.1.6.1) + rails (~> 6.1, >= 6.1.7.1) redis redis-namespace responders @@ -805,10 +846,10 @@ DEPENDENCIES seed_dump sentry-rails (~> 5.3, >= 5.3.1) sentry-ruby (~> 5.3) - sentry-sidekiq (~> 5.3) + sentry-sidekiq (~> 5.3, >= 5.3.1) shoulda-matchers - sidekiq (~> 6.4.0) - sidekiq-cron (~> 1.3) + sidekiq (~> 6.4.2) + sidekiq-cron (~> 1.6, >= 1.6.0) simplecov (= 0.17.1) slack-ruby-client spring @@ -824,14 +865,14 @@ DEPENDENCIES uglifier valid_email2 web-console + web-push webmock webpacker (~> 5.4, >= 5.4.3) - webpush wisper (= 2.0.0) working_hours RUBY VERSION - ruby 3.0.4p208 + ruby 3.1.3p185 BUNDLED WITH - 2.3.16 + 2.3.26 diff --git a/app/builders/messages/message_builder.rb b/app/builders/messages/message_builder.rb index 69ed786ce..ba5861859 100644 --- a/app/builders/messages/message_builder.rb +++ b/app/builders/messages/message_builder.rb @@ -48,13 +48,25 @@ class Messages::MessageBuilder def process_emails return unless @conversation.inbox&.inbox_type == 'Email' - cc_emails = @params[:cc_emails].split(',') if @params[:cc_emails] - bcc_emails = @params[:bcc_emails].split(',') if @params[:bcc_emails] + cc_emails = [] + cc_emails = @params[:cc_emails].split(',') if @params[:cc_emails].present? + + bcc_emails = [] + bcc_emails = @params[:bcc_emails].split(',') if @params[:bcc_emails].present? + + all_email_addresses = cc_emails + bcc_emails + validate_email_addresses(all_email_addresses) @message.content_attributes[:cc_emails] = cc_emails @message.content_attributes[:bcc_emails] = bcc_emails end + def validate_email_addresses(all_emails) + all_emails&.each do |email| + raise StandardError, 'Invalid email address' unless email.match?(URI::MailTo::EMAIL_REGEXP) + end + end + def message_type if @conversation.inbox.channel_type != 'Channel::Api' && @message_type == 'incoming' raise StandardError, 'Incoming messages are only allowed in Api inboxes' diff --git a/app/controllers/api/v1/accounts/articles_controller.rb b/app/controllers/api/v1/accounts/articles_controller.rb index a7891ffd4..b6e07be5f 100644 --- a/app/controllers/api/v1/accounts/articles_controller.rb +++ b/app/controllers/api/v1/accounts/articles_controller.rb @@ -1,14 +1,14 @@ class Api::V1::Accounts::ArticlesController < Api::V1::Accounts::BaseController before_action :portal before_action :check_authorization - before_action :fetch_article, except: [:index, :create] + before_action :fetch_article, except: [:index, :create, :attach_file] before_action :set_current_page, only: [:index] def index @portal_articles = @portal.articles @all_articles = @portal_articles.search(list_params) @articles_count = @all_articles.count - @articles = @all_articles.page(@current_page) + @articles = @all_articles.order_by_updated_at.page(@current_page) end def create @@ -23,7 +23,8 @@ class Api::V1::Accounts::ArticlesController < Api::V1::Accounts::BaseController def show; end def update - @article.update!(article_params) + @article.update!(article_params) if params[:article].present? + render json: { error: @article.errors.messages }, status: :unprocessable_entity and return unless @article.valid? end def destroy @@ -31,6 +32,17 @@ class Api::V1::Accounts::ArticlesController < Api::V1::Accounts::BaseController head :ok end + def attach_file + file_blob = ActiveStorage::Blob.create_and_upload!( + key: nil, + io: params[:background_image].tempfile, + filename: params[:background_image].original_filename, + content_type: params[:background_image].content_type + ) + file_blob.save! + render json: { file_url: url_for(file_blob) } + end + private def fetch_article @@ -43,7 +55,8 @@ class Api::V1::Accounts::ArticlesController < Api::V1::Accounts::BaseController def article_params params.require(:article).permit( - :title, :slug, :content, :description, :position, :category_id, :author_id, :associated_article_id, :status, meta: [:title, :description, + :title, :slug, :content, :description, :position, :category_id, :author_id, :associated_article_id, :status, meta: [:title, + :description, { tags: [] }] ) end diff --git a/app/controllers/api/v1/accounts/canned_responses_controller.rb b/app/controllers/api/v1/accounts/canned_responses_controller.rb index 031ffc415..5528610ec 100644 --- a/app/controllers/api/v1/accounts/canned_responses_controller.rb +++ b/app/controllers/api/v1/accounts/canned_responses_controller.rb @@ -33,7 +33,10 @@ class Api::V1::Accounts::CannedResponsesController < Api::V1::Accounts::BaseCont def canned_responses if params[:search] - Current.account.canned_responses.where('short_code ILIKE :search OR content ILIKE :search', search: "%#{params[:search]}%") + Current.account.canned_responses + .where('short_code ILIKE :search OR content ILIKE :search', search: "%#{params[:search]}%") + .order_by_search(params[:search]) + else Current.account.canned_responses end diff --git a/app/controllers/api/v1/accounts/conversations/messages_controller.rb b/app/controllers/api/v1/accounts/conversations/messages_controller.rb index 77a3a7081..319c6763f 100644 --- a/app/controllers/api/v1/accounts/conversations/messages_controller.rb +++ b/app/controllers/api/v1/accounts/conversations/messages_controller.rb @@ -18,6 +18,24 @@ class Api::V1::Accounts::Conversations::MessagesController < Api::V1::Accounts:: end end + def translate + return head :ok if already_translated_content_available? + + translated_content = Integrations::GoogleTranslate::ProcessorService.new( + message: message, + target_language: permitted_params[:target_language] + ).perform + + if translated_content.present? + translations = {} + translations[permitted_params[:target_language]] = translated_content + translations = message.translations.merge!(translations) if message.translations.present? + message.update!(translations: translations) + end + + render json: { content: translated_content } + end + private def message @@ -29,6 +47,10 @@ class Api::V1::Accounts::Conversations::MessagesController < Api::V1::Accounts:: end def permitted_params - params.permit(:id) + params.permit(:id, :target_language) + end + + def already_translated_content_available? + message.translations.present? && message.translations[permitted_params[:target_language]].present? end end diff --git a/app/controllers/api/v1/accounts/conversations/participants_controller.rb b/app/controllers/api/v1/accounts/conversations/participants_controller.rb new file mode 100644 index 000000000..ebd02380f --- /dev/null +++ b/app/controllers/api/v1/accounts/conversations/participants_controller.rb @@ -0,0 +1,41 @@ +class Api::V1::Accounts::Conversations::ParticipantsController < Api::V1::Accounts::Conversations::BaseController + def show + @participants = @conversation.conversation_participants + end + + def create + ActiveRecord::Base.transaction do + @participants = participants_to_be_added_ids.map { |user_id| @conversation.conversation_participants.find_or_create_by(user_id: user_id) } + end + end + + def update + ActiveRecord::Base.transaction do + participants_to_be_added_ids.each { |user_id| @conversation.conversation_participants.find_or_create_by(user_id: user_id) } + participants_to_be_removed_ids.each { |user_id| @conversation.conversation_participants.find_by(user_id: user_id)&.destroy } + end + @participants = @conversation.conversation_participants + render action: 'show' + end + + def destroy + ActiveRecord::Base.transaction do + params[:user_ids].map { |user_id| @conversation.conversation_participants.find_by(user_id: user_id)&.destroy } + end + head :ok + end + + private + + def participants_to_be_added_ids + params[:user_ids] - current_participant_ids + end + + def participants_to_be_removed_ids + current_participant_ids - params[:user_ids] + end + + def current_participant_ids + @current_participant_ids ||= @conversation.conversation_participants.pluck(:user_id) + end +end diff --git a/app/controllers/api/v1/accounts/inboxes_controller.rb b/app/controllers/api/v1/accounts/inboxes_controller.rb index 24507977e..24f797025 100644 --- a/app/controllers/api/v1/accounts/inboxes_controller.rb +++ b/app/controllers/api/v1/accounts/inboxes_controller.rb @@ -14,7 +14,7 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController # Deprecated: This API will be removed in 2.7.0 def assignable_agents - @assignable_agents = (Current.account.users.where(id: @inbox.members.select(:user_id)) + Current.account.administrators).uniq + @assignable_agents = @inbox.assignable_agents end def campaigns diff --git a/app/controllers/api/v1/accounts/portals_controller.rb b/app/controllers/api/v1/accounts/portals_controller.rb index d28ae54b7..b1837acbd 100644 --- a/app/controllers/api/v1/accounts/portals_controller.rb +++ b/app/controllers/api/v1/accounts/portals_controller.rb @@ -1,7 +1,7 @@ class Api::V1::Accounts::PortalsController < Api::V1::Accounts::BaseController include ::FileTypeHelper - before_action :fetch_portal, except: [:index, :create] + before_action :fetch_portal, except: [:index, :create, :attach_file] before_action :check_authorization before_action :set_current_page, only: [:index] @@ -48,7 +48,19 @@ class Api::V1::Accounts::PortalsController < Api::V1::Accounts::BaseController end def process_attached_logo - @portal.logo.attach(params[:logo]) + blob_id = params[:blob_id] + blob = ActiveStorage::Blob.find_by(id: blob_id) + @portal.logo.attach(blob) + end + + def attach_file + file_blob = ActiveStorage::Blob.create_and_upload!( + key: nil, + io: params[:logo].tempfile, + filename: params[:logo].original_filename, + content_type: params[:logo].content_type + ) + render json: { blob_key: file_blob.key, blob_id: file_blob.id } end private diff --git a/app/controllers/api_controller.rb b/app/controllers/api_controller.rb index a7764cbc9..23178b5f3 100644 --- a/app/controllers/api_controller.rb +++ b/app/controllers/api_controller.rb @@ -2,6 +2,24 @@ class ApiController < ApplicationController skip_before_action :set_current_user, only: [:index] def index - render json: { version: Chatwoot.config[:version], timestamp: Time.now.utc.to_formatted_s(:db) } + render json: { version: Chatwoot.config[:version], + timestamp: Time.now.utc.to_formatted_s(:db), + queue_services: redis_status, + data_services: postgres_status } + end + + private + + def redis_status + r = Redis.new(Redis::Config.app) + return 'ok' if r.ping + rescue Redis::CannotConnectError + 'failing' + end + + def postgres_status + ActiveRecord::Base.connection.active? ? 'ok' : 'failing' + rescue ActiveRecord::ConnectionNotEstablished + 'failing' end end diff --git a/app/controllers/concerns/switch_locale.rb b/app/controllers/concerns/switch_locale.rb index 5f308b8ab..744a70da9 100644 --- a/app/controllers/concerns/switch_locale.rb +++ b/app/controllers/concerns/switch_locale.rb @@ -3,25 +3,25 @@ module SwitchLocale private - def switch_locale(&action) + def switch_locale(&) # priority is for locale set in query string (mostly for widget/from js sdk) locale ||= locale_from_params # if locale is not set in account, let's use DEFAULT_LOCALE env variable locale ||= locale_from_env_variable - set_locale(locale, &action) + set_locale(locale, &) end - def switch_locale_using_account_locale(&action) + def switch_locale_using_account_locale(&) locale = locale_from_account(@current_account) - set_locale(locale, &action) + set_locale(locale, &) end - def set_locale(locale, &action) + def set_locale(locale, &) # if locale is empty, use default_locale locale ||= I18n.default_locale # Ensure locale won't bleed into other requests # https://guides.rubyonrails.org/i18n.html#managing-the-locale-across-requests - I18n.with_locale(locale, &action) + I18n.with_locale(locale, &) end def locale_from_params diff --git a/app/controllers/devise_overrides/omniauth_callbacks_controller.rb b/app/controllers/devise_overrides/omniauth_callbacks_controller.rb new file mode 100644 index 000000000..e1cf76d6b --- /dev/null +++ b/app/controllers/devise_overrides/omniauth_callbacks_controller.rb @@ -0,0 +1,75 @@ +class DeviseOverrides::OmniauthCallbacksController < DeviseTokenAuth::OmniauthCallbacksController + include EmailHelper + + def omniauth_success + get_resource_from_auth_hash + + @resource.present? ? sign_in_user : sign_up_user + end + + private + + def sign_in_user + @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) + redirect_to login_page_url(email: encoded_email, sso_auth_token: @resource.generate_sso_auth_token) + 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_business_account? + + create_account_for_user + token = @resource.send(:set_reset_password_token) + frontend_url = ENV.fetch('FRONTEND_URL', nil) + redirect_to "#{frontend_url}/app/auth/password/edit?config=default&reset_password_token=#{token}" + end + + def login_page_url(error: nil, email: nil, sso_auth_token: nil) + frontend_url = ENV.fetch('FRONTEND_URL', nil) + params = { email: email, sso_auth_token: sso_auth_token }.compact + params[:error] = error if error.present? + + "#{frontend_url}/app/login?#{params.to_query}" + end + + def account_signup_allowed? + # set it to true by default, this is the behaviour across the app + GlobalConfigService.load('ENABLE_ACCOUNT_SIGNUP', 'false') != 'false' + end + + def resource_class(_mapping = nil) + User + 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 + end + + def validate_business_account? + # return true if the user is a business account, false if it is a gmail account + auth_hash['info']['email'].exclude?('@gmail.com') + end + + def create_account_for_user + @resource, @account = AccountBuilder.new( + account_name: extract_domain_without_tld(auth_hash['info']['email']), + user_full_name: auth_hash['info']['name'], + email: auth_hash['info']['email'], + locale: I18n.locale, + confirmed: auth_hash['info']['email_verified'] + ).perform + Avatar::AvatarFromUrlJob.perform_later(@resource, auth_hash['info']['image']) + end + + def default_devise_mapping + 'user' + end +end diff --git a/app/controllers/microsoft_controller.rb b/app/controllers/microsoft_controller.rb new file mode 100644 index 000000000..07e58d4db --- /dev/null +++ b/app/controllers/microsoft_controller.rb @@ -0,0 +1,17 @@ +class MicrosoftController < ApplicationController + after_action :set_version_header + + def identity_association + microsoft_indentity + end + + private + + def set_version_header + response.headers['Content-Length'] = { associatedApplications: [{ applicationId: @identity_json }] }.to_json.length + end + + def microsoft_indentity + @identity_json = ENV.fetch('AZURE_APP_ID', nil) + end +end diff --git a/app/controllers/platform/api/v1/users_controller.rb b/app/controllers/platform/api/v1/users_controller.rb index 2c8995f81..e0de74da1 100644 --- a/app/controllers/platform/api/v1/users_controller.rb +++ b/app/controllers/platform/api/v1/users_controller.rb @@ -13,8 +13,7 @@ class Platform::Api::V1::UsersController < PlatformController end def login - encoded_email = ERB::Util.url_encode(@resource.email) - render json: { url: "#{ENV.fetch('FRONTEND_URL', nil)}/app/login?email=#{encoded_email}&sso_auth_token=#{@resource.generate_sso_auth_token}" } + render json: { url: @resource.generate_sso_link } end def show; end diff --git a/app/controllers/public/api/v1/portals/articles_controller.rb b/app/controllers/public/api/v1/portals/articles_controller.rb index ae0fb2b6a..22ffbbf7a 100644 --- a/app/controllers/public/api/v1/portals/articles_controller.rb +++ b/app/controllers/public/api/v1/portals/articles_controller.rb @@ -16,6 +16,7 @@ class Public::Api::V1::Portals::ArticlesController < PublicController def set_article @article = @category.articles.find(params[:id]) + @article.increment_view_count @parsed_content = render_article_content(@article.content) end diff --git a/app/controllers/super_admin/instance_statuses_controller.rb b/app/controllers/super_admin/instance_statuses_controller.rb new file mode 100644 index 000000000..e7b037099 --- /dev/null +++ b/app/controllers/super_admin/instance_statuses_controller.rb @@ -0,0 +1,44 @@ +class SuperAdmin::InstanceStatusesController < SuperAdmin::ApplicationController + def show + @metrics = {} + chatwoot_version + sha + postgres_status + redis_metrics + end + + def chatwoot_version + @metrics['Chatwoot version'] = Chatwoot.config[:version] + end + + def sha + sha = `git rev-parse HEAD` + @metrics['Git SHA'] = sha.presence || 'n/a' + end + + def postgres_status + @metrics['Postgres alive'] = if ActiveRecord::Base.connection.active? + 'true' + else + 'false' + end + end + + def redis_metrics + r = Redis.new(Redis::Config.app) + if r.ping == 'PONG' + redis_server = r.info + @metrics['Redis alive'] = 'true' + @metrics['Redis version'] = redis_server['redis_version'] + @metrics['Redis number of connected clients'] = redis_server['connected_clients'] + @metrics["Redis 'maxclients' setting"] = redis_server['maxclients'] + @metrics['Redis memory used'] = redis_server['used_memory_human'] + @metrics['Redis memory peak'] = redis_server['used_memory_peak_human'] + @metrics['Redis total memory available'] = redis_server['total_system_memory_human'] + @metrics["Redis 'maxmemory' setting"] = redis_server['maxmemory'] + @metrics["Redis 'maxmemory_policy' setting"] = redis_server['maxmemory_policy'] + end + rescue Redis::CannotConnectError + @metrics['Redis alive'] = false + end +end diff --git a/app/finders/conversation_finder.rb b/app/finders/conversation_finder.rb index b337e8458..9472dc623 100644 --- a/app/finders/conversation_finder.rb +++ b/app/finders/conversation_finder.rb @@ -97,6 +97,8 @@ class ConversationFinder when 'mention' conversation_ids = current_account.mentions.where(user: current_user).pluck(:conversation_id) @conversations = @conversations.where(id: conversation_ids) + when 'participating' + @conversations = current_user.participating_conversations.where(account_id: current_account.id) when 'unattended' @conversations = @conversations.where(first_reply_created_at: nil) end diff --git a/app/helpers/email_helper.rb b/app/helpers/email_helper.rb new file mode 100644 index 000000000..256a50387 --- /dev/null +++ b/app/helpers/email_helper.rb @@ -0,0 +1,6 @@ +module EmailHelper + def extract_domain_without_tld(email) + domain = email.split('@').last + domain.split('.').first + end +end diff --git a/app/helpers/message_format_helper.rb b/app/helpers/message_format_helper.rb index 3dd8d8f23..1e89c56c1 100644 --- a/app/helpers/message_format_helper.rb +++ b/app/helpers/message_format_helper.rb @@ -2,7 +2,8 @@ module MessageFormatHelper include RegexHelper def transform_user_mention_content(message_content) - message_content.gsub(MENTION_REGEX, '\1') + # attachment message without content, message_content is nil + message_content.presence ? message_content.gsub(MENTION_REGEX, '\1') : '' end def render_message_content(message_content) diff --git a/app/javascript/dashboard/api/helpCenter/articles.js b/app/javascript/dashboard/api/helpCenter/articles.js index 62328e8eb..bcbb4dc5b 100644 --- a/app/javascript/dashboard/api/helpCenter/articles.js +++ b/app/javascript/dashboard/api/helpCenter/articles.js @@ -46,6 +46,20 @@ class ArticlesAPI extends PortalsAPI { deleteArticle({ articleId, portalSlug }) { return axios.delete(`${this.url}/${portalSlug}/articles/${articleId}`); } + + uploadImage({ portalSlug, file }) { + let formData = new FormData(); + formData.append('background_image', file); + return axios.post( + `${this.url}/${portalSlug}/articles/attach_file`, + formData, + { + headers: { + 'Content-Type': 'multipart/form-data', + }, + } + ); + } } export default new ArticlesAPI(); diff --git a/app/javascript/dashboard/api/inbox/message.js b/app/javascript/dashboard/api/inbox/message.js index f0096cf23..d671b86ff 100644 --- a/app/javascript/dashboard/api/inbox/message.js +++ b/app/javascript/dashboard/api/inbox/message.js @@ -80,6 +80,15 @@ class MessageApi extends ApiClient { params: { before }, }); } + + translateMessage(conversationId, messageId, targetLanguage) { + return axios.post( + `${this.url}/${conversationId}/messages/${messageId}/translate`, + { + target_language: targetLanguage, + } + ); + } } export default new MessageApi(); diff --git a/app/javascript/dashboard/assets/scss/_foundation-custom.scss b/app/javascript/dashboard/assets/scss/_foundation-custom.scss index 650fa884d..2f817b125 100644 --- a/app/javascript/dashboard/assets/scss/_foundation-custom.scss +++ b/app/javascript/dashboard/assets/scss/_foundation-custom.scss @@ -17,9 +17,10 @@ } .tooltip { + background-color: var(--black-transparent); border-radius: $space-smaller; font-size: $font-size-mini; - max-width: 15rem; + max-width: var(--space-giga); padding: $space-smaller $space-small; z-index: 999; } diff --git a/app/javascript/dashboard/components/ChatList.vue b/app/javascript/dashboard/components/ChatList.vue index 7d4a9239e..6fd51f747 100644 --- a/app/javascript/dashboard/components/ChatList.vue +++ b/app/javascript/dashboard/components/ChatList.vue @@ -151,13 +151,14 @@ @@ -181,6 +182,7 @@ import AddCustomViews from 'dashboard/routes/dashboard/customviews/AddCustomView import DeleteCustomViews from 'dashboard/routes/dashboard/customviews/DeleteCustomViews.vue'; import ConversationBulkActions from './widgets/conversation/conversationBulkActions/Index.vue'; import alertMixin from 'shared/mixins/alertMixin'; +import filterMixin from 'shared/mixins/filterMixin'; import { hasPressedAltAndJKey, @@ -202,7 +204,13 @@ export default { DeleteCustomViews, ConversationBulkActions, }, - mixins: [timeMixin, conversationMixin, eventListenerMixins, alertMixin], + mixins: [ + timeMixin, + conversationMixin, + eventListenerMixins, + alertMixin, + filterMixin, + ], props: { conversationInbox: { type: [String, Number], @@ -248,11 +256,13 @@ export default { selectedConversations: [], selectedInboxes: [], isContextMenuOpen: false, + appliedFilter: [], }; }, computed: { ...mapGetters({ currentChat: 'getSelectedChat', + currentUser: 'getCurrentUser', chatLists: 'getAllConversations', mineChatsList: 'getMineChats', allChatList: 'getAllStatusChats', @@ -288,6 +298,13 @@ export default { !this.chatListLoading ); }, + currentUserDetails() { + const { id, name } = this.currentUser; + return { + id, + name, + }; + }, assigneeTabItems() { const ASSIGNEE_TYPE_TAB_KEYS = { me: 'mineCount', @@ -360,6 +377,9 @@ export default { if (this.conversationType === 'mention') { return this.$t('CHAT_LIST.MENTION_HEADING'); } + if (this.conversationType === 'participating') { + return this.$t('CONVERSATION_PARTICIPANTS.SIDEBAR_MENU_TITLE'); + } if (this.conversationType === 'unattended') { return this.$t('CHAT_LIST.UNATTENDED_HEADING'); } @@ -461,7 +481,14 @@ export default { this.showDeleteFoldersModal = false; }, onToggleAdvanceFiltersModal() { - this.showAdvancedFilters = !this.showAdvancedFilters; + if (!this.hasAppliedFilters) { + this.initializeExistingFilterToModal(); + } + this.showAdvancedFilters = true; + }, + closeAdvanceFiltersModal() { + this.showAdvancedFilters = false; + this.appliedFilter = []; }, getKeyboardListenerParams() { const allConversations = this.$refs.activeConversation.querySelectorAll( @@ -520,6 +547,7 @@ export default { return; } this.fetchConversations(); + this.appliedFilter = []; }, fetchConversations() { this.$store diff --git a/app/javascript/dashboard/components/layout/config/sidebarItems/conversations.js b/app/javascript/dashboard/components/layout/config/sidebarItems/conversations.js index 9455bf8af..a8302bd58 100644 --- a/app/javascript/dashboard/components/layout/config/sidebarItems/conversations.js +++ b/app/javascript/dashboard/components/layout/config/sidebarItems/conversations.js @@ -14,6 +14,8 @@ const conversations = accountId => ({ 'conversations_through_team', 'conversation_mentions', 'conversation_through_mentions', + 'conversation_participating', + 'conversation_through_participating', 'folder_conversations', 'conversations_through_folders', 'conversation_unattended', diff --git a/app/javascript/dashboard/components/layout/sidebarComponents/OptionsMenu.vue b/app/javascript/dashboard/components/layout/sidebarComponents/OptionsMenu.vue index c100fe93a..2dba51a5e 100644 --- a/app/javascript/dashboard/components/layout/sidebarComponents/OptionsMenu.vue +++ b/app/javascript/dashboard/components/layout/sidebarComponents/OptionsMenu.vue @@ -154,6 +154,7 @@ export default { left: var(--space-slab); bottom: var(--space-larger); min-width: 22rem; + top: unset; z-index: var(--z-index-low); } diff --git a/app/javascript/dashboard/components/ui/Auth/GoogleOAuthButton.spec.js b/app/javascript/dashboard/components/ui/Auth/GoogleOAuthButton.spec.js new file mode 100644 index 000000000..47cd387e5 --- /dev/null +++ b/app/javascript/dashboard/components/ui/Auth/GoogleOAuthButton.spec.js @@ -0,0 +1,69 @@ +import { shallowMount } from '@vue/test-utils'; +import GoogleOAuthButton from './GoogleOAuthButton.vue'; + +function getWrapper(showSeparator, buttonSize) { + return shallowMount(GoogleOAuthButton, { + propsData: { showSeparator: showSeparator, buttonSize: buttonSize }, + methods: { + $t(text) { + return text; + }, + }, + }); +} + +describe('GoogleOAuthButton.vue', () => { + beforeEach(() => { + window.chatwootConfig = { + googleOAuthClientId: 'clientId', + googleOAuthCallbackUrl: 'http://localhost:3000/test-callback', + }; + }); + + afterEach(() => { + window.chatwootConfig = {}; + }); + + it('renders the OR separator if showSeparator is true', () => { + const wrapper = getWrapper(true); + expect(wrapper.find('.separator').exists()).toBe(true); + }); + + it('does not render the OR separator if showSeparator is false', () => { + const wrapper = getWrapper(false); + expect(wrapper.find('.separator').exists()).toBe(false); + }); + + it('generates the correct Google Auth URL', () => { + const wrapper = getWrapper(); + const googleAuthUrl = new URL(wrapper.vm.getGoogleAuthUrl()); + + const params = googleAuthUrl.searchParams; + expect(googleAuthUrl.origin).toBe('https://accounts.google.com'); + expect(googleAuthUrl.pathname).toBe('/o/oauth2/auth/oauthchooseaccount'); + expect(params.get('client_id')).toBe('clientId'); + expect(params.get('redirect_uri')).toBe( + 'http://localhost:3000/test-callback' + ); + expect(params.get('response_type')).toBe('code'); + expect(params.get('scope')).toBe('email profile'); + }); + + it('responds to buttonSize prop properly', () => { + let wrapper = getWrapper(true, 'tiny'); + expect(wrapper.find('.button.tiny').exists()).toBe(true); + + wrapper = getWrapper(true, 'small'); + expect(wrapper.find('.button.small').exists()).toBe(true); + + wrapper = getWrapper(true, 'large'); + expect(wrapper.find('.button.large').exists()).toBe(true); + + // should not render either + wrapper = getWrapper(true, 'default'); + expect(wrapper.find('.button.small').exists()).toBe(false); + expect(wrapper.find('.button.tiny').exists()).toBe(false); + expect(wrapper.find('.button.large').exists()).toBe(false); + expect(wrapper.find('.button').exists()).toBe(true); + }); +}); diff --git a/app/javascript/dashboard/components/ui/Auth/GoogleOAuthButton.vue b/app/javascript/dashboard/components/ui/Auth/GoogleOAuthButton.vue new file mode 100644 index 000000000..404afe619 --- /dev/null +++ b/app/javascript/dashboard/components/ui/Auth/GoogleOAuthButton.vue @@ -0,0 +1,96 @@ + + + + + diff --git a/app/javascript/dashboard/components/ui/TimeAgo.vue b/app/javascript/dashboard/components/ui/TimeAgo.vue index c796ed0f6..224856253 100644 --- a/app/javascript/dashboard/components/ui/TimeAgo.vue +++ b/app/javascript/dashboard/components/ui/TimeAgo.vue @@ -1,7 +1,14 @@ diff --git a/app/javascript/dashboard/components/widgets/conversation/ReplyBox.vue b/app/javascript/dashboard/components/widgets/conversation/ReplyBox.vue index fe25419a9..6e8c9c34c 100644 --- a/app/javascript/dashboard/components/widgets/conversation/ReplyBox.vue +++ b/app/javascript/dashboard/components/widgets/conversation/ReplyBox.vue @@ -20,6 +20,7 @@ @@ -63,12 +64,15 @@ :placeholder="messagePlaceHolder" :update-selection-with="updateEditorSelectionWith" :min-height="4" + :enable-variables="true" + :variables="messageVariables" @typing-off="onTypingOff" @typing-on="onTypingOn" @focus="onFocus" @blur="onBlur" @toggle-user-mention="toggleUserMention" @toggle-canned-menu="toggleCannedMenu" + @toggle-variables-menu="toggleVariablesMenu" @clear-selection="clearEditorSelection" /> @@ -126,6 +130,12 @@ @on-send="onSendWhatsAppReply" @cancel="hideWhatsappTemplatesModal" /> + + @@ -152,7 +162,11 @@ import { AUDIO_FORMATS, } from 'shared/constants/messages'; import { BUS_EVENTS } from 'shared/constants/busEvents'; - +import { replaceVariablesInMessage } from 'dashboard/helper/messageHelper'; +import { + getMessageVariables, + getUndefinedVariablesInMessage, +} from 'dashboard/helper/messageHelper'; import WhatsappTemplates from './WhatsappTemplates/Modal.vue'; import { buildHotKeys } from 'shared/helpers/KeyboardHelpers'; import { MESSAGE_MAX_LENGTH } from 'shared/helpers/MessageTypeHelper'; @@ -164,9 +178,7 @@ import { LocalStorage, LOCAL_STORAGE_KEYS } from '../../../helper/localStorage'; import { trimContent, debounce } from '@chatwoot/utils'; import wootConstants from 'dashboard/constants'; import { isEditorHotKeyEnabled } from 'dashboard/mixins/uiSettings'; -import AnalyticsHelper, { - ANALYTICS_EVENTS, -} from '../../../helper/AnalyticsHelper'; +import { CONVERSATION_EVENTS } from '../../../helper/AnalyticsHelper/events'; const EmojiInput = () => import('shared/components/emoji/EmojiInput'); @@ -210,7 +222,6 @@ export default { message: '', isFocused: false, showEmojiPicker: false, - showMentions: false, attachedFiles: [], isRecordingAudio: false, recordingAudioState: '', @@ -218,13 +229,17 @@ export default { isUploading: false, replyType: REPLY_EDITOR_MODES.REPLY, mentionSearchKey: '', - hasUserMention: false, hasSlashCommand: false, bccEmails: '', ccEmails: '', doAutoSaveDraft: () => {}, showWhatsAppTemplatesModal: false, updateEditorSelectionWith: '', + undefinedVariableMessage: '', + showMentions: false, + showUserMentions: false, + showCannedMenu: false, + showVariablesMenu: false, }; }, computed: { @@ -471,6 +486,12 @@ export default { } return AUDIO_FORMATS.OGG; }, + messageVariables() { + const variables = getMessageVariables({ + conversation: this.currentChat, + }); + return variables; + }, }, watch: { currentChat(conversation) { @@ -612,8 +633,10 @@ export default { }, isAValidEvent(selectedKey) { return ( - !this.hasUserMention && + !this.showUserMentions && + !this.showMentions && !this.showCannedMenu && + !this.showVariablesMenu && this.isFocused && isEditorHotKeyEnabled(this.uiSettings, selectedKey) ); @@ -632,11 +655,14 @@ export default { }); }, toggleUserMention(currentMentionState) { - this.hasUserMention = currentMentionState; + this.showUserMentions = currentMentionState; }, toggleCannedMenu(value) { this.showCannedMenu = value; }, + toggleVariablesMenu(value) { + this.showVariablesMenu = value; + }, openWhatsappTemplateModal() { this.showWhatsAppTemplatesModal = true; }, @@ -666,7 +692,7 @@ export default { }; this.assignedAgent = selfAssign; }, - async onSendReply() { + confirmOnSendReply() { if (this.isReplyButtonDisabled) { return; } @@ -675,18 +701,57 @@ export default { if (this.isSignatureEnabledForInbox && this.messageSignature) { newMessage += '\n\n' + this.messageSignature; } - const messagePayload = this.getMessagePayload(newMessage); - this.clearMessage(); + const isOnWhatsApp = + this.isATwilioWhatsAppChannel || + this.isAWhatsAppCloudChannel || + this.is360DialogWhatsAppChannel; + if (isOnWhatsApp && !this.isPrivate) { + this.sendMessageAsMultipleMessages(newMessage); + } else { + const messagePayload = this.getMessagePayload(newMessage); + this.sendMessage(messagePayload); + } + if (!this.isPrivate) { this.clearEmailField(); } - this.sendMessage(messagePayload); + this.clearMessage(); this.hideEmojiPicker(); this.$emit('update:popoutReplyBox', false); } }, + sendMessageAsMultipleMessages(message) { + const messages = this.getMessagePayloadForWhatsapp(message); + messages.forEach(messagePayload => { + this.sendMessage(messagePayload); + }); + }, + async onSendReply() { + const undefinedVariables = getUndefinedVariablesInMessage({ + message: this.message, + variables: this.messageVariables, + }); + if (undefinedVariables.length > 0) { + const undefinedVariablesCount = + undefinedVariables.length > 1 ? undefinedVariables.length : 1; + this.undefinedVariableMessage = this.$t( + 'CONVERSATION.REPLYBOX.UNDEFINED_VARIABLES.MESSAGE', + { + undefinedVariablesCount, + undefinedVariables: undefinedVariables.join(', '), + } + ); + + const ok = await this.$refs.confirmDialog.showConfirmation(); + if (ok) { + this.confirmOnSendReply(); + } + } else { + this.confirmOnSendReply(); + } + }, async sendMessage(messagePayload) { try { await this.$store.dispatch( @@ -709,9 +774,13 @@ export default { this.hideWhatsappTemplatesModal(); }, replaceText(message) { + const updatedMessage = replaceVariablesInMessage({ + message, + variables: this.messageVariables, + }); setTimeout(() => { - AnalyticsHelper.track(ANALYTICS_EVENTS.INSERTED_A_CANNED_RESPONSE); - this.message = message; + this.$track(CONVERSATION_EVENTS.INSERTED_A_CANNED_RESPONSE); + this.message = updatedMessage; }, 100); }, setReplyMode(mode = REPLY_EDITOR_MODES.REPLY) { @@ -897,6 +966,33 @@ export default { (item, index) => itemIndex !== index ); }, + getMessagePayloadForWhatsapp(message) { + const multipleMessagePayload = []; + const messagePayload = { + conversationId: this.currentChat.id, + message, + private: false, + }; + + multipleMessagePayload.push(messagePayload); + + if (this.attachedFiles && this.attachedFiles.length) { + this.attachedFiles.forEach(attachment => { + const attachedFile = this.globalConfig.directUploadsEnabled + ? attachment.blobSignedId + : attachment.resource.file; + const attachmentPayload = { + conversationId: this.currentChat.id, + files: [attachedFile], + private: false, + message: '', + }; + multipleMessagePayload.push(attachmentPayload); + }); + } + + return multipleMessagePayload; + }, getMessagePayload(message) { const messagePayload = { conversationId: this.currentChat.id, @@ -992,6 +1088,7 @@ export default { } .reply-box__top { + position: relative; padding: 0 var(--space-normal); border-top: 1px solid var(--color-border); margin-top: -1px; @@ -1029,4 +1126,9 @@ export default { margin-bottom: 0; } } + +.normal-editor__canned-box { + width: calc(100% - 2 * var(--space-normal)); + left: var(--space-normal); +} diff --git a/app/javascript/dashboard/components/widgets/conversation/TagAgents.vue b/app/javascript/dashboard/components/widgets/conversation/TagAgents.vue index 85189757b..c48af3f16 100644 --- a/app/javascript/dashboard/components/widgets/conversation/TagAgents.vue +++ b/app/javascript/dashboard/components/widgets/conversation/TagAgents.vue @@ -2,7 +2,6 @@