mirror of
				https://github.com/lingble/chatwoot.git
				synced 2025-11-03 20:48:07 +00:00 
			
		
		
		
	Merge branch 'develop' into feat/voice-channel
This commit is contained in:
		@@ -6,6 +6,13 @@
 | 
			
		||||
# Use `rake secret` to generate this variable
 | 
			
		||||
SECRET_KEY_BASE=replace_with_lengthy_secure_hex
 | 
			
		||||
 | 
			
		||||
# Active Record Encryption keys (required for MFA/2FA functionality)
 | 
			
		||||
# Generate these keys by running: rails db:encryption:init
 | 
			
		||||
# IMPORTANT: Use different keys for each environment (development, staging, production)
 | 
			
		||||
# ACTIVE_RECORD_ENCRYPTION_PRIMARY_KEY=
 | 
			
		||||
# ACTIVE_RECORD_ENCRYPTION_DETERMINISTIC_KEY=
 | 
			
		||||
# ACTIVE_RECORD_ENCRYPTION_KEY_DERIVATION_SALT=
 | 
			
		||||
 | 
			
		||||
# Replace with the URL you are planning to use for your app
 | 
			
		||||
FRONTEND_URL=http://0.0.0.0:3000
 | 
			
		||||
# To use a dedicated URL for help center pages
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										99
									
								
								.github/workflows/run_mfa_spec.yml
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						
									
										99
									
								
								.github/workflows/run_mfa_spec.yml
									
									
									
									
										vendored
									
									
										Normal file
									
								
							@@ -0,0 +1,99 @@
 | 
			
		||||
name: Run MFA Tests
 | 
			
		||||
permissions:
 | 
			
		||||
  contents: read
 | 
			
		||||
 | 
			
		||||
on:
 | 
			
		||||
  pull_request:
 | 
			
		||||
 | 
			
		||||
# If two pushes happen within a short time in the same PR, cancel the run of the oldest push
 | 
			
		||||
concurrency:
 | 
			
		||||
  group: pr-${{ github.workflow }}-${{ github.head_ref }}
 | 
			
		||||
  cancel-in-progress: true
 | 
			
		||||
 | 
			
		||||
jobs:
 | 
			
		||||
  test:
 | 
			
		||||
    runs-on: ubuntu-22.04
 | 
			
		||||
    # Only run if MFA test keys are available
 | 
			
		||||
    if: github.event_name == 'workflow_dispatch' || (github.repository == 'chatwoot/chatwoot' && github.actor != 'dependabot[bot]')
 | 
			
		||||
 | 
			
		||||
    services:
 | 
			
		||||
      postgres:
 | 
			
		||||
        image: pgvector/pgvector:pg15
 | 
			
		||||
        env:
 | 
			
		||||
          POSTGRES_USER: postgres
 | 
			
		||||
          POSTGRES_PASSWORD: ''
 | 
			
		||||
          POSTGRES_DB: postgres
 | 
			
		||||
          POSTGRES_HOST_AUTH_METHOD: trust
 | 
			
		||||
        ports:
 | 
			
		||||
          - 5432:5432
 | 
			
		||||
        options: >-
 | 
			
		||||
          --mount type=tmpfs,destination=/var/lib/postgresql/data
 | 
			
		||||
          --health-cmd pg_isready
 | 
			
		||||
          --health-interval 10s
 | 
			
		||||
          --health-timeout 5s
 | 
			
		||||
          --health-retries 5
 | 
			
		||||
      redis:
 | 
			
		||||
        image: redis
 | 
			
		||||
        ports:
 | 
			
		||||
          - 6379:6379
 | 
			
		||||
        options: --entrypoint redis-server
 | 
			
		||||
 | 
			
		||||
    env:
 | 
			
		||||
      RAILS_ENV: test
 | 
			
		||||
      POSTGRES_HOST: localhost
 | 
			
		||||
      # Active Record encryption keys required for MFA - test keys only, not for production use
 | 
			
		||||
      ACTIVE_RECORD_ENCRYPTION_PRIMARY_KEY: 'test_key_a6cde8f7b9c2d3e4f5a6b7c8d9e0f1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7'
 | 
			
		||||
      ACTIVE_RECORD_ENCRYPTION_DETERMINISTIC_KEY: 'test_key_b7def9a8c0d3e4f5a6b7c8d9e0f1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d8'
 | 
			
		||||
      ACTIVE_RECORD_ENCRYPTION_KEY_DERIVATION_SALT: 'test_salt_c8efa0b9d1e4f5a6b7c8d9e0f1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d9'
 | 
			
		||||
 | 
			
		||||
    steps:
 | 
			
		||||
      - uses: actions/checkout@v4
 | 
			
		||||
 | 
			
		||||
      - uses: ruby/setup-ruby@v1
 | 
			
		||||
        with:
 | 
			
		||||
          bundler-cache: true
 | 
			
		||||
 | 
			
		||||
      - name: Create database
 | 
			
		||||
        run: bundle exec rake db:create
 | 
			
		||||
 | 
			
		||||
      - name: Install pgvector extension
 | 
			
		||||
        run: |
 | 
			
		||||
          PGPASSWORD="" psql -h localhost -U postgres -d chatwoot_test -c "CREATE EXTENSION IF NOT EXISTS vector;"
 | 
			
		||||
 | 
			
		||||
      - name: Seed database
 | 
			
		||||
        run: bundle exec rake db:schema:load
 | 
			
		||||
 | 
			
		||||
      - name: Run MFA-related backend tests
 | 
			
		||||
        run: |
 | 
			
		||||
          bundle exec rspec \
 | 
			
		||||
            spec/services/mfa/token_service_spec.rb \
 | 
			
		||||
            spec/services/mfa/authentication_service_spec.rb \
 | 
			
		||||
            spec/requests/api/v1/profile/mfa_controller_spec.rb \
 | 
			
		||||
            spec/controllers/devise_overrides/sessions_controller_spec.rb \
 | 
			
		||||
            --profile=10 \
 | 
			
		||||
            --format documentation
 | 
			
		||||
        env:
 | 
			
		||||
          NODE_OPTIONS: --openssl-legacy-provider
 | 
			
		||||
 | 
			
		||||
      - name: Run MFA-related tests in user_spec
 | 
			
		||||
        run: |
 | 
			
		||||
          # Run specific MFA-related tests from user_spec
 | 
			
		||||
          bundle exec rspec spec/models/user_spec.rb \
 | 
			
		||||
            -e "two factor" \
 | 
			
		||||
            -e "2FA" \
 | 
			
		||||
            -e "MFA" \
 | 
			
		||||
            -e "otp" \
 | 
			
		||||
            -e "backup code" \
 | 
			
		||||
            --profile=10 \
 | 
			
		||||
            --format documentation
 | 
			
		||||
        env:
 | 
			
		||||
          NODE_OPTIONS: --openssl-legacy-provider
 | 
			
		||||
 | 
			
		||||
      - name: Upload test logs
 | 
			
		||||
        uses: actions/upload-artifact@v4
 | 
			
		||||
        if: failure()
 | 
			
		||||
        with:
 | 
			
		||||
          name: mfa-test-logs
 | 
			
		||||
          path: |
 | 
			
		||||
            log/test.log
 | 
			
		||||
            tmp/screenshots/
 | 
			
		||||
							
								
								
									
										2
									
								
								Gemfile
									
									
									
									
									
								
							
							
						
						
									
										2
									
								
								Gemfile
									
									
									
									
									
								
							@@ -78,6 +78,8 @@ gem 'barnes'
 | 
			
		||||
gem 'devise', '>= 4.9.4'
 | 
			
		||||
gem 'devise-secure_password', git: 'https://github.com/chatwoot/devise-secure_password', branch: 'chatwoot'
 | 
			
		||||
gem 'devise_token_auth', '>= 1.2.3'
 | 
			
		||||
# two-factor authentication
 | 
			
		||||
gem 'devise-two-factor', '>= 5.0.0'
 | 
			
		||||
# authorization
 | 
			
		||||
gem 'jwt'
 | 
			
		||||
gem 'pundit'
 | 
			
		||||
 
 | 
			
		||||
@@ -212,6 +212,11 @@ GEM
 | 
			
		||||
      railties (>= 4.1.0)
 | 
			
		||||
      responders
 | 
			
		||||
      warden (~> 1.2.3)
 | 
			
		||||
    devise-two-factor (6.1.0)
 | 
			
		||||
      activesupport (>= 7.0, < 8.1)
 | 
			
		||||
      devise (~> 4.0)
 | 
			
		||||
      railties (>= 7.0, < 8.1)
 | 
			
		||||
      rotp (~> 6.0)
 | 
			
		||||
    devise_token_auth (1.2.5)
 | 
			
		||||
      bcrypt (~> 3.0)
 | 
			
		||||
      devise (> 3.5.2, < 5)
 | 
			
		||||
@@ -722,7 +727,8 @@ GEM
 | 
			
		||||
    retriable (3.1.2)
 | 
			
		||||
    reverse_markdown (2.1.1)
 | 
			
		||||
      nokogiri
 | 
			
		||||
    rexml (3.4.1)
 | 
			
		||||
    rexml (3.4.4)
 | 
			
		||||
    rotp (6.3.0)
 | 
			
		||||
    rspec-core (3.13.0)
 | 
			
		||||
      rspec-support (~> 3.13.0)
 | 
			
		||||
    rspec-expectations (3.13.2)
 | 
			
		||||
@@ -1005,6 +1011,7 @@ DEPENDENCIES
 | 
			
		||||
  debug (~> 1.8)
 | 
			
		||||
  devise (>= 4.9.4)
 | 
			
		||||
  devise-secure_password!
 | 
			
		||||
  devise-two-factor (>= 5.0.0)
 | 
			
		||||
  devise_token_auth (>= 1.2.3)
 | 
			
		||||
  dotenv-rails (>= 3.0.0)
 | 
			
		||||
  down
 | 
			
		||||
 
 | 
			
		||||
@@ -52,3 +52,5 @@ class AgentBuilder
 | 
			
		||||
    }.compact))
 | 
			
		||||
  end
 | 
			
		||||
end
 | 
			
		||||
 | 
			
		||||
AgentBuilder.prepend_mod_with('AgentBuilder')
 | 
			
		||||
 
 | 
			
		||||
@@ -85,7 +85,8 @@ class Api::V1::Accounts::PortalsController < Api::V1::Accounts::BaseController
 | 
			
		||||
 | 
			
		||||
  def live_chat_widget_params
 | 
			
		||||
    permitted_params = params.permit(:inbox_id)
 | 
			
		||||
    return {} if permitted_params[:inbox_id].blank?
 | 
			
		||||
    return {} unless permitted_params.key?(:inbox_id)
 | 
			
		||||
    return { channel_web_widget_id: nil } if permitted_params[:inbox_id].blank?
 | 
			
		||||
 | 
			
		||||
    inbox = Inbox.find(permitted_params[:inbox_id])
 | 
			
		||||
    return {} unless inbox.web_widget?
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										68
									
								
								app/controllers/api/v1/profile/mfa_controller.rb
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										68
									
								
								app/controllers/api/v1/profile/mfa_controller.rb
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,68 @@
 | 
			
		||||
class Api::V1::Profile::MfaController < Api::BaseController
 | 
			
		||||
  before_action :check_mfa_feature_available
 | 
			
		||||
  before_action :check_mfa_enabled, only: [:destroy, :backup_codes]
 | 
			
		||||
  before_action :check_mfa_disabled, only: [:create, :verify]
 | 
			
		||||
  before_action :validate_otp, only: [:verify, :backup_codes, :destroy]
 | 
			
		||||
  before_action :validate_password, only: [:destroy]
 | 
			
		||||
 | 
			
		||||
  def show; end
 | 
			
		||||
 | 
			
		||||
  def create
 | 
			
		||||
    mfa_service.enable_two_factor!
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  def verify
 | 
			
		||||
    @backup_codes = mfa_service.verify_and_activate!
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  def destroy
 | 
			
		||||
    mfa_service.disable_two_factor!
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  def backup_codes
 | 
			
		||||
    @backup_codes = mfa_service.generate_backup_codes!
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  private
 | 
			
		||||
 | 
			
		||||
  def mfa_service
 | 
			
		||||
    @mfa_service ||= Mfa::ManagementService.new(user: current_user)
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  def check_mfa_enabled
 | 
			
		||||
    render_could_not_create_error(I18n.t('errors.mfa.not_enabled')) unless current_user.mfa_enabled?
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  def check_mfa_feature_available
 | 
			
		||||
    return if Chatwoot.mfa_enabled?
 | 
			
		||||
 | 
			
		||||
    render json: {
 | 
			
		||||
      error: I18n.t('errors.mfa.feature_unavailable')
 | 
			
		||||
    }, status: :forbidden
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  def check_mfa_disabled
 | 
			
		||||
    render_could_not_create_error(I18n.t('errors.mfa.already_enabled')) if current_user.mfa_enabled?
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  def validate_otp
 | 
			
		||||
    authenticated = Mfa::AuthenticationService.new(
 | 
			
		||||
      user: current_user,
 | 
			
		||||
      otp_code: mfa_params[:otp_code]
 | 
			
		||||
    ).authenticate
 | 
			
		||||
 | 
			
		||||
    return if authenticated
 | 
			
		||||
 | 
			
		||||
    render_could_not_create_error(I18n.t('errors.mfa.invalid_code'))
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  def validate_password
 | 
			
		||||
    return if current_user.valid_password?(mfa_params[:password])
 | 
			
		||||
 | 
			
		||||
    render_could_not_create_error(I18n.t('errors.mfa.invalid_credentials'))
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  def mfa_params
 | 
			
		||||
    params.permit(:otp_code, :password)
 | 
			
		||||
  end
 | 
			
		||||
end
 | 
			
		||||
@@ -9,13 +9,11 @@ class DeviseOverrides::SessionsController < DeviseTokenAuth::SessionsController
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  def create
 | 
			
		||||
    # Authenticate user via the temporary sso auth token
 | 
			
		||||
    if params[:sso_auth_token].present? && @resource.present?
 | 
			
		||||
      authenticate_resource_with_sso_token
 | 
			
		||||
      yield @resource if block_given?
 | 
			
		||||
      render_create_success
 | 
			
		||||
    else
 | 
			
		||||
      super
 | 
			
		||||
    return handle_mfa_verification if mfa_verification_request?
 | 
			
		||||
    return handle_sso_authentication if sso_authentication_request?
 | 
			
		||||
 | 
			
		||||
    super do |resource|
 | 
			
		||||
      return handle_mfa_required(resource) if resource&.mfa_enabled?
 | 
			
		||||
    end
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
@@ -25,6 +23,20 @@ class DeviseOverrides::SessionsController < DeviseTokenAuth::SessionsController
 | 
			
		||||
 | 
			
		||||
  private
 | 
			
		||||
 | 
			
		||||
  def mfa_verification_request?
 | 
			
		||||
    params[:mfa_token].present?
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  def sso_authentication_request?
 | 
			
		||||
    params[:sso_auth_token].present? && @resource.present?
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  def handle_sso_authentication
 | 
			
		||||
    authenticate_resource_with_sso_token
 | 
			
		||||
    yield @resource if block_given?
 | 
			
		||||
    render_create_success
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  def login_page_url(error: nil)
 | 
			
		||||
    frontend_url = ENV.fetch('FRONTEND_URL', nil)
 | 
			
		||||
 | 
			
		||||
@@ -46,6 +58,41 @@ class DeviseOverrides::SessionsController < DeviseTokenAuth::SessionsController
 | 
			
		||||
    user = User.from_email(params[:email])
 | 
			
		||||
    @resource = user if user&.valid_sso_auth_token?(params[:sso_auth_token])
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  def handle_mfa_required(resource)
 | 
			
		||||
    render json: {
 | 
			
		||||
      mfa_required: true,
 | 
			
		||||
      mfa_token: Mfa::TokenService.new(user: resource).generate_token
 | 
			
		||||
    }, status: :partial_content
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  def handle_mfa_verification
 | 
			
		||||
    user = Mfa::TokenService.new(token: params[:mfa_token]).verify_token
 | 
			
		||||
    return render_mfa_error('errors.mfa.invalid_token', :unauthorized) unless user
 | 
			
		||||
 | 
			
		||||
    authenticated = Mfa::AuthenticationService.new(
 | 
			
		||||
      user: user,
 | 
			
		||||
      otp_code: params[:otp_code],
 | 
			
		||||
      backup_code: params[:backup_code]
 | 
			
		||||
    ).authenticate
 | 
			
		||||
 | 
			
		||||
    return render_mfa_error('errors.mfa.invalid_code') unless authenticated
 | 
			
		||||
 | 
			
		||||
    sign_in_mfa_user(user)
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  def sign_in_mfa_user(user)
 | 
			
		||||
    @resource = user
 | 
			
		||||
    @token = @resource.create_token
 | 
			
		||||
    @resource.save!
 | 
			
		||||
 | 
			
		||||
    sign_in(:user, @resource, store: false, bypass: false)
 | 
			
		||||
    render_create_success
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  def render_mfa_error(message_key, status = :bad_request)
 | 
			
		||||
    render json: { error: I18n.t(message_key) }, status: status
 | 
			
		||||
  end
 | 
			
		||||
end
 | 
			
		||||
 | 
			
		||||
DeviseOverrides::SessionsController.prepend_mod_with('DeviseOverrides::SessionsController')
 | 
			
		||||
 
 | 
			
		||||
@@ -70,7 +70,12 @@ class WidgetsController < ActionController::Base
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  def allow_iframe_requests
 | 
			
		||||
    if @web_widget.allowed_domains.blank?
 | 
			
		||||
      response.headers.delete('X-Frame-Options')
 | 
			
		||||
    else
 | 
			
		||||
      domains = @web_widget.allowed_domains.split(',').map(&:strip).join(' ')
 | 
			
		||||
      response.headers['Content-Security-Policy'] = "frame-ancestors #{domains}"
 | 
			
		||||
    end
 | 
			
		||||
  end
 | 
			
		||||
