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') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ $t('MFA_VERIFICATION.HELP_TEXT') }}
+
+
+
+
+
+
+
+
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,
+});
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ $t('MFA_SETTINGS.MANAGEMENT.BACKUP_CODES') }}
+
+
+
+ {{ $t('MFA_SETTINGS.MANAGEMENT.BACKUP_CODES_DESC') }}
+
+
+
+
+
+
+
+
+
+
+
+ {{ $t('MFA_SETTINGS.MANAGEMENT.DISABLE_MFA') }}
+
+
+
+ {{ $t('MFA_SETTINGS.MANAGEMENT.DISABLE_MFA_DESC') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/javascript/dashboard/routes/dashboard/settings/profile/MfaSettings.vue b/app/javascript/dashboard/routes/dashboard/settings/profile/MfaSettings.vue
new file mode 100644
index 000000000..a647bd0da
--- /dev/null
+++ b/app/javascript/dashboard/routes/dashboard/settings/profile/MfaSettings.vue
@@ -0,0 +1,178 @@
+
+
+
+
+
+
+
+ {{ $t('MFA_SETTINGS.TITLE') }}
+
+
+ {{ $t('MFA_SETTINGS.SUBTITLE') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/javascript/dashboard/routes/dashboard/settings/profile/MfaSettingsCard.vue b/app/javascript/dashboard/routes/dashboard/settings/profile/MfaSettingsCard.vue
new file mode 100644
index 000000000..673f97143
--- /dev/null
+++ b/app/javascript/dashboard/routes/dashboard/settings/profile/MfaSettingsCard.vue
@@ -0,0 +1,47 @@
+
+
+
+
+
+
+
+
+
+ {{ $t('MFA_SETTINGS.TITLE') }}
+
+
+
+ {{ $t('MFA_SETTINGS.DESCRIPTION') }}
+
+
+
+
+
+
diff --git a/app/javascript/dashboard/routes/dashboard/settings/profile/MfaSetupWizard.vue b/app/javascript/dashboard/routes/dashboard/settings/profile/MfaSetupWizard.vue
new file mode 100644
index 000000000..528df7658
--- /dev/null
+++ b/app/javascript/dashboard/routes/dashboard/settings/profile/MfaSetupWizard.vue
@@ -0,0 +1,323 @@
+
+
+
+
+
+
+
+
+
+ {{ $t('MFA_SETTINGS.SETUP.STEP1_TITLE') }}
+
+
+ {{ $t('MFA_SETTINGS.SETUP.STEP1_DESCRIPTION') }}
+
+
+
+
+
![MFA QR Code]()
+
+
+ {{ $t('MFA_SETTINGS.SETUP.LOADING_QR') }}
+
+
+
+
+
+
+
+ {{ $t('MFA_SETTINGS.SETUP.MANUAL_ENTRY') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ $t('MFA_SETTINGS.BACKUP.TITLE') }}
+
+
+ {{ $t('MFA_SETTINGS.BACKUP.DESCRIPTION') }}
+
+
+
+
+
+
+
+ {{ $t('MFA_SETTINGS.BACKUP.IMPORTANT') }}
+ {{ $t('MFA_SETTINGS.BACKUP.IMPORTANT_NOTE') }}
+
+
+
+
+
+
+
+ {{ code }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/javascript/dashboard/routes/dashboard/settings/profile/MfaStatusCard.vue b/app/javascript/dashboard/routes/dashboard/settings/profile/MfaStatusCard.vue
new file mode 100644
index 000000000..3dc22eb4a
--- /dev/null
+++ b/app/javascript/dashboard/routes/dashboard/settings/profile/MfaStatusCard.vue
@@ -0,0 +1,63 @@
+
+
+
+
+
+
+
+ {{ $t('MFA_SETTINGS.ENHANCE_SECURITY') }}
+
+
+ {{ $t('MFA_SETTINGS.ENHANCE_SECURITY_DESC') }}
+
+
+
+
+
+
+
+
+
+ {{ $t('MFA_SETTINGS.STATUS_ENABLED') }}
+
+
+
+ {{ $t('MFA_SETTINGS.STATUS_ENABLED_DESC') }}
+
+
+
+
diff --git a/app/javascript/dashboard/routes/dashboard/settings/profile/profile.routes.js b/app/javascript/dashboard/routes/dashboard/settings/profile/profile.routes.js
index b99031047..ef208eae4 100644
--- a/app/javascript/dashboard/routes/dashboard/settings/profile/profile.routes.js
+++ b/app/javascript/dashboard/routes/dashboard/settings/profile/profile.routes.js
@@ -1,7 +1,9 @@
import { frontendURL } from '../../../../helper/URLHelper';
+import { parseBoolean } from '@chatwoot/utils';
import SettingsContent from './Wrapper.vue';
import Index from './Index.vue';
+import MfaSettings from './MfaSettings.vue';
export default {
routes: [
@@ -21,6 +23,23 @@ export default {
permissions: ['administrator', 'agent', 'custom_role'],
},
},
+ {
+ path: 'mfa',
+ name: 'profile_settings_mfa',
+ component: MfaSettings,
+ meta: {
+ permissions: ['administrator', 'agent', 'custom_role'],
+ },
+ beforeEnter: (to, from, next) => {
+ // Check if MFA is enabled globally
+ if (!parseBoolean(window.chatwootConfig?.isMfaEnabled)) {
+ // Redirect to profile settings if MFA is disabled
+ next({ name: 'profile_settings_index' });
+ } else {
+ next();
+ }
+ },
+ },
],
},
],
diff --git a/app/javascript/shared/helpers/clipboard.js b/app/javascript/shared/helpers/clipboard.js
index dcd25afae..3b66c39ac 100644
--- a/app/javascript/shared/helpers/clipboard.js
+++ b/app/javascript/shared/helpers/clipboard.js
@@ -17,3 +17,22 @@ export const copyTextToClipboard = async data => {
throw new Error(`Unable to copy text to clipboard: ${error.message}`);
}
};
+
+/**
+ * Handles OTP paste events by extracting numeric digits from clipboard data.
+ *
+ * @param {ClipboardEvent} event - The paste event from the clipboard
+ * @param {number} maxLength - Maximum number of digits to extract (default: 6)
+ * @returns {string|null} - Extracted numeric string or null if invalid
+ */
+export const handleOtpPaste = (event, maxLength = 6) => {
+ if (!event?.clipboardData) return null;
+
+ const pastedData = event.clipboardData
+ .getData('text')
+ .replace(/\D/g, '') // Remove all non-digit characters
+ .slice(0, maxLength); // Limit to maxLength digits
+
+ // Only return if we have the exact expected length
+ return pastedData.length === maxLength ? pastedData : null;
+};
diff --git a/app/javascript/shared/helpers/specs/clipboard.spec.js b/app/javascript/shared/helpers/specs/clipboard.spec.js
index c675edd35..a169e0939 100644
--- a/app/javascript/shared/helpers/specs/clipboard.spec.js
+++ b/app/javascript/shared/helpers/specs/clipboard.spec.js
@@ -1,4 +1,4 @@
-import { copyTextToClipboard } from '../clipboard';
+import { copyTextToClipboard, handleOtpPaste } from '../clipboard';
const mockWriteText = vi.fn();
Object.assign(navigator, {
@@ -172,3 +172,113 @@ describe('copyTextToClipboard', () => {
});
});
});
+
+describe('handleOtpPaste', () => {
+ // Helper function to create mock clipboard event
+ const createMockPasteEvent = text => ({
+ clipboardData: {
+ getData: vi.fn().mockReturnValue(text),
+ },
+ });
+
+ describe('valid OTP paste scenarios', () => {
+ it('extracts 6-digit OTP from clean numeric string', () => {
+ const event = createMockPasteEvent('123456');
+ const result = handleOtpPaste(event);
+
+ expect(result).toBe('123456');
+ expect(event.clipboardData.getData).toHaveBeenCalledWith('text');
+ });
+
+ it('extracts 6-digit OTP from string with spaces', () => {
+ const event = createMockPasteEvent('1 2 3 4 5 6');
+ const result = handleOtpPaste(event);
+
+ expect(result).toBe('123456');
+ });
+
+ it('extracts 6-digit OTP from string with dashes', () => {
+ const event = createMockPasteEvent('123-456');
+ const result = handleOtpPaste(event);
+
+ expect(result).toBe('123456');
+ });
+
+ it('handles negative numbers by extracting digits only', () => {
+ const event = createMockPasteEvent('-123456');
+ const result = handleOtpPaste(event);
+
+ expect(result).toBe('123456');
+ });
+
+ it('handles decimal numbers by extracting digits only', () => {
+ const event = createMockPasteEvent('123.456');
+ const result = handleOtpPaste(event);
+
+ expect(result).toBe('123456');
+ });
+
+ it('extracts 6-digit OTP from mixed alphanumeric string', () => {
+ const event = createMockPasteEvent('Your code is: 987654');
+ const result = handleOtpPaste(event);
+
+ expect(result).toBe('987654');
+ });
+
+ it('extracts first 6 digits when more than 6 digits present', () => {
+ const event = createMockPasteEvent('12345678901234');
+ const result = handleOtpPaste(event);
+
+ expect(result).toBe('123456');
+ });
+
+ it('handles custom maxLength parameter', () => {
+ const event = createMockPasteEvent('12345678');
+ const result = handleOtpPaste(event, 8);
+
+ expect(result).toBe('12345678');
+ });
+
+ it('extracts 4-digit OTP with custom maxLength', () => {
+ const event = createMockPasteEvent('Your PIN: 9876');
+ const result = handleOtpPaste(event, 4);
+
+ expect(result).toBe('9876');
+ });
+ });
+
+ describe('invalid OTP paste scenarios', () => {
+ it('returns null for insufficient digits', () => {
+ const event = createMockPasteEvent('12345');
+ const result = handleOtpPaste(event);
+
+ expect(result).toBeNull();
+ });
+
+ it('returns null for text with no digits', () => {
+ const event = createMockPasteEvent('Hello World');
+ const result = handleOtpPaste(event);
+
+ expect(result).toBeNull();
+ });
+
+ it('returns null for empty string', () => {
+ const event = createMockPasteEvent('');
+ const result = handleOtpPaste(event);
+
+ expect(result).toBeNull();
+ });
+
+ it('returns null when event is null', () => {
+ const result = handleOtpPaste(null);
+
+ expect(result).toBeNull();
+ });
+
+ it('returns null when event is undefined', () => {
+ const result = handleOtpPaste(undefined);
+
+ expect(result).toBeNull();
+ });
+ });
+});
diff --git a/app/javascript/v3/App.vue b/app/javascript/v3/App.vue
index 992c0b381..ef7107beb 100644
--- a/app/javascript/v3/App.vue
+++ b/app/javascript/v3/App.vue
@@ -15,8 +15,10 @@ export default {
setColorTheme() {
if (window.matchMedia('(prefers-color-scheme: dark)').matches) {
this.theme = 'dark';
+ document.documentElement.classList.add('dark');
} else {
- this.theme = 'light ';
+ this.theme = 'light';
+ document.documentElement.classList.remove('dark');
}
},
listenToThemeChanges() {
@@ -25,8 +27,10 @@ export default {
mql.onchange = e => {
if (e.matches) {
this.theme = 'dark';
+ document.documentElement.classList.add('dark');
} else {
this.theme = 'light';
+ document.documentElement.classList.remove('dark');
}
};
},
diff --git a/app/javascript/v3/api/auth.js b/app/javascript/v3/api/auth.js
index a4793d3d0..146db4e07 100644
--- a/app/javascript/v3/api/auth.js
+++ b/app/javascript/v3/api/auth.js
@@ -13,6 +13,16 @@ export const login = async ({
}) => {
try {
const response = await wootAPI.post('auth/sign_in', credentials);
+
+ // Check if MFA is required
+ if (response.status === 206 && response.data.mfa_required) {
+ // Return MFA data instead of throwing error
+ return {
+ mfaRequired: true,
+ mfaToken: response.data.mfa_token,
+ };
+ }
+
setAuthCredentials(response);
clearLocalStorageOnLogout();
window.location = getLoginRedirectURL({
@@ -20,8 +30,17 @@ export const login = async ({
ssoConversationId,
user: response.data.data,
});
+ return null;
} catch (error) {
+ // Check if it's an MFA required response
+ if (error.response?.status === 206 && error.response?.data?.mfa_required) {
+ return {
+ mfaRequired: true,
+ mfaToken: error.response.data.mfa_token,
+ };
+ }
throwErrorMessage(error);
+ return null;
}
};
diff --git a/app/javascript/v3/views/login/Index.vue b/app/javascript/v3/views/login/Index.vue
index 4a51b3059..ff8022714 100644
--- a/app/javascript/v3/views/login/Index.vue
+++ b/app/javascript/v3/views/login/Index.vue
@@ -15,6 +15,7 @@ import FormInput from '../../components/Form/Input.vue';
import GoogleOAuthButton from '../../components/GoogleOauth/Button.vue';
import Spinner from 'shared/components/Spinner.vue';
import NextButton from 'dashboard/components-next/button/Button.vue';
+import MfaVerification from 'dashboard/components/auth/MfaVerification.vue';
const ERROR_MESSAGES = {
'no-account-found': 'LOGIN.OAUTH.NO_ACCOUNT_FOUND',
@@ -29,6 +30,7 @@ export default {
GoogleOAuthButton,
Spinner,
NextButton,
+ MfaVerification,
},
props: {
ssoAuthToken: { type: String, default: '' },
@@ -58,6 +60,8 @@ export default {
hasErrored: false,
},
error: '',
+ mfaRequired: false,
+ mfaToken: null,
};
},
validations() {
@@ -87,8 +91,10 @@ export default {
this.submitLogin();
}
if (this.authError) {
- const message = ERROR_MESSAGES[this.authError] ?? 'LOGIN.API.UNAUTH';
- useAlert(this.$t(message));
+ const messageKey = ERROR_MESSAGES[this.authError] ?? 'LOGIN.API.UNAUTH';
+ // Use a method to get the translated text to avoid dynamic key warning
+ const translatedMessage = this.getTranslatedMessage(messageKey);
+ useAlert(translatedMessage);
// wait for idle state
this.requestIdleCallbackPolyfill(() => {
// Remove the error query param from the url
@@ -98,6 +104,18 @@ export default {
}
},
methods: {
+ getTranslatedMessage(key) {
+ // Avoid dynamic key warning by handling each case explicitly
+ switch (key) {
+ case 'LOGIN.OAUTH.NO_ACCOUNT_FOUND':
+ return this.$t('LOGIN.OAUTH.NO_ACCOUNT_FOUND');
+ case 'LOGIN.OAUTH.BUSINESS_ACCOUNTS_ONLY':
+ return this.$t('LOGIN.OAUTH.BUSINESS_ACCOUNTS_ONLY');
+ case 'LOGIN.API.UNAUTH':
+ default:
+ return this.$t('LOGIN.API.UNAUTH');
+ }
+ },
// TODO: Remove this when Safari gets wider support
// Ref: https://caniuse.com/requestidlecallback
//
@@ -140,7 +158,15 @@ export default {
};
login(credentials)
- .then(() => {
+ .then(result => {
+ // Check if MFA is required
+ if (result?.mfaRequired) {
+ this.loginApi.showLoading = false;
+ this.mfaRequired = true;
+ this.mfaToken = result.mfaToken;
+ return;
+ }
+
this.handleImpersonation();
this.showAlertMessage(this.$t('LOGIN.API.SUCCESS_MESSAGE'));
})
@@ -163,6 +189,17 @@ export default {
this.submitLogin();
},
+ handleMfaVerified() {
+ // MFA verification successful, continue with login
+ this.handleImpersonation();
+ window.location = '/app';
+ },
+ handleMfaCancel() {
+ // User cancelled MFA, reset state
+ this.mfaRequired = false;
+ this.mfaToken = null;
+ this.credentials.password = '';
+ },
},
};
@@ -193,7 +230,19 @@ export default {
+
+
+
+
+