diff --git a/.circleci/config.yml b/.circleci/config.yml index b8a628677..bc7053130 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -19,13 +19,19 @@ jobs: steps: - checkout - node/install: - node-version: '20.12' + node-version: '23.7' - node/install-pnpm - node/install-packages: pkg-manager: pnpm override-ci-command: pnpm i - run: node --version - run: pnpm --version + - run: + name: Add PostgreSQL repository and update + command: | + sudo sh -c 'echo "deb http://apt.postgresql.org/pub/repos/apt/ $(lsb_release -cs)-pgdg main" > /etc/apt/sources.list.d/pgdg.list' + wget --quiet -O - https://www.postgresql.org/media/keys/ACCC4CF8.asc | sudo apt-key add - + sudo apt-get update -y - run: name: Install System Dependencies @@ -34,7 +40,9 @@ jobs: DEBIAN_FRONTEND=noninteractive sudo apt-get install -y \ libpq-dev \ redis-server \ - postgresql \ + postgresql-common \ + postgresql-16 \ + postgresql-16-pgvector \ build-essential \ git \ curl \ diff --git a/.codeclimate.yml b/.codeclimate.yml index 213f7d172..c68251f32 100644 --- a/.codeclimate.yml +++ b/.codeclimate.yml @@ -51,6 +51,7 @@ exclude_patterns: - 'app/javascript/dashboard/routes/dashboard/settings/automation/constants.js' - 'app/javascript/dashboard/components/widgets/FilterInput/FilterOperatorTypes.js' - 'app/javascript/dashboard/routes/dashboard/settings/reports/constants.js' + - 'app/javascript/dashboard/store/captain/storeFactory.js' - 'app/javascript/dashboard/i18n/index.js' - 'app/javascript/widget/i18n/index.js' - 'app/javascript/survey/i18n/index.js' diff --git a/.devcontainer/docker-compose.yml b/.devcontainer/docker-compose.yml index a804bb15c..c5530ac17 100644 --- a/.devcontainer/docker-compose.yml +++ b/.devcontainer/docker-compose.yml @@ -5,30 +5,30 @@ version: '3' services: - base: + base: build: context: .. dockerfile: .devcontainer/Dockerfile.base args: - VARIANT: "ubuntu-22.04" - NODE_VERSION: "20.9.0" - RUBY_VERSION: "3.3.3" + VARIANT: 'ubuntu-22.04' + NODE_VERSION: '23.7.0' + RUBY_VERSION: '3.3.3' # On Linux, you may need to update USER_UID and USER_GID below if not your local UID is not 1000. - USER_UID: "1000" - USER_GID: "1000" + USER_UID: '1000' + USER_GID: '1000' image: base:latest - + app: build: context: .. dockerfile: .devcontainer/Dockerfile args: - VARIANT: "ubuntu-22.04" - NODE_VERSION: "20.9.0" - RUBY_VERSION: "3.3.3" + VARIANT: 'ubuntu-22.04' + NODE_VERSION: '23.7.0' + RUBY_VERSION: '3.3.3' # On Linux, you may need to update USER_UID and USER_GID below if not your local UID is not 1000. - USER_UID: "1000" - USER_GID: "1000" + USER_UID: '1000' + USER_GID: '1000' volumes: - ..:/workspace:cached @@ -40,7 +40,7 @@ services: network_mode: service:db db: - image: postgres:latest + image: pgvector/pgvector:pg16 restart: unless-stopped volumes: - postgres-data:/var/lib/postgresql/data diff --git a/.eslintrc.js b/.eslintrc.js index a2932b2a9..6c867f557 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -4,6 +4,8 @@ module.exports = { 'prettier', 'plugin:vue/vue3-recommended', 'plugin:vitest-globals/recommended', + // use recommended-legacy when upgrading the plugin to v4 + 'plugin:@intlify/vue-i18n/recommended', ], overrides: [ { @@ -229,6 +231,18 @@ module.exports = { 'vue/singleline-html-element-content-newline': 'off', 'import/extensions': ['off'], 'no-console': 'error', + '@intlify/vue-i18n/no-dynamic-keys': 'warn', + '@intlify/vue-i18n/no-unused-keys': [ + 'warn', + { + extensions: ['.js', '.vue'], + }, + ], + }, + settings: { + 'vue-i18n': { + localeDir: './app/javascript/*/i18n/**.json', + }, }, env: { browser: true, diff --git a/.github/workflows/frontend-fe.yml b/.github/workflows/frontend-fe.yml index 15bb6f5e9..5af4857e0 100644 --- a/.github/workflows/frontend-fe.yml +++ b/.github/workflows/frontend-fe.yml @@ -23,12 +23,10 @@ jobs: bundler-cache: true - uses: pnpm/action-setup@v4 - with: - version: 9.3.0 - uses: actions/setup-node@v4 with: - node-version: 20 + node-version: 23 cache: 'pnpm' - name: Install pnpm dependencies diff --git a/.github/workflows/nightly_installer.yml b/.github/workflows/nightly_installer.yml index d11fe6401..a01ba1093 100644 --- a/.github/workflows/nightly_installer.yml +++ b/.github/workflows/nightly_installer.yml @@ -16,7 +16,7 @@ on: jobs: nightly: - runs-on: ubuntu-20.04 + runs-on: ubuntu-24.04 steps: - name: get installer diff --git a/.github/workflows/publish_ee_docker.yml b/.github/workflows/publish_ee_docker.yml new file mode 100644 index 000000000..8e2c22481 --- /dev/null +++ b/.github/workflows/publish_ee_docker.yml @@ -0,0 +1,140 @@ +# # +# # This action will publish Chatwoot EE docker image. +# # This is set to run against merges to develop, master +# # and when tags are created. +# # + +name: Publish Chatwoot EE docker images + +on: + push: + branches: + - develop + - master + tags: + - v* + workflow_dispatch: + +env: + DOCKER_REPO: chatwoot/chatwoot + +jobs: + build: + strategy: + fail-fast: false + matrix: + include: + - platform: linux/amd64 + runner: ubuntu-latest + - platform: linux/arm64 + runner: ubuntu-22.04-arm + runs-on: ${{ matrix.runner }} + env: + GIT_REF: ${{ github.head_ref || github.ref_name }} + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Prepare + run: | + platform=${{ matrix.platform }} + echo "PLATFORM_PAIR=${platform//\//-}" >> $GITHUB_ENV + + - name: Set Chatwoot edition + run: | + echo -en '\nENV CW_EDITION="ee"' >> docker/Dockerfile + + - name: Set Docker Tags + run: | + SANITIZED_REF=$(echo "$GIT_REF" | sed 's/\//-/g') + if [ "${{ github.ref_name }}" = "master" ]; then + echo "DOCKER_TAG=${DOCKER_REPO}:latest" >> $GITHUB_ENV + else + echo "DOCKER_TAG=${DOCKER_REPO}:${SANITIZED_REF}" >> $GITHUB_ENV + fi + + - name: Set up QEMU + uses: docker/setup-qemu-action@v3 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Login to DockerHub + if: github.event_name == 'push' || github.event_name == 'workflow_dispatch' + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + + - name: Build and push by digest + id: build + uses: docker/build-push-action@v6 + with: + context: . + file: docker/Dockerfile + platforms: ${{ matrix.platform }} + push: ${{ github.event_name == 'push' || github.event_name == 'workflow_dispatch' }} + outputs: type=image,name=${{ env.DOCKER_REPO }},push-by-digest=true,name-canonical=true,push=true + + - name: Export digest + run: | + mkdir -p ${{ runner.temp }}/digests + digest="${{ steps.build.outputs.digest }}" + touch "${{ runner.temp }}/digests/${digest#sha256:}" + + - name: Upload digest + uses: actions/upload-artifact@v4 + with: + name: digests-${{ env.PLATFORM_PAIR }} + path: ${{ runner.temp }}/digests/* + if-no-files-found: error + retention-days: 1 + + merge: + runs-on: ubuntu-latest + needs: + - build + steps: + - name: Download digests + uses: actions/download-artifact@v4 + with: + path: ${{ runner.temp }}/digests + pattern: digests-* + merge-multiple: true + + - name: Login to DockerHub + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Create manifest list and push + working-directory: ${{ runner.temp }}/digests + env: + GIT_REF: ${{ github.head_ref || github.ref_name }} + run: | + SANITIZED_REF=$(echo "$GIT_REF" | sed 's/\//-/g') + if [ "${{ github.ref_name }}" = "master" ]; then + TAG="${DOCKER_REPO}:latest" + else + TAG="${DOCKER_REPO}:${SANITIZED_REF}" + fi + + docker buildx imagetools create -t $TAG \ + $(printf '${{ env.DOCKER_REPO }}@sha256:%s ' *) + + - name: Inspect image + env: + GIT_REF: ${{ github.head_ref || github.ref_name }} + run: | + SANITIZED_REF=$(echo "$GIT_REF" | sed 's/\//-/g') + if [ "${{ github.ref_name }}" = "master" ]; then + TAG="${DOCKER_REPO}:latest" + else + TAG="${DOCKER_REPO}:${SANITIZED_REF}" + fi + + docker buildx imagetools inspect $TAG diff --git a/.github/workflows/publish_foss_docker.yml b/.github/workflows/publish_foss_docker.yml index 732b22a28..3075a7f3d 100644 --- a/.github/workflows/publish_foss_docker.yml +++ b/.github/workflows/publish_foss_docker.yml @@ -5,6 +5,7 @@ # # name: Publish Chatwoot CE docker images + on: push: branches: @@ -12,23 +13,32 @@ on: - master tags: - v* -# pull_request: workflow_dispatch: +env: + DOCKER_REPO: chatwoot/chatwoot + jobs: build: - runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + include: + - platform: linux/amd64 + runner: ubuntu-latest + - platform: linux/arm64 + runner: ubuntu-22.04-arm + runs-on: ${{ matrix.runner }} env: - GIT_REF: ${{ github.head_ref || github.ref_name }} # ref_name to get tags/branches + GIT_REF: ${{ github.head_ref || github.ref_name }} steps: - name: Checkout uses: actions/checkout@v4 - - name: Set up QEMU - uses: docker/setup-qemu-action@v1 - - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v1 + - name: Prepare + run: | + platform=${{ matrix.platform }} + echo "PLATFORM_PAIR=${platform//\//-}" >> $GITHUB_ENV - name: Strip enterprise code run: | @@ -39,29 +49,97 @@ jobs: run: | echo -en '\nENV CW_EDITION="ce"' >> docker/Dockerfile - - name: set docker tag + - name: Set Docker Tags run: | - # Replace forward slashes with hyphens in the ref name SANITIZED_REF=$(echo "$GIT_REF" | sed 's/\//-/g') - echo "DOCKER_TAG=chatwoot/chatwoot:$SANITIZED_REF-ce" >> $GITHUB_ENV + if [ "${{ github.ref_name }}" = "master" ]; then + echo "DOCKER_TAG=${DOCKER_REPO}:latest-ce" >> $GITHUB_ENV + else + echo "DOCKER_TAG=${DOCKER_REPO}:${SANITIZED_REF}-ce" >> $GITHUB_ENV + fi - - name: replace docker tag if master - if: github.ref_name == 'master' - run: | - echo "DOCKER_TAG=chatwoot/chatwoot:latest-ce" >> $GITHUB_ENV + - name: Set up QEMU + uses: docker/setup-qemu-action@v3 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 - name: Login to DockerHub if: github.event_name == 'push' || github.event_name == 'workflow_dispatch' - uses: docker/login-action@v1 + uses: docker/login-action@v3 with: username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} - - name: Build and push - uses: docker/build-push-action@v2 + - name: Build and push by digest + id: build + uses: docker/build-push-action@v6 with: context: . file: docker/Dockerfile - platforms: linux/amd64, linux/arm64 + platforms: ${{ matrix.platform }} push: ${{ github.event_name == 'push' || github.event_name == 'workflow_dispatch' }} - tags: ${{ env.DOCKER_TAG }} + outputs: type=image,name=${{ env.DOCKER_REPO }},push-by-digest=true,name-canonical=true,push=true + + - name: Export digest + run: | + mkdir -p ${{ runner.temp }}/digests + digest="${{ steps.build.outputs.digest }}" + touch "${{ runner.temp }}/digests/${digest#sha256:}" + + - name: Upload digest + uses: actions/upload-artifact@v4 + with: + name: digests-${{ env.PLATFORM_PAIR }} + path: ${{ runner.temp }}/digests/* + if-no-files-found: error + retention-days: 1 + + merge: + runs-on: ubuntu-latest + needs: + - build + steps: + - name: Download digests + uses: actions/download-artifact@v4 + with: + path: ${{ runner.temp }}/digests + pattern: digests-* + merge-multiple: true + + - name: Login to DockerHub + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Create manifest list and push + working-directory: ${{ runner.temp }}/digests + env: + GIT_REF: ${{ github.head_ref || github.ref_name }} + run: | + SANITIZED_REF=$(echo "$GIT_REF" | sed 's/\//-/g') + if [ "${{ github.ref_name }}" = "master" ]; then + TAG="${DOCKER_REPO}:latest-ce" + else + TAG="${DOCKER_REPO}:${SANITIZED_REF}-ce" + fi + + docker buildx imagetools create -t $TAG \ + $(printf '${{ env.DOCKER_REPO }}@sha256:%s ' *) + + - name: Inspect image + env: + GIT_REF: ${{ github.head_ref || github.ref_name }} + run: | + SANITIZED_REF=$(echo "$GIT_REF" | sed 's/\//-/g') + if [ "${{ github.ref_name }}" = "master" ]; then + TAG="${DOCKER_REPO}:latest-ce" + else + TAG="${DOCKER_REPO}:${SANITIZED_REF}-ce" + fi + + docker buildx imagetools inspect $TAG diff --git a/.github/workflows/run_foss_spec.yml b/.github/workflows/run_foss_spec.yml index 6a018b314..5a9d35d0b 100644 --- a/.github/workflows/run_foss_spec.yml +++ b/.github/workflows/run_foss_spec.yml @@ -1,9 +1,3 @@ -# # -# # This action will strip the enterprise folder -# # and run the spec. -# # This is set to run against every PR. -# # - name: Run Chatwoot CE spec on: push: @@ -18,7 +12,7 @@ jobs: runs-on: ubuntu-20.04 services: postgres: - image: postgres:15.3 + image: pgvector/pgvector:pg15 env: POSTGRES_USER: postgres POSTGRES_PASSWORD: '' @@ -44,7 +38,6 @@ jobs: - uses: actions/checkout@v4 - uses: pnpm/action-setup@v4 with: - version: 9 ref: ${{ github.event.pull_request.head.ref }} repository: ${{ github.event.pull_request.head.repo.full_name }} @@ -54,7 +47,7 @@ jobs: - uses: actions/setup-node@v4 with: - node-version: 20 + node-version: 23 cache: 'pnpm' - name: Install pnpm dependencies diff --git a/.github/workflows/run_response_bot_spec.yml b/.github/workflows/run_response_bot_spec.yml deleted file mode 100644 index c788a0400..000000000 --- a/.github/workflows/run_response_bot_spec.yml +++ /dev/null @@ -1,89 +0,0 @@ -# # -# # This workflow will run specs related to response bot -# # This can only be activated in installations Where vector extension is available. -# # - -name: Run Response Bot spec -on: - push: - branches: - - develop - - master - pull_request: - workflow_dispatch: - -jobs: - test: - runs-on: ubuntu-20.04 - services: - postgres: - image: ankane/pgvector - env: - POSTGRES_USER: postgres - POSTGRES_PASSWORD: "" - POSTGRES_DB: postgres - POSTGRES_HOST_AUTH_METHOD: trust - ports: - - 5432:5432 - # needed because the postgres container does not provide a healthcheck - # tmpfs makes DB faster by using RAM - 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 - - steps: - - uses: actions/checkout@v4 - with: - ref: ${{ github.event.pull_request.head.ref }} - repository: ${{ github.event.pull_request.head.repo.full_name }} - - - uses: ruby/setup-ruby@v1 - with: - bundler-cache: true # runs 'bundle install' and caches installed gems automatically - - - uses: pnpm/action-setup@v2 - with: - version: 9.3.0 - - - uses: actions/setup-node@v4 - with: - node-version: 20 - cache: pnpm - - - name: pnpm - run: pnpm install - - - name: Create database - run: bundle exec rake db:create - - - name: Seed database - run: bundle exec rake db:schema:load - - - name: Enable ResponseBotService in installation - run: RAILS_ENV=test bundle exec rails runner "Features::ResponseBotService.new.enable_in_installation" - - # Run Response Bot specs - - name: Run backend tests - run: | - bundle exec rspec \ - spec/enterprise/controllers/api/v1/accounts/response_sources_controller_spec.rb \ - spec/enterprise/services/enterprise/message_templates/response_bot_service_spec.rb \ - spec/enterprise/controllers/enterprise/api/v1/accounts/inboxes_controller_spec.rb:47 \ - spec/enterprise/jobs/enterprise/account/conversations_resolution_scheduler_job_spec.rb \ - --profile=10 \ - --format documentation - - - name: Upload rails log folder - uses: actions/upload-artifact@v4 - if: always() - with: - name: rails-log-folder - path: log diff --git a/.github/workflows/size-limit.yml b/.github/workflows/size-limit.yml index 0758ca7d0..724f69ec3 100644 --- a/.github/workflows/size-limit.yml +++ b/.github/workflows/size-limit.yml @@ -19,13 +19,11 @@ jobs: with: bundler-cache: true # runs 'bundle install' and caches installed gems automatically - - uses: pnpm/action-setup@v2 - with: - version: 9.3.0 + - uses: pnpm/action-setup@v4 - uses: actions/setup-node@v4 with: - node-version: 20 + node-version: 23 cache: 'pnpm' - name: pnpm @@ -39,7 +37,7 @@ jobs: - name: setup env run: | cp .env.example .env - + - name: Run asset compile run: bundle exec rake assets:precompile env: @@ -47,5 +45,3 @@ jobs: - name: Size Check run: pnpm run size - - diff --git a/.github/workflows/test_docker_build.yml b/.github/workflows/test_docker_build.yml new file mode 100644 index 000000000..460c4ba1f --- /dev/null +++ b/.github/workflows/test_docker_build.yml @@ -0,0 +1,40 @@ +name: Test Docker Build + +on: + pull_request: + branches: + - develop + - master + workflow_dispatch: + +jobs: + test-build: + strategy: + fail-fast: false + matrix: + include: + - platform: linux/amd64 + runner: ubuntu-latest + - platform: linux/arm64 + runner: ubuntu-22.04-arm + runs-on: ${{ matrix.runner }} + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up QEMU + uses: docker/setup-qemu-action@v3 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Build Docker image + uses: docker/build-push-action@v6 + with: + context: . + file: docker/Dockerfile + platforms: ${{ matrix.platform }} + push: false + load: false + cache-from: type=gha + cache-to: type=gha,mode=max diff --git a/.gitignore b/.gitignore index 5eb883db0..77c4a4740 100644 --- a/.gitignore +++ b/.gitignore @@ -73,6 +73,7 @@ test/cypress/videos/* #ignore files under .vscode directory .vscode +.cursor # yalc for local testing .yalc diff --git a/.husky/pre-commit b/.husky/pre-commit index 2525dde4e..adda426ad 100755 --- a/.husky/pre-commit +++ b/.husky/pre-commit @@ -1,11 +1,11 @@ -# #!/bin/sh -# . "$(dirname "$0")/_/husky.sh" +#!/bin/sh +. "$(dirname "$0")/_/husky.sh" -# # lint js and vue files -# npx --no-install lint-staged +# 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 --force-exclusion -a +# lint only staged ruby files +git diff --name-only --cached | xargs ls -1 2>/dev/null | grep '\.rb$' | xargs bundle exec rubocop --force-exclusion -a -# # stage rubocop changes to files -# git diff --name-only --cached | xargs git add +# stage rubocop changes to files +git diff --name-only --cached | xargs git add diff --git a/Gemfile b/Gemfile index 439b0f63e..937aef4af 100644 --- a/Gemfile +++ b/Gemfile @@ -94,7 +94,7 @@ gem 'twitty', '~> 0.1.5' # facebook client gem 'koala' # slack client -gem 'slack-ruby-client', '~> 2.2.0' +gem 'slack-ruby-client', '~> 2.5.2' # for dialogflow integrations gem 'google-cloud-dialogflow-v2', '>= 0.24.0' gem 'grpc' @@ -138,9 +138,7 @@ gem 'procore-sift' # parse email gem 'email_reply_trimmer' -# TODO: we might have to fork this gem since 0.3.1 has hard depency on nokogir 1.10. -# and this gem hasn't been updated for a while. -gem 'html2text', git: 'https://github.com/chatwoot/html2text_ruby', branch: 'chatwoot' +gem 'html2text' # to calculate working hours gem 'working_hours' @@ -175,6 +173,8 @@ gem 'pgvector' # Convert Website HTML to Markdown gem 'reverse_markdown' +gem 'ruby-openai' + ### Gems required only in specific deployment environments ### ############################################################## diff --git a/Gemfile.lock b/Gemfile.lock index 74cdbeea8..e9a573816 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -22,14 +22,6 @@ GIT devise (>= 4.0.0, < 5.0.0) railties (>= 5.0.0, < 8.0.0) -GIT - remote: https://github.com/chatwoot/html2text_ruby - revision: cdbdbbbf898d846d0136d69d688a003c6b26074b - branch: chatwoot - specs: - html2text (0.3.1) - nokogiri (>= 1.13.6) - GEM remote: https://rubygems.org/ specs: @@ -186,7 +178,7 @@ GEM database_cleaner-core (2.0.1) datadog-ci (0.8.3) msgpack - date (3.3.4) + date (3.4.1) ddtrace (1.23.2) datadog-ci (~> 0.8.1) debase-ruby_core_source (= 3.3.1) @@ -231,6 +223,7 @@ GEM erubi (1.13.0) et-orbi (1.2.11) tzinfo + event_stream_parser (1.0.0) execjs (2.8.1) facebook-messenger (2.0.1) httparty (~> 0.13, >= 0.13.7) @@ -279,7 +272,8 @@ GEM googleauth (~> 1.0) grpc (~> 1.36) geocoder (1.8.1) - gli (2.21.1) + gli (2.22.2) + ostruct globalid (1.2.1) activesupport (>= 6.1) gmail_xoauth (0.4.3) @@ -360,6 +354,8 @@ GEM hana (1.3.7) hashdiff (1.1.0) hashie (5.0.0) + html2text (0.4.0) + nokogiri (>= 1.0, < 2.0) http (5.1.1) addressable (~> 2.8) http-cookie (~> 1.0) @@ -486,7 +482,7 @@ GEM uri net-http-persistent (4.0.2) connection_pool (~> 2.2) - net-imap (0.4.17) + net-imap (0.4.19) date net-protocol net-pop (0.1.2) @@ -502,14 +498,14 @@ GEM newrelic_rpm (9.6.0) base64 nio4r (2.7.3) - nokogiri (1.17.1) + nokogiri (1.18.3) mini_portile2 (~> 2.8.2) racc (~> 1.4) - nokogiri (1.17.1-arm64-darwin) + nokogiri (1.18.3-arm64-darwin) racc (~> 1.4) - nokogiri (1.17.1-x86_64-darwin) + nokogiri (1.18.3-x86_64-darwin) racc (~> 1.4) - nokogiri (1.17.1-x86_64-linux) + nokogiri (1.18.3-x86_64-linux-gnu) racc (~> 1.4) oauth (1.1.0) oauth-tty (~> 1.0, >= 1.0.1) @@ -542,6 +538,7 @@ GEM openssl (3.2.0) orm_adapter (0.5.0) os (1.1.4) + ostruct (0.6.1) parallel (1.23.0) parser (3.2.2.1) ast (~> 2.4.1) @@ -564,7 +561,7 @@ GEM activesupport (>= 3.0.0) raabro (1.4.0) racc (1.8.1) - rack (2.2.10) + rack (2.2.11) rack-attack (6.7.0) rack (>= 1.0, < 4) rack-contrib (2.5.0) @@ -684,6 +681,10 @@ GEM rubocop-rspec (2.21.0) rubocop (~> 1.33) rubocop-capybara (~> 2.17) + ruby-openai (7.3.1) + event_stream_parser (>= 0.3.0, < 2.0.0) + faraday (>= 1) + faraday-multipart (>= 1) ruby-progressbar (1.13.0) ruby-vips (2.1.4) ffi (~> 1.12) @@ -746,12 +747,13 @@ GEM json (>= 1.8, < 3) simplecov-html (~> 0.10.0) simplecov-html (0.10.2) - slack-ruby-client (2.2.0) + slack-ruby-client (2.5.2) faraday (>= 2.0) faraday-mashify faraday-multipart gli hashie + logger snaky_hash (2.0.1) hashie version_gem (~> 1.1, >= 1.1.1) @@ -777,7 +779,7 @@ GEM time_diff (0.3.0) activesupport i18n - timeout (0.4.1) + timeout (0.4.3) trailblazer-option (0.1.2) twilio-ruby (5.77.0) faraday (>= 0.9, < 3.0) @@ -892,7 +894,7 @@ DEPENDENCIES haikunator hairtrigger hashie - html2text! + html2text image_processing jbuilder json_refs @@ -941,6 +943,7 @@ DEPENDENCIES rubocop-performance rubocop-rails rubocop-rspec + ruby-openai scout_apm scss_lint seed_dump @@ -951,7 +954,7 @@ DEPENDENCIES sidekiq (>= 7.3.1) sidekiq-cron (>= 1.12.0) simplecov (= 0.17.1) - slack-ruby-client (~> 2.2.0) + slack-ruby-client (~> 2.5.2) spring spring-watcher-listen squasher diff --git a/README.md b/README.md index e5f73eb97..f09bd5698 100644 --- a/README.md +++ b/README.md @@ -120,4 +120,4 @@ Thanks goes to all these [wonderful people](https://www.chatwoot.com/docs/contri -*Chatwoot* © 2017-2024, Chatwoot Inc - Released under the MIT License. +*Chatwoot* © 2017-2025, Chatwoot Inc - Released under the MIT License. diff --git a/SECURITY.md b/SECURITY.md index 0722f9217..ee9eba0b8 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -2,13 +2,13 @@ Chatwoot is looking forward to working with security researchers worldwide to ke ## Reporting a Vulnerability -We use [huntr.dev](https://huntr.dev/) for security issues that affect our project. If you believe you have found a vulnerability, please disclose it via this [form](https://huntr.dev/bounties/disclose). This will enable us to review the vulnerability, fix it promptly, and reward you for your efforts. +We use Github to track the security issues that affect our project. If you believe you have found a vulnerability, please disclose it via this [form](https://github.com/chatwoot/chatwoot/security/advisories/new). This will enable us to review the vulnerability, fix it promptly, and reward you for your efforts. If you have any questions about the process, contact security@chatwoot.com. -Please try your best to describe a clear and realistic impact for your report, and please don't open any public issues on GitHub or social media; we're doing our best to respond through Huntr as quickly as possible. +Please try your best to describe a clear and realistic impact for your report, and please don't open any public issues on GitHub or social media; we're doing our best to respond through Github as quickly as possible. -> Note: Please use the email for questions related to the process. Disclosures should be done via [huntr.dev](https://huntr.dev/) +> Note: Please use the email for questions related to the process. Disclosures should be done via [Github](https://github.com/chatwoot/chatwoot/security/advisories/new) ## Supported versions | Version | Supported | @@ -48,7 +48,7 @@ We consider the following out of scope, though there may be exceptions. - Brute force attacks - DNSSEC -If you are unsure about the scope, please create a [report](https://huntr.dev/repos/chatwoot/chatwoot/). +If you are unsure about the scope, please create a [report](https://github.com/chatwoot/chatwoot/security/advisories/new). ## Thanks diff --git a/VERSION_CWCTL b/VERSION_CWCTL index 4a36342fc..944880fa1 100644 --- a/VERSION_CWCTL +++ b/VERSION_CWCTL @@ -1 +1 @@ -3.0.0 +3.2.0 diff --git a/app/builders/notification_builder.rb b/app/builders/notification_builder.rb index 3d8ce9674..d5461eb4e 100644 --- a/app/builders/notification_builder.rb +++ b/app/builders/notification_builder.rb @@ -25,6 +25,8 @@ class NotificationBuilder def build_notification # Create conversation_creation notification only if user is subscribed to it return if notification_type == 'conversation_creation' && !user_subscribed_to_notification? + # skip notifications for blocked conversations except for user mentions + return if primary_actor.contact.blocked? && notification_type != 'conversation_mention' user.notifications.create!( notification_type: notification_type, diff --git a/app/builders/v2/reports/timeseries/average_report_builder.rb b/app/builders/v2/reports/timeseries/average_report_builder.rb index 3e30557e1..5df718b6a 100644 --- a/app/builders/v2/reports/timeseries/average_report_builder.rb +++ b/app/builders/v2/reports/timeseries/average_report_builder.rb @@ -28,7 +28,7 @@ class V2::Reports::Timeseries::AverageReportBuilder < V2::Reports::Timeseries::B end def object_scope - scope.reporting_events.where(name: event_name, created_at: range) + scope.reporting_events.where(name: event_name, created_at: range, account_id: account.id) end def reporting_events diff --git a/app/controllers/api/v1/accounts/contacts/conversations_controller.rb b/app/controllers/api/v1/accounts/contacts/conversations_controller.rb index 5e0a0e55e..de0ac4db9 100644 --- a/app/controllers/api/v1/accounts/contacts/conversations_controller.rb +++ b/app/controllers/api/v1/accounts/contacts/conversations_controller.rb @@ -2,7 +2,7 @@ class Api::V1::Accounts::Contacts::ConversationsController < Api::V1::Accounts:: def index @conversations = Current.account.conversations.includes( :assignee, :contact, :inbox, :taggings - ).where(inbox_id: inbox_ids, contact_id: @contact.id).order(id: :desc).limit(20) + ).where(inbox_id: inbox_ids, contact_id: @contact.id).order(last_activity_at: :desc).limit(20) end private diff --git a/app/controllers/api/v1/accounts/inbox_members_controller.rb b/app/controllers/api/v1/accounts/inbox_members_controller.rb index 0cb1a0c85..bf8822c27 100644 --- a/app/controllers/api/v1/accounts/inbox_members_controller.rb +++ b/app/controllers/api/v1/accounts/inbox_members_controller.rb @@ -10,7 +10,7 @@ class Api::V1::Accounts::InboxMembersController < Api::V1::Accounts::BaseControl def create authorize @inbox, :create? ActiveRecord::Base.transaction do - agents_to_be_added_ids.map { |user_id| @inbox.add_member(user_id) } + @inbox.add_members(agents_to_be_added_ids) end fetch_updated_agents end @@ -24,7 +24,7 @@ class Api::V1::Accounts::InboxMembersController < Api::V1::Accounts::BaseControl def destroy authorize @inbox, :destroy? ActiveRecord::Base.transaction do - params[:user_ids].map { |user_id| @inbox.remove_member(user_id) } + @inbox.remove_members(params[:user_ids]) end head :ok end @@ -41,8 +41,8 @@ class Api::V1::Accounts::InboxMembersController < Api::V1::Accounts::BaseControl # the missing ones are the agents which are to be deleted from the inbox # the new ones are the agents which are to be added to the inbox ActiveRecord::Base.transaction do - agents_to_be_added_ids.each { |user_id| @inbox.add_member(user_id) } - agents_to_be_removed_ids.each { |user_id| @inbox.remove_member(user_id) } + @inbox.add_members(agents_to_be_added_ids) + @inbox.remove_members(agents_to_be_removed_ids) end end diff --git a/app/controllers/api/v1/accounts/integrations/captain_controller.rb b/app/controllers/api/v1/accounts/integrations/captain_controller.rb deleted file mode 100644 index 6813d14b5..000000000 --- a/app/controllers/api/v1/accounts/integrations/captain_controller.rb +++ /dev/null @@ -1,78 +0,0 @@ -class Api::V1::Accounts::Integrations::CaptainController < Api::V1::Accounts::BaseController - before_action :hook - - def proxy - request_url = build_request_url(request_path) - response = HTTParty.send(request_method, request_url, body: permitted_params[:body].to_json, headers: headers) - render plain: response.body, status: response.code - end - - def copilot - request_url = build_request_url(build_request_path("/assistants/#{hook.settings['assistant_id']}/copilot")) - params = { - previous_messages: copilot_params[:previous_messages], - conversation_history: conversation_history, - message: copilot_params[:message] - } - response = HTTParty.send(:post, request_url, body: params.to_json, headers: headers) - render plain: response.body, status: response.code - end - - private - - def headers - { - 'X-User-Email' => hook.settings['account_email'], - 'X-User-Token' => hook.settings['access_token'], - 'Content-Type' => 'application/json', - 'Accept' => '*/*' - } - end - - def build_request_path(route) - "api/accounts/#{hook.settings['account_id']}#{route}" - end - - def request_path - request_route = with_leading_hash_on_route(params[:route]) - - return 'api/sessions/profile' if request_route == '/sessions/profile' - - build_request_path(request_route) - end - - def build_request_url(request_path) - base_url = InstallationConfig.find_by(name: 'CAPTAIN_API_URL').value - URI.join(base_url, request_path).to_s - end - - def hook - @hook ||= Current.account.hooks.find_by!(app_id: 'captain') - end - - def request_method - method = permitted_params[:method].downcase - raise 'Invalid or missing HTTP method' unless %w[get post put patch delete options head].include?(method) - - method - end - - def with_leading_hash_on_route(request_route) - return '' if request_route.blank? - - request_route.start_with?('/') ? request_route : "/#{request_route}" - end - - def conversation_history - conversation = Current.account.conversations.find_by!(display_id: copilot_params[:conversation_id]) - conversation.to_llm_text - end - - def copilot_params - params.permit(:previous_messages, :conversation_id, :message) - end - - def permitted_params - params.permit(:method, :route, body: {}) - end -end diff --git a/app/controllers/api/v1/accounts/integrations/linear_controller.rb b/app/controllers/api/v1/accounts/integrations/linear_controller.rb index 814373c7e..4e5348e88 100644 --- a/app/controllers/api/v1/accounts/integrations/linear_controller.rb +++ b/app/controllers/api/v1/accounts/integrations/linear_controller.rb @@ -1,5 +1,11 @@ class Api::V1::Accounts::Integrations::LinearController < Api::V1::Accounts::BaseController before_action :fetch_conversation, only: [:link_issue, :linked_issues] + before_action :fetch_hook, only: [:destroy] + + def destroy + @hook.destroy! + head :ok + end def teams teams = linear_processor_service.teams @@ -90,4 +96,8 @@ class Api::V1::Accounts::Integrations::LinearController < Api::V1::Accounts::Bas def permitted_params params.permit(:team_id, :project_id, :conversation_id, :issue_id, :link_id, :title, :description, :assignee_id, :priority, label_ids: []) end + + def fetch_hook + @hook = Integrations::Hook.where(account: Current.account).find_by(app_id: 'linear') + end end diff --git a/app/controllers/api/v1/accounts/team_members_controller.rb b/app/controllers/api/v1/accounts/team_members_controller.rb index 0243a915a..ffb115a34 100644 --- a/app/controllers/api/v1/accounts/team_members_controller.rb +++ b/app/controllers/api/v1/accounts/team_members_controller.rb @@ -9,14 +9,14 @@ class Api::V1::Accounts::TeamMembersController < Api::V1::Accounts::BaseControll def create ActiveRecord::Base.transaction do - @team_members = members_to_be_added_ids.map { |user_id| @team.add_member(user_id) } + @team_members = @team.add_members(members_to_be_added_ids) end end def update ActiveRecord::Base.transaction do - members_to_be_added_ids.each { |user_id| @team.add_member(user_id) } - members_to_be_removed_ids.each { |user_id| @team.remove_member(user_id) } + @team.add_members(members_to_be_added_ids) + @team.remove_members(members_to_be_removed_ids) end @team_members = @team.members render action: 'create' @@ -24,7 +24,7 @@ class Api::V1::Accounts::TeamMembersController < Api::V1::Accounts::BaseControll def destroy ActiveRecord::Base.transaction do - params[:user_ids].map { |user_id| @team.remove_member(user_id) } + @team.remove_members(params[:user_ids]) end head :ok end diff --git a/app/controllers/api/v2/accounts/live_reports_controller.rb b/app/controllers/api/v2/accounts/live_reports_controller.rb new file mode 100644 index 000000000..1c703764b --- /dev/null +++ b/app/controllers/api/v2/accounts/live_reports_controller.rb @@ -0,0 +1,64 @@ +class Api::V2::Accounts::LiveReportsController < Api::V1::Accounts::BaseController + before_action :load_conversations, only: [:conversation_metrics, :grouped_conversation_metrics] + before_action :set_group_scope, only: [:grouped_conversation_metrics] + + before_action :check_authorization + + def conversation_metrics + render json: { + open: @conversations.open.count, + unattended: @conversations.open.unattended.count, + unassigned: @conversations.open.unassigned.count, + pending: @conversations.pending.count + } + end + + def grouped_conversation_metrics + count_by_group = @conversations.open.group(@group_scope).count + unattended_by_group = @conversations.open.unattended.group(@group_scope).count + unassigned_by_group = @conversations.open.unassigned.group(@group_scope).count + + group_metrics = count_by_group.map do |group_id, count| + metric = { + open: count, + unattended: unattended_by_group[group_id] || 0, + unassigned: unassigned_by_group[group_id] || 0 + } + metric[@group_scope] = group_id + metric + end + + render json: group_metrics + end + + private + + def check_authorization + authorize :report, :view? + end + + def set_group_scope + render json: { error: 'invalid group_by' }, status: :unprocessable_entity and return unless %w[ + team_id + assignee_id + ].include?(permitted_params[:group_by]) + + @group_scope = permitted_params[:group_by] + end + + def team + return unless permitted_params[:team_id] + + @team ||= Current.account.teams.find(permitted_params[:team_id]) + end + + def load_conversations + scope = Current.account.conversations + scope = scope.where(team_id: team.id) if team.present? + @conversations = scope + end + + def permitted_params + params.permit(:team_id, :group_by) + end +end diff --git a/app/controllers/dashboard_controller.rb b/app/controllers/dashboard_controller.rb index 6ddaab73b..0f915ab8a 100644 --- a/app/controllers/dashboard_controller.rb +++ b/app/controllers/dashboard_controller.rb @@ -7,13 +7,17 @@ class DashboardController < ActionController::Base around_action :switch_locale before_action :ensure_installation_onboarding, only: [:index] before_action :render_hc_if_custom_domain, only: [:index] - + before_action :ensure_html_format layout 'vueapp' def index; end private + def ensure_html_format + head :not_acceptable unless request.format.html? + end + def set_global_config @global_config = GlobalConfig.get( 'LOGO', 'LOGO_DARK', 'LOGO_THUMBNAIL', @@ -32,7 +36,7 @@ class DashboardController < ActionController::Base 'LOGOUT_REDIRECT_LINK', 'DISABLE_USER_PROFILE_UPDATE', 'DEPLOYMENT_ENV', - 'CSML_EDITOR_HOST' + 'CSML_EDITOR_HOST', 'INSTALLATION_PRICING_PLAN' ).merge(app_config) end diff --git a/app/controllers/linear/callbacks_controller.rb b/app/controllers/linear/callbacks_controller.rb new file mode 100644 index 000000000..2eea49333 --- /dev/null +++ b/app/controllers/linear/callbacks_controller.rb @@ -0,0 +1,73 @@ +class Linear::CallbacksController < ApplicationController + include Linear::IntegrationHelper + + def show + @response = oauth_client.auth_code.get_token( + params[:code], + redirect_uri: "#{base_url}/linear/callback" + ) + + handle_response + rescue StandardError => e + Rails.logger.error("Linear callback error: #{e.message}") + redirect_to linear_redirect_uri + end + + private + + def oauth_client + app_id = GlobalConfigService.load('LINEAR_CLIENT_ID', nil) + app_secret = GlobalConfigService.load('LINEAR_CLIENT_SECRET', nil) + + OAuth2::Client.new( + app_id, + app_secret, + { + site: 'https://api.linear.app', + token_url: '/oauth/token', + authorize_url: '/oauth/authorize' + } + ) + end + + def handle_response + hook = account.hooks.new( + access_token: parsed_body['access_token'], + status: 'enabled', + app_id: 'linear', + settings: { + token_type: parsed_body['token_type'], + expires_in: parsed_body['expires_in'], + scope: parsed_body['scope'] + } + ) + # You may wonder why we're not handling the refresh token update, since the token will expire only after 10 years, https://github.com/linear/linear/issues/251 + hook.save! + redirect_to linear_redirect_uri + rescue StandardError => e + Rails.logger.error("Linear callback error: #{e.message}") + redirect_to linear_redirect_uri + end + + def account + @account ||= Account.find(account_id) + end + + def account_id + return unless params[:state] + + verify_linear_token(params[:state]) + end + + def linear_redirect_uri + "#{ENV.fetch('FRONTEND_URL', nil)}/app/accounts/#{account.id}/settings/integrations/linear" + end + + def parsed_body + @parsed_body ||= @response.response.parsed + end + + def base_url + ENV.fetch('FRONTEND_URL', 'http://localhost:3000') + end +end diff --git a/app/controllers/super_admin/app_configs_controller.rb b/app/controllers/super_admin/app_configs_controller.rb index b8f3bd9a9..43157fa0e 100644 --- a/app/controllers/super_admin/app_configs_controller.rb +++ b/app/controllers/super_admin/app_configs_controller.rb @@ -39,6 +39,8 @@ class SuperAdmin::AppConfigsController < SuperAdmin::ApplicationController %w[AZURE_APP_ID AZURE_APP_SECRET] when 'email' ['MAILER_INBOUND_EMAIL_DOMAIN'] + when 'linear' + %w[LINEAR_CLIENT_ID LINEAR_CLIENT_SECRET] else %w[ENABLE_ACCOUNT_SIGNUP FIREBASE_PROJECT_ID FIREBASE_CREDENTIALS] end diff --git a/app/dispatchers/async_dispatcher.rb b/app/dispatchers/async_dispatcher.rb index b582bc5dd..7416b7861 100644 --- a/app/dispatchers/async_dispatcher.rb +++ b/app/dispatchers/async_dispatcher.rb @@ -22,3 +22,5 @@ class AsyncDispatcher < BaseDispatcher ] end end + +AsyncDispatcher.prepend_mod_with('AsyncDispatcher') diff --git a/app/fields/enterprise/account_limits_field.rb b/app/fields/enterprise/account_limits_field.rb index 5833952ad..b014435c6 100644 --- a/app/fields/enterprise/account_limits_field.rb +++ b/app/fields/enterprise/account_limits_field.rb @@ -2,6 +2,6 @@ require 'administrate/field/base' class Enterprise::AccountLimitsField < Administrate::Field::Base def to_s - data.present? ? data.to_json : { agents: nil, inboxes: nil }.to_json + data.present? ? data.to_json : { agents: nil, inboxes: nil, captain_responses: nil, captain_documents: nil }.to_json end end diff --git a/app/helpers/api/v2/accounts/heatmap_helper.rb b/app/helpers/api/v2/accounts/heatmap_helper.rb index 58dade28d..a4977f43a 100644 --- a/app/helpers/api/v2/accounts/heatmap_helper.rb +++ b/app/helpers/api/v2/accounts/heatmap_helper.rb @@ -94,7 +94,8 @@ module Api::V2::Accounts::HeatmapHelper end def since_timestamp(date) - (date - 6.days).to_i.to_s + number_of_days = params[:days_before].present? ? params[:days_before].to_i.days : 6.days + (date - number_of_days).to_i.to_s end def until_timestamp(date) diff --git a/app/helpers/api/v2/accounts/reports_helper.rb b/app/helpers/api/v2/accounts/reports_helper.rb index 4d5a6f115..22c51b6ef 100644 --- a/app/helpers/api/v2/accounts/reports_helper.rb +++ b/app/helpers/api/v2/accounts/reports_helper.rb @@ -1,58 +1,70 @@ module Api::V2::Accounts::ReportsHelper def generate_agents_report + reports = V2::Reports::AgentSummaryBuilder.new( + account: Current.account, + params: build_params(type: :agent) + ).build + Current.account.users.map do |agent| - agent_report = report_builder({ type: :agent, id: agent.id }).summary - [agent.name] + generate_readable_report_metrics(agent_report) + report = reports.find { |r| r[:id] == agent.id } + [agent.name] + generate_readable_report_metrics(report) end end def generate_inboxes_report + reports = V2::Reports::InboxSummaryBuilder.new( + account: Current.account, + params: build_params(type: :inbox) + ).build + Current.account.inboxes.map do |inbox| - inbox_report = generate_report({ type: :inbox, id: inbox.id }) - [inbox.name, inbox.channel&.name] + generate_readable_report_metrics(inbox_report) + report = reports.find { |r| r[:id] == inbox.id } + [inbox.name, inbox.channel&.name] + generate_readable_report_metrics(report) end end def generate_teams_report + reports = V2::Reports::TeamSummaryBuilder.new( + account: Current.account, + params: build_params(type: :team) + ).build + Current.account.teams.map do |team| - team_report = report_builder({ type: :team, id: team.id }).summary - [team.name] + generate_readable_report_metrics(team_report) + report = reports.find { |r| r[:id] == team.id } + [team.name] + generate_readable_report_metrics(report) end end def generate_labels_report Current.account.labels.map do |label| - label_report = generate_report({ type: :label, id: label.id }) + label_report = report_builder({ type: :label, id: label.id }).short_summary [label.title] + generate_readable_report_metrics(label_report) end end - def report_builder(report_params) - V2::ReportBuilder.new( - Current.account, - report_params.merge( - { - since: params[:since], - until: params[:until], - business_hours: ActiveModel::Type::Boolean.new.cast(params[:business_hours]) - } - ) + private + + def build_params(base_params) + base_params.merge( + { + since: params[:since], + until: params[:until], + business_hours: ActiveModel::Type::Boolean.new.cast(params[:business_hours]) + } ) end - def generate_report(report_params) - report_builder(report_params).short_summary + def report_builder(report_params) + V2::ReportBuilder.new(Current.account, build_params(report_params)) end - private - - def generate_readable_report_metrics(report_metric) + def generate_readable_report_metrics(report) [ - report_metric[:conversations_count], - Reports::TimeFormatPresenter.new(report_metric[:avg_first_response_time]).format, - Reports::TimeFormatPresenter.new(report_metric[:avg_resolution_time]).format, - Reports::TimeFormatPresenter.new(report_metric[:reply_time]).format, - report_metric[:resolutions_count] + report[:conversations_count], + Reports::TimeFormatPresenter.new(report[:avg_first_response_time]).format, + Reports::TimeFormatPresenter.new(report[:avg_resolution_time]).format, + Reports::TimeFormatPresenter.new(report[:avg_reply_time]).format, + report[:resolved_conversations_count] ] end end diff --git a/app/helpers/filter_helper.rb b/app/helpers/filters/filter_helper.rb similarity index 90% rename from app/helpers/filter_helper.rb rename to app/helpers/filters/filter_helper.rb index 9b5cac684..fe03dae28 100644 --- a/app/helpers/filter_helper.rb +++ b/app/helpers/filters/filter_helper.rb @@ -1,4 +1,4 @@ -module FilterHelper +module Filters::FilterHelper def build_condition_query(model_filters, query_hash, current_index) current_filter = model_filters[query_hash['attribute_key']] @@ -89,4 +89,18 @@ module FilterHelper operator = condition['query_operator'].upcase raise CustomExceptions::CustomFilter::InvalidQueryOperator.new({}) unless %w[AND OR].include?(operator) end + + def conversation_status_values(values) + return Conversation.statuses.values if values.include?('all') + + values.map { |x| Conversation.statuses[x.to_sym] } + end + + def conversation_priority_values(values) + values.map { |x| Conversation.priorities[x.to_sym] } + end + + def message_type_values(values) + values.map { |x| Message.message_types[x.to_sym] } + end end diff --git a/app/helpers/linear/integration_helper.rb b/app/helpers/linear/integration_helper.rb new file mode 100644 index 000000000..67df836ce --- /dev/null +++ b/app/helpers/linear/integration_helper.rb @@ -0,0 +1,47 @@ +module Linear::IntegrationHelper + # Generates a signed JWT token for Linear integration + # + # @param account_id [Integer] The account ID to encode in the token + # @return [String, nil] The encoded JWT token or nil if client secret is missing + def generate_linear_token(account_id) + return if client_secret.blank? + + JWT.encode(token_payload(account_id), client_secret, 'HS256') + rescue StandardError => e + Rails.logger.error("Failed to generate Linear token: #{e.message}") + nil + end + + def token_payload(account_id) + { + sub: account_id, + iat: Time.current.to_i + } + end + + # Verifies and decodes a Linear JWT token + # + # @param token [String] The JWT token to verify + # @return [Integer, nil] The account ID from the token or nil if invalid + def verify_linear_token(token) + return if token.blank? || client_secret.blank? + + decode_token(token, client_secret) + end + + private + + def client_secret + @client_secret ||= GlobalConfigService.load('LINEAR_CLIENT_SECRET', nil) + end + + def decode_token(token, secret) + JWT.decode(token, secret, true, { + algorithm: 'HS256', + verify_expiration: true + }).first['sub'] + rescue StandardError => e + Rails.logger.error("Unexpected error verifying Linear token: #{e.message}") + nil + end +end diff --git a/app/helpers/portal_helper.rb b/app/helpers/portal_helper.rb index 342bb62aa..2b1ab7b21 100644 --- a/app/helpers/portal_helper.rb +++ b/app/helpers/portal_helper.rb @@ -5,8 +5,7 @@ module PortalHelper end def generate_portal_bg(portal_color, theme) - bg_image = theme == 'dark' ? 'hexagon-dark.svg' : 'hexagon-light.svg' - "url(/assets/images/hc/#{bg_image}) #{generate_portal_bg_color(portal_color, theme)}" + generate_portal_bg_color(portal_color, theme) end def generate_gradient_to_bottom(theme) diff --git a/app/helpers/super_admin/account_features_helper.rb b/app/helpers/super_admin/account_features_helper.rb index 759aec134..c24e65e68 100644 --- a/app/helpers/super_admin/account_features_helper.rb +++ b/app/helpers/super_admin/account_features_helper.rb @@ -6,4 +6,47 @@ module SuperAdmin::AccountFeaturesHelper def self.account_premium_features account_features.filter { |feature| feature['premium'] }.pluck('name') end + + # Returns a hash mapping feature names to their display names + def self.feature_display_names + account_features.each_with_object({}) do |feature, hash| + hash[feature['name']] = feature['display_name'] + end + end + + def self.filter_internal_features(features) + return features if GlobalConfig.get_value('DEPLOYMENT_ENV') == 'cloud' + + internal_features = account_features.select { |f| f['chatwoot_internal'] }.pluck('name') + features.except(*internal_features) + end + + def self.filter_deprecated_features(features) + deprecated_features = account_features.select { |f| f['deprecated'] }.pluck('name') + features.except(*deprecated_features) + end + + def self.sort_and_transform_features(features, display_names) + features.sort_by { |key, _| display_names[key] || key } + .to_h + .transform_keys { |key| [key, display_names[key]] } + end + + def self.partition_features(features) + filtered = filter_internal_features(features) + filtered = filter_deprecated_features(filtered) + display_names = feature_display_names + + regular, premium = filtered.partition { |key, _value| account_premium_features.exclude?(key) } + + [ + sort_and_transform_features(regular, display_names), + sort_and_transform_features(premium, display_names) + ] + end + + def self.filtered_features(features) + regular, premium = partition_features(features) + regular.merge(premium) + end end diff --git a/app/javascript/dashboard/App.vue b/app/javascript/dashboard/App.vue index c32cb98d2..ae50055fb 100644 --- a/app/javascript/dashboard/App.vue +++ b/app/javascript/dashboard/App.vue @@ -14,6 +14,7 @@ import WootSnackbarBox from './components/SnackbarContainer.vue'; import { setColorTheme } from './helper/themeHelper'; import { isOnOnboardingView } from 'v3/helpers/RouteHelper'; import { useAccount } from 'dashboard/composables/useAccount'; +import { useFontSize } from 'dashboard/composables/useFontSize'; import { registerSubscription, verifyServiceWorkerExistence, @@ -37,8 +38,15 @@ export default { const router = useRouter(); const store = useStore(); const { accountId } = useAccount(); + // Use the font size composable (it automatically sets up the watcher) + const { currentFontSize } = useFontSize(); - return { router, store, currentAccountId: accountId }; + return { + router, + store, + currentAccountId: accountId, + currentFontSize, + }; }, data() { return { diff --git a/app/javascript/dashboard/api/captain/assistant.js b/app/javascript/dashboard/api/captain/assistant.js new file mode 100644 index 000000000..ce636e526 --- /dev/null +++ b/app/javascript/dashboard/api/captain/assistant.js @@ -0,0 +1,19 @@ +/* global axios */ +import ApiClient from '../ApiClient'; + +class CaptainAssistant extends ApiClient { + constructor() { + super('captain/assistants', { accountScoped: true }); + } + + get({ page = 1, searchKey } = {}) { + return axios.get(this.url, { + params: { + page, + searchKey, + }, + }); + } +} + +export default new CaptainAssistant(); diff --git a/app/javascript/dashboard/api/captain/bulkActions.js b/app/javascript/dashboard/api/captain/bulkActions.js new file mode 100644 index 000000000..fd69a1108 --- /dev/null +++ b/app/javascript/dashboard/api/captain/bulkActions.js @@ -0,0 +1,9 @@ +import ApiClient from '../ApiClient'; + +class CaptainBulkActionsAPI extends ApiClient { + constructor() { + super('captain/bulk_actions', { accountScoped: true }); + } +} + +export default new CaptainBulkActionsAPI(); diff --git a/app/javascript/dashboard/api/captain/document.js b/app/javascript/dashboard/api/captain/document.js new file mode 100644 index 000000000..dc22b0c32 --- /dev/null +++ b/app/javascript/dashboard/api/captain/document.js @@ -0,0 +1,20 @@ +/* global axios */ +import ApiClient from '../ApiClient'; + +class CaptainDocument extends ApiClient { + constructor() { + super('captain/documents', { accountScoped: true }); + } + + get({ page = 1, searchKey, assistantId } = {}) { + return axios.get(this.url, { + params: { + page, + searchKey, + assistant_id: assistantId, + }, + }); + } +} + +export default new CaptainDocument(); diff --git a/app/javascript/dashboard/api/captain/inboxes.js b/app/javascript/dashboard/api/captain/inboxes.js new file mode 100644 index 000000000..e0a1efdfe --- /dev/null +++ b/app/javascript/dashboard/api/captain/inboxes.js @@ -0,0 +1,26 @@ +/* global axios */ +import ApiClient from '../ApiClient'; + +class CaptainInboxes extends ApiClient { + constructor() { + super('captain/assistants', { accountScoped: true }); + } + + get({ assistantId } = {}) { + return axios.get(`${this.url}/${assistantId}/inboxes`); + } + + create(params = {}) { + const { assistantId, inboxId } = params; + return axios.post(`${this.url}/${assistantId}/inboxes`, { + inbox: { inbox_id: inboxId }, + }); + } + + delete(params = {}) { + const { assistantId, inboxId } = params; + return axios.delete(`${this.url}/${assistantId}/inboxes/${inboxId}`); + } +} + +export default new CaptainInboxes(); diff --git a/app/javascript/dashboard/api/captain/response.js b/app/javascript/dashboard/api/captain/response.js new file mode 100644 index 000000000..e3c42757a --- /dev/null +++ b/app/javascript/dashboard/api/captain/response.js @@ -0,0 +1,22 @@ +/* global axios */ +import ApiClient from '../ApiClient'; + +class CaptainResponses extends ApiClient { + constructor() { + super('captain/assistant_responses', { accountScoped: true }); + } + + get({ page = 1, searchKey, assistantId, documentId, status } = {}) { + return axios.get(this.url, { + params: { + page, + searchKey, + assistant_id: assistantId, + document_id: documentId, + status, + }, + }); + } +} + +export default new CaptainResponses(); diff --git a/app/javascript/dashboard/api/inbox/conversation.js b/app/javascript/dashboard/api/inbox/conversation.js index ebc6176e7..39546096f 100644 --- a/app/javascript/dashboard/api/inbox/conversation.js +++ b/app/javascript/dashboard/api/inbox/conversation.js @@ -133,6 +133,14 @@ class ConversationApi extends ApiClient { getAllAttachments(conversationId) { return axios.get(`${this.url}/${conversationId}/attachments`); } + + requestCopilot(conversationId, body) { + return axios.post(`${this.url}/${conversationId}/copilot`, body); + } + + getInboxAssistant(conversationId) { + return axios.get(`${this.url}/${conversationId}/inbox_assistant`); + } } export default new ConversationApi(); diff --git a/app/javascript/dashboard/api/integrations.js b/app/javascript/dashboard/api/integrations.js index b78a2ea7e..2b816e603 100644 --- a/app/javascript/dashboard/api/integrations.js +++ b/app/javascript/dashboard/api/integrations.js @@ -32,14 +32,6 @@ class IntegrationsAPI extends ApiClient { deleteHook(hookId) { return axios.delete(`${this.baseUrl()}/integrations/hooks/${hookId}`); } - - requestCaptain(body) { - return axios.post(`${this.baseUrl()}/integrations/captain/proxy`, body); - } - - requestCaptainCopilot(body) { - return axios.post(`${this.baseUrl()}/integrations/captain/copilot`, body); - } } export default new IntegrationsAPI(); diff --git a/app/javascript/dashboard/api/reports.js b/app/javascript/dashboard/api/reports.js index 52fa7f444..c87dfc82b 100644 --- a/app/javascript/dashboard/api/reports.js +++ b/app/javascript/dashboard/api/reports.js @@ -61,9 +61,9 @@ class ReportsAPI extends ApiClient { }); } - getConversationTrafficCSV() { + getConversationTrafficCSV({ daysBefore = 6 } = {}) { return axios.get(`${this.url}/conversation_traffic`, { - params: { timezone_offset: getTimeOffset() }, + params: { timezone_offset: getTimeOffset(), days_before: daysBefore }, }); } diff --git a/app/javascript/dashboard/api/search.js b/app/javascript/dashboard/api/search.js index 7dc98dcf2..7abb584c0 100644 --- a/app/javascript/dashboard/api/search.js +++ b/app/javascript/dashboard/api/search.js @@ -14,26 +14,29 @@ class SearchAPI extends ApiClient { }); } - contacts({ q }) { + contacts({ q, page = 1 }) { return axios.get(`${this.url}/contacts`, { params: { q, + page: page, }, }); } - conversations({ q }) { + conversations({ q, page = 1 }) { return axios.get(`${this.url}/conversations`, { params: { q, + page: page, }, }); } - messages({ q }) { + messages({ q, page = 1 }) { return axios.get(`${this.url}/messages`, { params: { q, + page: page, }, }); } diff --git a/app/javascript/dashboard/api/summaryReports.js b/app/javascript/dashboard/api/summaryReports.js new file mode 100644 index 000000000..f772ef86f --- /dev/null +++ b/app/javascript/dashboard/api/summaryReports.js @@ -0,0 +1,40 @@ +/* global axios */ +import ApiClient from './ApiClient'; + +class SummaryReportsAPI extends ApiClient { + constructor() { + super('summary_reports', { accountScoped: true, apiVersion: 'v2' }); + } + + getTeamReports({ since, until, businessHours } = {}) { + return axios.get(`${this.url}/team`, { + params: { + since, + until, + business_hours: businessHours, + }, + }); + } + + getAgentReports({ since, until, businessHours } = {}) { + return axios.get(`${this.url}/agent`, { + params: { + since, + until, + business_hours: businessHours, + }, + }); + } + + getInboxReports({ since, until, businessHours } = {}) { + return axios.get(`${this.url}/inbox`, { + params: { + since, + until, + business_hours: businessHours, + }, + }); + } +} + +export default new SummaryReportsAPI(); diff --git a/app/javascript/dashboard/assets/scss/plugins/_dropdown.scss b/app/javascript/dashboard/assets/scss/plugins/_dropdown.scss index e08099ae6..9ab51d12a 100644 --- a/app/javascript/dashboard/assets/scss/plugins/_dropdown.scss +++ b/app/javascript/dashboard/assets/scss/plugins/_dropdown.scss @@ -1,7 +1,7 @@ .dropdown-pane { - @apply border rounded-lg hidden relative invisible shadow-lg border-slate-25 dark:border-slate-700 box-content p-2 w-fit z-[9999]; + @apply border rounded-lg hidden relative invisible shadow-lg border-n-strong dark:border-n-strong box-content p-2 w-fit z-[9999]; &.dropdown-pane--open { - @apply bg-white absolute dark:bg-slate-800 block visible; + @apply bg-n-alpha-3 backdrop-blur-[100px] absolute block visible; } } diff --git a/app/javascript/dashboard/assets/scss/super_admin/index.scss b/app/javascript/dashboard/assets/scss/super_admin/index.scss index 91f7835d0..ddac5f7c8 100644 --- a/app/javascript/dashboard/assets/scss/super_admin/index.scss +++ b/app/javascript/dashboard/assets/scss/super_admin/index.scss @@ -6,3 +6,209 @@ body { font-family: Inter, -apple-system, system-ui, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif !important; } + +@layer base { + // FIXME: Use a common color file for all packs + // scss-lint:disable PropertySortOrder + :root { + --slate-1: 252 252 253; + --slate-2: 249 249 251; + --slate-3: 240 240 243; + --slate-4: 232 232 236; + --slate-5: 224 225 230; + --slate-6: 217 217 224; + --slate-7: 205 206 214; + --slate-8: 185 187 198; + --slate-9: 139 141 152; + --slate-10: 128 131 141; + --slate-11: 96 100 108; + --slate-12: 28 32 36; + + --iris-1: 253 253 255; + --iris-2: 248 248 255; + --iris-3: 240 241 254; + --iris-4: 230 231 255; + --iris-5: 218 220 255; + --iris-6: 203 205 255; + --iris-7: 184 186 248; + --iris-8: 155 158 240; + --iris-9: 91 91 214; + --iris-10: 81 81 205; + --iris-11: 87 83 198; + --iris-12: 39 41 98; + + --ruby-1: 255 252 253; + --ruby-2: 255 247 248; + --ruby-3: 254 234 237; + --ruby-4: 255 220 225; + --ruby-5: 255 206 214; + --ruby-6: 248 191 200; + --ruby-7: 239 172 184; + --ruby-8: 229 146 163; + --ruby-9: 229 70 102; + --ruby-10: 220 59 93; + --ruby-11: 202 36 77; + --ruby-12: 100 23 43; + + --amber-1: 254 253 251; + --amber-2: 254 251 233; + --amber-3: 255 247 194; + --amber-4: 255 238 156; + --amber-5: 251 229 119; + --amber-6: 243 214 115; + --amber-7: 233 193 98; + --amber-8: 226 163 54; + --amber-9: 255 197 61; + --amber-10: 255 186 24; + --amber-11: 171 100 0; + --amber-12: 79 52 34; + + --teal-1: 250 254 253; + --teal-2: 243 251 249; + --teal-3: 224 248 243; + --teal-4: 204 243 234; + --teal-5: 184 234 224; + --teal-6: 161 222 210; + --teal-7: 131 205 193; + --teal-8: 83 185 171; + --teal-9: 18 165 148; + --teal-10: 13 155 138; + --teal-11: 0 133 115; + --teal-12: 13 61 56; + + --gray-1: 252 252 252; + --gray-2: 249 249 249; + --gray-3: 240 240 240; + --gray-4: 232 232 232; + --gray-5: 224 224 224; + --gray-6: 217 217 217; + --gray-7: 206 206 206; + --gray-8: 187 187 187; + --gray-9: 141 141 141; + --gray-10: 131 131 131; + --gray-11: 100 100 100; + --gray-12: 32 32 32; + + --background-color: 253 253 253; + --text-blue: 8 109 224; + --border-container: 236 236 236; + --border-strong: 235 235 235; + --border-weak: 234 234 234; + --solid-1: 255 255 255; + --solid-2: 255 255 255; + --solid-3: 255 255 255; + --solid-active: 255 255 255; + --solid-amber: 252 232 193; + --solid-blue: 218 236 255; + --solid-iris: 230 231 255; + + --alpha-1: 67, 67, 67, 0.06; + --alpha-2: 201, 202, 207, 0.15; + --alpha-3: 255, 255, 255, 0.96; + --black-alpha-1: 0, 0, 0, 0.12; + --black-alpha-2: 0, 0, 0, 0.04; + --border-blue: 39, 129, 246, 0.5; + --white-alpha: 255, 255, 255, 0.8; + } + + .dark { + --slate-1: 17 17 19; + --slate-2: 24 25 27; + --slate-3: 33 34 37; + --slate-4: 39 42 45; + --slate-5: 46 49 53; + --slate-6: 54 58 63; + --slate-7: 67 72 78; + --slate-8: 90 97 105; + --slate-9: 105 110 119; + --slate-10: 119 123 132; + --slate-11: 176 180 186; + --slate-12: 237 238 240; + + --iris-1: 19 19 30; + --iris-2: 23 22 37; + --iris-3: 32 34 72; + --iris-4: 38 42 101; + --iris-5: 48 51 116; + --iris-6: 61 62 130; + --iris-7: 74 74 149; + --iris-8: 89 88 177; + --iris-9: 91 91 214; + --iris-10: 84 114 228; + --iris-11: 158 177 255; + --iris-12: 224 223 254; + + --ruby-1: 25 17 19; + --ruby-2: 30 21 23; + --ruby-3: 58 20 30; + --ruby-4: 78 19 37; + --ruby-5: 94 26 46; + --ruby-6: 111 37 57; + --ruby-7: 136 52 71; + --ruby-8: 179 68 90; + --ruby-9: 229 70 102; + --ruby-10: 236 90 114; + --ruby-11: 255 148 157; + --ruby-12: 254 210 225; + + --amber-1: 22 18 12; + --amber-2: 29 24 15; + --amber-3: 48 32 8; + --amber-4: 63 39 0; + --amber-5: 77 48 0; + --amber-6: 92 61 5; + --amber-7: 113 79 25; + --amber-8: 143 100 36; + --amber-9: 255 197 61; + --amber-10: 255 214 10; + --amber-11: 255 202 22; + --amber-12: 255 231 179; + + --teal-1: 13 21 20; + --teal-2: 17 28 27; + --teal-3: 13 45 42; + --teal-4: 2 59 55; + --teal-5: 8 72 67; + --teal-6: 20 87 80; + --teal-7: 28 105 97; + --teal-8: 32 126 115; + --teal-9: 18 165 148; + --teal-10: 14 179 158; + --teal-11: 11 216 182; + --teal-12: 173 240 221; + + --gray-1: 17 17 17; + --gray-2: 25 25 25; + --gray-3: 34 34 34; + --gray-4: 42 42 42; + --gray-5: 49 49 49; + --gray-6: 58 58 58; + --gray-7: 72 72 72; + --gray-8: 96 96 96; + --gray-9: 110 110 110; + --gray-10: 123 123 123; + --gray-11: 180 180 180; + --gray-12: 238 238 238; + + --background-color: 18 18 19; + --border-strong: 52 52 52; + --border-weak: 38 38 42; + --solid-1: 23 23 26; + --solid-2: 29 30 36; + --solid-3: 44 45 54; + --solid-active: 53 57 66; + --solid-amber: 42 37 30; + --solid-blue: 16 49 91; + --solid-iris: 38 42 101; + --text-blue: 126 182 255; + + --alpha-1: 36, 36, 36, 0.8; + --alpha-2: 139, 147, 182, 0.15; + --alpha-3: 36, 38, 45, 0.9; + --black-alpha-1: 0, 0, 0, 0.3; + --black-alpha-2: 0, 0, 0, 0.2; + --border-blue: 39, 129, 246, 0.5; + --border-container: 236, 236, 236, 0; + --white-alpha: 255, 255, 255, 0.1; + } +} diff --git a/app/javascript/dashboard/assets/scss/widgets/_base.scss b/app/javascript/dashboard/assets/scss/widgets/_base.scss index d35c1cfc9..9367e8b2d 100644 --- a/app/javascript/dashboard/assets/scss/widgets/_base.scss +++ b/app/javascript/dashboard/assets/scss/widgets/_base.scss @@ -82,7 +82,7 @@ input[type='url']:not(.reset-base) { } input[type='file'] { - @apply bg-white dark:bg-slate-800 leading-[1.15] mb-4; + @apply bg-white dark:bg-n-solid-1 leading-[1.15] mb-4; } // Select @@ -141,11 +141,16 @@ code { @apply text-xs border-0; &.hljs { - @apply bg-slate-50 dark:bg-slate-700 text-slate-800 dark:text-slate-50 rounded-lg p-5; + @apply bg-n-slate-3 dark:bg-n-solid-3 text-slate-800 dark:text-slate-50 rounded-lg p-5; .hljs-number, .hljs-string { @apply text-red-800 dark:text-red-400; } + + .hljs-name, + .hljs-tag { + @apply text-n-slate-11; + } } } diff --git a/app/javascript/dashboard/assets/scss/widgets/_buttons.scss b/app/javascript/dashboard/assets/scss/widgets/_buttons.scss index 640e1395f..1213e7c26 100644 --- a/app/javascript/dashboard/assets/scss/widgets/_buttons.scss +++ b/app/javascript/dashboard/assets/scss/widgets/_buttons.scss @@ -31,7 +31,7 @@ button { } .button { - @apply items-center bg-woot-500 dark:bg-woot-500 px-2.5 text-white dark:text-white inline-flex h-10 mb-0 gap-2 font-medium; + @apply items-center bg-n-brand px-2.5 text-white dark:text-white inline-flex h-10 mb-0 gap-2 font-medium; .button__content { @apply w-full whitespace-nowrap overflow-hidden text-ellipsis; @@ -42,8 +42,10 @@ button { } } - &:hover { - @apply bg-woot-600 dark:bg-woot-600; + &:hover:not(.secondary):not(.success):not(.alert):not(.warning):not( + .clear + ):not(.smooth):not(.hollow) { + @apply bg-n-brand/80 dark:bg-n-brand/80; } &:disabled, @@ -52,23 +54,23 @@ button { } &.success { - @apply bg-[#44ce4b] dark:bg-[#44ce4b] text-white dark:text-white; + @apply bg-n-teal-9 text-white dark:text-white; } &.secondary { - @apply bg-slate-700 dark:bg-slate-600 text-white dark:text-white; + @apply bg-n-solid-3 text-white dark:text-white; } &.primary { - @apply bg-woot-500 dark:bg-woot-500 text-white dark:text-white; + @apply bg-n-brand text-white dark:text-white; } &.clear { - @apply text-woot-500 dark:text-woot-500 bg-transparent dark:bg-transparent; + @apply text-n-blue-text dark:text-n-blue-text bg-transparent dark:bg-transparent; } &.alert { - @apply bg-red-500 dark:bg-red-500 text-white dark:text-white; + @apply bg-n-ruby-9 text-white dark:text-white; &.clear { @apply bg-transparent dark:bg-transparent; @@ -76,7 +78,7 @@ button { } &.warning { - @apply bg-[#ffc532] dark:bg-[#ffc532] text-white dark:text-white; + @apply bg-n-amber-9 text-white dark:text-white; &.clear { @apply bg-transparent dark:bg-transparent; @@ -115,114 +117,74 @@ button { } &.hollow { - @apply border border-woot-500 bg-transparent dark:bg-transparent dark:border-woot-500 text-woot-500 dark:text-woot-500 hover:bg-woot-50 dark:hover:bg-woot-900; + @apply border border-n-brand/40 bg-transparent text-n-blue-text hover:bg-n-brand/20; &.secondary { - @apply text-slate-700 border-slate-100 dark:border-slate-800 dark:text-slate-100 hover:bg-slate-50 dark:hover:bg-slate-700; + @apply text-n-slate-12 border-n-slate-5 hover:bg-n-slate-5; } &.success { - @apply text-green-700 dark:text-green-400 border-green-100 dark:border-green-600 hover:bg-green-50 dark:hover:bg-green-800; + @apply text-n-teal-9 border-n-teal-8 hover:bg-n-teal-5; } &.alert { - @apply text-red-700 dark:text-red-400 border-red-100 dark:border-red-600 hover:bg-red-50 dark:hover:bg-red-800; + @apply text-n-ruby-9 border-n-ruby-8 hover:bg-n-ruby-5; } &.warning { - @apply text-yellow-600 dark:text-yellow-600 border-yellow-600 dark:border-yellow-700 hover:bg-yellow-50 dark:hover:bg-yellow-800; - } - - &:hover { - @apply bg-woot-75 dark:bg-woot-800 border-slate-100 dark:border-woot-600 dark:text-woot-400; - - &.secondary { - @apply border-slate-100 dark:border-slate-700 text-slate-800 dark:text-slate-100; - } - - &.success { - @apply border-slate-100 dark:border-slate-700 text-green-800 dark:text-green-100; - } - - &.alert { - @apply border-slate-100 dark:border-slate-700 text-red-700 dark:text-red-100; - } - - &.warning { - @apply border-slate-100 dark:border-slate-700 text-yellow-700 dark:text-yellow-500; - } + @apply text-n-amber-9 border-n-amber-8 hover:bg-n-amber-5; } } // Smooth style &.smooth { - @apply bg-woot-50 dark:bg-woot-800 text-woot-700 dark:text-woot-100 hover:text-woot-700 dark:hover:text-woot-700 hover:bg-woot-100 dark:hover:bg-woot-900; + @apply bg-n-brand/10 dark:bg-n-brand/30 text-n-blue-text hover:bg-n-brand/20 dark:hover:bg-n-brand/40; &.secondary { - @apply bg-slate-50 dark:bg-slate-700 text-slate-700 dark:text-slate-100 hover:bg-slate-100 dark:hover:bg-slate-800; + @apply bg-n-slate-4 text-n-slate-11 hover:text-n-slate-11 hover:bg-n-slate-5; } &.success { - @apply bg-green-50 dark:bg-green-700 text-green-700 dark:text-green-100 hover:bg-green-100 dark:hover:bg-green-800 hover:text-green-800 dark:hover:text-green-100; + @apply bg-n-teal-4 text-n-teal-11 hover:text-n-teal-11 hover:bg-n-teal-5; } &.alert { - @apply bg-red-50 dark:bg-red-700 dark:bg-opacity-50 text-red-700 dark:text-red-100 hover:bg-red-100 dark:hover:bg-red-800 dark:hover:bg-opacity-30; + @apply bg-n-ruby-4 text-n-ruby-11 hover:text-n-ruby-11 hover:bg-n-ruby-5; } &.warning { - @apply bg-yellow-100 dark:bg-yellow-100 text-yellow-700 dark:text-yellow-700 hover:bg-yellow-200 dark:hover:bg-yellow-200; + @apply bg-n-amber-4 text-n-amber-11 hover:text-n-amber-11 hover:bg-n-amber-5; } } &.clear { - @apply text-woot-500 dark:text-woot-500; + @apply text-n-blue-text hover:bg-n-brand/10 dark:hover:bg-n-brand/30; &.secondary { - @apply text-slate-700 dark:text-slate-100; + @apply text-n-slate-12 hover:bg-n-slate-4; } &.success { - @apply text-green-700 dark:text-green-100; + @apply text-n-teal-10 hover:bg-n-teal-4; } &.alert { - @apply text-red-700 dark:text-red-100; + @apply text-n-ruby-11 hover:bg-n-ruby-4; } &.warning { - @apply text-yellow-700 dark:text-yellow-600; - } - - &:hover { - @apply hover:bg-woot-50 dark:hover:bg-woot-900/50 hover:text-woot-500 dark:hover:text-woot-100; - - &.secondary { - @apply hover:bg-slate-50 dark:hover:bg-slate-700 hover:text-slate-800 dark:hover:text-slate-100; - } - - &.success { - @apply hover:bg-green-50 dark:hover:bg-green-800 hover:text-green-800 dark:hover:text-green-100; - } - - &.alert { - @apply hover:bg-red-50 dark:hover:bg-red-800 hover:text-red-700 dark:hover:text-red-100; - } - - &.warning { - @apply hover:bg-yellow-100 dark:hover:bg-yellow-800 hover:text-yellow-700 dark:hover:text-yellow-600; - } + @apply text-n-amber-11 hover:bg-n-amber-4; } &:active { &.secondary { - @apply active:bg-slate-100 dark:active:bg-slate-900; + @apply active:bg-n-slate-3 dark:active:bg-n-slate-7; } } &:focus { &.secondary { - @apply focus:bg-slate-50 dark:focus:bg-slate-700; + @apply focus:bg-n-slate-4 dark:focus:bg-n-slate-6; } } } diff --git a/app/javascript/dashboard/assets/scss/widgets/_tabs.scss b/app/javascript/dashboard/assets/scss/widgets/_tabs.scss index 01ccaf671..72a2e6be8 100644 --- a/app/javascript/dashboard/assets/scss/widgets/_tabs.scss +++ b/app/javascript/dashboard/assets/scss/widgets/_tabs.scss @@ -3,7 +3,7 @@ } .tabs--container--with-border { - @apply border-b border-slate-50 dark:border-slate-800/50; + @apply border-b border-n-weak; } .tabs--container--compact.tab--chat-type { @@ -42,7 +42,7 @@ @apply flex-shrink-0 my-0 mx-2; .badge { - @apply bg-slate-50 dark:bg-slate-800 rounded-md text-slate-600 dark:text-slate-100 h-5 flex items-center justify-center text-xxs font-semibold my-0 mx-1 px-1 py-0; + @apply bg-n-alpha-black2 dark:bg-n-solid-3 rounded-md text-n-slate-11 h-5 flex items-center justify-center text-xxs font-semibold my-0 mx-1 px-1 py-0; } &:first-child { @@ -56,22 +56,22 @@ &:hover, &:focus { a { - @apply text-slate-800 dark:text-slate-100; + @apply text-n-slate-12; } } a { - @apply flex items-center flex-row border-b py-2.5 select-none cursor-pointer border-transparent text-slate-500 dark:text-slate-200 text-sm top-[1px] relative; + @apply flex items-center flex-row border-b py-2.5 select-none cursor-pointer border-transparent text-n-slate-11 text-sm top-[1px] relative; transition: border-color 0.15s $swift-ease-out-function; } &.is-active { a { - @apply border-b border-woot-500 text-woot-500 dark:text-woot-500; + @apply border-b border-n-brand text-n-blue-text; } .badge { - @apply bg-woot-50 dark:bg-woot-500 text-woot-500 dark:text-woot-50 dark:bg-opacity-40; + @apply bg-n-brand/10 dark:bg-n-brand/20 text-n-blue-text; } } } diff --git a/app/javascript/dashboard/components-next/Campaigns/CampaignCard/CampaignCard.vue b/app/javascript/dashboard/components-next/Campaigns/CampaignCard/CampaignCard.vue index 229553253..304e55347 100644 --- a/app/javascript/dashboard/components-next/Campaigns/CampaignCard/CampaignCard.vue +++ b/app/javascript/dashboard/components-next/Campaigns/CampaignCard/CampaignCard.vue @@ -98,7 +98,7 @@ const inboxIcon = computed(() => {
diff --git a/app/javascript/dashboard/components-next/Campaigns/CampaignLayout.vue b/app/javascript/dashboard/components-next/Campaigns/CampaignLayout.vue index 3169dbc3e..184e8bd10 100644 --- a/app/javascript/dashboard/components-next/Campaigns/CampaignLayout.vue +++ b/app/javascript/dashboard/components-next/Campaigns/CampaignLayout.vue @@ -22,7 +22,7 @@ const handleButtonClick = () => {