end
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -6,11 +6,11 @@ class CaptainResponses extends ApiClient {
 | 
			
		||||
    super('captain/assistant_responses', { accountScoped: true });
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  get({ page = 1, searchKey, assistantId, documentId, status } = {}) {
 | 
			
		||||
  get({ page = 1, search, assistantId, documentId, status } = {}) {
 | 
			
		||||
    return axios.get(this.url, {
 | 
			
		||||
      params: {
 | 
			
		||||
        page,
 | 
			
		||||
        searchKey,
 | 
			
		||||
        search,
 | 
			
		||||
        assistant_id: assistantId,
 | 
			
		||||
        document_id: documentId,
 | 
			
		||||
        status,
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										28
									
								
								app/javascript/dashboard/api/mfa.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										28
									
								
								app/javascript/dashboard/api/mfa.js
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,28 @@
 | 
			
		||||
/* global axios */
 | 
			
		||||
import ApiClient from './ApiClient';
 | 
			
		||||
 | 
			
		||||
class MfaAPI extends ApiClient {
 | 
			
		||||
  constructor() {
 | 
			
		||||
    super('profile/mfa', { accountScoped: false });
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  enable() {
 | 
			
		||||
    return axios.post(`${this.url}`);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  verify(otpCode) {
 | 
			
		||||
    return axios.post(`${this.url}/verify`, { otp_code: otpCode });
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  disable(password, otpCode) {
 | 
			
		||||
    return axios.delete(this.url, {
 | 
			
		||||
      data: { password, otp_code: otpCode },
 | 
			
		||||
    });
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  regenerateBackupCodes(otpCode) {
 | 
			
		||||
    return axios.post(`${this.url}/backup_codes`, { otp_code: otpCode });
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export default new MfaAPI();
 | 
			
		||||
@@ -1,6 +1,6 @@
 | 
			
		||||
<script setup>
 | 
			
		||||
import { computed, ref } from 'vue';
 | 
			
		||||
import { useToggle } from '@vueuse/core';
 | 
			
		||||
import { useToggle, useWindowSize, useElementBounding } from '@vueuse/core';
 | 
			
		||||
import { vOnClickOutside } from '@vueuse/components';
 | 
			
		||||
import { picoSearch } from '@scmmishra/pico-search';
 | 
			
		||||
 | 
			
		||||
@@ -26,10 +26,24 @@ const props = defineProps({
 | 
			
		||||
 | 
			
		||||
const emit = defineEmits(['add']);
 | 
			
		||||
 | 
			
		||||
const BUFFER_SPACE = 20;
 | 
			
		||||
 | 
			
		||||
const [showPopover, togglePopover] = useToggle();
 | 
			
		||||
const buttonRef = ref();
 | 
			
		||||
const dropdownRef = ref();
 | 
			
		||||
 | 
			
		||||
const searchValue = ref('');
 | 
			
		||||
 | 
			
		||||
const { width: windowWidth, height: windowHeight } = useWindowSize();
 | 
			
		||||
const {
 | 
			
		||||
  top: buttonTop,
 | 
			
		||||
  left: buttonLeft,
 | 
			
		||||
  width: buttonWidth,
 | 
			
		||||
  height: buttonHeight,
 | 
			
		||||
} = useElementBounding(buttonRef);
 | 
			
		||||
const { width: dropdownWidth, height: dropdownHeight } =
 | 
			
		||||
  useElementBounding(dropdownRef);
 | 
			
		||||
 | 
			
		||||
const filteredItems = computed(() => {
 | 
			
		||||
  if (!searchValue.value) return props.items;
 | 
			
		||||
  const query = searchValue.value.toLowerCase();
 | 
			
		||||
@@ -42,6 +56,26 @@ const handleAdd = item => {
 | 
			
		||||
  togglePopover(false);
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
const shouldShowAbove = computed(() => {
 | 
			
		||||
  if (!buttonRef.value || !dropdownRef.value) return false;
 | 
			
		||||
  const spaceBelow =
 | 
			
		||||
    windowHeight.value - (buttonTop.value + buttonHeight.value);
 | 
			
		||||
  const spaceAbove = buttonTop.value;
 | 
			
		||||
  return (
 | 
			
		||||
    spaceBelow < dropdownHeight.value + BUFFER_SPACE && spaceAbove > spaceBelow
 | 
			
		||||
  );
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
const shouldAlignRight = computed(() => {
 | 
			
		||||
  if (!buttonRef.value || !dropdownRef.value) return false;
 | 
			
		||||
  const spaceRight = windowWidth.value - buttonLeft.value;
 | 
			
		||||
  const spaceLeft = buttonLeft.value + buttonWidth.value;
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    spaceRight < dropdownWidth.value + BUFFER_SPACE && spaceLeft > spaceRight
 | 
			
		||||
  );
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
const handleClickOutside = () => {
 | 
			
		||||
  if (showPopover.value) {
 | 
			
		||||
    togglePopover(false);
 | 
			
		||||
@@ -55,6 +89,7 @@ const handleClickOutside = () => {
 | 
			
		||||
    class="relative flex items-center group"
 | 
			
		||||
  >
 | 
			
		||||
    <Button
 | 
			
		||||
      ref="buttonRef"
 | 
			
		||||
      slate
 | 
			
		||||
      type="button"
 | 
			
		||||
      icon="i-lucide-plus"
 | 
			
		||||
@@ -64,7 +99,12 @@ const handleClickOutside = () => {
 | 
			
		||||
    />
 | 
			
		||||
    <div
 | 
			
		||||
      v-if="showPopover"
 | 
			
		||||
      class="top-full mt-2 ltr:right-0 rtl:left-0 xl:ltr:left-0 xl:rtl:right-0 z-50 flex flex-col items-start absolute bg-n-alpha-3 backdrop-blur-[50px] border-0 gap-4 outline outline-1 outline-n-weak rounded-xl max-w-96 min-w-80 max-h-[20rem] overflow-y-auto py-2"
 | 
			
		||||
      ref="dropdownRef"
 | 
			
		||||
      class="z-50 flex flex-col items-start absolute bg-n-alpha-3 backdrop-blur-[50px] border-0 gap-4 outline outline-1 outline-n-weak rounded-xl max-w-96 min-w-80 max-h-[20rem] overflow-y-auto py-2"
 | 
			
		||||
      :class="[
 | 
			
		||||
        shouldShowAbove ? 'bottom-full mb-2' : 'top-full mt-2',
 | 
			
		||||
        shouldAlignRight ? 'right-0' : 'left-0',
 | 
			
		||||
      ]"
 | 
			
		||||
    >
 | 
			
		||||
      <div class="flex flex-col divide-y divide-n-slate-4 w-full">
 | 
			
		||||
        <Input
 | 
			
		||||
@@ -90,7 +130,7 @@ const handleClickOutside = () => {
 | 
			
		||||
          <Icon
 | 
			
		||||
            v-if="item.icon"
 | 
			
		||||
            :icon="item.icon"
 | 
			
		||||
            class="size-2 text-n-slate-12 flex-shrink-0 mt-0.5"
 | 
			
		||||
            class="size-4 text-n-slate-12 flex-shrink-0 mt-0.5"
 | 
			
		||||
          />
 | 
			
		||||
          <span
 | 
			
		||||
            v-else-if="item.color"
 | 
			
		||||
@@ -105,24 +145,19 @@ const handleClickOutside = () => {
 | 
			
		||||
            :size="20"
 | 
			
		||||
            rounded-full
 | 
			
		||||
          />
 | 
			
		||||
          <div class="flex flex-col items-start gap-2 min-w-0">
 | 
			
		||||
            <div class="flex items-center gap-1 min-w-0">
 | 
			
		||||
          <div class="flex flex-col items-start gap-2 min-w-0 flex-1">
 | 
			
		||||
            <div class="flex items-center gap-1 min-w-0 w-full">
 | 
			
		||||
              <span
 | 
			
		||||
                :title="item.name || item.title"
 | 
			
		||||
                class="text-sm text-n-slate-12 truncate min-w-0"
 | 
			
		||||
                class="text-sm text-n-slate-12 truncate min-w-0 flex-1"
 | 
			
		||||
              >
 | 
			
		||||
                {{ item.name || item.title }}
 | 
			
		||||
              </span>
 | 
			
		||||
              <span
 | 
			
		||||
                v-if="item.id"
 | 
			
		||||
                class="text-xs text-n-slate-11 flex-shrink-0"
 | 
			
		||||
              >
 | 
			
		||||
                {{ `#${item.id}` }}
 | 
			
		||||
              </span>
 | 
			
		||||
            </div>
 | 
			
		||||
            <span
 | 
			
		||||
              v-if="item.email || item.phoneNumber"
 | 
			
		||||
              class="text-sm text-n-slate-11 truncate min-w-0"
 | 
			
		||||
              :title="item.email || item.phoneNumber"
 | 
			
		||||
              class="text-sm text-n-slate-11 truncate min-w-0 w-full block"
 | 
			
		||||
            >
 | 
			
		||||
              {{ item.email || item.phoneNumber }}
 | 
			
		||||
            </span>
 | 
			
		||||
 
 | 
			
		||||
@@ -119,7 +119,7 @@ onMounted(() => {
 | 
			
		||||
            )
 | 
			
		||||
          "
 | 
			
		||||
          :items="filteredTags"
 | 
			
		||||
          class="[&>button]:!text-n-blue-text"
 | 
			
		||||
          class="[&>button]:!text-n-blue-text [&>div]:min-w-64"
 | 
			
		||||
          @add="onClickAddTag"
 | 
			
		||||
        />
 | 
			
		||||
      </div>
 | 
			
		||||
 
 | 
			
		||||
@@ -110,23 +110,36 @@ const getInboxName = inboxId => {
 | 
			
		||||
      <div
 | 
			
		||||
        v-for="(limit, index) in inboxCapacityLimits"
 | 
			
		||||
        :key="limit.id || `temp-${index}`"
 | 
			
		||||
        class="flex items-center gap-3"
 | 
			
		||||
        class="flex flex-col xs:flex-row items-stretch gap-3"
 | 
			
		||||
      >
 | 
			
		||||
        <div
 | 
			
		||||
          class="flex items-start rounded-lg outline-1 outline cursor-not-allowed text-n-slate-11 outline-n-weak py-2.5 px-3 text-sm w-full"
 | 
			
		||||
          class="flex items-center rounded-lg outline-1 outline cursor-not-allowed text-n-slate-11 outline-n-weak py-2.5 px-3 text-sm w-full min-w-0"
 | 
			
		||||
          :title="getInboxName(limit.inboxId)"
 | 
			
		||||
        >
 | 
			
		||||
          <span class="truncate min-w-0">
 | 
			
		||||
            {{ getInboxName(limit.inboxId) }}
 | 
			
		||||
          </span>
 | 
			
		||||
        </div>
 | 
			
		||||
 | 
			
		||||
        <div class="flex items-center gap-3 w-full xs:w-auto">
 | 
			
		||||
          <div
 | 
			
		||||
          class="py-2.5 px-3 rounded-lg gap-2 outline outline-1 flex-shrink-0 flex items-center"
 | 
			
		||||
            class="py-2.5 px-3 rounded-lg gap-2 outline outline-1 flex-1 xs:flex-shrink-0 flex items-center min-w-0"
 | 
			
		||||
            :class="[
 | 
			
		||||
              !isLimitValid(limit) ? 'outline-n-ruby-8' : 'outline-n-weak',
 | 
			
		||||
            ]"
 | 
			
		||||
          >
 | 
			
		||||
          <label class="text-sm text-n-slate-12 ltr:pr-2 rtl:pl-2">
 | 
			
		||||
            <label
 | 
			
		||||
              class="text-sm text-n-slate-12 ltr:pr-2 rtl:pl-2 truncate min-w-0 flex-shrink"
 | 
			
		||||
              :title="
 | 
			
		||||
                t(
 | 
			
		||||
                  `${BASE_KEY}.FORM.INBOX_CAPACITY_LIMIT.FIELD.MAX_CONVERSATIONS`
 | 
			
		||||
                )
 | 
			
		||||
              "
 | 
			
		||||
            >
 | 
			
		||||
              {{
 | 
			
		||||
              t(`${BASE_KEY}.FORM.INBOX_CAPACITY_LIMIT.FIELD.MAX_CONVERSATIONS`)
 | 
			
		||||
                t(
 | 
			
		||||
                  `${BASE_KEY}.FORM.INBOX_CAPACITY_LIMIT.FIELD.MAX_CONVERSATIONS`
 | 
			
		||||
                )
 | 
			
		||||
              }}
 | 
			
		||||
            </label>
 | 
			
		||||
 | 
			
		||||
@@ -137,7 +150,7 @@ const getInboxName = inboxId => {
 | 
			
		||||
              type="number"
 | 
			
		||||
              :min="MIN_CONVERSATION_LIMIT"
 | 
			
		||||
              :max="MAX_CONVERSATION_LIMIT"
 | 
			
		||||
            class="reset-base bg-transparent focus:outline-none max-w-20 text-sm"
 | 
			
		||||
              class="reset-base bg-transparent focus:outline-none min-w-16 w-24 text-sm flex-shrink-0"
 | 
			
		||||
              :class="[
 | 
			
		||||
                !isLimitValid(limit)
 | 
			
		||||
                  ? 'placeholder:text-n-ruby-9 !text-n-ruby-9'
 | 
			
		||||
@@ -160,4 +173,5 @@ const getInboxName = inboxId => {
 | 
			
		||||
        </div>
 | 
			
		||||
      </div>
 | 
			
		||||
    </div>
 | 
			
		||||
  </div>
 | 
			
		||||
</template>
 | 
			
		||||
 
 | 
			
		||||
@@ -51,12 +51,20 @@ const originalState = reactive({ ...state });
 | 
			
		||||
 | 
			
		||||
const liveChatWidgets = computed(() => {
 | 
			
		||||
  const inboxes = store.getters['inboxes/getInboxes'];
 | 
			
		||||
  return inboxes
 | 
			
		||||
  const widgetOptions = inboxes
 | 
			
		||||
    .filter(inbox => inbox.channel_type === 'Channel::WebWidget')
 | 
			
		||||
    .map(inbox => ({
 | 
			
		||||
      value: inbox.id,
 | 
			
		||||
      label: inbox.name,
 | 
			
		||||
    }));
 | 
			
		||||
 | 
			
		||||
  return [
 | 
			
		||||
    {
 | 
			
		||||
      value: '',
 | 
			
		||||
      label: t('HELP_CENTER.PORTAL_SETTINGS.FORM.LIVE_CHAT_WIDGET.NONE_OPTION'),
 | 
			
		||||
    },
 | 
			
		||||
    ...widgetOptions,
 | 
			
		||||
  ];
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
const rules = {
 | 
			
		||||
@@ -108,7 +116,7 @@ watch(
 | 
			
		||||
        widgetColor: newVal.color,
 | 
			
		||||
        homePageLink: newVal.homepage_link,
 | 
			
		||||
        slug: newVal.slug,
 | 
			
		||||
        liveChatWidgetInboxId: newVal.inbox?.id,
 | 
			
		||||
        liveChatWidgetInboxId: newVal.inbox?.id || '',
 | 
			
		||||
      });
 | 
			
		||||
      if (newVal.logo) {
 | 
			
		||||
        const {
 | 
			
		||||
 
 | 
			
		||||
@@ -7,6 +7,11 @@ const props = defineProps({
 | 
			
		||||
  placeholder: { type: String, default: '' },
 | 
			
		||||
  label: { type: String, default: '' },
 | 
			
		||||
  id: { type: String, default: '' },
 | 
			
		||||
  size: {
 | 
			
		||||
    type: String,
 | 
			
		||||
    default: 'md',
 | 
			
		||||
    validator: value => ['sm', 'md'].includes(value),
 | 
			
		||||
  },
 | 
			
		||||
  message: { type: String, default: '' },
 | 
			
		||||
  disabled: { type: Boolean, default: false },
 | 
			
		||||
  messageType: {
 | 
			
		||||
@@ -69,6 +74,17 @@ const handleFocus = event => {
 | 
			
		||||
  isFocused.value = true;
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
const sizeClass = computed(() => {
 | 
			
		||||
  switch (props.size) {
 | 
			
		||||
    case 'sm':
 | 
			
		||||
      return 'h-8 !px-3 !py-2';
 | 
			
		||||
    case 'md':
 | 
			
		||||
      return 'h-10 !px-3 !py-2.5';
 | 
			
		||||
    default:
 | 
			
		||||
      return 'h-10 !px-3 !py-2.5';
 | 
			
		||||
  }
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
const handleBlur = event => {
 | 
			
		||||
  emit('blur', event);
 | 
			
		||||
  isFocused.value = false;
 | 
			
		||||
@@ -100,11 +116,13 @@ onMounted(() => {
 | 
			
		||||
    <slot name="prefix" />
 | 
			
		||||
    <input
 | 
			
		||||
      :id="uniqueId"
 | 
			
		||||
      v-bind="$attrs"
 | 
			
		||||
      ref="inputRef"
 | 
			
		||||
      :value="modelValue"
 | 
			
		||||
      :class="[
 | 
			
		||||
        customInputClass,
 | 
			
		||||
        inputOutlineClass,
 | 
			
		||||
        sizeClass,
 | 
			
		||||
        {
 | 
			
		||||
          error: messageType === 'error',
 | 
			
		||||
          focus: isFocused,
 | 
			
		||||
@@ -119,7 +137,7 @@ onMounted(() => {
 | 
			
		||||
          ? max
 | 
			
		||||
          : undefined
 | 
			
		||||
      "
 | 
			
		||||
      class="block w-full reset-base text-sm h-10 !px-3 !py-2.5 !mb-0 outline outline-1 border-none border-0 outline-offset-[-1px] rounded-lg bg-n-alpha-black2 file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-n-slate-10 dark:placeholder:text-n-slate-10 disabled:cursor-not-allowed disabled:opacity-50 text-n-slate-12 transition-all duration-500 ease-in-out [appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none"
 | 
			
		||||
      class="block w-full reset-base text-sm !mb-0 outline outline-1 border-none border-0 outline-offset-[-1px] rounded-lg bg-n-alpha-black2 file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-n-slate-10 dark:placeholder:text-n-slate-10 disabled:cursor-not-allowed disabled:opacity-50 text-n-slate-12 transition-all duration-500 ease-in-out [appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none"
 | 
			
		||||
      @input="handleInput"
 | 
			
		||||
      @focus="handleFocus"
 | 
			
		||||
      @blur="handleBlur"
 | 
			
		||||
 
 | 
			
		||||
@@ -131,6 +131,8 @@ const props = defineProps({
 | 
			
		||||
  sourceId: { type: String, default: '' }, // eslint-disable-line vue/no-unused-properties
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
const emit = defineEmits(['retry']);
 | 
			
		||||
 | 
			
		||||
const contextMenuPosition = ref({});
 | 
			
		||||
const showBackgroundHighlight = ref(false);
 | 
			
		||||
const showContextMenu = ref(false);
 | 
			
		||||
@@ -525,6 +527,7 @@ provideMessageContext({
 | 
			
		||||
        class="[grid-area:meta]"
 | 
			
		||||
        :class="flexOrientationClass"
 | 
			
		||||
        :error="contentAttributes.externalError"
 | 
			
		||||
        @retry="emit('retry')"
 | 
			
		||||
      />
 | 
			
		||||
    </div>
 | 
			
		||||
    <div v-if="shouldShowContextMenu" class="context-menu-wrap">
 | 
			
		||||
 
 | 
			
		||||
@@ -1,16 +1,22 @@
 | 
			
		||||
<script setup>
 | 
			
		||||
import { computed } from 'vue';
 | 
			
		||||
import Icon from 'next/icon/Icon.vue';
 | 
			
		||||
import { useI18n } from 'vue-i18n';
 | 
			
		||||
import { useMessageContext } from './provider.js';
 | 
			
		||||
import { ORIENTATION } from './constants';
 | 
			
		||||
import { hasOneDayPassed } from 'shared/helpers/timeHelper';
 | 
			
		||||
import { ORIENTATION, MESSAGE_STATUS } from './constants';
 | 
			
		||||
 | 
			
		||||
defineProps({
 | 
			
		||||
  error: { type: String, required: true },
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
const { orientation } = useMessageContext();
 | 
			
		||||
const emit = defineEmits(['retry']);
 | 
			
		||||
 | 
			
		||||
const { orientation, status, createdAt } = useMessageContext();
 | 
			
		||||
 | 
			
		||||
const { t } = useI18n();
 | 
			
		||||
 | 
			
		||||
const canRetry = computed(() => !hasOneDayPassed(createdAt.value));
 | 
			
		||||
</script>
 | 
			
		||||
 | 
			
		||||
<template>
 | 
			
		||||
@@ -35,5 +41,14 @@ const { t } = useI18n();
 | 
			
		||||
        {{ error }}
 | 
			
		||||
      </div>
 | 
			
		||||
    </div>
 | 
			
		||||
    <button
 | 
			
		||||
      v-if="canRetry"
 | 
			
		||||
      type="button"
 | 
			
		||||
      :disabled="status !== MESSAGE_STATUS.FAILED"
 | 
			
		||||
      class="bg-n-alpha-2 rounded-md size-5 grid place-content-center cursor-pointer"
 | 
			
		||||
      @click="emit('retry')"
 | 
			
		||||
    >
 | 
			
		||||
      <Icon icon="i-lucide-refresh-ccw" class="text-n-ruby-11 size-[14px]" />
 | 
			
		||||
    </button>
 | 
			
		||||
  </div>
 | 
			
		||||
</template>
 | 
			
		||||
 
 | 
			
		||||
@@ -37,6 +37,8 @@ const props = defineProps({
 | 
			
		||||
  },
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
const emit = defineEmits(['retry']);
 | 
			
		||||
 | 
			
		||||
const allMessages = computed(() => {
 | 
			
		||||
  return useCamelCase(props.messages, { deep: true });
 | 
			
		||||
});
 | 
			
		||||
@@ -113,6 +115,7 @@ const getInReplyToMessage = parentMessage => {
 | 
			
		||||
        :inbox-supports-reply-to="inboxSupportsReplyTo"
 | 
			
		||||
        :current-user-id="currentUserId"
 | 
			
		||||
        data-clarity-mask="True"
 | 
			
		||||
        @retry="emit('retry', message)"
 | 
			
		||||
      />
 | 
			
		||||
    </template>
 | 
			
		||||
    <slot name="after" />
 | 
			
		||||
 
 | 
			
		||||
@@ -1,8 +1,16 @@
 | 
			
		||||
<script setup>
 | 
			
		||||
import { computed, onMounted, useTemplateRef, ref } from 'vue';
 | 
			
		||||
import {
 | 
			
		||||
  computed,
 | 
			
		||||
  onMounted,
 | 
			
		||||
  useTemplateRef,
 | 
			
		||||
  ref,
 | 
			
		||||
  getCurrentInstance,
 | 
			
		||||
} from 'vue';
 | 
			
		||||
import Icon from 'next/icon/Icon.vue';
 | 
			
		||||
import { timeStampAppendedURL } from 'dashboard/helper/URLHelper';
 | 
			
		||||
import { downloadFile } from '@chatwoot/utils';
 | 
			
		||||
import { useEmitter } from 'dashboard/composables/emitter';
 | 
			
		||||
import { emitter } from 'shared/helpers/mitt';
 | 
			
		||||
 | 
			
		||||
const { attachment } = defineProps({
 | 
			
		||||
  attachment: {
 | 
			
		||||
@@ -27,6 +35,8 @@ const currentTime = ref(0);
 | 
			
		||||
const duration = ref(0);
 | 
			
		||||
const playbackSpeed = ref(1);
 | 
			
		||||
 | 
			
		||||
const { uid } = getCurrentInstance();
 | 
			
		||||
 | 
			
		||||
const onLoadedMetadata = () => {
 | 
			
		||||
  duration.value = audioPlayer.value?.duration;
 | 
			
		||||
};
 | 
			
		||||
@@ -43,6 +53,18 @@ onMounted(() => {
 | 
			
		||||
  audioPlayer.value.playbackRate = playbackSpeed.value;
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
// Listen for global audio play events and pause if it's not this audio
 | 
			
		||||
useEmitter('pause_playing_audio', currentPlayingId => {
 | 
			
		||||
  if (currentPlayingId !== uid && isPlaying.value) {
 | 
			
		||||
    try {
 | 
			
		||||
      audioPlayer.value.pause();
 | 
			
		||||
    } catch {
 | 
			
		||||
      /* ignore pause errors */
 | 
			
		||||
    }
 | 
			
		||||
    isPlaying.value = false;
 | 
			
		||||
  }
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
const formatTime = time => {
 | 
			
		||||
  if (!time || Number.isNaN(time)) return '00:00';
 | 
			
		||||
  const minutes = Math.floor(time / 60);
 | 
			
		||||
@@ -70,6 +92,8 @@ const playOrPause = () => {
 | 
			
		||||
    audioPlayer.value.pause();
 | 
			
		||||
    isPlaying.value = false;
 | 
			
		||||
  } else {
 | 
			
		||||
    // Emit event to pause all other audio
 | 
			
		||||
    emitter.emit('pause_playing_audio', uid);
 | 
			
		||||
    audioPlayer.value.play();
 | 
			
		||||
    isPlaying.value = true;
 | 
			
		||||
  }
 | 
			
		||||
@@ -101,6 +125,7 @@ const downloadAudio = async () => {
 | 
			
		||||
    ref="audioPlayer"
 | 
			
		||||
    controls
 | 
			
		||||
    class="hidden"
 | 
			
		||||
    playsinline
 | 
			
		||||
    @loadedmetadata="onLoadedMetadata"
 | 
			
		||||
    @timeupdate="onTimeUpdate"
 | 
			
		||||
    @ended="onEnd"
 | 
			
		||||
 
 | 
			
		||||
@@ -72,6 +72,10 @@ const formatType = computed(() => {
 | 
			
		||||
  return format ? format.charAt(0) + format.slice(1).toLowerCase() : '';
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
const isDocumentTemplate = computed(() => {
 | 
			
		||||
  return headerComponent.value?.format?.toLowerCase() === 'document';
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
const hasVariables = computed(() => {
 | 
			
		||||
  return bodyText.value?.match(/{{([^}]+)}}/g) !== null;
 | 
			
		||||
});
 | 
			
		||||
@@ -126,6 +130,11 @@ const updateMediaUrl = value => {
 | 
			
		||||
  processedParams.value.header.media_url = value;
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
const updateMediaName = value => {
 | 
			
		||||
  processedParams.value.header ??= {};
 | 
			
		||||
  processedParams.value.header.media_name = value;
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
const sendMessage = () => {
 | 
			
		||||
  v$.value.$touch();
 | 
			
		||||
  if (v$.value.$invalid) return;
 | 
			
		||||
@@ -168,10 +177,12 @@ defineExpose({
 | 
			
		||||
  processedParams,
 | 
			
		||||
  hasVariables,
 | 
			
		||||
  hasMediaHeader,
 | 
			
		||||
  isDocumentTemplate,
 | 
			
		||||
  headerComponent,
 | 
			
		||||
  renderedTemplate,
 | 
			
		||||
  v$,
 | 
			
		||||
  updateMediaUrl,
 | 
			
		||||
  updateMediaName,
 | 
			
		||||
  sendMessage,
 | 
			
		||||
  resetTemplate,
 | 
			
		||||
  goBack,
 | 
			
		||||
@@ -225,6 +236,17 @@ defineExpose({
 | 
			
		||||
            @update:model-value="updateMediaUrl"
 | 
			
		||||
          />
 | 
			
		||||
        </div>
 | 
			
		||||
        <div v-if="isDocumentTemplate" class="flex items-center mb-2.5">
 | 
			
		||||
          <Input
 | 
			
		||||
            :model-value="processedParams.header?.media_name || ''"
 | 
			
		||||
            type="text"
 | 
			
		||||
            class="flex-1"
 | 
			
		||||
            :placeholder="
 | 
			
		||||
              t('WHATSAPP_TEMPLATES.PARSER.DOCUMENT_NAME_PLACEHOLDER')
 | 
			
		||||
            "
 | 
			
		||||
            @update:model-value="updateMediaName"
 | 
			
		||||
          />
 | 
			
		||||
        </div>
 | 
			
		||||
      </div>
 | 
			
		||||
 | 
			
		||||
      <!-- Body Variables Section -->
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										328
									
								
								app/javascript/dashboard/components/auth/MfaVerification.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										328
									
								
								app/javascript/dashboard/components/auth/MfaVerification.vue
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,328 @@
 | 
			
		||||
<script setup>
 | 
			
		||||
import axios from 'axios';
 | 
			
		||||
import { ref, computed, nextTick } from 'vue';
 | 
			
		||||
import { useI18n } from 'vue-i18n';
 | 
			
		||||
import { handleOtpPaste } from 'shared/helpers/clipboard';
 | 
			
		||||
import { parseAPIErrorResponse } from 'dashboard/store/utils/api';
 | 
			
		||||
import { useAccount } from 'dashboard/composables/useAccount';
 | 
			
		||||
 | 
			
		||||
import Icon from 'dashboard/components-next/icon/Icon.vue';
 | 
			
		||||
import FormInput from 'v3/components/Form/Input.vue';
 | 
			
		||||
import NextButton from 'dashboard/components-next/button/Button.vue';
 | 
			
		||||
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
 | 
			
		||||
 | 
			
		||||
const props = defineProps({
 | 
			
		||||
  mfaToken: {
 | 
			
		||||
    type: String,
 | 
			
		||||
    required: true,
 | 
			
		||||
  },
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
const emit = defineEmits(['verified', 'cancel']);
 | 
			
		||||
 | 
			
		||||
const { t } = useI18n();
 | 
			
		||||
const { isOnChatwootCloud } = useAccount();
 | 
			
		||||
 | 
			
		||||
const OTP = 'otp';
 | 
			
		||||
const BACKUP = 'backup';
 | 
			
		||||
 | 
			
		||||
// State
 | 
			
		||||
const verificationMethod = ref(OTP);
 | 
			
		||||
const otpDigits = ref(['', '', '', '', '', '']);
 | 
			
		||||
const backupCode = ref('');
 | 
			
		||||
const isVerifying = ref(false);
 | 
			
		||||
const errorMessage = ref('');
 | 
			
		||||
const helpModalRef = ref(null);
 | 
			
		||||
const otpInputRefs = ref([]);
 | 
			
		||||
 | 
			
		||||
// Computed
 | 
			
		||||
const otpCode = computed(() => otpDigits.value.join(''));
 | 
			
		||||
const canSubmit = computed(() =>
 | 
			
		||||
  verificationMethod.value === OTP
 | 
			
		||||
    ? otpCode.value.length === 6
 | 
			
		||||
    : backupCode.value.length === 8
 | 
			
		||||
);
 | 
			
		||||
 | 
			
		||||
const contactDescKey = computed(() =>
 | 
			
		||||
  isOnChatwootCloud.value ? 'CONTACT_DESC_CLOUD' : 'CONTACT_DESC_SELF_HOSTED'
 | 
			
		||||
);
 | 
			
		||||
 | 
			
		||||
const focusInput = i => otpInputRefs.value[i]?.focus();
 | 
			
		||||
 | 
			
		||||
// Verification
 | 
			
		||||
const handleVerification = async () => {
 | 
			
		||||
  if (!canSubmit.value || isVerifying.value) return;
 | 
			
		||||
 | 
			
		||||
  isVerifying.value = true;
 | 
			
		||||
  errorMessage.value = '';
 | 
			
		||||
 | 
			
		||||
  try {
 | 
			
		||||
    const payload = {
 | 
			
		||||
      mfa_token: props.mfaToken,
 | 
			
		||||
    };
 | 
			
		||||
 | 
			
		||||
    if (verificationMethod.value === OTP) {
 | 
			
		||||
      payload.otp_code = otpCode.value;
 | 
			
		||||
    } else {
 | 
			
		||||
      payload.backup_code = backupCode.value;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    const response = await axios.post('/auth/sign_in', payload);
 | 
			
		||||
 | 
			
		||||
    // Set auth credentials and redirect
 | 
			
		||||
    if (response.data && response.headers) {
 | 
			
		||||
      // Store auth credentials in cookies
 | 
			
		||||
      const authData = {
 | 
			
		||||
        'access-token': response.headers['access-token'],
 | 
			
		||||
        'token-type': response.headers['token-type'],
 | 
			
		||||
        client: response.headers.client,
 | 
			
		||||
        expiry: response.headers.expiry,
 | 
			
		||||
        uid: response.headers.uid,
 | 
			
		||||
      };
 | 
			
		||||
 | 
			
		||||
      // Store in cookies for auth
 | 
			
		||||
      document.cookie = `cw_d_session_info=${encodeURIComponent(JSON.stringify(authData))}; path=/; SameSite=Lax`;
 | 
			
		||||
 | 
			
		||||
      // Redirect to dashboard
 | 
			
		||||
      window.location.href = '/app/';
 | 
			
		||||
    } else {
 | 
			
		||||
      emit('verified', response.data);
 | 
			
		||||
    }
 | 
			
		||||
  } catch (error) {
 | 
			
		||||
    errorMessage.value =
 | 
			
		||||
      parseAPIErrorResponse(error) || t('MFA_VERIFICATION.VERIFICATION_FAILED');
 | 
			
		||||
 | 
			
		||||
    // Clear inputs on error
 | 
			
		||||
    if (verificationMethod.value === OTP) {
 | 
			
		||||
      otpDigits.value.fill('');
 | 
			
		||||
      await nextTick();
 | 
			
		||||
      focusInput(0);
 | 
			
		||||
    } else {
 | 
			
		||||
      backupCode.value = '';
 | 
			
		||||
    }
 | 
			
		||||
  } finally {
 | 
			
		||||
    isVerifying.value = false;
 | 
			
		||||
  }
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
// OTP Input Handling
 | 
			
		||||
const handleOtpInput = async i => {
 | 
			
		||||
  const v = otpDigits.value[i];
 | 
			
		||||
 | 
			
		||||
  // Only allow numbers
 | 
			
		||||
  if (!/^\d*$/.test(v)) {
 | 
			
		||||
    otpDigits.value[i] = '';
 | 
			
		||||
    return;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  // Move to next input if value entered
 | 
			
		||||
  if (v && i < 5) {
 | 
			
		||||
    await nextTick();
 | 
			
		||||
    focusInput(i + 1);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  // Auto-submit if all digits entered
 | 
			
		||||
  if (otpCode.value.length === 6) {
 | 
			
		||||
    handleVerification();
 | 
			
		||||
  }
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
const handleBackspace = (e, i) => {
 | 
			
		||||
  if (!otpDigits.value[i] && i > 0) {
 | 
			
		||||
    e.preventDefault();
 | 
			
		||||
    focusInput(i - 1);
 | 
			
		||||
    otpDigits.value[i - 1] = '';
 | 
			
		||||
  }
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
const handleOtpCodePaste = e => {
 | 
			
		||||
  e.preventDefault();
 | 
			
		||||
  const code = handleOtpPaste(e, 6);
 | 
			
		||||
 | 
			
		||||
  if (code) {
 | 
			
		||||
    otpDigits.value = code.split('');
 | 
			
		||||
    handleVerification();
 | 
			
		||||
  }
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
// Alternative Actions
 | 
			
		||||
const handleTryAnotherMethod = () => {
 | 
			
		||||
  // Toggle between methods
 | 
			
		||||
  verificationMethod.value = verificationMethod.value === OTP ? BACKUP : OTP;
 | 
			
		||||
  otpDigits.value.fill('');
 | 
			
		||||
  backupCode.value = '';
 | 
			
		||||
  errorMessage.value = '';
 | 
			
		||||
};
 | 
			
		||||
</script>
 | 
			
		||||
 | 
			
		||||
<template>
 | 
			
		||||
  <div class="w-full max-w-md mx-auto">
 | 
			
		||||
    <div
 | 
			
		||||
      class="bg-white shadow sm:mx-auto sm:w-full sm:max-w-lg dark:bg-n-solid-2 p-11 sm:shadow-lg sm:rounded-lg"
 | 
			
		||||
    >
 | 
			
		||||
      <!-- Header -->
 | 
			
		||||
      <div class="text-center mb-6">
 | 
			
		||||
        <div
 | 
			
		||||
          class="inline-flex items-center justify-center size-14 bg-n-solid-1 outline outline-n-weak rounded-full mb-4"
 | 
			
		||||
        >
 | 
			
		||||
          <Icon icon="i-lucide-lock-keyhole" class="size-6 text-n-slate-10" />
 | 
			
		||||
        </div>
 | 
			
		||||
        <h2 class="text-2xl font-semibold text-n-slate-12">
 | 
			
		||||
          {{ $t('MFA_VERIFICATION.TITLE') }}
 | 
			
		||||
        </h2>
 | 
			
		||||
        <p class="text-sm text-n-slate-11 mt-2">
 | 
			
		||||
          {{ $t('MFA_VERIFICATION.DESCRIPTION') }}
 | 
			
		||||
        </p>
 | 
			
		||||
      </div>
 | 
			
		||||
 | 
			
		||||
      <!-- Tab Selection -->
 | 
			
		||||
      <div class="flex rounded-lg bg-n-alpha-black2 p-1 mb-6">
 | 
			
		||||
        <button
 | 
			
		||||
          v-for="method in [OTP, BACKUP]"
 | 
			
		||||
          :key="method"
 | 
			
		||||
          class="flex-1 py-2 px-4 text-sm font-medium rounded-md transition-colors"
 | 
			
		||||
          :class="
 | 
			
		||||
            verificationMethod === method
 | 
			
		||||
              ? 'bg-n-solid-active text-n-slate-12 shadow-sm'
 | 
			
		||||
              : 'text-n-slate-12'
 | 
			
		||||
          "
 | 
			
		||||
          @click="verificationMethod = method"
 | 
			
		||||
        >
 | 
			
		||||
          {{
 | 
			
		||||
            $t(
 | 
			
		||||
              `MFA_VERIFICATION.${method === OTP ? 'AUTHENTICATOR_APP' : 'BACKUP_CODE'}`
 | 
			
		||||
            )
 | 
			
		||||
          }}
 | 
			
		||||
        </button>
 | 
			
		||||
      </div>
 | 
			
		||||
 | 
			
		||||
      <!-- Verification Form -->
 | 
			
		||||
      <form class="space-y-4" @submit.prevent="handleVerification">
 | 
			
		||||
        <!-- OTP Code Input -->
 | 
			
		||||
        <div v-if="verificationMethod === OTP">
 | 
			
		||||
          <label class="block text-sm font-medium text-n-slate-12 mb-2">
 | 
			
		||||
            {{ $t('MFA_VERIFICATION.ENTER_OTP_CODE') }}
 | 
			
		||||
          </label>
 | 
			
		||||
          <div class="flex justify-between gap-2">
 | 
			
		||||
            <input
 | 
			
		||||
              v-for="(_, i) in otpDigits"
 | 
			
		||||
              :key="i"
 | 
			
		||||
              ref="otpInputRefs"
 | 
			
		||||
              v-model="otpDigits[i]"
 | 
			
		||||
              type="text"
 | 
			
		||||
              maxlength="1"
 | 
			
		||||
              pattern="[0-9]"
 | 
			
		||||
              inputmode="numeric"
 | 
			
		||||
              class="w-12 h-12 text-center text-lg font-semibold border-2 border-n-weak hover:border-n-strong rounded-lg focus:border-n-brand bg-n-alpha-black2 text-n-slate-12 placeholder:text-n-slate-10"
 | 
			
		||||
              @input="handleOtpInput(i)"
 | 
			
		||||
              @keydown.left.prevent="focusInput(i - 1)"
 | 
			
		||||
              @keydown.right.prevent="focusInput(i + 1)"
 | 
			
		||||
              @keydown.backspace="handleBackspace($event, i)"
 | 
			
		||||
              @paste="handleOtpCodePaste"
 | 
			
		||||
            />
 | 
			
		||||
          </div>
 | 
			
		||||
        </div>
 | 
			
		||||
 | 
			
		||||
        <!-- Backup Code Input -->
 | 
			
		||||
        <div v-if="verificationMethod === BACKUP">
 | 
			
		||||
          <FormInput
 | 
			
		||||
            v-model="backupCode"
 | 
			
		||||
            name="backup_code"
 | 
			
		||||
            type="text"
 | 
			
		||||
            data-testid="backup_code_input"
 | 
			
		||||
            :tabindex="1"
 | 
			
		||||
            required
 | 
			
		||||
            :label="$t('MFA_VERIFICATION.ENTER_BACKUP_CODE')"
 | 
			
		||||
            :placeholder="
 | 
			
		||||
              $t('MFA_VERIFICATION.BACKUP_CODE_PLACEHOLDER') || '000000'
 | 
			
		||||
            "
 | 
			
		||||
            @keyup.enter="handleVerification"
 | 
			
		||||
          />
 | 
			
		||||
        </div>
 | 
			
		||||
 | 
			
		||||
        <!-- Error Message -->
 | 
			
		||||
        <div
 | 
			
		||||
          v-if="errorMessage"
 | 
			
		||||
          class="p-3 bg-n-ruby-3 outline outline-n-ruby-5 outline-1 rounded-lg"
 | 
			
		||||
        >
 | 
			
		||||
          <p class="text-sm text-n-ruby-9">{{ errorMessage }}</p>
 | 
			
		||||
        </div>
 | 
			
		||||
 | 
			
		||||
        <!-- Submit Button -->
 | 
			
		||||
        <NextButton
 | 
			
		||||
          lg
 | 
			
		||||
          type="submit"
 | 
			
		||||
          data-testid="submit_button"
 | 
			
		||||
          class="w-full"
 | 
			
		||||
          :tabindex="2"
 | 
			
		||||
          :label="$t('MFA_VERIFICATION.VERIFY_BUTTON')"
 | 
			
		||||
          :disabled="!canSubmit || isVerifying"
 | 
			
		||||
          :is-loading="isVerifying"
 | 
			
		||||
        />
 | 
			
		||||
 | 
			
		||||
        <!-- Alternative Actions -->
 | 
			
		||||
        <div class="text-center flex items-center flex-col gap-2 pt-4">
 | 
			
		||||
          <NextButton
 | 
			
		||||
            sm
 | 
			
		||||
            link
 | 
			
		||||
            type="button"
 | 
			
		||||
            class="w-full hover:!no-underline"
 | 
			
		||||
            :tabindex="2"
 | 
			
		||||
            :label="$t('MFA_VERIFICATION.TRY_ANOTHER_METHOD')"
 | 
			
		||||
            @click="handleTryAnotherMethod"
 | 
			
		||||
          />
 | 
			
		||||
          <NextButton
 | 
			
		||||
            sm
 | 
			
		||||
            slate
 | 
			
		||||
            link
 | 
			
		||||
            type="button"
 | 
			
		||||
            class="w-full hover:!no-underline"
 | 
			
		||||
            :tabindex="3"
 | 
			
		||||
            :label="$t('MFA_VERIFICATION.CANCEL_LOGIN')"
 | 
			
		||||
            @click="() => emit('cancel')"
 | 
			
		||||
          />
 | 
			
		||||
        </div>
 | 
			
		||||
      </form>
 | 
			
		||||
    </div>
 | 
			
		||||
 | 
			
		||||
    <!-- Help Text -->
 | 
			
		||||
    <div class="mt-6 text-center">
 | 
			
		||||
      <p class="text-sm text-n-slate-11">
 | 
			
		||||
        {{ $t('MFA_VERIFICATION.HELP_TEXT') }}
 | 
			
		||||
      </p>
 | 
			
		||||
      <NextButton
 | 
			
		||||
        sm
 | 
			
		||||
        link
 | 
			
		||||
        type="button"
 | 
			
		||||
        class="w-full hover:!no-underline"
 | 
			
		||||
        :tabindex="4"
 | 
			
		||||
        :label="$t('MFA_VERIFICATION.LEARN_MORE')"
 | 
			
		||||
        @click="helpModalRef?.open()"
 | 
			
		||||
      />
 | 
			
		||||
    </div>
 | 
			
		||||
 | 
			
		||||
    <!-- Help Modal -->
 | 
			
		||||
    <Dialog
 | 
			
		||||
      ref="helpModalRef"
 | 
			
		||||
      :title="$t('MFA_VERIFICATION.HELP_MODAL.TITLE')"
 | 
			
		||||
      :show-confirm-button="false"
 | 
			
		||||
      class="[&>dialog>div]:bg-n-alpha-3 [&>dialog>div]:rounded-lg"
 | 
			
		||||
      @confirm="helpModalRef?.close()"
 | 
			
		||||
    >
 | 
			
		||||
      <div class="space-y-4 text-sm text-n-slate-11">
 | 
			
		||||
        <div v-for="section in ['AUTHENTICATOR', 'BACKUP']" :key="section">
 | 
			
		||||
          <h4 class="font-medium text-n-slate-12 mb-2">
 | 
			
		||||
            {{ $t(`MFA_VERIFICATION.HELP_MODAL.${section}_TITLE`) }}
 | 
			
		||||
          </h4>
 | 
			
		||||
          <p>{{ $t(`MFA_VERIFICATION.HELP_MODAL.${section}_DESC`) }}</p>
 | 
			
		||||
        </div>
 | 
			
		||||
        <div>
 | 
			
		||||
          <h4 class="font-medium text-n-slate-12 mb-2">
 | 
			
		||||
            {{ $t('MFA_VERIFICATION.HELP_MODAL.CONTACT_TITLE') }}
 | 
			
		||||
          </h4>
 | 
			
		||||
          <p>{{ $t(`MFA_VERIFICATION.HELP_MODAL.${contactDescKey}`) }}</p>
 | 
			
		||||
        </div>
 | 
			
		||||
      </div>
 | 
			
		||||
    </Dialog>
 | 
			
		||||
  </div>
 | 
			
		||||
</template>
 | 
			
		||||
@@ -4,6 +4,7 @@ import { ref, provide } from 'vue';
 | 
			
		||||
import { useConfig } from 'dashboard/composables/useConfig';
 | 
			
		||||
import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents';
 | 
			
		||||
import { useAI } from 'dashboard/composables/useAI';
 | 
			
		||||
import { useSnakeCase } from 'dashboard/composables/useTransformKeys';
 | 
			
		||||
 | 
			
		||||
// components
 | 
			
		||||
import ReplyBox from './ReplyBox.vue';
 | 
			
		||||
@@ -441,6 +442,11 @@ export default {
 | 
			
		||||
    makeMessagesRead() {
 | 
			
		||||
      this.$store.dispatch('markMessagesRead', { id: this.currentChat.id });
 | 
			
		||||
    },
 | 
			
		||||
    async handleMessageRetry(message) {
 | 
			
		||||
      if (!message) return;
 | 
			
		||||
      const payload = useSnakeCase(message);
 | 
			
		||||
      await this.$store.dispatch('sendMessageWithData', payload);
 | 
			
		||||
    },
 | 
			
		||||
  },
 | 
			
		||||
};
 | 
			
		||||
</script>
 | 
			
		||||
@@ -469,6 +475,7 @@ export default {
 | 
			
		||||
      :is-an-email-channel="isAnEmailChannel"
 | 
			
		||||
      :inbox-supports-reply-to="inboxSupportsReplyTo"
 | 
			
		||||
      :messages="getMessages"
 | 
			
		||||
      @retry="handleMessageRetry"
 | 
			
		||||
    >
 | 
			
		||||
      <template #beforeAll>
 | 
			
		||||
        <transition name="slide-up">
 | 
			
		||||
 
 | 
			
		||||
@@ -104,6 +104,7 @@ export default function useAutomationValues() {
 | 
			
		||||
      contacts: contacts.value,
 | 
			
		||||
      customAttributes: getters['attributes/getAttributes'].value,
 | 
			
		||||
      inboxes: inboxes.value,
 | 
			
		||||
      labels: labels.value,
 | 
			
		||||
      statusFilterOptions: statusFilterOptions.value,
 | 
			
		||||
      priorityOptions: priorityOptions.value,
 | 
			
		||||
      messageTypeOptions: messageTypeOptions.value,
 | 
			
		||||
 
 | 
			
		||||
@@ -124,6 +124,7 @@ export const getConditionOptions = ({
 | 
			
		||||
  customAttributes,
 | 
			
		||||
  inboxes,
 | 
			
		||||
  languages,
 | 
			
		||||
  labels,
 | 
			
		||||
  statusFilterOptions,
 | 
			
		||||
  teams,
 | 
			
		||||
  type,
 | 
			
		||||
@@ -150,6 +151,7 @@ export const getConditionOptions = ({
 | 
			
		||||
    country_code: countries,
 | 
			
		||||
    message_type: messageTypeOptions,
 | 
			
		||||
    priority: priorityOptions,
 | 
			
		||||
    labels: generateConditionOptions(labels, 'title'),
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  return conditionFilterMaps[type];
 | 
			
		||||
 
 | 
			
		||||
@@ -218,6 +218,7 @@ describe('templateHelper', () => {
 | 
			
		||||
      expect(result.header).toEqual({
 | 
			
		||||
        media_url: '',
 | 
			
		||||
        media_type: 'document',
 | 
			
		||||
        media_name: '',
 | 
			
		||||
      });
 | 
			
		||||
      expect(result.body).toEqual({
 | 
			
		||||
        1: '',
 | 
			
		||||
 
 | 
			
		||||
@@ -51,6 +51,11 @@ export const buildTemplateParameters = (template, hasMediaHeaderValue) => {
 | 
			
		||||
    if (!allVariables.header) allVariables.header = {};
 | 
			
		||||
    allVariables.header.media_url = '';
 | 
			
		||||
    allVariables.header.media_type = headerComponent.format.toLowerCase();
 | 
			
		||||
 | 
			
		||||
    // For document templates, include media_name field for filename support
 | 
			
		||||
    if (headerComponent.format.toLowerCase() === 'document') {
 | 
			
		||||
      allVariables.header.media_name = '';
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  // Process button variables
 | 
			
		||||
 
 | 
			
		||||
@@ -177,7 +177,8 @@
 | 
			
		||||
      "REFERER_LINK": "Referrer Link",
 | 
			
		||||
      "ASSIGNEE_NAME": "Assignee",
 | 
			
		||||
      "TEAM_NAME": "Team",
 | 
			
		||||
      "PRIORITY": "Priority"
 | 
			
		||||
      "PRIORITY": "Priority",
 | 
			
		||||
      "LABELS": "Labels"
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -741,7 +741,8 @@
 | 
			
		||||
        "LIVE_CHAT_WIDGET": {
 | 
			
		||||
          "LABEL": "Live chat widget",
 | 
			
		||||
          "PLACEHOLDER": "Select live chat widget",
 | 
			
		||||
          "HELP_TEXT": "Select a live chat widget that will appear on your help center"
 | 
			
		||||
          "HELP_TEXT": "Select a live chat widget that will appear on your help center",
 | 
			
		||||
          "NONE_OPTION": "No widget"
 | 
			
		||||
        },
 | 
			
		||||
        "BRAND_COLOR": {
 | 
			
		||||
          "LABEL": "Brand color"
 | 
			
		||||
 
 | 
			
		||||
@@ -36,6 +36,7 @@ import sla from './sla.json';
 | 
			
		||||
import teamsSettings from './teamsSettings.json';
 | 
			
		||||
import whatsappTemplates from './whatsappTemplates.json';
 | 
			
		||||
import contentTemplates from './contentTemplates.json';
 | 
			
		||||
import mfa from './mfa.json';
 | 
			
		||||
 | 
			
		||||
export default {
 | 
			
		||||
  ...advancedFilters,
 | 
			
		||||
@@ -76,4 +77,5 @@ export default {
 | 
			
		||||
  ...teamsSettings,
 | 
			
		||||
  ...whatsappTemplates,
 | 
			
		||||
  ...contentTemplates,
 | 
			
		||||
  ...mfa,
 | 
			
		||||
};
 | 
			
		||||
 
 | 
			
		||||
@@ -759,6 +759,7 @@
 | 
			
		||||
      "SELECTED": "{count} selected",
 | 
			
		||||
      "SELECT_ALL": "Select all ({count})",
 | 
			
		||||
      "UNSELECT_ALL": "Unselect all ({count})",
 | 
			
		||||
      "SEARCH_PLACEHOLDER": "Search FAQs...",
 | 
			
		||||
      "BULK_APPROVE_BUTTON": "Approve",
 | 
			
		||||
      "BULK_DELETE_BUTTON": "Delete",
 | 
			
		||||
      "BULK_APPROVE": {
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										106
									
								
								app/javascript/dashboard/i18n/locale/en/mfa.json
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										106
									
								
								app/javascript/dashboard/i18n/locale/en/mfa.json
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,106 @@
 | 
			
		||||
{
 | 
			
		||||
  "MFA_SETTINGS": {
 | 
			
		||||
    "TITLE": "Two-Factor Authentication",
 | 
			
		||||
    "SUBTITLE": "Secure your account with TOTP-based authentication",
 | 
			
		||||
    "DESCRIPTION": "Add an extra layer of security to your account using a time-based one-time password (TOTP)",
 | 
			
		||||
    "STATUS_TITLE": "Authentication Status",
 | 
			
		||||
    "STATUS_DESCRIPTION": "Manage your two-factor authentication settings and backup recovery codes",
 | 
			
		||||
    "ENABLED": "Enabled",
 | 
			
		||||
    "DISABLED": "Disabled",
 | 
			
		||||
    "STATUS_ENABLED": "Two-factor authentication is active",
 | 
			
		||||
    "STATUS_ENABLED_DESC": "Your account is protected with an additional layer of security",
 | 
			
		||||
    "ENABLE_BUTTON": "Enable Two-Factor Authentication",
 | 
			
		||||
    "ENHANCE_SECURITY": "Enhance Your Account Security",
 | 
			
		||||
    "ENHANCE_SECURITY_DESC": "Two-factor authentication adds an extra layer of security by requiring a verification code from your authenticator app in addition to your password.",
 | 
			
		||||
    "SETUP": {
 | 
			
		||||
      "STEP_NUMBER_1": "1",
 | 
			
		||||
      "STEP_NUMBER_2": "2",
 | 
			
		||||
      "STEP1_TITLE": "Scan QR Code with Your Authenticator App",
 | 
			
		||||
      "STEP1_DESCRIPTION": "Use Google Authenticator, Authy, or any TOTP-compatible app",
 | 
			
		||||
      "LOADING_QR": "Loading...",
 | 
			
		||||
      "MANUAL_ENTRY": "Can't scan? Enter code manually",
 | 
			
		||||
      "SECRET_KEY": "Secret Key",
 | 
			
		||||
      "COPY": "Copy",
 | 
			
		||||
      "ENTER_CODE": "Enter the 6-digit code from your authenticator app",
 | 
			
		||||
      "ENTER_CODE_PLACEHOLDER": "000000",
 | 
			
		||||
      "VERIFY_BUTTON": "Verify & Continue",
 | 
			
		||||
      "CANCEL": "Cancel",
 | 
			
		||||
      "ERROR_STARTING": "MFA not enabled. Please contact administrator.",
 | 
			
		||||
      "INVALID_CODE": "Invalid verification code",
 | 
			
		||||
      "SECRET_COPIED": "Secret key copied to clipboard",
 | 
			
		||||
      "SUCCESS": "Two-factor authentication has been enabled successfully"
 | 
			
		||||
    },
 | 
			
		||||
    "BACKUP": {
 | 
			
		||||
      "TITLE": "Save Your Backup Codes",
 | 
			
		||||
      "DESCRIPTION": "Keep these codes safe. Each can be used once if you lose access to your authenticator",
 | 
			
		||||
      "IMPORTANT": "Important:",
 | 
			
		||||
      "IMPORTANT_NOTE": " Save these codes in a secure location. You won't be able to see them again.",
 | 
			
		||||
      "DOWNLOAD": "Download",
 | 
			
		||||
      "COPY_ALL": "Copy All",
 | 
			
		||||
      "CONFIRM": "I have saved my backup codes in a secure location and understand that I won't be able to see them again",
 | 
			
		||||
      "COMPLETE_SETUP": "Complete Setup",
 | 
			
		||||
      "CODES_COPIED": "Backup codes copied to clipboard"
 | 
			
		||||
    },
 | 
			
		||||
    "MANAGEMENT": {
 | 
			
		||||
      "BACKUP_CODES": "Backup Codes",
 | 
			
		||||
      "BACKUP_CODES_DESC": "Generate new codes if you've lost or used your existing ones",
 | 
			
		||||
      "REGENERATE": "Regenerate Backup Codes",
 | 
			
		||||
      "DISABLE_MFA": "Disable 2FA",
 | 
			
		||||
      "DISABLE_MFA_DESC": "Remove two-factor authentication from your account",
 | 
			
		||||
      "DISABLE_BUTTON": "Disable Two-Factor Authentication"
 | 
			
		||||
    },
 | 
			
		||||
    "DISABLE": {
 | 
			
		||||
      "TITLE": "Disable Two-Factor Authentication",
 | 
			
		||||
      "DESCRIPTION": "You'll need to enter your password and a verification code to disable two-factor authentication.",
 | 
			
		||||
      "PASSWORD": "Password",
 | 
			
		||||
      "OTP_CODE": "Verification Code",
 | 
			
		||||
      "OTP_CODE_PLACEHOLDER": "000000",
 | 
			
		||||
      "CONFIRM": "Disable 2FA",
 | 
			
		||||
      "CANCEL": "Cancel",
 | 
			
		||||
      "SUCCESS": "Two-factor authentication has been disabled",
 | 
			
		||||
      "ERROR": "Failed to disable MFA. Please check your credentials."
 | 
			
		||||
    },
 | 
			
		||||
    "REGENERATE": {
 | 
			
		||||
      "TITLE": "Regenerate Backup Codes",
 | 
			
		||||
      "DESCRIPTION": "This will invalidate your existing backup codes and generate new ones. Enter your verification code to continue.",
 | 
			
		||||
      "OTP_CODE": "Verification Code",
 | 
			
		||||
      "OTP_CODE_PLACEHOLDER": "000000",
 | 
			
		||||
      "CONFIRM": "Generate New Codes",
 | 
			
		||||
      "CANCEL": "Cancel",
 | 
			
		||||
      "NEW_CODES_TITLE": "New Backup Codes Generated",
 | 
			
		||||
      "NEW_CODES_DESC": "Your old backup codes have been invalidated. Save these new codes in a secure location.",
 | 
			
		||||
      "CODES_IMPORTANT": "Important:",
 | 
			
		||||
      "CODES_IMPORTANT_NOTE": " Each code can only be used once. Save them before closing this window.",
 | 
			
		||||
      "DOWNLOAD_CODES": "Download Codes",
 | 
			
		||||
      "COPY_ALL_CODES": "Copy All Codes",
 | 
			
		||||
      "CODES_SAVED": "I've Saved My Codes",
 | 
			
		||||
      "SUCCESS": "New backup codes have been generated",
 | 
			
		||||
      "ERROR": "Failed to regenerate backup codes"
 | 
			
		||||
    }
 | 
			
		||||
  },
 | 
			
		||||
  "MFA_VERIFICATION": {
 | 
			
		||||
    "TITLE": "Two-Factor Authentication",
 | 
			
		||||
    "DESCRIPTION": "Enter your verification code to continue",
 | 
			
		||||
    "AUTHENTICATOR_APP": "Authenticator App",
 | 
			
		||||
    "BACKUP_CODE": "Backup Code",
 | 
			
		||||
    "ENTER_OTP_CODE": "Enter 6-digit code from your authenticator app",
 | 
			
		||||
    "ENTER_BACKUP_CODE": "Enter one of your backup codes",
 | 
			
		||||
    "BACKUP_CODE_PLACEHOLDER": "000000",
 | 
			
		||||
    "VERIFY_BUTTON": "Verify",
 | 
			
		||||
    "TRY_ANOTHER_METHOD": "Try another verification method",
 | 
			
		||||
    "CANCEL_LOGIN": "Cancel and return to login",
 | 
			
		||||
    "HELP_TEXT": "Having trouble signing in?",
 | 
			
		||||
    "LEARN_MORE": "Learn more about 2FA",
 | 
			
		||||
    "HELP_MODAL": {
 | 
			
		||||
      "TITLE": "Two-Factor Authentication Help",
 | 
			
		||||
      "AUTHENTICATOR_TITLE": "Using an Authenticator App",
 | 
			
		||||
      "AUTHENTICATOR_DESC": "Open your authenticator app (Google Authenticator, Authy, etc.) and enter the 6-digit code shown for your account.",
 | 
			
		||||
      "BACKUP_TITLE": "Using a Backup Code",
 | 
			
		||||
      "BACKUP_DESC": "If you don't have access to your authenticator app, you can use one of the backup codes you saved when setting up 2FA. Each code can only be used once.",
 | 
			
		||||
      "CONTACT_TITLE": "Need More Help?",
 | 
			
		||||
      "CONTACT_DESC_CLOUD": "If you've lost access to both your authenticator app and backup codes, please reach out to Chatwoot support for assistance.",
 | 
			
		||||
      "CONTACT_DESC_SELF_HOSTED": "If you've lost access to both your authenticator app and backup codes, please contact your administrator for assistance."
 | 
			
		||||
    },
 | 
			
		||||
    "VERIFICATION_FAILED": "Verification failed. Please try again."
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
@@ -80,6 +80,11 @@
 | 
			
		||||
        "NOTE": "Updating your password would reset your logins in multiple devices.",
 | 
			
		||||
        "BTN_TEXT": "Change password"
 | 
			
		||||
      },
 | 
			
		||||
      "SECURITY_SECTION": {
 | 
			
		||||
        "TITLE": "Security",
 | 
			
		||||
        "NOTE": "Manage additional security features for your account.",
 | 
			
		||||
        "MFA_BUTTON": "Manage Two-Factor Authentication"
 | 
			
		||||
      },
 | 
			
		||||
      "ACCESS_TOKEN": {
 | 
			
		||||
        "TITLE": "Access Token",
 | 
			
		||||
        "NOTE": "This token can be used if you are building an API based integration",
 | 
			
		||||
 
 | 
			
		||||
@@ -40,6 +40,7 @@
 | 
			
		||||
      "BUTTON_LABEL": "Button {index}",
 | 
			
		||||
      "COUPON_CODE": "Enter coupon code (max 15 chars)",
 | 
			
		||||
      "MEDIA_URL_LABEL": "Enter {type} URL",
 | 
			
		||||
      "DOCUMENT_NAME_PLACEHOLDER": "Enter document filename (e.g., Invoice_2025.pdf)",
 | 
			
		||||
      "BUTTON_PARAMETER": "Enter button parameter"
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 
 | 
			
		||||
@@ -6,10 +6,12 @@ import { useI18n } from 'vue-i18n';
 | 
			
		||||
import { OnClickOutside } from '@vueuse/components';
 | 
			
		||||
import { useRouter } from 'vue-router';
 | 
			
		||||
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
 | 
			
		||||
import { debounce } from '@chatwoot/utils';
 | 
			
		||||
 | 
			
		||||
import Button from 'dashboard/components-next/button/Button.vue';
 | 
			
		||||
import Checkbox from 'dashboard/components-next/checkbox/Checkbox.vue';
 | 
			
		||||
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
 | 
			
		||||
import Input from 'dashboard/components-next/input/Input.vue';
 | 
			
		||||
import DeleteDialog from 'dashboard/components-next/captain/pageComponents/DeleteDialog.vue';
 | 
			
		||||
import BulkDeleteDialog from 'dashboard/components-next/captain/pageComponents/BulkDeleteDialog.vue';
 | 
			
		||||
import PageLayout from 'dashboard/components-next/captain/PageLayout.vue';
 | 
			
		||||
@@ -36,6 +38,7 @@ const bulkDeleteDialog = ref(null);
 | 
			
		||||
const selectedStatus = ref('all');
 | 
			
		||||
const selectedAssistant = ref('all');
 | 
			
		||||
const dialogType = ref('');
 | 
			
		||||
const searchQuery = ref('');
 | 
			
		||||
const { t } = useI18n();
 | 
			
		||||
 | 
			
		||||
const createDialog = ref(null);
 | 
			
		||||
@@ -138,6 +141,9 @@ const fetchResponses = (page = 1) => {
 | 
			
		||||
  if (selectedAssistant.value !== 'all') {
 | 
			
		||||
    filterParams.assistantId = selectedAssistant.value;
 | 
			
		||||
  }
 | 
			
		||||
  if (searchQuery.value) {
 | 
			
		||||
    filterParams.search = searchQuery.value;
 | 
			
		||||
  }
 | 
			
		||||
  store.dispatch('captainResponses/get', filterParams);
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
@@ -250,6 +256,10 @@ const handleAssistantFilterChange = assistant => {
 | 
			
		||||
  fetchResponses();
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
const debouncedSearch = debounce(async () => {
 | 
			
		||||
  fetchResponses();
 | 
			
		||||
}, 500);
 | 
			
		||||
 | 
			
		||||
onMounted(() => {
 | 
			
		||||
  store.dispatch('captainAssistants/get');
 | 
			
		||||
  fetchResponses();
 | 
			
		||||
@@ -292,13 +302,17 @@ onMounted(() => {
 | 
			
		||||
    <template #controls>
 | 
			
		||||
      <div
 | 
			
		||||
        v-if="shouldShowDropdown"
 | 
			
		||||
        class="mb-4 -mt-3 flex justify-between items-center w-fit py-1"
 | 
			
		||||
        class="mb-4 -mt-3 flex justify-between items-center py-1"
 | 
			
		||||
        :class="{
 | 
			
		||||
          'ltr:pl-3 rtl:pr-3 ltr:pr-1 rtl:pl-1 rounded-lg outline outline-1 outline-n-weak bg-n-solid-3':
 | 
			
		||||
          'ltr:pl-3 rtl:pr-3 ltr:pr-1 rtl:pl-1 rounded-lg outline outline-1 outline-n-weak bg-n-solid-3 w-fit':
 | 
			
		||||
            bulkSelectionState.hasSelected,
 | 
			
		||||
        }"
 | 
			
		||||
      >
 | 
			
		||||
        <div v-if="!bulkSelectionState.hasSelected" class="flex gap-3">
 | 
			
		||||
        <div
 | 
			
		||||
          v-if="!bulkSelectionState.hasSelected"
 | 
			
		||||
          class="flex gap-3 justify-between w-full items-center"
 | 
			
		||||
        >
 | 
			
		||||
          <div class="flex gap-3">
 | 
			
		||||
            <OnClickOutside @trigger="isStatusFilterOpen = false">
 | 
			
		||||
              <Button
 | 
			
		||||
                :label="selectedStatusLabel"
 | 
			
		||||
@@ -322,6 +336,15 @@ onMounted(() => {
 | 
			
		||||
              @update="handleAssistantFilterChange"
 | 
			
		||||
            />
 | 
			
		||||
          </div>
 | 
			
		||||
          <Input
 | 
			
		||||
            v-model="searchQuery"
 | 
			
		||||
            :placeholder="$t('CAPTAIN.RESPONSES.SEARCH_PLACEHOLDER')"
 | 
			
		||||
            class="w-64"
 | 
			
		||||
            size="sm"
 | 
			
		||||
            autofocus
 | 
			
		||||
            @input="debouncedSearch"
 | 
			
		||||
          />
 | 
			
		||||
        </div>
 | 
			
		||||
 | 
			
		||||
        <transition
 | 
			
		||||
          name="slide-fade"
 | 
			
		||||
 
 | 
			
		||||
@@ -5,6 +5,7 @@ import { useStore, useMapGetter } from 'dashboard/composables/store';
 | 
			
		||||
import { useRoute, useRouter } from 'vue-router';
 | 
			
		||||
import { useAlert } from 'dashboard/composables';
 | 
			
		||||
import camelcaseKeys from 'camelcase-keys';
 | 
			
		||||
import { getInboxIconByType } from 'dashboard/helper/inbox';
 | 
			
		||||
 | 
			
		||||
import Breadcrumb from 'dashboard/components-next/breadcrumb/Breadcrumb.vue';
 | 
			
		||||
import SettingsLayout from 'dashboard/routes/dashboard/settings/SettingsLayout.vue';
 | 
			
		||||
@@ -58,7 +59,19 @@ const allAgents = computed(() =>
 | 
			
		||||
 | 
			
		||||
const allLabels = computed(() => buildList(labelsList.value));
 | 
			
		||||
 | 
			
		||||
const allInboxes = computed(() => buildList(inboxes.value));
 | 
			
		||||
const allInboxes = computed(
 | 
			
		||||
  () =>
 | 
			
		||||
    inboxes.value
 | 
			
		||||
      ?.slice()
 | 
			
		||||
      .sort((a, b) => a.name.localeCompare(b.name))
 | 
			
		||||
      .map(({ name, id, email, phoneNumber, channelType, medium }) => ({
 | 
			
		||||
        name,
 | 
			
		||||
        id,
 | 
			
		||||
        email,
 | 
			
		||||
        phoneNumber,
 | 
			
		||||
        icon: getInboxIconByType(channelType, medium, 'line'),
 | 
			
		||||
      })) || []
 | 
			
		||||
);
 | 
			
		||||
 | 
			
		||||
const formData = computed(() => ({
 | 
			
		||||
  name: selectedPolicy.value?.name || '',
 | 
			
		||||
 
 | 
			
		||||
@@ -215,8 +215,19 @@ defineExpose({
 | 
			
		||||
          v-model:window-unit="state.windowUnit"
 | 
			
		||||
        />
 | 
			
		||||
      </div>
 | 
			
		||||
    </div>
 | 
			
		||||
 | 
			
		||||
      <div v-if="showInboxSection" class="py-4 flex-col flex gap-4">
 | 
			
		||||
    <Button
 | 
			
		||||
      type="submit"
 | 
			
		||||
      :label="buttonLabel"
 | 
			
		||||
      :disabled="!validationState.isValid || isLoading"
 | 
			
		||||
      :is-loading="isLoading"
 | 
			
		||||
    />
 | 
			
		||||
 | 
			
		||||
    <div
 | 
			
		||||
      v-if="showInboxSection"
 | 
			
		||||
      class="py-4 flex-col flex gap-4 border-t border-n-weak mt-6"
 | 
			
		||||
    >
 | 
			
		||||
      <div class="flex items-end gap-4 w-full justify-between">
 | 
			
		||||
        <div class="flex flex-col items-start gap-1 py-1">
 | 
			
		||||
          <label class="text-sm font-medium text-n-slate-12 py-1">
 | 
			
		||||
@@ -242,13 +253,5 @@ defineExpose({
 | 
			
		||||
        @delete="$emit('deleteInbox', $event)"
 | 
			
		||||
      />
 | 
			
		||||
    </div>
 | 
			
		||||
    </div>
 | 
			
		||||
 | 
			
		||||
    <Button
 | 
			
		||||
      type="submit"
 | 
			
		||||
      :label="buttonLabel"
 | 
			
		||||
      :disabled="!validationState.isValid || isLoading"
 | 
			
		||||
      :is-loading="isLoading"
 | 
			
		||||
    />
 | 
			
		||||
  </form>
 | 
			
		||||
</template>
 | 
			
		||||
 
 | 
			
		||||
@@ -145,7 +145,7 @@ defineExpose({
 | 
			
		||||
 | 
			
		||||
<template>
 | 
			
		||||
  <form @submit.prevent="handleSubmit">
 | 
			
		||||
    <div class="flex flex-col gap-4 divide-y divide-n-weak">
 | 
			
		||||
    <div class="flex flex-col gap-4 mb-2 divide-y divide-n-weak">
 | 
			
		||||
      <BaseInfo
 | 
			
		||||
        v-model:policy-name="state.name"
 | 
			
		||||
        v-model:description="state.description"
 | 
			
		||||
 
 | 
			
		||||
@@ -68,6 +68,12 @@ export const AUTOMATIONS = {
 | 
			
		||||
        inputType: 'plain_text',
 | 
			
		||||
        filterOperators: OPERATOR_TYPES_6,
 | 
			
		||||
      },
 | 
			
		||||
      {
 | 
			
		||||
        key: 'labels',
 | 
			
		||||
        name: 'LABELS',
 | 
			
		||||
        inputType: 'multi_select',
 | 
			
		||||
        filterOperators: OPERATOR_TYPES_3,
 | 
			
		||||
      },
 | 
			
		||||
    ],
 | 
			
		||||
    actions: [
 | 
			
		||||
      {
 | 
			
		||||
@@ -186,6 +192,12 @@ export const AUTOMATIONS = {
 | 
			
		||||
        inputType: 'multi_select',
 | 
			
		||||
        filterOperators: OPERATOR_TYPES_1,
 | 
			
		||||
      },
 | 
			
		||||
      {
 | 
			
		||||
        key: 'labels',
 | 
			
		||||
        name: 'LABELS',
 | 
			
		||||
        inputType: 'multi_select',
 | 
			
		||||
        filterOperators: OPERATOR_TYPES_3,
 | 
			
		||||
      },
 | 
			
		||||
    ],
 | 
			
		||||
    actions: [
 | 
			
		||||
      {
 | 
			
		||||
@@ -308,6 +320,12 @@ export const AUTOMATIONS = {
 | 
			
		||||
        inputType: 'multi_select',
 | 
			
		||||
        filterOperators: OPERATOR_TYPES_1,
 | 
			
		||||
      },
 | 
			
		||||
      {
 | 
			
		||||
        key: 'labels',
 | 
			
		||||
        name: 'LABELS',
 | 
			
		||||
        inputType: 'multi_select',
 | 
			
		||||
        filterOperators: OPERATOR_TYPES_3,
 | 
			
		||||
      },
 | 
			
		||||
    ],
 | 
			
		||||
    actions: [
 | 
			
		||||
      {
 | 
			
		||||
@@ -424,6 +442,12 @@ export const AUTOMATIONS = {
 | 
			
		||||
        inputType: 'multi_select',
 | 
			
		||||
        filterOperators: OPERATOR_TYPES_1,
 | 
			
		||||
      },
 | 
			
		||||
      {
 | 
			
		||||
        key: 'labels',
 | 
			
		||||
        name: 'LABELS',
 | 
			
		||||
        inputType: 'multi_select',
 | 
			
		||||
        filterOperators: OPERATOR_TYPES_3,
 | 
			
		||||
      },
 | 
			
		||||
    ],
 | 
			
		||||
    actions: [
 | 
			
		||||
      {
 | 
			
		||||
 
 | 
			
		||||
@@ -331,7 +331,7 @@ export default {
 | 
			
		||||
        this.continuityViaEmail = this.inbox.continuity_via_email;
 | 
			
		||||
        this.channelWebsiteUrl = this.inbox.website_url;
 | 
			
		||||
        this.channelWelcomeTitle = this.inbox.welcome_title;
 | 
			
		||||
        this.channelWelcomeTagline = this.inbox.welcome_tagline;
 | 
			
		||||
        this.channelWelcomeTagline = this.inbox.welcome_tagline || '';
 | 
			
		||||
        this.selectedFeatureFlags = this.inbox.selected_feature_flags || [];
 | 
			
		||||
        this.replyTime = this.inbox.reply_time;
 | 
			
		||||
        this.locktoSingleConversation = this.inbox.lock_to_single_conversation;
 | 
			
		||||
 
 | 
			
		||||
@@ -7,6 +7,7 @@ import { useBranding } from 'shared/composables/useBranding';
 | 
			
		||||
import { clearCookiesOnLogout } from 'dashboard/store/utils/api.js';
 | 
			
		||||
import { copyTextToClipboard } from 'shared/helpers/clipboard';
 | 
			
		||||
import { parseAPIErrorResponse } from 'dashboard/store/utils/api';
 | 
			
		||||
import { parseBoolean } from '@chatwoot/utils';
 | 
			
		||||
import UserProfilePicture from './UserProfilePicture.vue';
 | 
			
		||||
import UserBasicDetails from './UserBasicDetails.vue';
 | 
			
		||||
import MessageSignature from './MessageSignature.vue';
 | 
			
		||||
@@ -18,6 +19,7 @@ import NotificationPreferences from './NotificationPreferences.vue';
 | 
			
		||||
import AudioNotifications from './AudioNotifications.vue';
 | 
			
		||||
import FormSection from 'dashboard/components/FormSection.vue';
 | 
			
		||||
import AccessToken from './AccessToken.vue';
 | 
			
		||||
import MfaSettingsCard from './MfaSettingsCard.vue';
 | 
			
		||||
import Policy from 'dashboard/components/policy.vue';
 | 
			
		||||
import {
 | 
			
		||||
  ROLES,
 | 
			
		||||
@@ -38,6 +40,7 @@ export default {
 | 
			
		||||
    NotificationPreferences,
 | 
			
		||||
    AudioNotifications,
 | 
			
		||||
    AccessToken,
 | 
			
		||||
    MfaSettingsCard,
 | 
			
		||||
  },
 | 
			
		||||
  setup() {
 | 
			
		||||
    const { isEditorHotKeyEnabled, updateUISettings } = useUISettings();
 | 
			
		||||
@@ -95,6 +98,9 @@ export default {
 | 
			
		||||
      currentUserId: 'getCurrentUserID',
 | 
			
		||||
      globalConfig: 'globalConfig/get',
 | 
			
		||||
    }),
 | 
			
		||||
    isMfaEnabled() {
 | 
			
		||||
      return parseBoolean(window.chatwootConfig?.isMfaEnabled);
 | 
			
		||||
    },
 | 
			
		||||
  },
 | 
			
		||||
  mounted() {
 | 
			
		||||
    if (this.currentUserId) {
 | 
			
		||||
@@ -283,6 +289,13 @@ export default {
 | 
			
		||||
    >
 | 
			
		||||
      <ChangePassword />
 | 
			
		||||
    </FormSection>
 | 
			
		||||
    <FormSection
 | 
			
		||||
      v-if="isMfaEnabled"
 | 
			
		||||
      :title="$t('PROFILE_SETTINGS.FORM.SECURITY_SECTION.TITLE')"
 | 
			
		||||
      :description="$t('PROFILE_SETTINGS.FORM.SECURITY_SECTION.NOTE')"
 | 
			
		||||
    >
 | 
			
		||||
      <MfaSettingsCard />
 | 
			
		||||
    </FormSection>
 | 
			
		||||
    <Policy :permissions="audioNotificationPermissions">
 | 
			
		||||
      <FormSection
 | 
			
		||||
        :title="$t('PROFILE_SETTINGS.FORM.AUDIO_NOTIFICATIONS_SECTION.TITLE')"
 | 
			
		||||
 
 | 
			
		||||
@@ -0,0 +1,248 @@
 | 
			
		||||
<script setup>
 | 
			
		||||
import { ref } from 'vue';
 | 
			
		||||
import { useI18n } from 'vue-i18n';
 | 
			
		||||
import { copyTextToClipboard } from 'shared/helpers/clipboard';
 | 
			
		||||
import { useAlert } from 'dashboard/composables';
 | 
			
		||||
import Button from 'dashboard/components-next/button/Button.vue';
 | 
			
		||||
import Input from 'dashboard/components-next/input/Input.vue';
 | 
			
		||||
import Icon from 'dashboard/components-next/icon/Icon.vue';
 | 
			
		||||
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
 | 
			
		||||
 | 
			
		||||
const props = defineProps({
 | 
			
		||||
  mfaEnabled: {
 | 
			
		||||
    type: Boolean,
 | 
			
		||||
    required: true,
 | 
			
		||||
  },
 | 
			
		||||
  backupCodes: {
 | 
			
		||||
    type: Array,
 | 
			
		||||
    default: () => [],
 | 
			
		||||
  },
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
const emit = defineEmits(['disableMfa', 'regenerateBackupCodes']);
 | 
			
		||||
 | 
			
		||||
const { t } = useI18n();
 | 
			
		||||
 | 
			
		||||
// Dialog refs
 | 
			
		||||
const disableDialogRef = ref(null);
 | 
			
		||||
const regenerateDialogRef = ref(null);
 | 
			
		||||
const backupCodesDialogRef = ref(null);
 | 
			
		||||
 | 
			
		||||
// Form values
 | 
			
		||||
const disablePassword = ref('');
 | 
			
		||||
const disableOtpCode = ref('');
 | 
			
		||||
const regenerateOtpCode = ref('');
 | 
			
		||||
 | 
			
		||||
// Utility functions
 | 
			
		||||
const copyBackupCodes = async () => {
 | 
			
		||||
  const codesText = props.backupCodes.join('\n');
 | 
			
		||||
  await copyTextToClipboard(codesText);
 | 
			
		||||
  useAlert(t('MFA_SETTINGS.BACKUP.CODES_COPIED'));
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
const downloadBackupCodes = () => {
 | 
			
		||||
  const codesText = `Chatwoot Two-Factor Authentication Backup Codes\n\n${props.backupCodes.join('\n')}\n\nKeep these codes in a safe place.`;
 | 
			
		||||
  const blob = new Blob([codesText], { type: 'text/plain' });
 | 
			
		||||
  const url = URL.createObjectURL(blob);
 | 
			
		||||
  const a = document.createElement('a');
 | 
			
		||||
  a.href = url;
 | 
			
		||||
  a.download = 'chatwoot-backup-codes.txt';
 | 
			
		||||
  a.click();
 | 
			
		||||
  URL.revokeObjectURL(url);
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
const handleDisableMfa = async () => {
 | 
			
		||||
  emit('disableMfa', {
 | 
			
		||||
    password: disablePassword.value,
 | 
			
		||||
    otpCode: disableOtpCode.value,
 | 
			
		||||
  });
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
const handleRegenerateBackupCodes = async () => {
 | 
			
		||||
  emit('regenerateBackupCodes', {
 | 
			
		||||
    otpCode: regenerateOtpCode.value,
 | 
			
		||||
  });
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
// Methods exposed for parent component
 | 
			
		||||
const resetDisableForm = () => {
 | 
			
		||||
  disablePassword.value = '';
 | 
			
		||||
  disableOtpCode.value = '';
 | 
			
		||||
  disableDialogRef.value?.close();
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
const resetRegenerateForm = () => {
 | 
			
		||||
  regenerateOtpCode.value = '';
 | 
			
		||||
  regenerateDialogRef.value?.close();
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
const showBackupCodesDialog = () => {
 | 
			
		||||
  backupCodesDialogRef.value?.open();
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
defineExpose({
 | 
			
		||||
  resetDisableForm,
 | 
			
		||||
  resetRegenerateForm,
 | 
			
		||||
  showBackupCodesDialog,
 | 
			
		||||
});
 | 
			
		||||
</script>
 | 
			
		||||
 | 
			
		||||
<template>
 | 
			
		||||
  <div v-if="mfaEnabled">
 | 
			
		||||
    <!-- Actions Grid -->
 | 
			
		||||
    <div class="grid grid-cols-1 md:grid-cols-2 gap-4">
 | 
			
		||||
      <!-- Regenerate Backup Codes -->
 | 
			
		||||
      <div class="bg-n-solid-1 rounded-xl outline-1 outline-n-weak outline p-5">
 | 
			
		||||
        <div class="flex-1 flex flex-col gap-2">
 | 
			
		||||
          <div class="flex items-center gap-2">
 | 
			
		||||
            <Icon
 | 
			
		||||
              icon="i-lucide-key"
 | 
			
		||||
              class="size-4 flex-shrink-0 text-n-slate-11"
 | 
			
		||||
            />
 | 
			
		||||
            <h4 class="font-medium text-n-slate-12">
 | 
			
		||||
              {{ $t('MFA_SETTINGS.MANAGEMENT.BACKUP_CODES') }}
 | 
			
		||||
            </h4>
 | 
			
		||||
          </div>
 | 
			
		||||
          <p class="text-sm text-n-slate-11">
 | 
			
		||||
            {{ $t('MFA_SETTINGS.MANAGEMENT.BACKUP_CODES_DESC') }}
 | 
			
		||||
          </p>
 | 
			
		||||
          <Button
 | 
			
		||||
            faded
 | 
			
		||||
            slate
 | 
			
		||||
            :label="$t('MFA_SETTINGS.MANAGEMENT.REGENERATE')"
 | 
			
		||||
            @click="regenerateDialogRef?.open()"
 | 
			
		||||
          />
 | 
			
		||||
        </div>
 | 
			
		||||
      </div>
 | 
			
		||||
 | 
			
		||||
      <!-- Disable MFA -->
 | 
			
		||||
      <div class="bg-n-solid-1 rounded-xl outline-1 outline-n-weak outline p-5">
 | 
			
		||||
        <div class="flex-1 flex flex-col gap-2">
 | 
			
		||||
          <div class="flex items-center gap-2">
 | 
			
		||||
            <Icon
 | 
			
		||||
              icon="i-lucide-lock-keyhole-open"
 | 
			
		||||
              class="size-4 flex-shrink-0 text-n-slate-11"
 | 
			
		||||
            />
 | 
			
		||||
            <h4 class="font-medium text-n-slate-12">
 | 
			
		||||
              {{ $t('MFA_SETTINGS.MANAGEMENT.DISABLE_MFA') }}
 | 
			
		||||
            </h4>
 | 
			
		||||
          </div>
 | 
			
		||||
          <p class="text-sm text-n-slate-11">
 | 
			
		||||
            {{ $t('MFA_SETTINGS.MANAGEMENT.DISABLE_MFA_DESC') }}
 | 
			
		||||
          </p>
 | 
			
		||||
          <Button
 | 
			
		||||
            faded
 | 
			
		||||
            ruby
 | 
			
		||||
            :label="$t('MFA_SETTINGS.MANAGEMENT.DISABLE_BUTTON')"
 | 
			
		||||
            @click="disableDialogRef?.open()"
 | 
			
		||||
          />
 | 
			
		||||
        </div>
 | 
			
		||||
      </div>
 | 
			
		||||
    </div>
 | 
			
		||||
 | 
			
		||||
    <!-- Disable MFA Dialog -->
 | 
			
		||||
    <Dialog
 | 
			
		||||
      ref="disableDialogRef"
 | 
			
		||||
      type="alert"
 | 
			
		||||
      :title="$t('MFA_SETTINGS.DISABLE.TITLE')"
 | 
			
		||||
      :description="$t('MFA_SETTINGS.DISABLE.DESCRIPTION')"
 | 
			
		||||
      :confirm-button-label="$t('MFA_SETTINGS.DISABLE.CONFIRM')"
 | 
			
		||||
      :cancel-button-label="$t('MFA_SETTINGS.DISABLE.CANCEL')"
 | 
			
		||||
      @confirm="handleDisableMfa"
 | 
			
		||||
    >
 | 
			
		||||
      <div class="space-y-4">
 | 
			
		||||
        <Input
 | 
			
		||||
          v-model="disablePassword"
 | 
			
		||||
          type="password"
 | 
			
		||||
          :label="$t('MFA_SETTINGS.DISABLE.PASSWORD')"
 | 
			
		||||
        />
 | 
			
		||||
        <Input
 | 
			
		||||
          v-model="disableOtpCode"
 | 
			
		||||
          type="text"
 | 
			
		||||
          maxlength="6"
 | 
			
		||||
          :label="$t('MFA_SETTINGS.DISABLE.OTP_CODE')"
 | 
			
		||||
          :placeholder="$t('MFA_SETTINGS.DISABLE.OTP_CODE_PLACEHOLDER')"
 | 
			
		||||
        />
 | 
			
		||||
      </div>
 | 
			
		||||
    </Dialog>
 | 
			
		||||
 | 
			
		||||
    <!-- Regenerate Backup Codes Dialog -->
 | 
			
		||||
    <Dialog
 | 
			
		||||
      ref="regenerateDialogRef"
 | 
			
		||||
      type="edit"
 | 
			
		||||
      :title="$t('MFA_SETTINGS.REGENERATE.TITLE')"
 | 
			
		||||
      :description="$t('MFA_SETTINGS.REGENERATE.DESCRIPTION')"
 | 
			
		||||
      :confirm-button-label="$t('MFA_SETTINGS.REGENERATE.CONFIRM')"
 | 
			
		||||
      :cancel-button-label="$t('MFA_SETTINGS.DISABLE.CANCEL')"
 | 
			
		||||
      @confirm="handleRegenerateBackupCodes"
 | 
			
		||||
    >
 | 
			
		||||
      <Input
 | 
			
		||||
        v-model="regenerateOtpCode"
 | 
			
		||||
        type="text"
 | 
			
		||||
        maxlength="6"
 | 
			
		||||
        :label="$t('MFA_SETTINGS.REGENERATE.OTP_CODE')"
 | 
			
		||||
        :placeholder="$t('MFA_SETTINGS.REGENERATE.OTP_CODE_PLACEHOLDER')"
 | 
			
		||||
      />
 | 
			
		||||
    </Dialog>
 | 
			
		||||
 | 
			
		||||
    <!-- Backup Codes Display Dialog -->
 | 
			
		||||
    <Dialog
 | 
			
		||||
      ref="backupCodesDialogRef"
 | 
			
		||||
      type="edit"
 | 
			
		||||
      width="2xl"
 | 
			
		||||
      :title="$t('MFA_SETTINGS.REGENERATE.NEW_CODES_TITLE')"
 | 
			
		||||
      :description="$t('MFA_SETTINGS.REGENERATE.NEW_CODES_DESC')"
 | 
			
		||||
      :show-cancel-button="false"
 | 
			
		||||
      :confirm-button-label="$t('MFA_SETTINGS.REGENERATE.CODES_SAVED')"
 | 
			
		||||
      @confirm="backupCodesDialogRef?.close()"
 | 
			
		||||
    >
 | 
			
		||||
      <!-- Warning Alert -->
 | 
			
		||||
      <div
 | 
			
		||||
        class="flex items-start gap-2 p-4 bg-n-solid-1 outline outline-n-weak rounded-xl outline-1"
 | 
			
		||||
      >
 | 
			
		||||
        <Icon
 | 
			
		||||
          icon="i-lucide-alert-circle"
 | 
			
		||||
          class="size-4 text-n-slate-10 flex-shrink-0 mt-0.5"
 | 
			
		||||
        />
 | 
			
		||||
        <p class="text-sm text-n-slate-11">
 | 
			
		||||
          <strong>{{ $t('MFA_SETTINGS.BACKUP.IMPORTANT') }}</strong>
 | 
			
		||||
          {{ $t('MFA_SETTINGS.BACKUP.IMPORTANT_NOTE') }}
 | 
			
		||||
        </p>
 | 
			
		||||
      </div>
 | 
			
		||||
 | 
			
		||||
      <div
 | 
			
		||||
        class="bg-n-solid-1 rounded-xl outline-1 outline-n-weak outline flex flex-col gap-6 p-6"
 | 
			
		||||
      >
 | 
			
		||||
        <div class="grid grid-cols-2 xs:grid-cols-4 sm:grid-cols-5 gap-3">
 | 
			
		||||
          <span
 | 
			
		||||
            v-for="(code, index) in backupCodes"
 | 
			
		||||
            :key="index"
 | 
			
		||||
            class="px-1 py-2 font-mono text-base text-center text-n-slate-12"
 | 
			
		||||
          >
 | 
			
		||||
            {{ code }}
 | 
			
		||||
          </span>
 | 
			
		||||
        </div>
 | 
			
		||||
 | 
			
		||||
        <div class="flex items-center justify-center gap-3">
 | 
			
		||||
          <Button
 | 
			
		||||
            outline
 | 
			
		||||
            slate
 | 
			
		||||
            sm
 | 
			
		||||
            icon="i-lucide-download"
 | 
			
		||||
            :label="$t('MFA_SETTINGS.BACKUP.DOWNLOAD')"
 | 
			
		||||
            @click="downloadBackupCodes"
 | 
			
		||||
          />
 | 
			
		||||
          <Button
 | 
			
		||||
            outline
 | 
			
		||||
            slate
 | 
			
		||||
            sm
 | 
			
		||||
            icon="i-lucide-clipboard"
 | 
			
		||||
            :label="$t('MFA_SETTINGS.BACKUP.COPY_ALL')"
 | 
			
		||||
            @click="copyBackupCodes"
 | 
			
		||||
          />
 | 
			
		||||
        </div>
 | 
			
		||||
      </div>
 | 
			
		||||
    </Dialog>
 | 
			
		||||
  </div>
 | 
			
		||||
  <template v-else />
 | 
			
		||||
</template>
 | 
			
		||||
@@ -0,0 +1,178 @@
 | 
			
		||||
<script setup>
 | 
			
		||||
import { ref, onMounted } from 'vue';
 | 
			
		||||
import { useI18n } from 'vue-i18n';
 | 
			
		||||
import { useRouter, useRoute } from 'vue-router';
 | 
			
		||||
import { parseBoolean } from '@chatwoot/utils';
 | 
			
		||||
import mfaAPI from 'dashboard/api/mfa';
 | 
			
		||||
import { useAlert } from 'dashboard/composables';
 | 
			
		||||
import MfaStatusCard from './MfaStatusCard.vue';
 | 
			
		||||
import MfaSetupWizard from './MfaSetupWizard.vue';
 | 
			
		||||
import MfaManagementActions from './MfaManagementActions.vue';
 | 
			
		||||
 | 
			
		||||
const { t } = useI18n();
 | 
			
		||||
const router = useRouter();
 | 
			
		||||
const route = useRoute();
 | 
			
		||||
 | 
			
		||||
// State
 | 
			
		||||
const mfaEnabled = ref(false);
 | 
			
		||||
const backupCodesGenerated = ref(false);
 | 
			
		||||
const showSetup = ref(false);
 | 
			
		||||
const provisioningUri = ref('');
 | 
			
		||||
const qrCodeUrl = ref('');
 | 
			
		||||
const secretKey = ref('');
 | 
			
		||||
const backupCodes = ref([]);
 | 
			
		||||
 | 
			
		||||
// Component refs
 | 
			
		||||
const setupWizardRef = ref(null);
 | 
			
		||||
const managementActionsRef = ref(null);
 | 
			
		||||
 | 
			
		||||
// Load MFA status on mount
 | 
			
		||||
onMounted(async () => {
 | 
			
		||||
  // Check if MFA is enabled globally
 | 
			
		||||
  if (!parseBoolean(window.chatwootConfig?.isMfaEnabled)) {
 | 
			
		||||
    // Redirect to profile settings if MFA is disabled
 | 
			
		||||
    router.push({
 | 
			
		||||
      name: 'profile_settings_index',
 | 
			
		||||
      params: {
 | 
			
		||||
        accountId: route.params.accountId,
 | 
			
		||||
      },
 | 
			
		||||
    });
 | 
			
		||||
    return;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  try {
 | 
			
		||||
    const response = await mfaAPI.get();
 | 
			
		||||
    mfaEnabled.value = response.data.enabled;
 | 
			
		||||
    backupCodesGenerated.value = response.data.backup_codes_generated;
 | 
			
		||||
  } catch (error) {
 | 
			
		||||
    // Handle error silently
 | 
			
		||||
  }
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
// Start MFA setup
 | 
			
		||||
const startMfaSetup = async () => {
 | 
			
		||||
  try {
 | 
			
		||||
    const response = await mfaAPI.enable();
 | 
			
		||||
 | 
			
		||||
    // Store the provisioning URI
 | 
			
		||||
    provisioningUri.value =
 | 
			
		||||
      response.data.provisioning_uri || response.data.provisioning_url;
 | 
			
		||||
 | 
			
		||||
    // Store QR code URL if provided by backend
 | 
			
		||||
    if (response.data.qr_code_url) {
 | 
			
		||||
      qrCodeUrl.value = response.data.qr_code_url;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    secretKey.value = response.data.secret;
 | 
			
		||||
    // Backup codes are now generated after verification, not during enable
 | 
			
		||||
    backupCodes.value = [];
 | 
			
		||||
    showSetup.value = true;
 | 
			
		||||
  } catch (error) {
 | 
			
		||||
    useAlert(t('MFA_SETTINGS.SETUP.ERROR_STARTING'));
 | 
			
		||||
  }
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
// Verify OTP code
 | 
			
		||||
const verifyCode = async verificationCode => {
 | 
			
		||||
  try {
 | 
			
		||||
    const response = await mfaAPI.verify(verificationCode);
 | 
			
		||||
    // Store backup codes returned from verification
 | 
			
		||||
    if (response.data.backup_codes) {
 | 
			
		||||
      backupCodes.value = response.data.backup_codes;
 | 
			
		||||
    }
 | 
			
		||||
    return true;
 | 
			
		||||
  } catch (error) {
 | 
			
		||||
    setupWizardRef.value?.handleVerificationError(
 | 
			
		||||
      error.response?.data?.error || t('MFA_SETTINGS.SETUP.INVALID_CODE')
 | 
			
		||||
    );
 | 
			
		||||
    throw error;
 | 
			
		||||
  }
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
// Complete MFA setup
 | 
			
		||||
const completeMfaSetup = () => {
 | 
			
		||||
  mfaEnabled.value = true;
 | 
			
		||||
  backupCodesGenerated.value = true;
 | 
			
		||||
  showSetup.value = false;
 | 
			
		||||
  useAlert(t('MFA_SETTINGS.SETUP.SUCCESS'));
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
// Cancel setup
 | 
			
		||||
const cancelSetup = () => {
 | 
			
		||||
  showSetup.value = false;
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
// Disable MFA
 | 
			
		||||
const disableMfa = async ({ password, otpCode }) => {
 | 
			
		||||
  try {
 | 
			
		||||
    await mfaAPI.disable(password, otpCode);
 | 
			
		||||
    mfaEnabled.value = false;
 | 
			
		||||
    backupCodesGenerated.value = false;
 | 
			
		||||
    managementActionsRef.value?.resetDisableForm();
 | 
			
		||||
    useAlert(t('MFA_SETTINGS.DISABLE.SUCCESS'));
 | 
			
		||||
  } catch (error) {
 | 
			
		||||
    useAlert(t('MFA_SETTINGS.DISABLE.ERROR'));
 | 
			
		||||
  }
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
// Regenerate backup codes
 | 
			
		||||
const regenerateBackupCodes = async ({ otpCode }) => {
 | 
			
		||||
  try {
 | 
			
		||||
    const response = await mfaAPI.regenerateBackupCodes(otpCode);
 | 
			
		||||
    backupCodes.value = response.data.backup_codes;
 | 
			
		||||
    managementActionsRef.value?.resetRegenerateForm();
 | 
			
		||||
    managementActionsRef.value?.showBackupCodesDialog();
 | 
			
		||||
    useAlert(t('MFA_SETTINGS.REGENERATE.SUCCESS'));
 | 
			
		||||
  } catch (error) {
 | 
			
		||||
    useAlert(t('MFA_SETTINGS.REGENERATE.ERROR'));
 | 
			
		||||
  }
 | 
			
		||||
};
 | 
			
		||||
</script>
 | 
			
		||||
 | 
			
		||||
<template>
 | 
			
		||||
  <div
 | 
			
		||||
    class="grid py-16 px-5 font-inter mx-auto gap-16 sm:max-w-screen-md w-full"
 | 
			
		||||
  >
 | 
			
		||||
    <!-- Page Header -->
 | 
			
		||||
    <div class="flex flex-col gap-6">
 | 
			
		||||
      <h2 class="text-2xl font-medium text-n-slate-12">
 | 
			
		||||
        {{ $t('MFA_SETTINGS.TITLE') }}
 | 
			
		||||
      </h2>
 | 
			
		||||
      <p class="text-sm text-n-slate-11">
 | 
			
		||||
        {{ $t('MFA_SETTINGS.SUBTITLE') }}
 | 
			
		||||
      </p>
 | 
			
		||||
    </div>
 | 
			
		||||
 | 
			
		||||
    <div class="grid gap-4 w-full">
 | 
			
		||||
      <!-- MFA Status Card -->
 | 
			
		||||
      <MfaStatusCard
 | 
			
		||||
        :mfa-enabled="mfaEnabled"
 | 
			
		||||
        :show-setup="showSetup"
 | 
			
		||||
        @enable-mfa="startMfaSetup"
 | 
			
		||||
      />
 | 
			
		||||
 | 
			
		||||
      <!-- MFA Setup Wizard -->
 | 
			
		||||
      <MfaSetupWizard
 | 
			
		||||
        ref="setupWizardRef"
 | 
			
		||||
        :show-setup="showSetup"
 | 
			
		||||
        :mfa-enabled="mfaEnabled"
 | 
			
		||||
        :provisioning-uri="provisioningUri"
 | 
			
		||||
        :secret-key="secretKey"
 | 
			
		||||
        :backup-codes="backupCodes"
 | 
			
		||||
        :qr-code-url-prop="qrCodeUrl"
 | 
			
		||||
        @cancel="cancelSetup"
 | 
			
		||||
        @verify="verifyCode"
 | 
			
		||||
        @complete="completeMfaSetup"
 | 
			
		||||
      />
 | 
			
		||||
 | 
			
		||||
      <!-- MFA Management Actions -->
 | 
			
		||||
      <MfaManagementActions
 | 
			
		||||
        ref="managementActionsRef"
 | 
			
		||||
        :mfa-enabled="mfaEnabled"
 | 
			
		||||
        :backup-codes="backupCodes"
 | 
			
		||||
        @disable-mfa="disableMfa"
 | 
			
		||||
        @regenerate-backup-codes="regenerateBackupCodes"
 | 
			
		||||
      />
 | 
			
		||||
    </div>
 | 
			
		||||
  </div>
 | 
			
		||||
</template>
 | 
			
		||||
@@ -0,0 +1,47 @@
 | 
			
		||||
<script setup>
 | 
			
		||||
import { useRouter, useRoute } from 'vue-router';
 | 
			
		||||
 | 
			
		||||
import Button from 'dashboard/components-next/button/Button.vue';
 | 
			
		||||
import Icon from 'dashboard/components-next/icon/Icon.vue';
 | 
			
		||||
 | 
			
		||||
const router = useRouter();
 | 
			
		||||
const route = useRoute();
 | 
			
		||||
 | 
			
		||||
const navigateToMfa = () => {
 | 
			
		||||
  router.push({
 | 
			
		||||
    name: 'profile_settings_mfa',
 | 
			
		||||
    params: {
 | 
			
		||||
      accountId: route.params.accountId,
 | 
			
		||||
    },
 | 
			
		||||
  });
 | 
			
		||||
};
 | 
			
		||||
</script>
 | 
			
		||||
 | 
			
		||||
<template>
 | 
			
		||||
  <div class="bg-n-background rounded-xl p-4 border border-n-slate-4">
 | 
			
		||||
    <div class="flex flex-col xs:flex-row items-center justify-between gap-4">
 | 
			
		||||
      <div class="flex flex-col items-start gap-1.5">
 | 
			
		||||
        <div class="flex items-center gap-2">
 | 
			
		||||
          <Icon
 | 
			
		||||
            icon="i-lucide-lock-keyhole"
 | 
			
		||||
            class="size-4 text-n-slate-10 flex-shrink-0"
 | 
			
		||||
          />
 | 
			
		||||
          <h5 class="text-sm font-semibold text-n-slate-12">
 | 
			
		||||
            {{ $t('MFA_SETTINGS.TITLE') }}
 | 
			
		||||
          </h5>
 | 
			
		||||
        </div>
 | 
			
		||||
        <p class="text-sm text-n-slate-11">
 | 
			
		||||
          {{ $t('MFA_SETTINGS.DESCRIPTION') }}
 | 
			
		||||
        </p>
 | 
			
		||||
      </div>
 | 
			
		||||
      <Button
 | 
			
		||||
        type="button"
 | 
			
		||||
        faded
 | 
			
		||||
        :label="$t('PROFILE_SETTINGS.FORM.SECURITY_SECTION.MFA_BUTTON')"
 | 
			
		||||
        icon="i-lucide-settings"
 | 
			
		||||
        class="flex-shrink-0"
 | 
			
		||||
        @click="navigateToMfa"
 | 
			
		||||
      />
 | 
			
		||||
    </div>
 | 
			
		||||
  </div>
 | 
			
		||||
</template>
 | 
			
		||||
@@ -0,0 +1,323 @@
 | 
			
		||||
<script setup>
 | 
			
		||||
import { ref, watch } from 'vue';
 | 
			
		||||
import { useI18n } from 'vue-i18n';
 | 
			
		||||
import QRCode from 'qrcode';
 | 
			
		||||
import { copyTextToClipboard } from 'shared/helpers/clipboard';
 | 
			
		||||
import { useAlert } from 'dashboard/composables';
 | 
			
		||||
import Button from 'dashboard/components-next/button/Button.vue';
 | 
			
		||||
import Input from 'dashboard/components-next/input/Input.vue';
 | 
			
		||||
import Icon from 'dashboard/components-next/icon/Icon.vue';
 | 
			
		||||
 | 
			
		||||
const props = defineProps({
 | 
			
		||||
  showSetup: {
 | 
			
		||||
    type: Boolean,
 | 
			
		||||
    required: true,
 | 
			
		||||
  },
 | 
			
		||||
  mfaEnabled: {
 | 
			
		||||
    type: Boolean,
 | 
			
		||||
    required: true,
 | 
			
		||||
  },
 | 
			
		||||
  provisioningUri: {
 | 
			
		||||
    type: String,
 | 
			
		||||
    default: '',
 | 
			
		||||
  },
 | 
			
		||||
  secretKey: {
 | 
			
		||||
    type: String,
 | 
			
		||||
    default: '',
 | 
			
		||||
  },
 | 
			
		||||
  backupCodes: {
 | 
			
		||||
    type: Array,
 | 
			
		||||
    default: () => [],
 | 
			
		||||
  },
 | 
			
		||||
  qrCodeUrlProp: {
 | 
			
		||||
    type: String,
 | 
			
		||||
    default: '',
 | 
			
		||||
  },
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
const emit = defineEmits(['cancel', 'verify', 'complete']);
 | 
			
		||||
 | 
			
		||||
const { t } = useI18n();
 | 
			
		||||
 | 
			
		||||
// Local state
 | 
			
		||||
const setupStep = ref('qr');
 | 
			
		||||
const qrCodeUrl = ref('');
 | 
			
		||||
const verificationCode = ref('');
 | 
			
		||||
const verificationError = ref('');
 | 
			
		||||
const backupCodesConfirmed = ref(false);
 | 
			
		||||
 | 
			
		||||
// Generate QR code from provisioning URI
 | 
			
		||||
const generateQRCode = async provisioningUrl => {
 | 
			
		||||
  try {
 | 
			
		||||
    const qrCodeDataUrl = await QRCode.toDataURL(provisioningUrl, {
 | 
			
		||||
      width: 256,
 | 
			
		||||
      margin: 2,
 | 
			
		||||
      color: {
 | 
			
		||||
        dark: '#000000',
 | 
			
		||||
        light: '#FFFFFF',
 | 
			
		||||
      },
 | 
			
		||||
    });
 | 
			
		||||
    return qrCodeDataUrl;
 | 
			
		||||
  } catch (error) {
 | 
			
		||||
    return null;
 | 
			
		||||
  }
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
// Watch for provisioning URI changes
 | 
			
		||||
watch(
 | 
			
		||||
  () => props.provisioningUri,
 | 
			
		||||
  async newUri => {
 | 
			
		||||
    if (newUri) {
 | 
			
		||||
      qrCodeUrl.value = await generateQRCode(newUri);
 | 
			
		||||
    } else if (props.qrCodeUrlProp) {
 | 
			
		||||
      qrCodeUrl.value = props.qrCodeUrlProp;
 | 
			
		||||
    }
 | 
			
		||||
  },
 | 
			
		||||
  { immediate: true }
 | 
			
		||||
);
 | 
			
		||||
 | 
			
		||||
const verifyCode = async () => {
 | 
			
		||||
  verificationError.value = '';
 | 
			
		||||
  try {
 | 
			
		||||
    emit('verify', verificationCode.value);
 | 
			
		||||
    setupStep.value = 'backup';
 | 
			
		||||
    verificationCode.value = '';
 | 
			
		||||
  } catch (error) {
 | 
			
		||||
    verificationError.value = t('MFA_SETTINGS.SETUP.INVALID_CODE');
 | 
			
		||||
  }
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
const copySecret = async () => {
 | 
			
		||||
  await copyTextToClipboard(props.secretKey);
 | 
			
		||||
  useAlert(t('MFA_SETTINGS.SETUP.SECRET_COPIED'));
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
const copyBackupCodes = async () => {
 | 
			
		||||
  const codesText = props.backupCodes.join('\n');
 | 
			
		||||
  await copyTextToClipboard(codesText);
 | 
			
		||||
  useAlert(t('MFA_SETTINGS.BACKUP.CODES_COPIED'));
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
const downloadBackupCodes = () => {
 | 
			
		||||
  const codesText = `Chatwoot Two-Factor Authentication Backup Codes\n\n${props.backupCodes.join('\n')}\n\nKeep these codes in a safe place.`;
 | 
			
		||||
  const blob = new Blob([codesText], { type: 'text/plain' });
 | 
			
		||||
  const url = URL.createObjectURL(blob);
 | 
			
		||||
  const a = document.createElement('a');
 | 
			
		||||
  a.href = url;
 | 
			
		||||
  a.download = 'chatwoot-backup-codes.txt';
 | 
			
		||||
  a.click();
 | 
			
		||||
  URL.revokeObjectURL(url);
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
const cancelSetup = () => {
 | 
			
		||||
  setupStep.value = 'qr';
 | 
			
		||||
  verificationCode.value = '';
 | 
			
		||||
  verificationError.value = '';
 | 
			
		||||
  backupCodesConfirmed.value = false;
 | 
			
		||||
  emit('cancel');
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
const completeMfaSetup = () => {
 | 
			
		||||
  setupStep.value = 'qr';
 | 
			
		||||
  backupCodesConfirmed.value = false;
 | 
			
		||||
  emit('complete');
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
// Reset when showSetup changes
 | 
			
		||||
watch(
 | 
			
		||||
  () => props.showSetup,
 | 
			
		||||
  newVal => {
 | 
			
		||||
    if (newVal) {
 | 
			
		||||
      setupStep.value = 'qr';
 | 
			
		||||
      verificationCode.value = '';
 | 
			
		||||
      verificationError.value = '';
 | 
			
		||||
      backupCodesConfirmed.value = false;
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
);
 | 
			
		||||
 | 
			
		||||
// Handle verification error
 | 
			
		||||
const handleVerificationError = error => {
 | 
			
		||||
  verificationError.value = error || t('MFA_SETTINGS.SETUP.INVALID_CODE');
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
defineExpose({
 | 
			
		||||
  handleVerificationError,
 | 
			
		||||
  setupStep,
 | 
			
		||||
});
 | 
			
		||||
</script>
 | 
			
		||||
 | 
			
		||||
<template>
 | 
			
		||||
  <div v-if="showSetup && !mfaEnabled">
 | 
			
		||||
    <!-- Step 1: QR Code -->
 | 
			
		||||
    <div v-if="setupStep === 'qr'" class="space-y-6">
 | 
			
		||||
      <div
 | 
			
		||||
        class="bg-n-solid-1 rounded-xl outline-1 outline-n-weak outline p-10 flex flex-col gap-4"
 | 
			
		||||
      >
 | 
			
		||||
        <div class="text-center">
 | 
			
		||||
          <h3 class="text-lg font-medium text-n-slate-12 mb-2">
 | 
			
		||||
            {{ $t('MFA_SETTINGS.SETUP.STEP1_TITLE') }}
 | 
			
		||||
          </h3>
 | 
			
		||||
          <p class="text-sm text-n-slate-11">
 | 
			
		||||
            {{ $t('MFA_SETTINGS.SETUP.STEP1_DESCRIPTION') }}
 | 
			
		||||
          </p>
 | 
			
		||||
        </div>
 | 
			
		||||
        <div class="flex justify-center">
 | 
			
		||||
          <div
 | 
			
		||||
            class="bg-n-background p-4 rounded-lg outline outline-1 outline-n-weak"
 | 
			
		||||
          >
 | 
			
		||||
            <img
 | 
			
		||||
              v-if="qrCodeUrl"
 | 
			
		||||
              :src="qrCodeUrl"
 | 
			
		||||
              alt="MFA QR Code"
 | 
			
		||||
              class="w-48 h-48 dark:invert-0"
 | 
			
		||||
            />
 | 
			
		||||
            <div
 | 
			
		||||
              v-else
 | 
			
		||||
              class="w-48 h-48 flex items-center justify-center bg-n-slate-2 dark:bg-n-slate-3"
 | 
			
		||||
            >
 | 
			
		||||
              <span class="text-n-slate-10">
 | 
			
		||||
                {{ $t('MFA_SETTINGS.SETUP.LOADING_QR') }}
 | 
			
		||||
              </span>
 | 
			
		||||
            </div>
 | 
			
		||||
          </div>
 | 
			
		||||
        </div>
 | 
			
		||||
 | 
			
		||||
        <details class="border border-n-slate-4 rounded-lg">
 | 
			
		||||
          <summary
 | 
			
		||||
            class="px-4 py-3 cursor-pointer hover:bg-n-slate-2 dark:hover:bg-n-slate-3 text-sm font-medium text-n-slate-11"
 | 
			
		||||
          >
 | 
			
		||||
            {{ $t('MFA_SETTINGS.SETUP.MANUAL_ENTRY') }}
 | 
			
		||||
          </summary>
 | 
			
		||||
          <div class="px-4 pb-4">
 | 
			
		||||
            <label class="block text-xs text-n-slate-10 mb-2">
 | 
			
		||||
              {{ $t('MFA_SETTINGS.SETUP.SECRET_KEY') }}
 | 
			
		||||
            </label>
 | 
			
		||||
            <div class="flex items-center gap-2">
 | 
			
		||||
              <Input :model-value="secretKey" readonly class="flex-1" />
 | 
			
		||||
              <Button
 | 
			
		||||
                variant="outline"
 | 
			
		||||
                color="slate"
 | 
			
		||||
                size="sm"
 | 
			
		||||
                :label="$t('MFA_SETTINGS.SETUP.COPY')"
 | 
			
		||||
                @click="copySecret"
 | 
			
		||||
              />
 | 
			
		||||
            </div>
 | 
			
		||||
          </div>
 | 
			
		||||
        </details>
 | 
			
		||||
 | 
			
		||||
        <div class="flex flex-col items-start gap-3 w-full">
 | 
			
		||||
          <Input
 | 
			
		||||
            v-model="verificationCode"
 | 
			
		||||
            type="text"
 | 
			
		||||
            maxlength="6"
 | 
			
		||||
            pattern="[0-9]{6}"
 | 
			
		||||
            :label="$t('MFA_SETTINGS.SETUP.ENTER_CODE')"
 | 
			
		||||
            :placeholder="$t('MFA_SETTINGS.SETUP.ENTER_CODE_PLACEHOLDER')"
 | 
			
		||||
            :message="verificationError"
 | 
			
		||||
            :message-type="verificationError ? 'error' : 'info'"
 | 
			
		||||
            class="w-full"
 | 
			
		||||
            @keyup.enter="verifyCode"
 | 
			
		||||
          />
 | 
			
		||||
 | 
			
		||||
          <div class="flex gap-3 mt-1 w-full justify-between">
 | 
			
		||||
            <Button
 | 
			
		||||
              faded
 | 
			
		||||
              color="slate"
 | 
			
		||||
              class="flex-1"
 | 
			
		||||
              :label="$t('MFA_SETTINGS.SETUP.CANCEL')"
 | 
			
		||||
              @click="cancelSetup"
 | 
			
		||||
            />
 | 
			
		||||
            <Button
 | 
			
		||||
              class="flex-1"
 | 
			
		||||
              :disabled="verificationCode.length !== 6"
 | 
			
		||||
              :label="$t('MFA_SETTINGS.SETUP.VERIFY_BUTTON')"
 | 
			
		||||
              @click="verifyCode"
 | 
			
		||||
            />
 | 
			
		||||
          </div>
 | 
			
		||||
        </div>
 | 
			
		||||
      </div>
 | 
			
		||||
    </div>
 | 
			
		||||
 | 
			
		||||
    <!-- Step 2: Backup Codes -->
 | 
			
		||||
    <div v-if="setupStep === 'backup'" class="space-y-6">
 | 
			
		||||
      <div class="text-start">
 | 
			
		||||
        <h3 class="text-lg font-medium text-n-slate-12 mb-2">
 | 
			
		||||
          {{ $t('MFA_SETTINGS.BACKUP.TITLE') }}
 | 
			
		||||
        </h3>
 | 
			
		||||
        <p class="text-sm text-n-slate-11">
 | 
			
		||||
          {{ $t('MFA_SETTINGS.BACKUP.DESCRIPTION') }}
 | 
			
		||||
        </p>
 | 
			
		||||
      </div>
 | 
			
		||||
 | 
			
		||||
      <!-- Warning Alert -->
 | 
			
		||||
      <div
 | 
			
		||||
        class="flex items-start gap-2 p-4 bg-n-solid-1 outline outline-n-weak rounded-xl outline-1"
 | 
			
		||||
      >
 | 
			
		||||
        <Icon
 | 
			
		||||
          icon="i-lucide-alert-circle"
 | 
			
		||||
          class="size-4 text-n-slate-10 flex-shrink-0 mt-0.5"
 | 
			
		||||
        />
 | 
			
		||||
        <p class="text-sm text-n-slate-11">
 | 
			
		||||
          <strong>{{ $t('MFA_SETTINGS.BACKUP.IMPORTANT') }}</strong>
 | 
			
		||||
          {{ $t('MFA_SETTINGS.BACKUP.IMPORTANT_NOTE') }}
 | 
			
		||||
        </p>
 | 
			
		||||
      </div>
 | 
			
		||||
 | 
			
		||||
      <!-- Backup Codes Grid -->
 | 
			
		||||
      <div
 | 
			
		||||
        class="bg-n-solid-1 rounded-xl outline-1 outline-n-weak outline flex flex-col gap-6 p-6"
 | 
			
		||||
      >
 | 
			
		||||
        <div class="grid grid-cols-2 xs:grid-cols-4 sm:grid-cols-5 gap-3">
 | 
			
		||||
          <span
 | 
			
		||||
            v-for="(code, index) in backupCodes"
 | 
			
		||||
            :key="index"
 | 
			
		||||
            class="px-1 py-2 font-mono text-base text-center text-n-slate-12"
 | 
			
		||||
          >
 | 
			
		||||
            {{ code }}
 | 
			
		||||
          </span>
 | 
			
		||||
        </div>
 | 
			
		||||
 | 
			
		||||
        <div class="flex items-center justify-center gap-3">
 | 
			
		||||
          <Button
 | 
			
		||||
            outline
 | 
			
		||||
            slate
 | 
			
		||||
            sm
 | 
			
		||||
            icon="i-lucide-download"
 | 
			
		||||
            :label="$t('MFA_SETTINGS.BACKUP.DOWNLOAD')"
 | 
			
		||||
            @click="downloadBackupCodes"
 | 
			
		||||
          />
 | 
			
		||||
          <Button
 | 
			
		||||
            outline
 | 
			
		||||
            slate
 | 
			
		||||
            sm
 | 
			
		||||
            icon="i-lucide-clipboard"
 | 
			
		||||
            :label="$t('MFA_SETTINGS.BACKUP.COPY_ALL')"
 | 
			
		||||
            @click="copyBackupCodes"
 | 
			
		||||
          />
 | 
			
		||||
        </div>
 | 
			
		||||
      </div>
 | 
			
		||||
 | 
			
		||||
      <!-- Confirmation -->
 | 
			
		||||
      <div class="space-y-4">
 | 
			
		||||
        <label class="flex items-start gap-3">
 | 
			
		||||
          <input
 | 
			
		||||
            v-model="backupCodesConfirmed"
 | 
			
		||||
            type="checkbox"
 | 
			
		||||
            class="mt-1 rounded border-n-slate-4 text-n-blue-9 focus:ring-n-blue-8"
 | 
			
		||||
          />
 | 
			
		||||
          <span class="text-sm text-n-slate-11">
 | 
			
		||||
            {{ $t('MFA_SETTINGS.BACKUP.CONFIRM') }}
 | 
			
		||||
          </span>
 | 
			
		||||
        </label>
 | 
			
		||||
 | 
			
		||||
        <Button
 | 
			
		||||
          :disabled="!backupCodesConfirmed"
 | 
			
		||||
          :label="$t('MFA_SETTINGS.BACKUP.COMPLETE_SETUP')"
 | 
			
		||||
          @click="completeMfaSetup"
 | 
			
		||||
        />
 | 
			
		||||
      </div>
 | 
			
		||||
    </div>
 | 
			
		||||
  </div>
 | 
			
		||||
  <template v-else />
 | 
			
		||||
</template>
 | 
			
		||||
@@ -0,0 +1,63 @@
 | 
			
		||||
<script setup>
 | 
			
		||||
import Button from 'dashboard/components-next/button/Button.vue';
 | 
			
		||||
import Icon from 'dashboard/components-next/icon/Icon.vue';
 | 
			
		||||
 | 
			
		||||
defineProps({
 | 
			
		||||
  mfaEnabled: {
 | 
			
		||||
    type: Boolean,
 | 
			
		||||
    required: true,
 | 
			
		||||
  },
 | 
			
		||||
  showSetup: {
 | 
			
		||||
    type: Boolean,
 | 
			
		||||
    default: false,
 | 
			
		||||
  },
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
const emit = defineEmits(['enableMfa']);
 | 
			
		||||
 | 
			
		||||
const startSetup = () => {
 | 
			
		||||
  emit('enableMfa');
 | 
			
		||||
};
 | 
			
		||||
</script>
 | 
			
		||||
 | 
			
		||||
<template>
 | 
			
		||||
  <div v-if="!mfaEnabled && !showSetup" class="space-y-6">
 | 
			
		||||
    <div
 | 
			
		||||
      class="bg-n-solid-1 rounded-lg p-6 outline outline-n-weak outline-1 text-center"
 | 
			
		||||
    >
 | 
			
		||||
      <Icon
 | 
			
		||||
        icon="i-lucide-lock-keyhole"
 | 
			
		||||
        class="size-8 text-n-slate-10 mx-auto mb-4 block"
 | 
			
		||||
      />
 | 
			
		||||
      <h3 class="text-lg font-medium text-n-slate-12 mb-2">
 | 
			
		||||
        {{ $t('MFA_SETTINGS.ENHANCE_SECURITY') }}
 | 
			
		||||
      </h3>
 | 
			
		||||
      <p class="text-sm text-n-slate-11 mb-6 max-w-md mx-auto">
 | 
			
		||||
        {{ $t('MFA_SETTINGS.ENHANCE_SECURITY_DESC') }}
 | 
			
		||||
      </p>
 | 
			
		||||
      <Button
 | 
			
		||||
        icon="i-lucide-settings"
 | 
			
		||||
        :label="$t('MFA_SETTINGS.ENABLE_BUTTON')"
 | 
			
		||||
        @click="startSetup"
 | 
			
		||||
      />
 | 
			
		||||
    </div>
 | 
			
		||||
  </div>
 | 
			
		||||
  <div v-else-if="mfaEnabled && !showSetup">
 | 
			
		||||
    <div
 | 
			
		||||
      class="bg-n-solid-1 rounded-xl outline-1 outline-n-weak outline p-4 flex-1 flex flex-col gap-2"
 | 
			
		||||
    >
 | 
			
		||||
      <div class="flex items-center gap-2">
 | 
			
		||||
        <Icon
 | 
			
		||||
          icon="i-lucide-lock-keyhole"
 | 
			
		||||
          class="size-4 flex-shrink-0 text-n-slate-11"
 | 
			
		||||
        />
 | 
			
		||||
        <h4 class="text-sm font-medium text-n-slate-12">
 | 
			
		||||
          {{ $t('MFA_SETTINGS.STATUS_ENABLED') }}
 | 
			
		||||
        </h4>
 | 
			
		||||
      </div>
 | 
			
		||||
      <p class="text-sm text-n-slate-11">
 | 
			
		||||
        {{ $t('MFA_SETTINGS.STATUS_ENABLED_DESC') }}
 | 
			
		||||
      </p>
 | 
			
		||||
    </div>
 | 
			
		||||
  </div>
 | 
			
		||||
</template>
 | 
			
		||||
@@ -1,7 +1,9 @@
 | 
			
		||||
import { frontendURL } from '../../../../helper/URLHelper';
 | 
			
		||||
import { parseBoolean } from '@chatwoot/utils';
 | 
			
		||||
 | 
			
		||||
import SettingsContent from './Wrapper.vue';
 | 
			
		||||
import Index from './Index.vue';
 | 
			
		||||
import MfaSettings from './MfaSettings.vue';
 | 
			
		||||
 | 
			
		||||
export default {
 | 
			
		||||
  routes: [
 | 
			
		||||
@@ -21,6 +23,23 @@ export default {
 | 
			
		||||
            permissions: ['administrator', 'agent', 'custom_role'],
 | 
			
		||||
          },
 | 
			
		||||
        },
 | 
			
		||||
        {
 | 
			
		||||
          path: 'mfa',
 | 
			
		||||
          name: 'profile_settings_mfa',
 | 
			
		||||
          component: MfaSettings,
 | 
			
		||||
          meta: {
 | 
			
		||||
            permissions: ['administrator', 'agent', 'custom_role'],
 | 
			
		||||
          },
 | 
			
		||||
          beforeEnter: (to, from, next) => {
 | 
			
		||||
            // Check if MFA is enabled globally
 | 
			
		||||
            if (!parseBoolean(window.chatwootConfig?.isMfaEnabled)) {
 | 
			
		||||
              // Redirect to profile settings if MFA is disabled
 | 
			
		||||
              next({ name: 'profile_settings_index' });
 | 
			
		||||
            } else {
 | 
			
		||||
              next();
 | 
			
		||||
            }
 | 
			
		||||
          },
 | 
			
		||||
        },
 | 
			
		||||
      ],
 | 
			
		||||
    },
 | 
			
		||||
  ],
 | 
			
		||||
 
 | 
			
		||||
@@ -17,3 +17,22 @@ export const copyTextToClipboard = async data => {
 | 
			
		||||
    throw new Error(`Unable to copy text to clipboard: ${error.message}`);
 | 
			
		||||
  }
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Handles OTP paste events by extracting numeric digits from clipboard data.
 | 
			
		||||
 *
 | 
			
		||||
 * @param {ClipboardEvent} event - The paste event from the clipboard
 | 
			
		||||
 * @param {number} maxLength - Maximum number of digits to extract (default: 6)
 | 
			
		||||
 * @returns {string|null} - Extracted numeric string or null if invalid
 | 
			
		||||
 */
 | 
			
		||||
export const handleOtpPaste = (event, maxLength = 6) => {
 | 
			
		||||
  if (!event?.clipboardData) return null;
 | 
			
		||||
 | 
			
		||||
  const pastedData = event.clipboardData
 | 
			
		||||
    .getData('text')
 | 
			
		||||
    .replace(/\D/g, '') // Remove all non-digit characters
 | 
			
		||||
    .slice(0, maxLength); // Limit to maxLength digits
 | 
			
		||||
 | 
			
		||||
  // Only return if we have the exact expected length
 | 
			
		||||
  return pastedData.length === maxLength ? pastedData : null;
 | 
			
		||||
};
 | 
			
		||||
 
 | 
			
		||||
@@ -1,4 +1,4 @@
 | 
			
		||||
import { copyTextToClipboard } from '../clipboard';
 | 
			
		||||
import { copyTextToClipboard, handleOtpPaste } from '../clipboard';
 | 
			
		||||
 | 
			
		||||
const mockWriteText = vi.fn();
 | 
			
		||||
Object.assign(navigator, {
 | 
			
		||||
@@ -172,3 +172,113 @@ describe('copyTextToClipboard', () => {
 | 
			
		||||
    });
 | 
			
		||||
  });
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
describe('handleOtpPaste', () => {
 | 
			
		||||
  // Helper function to create mock clipboard event
 | 
			
		||||
  const createMockPasteEvent = text => ({
 | 
			
		||||
    clipboardData: {
 | 
			
		||||
      getData: vi.fn().mockReturnValue(text),
 | 
			
		||||
    },
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  describe('valid OTP paste scenarios', () => {
 | 
			
		||||
    it('extracts 6-digit OTP from clean numeric string', () => {
 | 
			
		||||
      const event = createMockPasteEvent('123456');
 | 
			
		||||
      const result = handleOtpPaste(event);
 | 
			
		||||
 | 
			
		||||
      expect(result).toBe('123456');
 | 
			
		||||
      expect(event.clipboardData.getData).toHaveBeenCalledWith('text');
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    it('extracts 6-digit OTP from string with spaces', () => {
 | 
			
		||||
      const event = createMockPasteEvent('1 2 3 4 5 6');
 | 
			
		||||
      const result = handleOtpPaste(event);
 | 
			
		||||
 | 
			
		||||
      expect(result).toBe('123456');
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    it('extracts 6-digit OTP from string with dashes', () => {
 | 
			
		||||
      const event = createMockPasteEvent('123-456');
 | 
			
		||||
      const result = handleOtpPaste(event);
 | 
			
		||||
 | 
			
		||||
      expect(result).toBe('123456');
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    it('handles negative numbers by extracting digits only', () => {
 | 
			
		||||
      const event = createMockPasteEvent('-123456');
 | 
			
		||||
      const result = handleOtpPaste(event);
 | 
			
		||||
 | 
			
		||||
      expect(result).toBe('123456');
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    it('handles decimal numbers by extracting digits only', () => {
 | 
			
		||||
      const event = createMockPasteEvent('123.456');
 | 
			
		||||
      const result = handleOtpPaste(event);
 | 
			
		||||
 | 
			
		||||
      expect(result).toBe('123456');
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    it('extracts 6-digit OTP from mixed alphanumeric string', () => {
 | 
			
		||||
      const event = createMockPasteEvent('Your code is: 987654');
 | 
			
		||||
      const result = handleOtpPaste(event);
 | 
			
		||||
 | 
			
		||||
      expect(result).toBe('987654');
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    it('extracts first 6 digits when more than 6 digits present', () => {
 | 
			
		||||
      const event = createMockPasteEvent('12345678901234');
 | 
			
		||||
      const result = handleOtpPaste(event);
 | 
			
		||||
 | 
			
		||||
      expect(result).toBe('123456');
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    it('handles custom maxLength parameter', () => {
 | 
			
		||||
      const event = createMockPasteEvent('12345678');
 | 
			
		||||
      const result = handleOtpPaste(event, 8);
 | 
			
		||||
 | 
			
		||||
      expect(result).toBe('12345678');
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    it('extracts 4-digit OTP with custom maxLength', () => {
 | 
			
		||||
      const event = createMockPasteEvent('Your PIN: 9876');
 | 
			
		||||
      const result = handleOtpPaste(event, 4);
 | 
			
		||||
 | 
			
		||||
      expect(result).toBe('9876');
 | 
			
		||||
    });
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  describe('invalid OTP paste scenarios', () => {
 | 
			
		||||
    it('returns null for insufficient digits', () => {
 | 
			
		||||
      const event = createMockPasteEvent('12345');
 | 
			
		||||
      const result = handleOtpPaste(event);
 | 
			
		||||
 | 
			
		||||
      expect(result).toBeNull();
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    it('returns null for text with no digits', () => {
 | 
			
		||||
      const event = createMockPasteEvent('Hello World');
 | 
			
		||||
      const result = handleOtpPaste(event);
 | 
			
		||||
 | 
			
		||||
      expect(result).toBeNull();
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    it('returns null for empty string', () => {
 | 
			
		||||
      const event = createMockPasteEvent('');
 | 
			
		||||
      const result = handleOtpPaste(event);
 | 
			
		||||
 | 
			
		||||
      expect(result).toBeNull();
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    it('returns null when event is null', () => {
 | 
			
		||||
      const result = handleOtpPaste(null);
 | 
			
		||||
 | 
			
		||||
      expect(result).toBeNull();
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    it('returns null when event is undefined', () => {
 | 
			
		||||
      const result = handleOtpPaste(undefined);
 | 
			
		||||
 | 
			
		||||
      expect(result).toBeNull();
 | 
			
		||||
    });
 | 
			
		||||
  });
 | 
			
		||||
});
 | 
			
		||||
 
 | 
			
		||||
@@ -4,6 +4,8 @@ import {
 | 
			
		||||
  dynamicTime,
 | 
			
		||||
  dateFormat,
 | 
			
		||||
  shortTimestamp,
 | 
			
		||||
  getDayDifferenceFromNow,
 | 
			
		||||
  hasOneDayPassed,
 | 
			
		||||
} from 'shared/helpers/timeHelper';
 | 
			
		||||
 | 
			
		||||
beforeEach(() => {
 | 
			
		||||
@@ -90,3 +92,148 @@ describe('#shortTimestamp', () => {
 | 
			
		||||
    expect(shortTimestamp('4 years ago', true)).toEqual('4y ago');
 | 
			
		||||
  });
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
describe('#getDayDifferenceFromNow', () => {
 | 
			
		||||
  it('returns 0 for timestamps from today', () => {
 | 
			
		||||
    // Mock current date: May 5, 2023
 | 
			
		||||
    const now = new Date(Date.UTC(2023, 4, 5, 12, 0, 0)); // 12:00 PM
 | 
			
		||||
    const todayTimestamp = Math.floor(now.getTime() / 1000); // Same day
 | 
			
		||||
 | 
			
		||||
    expect(getDayDifferenceFromNow(now, todayTimestamp)).toEqual(0);
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  it('returns 2 for timestamps from 2 days ago', () => {
 | 
			
		||||
    const now = new Date(Date.UTC(2023, 4, 5, 12, 0, 0)); // May 5, 2023
 | 
			
		||||
    const twoDaysAgoTimestamp = Math.floor(
 | 
			
		||||
      new Date(Date.UTC(2023, 4, 3, 10, 0, 0)).getTime() / 1000
 | 
			
		||||
    ); // May 3, 2023
 | 
			
		||||
 | 
			
		||||
    expect(getDayDifferenceFromNow(now, twoDaysAgoTimestamp)).toEqual(2);
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  it('returns 7 for timestamps from a week ago', () => {
 | 
			
		||||
    const now = new Date(Date.UTC(2023, 4, 5, 12, 0, 0)); // May 5, 2023
 | 
			
		||||
    const weekAgoTimestamp = Math.floor(
 | 
			
		||||
      new Date(Date.UTC(2023, 3, 28, 8, 0, 0)).getTime() / 1000
 | 
			
		||||
    ); // April 28, 2023
 | 
			
		||||
 | 
			
		||||
    expect(getDayDifferenceFromNow(now, weekAgoTimestamp)).toEqual(7);
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  it('returns 30 for timestamps from a month ago', () => {
 | 
			
		||||
    const now = new Date(Date.UTC(2023, 4, 5, 12, 0, 0)); // May 5, 2023
 | 
			
		||||
    const monthAgoTimestamp = Math.floor(
 | 
			
		||||
      new Date(Date.UTC(2023, 3, 5, 12, 0, 0)).getTime() / 1000
 | 
			
		||||
    ); // April 5, 2023
 | 
			
		||||
 | 
			
		||||
    expect(getDayDifferenceFromNow(now, monthAgoTimestamp)).toEqual(30);
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  it('handles edge case with different times on same day', () => {
 | 
			
		||||
    const now = new Date(Date.UTC(2023, 4, 5, 23, 59, 59)); // May 5, 2023 11:59:59 PM
 | 
			
		||||
    const morningTimestamp = Math.floor(
 | 
			
		||||
      new Date(Date.UTC(2023, 4, 5, 0, 0, 1)).getTime() / 1000
 | 
			
		||||
    ); // May 5, 2023 12:00:01 AM
 | 
			
		||||
 | 
			
		||||
    expect(getDayDifferenceFromNow(now, morningTimestamp)).toEqual(0);
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  it('handles cross-month boundaries correctly', () => {
 | 
			
		||||
    const now = new Date(Date.UTC(2023, 4, 1, 12, 0, 0)); // May 1, 2023
 | 
			
		||||
    const lastMonthTimestamp = Math.floor(
 | 
			
		||||
      new Date(Date.UTC(2023, 3, 30, 12, 0, 0)).getTime() / 1000
 | 
			
		||||
    ); // April 30, 2023
 | 
			
		||||
 | 
			
		||||
    expect(getDayDifferenceFromNow(now, lastMonthTimestamp)).toEqual(1);
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  it('handles cross-year boundaries correctly', () => {
 | 
			
		||||
    const now = new Date(Date.UTC(2023, 0, 2, 12, 0, 0)); // January 2, 2023
 | 
			
		||||
    const lastYearTimestamp = Math.floor(
 | 
			
		||||
      new Date(Date.UTC(2022, 11, 31, 12, 0, 0)).getTime() / 1000
 | 
			
		||||
    ); // December 31, 2022
 | 
			
		||||
 | 
			
		||||
    expect(getDayDifferenceFromNow(now, lastYearTimestamp)).toEqual(2);
 | 
			
		||||
  });
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
describe('#hasOneDayPassed', () => {
 | 
			
		||||
  beforeEach(() => {
 | 
			
		||||
    // Mock current date: May 5, 2023, 12:00 PM UTC (1683288000)
 | 
			
		||||
    const mockDate = new Date(1683288000 * 1000);
 | 
			
		||||
    vi.setSystemTime(mockDate);
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  it('returns false for timestamps from today', () => {
 | 
			
		||||
    // Same day, different time - May 5, 2023 8:00 AM UTC
 | 
			
		||||
    const todayTimestamp = 1683273600;
 | 
			
		||||
 | 
			
		||||
    expect(hasOneDayPassed(todayTimestamp)).toBe(false);
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  it('returns false for timestamps from yesterday (less than 24 hours)', () => {
 | 
			
		||||
    // Yesterday but less than 24 hours ago - May 4, 2023 6:00 PM UTC (18 hours ago)
 | 
			
		||||
    const yesterdayTimestamp = 1683230400;
 | 
			
		||||
 | 
			
		||||
    expect(hasOneDayPassed(yesterdayTimestamp)).toBe(false);
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  it('returns true for timestamps from exactly 1 day ago', () => {
 | 
			
		||||
    // Exactly 24 hours ago - May 4, 2023 12:00 PM UTC
 | 
			
		||||
    const oneDayAgoTimestamp = 1683201600;
 | 
			
		||||
 | 
			
		||||
    expect(hasOneDayPassed(oneDayAgoTimestamp)).toBe(true);
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  it('returns true for timestamps from more than 1 day ago', () => {
 | 
			
		||||
    // 2 days ago - May 3, 2023 10:00 AM UTC
 | 
			
		||||
    const twoDaysAgoTimestamp = 1683108000;
 | 
			
		||||
 | 
			
		||||
    expect(hasOneDayPassed(twoDaysAgoTimestamp)).toBe(true);
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  it('returns true for timestamps from a week ago', () => {
 | 
			
		||||
    // 7 days ago - April 28, 2023 8:00 AM UTC
 | 
			
		||||
    const weekAgoTimestamp = 1682668800;
 | 
			
		||||
 | 
			
		||||
    expect(hasOneDayPassed(weekAgoTimestamp)).toBe(true);
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  it('returns true for null timestamp (defensive check)', () => {
 | 
			
		||||
    expect(hasOneDayPassed(null)).toBe(true);
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  it('returns true for undefined timestamp (defensive check)', () => {
 | 
			
		||||
    expect(hasOneDayPassed(undefined)).toBe(true);
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  it('returns true for zero timestamp (defensive check)', () => {
 | 
			
		||||
    expect(hasOneDayPassed(0)).toBe(true);
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  it('returns true for empty string timestamp (defensive check)', () => {
 | 
			
		||||
    expect(hasOneDayPassed('')).toBe(true);
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  it('handles cross-month boundaries correctly', () => {
 | 
			
		||||
    // Set current time to May 1, 2023 12:00 PM UTC (1682942400)
 | 
			
		||||
    const mayFirst = new Date(1682942400 * 1000);
 | 
			
		||||
    vi.setSystemTime(mayFirst);
 | 
			
		||||
 | 
			
		||||
    // April 29, 2023 12:00 PM UTC (1682769600) - 2 days ago, crossing month boundary
 | 
			
		||||
    const crossMonthTimestamp = 1682769600;
 | 
			
		||||
 | 
			
		||||
    expect(hasOneDayPassed(crossMonthTimestamp)).toBe(true);
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  it('handles cross-year boundaries correctly', () => {
 | 
			
		||||
    // Set current time to January 2, 2023 12:00 PM UTC (1672660800)
 | 
			
		||||
    const newYear = new Date(1672660800 * 1000);
 | 
			
		||||
    vi.setSystemTime(newYear);
 | 
			
		||||
 | 
			
		||||
    // December 30, 2022 12:00 PM UTC (1672401600) - 3 days ago, crossing year boundary
 | 
			
		||||
    const crossYearTimestamp = 1672401600;
 | 
			
		||||
 | 
			
		||||
    expect(hasOneDayPassed(crossYearTimestamp)).toBe(true);
 | 
			
		||||
  });
 | 
			
		||||
});
 | 
			
		||||
 
 | 
			
		||||
@@ -3,6 +3,7 @@ import {
 | 
			
		||||
  isSameYear,
 | 
			
		||||
  fromUnixTime,
 | 
			
		||||
  formatDistanceToNow,
 | 
			
		||||
  differenceInDays,
 | 
			
		||||
} from 'date-fns';
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
@@ -91,3 +92,25 @@ export const shortTimestamp = (time, withAgo = false) => {
 | 
			
		||||
    .replace(' years ago', `y${suffix}`);
 | 
			
		||||
  return convertToShortTime;
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Calculates the difference in days between now and a given timestamp.
 | 
			
		||||
 * @param {Date} now - Current date/time.
 | 
			
		||||
 * @param {number} timestampInSeconds - Unix timestamp in seconds.
 | 
			
		||||
 * @returns {number} Number of days difference.
 | 
			
		||||
 */
 | 
			
		||||
export const getDayDifferenceFromNow = (now, timestampInSeconds) => {
 | 
			
		||||
  const date = new Date(timestampInSeconds * 1000);
 | 
			
		||||
  return differenceInDays(now, date);
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Checks if more than 24 hours have passed since a given timestamp.
 | 
			
		||||
 * Useful for determining if retry/refresh actions should be disabled.
 | 
			
		||||
 * @param {number} timestamp - Unix timestamp.
 | 
			
		||||
 * @returns {boolean} True if more than 24 hours have passed.
 | 
			
		||||
 */
 | 
			
		||||
export const hasOneDayPassed = timestamp => {
 | 
			
		||||
  if (!timestamp) return true; // Defensive check
 | 
			
		||||
  return getDayDifferenceFromNow(new Date(), timestamp) >= 1;
 | 
			
		||||
};
 | 
			
		||||
 
 | 
			
		||||
@@ -15,8 +15,10 @@ export default {
 | 
			
		||||
    setColorTheme() {
 | 
			
		||||
      if (window.matchMedia('(prefers-color-scheme: dark)').matches) {
 | 
			
		||||
        this.theme = 'dark';
 | 
			
		||||
        document.documentElement.classList.add('dark');
 | 
			
		||||
      } else {
 | 
			
		||||
        this.theme = 'light ';
 | 
			
		||||
        this.theme = 'light';
 | 
			
		||||
        document.documentElement.classList.remove('dark');
 | 
			
		||||
      }
 | 
			
		||||
    },
 | 
			
		||||
    listenToThemeChanges() {
 | 
			
		||||
@@ -25,8 +27,10 @@ export default {
 | 
			
		||||
      mql.onchange = e => {
 | 
			
		||||
        if (e.matches) {
 | 
			
		||||
          this.theme = 'dark';
 | 
			
		||||
          document.documentElement.classList.add('dark');
 | 
			
		||||
        } else {
 | 
			
		||||
          this.theme = 'light';
 | 
			
		||||
          document.documentElement.classList.remove('dark');
 | 
			
		||||
        }
 | 
			
		||||
      };
 | 
			
		||||
    },
 | 
			
		||||
 
 | 
			
		||||
@@ -13,6 +13,16 @@ export const login = async ({
 | 
			
		||||
}) => {
 | 
			
		||||
  try {
 | 
			
		||||
    const response = await wootAPI.post('auth/sign_in', credentials);
 | 
			
		||||
 | 
			
		||||
    // Check if MFA is required
 | 
			
		||||
    if (response.status === 206 && response.data.mfa_required) {
 | 
			
		||||
      // Return MFA data instead of throwing error
 | 
			
		||||
      return {
 | 
			
		||||
        mfaRequired: true,
 | 
			
		||||
        mfaToken: response.data.mfa_token,
 | 
			
		||||
      };
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    setAuthCredentials(response);
 | 
			
		||||
    clearLocalStorageOnLogout();
 | 
			
		||||
    window.location = getLoginRedirectURL({
 | 
			
		||||
@@ -20,8 +30,17 @@ export const login = async ({
 | 
			
		||||
      ssoConversationId,
 | 
			
		||||
      user: response.data.data,
 | 
			
		||||
    });
 | 
			
		||||
    return null;
 | 
			
		||||
  } catch (error) {
 | 
			
		||||
    // Check if it's an MFA required response
 | 
			
		||||
    if (error.response?.status === 206 && error.response?.data?.mfa_required) {
 | 
			
		||||
      return {
 | 
			
		||||
        mfaRequired: true,
 | 
			
		||||
        mfaToken: error.response.data.mfa_token,
 | 
			
		||||
      };
 | 
			
		||||
    }
 | 
			
		||||
    throwErrorMessage(error);
 | 
			
		||||
    return null;
 | 
			
		||||
  }
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -15,6 +15,7 @@ import FormInput from '../../components/Form/Input.vue';
 | 
			
		||||
import GoogleOAuthButton from '../../components/GoogleOauth/Button.vue';
 | 
			
		||||
import Spinner from 'shared/components/Spinner.vue';
 | 
			
		||||
import NextButton from 'dashboard/components-next/button/Button.vue';
 | 
			
		||||
import MfaVerification from 'dashboard/components/auth/MfaVerification.vue';
 | 
			
		||||
 | 
			
		||||
const ERROR_MESSAGES = {
 | 
			
		||||
  'no-account-found': 'LOGIN.OAUTH.NO_ACCOUNT_FOUND',
 | 
			
		||||
@@ -29,6 +30,7 @@ export default {
 | 
			
		||||
    GoogleOAuthButton,
 | 
			
		||||
    Spinner,
 | 
			
		||||
    NextButton,
 | 
			
		||||
    MfaVerification,
 | 
			
		||||
  },
 | 
			
		||||
  props: {
 | 
			
		||||
    ssoAuthToken: { type: String, default: '' },
 | 
			
		||||
@@ -58,6 +60,8 @@ export default {
 | 
			
		||||
        hasErrored: false,
 | 
			
		||||
      },
 | 
			
		||||
      error: '',
 | 
			
		||||
      mfaRequired: false,
 | 
			
		||||
      mfaToken: null,
 | 
			
		||||
    };
 | 
			
		||||
  },
 | 
			
		||||
  validations() {
 | 
			
		||||
@@ -87,8 +91,10 @@ export default {
 | 
			
		||||
      this.submitLogin();
 | 
			
		||||
    }
 | 
			
		||||
    if (this.authError) {
 | 
			
		||||
      const message = ERROR_MESSAGES[this.authError] ?? 'LOGIN.API.UNAUTH';
 | 
			
		||||
      useAlert(this.$t(message));
 | 
			
		||||
      const messageKey = ERROR_MESSAGES[this.authError] ?? 'LOGIN.API.UNAUTH';
 | 
			
		||||
      // Use a method to get the translated text to avoid dynamic key warning
 | 
			
		||||
      const translatedMessage = this.getTranslatedMessage(messageKey);
 | 
			
		||||
      useAlert(translatedMessage);
 | 
			
		||||
      // wait for idle state
 | 
			
		||||
      this.requestIdleCallbackPolyfill(() => {
 | 
			
		||||
        // Remove the error query param from the url
 | 
			
		||||
@@ -98,6 +104,18 @@ export default {
 | 
			
		||||
    }
 | 
			
		||||
  },
 | 
			
		||||
  methods: {
 | 
			
		||||
    getTranslatedMessage(key) {
 | 
			
		||||
      // Avoid dynamic key warning by handling each case explicitly
 | 
			
		||||
      switch (key) {
 | 
			
		||||
        case 'LOGIN.OAUTH.NO_ACCOUNT_FOUND':
 | 
			
		||||
          return this.$t('LOGIN.OAUTH.NO_ACCOUNT_FOUND');
 | 
			
		||||
        case 'LOGIN.OAUTH.BUSINESS_ACCOUNTS_ONLY':
 | 
			
		||||
          return this.$t('LOGIN.OAUTH.BUSINESS_ACCOUNTS_ONLY');
 | 
			
		||||
        case 'LOGIN.API.UNAUTH':
 | 
			
		||||
        default:
 | 
			
		||||
          return this.$t('LOGIN.API.UNAUTH');
 | 
			
		||||
      }
 | 
			
		||||
    },
 | 
			
		||||
    // TODO: Remove this when Safari gets wider support
 | 
			
		||||
    // Ref: https://caniuse.com/requestidlecallback
 | 
			
		||||
    //
 | 
			
		||||
@@ -140,7 +158,15 @@ export default {
 | 
			
		||||
      };
 | 
			
		||||
 | 
			
		||||
      login(credentials)
 | 
			
		||||
        .then(() => {
 | 
			
		||||
        .then(result => {
 | 
			
		||||
          // Check if MFA is required
 | 
			
		||||
          if (result?.mfaRequired) {
 | 
			
		||||
            this.loginApi.showLoading = false;
 | 
			
		||||
            this.mfaRequired = true;
 | 
			
		||||
            this.mfaToken = result.mfaToken;
 | 
			
		||||
            return;
 | 
			
		||||
          }
 | 
			
		||||
 | 
			
		||||
          this.handleImpersonation();
 | 
			
		||||
          this.showAlertMessage(this.$t('LOGIN.API.SUCCESS_MESSAGE'));
 | 
			
		||||
        })
 | 
			
		||||
@@ -163,6 +189,17 @@ export default {
 | 
			
		||||
 | 
			
		||||
      this.submitLogin();
 | 
			
		||||
    },
 | 
			
		||||
    handleMfaVerified() {
 | 
			
		||||
      // MFA verification successful, continue with login
 | 
			
		||||
      this.handleImpersonation();
 | 
			
		||||
      window.location = '/app';
 | 
			
		||||
    },
 | 
			
		||||
    handleMfaCancel() {
 | 
			
		||||
      // User cancelled MFA, reset state
 | 
			
		||||
      this.mfaRequired = false;
 | 
			
		||||
      this.mfaToken = null;
 | 
			
		||||
      this.credentials.password = '';
 | 
			
		||||
    },
 | 
			
		||||
  },
 | 
			
		||||
};
 | 
			
		||||
</script>
 | 
			
		||||
@@ -193,7 +230,19 @@ export default {
 | 
			
		||||
        </router-link>
 | 
			
		||||
      </p>
 | 
			
		||||
    </section>
 | 
			
		||||
 | 
			
		||||
    <!-- MFA Verification Section -->
 | 
			
		||||
    <section v-if="mfaRequired" class="mt-11">
 | 
			
		||||
      <MfaVerification
 | 
			
		||||
        :mfa-token="mfaToken"
 | 
			
		||||
        @verified="handleMfaVerified"
 | 
			
		||||
        @cancel="handleMfaCancel"
 | 
			
		||||
      />
 | 
			
		||||
    </section>
 | 
			
		||||
 | 
			
		||||
    <!-- Regular Login Section -->
 | 
			
		||||
    <section
 | 
			
		||||
      v-else
 | 
			
		||||
      class="bg-white shadow sm:mx-auto mt-11 sm:w-full sm:max-w-lg dark:bg-n-solid-2 p-11 sm:shadow-lg sm:rounded-lg"
 | 
			
		||||
      :class="{
 | 
			
		||||
        'mb-8 mt-15': !showGoogleOAuth,
 | 
			
		||||
 
 | 
			
		||||
@@ -36,7 +36,7 @@ class AutomationRule < ApplicationRecord
 | 
			
		||||
 | 
			
		||||
  def conditions_attributes
 | 
			
		||||
    %w[content email country_code status message_type browser_language assignee_id team_id referer city company inbox_id
 | 
			
		||||
       mail_subject phone_number priority conversation_language]
 | 
			
		||||
       mail_subject phone_number priority conversation_language labels]
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  def actions_attributes
 | 
			
		||||
 
 | 
			
		||||
@@ -3,6 +3,7 @@
 | 
			
		||||
# Table name: channel_web_widgets
 | 
			
		||||
#
 | 
			
		||||
#  id                    :integer          not null, primary key
 | 
			
		||||
#  allowed_domains       :text             default("")
 | 
			
		||||
#  continuity_via_email  :boolean          default(TRUE), not null
 | 
			
		||||
#  feature_flags         :integer          default(7), not null
 | 
			
		||||
#  hmac_mandatory        :boolean          default(FALSE)
 | 
			
		||||
@@ -31,7 +32,7 @@ class Channel::WebWidget < ApplicationRecord
 | 
			
		||||
 | 
			
		||||
  self.table_name = 'channel_web_widgets'
 | 
			
		||||
  EDITABLE_ATTRS = [:website_url, :widget_color, :welcome_title, :welcome_tagline, :reply_time, :pre_chat_form_enabled,
 | 
			
		||||
                    :continuity_via_email, :hmac_mandatory,
 | 
			
		||||
                    :continuity_via_email, :hmac_mandatory, :allowed_domains,
 | 
			
		||||
                    { pre_chat_form_options: [:pre_chat_message, :require_email,
 | 
			
		||||
                                              { pre_chat_fields:
 | 
			
		||||
                                                [:field_type, :label, :placeholder, :name, :enabled, :type, :enabled, :required,
 | 
			
		||||
 
 | 
			
		||||
@@ -10,6 +10,8 @@ module Labelable
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  def add_labels(new_labels = nil)
 | 
			
		||||
    return if new_labels.blank?
 | 
			
		||||
 | 
			
		||||
    new_labels = Array(new_labels) # Make sure new_labels is an array
 | 
			
		||||
    combined_labels = labels + new_labels
 | 
			
		||||
    update!(label_list: combined_labels)
 | 
			
		||||
 
 | 
			
		||||
@@ -297,8 +297,6 @@ class Conversation < ApplicationRecord
 | 
			
		||||
    previous_labels, current_labels = previous_changes[:label_list]
 | 
			
		||||
    return unless (previous_labels.is_a? Array) && (current_labels.is_a? Array)
 | 
			
		||||
 | 
			
		||||
    dispatcher_dispatch(CONVERSATION_UPDATED, previous_changes)
 | 
			
		||||
 | 
			
		||||
    create_label_added(user_name, current_labels - previous_labels)
 | 
			
		||||
    create_label_removed(user_name, previous_labels - current_labels)
 | 
			
		||||
  end
 | 
			
		||||
 
 | 
			
		||||
@@ -7,6 +7,7 @@
 | 
			
		||||
#  confirmation_sent_at   :datetime
 | 
			
		||||
#  confirmation_token     :string
 | 
			
		||||
#  confirmed_at           :datetime
 | 
			
		||||
#  consumed_timestep      :integer
 | 
			
		||||
#  current_sign_in_at     :datetime
 | 
			
		||||
#  current_sign_in_ip     :string
 | 
			
		||||
#  custom_attributes      :jsonb
 | 
			
		||||
@@ -17,6 +18,9 @@
 | 
			
		||||
#  last_sign_in_ip        :string
 | 
			
		||||
#  message_signature      :text
 | 
			
		||||
#  name                   :string           not null
 | 
			
		||||
#  otp_backup_codes       :text
 | 
			
		||||
#  otp_required_for_login :boolean          default(FALSE), not null
 | 
			
		||||
#  otp_secret             :string
 | 
			
		||||
#  provider               :string           default("email"), not null
 | 
			
		||||
#  pubsub_token           :string
 | 
			
		||||
#  remember_created_at    :datetime
 | 
			
		||||
@@ -34,6 +38,8 @@
 | 
			
		||||
# Indexes
 | 
			
		||||
#
 | 
			
		||||
#  index_users_on_email                   (email)
 | 
			
		||||
#  index_users_on_otp_required_for_login  (otp_required_for_login)
 | 
			
		||||
#  index_users_on_otp_secret              (otp_secret) UNIQUE
 | 
			
		||||
#  index_users_on_pubsub_token            (pubsub_token) UNIQUE
 | 
			
		||||
#  index_users_on_reset_password_token    (reset_password_token) UNIQUE
 | 
			
		||||
#  index_users_on_uid_and_provider        (uid,provider) UNIQUE
 | 
			
		||||
 
 | 
			
		||||
@@ -7,6 +7,7 @@
 | 
			
		||||
#  confirmation_sent_at   :datetime
 | 
			
		||||
#  confirmation_token     :string
 | 
			
		||||
#  confirmed_at           :datetime
 | 
			
		||||
#  consumed_timestep      :integer
 | 
			
		||||
#  current_sign_in_at     :datetime
 | 
			
		||||
#  current_sign_in_ip     :string
 | 
			
		||||
#  custom_attributes      :jsonb
 | 
			
		||||
@@ -17,6 +18,9 @@
 | 
			
		||||
#  last_sign_in_ip        :string
 | 
			
		||||
#  message_signature      :text
 | 
			
		||||
#  name                   :string           not null
 | 
			
		||||
#  otp_backup_codes       :text
 | 
			
		||||
#  otp_required_for_login :boolean          default(FALSE), not null
 | 
			
		||||
#  otp_secret             :string
 | 
			
		||||
#  provider               :string           default("email"), not null
 | 
			
		||||
#  pubsub_token           :string
 | 
			
		||||
#  remember_created_at    :datetime
 | 
			
		||||
@@ -34,6 +38,8 @@
 | 
			
		||||
# Indexes
 | 
			
		||||
#
 | 
			
		||||
#  index_users_on_email                   (email)
 | 
			
		||||
#  index_users_on_otp_required_for_login  (otp_required_for_login)
 | 
			
		||||
#  index_users_on_otp_secret              (otp_secret) UNIQUE
 | 
			
		||||
#  index_users_on_pubsub_token            (pubsub_token) UNIQUE
 | 
			
		||||
#  index_users_on_reset_password_token    (reset_password_token) UNIQUE
 | 
			
		||||
#  index_users_on_uid_and_provider        (uid,provider) UNIQUE
 | 
			
		||||
@@ -58,6 +64,7 @@ class User < ApplicationRecord
 | 
			
		||||
         :validatable,
 | 
			
		||||
         :confirmable,
 | 
			
		||||
         :password_has_required_content,
 | 
			
		||||
         :two_factor_authenticatable,
 | 
			
		||||
         :omniauthable, omniauth_providers: [:google_oauth2, :saml]
 | 
			
		||||
 | 
			
		||||
  # TODO: remove in a future version once online status is moved to account users
 | 
			
		||||
@@ -70,6 +77,12 @@ class User < ApplicationRecord
 | 
			
		||||
 | 
			
		||||
  validates :email, presence: true
 | 
			
		||||
 | 
			
		||||
  serialize :otp_backup_codes, type: Array
 | 
			
		||||
 | 
			
		||||
  # Encrypt sensitive MFA fields
 | 
			
		||||
  encrypts :otp_secret, deterministic: true
 | 
			
		||||
  encrypts :otp_backup_codes
 | 
			
		||||
 | 
			
		||||
  has_many :account_users, dependent: :destroy_async
 | 
			
		||||
  has_many :accounts, through: :account_users
 | 
			
		||||
  accepts_nested_attributes_for :account_users
 | 
			
		||||
@@ -156,6 +169,27 @@ class User < ApplicationRecord
 | 
			
		||||
    find_by(email: email&.downcase)
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  # 2FA/MFA Methods
 | 
			
		||||
  # Delegated to Mfa::ManagementService for better separation of concerns
 | 
			
		||||
  def mfa_service
 | 
			
		||||
    @mfa_service ||= Mfa::ManagementService.new(user: self)
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  delegate :two_factor_provisioning_uri, to: :mfa_service
 | 
			
		||||
  delegate :backup_codes_generated?, to: :mfa_service
 | 
			
		||||
  delegate :enable_two_factor!, to: :mfa_service
 | 
			
		||||
  delegate :disable_two_factor!, to: :mfa_service
 | 
			
		||||
  delegate :generate_backup_codes!, to: :mfa_service
 | 
			
		||||
  delegate :validate_backup_code!, to: :mfa_service
 | 
			
		||||
 | 
			
		||||
  def mfa_enabled?
 | 
			
		||||
    otp_required_for_login?
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  def mfa_feature_available?
 | 
			
		||||
    Chatwoot.mfa_enabled?
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  private
 | 
			
		||||
 | 
			
		||||
  def remove_macros
 | 
			
		||||
 
 | 
			
		||||
@@ -151,13 +151,36 @@ class AutomationRules::ConditionsFilterService < FilterService
 | 
			
		||||
      " #{table_name}.additional_attributes ->> '#{attribute_key}' #{filter_operator_value} #{query_operator} "
 | 
			
		||||
    when 'standard'
 | 
			
		||||
      if attribute_key == 'labels'
 | 
			
		||||
        " tags.id #{filter_operator_value} #{query_operator} "
 | 
			
		||||
        build_label_query_string(query_hash, current_index, query_operator)
 | 
			
		||||
      else
 | 
			
		||||
        " #{table_name}.#{attribute_key} #{filter_operator_value} #{query_operator} "
 | 
			
		||||
      end
 | 
			
		||||
    end
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  def build_label_query_string(query_hash, current_index, query_operator)
 | 
			
		||||
    case query_hash['filter_operator']
 | 
			
		||||
    when 'equal_to'
 | 
			
		||||
      return " 1=0 #{query_operator} " if query_hash['values'].blank?
 | 
			
		||||
 | 
			
		||||
      value_placeholder = "value_#{current_index}"
 | 
			
		||||
      @filter_values[value_placeholder] = query_hash['values'].first
 | 
			
		||||
      " tags.name = :#{value_placeholder} #{query_operator} "
 | 
			
		||||
    when 'not_equal_to'
 | 
			
		||||
      return " 1=0 #{query_operator} " if query_hash['values'].blank?
 | 
			
		||||
 | 
			
		||||
      value_placeholder = "value_#{current_index}"
 | 
			
		||||
      @filter_values[value_placeholder] = query_hash['values'].first
 | 
			
		||||
      " tags.name != :#{value_placeholder} #{query_operator} "
 | 
			
		||||
    when 'is_present'
 | 
			
		||||
      " tags.id IS NOT NULL #{query_operator} "
 | 
			
		||||
    when 'is_not_present'
 | 
			
		||||
      " tags.id IS NULL #{query_operator} "
 | 
			
		||||
    else
 | 
			
		||||
      " tags.id #{filter_operation(query_hash, current_index)} #{query_operator} "
 | 
			
		||||
    end
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  private
 | 
			
		||||
 | 
			
		||||
  def base_relation
 | 
			
		||||
@@ -166,7 +189,21 @@ class AutomationRules::ConditionsFilterService < FilterService
 | 
			
		||||
    ).joins(
 | 
			
		||||
      'LEFT OUTER JOIN messages on messages.conversation_id = conversations.id'
 | 
			
		||||
    )
 | 
			
		||||
 | 
			
		||||
    # Only add label joins when label conditions exist
 | 
			
		||||
    if label_conditions?
 | 
			
		||||
      records = records.joins(
 | 
			
		||||
        'LEFT OUTER JOIN taggings ON taggings.taggable_id = conversations.id AND taggings.taggable_type = \'Conversation\''
 | 
			
		||||
      ).joins(
 | 
			
		||||
        'LEFT OUTER JOIN tags ON taggings.tag_id = tags.id'
 | 
			
		||||
      )
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
    records = records.where(messages: { id: @options[:message].id }) if @options[:message].present?
 | 
			
		||||
    records
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  def label_conditions?
 | 
			
		||||
    @rule.conditions.any? { |condition| condition['attribute_key'] == 'labels' }
 | 
			
		||||
  end
 | 
			
		||||
end
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										27
									
								
								app/services/base_token_service.rb
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										27
									
								
								app/services/base_token_service.rb
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,27 @@
 | 
			
		||||
class BaseTokenService
 | 
			
		||||
  pattr_initialize [:payload, :token]
 | 
			
		||||
 | 
			
		||||
  def generate_token
 | 
			
		||||
    JWT.encode(token_payload, secret_key, algorithm)
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  def decode_token
 | 
			
		||||
    JWT.decode(token, secret_key, true, algorithm: algorithm).first.symbolize_keys
 | 
			
		||||
  rescue JWT::ExpiredSignature, JWT::DecodeError
 | 
			
		||||
    {}
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  private
 | 
			
		||||
 | 
			
		||||
  def token_payload
 | 
			
		||||
    payload || {}
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  def secret_key
 | 
			
		||||
    Rails.application.secret_key_base
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  def algorithm
 | 
			
		||||
    'HS256'
 | 
			
		||||
  end
 | 
			
		||||
end
 | 
			
		||||
							
								
								
									
										23
									
								
								app/services/mfa/authentication_service.rb
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										23
									
								
								app/services/mfa/authentication_service.rb
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,23 @@
 | 
			
		||||
class Mfa::AuthenticationService
 | 
			
		||||
  pattr_initialize [:user!, :otp_code, :backup_code]
 | 
			
		||||
 | 
			
		||||
  def authenticate
 | 
			
		||||
    return false unless user
 | 
			
		||||
 | 
			
		||||
    return authenticate_with_otp if otp_code.present?
 | 
			
		||||
    return authenticate_with_backup_code if backup_code.present?
 | 
			
		||||
 | 
			
		||||
    false
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  private
 | 
			
		||||
 | 
			
		||||
  def authenticate_with_otp
 | 
			
		||||
    user.validate_and_consume_otp!(otp_code)
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  def authenticate_with_backup_code
 | 
			
		||||
    mfa_service = Mfa::ManagementService.new(user: user)
 | 
			
		||||
    mfa_service.validate_backup_code!(backup_code)
 | 
			
		||||
  end
 | 
			
		||||
end
 | 
			
		||||
							
								
								
									
										88
									
								
								app/services/mfa/management_service.rb
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										88
									
								
								app/services/mfa/management_service.rb
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,88 @@
 | 
			
		||||
class Mfa::ManagementService
 | 
			
		||||
  pattr_initialize [:user!]
 | 
			
		||||
 | 
			
		||||
  def enable_two_factor!
 | 
			
		||||
    user.otp_secret = User.generate_otp_secret
 | 
			
		||||
    user.save!
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  def disable_two_factor!
 | 
			
		||||
    user.otp_secret = nil
 | 
			
		||||
    user.otp_required_for_login = false
 | 
			
		||||
    user.otp_backup_codes = nil
 | 
			
		||||
    user.save!
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  def verify_and_activate!
 | 
			
		||||
    ActiveRecord::Base.transaction do
 | 
			
		||||
      user.update!(otp_required_for_login: true)
 | 
			
		||||
      backup_codes_generated? ? nil : generate_backup_codes!
 | 
			
		||||
    end
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  def two_factor_provisioning_uri
 | 
			
		||||
    return nil if user.otp_secret.blank?
 | 
			
		||||
 | 
			
		||||
    issuer = 'Chatwoot'
 | 
			
		||||
    label = user.email
 | 
			
		||||
    user.otp_provisioning_uri(label, issuer: issuer)
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  def generate_backup_codes!
 | 
			
		||||
    codes = Array.new(10) { SecureRandom.hex(4).upcase }
 | 
			
		||||
    user.otp_backup_codes = codes
 | 
			
		||||
    user.save!
 | 
			
		||||
    codes
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  def validate_backup_code!(code)
 | 
			
		||||
    return false unless valid_backup_code_input?(code)
 | 
			
		||||
 | 
			
		||||
    codes = user.otp_backup_codes
 | 
			
		||||
    found_index = find_matching_code_index(codes, code)
 | 
			
		||||
 | 
			
		||||
    return false if found_index.nil?
 | 
			
		||||
 | 
			
		||||
    mark_code_as_used(codes, found_index)
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  private
 | 
			
		||||
 | 
			
		||||
  def valid_backup_code_input?(code)
 | 
			
		||||
    user.otp_backup_codes.present? && code.present?
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  def find_matching_code_index(codes, code)
 | 
			
		||||
    found_index = nil
 | 
			
		||||
 | 
			
		||||
    # Constant-time comparison to prevent timing attacks
 | 
			
		||||
    codes.each_with_index do |stored_code, idx|
 | 
			
		||||
      is_match = ActiveSupport::SecurityUtils.secure_compare(stored_code, code)
 | 
			
		||||
      is_unused = stored_code != 'XXXXXXXX'
 | 
			
		||||
      found_index = idx if is_match && is_unused
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
    found_index
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  def mark_code_as_used(codes, index)
 | 
			
		||||
    codes[index] = 'XXXXXXXX'
 | 
			
		||||
    user.otp_backup_codes = codes
 | 
			
		||||
    user.save!
 | 
			
		||||
    true
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  public
 | 
			
		||||
 | 
			
		||||
  def backup_codes_generated?
 | 
			
		||||
    user.otp_backup_codes.present?
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  def mfa_enabled?
 | 
			
		||||
    user.otp_required_for_login?
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  def two_factor_setup_pending?
 | 
			
		||||
    user.otp_secret.present? && !user.otp_required_for_login?
 | 
			
		||||
  end
 | 
			
		||||
end
 | 
			
		||||
							
								
								
									
										28
									
								
								app/services/mfa/token_service.rb
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										28
									
								
								app/services/mfa/token_service.rb
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,28 @@
 | 
			
		||||
class Mfa::TokenService < BaseTokenService
 | 
			
		||||
  pattr_initialize [:user, :token]
 | 
			
		||||
 | 
			
		||||
  MFA_TOKEN_EXPIRY = 5.minutes
 | 
			
		||||
 | 
			
		||||
  def generate_token
 | 
			
		||||
    @payload = build_payload
 | 
			
		||||
    super
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  def verify_token
 | 
			
		||||
    decoded = decode_token
 | 
			
		||||
    return nil if decoded.blank?
 | 
			
		||||
 | 
			
		||||
    User.find(decoded[:user_id])
 | 
			
		||||
  rescue ActiveRecord::RecordNotFound
 | 
			
		||||
    nil
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  private
 | 
			
		||||
 | 
			
		||||
  def build_payload
 | 
			
		||||
    {
 | 
			
		||||
      user_id: user.id,
 | 
			
		||||
      exp: MFA_TOKEN_EXPIRY.from_now.to_i
 | 
			
		||||
    }
 | 
			
		||||
  end
 | 
			
		||||
end
 | 
			
		||||
@@ -30,12 +30,12 @@ class Whatsapp::PopulateTemplateParametersService
 | 
			
		||||
    end
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  def build_media_parameter(url, media_type)
 | 
			
		||||
  def build_media_parameter(url, media_type, media_name = nil)
 | 
			
		||||
    return nil if url.blank?
 | 
			
		||||
 | 
			
		||||
    sanitized_url = sanitize_parameter(url)
 | 
			
		||||
    validate_url(sanitized_url)
 | 
			
		||||
    build_media_type_parameter(sanitized_url, media_type.downcase)
 | 
			
		||||
    build_media_type_parameter(sanitized_url, media_type.downcase, media_name)
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  def build_named_parameter(parameter_name, value)
 | 
			
		||||
@@ -89,14 +89,14 @@ class Whatsapp::PopulateTemplateParametersService
 | 
			
		||||
    }
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  def build_media_type_parameter(sanitized_url, media_type)
 | 
			
		||||
  def build_media_type_parameter(sanitized_url, media_type, media_name = nil)
 | 
			
		||||
    case media_type
 | 
			
		||||
    when 'image'
 | 
			
		||||
      build_image_parameter(sanitized_url)
 | 
			
		||||
    when 'video'
 | 
			
		||||
      build_video_parameter(sanitized_url)
 | 
			
		||||
    when 'document'
 | 
			
		||||
      build_document_parameter(sanitized_url)
 | 
			
		||||
      build_document_parameter(sanitized_url, media_name)
 | 
			
		||||
    else
 | 
			
		||||
      raise ArgumentError, "Unsupported media type: #{media_type}"
 | 
			
		||||
    end
 | 
			
		||||
@@ -110,8 +110,11 @@ class Whatsapp::PopulateTemplateParametersService
 | 
			
		||||
    { type: 'video', video: { link: url } }
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  def build_document_parameter(url)
 | 
			
		||||
    { type: 'document', document: { link: url } }
 | 
			
		||||
  def build_document_parameter(url, media_name = nil)
 | 
			
		||||
    document_params = { link: url }
 | 
			
		||||
    document_params[:filename] = media_name if media_name.present?
 | 
			
		||||
 | 
			
		||||
    { type: 'document', document: document_params }
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  def rich_formatting?(text)
 | 
			
		||||
 
 | 
			
		||||
@@ -141,7 +141,11 @@ class Whatsapp::Providers::WhatsappCloudService < Whatsapp::Providers::BaseServi
 | 
			
		||||
    # {
 | 
			
		||||
    #   processed_params: {
 | 
			
		||||
    #     body: { '1': 'John', '2': '123 Main St' },
 | 
			
		||||
    #     header: { media_url: 'https://...', media_type: 'image' },
 | 
			
		||||
    #     header: {
 | 
			
		||||
    #       media_url: 'https://...',
 | 
			
		||||
    #       media_type: 'image',
 | 
			
		||||
    #       media_name: 'filename.pdf' # Optional, for document templates only
 | 
			
		||||
    #     },
 | 
			
		||||
    #     buttons: [{ type: 'url', parameter: 'otp123456' }]
 | 
			
		||||
    #   }
 | 
			
		||||
    # }
 | 
			
		||||
 
 | 
			
		||||
@@ -60,9 +60,10 @@ class Whatsapp::TemplateProcessorService
 | 
			
		||||
      next if value.blank?
 | 
			
		||||
 | 
			
		||||
      if media_url_with_type?(key, header_data)
 | 
			
		||||
        media_param = parameter_builder.build_media_parameter(value, header_data['media_type'])
 | 
			
		||||
        media_name = header_data['media_name']
 | 
			
		||||
        media_param = parameter_builder.build_media_parameter(value, header_data['media_type'], media_name)
 | 
			
		||||
        header_params << media_param if media_param
 | 
			
		||||
      elsif key != 'media_type'
 | 
			
		||||
      elsif key != 'media_type' && key != 'media_name'
 | 
			
		||||
        header_params << parameter_builder.build_parameter(value)
 | 
			
		||||
      end
 | 
			
		||||
    end
 | 
			
		||||
 
 | 
			
		||||
@@ -1,21 +1,27 @@
 | 
			
		||||
class Widget::TokenService
 | 
			
		||||
  pattr_initialize [:payload, :token]
 | 
			
		||||
class Widget::TokenService < BaseTokenService
 | 
			
		||||
  DEFAULT_EXPIRY_DAYS = 180
 | 
			
		||||
 | 
			
		||||
  def generate_token
 | 
			
		||||
    JWT.encode payload, secret_key, 'HS256'
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  def decode_token
 | 
			
		||||
    JWT.decode(
 | 
			
		||||
      token, secret_key, true, algorithm: 'HS256'
 | 
			
		||||
    ).first.symbolize_keys
 | 
			
		||||
  rescue StandardError
 | 
			
		||||
    {}
 | 
			
		||||
    JWT.encode(token_payload, secret_key, algorithm)
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  private
 | 
			
		||||
 | 
			
		||||
  def secret_key
 | 
			
		||||
    Rails.application.secret_key_base
 | 
			
		||||
  def token_payload
 | 
			
		||||
    (payload || {}).merge(exp: exp, iat: iat)
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  def iat
 | 
			
		||||
    Time.zone.now.to_i
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  def exp
 | 
			
		||||
    iat + expire_in.days.to_i
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  def expire_in
 | 
			
		||||
    # Value is stored in days, defaulting to 6 months (180 days)
 | 
			
		||||
    token_expiry_value = InstallationConfig.find_by(name: 'WIDGET_TOKEN_EXPIRY')&.value
 | 
			
		||||
    (token_expiry_value.presence || DEFAULT_EXPIRY_DAYS).to_i
 | 
			
		||||
  end
 | 
			
		||||
end
 | 
			
		||||
 
 | 
			
		||||
@@ -33,6 +33,7 @@ end
 | 
			
		||||
json.tweets_enabled resource.channel.try(:tweets_enabled) if resource.twitter?
 | 
			
		||||
 | 
			
		||||
## WebWidget Attributes
 | 
			
		||||
json.allowed_domains resource.channel.try(:allowed_domains)
 | 
			
		||||
json.widget_color resource.channel.try(:widget_color)
 | 
			
		||||
json.website_url resource.channel.try(:website_url)
 | 
			
		||||
json.hmac_mandatory resource.channel.try(:hmac_mandatory)
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										1
									
								
								app/views/api/v1/profile/mfa/backup_codes.json.jbuilder
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										1
									
								
								app/views/api/v1/profile/mfa/backup_codes.json.jbuilder
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1 @@
 | 
			
		||||
json.backup_codes @backup_codes
 | 
			
		||||
							
								
								
									
										2
									
								
								app/views/api/v1/profile/mfa/create.json.jbuilder
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										2
									
								
								app/views/api/v1/profile/mfa/create.json.jbuilder
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,2 @@
 | 
			
		||||
json.provisioning_url @user.mfa_service.two_factor_provisioning_uri
 | 
			
		||||
json.secret @user.otp_secret
 | 
			
		||||
							
								
								
									
										1
									
								
								app/views/api/v1/profile/mfa/destroy.json.jbuilder
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										1
									
								
								app/views/api/v1/profile/mfa/destroy.json.jbuilder
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1 @@
 | 
			
		||||
json.enabled @user.mfa_enabled?
 | 
			
		||||
							
								
								
									
										3
									
								
								app/views/api/v1/profile/mfa/show.json.jbuilder
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										3
									
								
								app/views/api/v1/profile/mfa/show.json.jbuilder
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,3 @@
 | 
			
		||||
json.feature_available Chatwoot.mfa_enabled?
 | 
			
		||||
json.enabled @user.mfa_enabled?
 | 
			
		||||
json.backup_codes_generated @user.mfa_service.backup_codes_generated? if Chatwoot.mfa_enabled?
 | 
			
		||||
							
								
								
									
										2
									
								
								app/views/api/v1/profile/mfa/verify.json.jbuilder
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										2
									
								
								app/views/api/v1/profile/mfa/verify.json.jbuilder
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,2 @@
 | 
			
		||||
json.enabled @user.mfa_enabled?
 | 
			
		||||
json.backup_codes @backup_codes if @backup_codes.present?
 | 
			
		||||
@@ -44,6 +44,7 @@
 | 
			
		||||
        whatsappApiVersion: '<%= @global_config['WHATSAPP_API_VERSION'] %>',
 | 
			
		||||
        signupEnabled: '<%= @global_config['ENABLE_ACCOUNT_SIGNUP'] %>',
 | 
			
		||||
        isEnterprise: '<%= @global_config['IS_ENTERPRISE'] %>',
 | 
			
		||||
        isMfaEnabled: '<%= Chatwoot.mfa_enabled? %>',
 | 
			
		||||
        <% if @global_config['IS_ENTERPRISE'] %>
 | 
			
		||||
        enterprisePlanName: '<%= @global_config['INSTALLATION_PRICING_PLAN'] %>',
 | 
			
		||||
        <% end %>
 | 
			
		||||
 
 | 
			
		||||
@@ -1,5 +1,5 @@
 | 
			
		||||
shared: &shared
 | 
			
		||||
  version: '4.5.2'
 | 
			
		||||
  version: '4.6.0'
 | 
			
		||||
 | 
			
		||||
development:
 | 
			
		||||
  <<: *shared
 | 
			
		||||
 
 | 
			
		||||
@@ -68,6 +68,16 @@ module Chatwoot
 | 
			
		||||
 | 
			
		||||
    # Disable PDF/video preview generation as we don't use them
 | 
			
		||||
    config.active_storage.previewers = []
 | 
			
		||||
 | 
			
		||||
    # Active Record Encryption configuration
 | 
			
		||||
    # Required for MFA/2FA features - skip if not using encryption
 | 
			
		||||
    if ENV['ACTIVE_RECORD_ENCRYPTION_PRIMARY_KEY'].present?
 | 
			
		||||
      config.active_record.encryption.primary_key = ENV['ACTIVE_RECORD_ENCRYPTION_PRIMARY_KEY']
 | 
			
		||||
      config.active_record.encryption.deterministic_key = ENV.fetch('ACTIVE_RECORD_ENCRYPTION_DETERMINISTIC_KEY', nil)
 | 
			
		||||
      config.active_record.encryption.key_derivation_salt = ENV.fetch('ACTIVE_RECORD_ENCRYPTION_KEY_DERIVATION_SALT', nil)
 | 
			
		||||
      config.active_record.encryption.support_unencrypted_data = true
 | 
			
		||||
      config.active_record.encryption.store_key_references = true
 | 
			
		||||
    end
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  def self.config
 | 
			
		||||
@@ -82,4 +92,16 @@ module Chatwoot
 | 
			
		||||
    # ref: https://www.rubydoc.info/stdlib/openssl/OpenSSL/SSL/SSLContext#DEFAULT_PARAMS-constant
 | 
			
		||||
    ENV['REDIS_OPENSSL_VERIFY_MODE'] == 'none' ? OpenSSL::SSL::VERIFY_NONE : OpenSSL::SSL::VERIFY_PEER
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  def self.encryption_configured?
 | 
			
		||||
    # Check if proper encryption keys are configured
 | 
			
		||||
    # MFA/2FA features should only be enabled when proper keys are set
 | 
			
		||||
    ENV['ACTIVE_RECORD_ENCRYPTION_PRIMARY_KEY'].present? &&
 | 
			
		||||
      ENV['ACTIVE_RECORD_ENCRYPTION_DETERMINISTIC_KEY'].present? &&
 | 
			
		||||
      ENV['ACTIVE_RECORD_ENCRYPTION_KEY_DERIVATION_SALT'].present?
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  def self.mfa_enabled?
 | 
			
		||||
    encryption_configured?
 | 
			
		||||
  end
 | 
			
		||||
end
 | 
			
		||||
 
 | 
			
		||||
@@ -2,7 +2,8 @@
 | 
			
		||||
 | 
			
		||||
# Configure sensitive parameters which will be filtered from the log file.
 | 
			
		||||
Rails.application.config.filter_parameters += [
 | 
			
		||||
  :password, :secret, :_key, :auth, :crypt, :salt, :certificate, :otp, :access, :private, :protected, :ssn
 | 
			
		||||
  :password, :secret, :_key, :auth, :crypt, :salt, :certificate, :otp, :access, :private, :protected, :ssn,
 | 
			
		||||
  :otp_secret, :otp_code, :backup_code, :mfa_token, :otp_backup_codes
 | 
			
		||||
]
 | 
			
		||||
 | 
			
		||||
# Regex to filter all occurrences of 'token' in keys except for 'website_token'
 | 
			
		||||
 
 | 
			
		||||
@@ -83,12 +83,17 @@ class Rack::Attack
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  # ### Prevent Brute-Force Login Attacks ###
 | 
			
		||||
  # Exclude MFA verification attempts from regular login throttling
 | 
			
		||||
  throttle('login/ip', limit: 5, period: 5.minutes) do |req|
 | 
			
		||||
    req.ip if req.path_without_extentions == '/auth/sign_in' && req.post?
 | 
			
		||||
    if req.path_without_extentions == '/auth/sign_in' && req.post? && req.params['mfa_token'].blank?
 | 
			
		||||
      # Skip if this is an MFA verification request
 | 
			
		||||
      req.ip
 | 
			
		||||
    end
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  throttle('login/email', limit: 10, period: 15.minutes) do |req|
 | 
			
		||||
    if req.path_without_extentions == '/auth/sign_in' && req.post?
 | 
			
		||||
    # Skip if this is an MFA verification request
 | 
			
		||||
    if req.path_without_extentions == '/auth/sign_in' && req.post? && req.params['mfa_token'].blank?
 | 
			
		||||
      # ref: https://github.com/rack/rack-attack/issues/399
 | 
			
		||||
      # NOTE: This line used to throw ArgumentError /rails/action_mailbox/sendgrid/inbound_emails : invalid byte sequence in UTF-8
 | 
			
		||||
      # Hence placed in the if block
 | 
			
		||||
@@ -114,6 +119,28 @@ class Rack::Attack
 | 
			
		||||
    req.ip if req.path_without_extentions == '/api/v1/profile/resend_confirmation' && req.post?
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  ## MFA throttling - prevent brute force attacks
 | 
			
		||||
  throttle('mfa_verification/ip', limit: 5, period: 1.minute) do |req|
 | 
			
		||||
    if req.path_without_extentions == '/api/v1/profile/mfa'
 | 
			
		||||
      req.ip if req.delete? # Throttle disable attempts
 | 
			
		||||
    elsif req.path_without_extentions.match?(%r{/api/v1/profile/mfa/(verify|backup_codes)})
 | 
			
		||||
      req.ip if req.post? # Throttle verify and backup_codes attempts
 | 
			
		||||
    end
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  # Separate rate limiting for MFA verification attempts
 | 
			
		||||
  throttle('mfa_login/ip', limit: 10, period: 1.minute) do |req|
 | 
			
		||||
    req.ip if req.path_without_extentions == '/auth/sign_in' && req.post? && req.params['mfa_token'].present?
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  throttle('mfa_login/token', limit: 10, period: 1.minute) do |req|
 | 
			
		||||
    if req.path_without_extentions == '/auth/sign_in' && req.post?
 | 
			
		||||
      # Track by MFA token to prevent brute force on a specific token
 | 
			
		||||
      mfa_token = req.params['mfa_token'].presence
 | 
			
		||||
      (mfa_token.presence)
 | 
			
		||||
    end
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  ## Prevent Brute-Force Signup Attacks ###
 | 
			
		||||
  throttle('accounts/ip', limit: 5, period: 30.minutes) do |req|
 | 
			
		||||
    req.ip if req.path_without_extentions == '/api/v1/accounts' && req.post?
 | 
			
		||||
 
 | 
			
		||||
@@ -439,3 +439,11 @@
 | 
			
		||||
  locked: false
 | 
			
		||||
  description: 'Zone ID for the Cloudflare domain'
 | 
			
		||||
## ------ End of Configs added for Cloudflare ------ ##
 | 
			
		||||
 | 
			
		||||
## ------ Customizations for Customers ------ ##
 | 
			
		||||
- name: WIDGET_TOKEN_EXPIRY
 | 
			
		||||
  display_title: 'Widget Token Expiry'
 | 
			
		||||
  value: 180
 | 
			
		||||
  locked: false
 | 
			
		||||
  description: 'Token expiry in days'
 | 
			
		||||
## ------ End of Customizations for Customers ------ ##
 | 
			
		||||
 
 | 
			
		||||
@@ -103,6 +103,18 @@ en:
 | 
			
		||||
      invalid_value: Invalid value. The values provided for %{attribute_name} are invalid
 | 
			
		||||
    custom_attribute_definition:
 | 
			
		||||
      key_conflict: The provided key is not allowed as it might conflict with default attributes.
 | 
			
		||||
    mfa:
 | 
			
		||||
      already_enabled: MFA is already enabled
 | 
			
		||||
      not_enabled: MFA is not enabled
 | 
			
		||||
      invalid_code: Invalid verification code
 | 
			
		||||
      invalid_backup_code: Invalid backup code
 | 
			
		||||
      invalid_token: Invalid or expired MFA token
 | 
			
		||||
      invalid_credentials: Invalid credentials or verification code
 | 
			
		||||
      feature_unavailable: MFA feature is not available. Please configure encryption keys.
 | 
			
		||||
  profile:
 | 
			
		||||
    mfa:
 | 
			
		||||
      enabled: MFA enabled successfully
 | 
			
		||||
      disabled: MFA disabled successfully
 | 
			
		||||
    account_saml_settings:
 | 
			
		||||
      invalid_certificate: must be a valid X.509 certificate in PEM format
 | 
			
		||||
  reports:
 | 
			
		||||
 
 | 
			
		||||
@@ -344,6 +344,14 @@ Rails.application.routes.draw do
 | 
			
		||||
          post :resend_confirmation
 | 
			
		||||
          post :reset_access_token
 | 
			
		||||
        end
 | 
			
		||||
 | 
			
		||||
        # MFA routes
 | 
			
		||||
        scope module: 'profile' do
 | 
			
		||||
          resource :mfa, controller: 'mfa', only: [:show, :create, :destroy] do
 | 
			
		||||
            post :verify
 | 
			
		||||
            post :backup_codes
 | 
			
		||||
          end
 | 
			
		||||
        end
 | 
			
		||||
      end
 | 
			
		||||
 | 
			
		||||
      resource :notification_subscriptions, only: [:create, :destroy]
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										11
									
								
								db/migrate/20250820130619_add_two_factor_to_users.rb
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										11
									
								
								db/migrate/20250820130619_add_two_factor_to_users.rb
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,11 @@
 | 
			
		||||
class AddTwoFactorToUsers < ActiveRecord::Migration[7.1]
 | 
			
		||||
  def change
 | 
			
		||||
    add_column :users, :otp_secret, :string
 | 
			
		||||
    add_column :users, :consumed_timestep, :integer
 | 
			
		||||
    add_column :users, :otp_required_for_login, :boolean, default: false, null: false
 | 
			
		||||
    add_column :users, :otp_backup_codes, :text
 | 
			
		||||
 | 
			
		||||
    add_index :users, :otp_secret, unique: true
 | 
			
		||||
    add_index :users, :otp_required_for_login
 | 
			
		||||
  end
 | 
			
		||||
end
 | 
			
		||||
@@ -0,0 +1,5 @@
 | 
			
		||||
class AddAllowedDomainsToChannelWidgets < ActiveRecord::Migration[7.1]
 | 
			
		||||
  def change
 | 
			
		||||
    add_column :channel_web_widgets, :allowed_domains, :text, default: ''
 | 
			
		||||
  end
 | 
			
		||||
end
 | 
			
		||||
@@ -10,7 +10,7 @@
 | 
			
		||||
#
 | 
			
		||||
# It's strongly recommended that you check this file into your version control system.
 | 
			
		||||
 | 
			
		||||
ActiveRecord::Schema[7.1].define(version: 2025_08_26_000000) do
 | 
			
		||||
ActiveRecord::Schema[7.1].define(version: 2025_09_16_024703) do
 | 
			
		||||
  # These extensions should be enabled to support this database
 | 
			
		||||
  enable_extension "pg_stat_statements"
 | 
			
		||||
  enable_extension "pg_trgm"
 | 
			
		||||
@@ -533,6 +533,7 @@ ActiveRecord::Schema[7.1].define(version: 2025_08_26_000000) do
 | 
			
		||||
    t.jsonb "pre_chat_form_options", default: {}
 | 
			
		||||
    t.boolean "hmac_mandatory", default: false
 | 
			
		||||
    t.boolean "continuity_via_email", default: true, null: false
 | 
			
		||||
    t.text "allowed_domains", default: ""
 | 
			
		||||
    t.index ["hmac_token"], name: "index_channel_web_widgets_on_hmac_token", unique: true
 | 
			
		||||
    t.index ["website_token"], name: "index_channel_web_widgets_on_website_token", unique: true
 | 
			
		||||
  end
 | 
			
		||||
@@ -1174,7 +1175,13 @@ ActiveRecord::Schema[7.1].define(version: 2025_08_26_000000) do
 | 
			
		||||
    t.jsonb "custom_attributes", default: {}
 | 
			
		||||
    t.string "type"
 | 
			
		||||
    t.text "message_signature"
 | 
			
		||||
    t.string "otp_secret"
 | 
			
		||||
    t.integer "consumed_timestep"
 | 
			
		||||
    t.boolean "otp_required_for_login", default: false
 | 
			
		||||
    t.text "otp_backup_codes"
 | 
			
		||||
    t.index ["email"], name: "index_users_on_email"
 | 
			
		||||
    t.index ["otp_required_for_login"], name: "index_users_on_otp_required_for_login"
 | 
			
		||||
    t.index ["otp_secret"], name: "index_users_on_otp_secret", unique: true
 | 
			
		||||
    t.index ["pubsub_token"], name: "index_users_on_pubsub_token", unique: true
 | 
			
		||||
    t.index ["reset_password_token"], name: "index_users_on_reset_password_token", unique: true
 | 
			
		||||
    t.index ["uid", "provider"], name: "index_users_on_uid_and_provider", unique: true
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										13
									
								
								enterprise/app/builders/enterprise/agent_builder.rb
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										13
									
								
								enterprise/app/builders/enterprise/agent_builder.rb
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,13 @@
 | 
			
		||||
module Enterprise::AgentBuilder
 | 
			
		||||
  def perform
 | 
			
		||||
    super.tap do |user|
 | 
			
		||||
      convert_to_saml_provider(user) if user.persisted? && account.saml_enabled?
 | 
			
		||||
    end
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  private
 | 
			
		||||
 | 
			
		||||
  def convert_to_saml_provider(user)
 | 
			
		||||
    user.update!(provider: 'saml') unless user.provider == 'saml'
 | 
			
		||||
  end
 | 
			
		||||
end
 | 
			
		||||
@@ -10,21 +10,9 @@ class Api::V1::Accounts::Captain::AssistantResponsesController < Api::V1::Accoun
 | 
			
		||||
  RESULTS_PER_PAGE = 25
 | 
			
		||||
 | 
			
		||||
  def index
 | 
			
		||||
    base_query = @responses
 | 
			
		||||
    base_query = base_query.where(assistant_id: permitted_params[:assistant_id]) if permitted_params[:assistant_id].present?
 | 
			
		||||
 | 
			
		||||
    if permitted_params[:document_id].present?
 | 
			
		||||
      base_query = base_query.where(
 | 
			
		||||
        documentable_id: permitted_params[:document_id],
 | 
			
		||||
        documentable_type: 'Captain::Document'
 | 
			
		||||
      )
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
    base_query = base_query.where(status: permitted_params[:status]) if permitted_params[:status].present?
 | 
			
		||||
 | 
			
		||||
    @responses_count = base_query.count
 | 
			
		||||
 | 
			
		||||
    @responses = base_query.page(@current_page).per(RESULTS_PER_PAGE)
 | 
			
		||||
    filtered_query = apply_filters(@responses)
 | 
			
		||||
    @responses_count = filtered_query.count
 | 
			
		||||
    @responses = filtered_query.page(@current_page).per(RESULTS_PER_PAGE)
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  def show; end
 | 
			
		||||
@@ -46,6 +34,29 @@ class Api::V1::Accounts::Captain::AssistantResponsesController < Api::V1::Accoun
 | 
			
		||||
 | 
			
		||||
  private
 | 
			
		||||
 | 
			
		||||
  def apply_filters(base_query)
 | 
			
		||||
    base_query = base_query.where(assistant_id: permitted_params[:assistant_id]) if permitted_params[:assistant_id].present?
 | 
			
		||||
 | 
			
		||||
    if permitted_params[:document_id].present?
 | 
			
		||||
      base_query = base_query.where(
 | 
			
		||||
        documentable_id: permitted_params[:document_id],
 | 
			
		||||
        documentable_type: 'Captain::Document'
 | 
			
		||||
      )
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
    base_query = base_query.where(status: permitted_params[:status]) if permitted_params[:status].present?
 | 
			
		||||
 | 
			
		||||
    if permitted_params[:search].present?
 | 
			
		||||
      search_term = "%#{permitted_params[:search]}%"
 | 
			
		||||
      base_query = base_query.where(
 | 
			
		||||
        'question ILIKE :search OR answer ILIKE :search',
 | 
			
		||||
        search: search_term
 | 
			
		||||
      )
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
    base_query
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  def set_assistant
 | 
			
		||||
    @assistant = Current.account.captain_assistants.find_by(id: params[:assistant_id])
 | 
			
		||||
  end
 | 
			
		||||
@@ -63,7 +74,7 @@ class Api::V1::Accounts::Captain::AssistantResponsesController < Api::V1::Accoun
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  def permitted_params
 | 
			
		||||
    params.permit(:id, :assistant_id, :page, :document_id, :account_id, :status)
 | 
			
		||||
    params.permit(:id, :assistant_id, :page, :document_id, :account_id, :status, :search)
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  def response_params
 | 
			
		||||
 
 | 
			
		||||
@@ -0,0 +1,45 @@
 | 
			
		||||
<p>Hi <%= @resource.name %>,</p>
 | 
			
		||||
 | 
			
		||||
<% account_user = @resource&.account_users&.first %>
 | 
			
		||||
<% is_saml_account = account_user&.account&.saml_enabled? %>
 | 
			
		||||
 | 
			
		||||
<% if account_user&.inviter.present? && @resource.unconfirmed_email.blank? %>
 | 
			
		||||
  <% if is_saml_account %>
 | 
			
		||||
    <p><%= account_user.inviter.name %>, with <%= account_user.account.name %>, has invited you to access <%= global_config['BRAND_NAME'] || 'Chatwoot' %> via Single Sign-On (SSO).</p>
 | 
			
		||||
    <p>Your organization uses SSO for secure authentication. You will not need a password to access your account.</p>
 | 
			
		||||
  <% else %>
 | 
			
		||||
    <p><%= account_user.inviter.name %>, with <%= account_user.account.name %>, has invited you to try out <%= global_config['BRAND_NAME'] || 'Chatwoot' %>.</p>
 | 
			
		||||
  <% end %>
 | 
			
		||||
<% end %>
 | 
			
		||||
 | 
			
		||||
<% if @resource.confirmed? %>
 | 
			
		||||
  <p>You can login to your <%= global_config['BRAND_NAME'] || 'Chatwoot' %> account through the link below:</p>
 | 
			
		||||
<% else %>
 | 
			
		||||
  <% if account_user&.inviter.blank? %>
 | 
			
		||||
  <p>
 | 
			
		||||
    Welcome to <%= global_config['BRAND_NAME'] || 'Chatwoot' %>! We have a suite of powerful tools ready for you to explore. Before that we quickly need to verify your email address to know it's really you.
 | 
			
		||||
  </p>
 | 
			
		||||
  <% end %>
 | 
			
		||||
  <% unless is_saml_account %>
 | 
			
		||||
  <p>Please take a moment and click the link below and activate your account.</p>
 | 
			
		||||
  <% end %>
 | 
			
		||||
<% end %>
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
<% if @resource.unconfirmed_email.present? %>
 | 
			
		||||
  <p><%= link_to 'Confirm my account', frontend_url('auth/confirmation', confirmation_token: @token) %></p>
 | 
			
		||||
<% elsif @resource.confirmed? %>
 | 
			
		||||
  <% if is_saml_account %>
 | 
			
		||||
    <p>You can now access your account by logging in through your organization's SSO portal.</p>
 | 
			
		||||
  <% else %>
 | 
			
		||||
    <p><%= link_to 'Login to my account', frontend_url('auth/sign_in') %></p>
 | 
			
		||||
  <% end %>
 | 
			
		||||
<% elsif account_user&.inviter.present? %>
 | 
			
		||||
  <% if is_saml_account %>
 | 
			
		||||
    <p>You can access your account by logging in through your organization's SSO portal.</p>
 | 
			
		||||
  <% else %>
 | 
			
		||||
    <p><%= link_to 'Confirm my account', frontend_url('auth/password/edit', reset_password_token: @resource.send(:set_reset_password_token)) %></p>
 | 
			
		||||
  <% end %>
 | 
			
		||||
<% else %>
 | 
			
		||||
  <p><%= link_to 'Confirm my account', frontend_url('auth/confirmation', confirmation_token: @token) %></p>
 | 
			
		||||
<% end %>
 | 
			
		||||
@@ -138,7 +138,7 @@ contacts:
 | 
			
		||||
      - "does_not_contain"
 | 
			
		||||
  phone_number:
 | 
			
		||||
    attribute_type: "standard"
 | 
			
		||||
    data_type: "text_case_insensitive"
 | 
			
		||||
    data_type: "text" # Text is not explicity defined in filters, default filter will be used
 | 
			
		||||
    filter_operators:
 | 
			
		||||
      - "equal_to"
 | 
			
		||||
      - "not_equal_to"
 | 
			
		||||
 
 | 
			
		||||
@@ -1,6 +1,6 @@
 | 
			
		||||
{
 | 
			
		||||
  "name": "@chatwoot/chatwoot",
 | 
			
		||||
  "version": "4.5.2",
 | 
			
		||||
  "version": "4.6.0",
 | 
			
		||||
  "license": "MIT",
 | 
			
		||||
  "scripts": {
 | 
			
		||||
    "eslint": "eslint app/**/*.{js,vue}",
 | 
			
		||||
 
 | 
			
		||||
@@ -154,6 +154,25 @@ RSpec.describe 'Api::V1::Accounts::Portals', type: :request do
 | 
			
		||||
        portal.reload
 | 
			
		||||
        expect(portal.archived).to be_truthy
 | 
			
		||||
      end
 | 
			
		||||
 | 
			
		||||
      it 'clears associated web widget when inbox selection is blank' do
 | 
			
		||||
        web_widget_inbox = create(:inbox, account: account)
 | 
			
		||||
        portal.update!(channel_web_widget: web_widget_inbox.channel)
 | 
			
		||||
 | 
			
		||||
        expect(portal.channel_web_widget_id).to eq(web_widget_inbox.channel.id)
 | 
			
		||||
 | 
			
		||||
        put "/api/v1/accounts/#{account.id}/portals/#{portal.slug}",
 | 
			
		||||
            params: {
 | 
			
		||||
              portal: { name: portal.name },
 | 
			
		||||
              inbox_id: ''
 | 
			
		||||
            },
 | 
			
		||||
            headers: admin.create_new_auth_token
 | 
			
		||||
 | 
			
		||||
        expect(response).to have_http_status(:success)
 | 
			
		||||
        portal.reload
 | 
			
		||||
        expect(portal.channel_web_widget_id).to be_nil
 | 
			
		||||
        expect(response.parsed_body['inbox']).to be_nil
 | 
			
		||||
      end
 | 
			
		||||
    end
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										146
									
								
								spec/controllers/devise_overrides/sessions_controller_spec.rb
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										146
									
								
								spec/controllers/devise_overrides/sessions_controller_spec.rb
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,146 @@
 | 
			
		||||
require 'rails_helper'
 | 
			
		||||
 | 
			
		||||
RSpec.describe DeviseOverrides::SessionsController, type: :controller do
 | 
			
		||||
  include Devise::Test::ControllerHelpers
 | 
			
		||||
 | 
			
		||||
  before do
 | 
			
		||||
    request.env['devise.mapping'] = Devise.mappings[:user]
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  describe 'POST #create' do
 | 
			
		||||
    let(:user) { create(:user, password: 'Test@123456') }
 | 
			
		||||
 | 
			
		||||
    context 'with standard authentication' do
 | 
			
		||||
      it 'authenticates with valid credentials' do
 | 
			
		||||
        post :create, params: { email: user.email, password: 'Test@123456' }
 | 
			
		||||
 | 
			
		||||
        expect(response).to have_http_status(:success)
 | 
			
		||||
      end
 | 
			
		||||
 | 
			
		||||
      it 'rejects invalid credentials' do
 | 
			
		||||
        post :create, params: { email: user.email, password: 'wrong' }
 | 
			
		||||
 | 
			
		||||
        expect(response).to have_http_status(:unauthorized)
 | 
			
		||||
      end
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
    context 'with MFA authentication' do
 | 
			
		||||
      before do
 | 
			
		||||
        skip('Skipping since MFA is not configured in this environment') unless Chatwoot.encryption_configured?
 | 
			
		||||
        user.enable_two_factor!
 | 
			
		||||
        user.update!(otp_required_for_login: true)
 | 
			
		||||
      end
 | 
			
		||||
 | 
			
		||||
      it 'requires MFA verification after successful password authentication' do
 | 
			
		||||
        post :create, params: { email: user.email, password: 'Test@123456' }
 | 
			
		||||
 | 
			
		||||
        expect(response).to have_http_status(:partial_content)
 | 
			
		||||
        json_response = response.parsed_body
 | 
			
		||||
        expect(json_response['mfa_required']).to be(true)
 | 
			
		||||
        expect(json_response['mfa_token']).to be_present
 | 
			
		||||
      end
 | 
			
		||||
 | 
			
		||||
      context 'when verifying MFA' do
 | 
			
		||||
        let(:mfa_token) { Mfa::TokenService.new(user: user).generate_token }
 | 
			
		||||
 | 
			
		||||
        it 'authenticates with valid OTP' do
 | 
			
		||||
          post :create, params: {
 | 
			
		||||
            mfa_token: mfa_token,
 | 
			
		||||
            otp_code: user.current_otp
 | 
			
		||||
          }
 | 
			
		||||
 | 
			
		||||
          expect(response).to have_http_status(:success)
 | 
			
		||||
        end
 | 
			
		||||
 | 
			
		||||
        it 'authenticates with valid backup code' do
 | 
			
		||||
          backup_codes = user.generate_backup_codes!
 | 
			
		||||
 | 
			
		||||
          post :create, params: {
 | 
			
		||||
            mfa_token: mfa_token,
 | 
			
		||||
            backup_code: backup_codes.first
 | 
			
		||||
          }
 | 
			
		||||
 | 
			
		||||
          expect(response).to have_http_status(:success)
 | 
			
		||||
        end
 | 
			
		||||
 | 
			
		||||
        it 'rejects invalid OTP' do
 | 
			
		||||
          post :create, params: {
 | 
			
		||||
            mfa_token: mfa_token,
 | 
			
		||||
            otp_code: '000000'
 | 
			
		||||
          }
 | 
			
		||||
 | 
			
		||||
          expect(response).to have_http_status(:bad_request)
 | 
			
		||||
          expect(response.parsed_body['error']).to eq(I18n.t('errors.mfa.invalid_code'))
 | 
			
		||||
        end
 | 
			
		||||
 | 
			
		||||
        it 'rejects invalid backup code' do
 | 
			
		||||
          user.generate_backup_codes!
 | 
			
		||||
 | 
			
		||||
          post :create, params: {
 | 
			
		||||
            mfa_token: mfa_token,
 | 
			
		||||
            backup_code: 'invalid'
 | 
			
		||||
          }
 | 
			
		||||
 | 
			
		||||
          expect(response).to have_http_status(:bad_request)
 | 
			
		||||
          expect(response.parsed_body['error']).to eq(I18n.t('errors.mfa.invalid_code'))
 | 
			
		||||
        end
 | 
			
		||||
 | 
			
		||||
        it 'rejects expired MFA token' do
 | 
			
		||||
          expired_token = JWT.encode(
 | 
			
		||||
            { user_id: user.id, exp: 1.minute.ago.to_i },
 | 
			
		||||
            Rails.application.secret_key_base,
 | 
			
		||||
            'HS256'
 | 
			
		||||
          )
 | 
			
		||||
 | 
			
		||||
          post :create, params: {
 | 
			
		||||
            mfa_token: expired_token,
 | 
			
		||||
            otp_code: user.current_otp
 | 
			
		||||
          }
 | 
			
		||||
 | 
			
		||||
          expect(response).to have_http_status(:unauthorized)
 | 
			
		||||
          expect(response.parsed_body['error']).to eq(I18n.t('errors.mfa.invalid_token'))
 | 
			
		||||
        end
 | 
			
		||||
 | 
			
		||||
        it 'requires either OTP or backup code' do
 | 
			
		||||
          post :create, params: { mfa_token: mfa_token }
 | 
			
		||||
 | 
			
		||||
          expect(response).to have_http_status(:bad_request)
 | 
			
		||||
          expect(response.parsed_body['error']).to eq(I18n.t('errors.mfa.invalid_code'))
 | 
			
		||||
        end
 | 
			
		||||
      end
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
    context 'with SSO authentication' do
 | 
			
		||||
      it 'authenticates with valid SSO token' do
 | 
			
		||||
        sso_token = user.generate_sso_auth_token
 | 
			
		||||
 | 
			
		||||
        post :create, params: {
 | 
			
		||||
          email: user.email,
 | 
			
		||||
          sso_auth_token: sso_token
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        expect(response).to have_http_status(:success)
 | 
			
		||||
      end
 | 
			
		||||
 | 
			
		||||
      it 'rejects invalid SSO token' do
 | 
			
		||||
        post :create, params: {
 | 
			
		||||
          email: user.email,
 | 
			
		||||
          sso_auth_token: 'invalid'
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        expect(response).to have_http_status(:unauthorized)
 | 
			
		||||
      end
 | 
			
		||||
    end
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  describe 'GET #new' do
 | 
			
		||||
    it 'redirects to frontend login page' do
 | 
			
		||||
      allow(ENV).to receive(:fetch).and_call_original
 | 
			
		||||
      allow(ENV).to receive(:fetch).with('FRONTEND_URL', nil).and_return('/frontend')
 | 
			
		||||
 | 
			
		||||
      get :new
 | 
			
		||||
 | 
			
		||||
      expect(response).to redirect_to('/frontend/app/login?error=access-denied')
 | 
			
		||||
    end
 | 
			
		||||
  end
 | 
			
		||||
end
 | 
			
		||||
							
								
								
									
										139
									
								
								spec/enterprise/builders/agent_builder_spec.rb
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										139
									
								
								spec/enterprise/builders/agent_builder_spec.rb
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,139 @@
 | 
			
		||||
require 'rails_helper'
 | 
			
		||||
 | 
			
		||||
RSpec.describe AgentBuilder do
 | 
			
		||||
  let(:email) { 'agent@example.com' }
 | 
			
		||||
  let(:name) { 'Test Agent' }
 | 
			
		||||
  let(:account) { create(:account) }
 | 
			
		||||
  let!(:inviter) { create(:user, account: account, role: 'administrator') }
 | 
			
		||||
  let(:builder) do
 | 
			
		||||
    described_class.new(
 | 
			
		||||
      email: email,
 | 
			
		||||
      name: name,
 | 
			
		||||
      account: account,
 | 
			
		||||
      inviter: inviter
 | 
			
		||||
    )
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  describe '#perform with SAML enabled' do
 | 
			
		||||
    let(:saml_settings) do
 | 
			
		||||
      create(:account_saml_settings, account: account)
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
    before { saml_settings }
 | 
			
		||||
 | 
			
		||||
    context 'when user does not exist' do
 | 
			
		||||
      it 'creates a new user with SAML provider' do
 | 
			
		||||
        expect { builder.perform }.to change(User, :count).by(1)
 | 
			
		||||
 | 
			
		||||
        user = User.from_email(email)
 | 
			
		||||
        expect(user.provider).to eq('saml')
 | 
			
		||||
      end
 | 
			
		||||
 | 
			
		||||
      it 'creates user with correct attributes' do
 | 
			
		||||
        user = builder.perform
 | 
			
		||||
 | 
			
		||||
        expect(user.email).to eq(email)
 | 
			
		||||
        expect(user.name).to eq(name)
 | 
			
		||||
        expect(user.provider).to eq('saml')
 | 
			
		||||
        expect(user.encrypted_password).to be_present
 | 
			
		||||
      end
 | 
			
		||||
 | 
			
		||||
      it 'adds user to the account with correct role' do
 | 
			
		||||
        user = builder.perform
 | 
			
		||||
        account_user = AccountUser.find_by(user: user, account: account)
 | 
			
		||||
 | 
			
		||||
        expect(account_user).to be_present
 | 
			
		||||
        expect(account_user.role).to eq('agent')
 | 
			
		||||
        expect(account_user.inviter).to eq(inviter)
 | 
			
		||||
      end
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
    context 'when user already exists with email provider' do
 | 
			
		||||
      let!(:existing_user) { create(:user, email: email, provider: 'email') }
 | 
			
		||||
 | 
			
		||||
      it 'does not create a new user' do
 | 
			
		||||
        expect { builder.perform }.not_to change(User, :count)
 | 
			
		||||
      end
 | 
			
		||||
 | 
			
		||||
      it 'converts existing user to SAML provider' do
 | 
			
		||||
        expect(existing_user.provider).to eq('email')
 | 
			
		||||
 | 
			
		||||
        builder.perform
 | 
			
		||||
 | 
			
		||||
        expect(existing_user.reload.provider).to eq('saml')
 | 
			
		||||
      end
 | 
			
		||||
 | 
			
		||||
      it 'adds existing user to the account' do
 | 
			
		||||
        user = builder.perform
 | 
			
		||||
        account_user = AccountUser.find_by(user: user, account: account)
 | 
			
		||||
 | 
			
		||||
        expect(account_user).to be_present
 | 
			
		||||
        expect(account_user.inviter).to eq(inviter)
 | 
			
		||||
      end
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
    context 'when user already exists with SAML provider' do
 | 
			
		||||
      let!(:existing_user) { create(:user, email: email, provider: 'saml') }
 | 
			
		||||
 | 
			
		||||
      it 'does not change the provider' do
 | 
			
		||||
        expect { builder.perform }.not_to(change { existing_user.reload.provider })
 | 
			
		||||
      end
 | 
			
		||||
 | 
			
		||||
      it 'still adds user to the account' do
 | 
			
		||||
        user = builder.perform
 | 
			
		||||
        account_user = AccountUser.find_by(user: user, account: account)
 | 
			
		||||
 | 
			
		||||
        expect(account_user).to be_present
 | 
			
		||||
      end
 | 
			
		||||
    end
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  describe '#perform without SAML' do
 | 
			
		||||
    context 'when user does not exist' do
 | 
			
		||||
      it 'creates a new user with email provider (default behavior)' do
 | 
			
		||||
        expect { builder.perform }.to change(User, :count).by(1)
 | 
			
		||||
 | 
			
		||||
        user = User.from_email(email)
 | 
			
		||||
        expect(user.provider).to eq('email')
 | 
			
		||||
      end
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
    context 'when user already exists' do
 | 
			
		||||
      let!(:existing_user) { create(:user, email: email, provider: 'email') }
 | 
			
		||||
 | 
			
		||||
      it 'does not change the existing user provider' do
 | 
			
		||||
        expect { builder.perform }.not_to(change { existing_user.reload.provider })
 | 
			
		||||
      end
 | 
			
		||||
    end
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  describe '#perform with different account configurations' do
 | 
			
		||||
    context 'when account has no SAML settings' do
 | 
			
		||||
      # No saml_settings created for this account
 | 
			
		||||
 | 
			
		||||
      it 'treats account as non-SAML enabled' do
 | 
			
		||||
        user = builder.perform
 | 
			
		||||
        expect(user.provider).to eq('email')
 | 
			
		||||
      end
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
    context 'when SAML settings are deleted after user creation' do
 | 
			
		||||
      let(:saml_settings) do
 | 
			
		||||
        create(:account_saml_settings, account: account)
 | 
			
		||||
      end
 | 
			
		||||
      let(:existing_user) { create(:user, email: email, provider: 'saml') }
 | 
			
		||||
 | 
			
		||||
      before do
 | 
			
		||||
        saml_settings
 | 
			
		||||
        existing_user
 | 
			
		||||
      end
 | 
			
		||||
 | 
			
		||||
      it 'does not affect existing SAML users when adding to account' do
 | 
			
		||||
        saml_settings.destroy!
 | 
			
		||||
 | 
			
		||||
        user = builder.perform
 | 
			
		||||
        expect(user.provider).to eq('saml') # Unchanged
 | 
			
		||||
      end
 | 
			
		||||
    end
 | 
			
		||||
  end
 | 
			
		||||
end
 | 
			
		||||
@@ -90,6 +90,53 @@ RSpec.describe 'Api::V1::Accounts::Captain::AssistantResponses', type: :request
 | 
			
		||||
        expect(json_response[:payload][0][:documentable][:id]).to eq(document.id)
 | 
			
		||||
      end
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
    context 'when searching' do
 | 
			
		||||
      before do
 | 
			
		||||
        create(:captain_assistant_response,
 | 
			
		||||
               account: account,
 | 
			
		||||
               assistant: assistant,
 | 
			
		||||
               question: 'How to reset password?',
 | 
			
		||||
               answer: 'Click forgot password')
 | 
			
		||||
        create(:captain_assistant_response,
 | 
			
		||||
               account: account,
 | 
			
		||||
               assistant: assistant,
 | 
			
		||||
               question: 'How to change email?',
 | 
			
		||||
               answer: 'Go to settings')
 | 
			
		||||
      end
 | 
			
		||||
 | 
			
		||||
      it 'finds responses by question text' do
 | 
			
		||||
        get "/api/v1/accounts/#{account.id}/captain/assistant_responses",
 | 
			
		||||
            params: { search: 'password' },
 | 
			
		||||
            headers: agent.create_new_auth_token,
 | 
			
		||||
            as: :json
 | 
			
		||||
 | 
			
		||||
        expect(response).to have_http_status(:ok)
 | 
			
		||||
        expect(json_response[:payload].length).to eq(1)
 | 
			
		||||
        expect(json_response[:payload][0][:question]).to include('password')
 | 
			
		||||
      end
 | 
			
		||||
 | 
			
		||||
      it 'finds responses by answer text' do
 | 
			
		||||
        get "/api/v1/accounts/#{account.id}/captain/assistant_responses",
 | 
			
		||||
            params: { search: 'settings' },
 | 
			
		||||
            headers: agent.create_new_auth_token,
 | 
			
		||||
            as: :json
 | 
			
		||||
 | 
			
		||||
        expect(response).to have_http_status(:ok)
 | 
			
		||||
        expect(json_response[:payload].length).to eq(1)
 | 
			
		||||
        expect(json_response[:payload][0][:answer]).to include('settings')
 | 
			
		||||
      end
 | 
			
		||||
 | 
			
		||||
      it 'returns empty when no matches' do
 | 
			
		||||
        get "/api/v1/accounts/#{account.id}/captain/assistant_responses",
 | 
			
		||||
            params: { search: 'nonexistent' },
 | 
			
		||||
            headers: agent.create_new_auth_token,
 | 
			
		||||
            as: :json
 | 
			
		||||
 | 
			
		||||
        expect(response).to have_http_status(:ok)
 | 
			
		||||
        expect(json_response[:payload].length).to eq(0)
 | 
			
		||||
      end
 | 
			
		||||
    end
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  describe 'GET /api/v1/accounts/:account_id/captain/assistant_responses/:id' do
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										150
									
								
								spec/enterprise/mailers/devise_mailer_spec.rb
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										150
									
								
								spec/enterprise/mailers/devise_mailer_spec.rb
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,150 @@
 | 
			
		||||
# frozen_string_literal: true
 | 
			
		||||
 | 
			
		||||
require 'rails_helper'
 | 
			
		||||
 | 
			
		||||
RSpec.describe 'Devise::Mailer' do
 | 
			
		||||
  describe 'confirmation_instructions with Enterprise features' do
 | 
			
		||||
    let(:account) { create(:account) }
 | 
			
		||||
    let!(:confirmable_user) { create(:user, inviter: inviter_val, account: account) }
 | 
			
		||||
    let(:inviter_val) { nil }
 | 
			
		||||
    let(:mail) { Devise::Mailer.confirmation_instructions(confirmable_user.reload, nil, {}) }
 | 
			
		||||
 | 
			
		||||
    before do
 | 
			
		||||
      confirmable_user.update!(confirmed_at: nil)
 | 
			
		||||
      confirmable_user.send(:generate_confirmation_token)
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
    context 'with SAML enabled account' do
 | 
			
		||||
      let(:saml_settings) { create(:account_saml_settings, account: account) }
 | 
			
		||||
 | 
			
		||||
      before { saml_settings }
 | 
			
		||||
 | 
			
		||||
      context 'when user has no inviter' do
 | 
			
		||||
        it 'shows standard welcome message without SSO references' do
 | 
			
		||||
          expect(mail.body).to match('We have a suite of powerful tools ready for you to explore.')
 | 
			
		||||
          expect(mail.body).not_to match('via Single Sign-On')
 | 
			
		||||
        end
 | 
			
		||||
 | 
			
		||||
        it 'does not show activation instructions for SAML accounts' do
 | 
			
		||||
          expect(mail.body).not_to match('Please take a moment and click the link below and activate your account')
 | 
			
		||||
        end
 | 
			
		||||
 | 
			
		||||
        it 'shows confirmation link' do
 | 
			
		||||
          expect(mail.body).to include("app/auth/confirmation?confirmation_token=#{confirmable_user.confirmation_token}")
 | 
			
		||||
        end
 | 
			
		||||
      end
 | 
			
		||||
 | 
			
		||||
      context 'when user has inviter and SAML is enabled' do
 | 
			
		||||
        let(:inviter_val) { create(:user, :administrator, skip_confirmation: true, account: account) }
 | 
			
		||||
 | 
			
		||||
        it 'mentions SSO invitation' do
 | 
			
		||||
          expect(mail.body).to match(
 | 
			
		||||
            "#{CGI.escapeHTML(inviter_val.name)}, with #{CGI.escapeHTML(account.name)}, has invited you to access.*via Single Sign-On \\(SSO\\)"
 | 
			
		||||
          )
 | 
			
		||||
        end
 | 
			
		||||
 | 
			
		||||
        it 'explains SSO authentication' do
 | 
			
		||||
          expect(mail.body).to match('Your organization uses SSO for secure authentication')
 | 
			
		||||
          expect(mail.body).to match('You will not need a password to access your account')
 | 
			
		||||
        end
 | 
			
		||||
 | 
			
		||||
        it 'does not show standard invitation message' do
 | 
			
		||||
          expect(mail.body).not_to match('has invited you to try out')
 | 
			
		||||
        end
 | 
			
		||||
 | 
			
		||||
        it 'directs to SSO portal instead of password reset' do
 | 
			
		||||
          expect(mail.body).to match('You can access your account by logging in through your organization\'s SSO portal')
 | 
			
		||||
          expect(mail.body).not_to include('app/auth/password/edit')
 | 
			
		||||
        end
 | 
			
		||||
      end
 | 
			
		||||
 | 
			
		||||
      context 'when user is already confirmed and has inviter' do
 | 
			
		||||
        let(:inviter_val) { create(:user, :administrator, skip_confirmation: true, account: account) }
 | 
			
		||||
 | 
			
		||||
        before do
 | 
			
		||||
          confirmable_user.confirm
 | 
			
		||||
        end
 | 
			
		||||
 | 
			
		||||
        it 'shows SSO login instructions' do
 | 
			
		||||
          expect(mail.body).to match('You can now access your account by logging in through your organization\'s SSO portal')
 | 
			
		||||
          expect(mail.body).not_to include('/auth/sign_in')
 | 
			
		||||
        end
 | 
			
		||||
      end
 | 
			
		||||
 | 
			
		||||
      context 'when user updates email on SAML account' do
 | 
			
		||||
        let(:inviter_val) { create(:user, :administrator, skip_confirmation: true, account: account) }
 | 
			
		||||
 | 
			
		||||
        before do
 | 
			
		||||
          confirmable_user.update!(email: 'updated@example.com')
 | 
			
		||||
        end
 | 
			
		||||
 | 
			
		||||
        it 'still shows confirmation link for email verification' do
 | 
			
		||||
          expect(mail.body).to include('app/auth/confirmation?confirmation_token')
 | 
			
		||||
          expect(confirmable_user.unconfirmed_email.blank?).to be false
 | 
			
		||||
        end
 | 
			
		||||
      end
 | 
			
		||||
 | 
			
		||||
      context 'when user is already confirmed with no inviter' do
 | 
			
		||||
        before do
 | 
			
		||||
          confirmable_user.confirm
 | 
			
		||||
        end
 | 
			
		||||
 | 
			
		||||
        it 'shows SSO login instructions instead of regular login' do
 | 
			
		||||
          expect(mail.body).to match('You can now access your account by logging in through your organization\'s SSO portal')
 | 
			
		||||
          expect(mail.body).not_to include('/auth/sign_in')
 | 
			
		||||
        end
 | 
			
		||||
      end
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
    context 'when account does not have SAML enabled' do
 | 
			
		||||
      context 'when user has inviter' do
 | 
			
		||||
        let(:inviter_val) { create(:user, :administrator, skip_confirmation: true, account: account) }
 | 
			
		||||
 | 
			
		||||
        it 'shows standard invitation without SSO references' do
 | 
			
		||||
          expect(mail.body).to match('has invited you to try out Chatwoot')
 | 
			
		||||
          expect(mail.body).not_to match('via Single Sign-On')
 | 
			
		||||
          expect(mail.body).not_to match('SSO portal')
 | 
			
		||||
        end
 | 
			
		||||
 | 
			
		||||
        it 'shows password reset link' do
 | 
			
		||||
          expect(mail.body).to include('app/auth/password/edit')
 | 
			
		||||
        end
 | 
			
		||||
      end
 | 
			
		||||
 | 
			
		||||
      context 'when user has no inviter' do
 | 
			
		||||
        it 'shows standard welcome message and activation instructions' do
 | 
			
		||||
          expect(mail.body).to match('We have a suite of powerful tools ready for you to explore')
 | 
			
		||||
          expect(mail.body).to match('Please take a moment and click the link below and activate your account')
 | 
			
		||||
        end
 | 
			
		||||
 | 
			
		||||
        it 'shows confirmation link' do
 | 
			
		||||
          expect(mail.body).to include("app/auth/confirmation?confirmation_token=#{confirmable_user.confirmation_token}")
 | 
			
		||||
        end
 | 
			
		||||
      end
 | 
			
		||||
 | 
			
		||||
      context 'when user is already confirmed' do
 | 
			
		||||
        let(:inviter_val) { create(:user, :administrator, skip_confirmation: true, account: account) }
 | 
			
		||||
 | 
			
		||||
        before do
 | 
			
		||||
          confirmable_user.confirm
 | 
			
		||||
        end
 | 
			
		||||
 | 
			
		||||
        it 'shows regular login link' do
 | 
			
		||||
          expect(mail.body).to include('/auth/sign_in')
 | 
			
		||||
          expect(mail.body).not_to match('SSO portal')
 | 
			
		||||
        end
 | 
			
		||||
      end
 | 
			
		||||
 | 
			
		||||
      context 'when user updates email' do
 | 
			
		||||
        before do
 | 
			
		||||
          confirmable_user.update!(email: 'updated@example.com')
 | 
			
		||||
        end
 | 
			
		||||
 | 
			
		||||
        it 'shows confirmation link for email verification' do
 | 
			
		||||
          expect(mail.body).to include('app/auth/confirmation?confirmation_token')
 | 
			
		||||
          expect(confirmable_user.unconfirmed_email.blank?).to be false
 | 
			
		||||
        end
 | 
			
		||||
      end
 | 
			
		||||
    end
 | 
			
		||||
  end
 | 
			
		||||
end
 | 
			
		||||
@@ -33,7 +33,7 @@ RSpec.describe Inbox do
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
    it 'returns all member ids when inbox max_assignment_limit is not configured' do
 | 
			
		||||
      expect(inbox.member_ids_with_assignment_capacity).to eq(inbox.members.ids)
 | 
			
		||||
      expect(inbox.member_ids_with_assignment_capacity).to match_array(inbox.members.ids)
 | 
			
		||||
    end
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										244
									
								
								spec/listeners/automation_rule_listener_labels_spec.rb
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										244
									
								
								spec/listeners/automation_rule_listener_labels_spec.rb
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,244 @@
 | 
			
		||||
require 'rails_helper'
 | 
			
		||||
 | 
			
		||||
describe AutomationRuleListener do
 | 
			
		||||
  let(:listener) { described_class.instance }
 | 
			
		||||
  let!(:account) { create(:account) }
 | 
			
		||||
  let!(:user) { create(:user, account: account) }
 | 
			
		||||
  let!(:inbox) { create(:inbox, account: account) }
 | 
			
		||||
  let!(:contact) { create(:contact, account: account) }
 | 
			
		||||
  let!(:conversation) { create(:conversation, account: account, inbox: inbox, contact: contact) }
 | 
			
		||||
  let(:label1) { create(:label, account: account, title: 'bug') }
 | 
			
		||||
  let(:label2) { create(:label, account: account, title: 'feature') }
 | 
			
		||||
  let(:label3) { create(:label, account: account, title: 'urgent') }
 | 
			
		||||
 | 
			
		||||
  before do
 | 
			
		||||
    Current.user = user
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  describe 'conversation_updated with label conditions and actions' do
 | 
			
		||||
    context 'when label is added and automation rule has label condition' do
 | 
			
		||||
      let(:automation_rule) do
 | 
			
		||||
        create(:automation_rule,
 | 
			
		||||
               event_name: 'conversation_updated',
 | 
			
		||||
               account: account,
 | 
			
		||||
               conditions: [
 | 
			
		||||
                 {
 | 
			
		||||
                   attribute_key: 'labels',
 | 
			
		||||
                   filter_operator: 'equal_to',
 | 
			
		||||
                   values: ['bug'],
 | 
			
		||||
                   query_operator: nil
 | 
			
		||||
                 }
 | 
			
		||||
               ],
 | 
			
		||||
               actions: [
 | 
			
		||||
                 {
 | 
			
		||||
                   action_name: 'add_label',
 | 
			
		||||
                   action_params: ['urgent']
 | 
			
		||||
                 },
 | 
			
		||||
                 {
 | 
			
		||||
                   action_name: 'send_message',
 | 
			
		||||
                   action_params: ['Bug report received. We will investigate this issue.']
 | 
			
		||||
                 }
 | 
			
		||||
               ])
 | 
			
		||||
      end
 | 
			
		||||
 | 
			
		||||
      it 'triggers automation when the specified label is added' do
 | 
			
		||||
        automation_rule # Create the automation rule
 | 
			
		||||
        expect(Messages::MessageBuilder).to receive(:new).and_call_original
 | 
			
		||||
 | 
			
		||||
        # Add the 'bug' label to trigger the automation
 | 
			
		||||
        conversation.add_labels(['bug'])
 | 
			
		||||
 | 
			
		||||
        # Dispatch the event
 | 
			
		||||
        event = Events::Base.new('conversation_updated', Time.zone.now, {
 | 
			
		||||
                                   conversation: conversation,
 | 
			
		||||
                                   changed_attributes: { label_list: [[], ['bug']] }
 | 
			
		||||
                                 })
 | 
			
		||||
 | 
			
		||||
        listener.conversation_updated(event)
 | 
			
		||||
 | 
			
		||||
        # Verify the label was added by automation
 | 
			
		||||
        expect(conversation.reload.label_list).to include('urgent')
 | 
			
		||||
 | 
			
		||||
        # Verify a message was sent
 | 
			
		||||
        expect(conversation.messages.last.content).to eq('Bug report received. We will investigate this issue.')
 | 
			
		||||
      end
 | 
			
		||||
 | 
			
		||||
      it 'does not trigger automation when a different label is added' do
 | 
			
		||||
        automation_rule # Create the automation rule
 | 
			
		||||
        expect(Messages::MessageBuilder).not_to receive(:new)
 | 
			
		||||
 | 
			
		||||
        # Add a different label
 | 
			
		||||
        conversation.add_labels(['feature'])
 | 
			
		||||
 | 
			
		||||
        event = Events::Base.new('conversation_updated', Time.zone.now, {
 | 
			
		||||
                                   conversation: conversation,
 | 
			
		||||
                                   changed_attributes: { label_list: [[], ['feature']] }
 | 
			
		||||
                                 })
 | 
			
		||||
 | 
			
		||||
        listener.conversation_updated(event)
 | 
			
		||||
 | 
			
		||||
        # Verify the automation did not run
 | 
			
		||||
        expect(conversation.reload.label_list).not_to include('urgent')
 | 
			
		||||
      end
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
    context 'when automation rule has is_present label condition' do
 | 
			
		||||
      let(:automation_rule) do
 | 
			
		||||
        create(:automation_rule,
 | 
			
		||||
               event_name: 'conversation_updated',
 | 
			
		||||
               account: account,
 | 
			
		||||
               conditions: [
 | 
			
		||||
                 {
 | 
			
		||||
                   attribute_key: 'labels',
 | 
			
		||||
                   filter_operator: 'is_present',
 | 
			
		||||
                   values: [],
 | 
			
		||||
                   query_operator: nil
 | 
			
		||||
                 }
 | 
			
		||||
               ],
 | 
			
		||||
               actions: [
 | 
			
		||||
                 {
 | 
			
		||||
                   action_name: 'send_message',
 | 
			
		||||
                   action_params: ['Thank you for adding a label to categorize this conversation.']
 | 
			
		||||
                 }
 | 
			
		||||
               ])
 | 
			
		||||
      end
 | 
			
		||||
 | 
			
		||||
      it 'triggers automation when any label is added to an unlabeled conversation' do
 | 
			
		||||
        automation_rule # Create the automation rule
 | 
			
		||||
        expect(Messages::MessageBuilder).to receive(:new).and_call_original
 | 
			
		||||
 | 
			
		||||
        # Add any label to trigger the automation
 | 
			
		||||
        conversation.add_labels(['feature'])
 | 
			
		||||
 | 
			
		||||
        event = Events::Base.new('conversation_updated', Time.zone.now, {
 | 
			
		||||
                                   conversation: conversation,
 | 
			
		||||
                                   changed_attributes: { label_list: [[], ['feature']] }
 | 
			
		||||
                                 })
 | 
			
		||||
 | 
			
		||||
        listener.conversation_updated(event)
 | 
			
		||||
 | 
			
		||||
        # Verify a message was sent
 | 
			
		||||
        expect(conversation.messages.last.content).to eq('Thank you for adding a label to categorize this conversation.')
 | 
			
		||||
      end
 | 
			
		||||
 | 
			
		||||
      it 'still triggers when labels are removed but conversation still has labels' do
 | 
			
		||||
        automation_rule # Create the automation rule
 | 
			
		||||
        # Start with multiple labels
 | 
			
		||||
        conversation.add_labels(%w[bug feature])
 | 
			
		||||
        conversation.reload
 | 
			
		||||
 | 
			
		||||
        expect(Messages::MessageBuilder).to receive(:new).and_call_original
 | 
			
		||||
 | 
			
		||||
        # Remove one label but conversation still has labels
 | 
			
		||||
        conversation.update_labels(['bug'])
 | 
			
		||||
 | 
			
		||||
        event = Events::Base.new('conversation_updated', Time.zone.now, {
 | 
			
		||||
                                   conversation: conversation,
 | 
			
		||||
                                   changed_attributes: { label_list: [%w[bug feature], ['bug']] }
 | 
			
		||||
                                 })
 | 
			
		||||
 | 
			
		||||
        listener.conversation_updated(event)
 | 
			
		||||
 | 
			
		||||
        # Should still trigger because conversation has labels (is_present condition)
 | 
			
		||||
        expect(conversation.messages.last.content).to eq('Thank you for adding a label to categorize this conversation.')
 | 
			
		||||
      end
 | 
			
		||||
 | 
			
		||||
      it 'does not trigger when all labels are removed' do
 | 
			
		||||
        automation_rule # Create the automation rule
 | 
			
		||||
        # Start with labels
 | 
			
		||||
        conversation.add_labels(['bug'])
 | 
			
		||||
        conversation.reload
 | 
			
		||||
 | 
			
		||||
        expect(Messages::MessageBuilder).not_to receive(:new)
 | 
			
		||||
 | 
			
		||||
        # Remove all labels
 | 
			
		||||
        conversation.update_labels([])
 | 
			
		||||
 | 
			
		||||
        event = Events::Base.new('conversation_updated', Time.zone.now, {
 | 
			
		||||
                                   conversation: conversation,
 | 
			
		||||
                                   changed_attributes: { label_list: [['bug'], []] }
 | 
			
		||||
                                 })
 | 
			
		||||
 | 
			
		||||
        listener.conversation_updated(event)
 | 
			
		||||
      end
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
    context 'when automation rule has remove_label action' do
 | 
			
		||||
      let!(:automation_rule) do
 | 
			
		||||
        create(:automation_rule,
 | 
			
		||||
               event_name: 'conversation_updated',
 | 
			
		||||
               account: account,
 | 
			
		||||
               conditions: [
 | 
			
		||||
                 {
 | 
			
		||||
                   attribute_key: 'labels',
 | 
			
		||||
                   filter_operator: 'equal_to',
 | 
			
		||||
                   values: ['urgent'],
 | 
			
		||||
                   query_operator: nil
 | 
			
		||||
                 }
 | 
			
		||||
               ],
 | 
			
		||||
               actions: [
 | 
			
		||||
                 {
 | 
			
		||||
                   action_name: 'remove_label',
 | 
			
		||||
                   action_params: ['bug']
 | 
			
		||||
                 }
 | 
			
		||||
               ])
 | 
			
		||||
      end
 | 
			
		||||
 | 
			
		||||
      it 'removes specified labels when condition is met' do
 | 
			
		||||
        automation_rule # Create the automation rule
 | 
			
		||||
        # Start with both labels
 | 
			
		||||
        conversation.add_labels(%w[bug urgent])
 | 
			
		||||
 | 
			
		||||
        event = Events::Base.new('conversation_updated', Time.zone.now, {
 | 
			
		||||
                                   conversation: conversation,
 | 
			
		||||
                                   changed_attributes: { label_list: [['bug'], %w[bug urgent]] }
 | 
			
		||||
                                 })
 | 
			
		||||
 | 
			
		||||
        listener.conversation_updated(event)
 | 
			
		||||
 | 
			
		||||
        # Verify the bug label was removed but urgent remains
 | 
			
		||||
        expect(conversation.reload.label_list).to include('urgent')
 | 
			
		||||
        expect(conversation.reload.label_list).not_to include('bug')
 | 
			
		||||
      end
 | 
			
		||||
    end
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  describe 'preventing infinite loops' do
 | 
			
		||||
    let!(:automation_rule) do
 | 
			
		||||
      create(:automation_rule,
 | 
			
		||||
             event_name: 'conversation_updated',
 | 
			
		||||
             account: account,
 | 
			
		||||
             conditions: [
 | 
			
		||||
               {
 | 
			
		||||
                 attribute_key: 'labels',
 | 
			
		||||
                 filter_operator: 'equal_to',
 | 
			
		||||
                 values: ['bug'],
 | 
			
		||||
                 query_operator: nil
 | 
			
		||||
               }
 | 
			
		||||
             ],
 | 
			
		||||
             actions: [
 | 
			
		||||
               {
 | 
			
		||||
                 action_name: 'add_label',
 | 
			
		||||
                 action_params: ['processed']
 | 
			
		||||
               }
 | 
			
		||||
             ])
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
    it 'does not trigger automation when performed by automation rule' do
 | 
			
		||||
      automation_rule # Create the automation rule
 | 
			
		||||
      conversation.add_labels(['bug'])
 | 
			
		||||
 | 
			
		||||
      # Simulate event performed by automation rule
 | 
			
		||||
      event = Events::Base.new('conversation_updated', Time.zone.now, {
 | 
			
		||||
                                 conversation: conversation,
 | 
			
		||||
                                 changed_attributes: { label_list: [[], ['bug']] },
 | 
			
		||||
                                 performed_by: automation_rule
 | 
			
		||||
                               })
 | 
			
		||||
 | 
			
		||||
      # Should not process the event since it was performed by automation
 | 
			
		||||
      expect(AutomationRules::ActionService).not_to receive(:new)
 | 
			
		||||
 | 
			
		||||
      listener.conversation_updated(event)
 | 
			
		||||
    end
 | 
			
		||||
  end
 | 
			
		||||
end
 | 
			
		||||
@@ -17,8 +17,7 @@ RSpec.describe AdministratorNotifications::BaseMailer do
 | 
			
		||||
      # Call the private method
 | 
			
		||||
      admin_emails = mailer.send(:admin_emails)
 | 
			
		||||
 | 
			
		||||
      expect(admin_emails).to include(admin1.email)
 | 
			
		||||
      expect(admin_emails).to include(admin2.email)
 | 
			
		||||
      expect(admin_emails).to contain_exactly(admin1.email, admin2.email)
 | 
			
		||||
      expect(admin_emails).not_to include(agent.email)
 | 
			
		||||
    end
 | 
			
		||||
  end
 | 
			
		||||
@@ -49,7 +48,7 @@ RSpec.describe AdministratorNotifications::BaseMailer do
 | 
			
		||||
 | 
			
		||||
      # Mock the send_mail_with_liquid method
 | 
			
		||||
      expect(mailer).to receive(:send_mail_with_liquid).with(
 | 
			
		||||
        to: [admin1.email, admin2.email],
 | 
			
		||||
        to: contain_exactly(admin1.email, admin2.email),
 | 
			
		||||
        subject: subject
 | 
			
		||||
      ).and_return(true)
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -9,6 +9,7 @@ RSpec.describe AdministratorNotifications::ChannelNotificationsMailer do
 | 
			
		||||
  let(:class_instance) { described_class.new }
 | 
			
		||||
  let!(:account) { create(:account) }
 | 
			
		||||
  let!(:administrator) { create(:user, :administrator, email: 'agent1@example.com', account: account) }
 | 
			
		||||
  let!(:another_administrator) { create(:user, :administrator, email: 'agent2@example.com', account: account) }
 | 
			
		||||
 | 
			
		||||
  describe 'facebook_disconnect' do
 | 
			
		||||
    before do
 | 
			
		||||
@@ -26,7 +27,7 @@ RSpec.describe AdministratorNotifications::ChannelNotificationsMailer do
 | 
			
		||||
      end
 | 
			
		||||
 | 
			
		||||
      it 'renders the receiver email' do
 | 
			
		||||
        expect(mail.to).to eq([administrator.email])
 | 
			
		||||
        expect(mail.to).to contain_exactly(administrator.email, another_administrator.email)
 | 
			
		||||
      end
 | 
			
		||||
    end
 | 
			
		||||
  end
 | 
			
		||||
@@ -41,7 +42,7 @@ RSpec.describe AdministratorNotifications::ChannelNotificationsMailer do
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
    it 'renders the receiver email' do
 | 
			
		||||
      expect(mail.to).to eq([administrator.email])
 | 
			
		||||
      expect(mail.to).to contain_exactly(administrator.email, another_administrator.email)
 | 
			
		||||
    end
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
@@ -55,7 +56,7 @@ RSpec.describe AdministratorNotifications::ChannelNotificationsMailer do
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
    it 'renders the receiver email' do
 | 
			
		||||
      expect(mail.to).to eq([administrator.email])
 | 
			
		||||
      expect(mail.to).to contain_exactly(administrator.email, another_administrator.email)
 | 
			
		||||
    end
 | 
			
		||||
  end
 | 
			
		||||
end
 | 
			
		||||
 
 | 
			
		||||
Some files were not shown because too many files have changed in this diff Show More
		Reference in New Issue
	
	Block a user