diff --git a/.env.example b/.env.example index 2ab2933dc..de671599c 100644 --- a/.env.example +++ b/.env.example @@ -6,6 +6,13 @@ # Use `rake secret` to generate this variable SECRET_KEY_BASE=replace_with_lengthy_secure_hex +# Active Record Encryption keys (required for MFA/2FA functionality) +# Generate these keys by running: rails db:encryption:init +# IMPORTANT: Use different keys for each environment (development, staging, production) +# ACTIVE_RECORD_ENCRYPTION_PRIMARY_KEY= +# ACTIVE_RECORD_ENCRYPTION_DETERMINISTIC_KEY= +# ACTIVE_RECORD_ENCRYPTION_KEY_DERIVATION_SALT= + # Replace with the URL you are planning to use for your app FRONTEND_URL=http://0.0.0.0:3000 # To use a dedicated URL for help center pages diff --git a/.github/workflows/run_mfa_spec.yml b/.github/workflows/run_mfa_spec.yml new file mode 100644 index 000000000..61b406f8a --- /dev/null +++ b/.github/workflows/run_mfa_spec.yml @@ -0,0 +1,99 @@ +name: Run MFA Tests +permissions: + contents: read + +on: + pull_request: + +# If two pushes happen within a short time in the same PR, cancel the run of the oldest push +concurrency: + group: pr-${{ github.workflow }}-${{ github.head_ref }} + cancel-in-progress: true + +jobs: + test: + runs-on: ubuntu-22.04 + # Only run if MFA test keys are available + if: github.event_name == 'workflow_dispatch' || (github.repository == 'chatwoot/chatwoot' && github.actor != 'dependabot[bot]') + + services: + postgres: + image: pgvector/pgvector:pg15 + env: + POSTGRES_USER: postgres + POSTGRES_PASSWORD: '' + POSTGRES_DB: postgres + POSTGRES_HOST_AUTH_METHOD: trust + ports: + - 5432:5432 + options: >- + --mount type=tmpfs,destination=/var/lib/postgresql/data + --health-cmd pg_isready + --health-interval 10s + --health-timeout 5s + --health-retries 5 + redis: + image: redis + ports: + - 6379:6379 + options: --entrypoint redis-server + + env: + RAILS_ENV: test + POSTGRES_HOST: localhost + # Active Record encryption keys required for MFA - test keys only, not for production use + ACTIVE_RECORD_ENCRYPTION_PRIMARY_KEY: 'test_key_a6cde8f7b9c2d3e4f5a6b7c8d9e0f1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7' + ACTIVE_RECORD_ENCRYPTION_DETERMINISTIC_KEY: 'test_key_b7def9a8c0d3e4f5a6b7c8d9e0f1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d8' + ACTIVE_RECORD_ENCRYPTION_KEY_DERIVATION_SALT: 'test_salt_c8efa0b9d1e4f5a6b7c8d9e0f1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d9' + + steps: + - uses: actions/checkout@v4 + + - uses: ruby/setup-ruby@v1 + with: + bundler-cache: true + + - name: Create database + run: bundle exec rake db:create + + - name: Install pgvector extension + run: | + PGPASSWORD="" psql -h localhost -U postgres -d chatwoot_test -c "CREATE EXTENSION IF NOT EXISTS vector;" + + - name: Seed database + run: bundle exec rake db:schema:load + + - name: Run MFA-related backend tests + run: | + bundle exec rspec \ + spec/services/mfa/token_service_spec.rb \ + spec/services/mfa/authentication_service_spec.rb \ + spec/requests/api/v1/profile/mfa_controller_spec.rb \ + spec/controllers/devise_overrides/sessions_controller_spec.rb \ + --profile=10 \ + --format documentation + env: + NODE_OPTIONS: --openssl-legacy-provider + + - name: Run MFA-related tests in user_spec + run: | + # Run specific MFA-related tests from user_spec + bundle exec rspec spec/models/user_spec.rb \ + -e "two factor" \ + -e "2FA" \ + -e "MFA" \ + -e "otp" \ + -e "backup code" \ + --profile=10 \ + --format documentation + env: + NODE_OPTIONS: --openssl-legacy-provider + + - name: Upload test logs + uses: actions/upload-artifact@v4 + if: failure() + with: + name: mfa-test-logs + path: | + log/test.log + tmp/screenshots/ diff --git a/Gemfile b/Gemfile index 927a853a0..265c609c1 100644 --- a/Gemfile +++ b/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' diff --git a/Gemfile.lock b/Gemfile.lock index 2cce9f322..f9e253e6c 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -212,6 +212,11 @@ GEM railties (>= 4.1.0) responders warden (~> 1.2.3) + devise-two-factor (6.1.0) + activesupport (>= 7.0, < 8.1) + devise (~> 4.0) + railties (>= 7.0, < 8.1) + rotp (~> 6.0) devise_token_auth (1.2.5) bcrypt (~> 3.0) devise (> 3.5.2, < 5) @@ -723,6 +728,7 @@ GEM reverse_markdown (2.1.1) nokogiri rexml (3.4.1) + 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 diff --git a/app/controllers/api/v1/profile/mfa_controller.rb b/app/controllers/api/v1/profile/mfa_controller.rb new file mode 100644 index 000000000..dd874f222 --- /dev/null +++ b/app/controllers/api/v1/profile/mfa_controller.rb @@ -0,0 +1,68 @@ +class Api::V1::Profile::MfaController < Api::BaseController + before_action :check_mfa_feature_available + before_action :check_mfa_enabled, only: [:destroy, :backup_codes] + before_action :check_mfa_disabled, only: [:create, :verify] + before_action :validate_otp, only: [:verify, :backup_codes, :destroy] + before_action :validate_password, only: [:destroy] + + def show; end + + def create + mfa_service.enable_two_factor! + end + + def verify + @backup_codes = mfa_service.verify_and_activate! + end + + def destroy + mfa_service.disable_two_factor! + end + + def backup_codes + @backup_codes = mfa_service.generate_backup_codes! + end + + private + + def mfa_service + @mfa_service ||= Mfa::ManagementService.new(user: current_user) + end + + def check_mfa_enabled + render_could_not_create_error(I18n.t('errors.mfa.not_enabled')) unless current_user.mfa_enabled? + end + + def check_mfa_feature_available + return if Chatwoot.mfa_enabled? + + render json: { + error: I18n.t('errors.mfa.feature_unavailable') + }, status: :forbidden + end + + def check_mfa_disabled + render_could_not_create_error(I18n.t('errors.mfa.already_enabled')) if current_user.mfa_enabled? + end + + def validate_otp + authenticated = Mfa::AuthenticationService.new( + user: current_user, + otp_code: mfa_params[:otp_code] + ).authenticate + + return if authenticated + + render_could_not_create_error(I18n.t('errors.mfa.invalid_code')) + end + + def validate_password + return if current_user.valid_password?(mfa_params[:password]) + + render_could_not_create_error(I18n.t('errors.mfa.invalid_credentials')) + end + + def mfa_params + params.permit(:otp_code, :password) + end +end diff --git a/app/controllers/devise_overrides/sessions_controller.rb b/app/controllers/devise_overrides/sessions_controller.rb index fc7b12767..bf3a7f221 100644 --- a/app/controllers/devise_overrides/sessions_controller.rb +++ b/app/controllers/devise_overrides/sessions_controller.rb @@ -9,13 +9,11 @@ class DeviseOverrides::SessionsController < DeviseTokenAuth::SessionsController end def create - # Authenticate user via the temporary sso auth token - if params[:sso_auth_token].present? && @resource.present? - authenticate_resource_with_sso_token - yield @resource if block_given? - render_create_success - else - super + return handle_mfa_verification if mfa_verification_request? + return handle_sso_authentication if sso_authentication_request? + + super do |resource| + return handle_mfa_required(resource) if resource&.mfa_enabled? end end @@ -25,6 +23,20 @@ class DeviseOverrides::SessionsController < DeviseTokenAuth::SessionsController private + def mfa_verification_request? + params[:mfa_token].present? + end + + def sso_authentication_request? + params[:sso_auth_token].present? && @resource.present? + end + + def handle_sso_authentication + authenticate_resource_with_sso_token + yield @resource if block_given? + render_create_success + end + def login_page_url(error: nil) frontend_url = ENV.fetch('FRONTEND_URL', nil) @@ -46,6 +58,41 @@ class DeviseOverrides::SessionsController < DeviseTokenAuth::SessionsController user = User.from_email(params[:email]) @resource = user if user&.valid_sso_auth_token?(params[:sso_auth_token]) end + + def handle_mfa_required(resource) + render json: { + mfa_required: true, + mfa_token: Mfa::TokenService.new(user: resource).generate_token + }, status: :partial_content + end + + def handle_mfa_verification + user = Mfa::TokenService.new(token: params[:mfa_token]).verify_token + return render_mfa_error('errors.mfa.invalid_token', :unauthorized) unless user + + authenticated = Mfa::AuthenticationService.new( + user: user, + otp_code: params[:otp_code], + backup_code: params[:backup_code] + ).authenticate + + return render_mfa_error('errors.mfa.invalid_code') unless authenticated + + sign_in_mfa_user(user) + end + + def sign_in_mfa_user(user) + @resource = user + @token = @resource.create_token + @resource.save! + + sign_in(:user, @resource, store: false, bypass: false) + render_create_success + end + + def render_mfa_error(message_key, status = :bad_request) + render json: { error: I18n.t(message_key) }, status: status + end end DeviseOverrides::SessionsController.prepend_mod_with('DeviseOverrides::SessionsController') diff --git a/app/javascript/dashboard/api/captain/response.js b/app/javascript/dashboard/api/captain/response.js index e3c42757a..d48bd81c7 100644 --- a/app/javascript/dashboard/api/captain/response.js +++ b/app/javascript/dashboard/api/captain/response.js @@ -6,11 +6,11 @@ class CaptainResponses extends ApiClient { super('captain/assistant_responses', { accountScoped: true }); } - get({ page = 1, searchKey, assistantId, documentId, status } = {}) { + get({ page = 1, search, assistantId, documentId, status } = {}) { return axios.get(this.url, { params: { page, - searchKey, + search, assistant_id: assistantId, document_id: documentId, status, diff --git a/app/javascript/dashboard/components-next/input/Input.vue b/app/javascript/dashboard/components-next/input/Input.vue index f4bf5a94f..71964b4f8 100644 --- a/app/javascript/dashboard/components-next/input/Input.vue +++ b/app/javascript/dashboard/components-next/input/Input.vue @@ -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; @@ -105,6 +121,7 @@ onMounted(() => { :class="[ customInputClass, inputOutlineClass, + sizeClass, { error: messageType === 'error', focus: isFocused, @@ -119,7 +136,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" diff --git a/app/javascript/dashboard/components-next/whatsapp/WhatsAppTemplateParser.vue b/app/javascript/dashboard/components-next/whatsapp/WhatsAppTemplateParser.vue index cf62a6d5d..6df06642c 100644 --- a/app/javascript/dashboard/components-next/whatsapp/WhatsAppTemplateParser.vue +++ b/app/javascript/dashboard/components-next/whatsapp/WhatsAppTemplateParser.vue @@ -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" /> +
+ +
diff --git a/app/javascript/dashboard/composables/useAutomationValues.js b/app/javascript/dashboard/composables/useAutomationValues.js index abc44f66b..5279f15e4 100644 --- a/app/javascript/dashboard/composables/useAutomationValues.js +++ b/app/javascript/dashboard/composables/useAutomationValues.js @@ -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, diff --git a/app/javascript/dashboard/helper/automationHelper.js b/app/javascript/dashboard/helper/automationHelper.js index 3723fd4d5..3e5f46f90 100644 --- a/app/javascript/dashboard/helper/automationHelper.js +++ b/app/javascript/dashboard/helper/automationHelper.js @@ -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]; diff --git a/app/javascript/dashboard/helper/specs/templateHelper.spec.js b/app/javascript/dashboard/helper/specs/templateHelper.spec.js index 6e0661152..375e38a2d 100644 --- a/app/javascript/dashboard/helper/specs/templateHelper.spec.js +++ b/app/javascript/dashboard/helper/specs/templateHelper.spec.js @@ -218,6 +218,7 @@ describe('templateHelper', () => { expect(result.header).toEqual({ media_url: '', media_type: 'document', + media_name: '', }); expect(result.body).toEqual({ 1: '', diff --git a/app/javascript/dashboard/helper/templateHelper.js b/app/javascript/dashboard/helper/templateHelper.js index 5c9bbff05..1fb61d760 100644 --- a/app/javascript/dashboard/helper/templateHelper.js +++ b/app/javascript/dashboard/helper/templateHelper.js @@ -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 diff --git a/app/javascript/dashboard/i18n/locale/en/automation.json b/app/javascript/dashboard/i18n/locale/en/automation.json index 80274f488..43245a1d5 100644 --- a/app/javascript/dashboard/i18n/locale/en/automation.json +++ b/app/javascript/dashboard/i18n/locale/en/automation.json @@ -177,7 +177,8 @@ "REFERER_LINK": "Referrer Link", "ASSIGNEE_NAME": "Assignee", "TEAM_NAME": "Team", - "PRIORITY": "Priority" + "PRIORITY": "Priority", + "LABELS": "Labels" } } } diff --git a/app/javascript/dashboard/i18n/locale/en/integrations.json b/app/javascript/dashboard/i18n/locale/en/integrations.json index 619d31416..12826a6b8 100644 --- a/app/javascript/dashboard/i18n/locale/en/integrations.json +++ b/app/javascript/dashboard/i18n/locale/en/integrations.json @@ -826,6 +826,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": { diff --git a/app/javascript/dashboard/i18n/locale/en/whatsappTemplates.json b/app/javascript/dashboard/i18n/locale/en/whatsappTemplates.json index 5f53faaa8..cf28312dc 100644 --- a/app/javascript/dashboard/i18n/locale/en/whatsappTemplates.json +++ b/app/javascript/dashboard/i18n/locale/en/whatsappTemplates.json @@ -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" } } diff --git a/app/javascript/dashboard/routes/dashboard/captain/responses/Index.vue b/app/javascript/dashboard/routes/dashboard/captain/responses/Index.vue index 85e7f1ed0..86d3fff69 100644 --- a/app/javascript/dashboard/routes/dashboard/captain/responses/Index.vue +++ b/app/javascript/dashboard/routes/dashboard/captain/responses/Index.vue @@ -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,34 +302,47 @@ onMounted(() => {