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 e0377668c..ff3fa3273 100644 --- a/Gemfile +++ b/Gemfile @@ -79,6 +79,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 357277b7a..5d500058d 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) @@ -725,7 +730,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) @@ -1011,6 +1017,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/Rakefile b/Rakefile index 591d2c4c6..2e996417e 100644 --- a/Rakefile +++ b/Rakefile @@ -2,9 +2,8 @@ # for example lib/tasks/capistrano.rake, and they will automatically be available to Rake. require_relative 'config/application' +# Load Enterprise Edition rake tasks if they exist +enterprise_tasks_path = Rails.root.join('enterprise/tasks_railtie.rb').to_s +require enterprise_tasks_path if File.exist?(enterprise_tasks_path) Rails.application.load_tasks - -# Load Enterprise Edition rake tasks if they exist -enterprise_tasks_path = Rails.root.join('enterprise/lib/tasks.rb').to_s -require enterprise_tasks_path if File.exist?(enterprise_tasks_path) 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/accounts/whatsapp/authorizations_controller.rb b/app/controllers/api/v1/accounts/whatsapp/authorizations_controller.rb index 3e7d876c3..d52f396fc 100644 --- a/app/controllers/api/v1/accounts/whatsapp/authorizations_controller.rb +++ b/app/controllers/api/v1/accounts/whatsapp/authorizations_controller.rb @@ -1,5 +1,4 @@ class Api::V1::Accounts::Whatsapp::AuthorizationsController < Api::V1::Accounts::BaseController - before_action :validate_feature_enabled! before_action :fetch_and_validate_inbox, if: -> { params[:inbox_id].present? } # POST /api/v1/accounts/:account_id/whatsapp/authorization @@ -65,15 +64,6 @@ class Api::V1::Accounts::Whatsapp::AuthorizationsController < Api::V1::Accounts: }, status: :unprocessable_entity end - def validate_feature_enabled! - return if Current.account.feature_whatsapp_embedded_signup? - - render json: { - success: false, - error: 'WhatsApp embedded signup is not enabled for this account' - }, status: :forbidden - end - def validate_embedded_signup_params! missing_params = [] missing_params << 'code' if params[:code].blank? 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/controllers/widgets_controller.rb b/app/controllers/widgets_controller.rb index 4be690ffe..9a6a376f7 100644 --- a/app/controllers/widgets_controller.rb +++ b/app/controllers/widgets_controller.rb @@ -70,7 +70,12 @@ class WidgetsController < ActionController::Base end def allow_iframe_requests - response.headers.delete('X-Frame-Options') + 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 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/api/samlSettings.js b/app/javascript/dashboard/api/samlSettings.js new file mode 100644 index 000000000..7c0f5b266 --- /dev/null +++ b/app/javascript/dashboard/api/samlSettings.js @@ -0,0 +1,26 @@ +/* global axios */ +import ApiClient from './ApiClient'; + +class SamlSettingsAPI extends ApiClient { + constructor() { + super('saml_settings', { accountScoped: true }); + } + + get() { + return axios.get(this.url); + } + + create(data) { + return axios.post(this.url, { saml_settings: data }); + } + + update(data) { + return axios.put(this.url, { saml_settings: data }); + } + + delete() { + return axios.delete(this.url); + } +} + +export default new SamlSettingsAPI(); diff --git a/app/javascript/dashboard/components-next/AssignmentPolicy/components/AddDataDropdown.vue b/app/javascript/dashboard/components-next/AssignmentPolicy/components/AddDataDropdown.vue index c664d8929..a078b9cc7 100644 --- a/app/javascript/dashboard/components-next/AssignmentPolicy/components/AddDataDropdown.vue +++ b/app/javascript/dashboard/components-next/AssignmentPolicy/components/AddDataDropdown.vue @@ -1,9 +1,10 @@ + + diff --git a/app/javascript/dashboard/components-next/AssignmentPolicy/components/InboxCapacityLimits.vue b/app/javascript/dashboard/components-next/AssignmentPolicy/components/InboxCapacityLimits.vue new file mode 100644 index 000000000..b31248653 --- /dev/null +++ b/app/javascript/dashboard/components-next/AssignmentPolicy/components/InboxCapacityLimits.vue @@ -0,0 +1,177 @@ + + + diff --git a/app/javascript/dashboard/components-next/AssignmentPolicy/components/story/AddDataDropdown.story.vue b/app/javascript/dashboard/components-next/AssignmentPolicy/components/story/AddDataDropdown.story.vue index 2ac4d8854..e69aa798f 100644 --- a/app/javascript/dashboard/components-next/AssignmentPolicy/components/story/AddDataDropdown.story.vue +++ b/app/javascript/dashboard/components-next/AssignmentPolicy/components/story/AddDataDropdown.story.vue @@ -34,6 +34,29 @@ const mockInboxes = [ }, ]; +const mockTags = [ + { + id: 1, + name: 'urgent', + color: '#ff4757', + }, + { + id: 2, + name: 'bug', + color: '#ff6b6b', + }, + { + id: 3, + name: 'feature-request', + color: '#4834d4', + }, + { + id: 4, + name: 'documentation', + color: '#26de81', + }, +]; + const handleAdd = item => { console.log('Add item:', item); }; @@ -42,9 +65,9 @@ const handleAdd = item => { diff --git a/app/javascript/dashboard/components-next/AssignmentPolicy/components/story/DataTable.story.vue b/app/javascript/dashboard/components-next/AssignmentPolicy/components/story/DataTable.story.vue index 912b1fdfc..a81a29976 100644 --- a/app/javascript/dashboard/components-next/AssignmentPolicy/components/story/DataTable.story.vue +++ b/app/javascript/dashboard/components-next/AssignmentPolicy/components/story/DataTable.story.vue @@ -22,6 +22,21 @@ const mockItems = [ }, ]; +const mockAgentList = [ + { + id: 1, + name: 'John Doe', + email: 'john.doe@example.com', + avatarUrl: 'https://i.pravatar.cc/150?img=1', + }, + { + id: 2, + name: 'Jane Smith', + email: 'jane.smith@example.com', + avatarUrl: 'https://i.pravatar.cc/150?img=2', + }, +]; + const handleDelete = itemId => { console.log('Delete item:', itemId); }; @@ -30,7 +45,7 @@ const handleDelete = itemId => { diff --git a/app/javascript/dashboard/components-next/message/chips/Audio.vue b/app/javascript/dashboard/components-next/message/chips/Audio.vue index 431058463..667d2d7b6 100644 --- a/app/javascript/dashboard/components-next/message/chips/Audio.vue +++ b/app/javascript/dashboard/components-next/message/chips/Audio.vue @@ -1,8 +1,16 @@ + + diff --git a/app/javascript/dashboard/components/widgets/conversation/MessagesView.vue b/app/javascript/dashboard/components/widgets/conversation/MessagesView.vue index 1f850cd74..3cb46c05f 100644 --- a/app/javascript/dashboard/components/widgets/conversation/MessagesView.vue +++ b/app/javascript/dashboard/components/widgets/conversation/MessagesView.vue @@ -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'; @@ -437,6 +438,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); + }, }, }; @@ -465,6 +471,7 @@ export default { :is-an-email-channel="isAnEmailChannel" :inbox-supports-reply-to="inboxSupportsReplyTo" :messages="getMessages" + @retry="handleMessageRetry" > diff --git a/app/javascript/dashboard/routes/dashboard/settings/inbox/channels/WhatsappEmbeddedSignup.vue b/app/javascript/dashboard/routes/dashboard/settings/inbox/channels/WhatsappEmbeddedSignup.vue index 583ca2413..cf5c1310e 100644 --- a/app/javascript/dashboard/routes/dashboard/settings/inbox/channels/WhatsappEmbeddedSignup.vue +++ b/app/javascript/dashboard/routes/dashboard/settings/inbox/channels/WhatsappEmbeddedSignup.vue @@ -2,12 +2,13 @@ import { ref, computed, onMounted, onBeforeUnmount } from 'vue'; import { useStore } from 'vuex'; import { useRouter } from 'vue-router'; -import { useI18n } from 'vue-i18n'; +import { useI18n, I18nT } from 'vue-i18n'; import { useAlert } from 'dashboard/composables'; import Icon from 'next/icon/Icon.vue'; import NextButton from 'next/button/Button.vue'; import LoadingState from 'dashboard/components/widgets/LoadingState.vue'; import { parseAPIErrorResponse } from 'dashboard/store/utils/api'; +import globalConstants from 'dashboard/constants/globals.js'; import { setupFacebookSdk, initWhatsAppEmbeddedSignup, @@ -28,9 +29,6 @@ const authCode = ref(null); const businessData = ref(null); const isAuthenticating = ref(false); -// Computed -const whatsappIconPath = '/assets/images/dashboard/channels/whatsapp.png'; - const benefits = computed(() => [ { key: 'EASY_SETUP', @@ -235,14 +233,9 @@ onBeforeUnmount(() => {
- +
@@ -266,22 +259,26 @@ onBeforeUnmount(() => {
- - {{ $t('INBOX_MGMT.ADD.WHATSAPP.EMBEDDED_SIGNUP.LEARN_MORE.TEXT') }} - {{ ' ' }} - - {{ - $t('INBOX_MGMT.ADD.WHATSAPP.EMBEDDED_SIGNUP.LEARN_MORE.LINK_TEXT') - }} - - + + +
diff --git a/app/javascript/dashboard/routes/dashboard/settings/inbox/helpers/timezones.json b/app/javascript/dashboard/routes/dashboard/settings/inbox/helpers/timezones.json index b238add21..810415ffb 100644 --- a/app/javascript/dashboard/routes/dashboard/settings/inbox/helpers/timezones.json +++ b/app/javascript/dashboard/routes/dashboard/settings/inbox/helpers/timezones.json @@ -86,7 +86,7 @@ "Riyadh (GMT+03:00)": "Asia/Riyadh", "Nairobi (GMT+03:00)": "Africa/Nairobi", "Baghdad (GMT+03:00)": "Asia/Baghdad", - "Tehran (GMT+04:30)": "Asia/Tehran", + "Tehran (GMT+03:30)": "Asia/Tehran", "Abu Dhabi (GMT+04:00)": "Asia/Muscat", "Muscat (GMT+04:00)": "Asia/Muscat", "Baku (GMT+04:00)": "Asia/Baku", diff --git a/app/javascript/dashboard/routes/dashboard/settings/profile/Index.vue b/app/javascript/dashboard/routes/dashboard/settings/profile/Index.vue index ce0a480bb..305a6d3ef 100644 --- a/app/javascript/dashboard/routes/dashboard/settings/profile/Index.vue +++ b/app/javascript/dashboard/routes/dashboard/settings/profile/Index.vue @@ -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 { > + + + +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, +}); + + +