From 4014a846f0c94159243ca207cbdf91f1fe52bda5 Mon Sep 17 00:00:00 2001 From: Tanmay Deep Sharma <32020192+tds-1@users.noreply.github.com> Date: Thu, 18 Sep 2025 17:46:06 +0200 Subject: [PATCH] feat: Add the frontend support for MFA (#12372) FE support for https://github.com/chatwoot/chatwoot/pull/12290 ## Linear: - https://github.com/chatwoot/chatwoot/issues/486 ## Description This PR implements Multi-Factor Authentication (MFA) support for user accounts, enhancing security by requiring a second form of verification during login. The feature adds TOTP (Time-based One-Time Password) authentication with QR code generation and backup codes for account recovery. ## Type of change - [ ] New feature (non-breaking change which adds functionality) ## How Has This Been Tested? - Added comprehensive RSpec tests for MFA controller functionality - Tested MFA setup flow with QR code generation - Verified OTP validation and backup code generation - Tested login flow with MFA enabled/disabled ## Checklist: - [ ] My code follows the style guidelines of this project - [ ] I have performed a self-review of my code - [ ] I have commented on my code, particularly in hard-to-understand areas - [ ] I have made corresponding changes to the documentation - [ ] My changes generate no new warnings - [ ] I have added tests that prove my fix is effective or that my feature works - [ ] New and existing unit tests pass locally with my changes - [ ] Any dependent changes have been merged and published in downstream modules --------- Co-authored-by: Pranav Co-authored-by: iamsivin Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com> Co-authored-by: Muhsin Keloth Co-authored-by: Sojan Jose --- app/javascript/dashboard/api/mfa.js | 28 ++ .../dashboard/components-next/input/Input.vue | 1 + .../components/auth/MfaVerification.vue | 328 ++++++++++++++++++ .../dashboard/i18n/locale/en/index.js | 2 + .../dashboard/i18n/locale/en/mfa.json | 106 ++++++ .../dashboard/i18n/locale/en/settings.json | 5 + .../dashboard/settings/profile/Index.vue | 13 + .../settings/profile/MfaManagementActions.vue | 248 +++++++++++++ .../settings/profile/MfaSettings.vue | 178 ++++++++++ .../settings/profile/MfaSettingsCard.vue | 47 +++ .../settings/profile/MfaSetupWizard.vue | 323 +++++++++++++++++ .../settings/profile/MfaStatusCard.vue | 63 ++++ .../settings/profile/profile.routes.js | 19 + app/javascript/shared/helpers/clipboard.js | 19 + .../shared/helpers/specs/clipboard.spec.js | 112 +++++- app/javascript/v3/App.vue | 6 +- app/javascript/v3/api/auth.js | 19 + app/javascript/v3/views/login/Index.vue | 55 ++- app/views/layouts/vueapp.html.erb | 1 + 19 files changed, 1568 insertions(+), 5 deletions(-) create mode 100644 app/javascript/dashboard/api/mfa.js create mode 100644 app/javascript/dashboard/components/auth/MfaVerification.vue create mode 100644 app/javascript/dashboard/i18n/locale/en/mfa.json create mode 100644 app/javascript/dashboard/routes/dashboard/settings/profile/MfaManagementActions.vue create mode 100644 app/javascript/dashboard/routes/dashboard/settings/profile/MfaSettings.vue create mode 100644 app/javascript/dashboard/routes/dashboard/settings/profile/MfaSettingsCard.vue create mode 100644 app/javascript/dashboard/routes/dashboard/settings/profile/MfaSetupWizard.vue create mode 100644 app/javascript/dashboard/routes/dashboard/settings/profile/MfaStatusCard.vue 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/input/Input.vue b/app/javascript/dashboard/components-next/input/Input.vue index 71964b4f8..561f98ffe 100644 --- a/app/javascript/dashboard/components-next/input/Input.vue +++ b/app/javascript/dashboard/components-next/input/Input.vue @@ -116,6 +116,7 @@ onMounted(() => { +
+ +
+
+ +
+

+ {{ $t('MFA_VERIFICATION.TITLE') }} +

+

+ {{ $t('MFA_VERIFICATION.DESCRIPTION') }} +

+
+ + +
+ +
+ + +
+ +
+ +
+ +
+
+ + +
+ +
+ + +
+

{{ errorMessage }}

+
+ + + + + +
+ + +
+ +
+ + +
+

+ {{ $t('MFA_VERIFICATION.HELP_TEXT') }} +

+ +
+ + + +
+
+

+ {{ $t(`MFA_VERIFICATION.HELP_MODAL.${section}_TITLE`) }} +

+

{{ $t(`MFA_VERIFICATION.HELP_MODAL.${section}_DESC`) }}

+
+
+

+ {{ $t('MFA_VERIFICATION.HELP_MODAL.CONTACT_TITLE') }} +

+

{{ $t(`MFA_VERIFICATION.HELP_MODAL.${contactDescKey}`) }}

+
+
+
+ + 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/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/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, +}); + + +