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..16e57d4f8 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) @@ -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 diff --git a/app/builders/agent_builder.rb b/app/builders/agent_builder.rb index 54f478920..2fe11cae0 100644 --- a/app/builders/agent_builder.rb +++ b/app/builders/agent_builder.rb @@ -52,3 +52,5 @@ class AgentBuilder }.compact)) end end + +AgentBuilder.prepend_mod_with('AgentBuilder') diff --git a/app/controllers/api/v1/accounts/portals_controller.rb b/app/controllers/api/v1/accounts/portals_controller.rb index af96441f8..57344cc1e 100644 --- a/app/controllers/api/v1/accounts/portals_controller.rb +++ b/app/controllers/api/v1/accounts/portals_controller.rb @@ -85,7 +85,8 @@ class Api::V1::Accounts::PortalsController < Api::V1::Accounts::BaseController def live_chat_widget_params permitted_params = params.permit(:inbox_id) - return {} if permitted_params[:inbox_id].blank? + return {} unless permitted_params.key?(:inbox_id) + return { channel_web_widget_id: nil } if permitted_params[:inbox_id].blank? inbox = Inbox.find(permitted_params[:inbox_id]) return {} unless inbox.web_widget? diff --git a/app/controllers/api/v1/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/api/mfa.js b/app/javascript/dashboard/api/mfa.js new file mode 100644 index 000000000..c18bea3e9 --- /dev/null +++ b/app/javascript/dashboard/api/mfa.js @@ -0,0 +1,28 @@ +/* global axios */ +import ApiClient from './ApiClient'; + +class MfaAPI extends ApiClient { + constructor() { + super('profile/mfa', { accountScoped: false }); + } + + enable() { + return axios.post(`${this.url}`); + } + + verify(otpCode) { + return axios.post(`${this.url}/verify`, { otp_code: otpCode }); + } + + disable(password, otpCode) { + return axios.delete(this.url, { + data: { password, otp_code: otpCode }, + }); + } + + regenerateBackupCodes(otpCode) { + return axios.post(`${this.url}/backup_codes`, { otp_code: otpCode }); + } +} + +export default new MfaAPI(); diff --git a/app/javascript/dashboard/components-next/HelpCenter/Pages/PortalSettingsPage/PortalBaseSettings.vue b/app/javascript/dashboard/components-next/HelpCenter/Pages/PortalSettingsPage/PortalBaseSettings.vue index f74bf95e2..b99f08a29 100644 --- a/app/javascript/dashboard/components-next/HelpCenter/Pages/PortalSettingsPage/PortalBaseSettings.vue +++ b/app/javascript/dashboard/components-next/HelpCenter/Pages/PortalSettingsPage/PortalBaseSettings.vue @@ -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 { diff --git a/app/javascript/dashboard/components-next/captain/AnimatingImg/AnimatingImg.story.vue b/app/javascript/dashboard/components-next/captain/AnimatingImg/AnimatingImg.story.vue new file mode 100644 index 000000000..c75e6bd6d --- /dev/null +++ b/app/javascript/dashboard/components-next/captain/AnimatingImg/AnimatingImg.story.vue @@ -0,0 +1,34 @@ + + + diff --git a/app/javascript/dashboard/components-next/captain/AnimatingImg/Guardrails.vue b/app/javascript/dashboard/components-next/captain/AnimatingImg/Guardrails.vue new file mode 100644 index 000000000..e99d01758 --- /dev/null +++ b/app/javascript/dashboard/components-next/captain/AnimatingImg/Guardrails.vue @@ -0,0 +1,1000 @@ + + + + + diff --git a/app/javascript/dashboard/components-next/captain/AnimatingImg/ResponseGuidelines.vue b/app/javascript/dashboard/components-next/captain/AnimatingImg/ResponseGuidelines.vue new file mode 100644 index 000000000..958f97dee --- /dev/null +++ b/app/javascript/dashboard/components-next/captain/AnimatingImg/ResponseGuidelines.vue @@ -0,0 +1,990 @@ + + + + + diff --git a/app/javascript/dashboard/components-next/captain/AnimatingImg/Scenarios.vue b/app/javascript/dashboard/components-next/captain/AnimatingImg/Scenarios.vue new file mode 100644 index 000000000..b50bcf0ac --- /dev/null +++ b/app/javascript/dashboard/components-next/captain/AnimatingImg/Scenarios.vue @@ -0,0 +1,1060 @@ + + + + + diff --git a/app/javascript/dashboard/components-next/captain/AnimatingImg/Settings.vue b/app/javascript/dashboard/components-next/captain/AnimatingImg/Settings.vue new file mode 100644 index 000000000..d50b2ff54 --- /dev/null +++ b/app/javascript/dashboard/components-next/captain/AnimatingImg/Settings.vue @@ -0,0 +1,752 @@ + + + diff --git a/app/javascript/dashboard/components-next/input/Input.vue b/app/javascript/dashboard/components-next/input/Input.vue index f4bf5a94f..561f98ffe 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; @@ -100,11 +116,13 @@ onMounted(() => { -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" 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/components/auth/MfaVerification.vue b/app/javascript/dashboard/components/auth/MfaVerification.vue new file mode 100644 index 000000000..2f17d790a --- /dev/null +++ b/app/javascript/dashboard/components/auth/MfaVerification.vue @@ -0,0 +1,328 @@ + + + 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/helpCenter.json b/app/javascript/dashboard/i18n/locale/en/helpCenter.json index 16f108c0e..b47af9181 100644 --- a/app/javascript/dashboard/i18n/locale/en/helpCenter.json +++ b/app/javascript/dashboard/i18n/locale/en/helpCenter.json @@ -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" diff --git a/app/javascript/dashboard/i18n/locale/en/index.js b/app/javascript/dashboard/i18n/locale/en/index.js index bc4a8312a..e93dcd88e 100644 --- a/app/javascript/dashboard/i18n/locale/en/index.js +++ b/app/javascript/dashboard/i18n/locale/en/index.js @@ -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, }; diff --git a/app/javascript/dashboard/i18n/locale/en/integrations.json b/app/javascript/dashboard/i18n/locale/en/integrations.json index c4399b0e9..8a812dff3 100644 --- a/app/javascript/dashboard/i18n/locale/en/integrations.json +++ b/app/javascript/dashboard/i18n/locale/en/integrations.json @@ -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": { diff --git a/app/javascript/dashboard/i18n/locale/en/mfa.json b/app/javascript/dashboard/i18n/locale/en/mfa.json new file mode 100644 index 000000000..f7556fdcf --- /dev/null +++ b/app/javascript/dashboard/i18n/locale/en/mfa.json @@ -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." + } +} diff --git a/app/javascript/dashboard/i18n/locale/en/settings.json b/app/javascript/dashboard/i18n/locale/en/settings.json index b81a47f4e..9ddc3b805 100644 --- a/app/javascript/dashboard/i18n/locale/en/settings.json +++ b/app/javascript/dashboard/i18n/locale/en/settings.json @@ -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", 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(() => {