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 <pranav@chatwoot.com>
Co-authored-by: iamsivin <iamsivin@gmail.com>
Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com>
Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
Co-authored-by: Sojan Jose <sojan@pepalo.com>
This commit is contained in:
Tanmay Deep Sharma
2025-09-18 17:46:06 +02:00
committed by GitHub
parent 239c4dcb91
commit 4014a846f0
19 changed files with 1568 additions and 5 deletions

View File

@@ -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();

View File

@@ -116,6 +116,7 @@ onMounted(() => {
<slot name="prefix" />
<input
:id="uniqueId"
v-bind="$attrs"
ref="inputRef"
:value="modelValue"
:class="[

View File

@@ -0,0 +1,328 @@
<script setup>
import axios from 'axios';
import { ref, computed, nextTick } from 'vue';
import { useI18n } from 'vue-i18n';
import { handleOtpPaste } from 'shared/helpers/clipboard';
import { parseAPIErrorResponse } from 'dashboard/store/utils/api';
import { useAccount } from 'dashboard/composables/useAccount';
import Icon from 'dashboard/components-next/icon/Icon.vue';
import FormInput from 'v3/components/Form/Input.vue';
import NextButton from 'dashboard/components-next/button/Button.vue';
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
const props = defineProps({
mfaToken: {
type: String,
required: true,
},
});
const emit = defineEmits(['verified', 'cancel']);
const { t } = useI18n();
const { isOnChatwootCloud } = useAccount();
const OTP = 'otp';
const BACKUP = 'backup';
// State
const verificationMethod = ref(OTP);
const otpDigits = ref(['', '', '', '', '', '']);
const backupCode = ref('');
const isVerifying = ref(false);
const errorMessage = ref('');
const helpModalRef = ref(null);
const otpInputRefs = ref([]);
// Computed
const otpCode = computed(() => otpDigits.value.join(''));
const canSubmit = computed(() =>
verificationMethod.value === OTP
? otpCode.value.length === 6
: backupCode.value.length === 8
);
const contactDescKey = computed(() =>
isOnChatwootCloud.value ? 'CONTACT_DESC_CLOUD' : 'CONTACT_DESC_SELF_HOSTED'
);
const focusInput = i => otpInputRefs.value[i]?.focus();
// Verification
const handleVerification = async () => {
if (!canSubmit.value || isVerifying.value) return;
isVerifying.value = true;
errorMessage.value = '';
try {
const payload = {
mfa_token: props.mfaToken,
};
if (verificationMethod.value === OTP) {
payload.otp_code = otpCode.value;
} else {
payload.backup_code = backupCode.value;
}
const response = await axios.post('/auth/sign_in', payload);
// Set auth credentials and redirect
if (response.data && response.headers) {
// Store auth credentials in cookies
const authData = {
'access-token': response.headers['access-token'],
'token-type': response.headers['token-type'],
client: response.headers.client,
expiry: response.headers.expiry,
uid: response.headers.uid,
};
// Store in cookies for auth
document.cookie = `cw_d_session_info=${encodeURIComponent(JSON.stringify(authData))}; path=/; SameSite=Lax`;
// Redirect to dashboard
window.location.href = '/app/';
} else {
emit('verified', response.data);
}
} catch (error) {
errorMessage.value =
parseAPIErrorResponse(error) || t('MFA_VERIFICATION.VERIFICATION_FAILED');
// Clear inputs on error
if (verificationMethod.value === OTP) {
otpDigits.value.fill('');
await nextTick();
focusInput(0);
} else {
backupCode.value = '';
}
} finally {
isVerifying.value = false;
}
};
// OTP Input Handling
const handleOtpInput = async i => {
const v = otpDigits.value[i];
// Only allow numbers
if (!/^\d*$/.test(v)) {
otpDigits.value[i] = '';
return;
}
// Move to next input if value entered
if (v && i < 5) {
await nextTick();
focusInput(i + 1);
}
// Auto-submit if all digits entered
if (otpCode.value.length === 6) {
handleVerification();
}
};
const handleBackspace = (e, i) => {
if (!otpDigits.value[i] && i > 0) {
e.preventDefault();
focusInput(i - 1);
otpDigits.value[i - 1] = '';
}
};
const handleOtpCodePaste = e => {
e.preventDefault();
const code = handleOtpPaste(e, 6);
if (code) {
otpDigits.value = code.split('');
handleVerification();
}
};
// Alternative Actions
const handleTryAnotherMethod = () => {
// Toggle between methods
verificationMethod.value = verificationMethod.value === OTP ? BACKUP : OTP;
otpDigits.value.fill('');
backupCode.value = '';
errorMessage.value = '';
};
</script>
<template>
<div class="w-full max-w-md mx-auto">
<div
class="bg-white shadow sm:mx-auto sm:w-full sm:max-w-lg dark:bg-n-solid-2 p-11 sm:shadow-lg sm:rounded-lg"
>
<!-- Header -->
<div class="text-center mb-6">
<div
class="inline-flex items-center justify-center size-14 bg-n-solid-1 outline outline-n-weak rounded-full mb-4"
>
<Icon icon="i-lucide-lock-keyhole" class="size-6 text-n-slate-10" />
</div>
<h2 class="text-2xl font-semibold text-n-slate-12">
{{ $t('MFA_VERIFICATION.TITLE') }}
</h2>
<p class="text-sm text-n-slate-11 mt-2">
{{ $t('MFA_VERIFICATION.DESCRIPTION') }}
</p>
</div>
<!-- Tab Selection -->
<div class="flex rounded-lg bg-n-alpha-black2 p-1 mb-6">
<button
v-for="method in [OTP, BACKUP]"
:key="method"
class="flex-1 py-2 px-4 text-sm font-medium rounded-md transition-colors"
:class="
verificationMethod === method
? 'bg-n-solid-active text-n-slate-12 shadow-sm'
: 'text-n-slate-12'
"
@click="verificationMethod = method"
>
{{
$t(
`MFA_VERIFICATION.${method === OTP ? 'AUTHENTICATOR_APP' : 'BACKUP_CODE'}`
)
}}
</button>
</div>
<!-- Verification Form -->
<form class="space-y-4" @submit.prevent="handleVerification">
<!-- OTP Code Input -->
<div v-if="verificationMethod === OTP">
<label class="block text-sm font-medium text-n-slate-12 mb-2">
{{ $t('MFA_VERIFICATION.ENTER_OTP_CODE') }}
</label>
<div class="flex justify-between gap-2">
<input
v-for="(_, i) in otpDigits"
:key="i"
ref="otpInputRefs"
v-model="otpDigits[i]"
type="text"
maxlength="1"
pattern="[0-9]"
inputmode="numeric"
class="w-12 h-12 text-center text-lg font-semibold border-2 border-n-weak hover:border-n-strong rounded-lg focus:border-n-brand bg-n-alpha-black2 text-n-slate-12 placeholder:text-n-slate-10"
@input="handleOtpInput(i)"
@keydown.left.prevent="focusInput(i - 1)"
@keydown.right.prevent="focusInput(i + 1)"
@keydown.backspace="handleBackspace($event, i)"
@paste="handleOtpCodePaste"
/>
</div>
</div>
<!-- Backup Code Input -->
<div v-if="verificationMethod === BACKUP">
<FormInput
v-model="backupCode"
name="backup_code"
type="text"
data-testid="backup_code_input"
:tabindex="1"
required
:label="$t('MFA_VERIFICATION.ENTER_BACKUP_CODE')"
:placeholder="
$t('MFA_VERIFICATION.BACKUP_CODE_PLACEHOLDER') || '000000'
"
@keyup.enter="handleVerification"
/>
</div>
<!-- Error Message -->
<div
v-if="errorMessage"
class="p-3 bg-n-ruby-3 outline outline-n-ruby-5 outline-1 rounded-lg"
>
<p class="text-sm text-n-ruby-9">{{ errorMessage }}</p>
</div>
<!-- Submit Button -->
<NextButton
lg
type="submit"
data-testid="submit_button"
class="w-full"
:tabindex="2"
:label="$t('MFA_VERIFICATION.VERIFY_BUTTON')"
:disabled="!canSubmit || isVerifying"
:is-loading="isVerifying"
/>
<!-- Alternative Actions -->
<div class="text-center flex items-center flex-col gap-2 pt-4">
<NextButton
sm
link
type="button"
class="w-full hover:!no-underline"
:tabindex="2"
:label="$t('MFA_VERIFICATION.TRY_ANOTHER_METHOD')"
@click="handleTryAnotherMethod"
/>
<NextButton
sm
slate
link
type="button"
class="w-full hover:!no-underline"
:tabindex="3"
:label="$t('MFA_VERIFICATION.CANCEL_LOGIN')"
@click="() => emit('cancel')"
/>
</div>
</form>
</div>
<!-- Help Text -->
<div class="mt-6 text-center">
<p class="text-sm text-n-slate-11">
{{ $t('MFA_VERIFICATION.HELP_TEXT') }}
</p>
<NextButton
sm
link
type="button"
class="w-full hover:!no-underline"
:tabindex="4"
:label="$t('MFA_VERIFICATION.LEARN_MORE')"
@click="helpModalRef?.open()"
/>
</div>
<!-- Help Modal -->
<Dialog
ref="helpModalRef"
:title="$t('MFA_VERIFICATION.HELP_MODAL.TITLE')"
:show-confirm-button="false"
class="[&>dialog>div]:bg-n-alpha-3 [&>dialog>div]:rounded-lg"
@confirm="helpModalRef?.close()"
>
<div class="space-y-4 text-sm text-n-slate-11">
<div v-for="section in ['AUTHENTICATOR', 'BACKUP']" :key="section">
<h4 class="font-medium text-n-slate-12 mb-2">
{{ $t(`MFA_VERIFICATION.HELP_MODAL.${section}_TITLE`) }}
</h4>
<p>{{ $t(`MFA_VERIFICATION.HELP_MODAL.${section}_DESC`) }}</p>
</div>
<div>
<h4 class="font-medium text-n-slate-12 mb-2">
{{ $t('MFA_VERIFICATION.HELP_MODAL.CONTACT_TITLE') }}
</h4>
<p>{{ $t(`MFA_VERIFICATION.HELP_MODAL.${contactDescKey}`) }}</p>
</div>
</div>
</Dialog>
</div>
</template>

View File

@@ -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,
};

View File

@@ -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."
}
}

View File

@@ -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",

View File

@@ -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 {
>
<ChangePassword />
</FormSection>
<FormSection
v-if="isMfaEnabled"
:title="$t('PROFILE_SETTINGS.FORM.SECURITY_SECTION.TITLE')"
:description="$t('PROFILE_SETTINGS.FORM.SECURITY_SECTION.NOTE')"
>
<MfaSettingsCard />
</FormSection>
<Policy :permissions="audioNotificationPermissions">
<FormSection
:title="$t('PROFILE_SETTINGS.FORM.AUDIO_NOTIFICATIONS_SECTION.TITLE')"

View File

@@ -0,0 +1,248 @@
<script setup>
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,
});
</script>
<template>
<div v-if="mfaEnabled">
<!-- Actions Grid -->
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<!-- Regenerate Backup Codes -->
<div class="bg-n-solid-1 rounded-xl outline-1 outline-n-weak outline p-5">
<div class="flex-1 flex flex-col gap-2">
<div class="flex items-center gap-2">
<Icon
icon="i-lucide-key"
class="size-4 flex-shrink-0 text-n-slate-11"
/>
<h4 class="font-medium text-n-slate-12">
{{ $t('MFA_SETTINGS.MANAGEMENT.BACKUP_CODES') }}
</h4>
</div>
<p class="text-sm text-n-slate-11">
{{ $t('MFA_SETTINGS.MANAGEMENT.BACKUP_CODES_DESC') }}
</p>
<Button
faded
slate
:label="$t('MFA_SETTINGS.MANAGEMENT.REGENERATE')"
@click="regenerateDialogRef?.open()"
/>
</div>
</div>
<!-- Disable MFA -->
<div class="bg-n-solid-1 rounded-xl outline-1 outline-n-weak outline p-5">
<div class="flex-1 flex flex-col gap-2">
<div class="flex items-center gap-2">
<Icon
icon="i-lucide-lock-keyhole-open"
class="size-4 flex-shrink-0 text-n-slate-11"
/>
<h4 class="font-medium text-n-slate-12">
{{ $t('MFA_SETTINGS.MANAGEMENT.DISABLE_MFA') }}
</h4>
</div>
<p class="text-sm text-n-slate-11">
{{ $t('MFA_SETTINGS.MANAGEMENT.DISABLE_MFA_DESC') }}
</p>
<Button
faded
ruby
:label="$t('MFA_SETTINGS.MANAGEMENT.DISABLE_BUTTON')"
@click="disableDialogRef?.open()"
/>
</div>
</div>
</div>
<!-- Disable MFA Dialog -->
<Dialog
ref="disableDialogRef"
type="alert"
:title="$t('MFA_SETTINGS.DISABLE.TITLE')"
:description="$t('MFA_SETTINGS.DISABLE.DESCRIPTION')"
:confirm-button-label="$t('MFA_SETTINGS.DISABLE.CONFIRM')"
:cancel-button-label="$t('MFA_SETTINGS.DISABLE.CANCEL')"
@confirm="handleDisableMfa"
>
<div class="space-y-4">
<Input
v-model="disablePassword"
type="password"
:label="$t('MFA_SETTINGS.DISABLE.PASSWORD')"
/>
<Input
v-model="disableOtpCode"
type="text"
maxlength="6"
:label="$t('MFA_SETTINGS.DISABLE.OTP_CODE')"
:placeholder="$t('MFA_SETTINGS.DISABLE.OTP_CODE_PLACEHOLDER')"
/>
</div>
</Dialog>
<!-- Regenerate Backup Codes Dialog -->
<Dialog
ref="regenerateDialogRef"
type="edit"
:title="$t('MFA_SETTINGS.REGENERATE.TITLE')"
:description="$t('MFA_SETTINGS.REGENERATE.DESCRIPTION')"
:confirm-button-label="$t('MFA_SETTINGS.REGENERATE.CONFIRM')"
:cancel-button-label="$t('MFA_SETTINGS.DISABLE.CANCEL')"
@confirm="handleRegenerateBackupCodes"
>
<Input
v-model="regenerateOtpCode"
type="text"
maxlength="6"
:label="$t('MFA_SETTINGS.REGENERATE.OTP_CODE')"
:placeholder="$t('MFA_SETTINGS.REGENERATE.OTP_CODE_PLACEHOLDER')"
/>
</Dialog>
<!-- Backup Codes Display Dialog -->
<Dialog
ref="backupCodesDialogRef"
type="edit"
width="2xl"
:title="$t('MFA_SETTINGS.REGENERATE.NEW_CODES_TITLE')"
:description="$t('MFA_SETTINGS.REGENERATE.NEW_CODES_DESC')"
:show-cancel-button="false"
:confirm-button-label="$t('MFA_SETTINGS.REGENERATE.CODES_SAVED')"
@confirm="backupCodesDialogRef?.close()"
>
<!-- Warning Alert -->
<div
class="flex items-start gap-2 p-4 bg-n-solid-1 outline outline-n-weak rounded-xl outline-1"
>
<Icon
icon="i-lucide-alert-circle"
class="size-4 text-n-slate-10 flex-shrink-0 mt-0.5"
/>
<p class="text-sm text-n-slate-11">
<strong>{{ $t('MFA_SETTINGS.BACKUP.IMPORTANT') }}</strong>
{{ $t('MFA_SETTINGS.BACKUP.IMPORTANT_NOTE') }}
</p>
</div>
<div
class="bg-n-solid-1 rounded-xl outline-1 outline-n-weak outline flex flex-col gap-6 p-6"
>
<div class="grid grid-cols-2 xs:grid-cols-4 sm:grid-cols-5 gap-3">
<span
v-for="(code, index) in backupCodes"
:key="index"
class="px-1 py-2 font-mono text-base text-center text-n-slate-12"
>
{{ code }}
</span>
</div>
<div class="flex items-center justify-center gap-3">
<Button
outline
slate
sm
icon="i-lucide-download"
:label="$t('MFA_SETTINGS.BACKUP.DOWNLOAD')"
@click="downloadBackupCodes"
/>
<Button
outline
slate
sm
icon="i-lucide-clipboard"
:label="$t('MFA_SETTINGS.BACKUP.COPY_ALL')"
@click="copyBackupCodes"
/>
</div>
</div>
</Dialog>
</div>
<template v-else />
</template>

View File

@@ -0,0 +1,178 @@
<script setup>
import { ref, onMounted } from 'vue';
import { useI18n } from 'vue-i18n';
import { useRouter, useRoute } from 'vue-router';
import { parseBoolean } from '@chatwoot/utils';
import mfaAPI from 'dashboard/api/mfa';
import { useAlert } from 'dashboard/composables';
import MfaStatusCard from './MfaStatusCard.vue';
import MfaSetupWizard from './MfaSetupWizard.vue';
import MfaManagementActions from './MfaManagementActions.vue';
const { t } = useI18n();
const router = useRouter();
const route = useRoute();
// State
const mfaEnabled = ref(false);
const backupCodesGenerated = ref(false);
const showSetup = ref(false);
const provisioningUri = ref('');
const qrCodeUrl = ref('');
const secretKey = ref('');
const backupCodes = ref([]);
// Component refs
const setupWizardRef = ref(null);
const managementActionsRef = ref(null);
// Load MFA status on mount
onMounted(async () => {
// Check if MFA is enabled globally
if (!parseBoolean(window.chatwootConfig?.isMfaEnabled)) {
// Redirect to profile settings if MFA is disabled
router.push({
name: 'profile_settings_index',
params: {
accountId: route.params.accountId,
},
});
return;
}
try {
const response = await mfaAPI.get();
mfaEnabled.value = response.data.enabled;
backupCodesGenerated.value = response.data.backup_codes_generated;
} catch (error) {
// Handle error silently
}
});
// Start MFA setup
const startMfaSetup = async () => {
try {
const response = await mfaAPI.enable();
// Store the provisioning URI
provisioningUri.value =
response.data.provisioning_uri || response.data.provisioning_url;
// Store QR code URL if provided by backend
if (response.data.qr_code_url) {
qrCodeUrl.value = response.data.qr_code_url;
}
secretKey.value = response.data.secret;
// Backup codes are now generated after verification, not during enable
backupCodes.value = [];
showSetup.value = true;
} catch (error) {
useAlert(t('MFA_SETTINGS.SETUP.ERROR_STARTING'));
}
};
// Verify OTP code
const verifyCode = async verificationCode => {
try {
const response = await mfaAPI.verify(verificationCode);
// Store backup codes returned from verification
if (response.data.backup_codes) {
backupCodes.value = response.data.backup_codes;
}
return true;
} catch (error) {
setupWizardRef.value?.handleVerificationError(
error.response?.data?.error || t('MFA_SETTINGS.SETUP.INVALID_CODE')
);
throw error;
}
};
// Complete MFA setup
const completeMfaSetup = () => {
mfaEnabled.value = true;
backupCodesGenerated.value = true;
showSetup.value = false;
useAlert(t('MFA_SETTINGS.SETUP.SUCCESS'));
};
// Cancel setup
const cancelSetup = () => {
showSetup.value = false;
};
// Disable MFA
const disableMfa = async ({ password, otpCode }) => {
try {
await mfaAPI.disable(password, otpCode);
mfaEnabled.value = false;
backupCodesGenerated.value = false;
managementActionsRef.value?.resetDisableForm();
useAlert(t('MFA_SETTINGS.DISABLE.SUCCESS'));
} catch (error) {
useAlert(t('MFA_SETTINGS.DISABLE.ERROR'));
}
};
// Regenerate backup codes
const regenerateBackupCodes = async ({ otpCode }) => {
try {
const response = await mfaAPI.regenerateBackupCodes(otpCode);
backupCodes.value = response.data.backup_codes;
managementActionsRef.value?.resetRegenerateForm();
managementActionsRef.value?.showBackupCodesDialog();
useAlert(t('MFA_SETTINGS.REGENERATE.SUCCESS'));
} catch (error) {
useAlert(t('MFA_SETTINGS.REGENERATE.ERROR'));
}
};
</script>
<template>
<div
class="grid py-16 px-5 font-inter mx-auto gap-16 sm:max-w-screen-md w-full"
>
<!-- Page Header -->
<div class="flex flex-col gap-6">
<h2 class="text-2xl font-medium text-n-slate-12">
{{ $t('MFA_SETTINGS.TITLE') }}
</h2>
<p class="text-sm text-n-slate-11">
{{ $t('MFA_SETTINGS.SUBTITLE') }}
</p>
</div>
<div class="grid gap-4 w-full">
<!-- MFA Status Card -->
<MfaStatusCard
:mfa-enabled="mfaEnabled"
:show-setup="showSetup"
@enable-mfa="startMfaSetup"
/>
<!-- MFA Setup Wizard -->
<MfaSetupWizard
ref="setupWizardRef"
:show-setup="showSetup"
:mfa-enabled="mfaEnabled"
:provisioning-uri="provisioningUri"
:secret-key="secretKey"
:backup-codes="backupCodes"
:qr-code-url-prop="qrCodeUrl"
@cancel="cancelSetup"
@verify="verifyCode"
@complete="completeMfaSetup"
/>
<!-- MFA Management Actions -->
<MfaManagementActions
ref="managementActionsRef"
:mfa-enabled="mfaEnabled"
:backup-codes="backupCodes"
@disable-mfa="disableMfa"
@regenerate-backup-codes="regenerateBackupCodes"
/>
</div>
</div>
</template>

View File

@@ -0,0 +1,47 @@
<script setup>
import { useRouter, useRoute } from 'vue-router';
import Button from 'dashboard/components-next/button/Button.vue';
import Icon from 'dashboard/components-next/icon/Icon.vue';
const router = useRouter();
const route = useRoute();
const navigateToMfa = () => {
router.push({
name: 'profile_settings_mfa',
params: {
accountId: route.params.accountId,
},
});
};
</script>
<template>
<div class="bg-n-background rounded-xl p-4 border border-n-slate-4">
<div class="flex flex-col xs:flex-row items-center justify-between gap-4">
<div class="flex flex-col items-start gap-1.5">
<div class="flex items-center gap-2">
<Icon
icon="i-lucide-lock-keyhole"
class="size-4 text-n-slate-10 flex-shrink-0"
/>
<h5 class="text-sm font-semibold text-n-slate-12">
{{ $t('MFA_SETTINGS.TITLE') }}
</h5>
</div>
<p class="text-sm text-n-slate-11">
{{ $t('MFA_SETTINGS.DESCRIPTION') }}
</p>
</div>
<Button
type="button"
faded
:label="$t('PROFILE_SETTINGS.FORM.SECURITY_SECTION.MFA_BUTTON')"
icon="i-lucide-settings"
class="flex-shrink-0"
@click="navigateToMfa"
/>
</div>
</div>
</template>

View File

@@ -0,0 +1,323 @@
<script setup>
import { ref, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import QRCode from 'qrcode';
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';
const props = defineProps({
showSetup: {
type: Boolean,
required: true,
},
mfaEnabled: {
type: Boolean,
required: true,
},
provisioningUri: {
type: String,
default: '',
},
secretKey: {
type: String,
default: '',
},
backupCodes: {
type: Array,
default: () => [],
},
qrCodeUrlProp: {
type: String,
default: '',
},
});
const emit = defineEmits(['cancel', 'verify', 'complete']);
const { t } = useI18n();
// Local state
const setupStep = ref('qr');
const qrCodeUrl = ref('');
const verificationCode = ref('');
const verificationError = ref('');
const backupCodesConfirmed = ref(false);
// Generate QR code from provisioning URI
const generateQRCode = async provisioningUrl => {
try {
const qrCodeDataUrl = await QRCode.toDataURL(provisioningUrl, {
width: 256,
margin: 2,
color: {
dark: '#000000',
light: '#FFFFFF',
},
});
return qrCodeDataUrl;
} catch (error) {
return null;
}
};
// Watch for provisioning URI changes
watch(
() => props.provisioningUri,
async newUri => {
if (newUri) {
qrCodeUrl.value = await generateQRCode(newUri);
} else if (props.qrCodeUrlProp) {
qrCodeUrl.value = props.qrCodeUrlProp;
}
},
{ immediate: true }
);
const verifyCode = async () => {
verificationError.value = '';
try {
emit('verify', verificationCode.value);
setupStep.value = 'backup';
verificationCode.value = '';
} catch (error) {
verificationError.value = t('MFA_SETTINGS.SETUP.INVALID_CODE');
}
};
const copySecret = async () => {
await copyTextToClipboard(props.secretKey);
useAlert(t('MFA_SETTINGS.SETUP.SECRET_COPIED'));
};
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 cancelSetup = () => {
setupStep.value = 'qr';
verificationCode.value = '';
verificationError.value = '';
backupCodesConfirmed.value = false;
emit('cancel');
};
const completeMfaSetup = () => {
setupStep.value = 'qr';
backupCodesConfirmed.value = false;
emit('complete');
};
// Reset when showSetup changes
watch(
() => props.showSetup,
newVal => {
if (newVal) {
setupStep.value = 'qr';
verificationCode.value = '';
verificationError.value = '';
backupCodesConfirmed.value = false;
}
}
);
// Handle verification error
const handleVerificationError = error => {
verificationError.value = error || t('MFA_SETTINGS.SETUP.INVALID_CODE');
};
defineExpose({
handleVerificationError,
setupStep,
});
</script>
<template>
<div v-if="showSetup && !mfaEnabled">
<!-- Step 1: QR Code -->
<div v-if="setupStep === 'qr'" class="space-y-6">
<div
class="bg-n-solid-1 rounded-xl outline-1 outline-n-weak outline p-10 flex flex-col gap-4"
>
<div class="text-center">
<h3 class="text-lg font-medium text-n-slate-12 mb-2">
{{ $t('MFA_SETTINGS.SETUP.STEP1_TITLE') }}
</h3>
<p class="text-sm text-n-slate-11">
{{ $t('MFA_SETTINGS.SETUP.STEP1_DESCRIPTION') }}
</p>
</div>
<div class="flex justify-center">
<div
class="bg-n-background p-4 rounded-lg outline outline-1 outline-n-weak"
>
<img
v-if="qrCodeUrl"
:src="qrCodeUrl"
alt="MFA QR Code"
class="w-48 h-48 dark:invert-0"
/>
<div
v-else
class="w-48 h-48 flex items-center justify-center bg-n-slate-2 dark:bg-n-slate-3"
>
<span class="text-n-slate-10">
{{ $t('MFA_SETTINGS.SETUP.LOADING_QR') }}
</span>
</div>
</div>
</div>
<details class="border border-n-slate-4 rounded-lg">
<summary
class="px-4 py-3 cursor-pointer hover:bg-n-slate-2 dark:hover:bg-n-slate-3 text-sm font-medium text-n-slate-11"
>
{{ $t('MFA_SETTINGS.SETUP.MANUAL_ENTRY') }}
</summary>
<div class="px-4 pb-4">
<label class="block text-xs text-n-slate-10 mb-2">
{{ $t('MFA_SETTINGS.SETUP.SECRET_KEY') }}
</label>
<div class="flex items-center gap-2">
<Input :model-value="secretKey" readonly class="flex-1" />
<Button
variant="outline"
color="slate"
size="sm"
:label="$t('MFA_SETTINGS.SETUP.COPY')"
@click="copySecret"
/>
</div>
</div>
</details>
<div class="flex flex-col items-start gap-3 w-full">
<Input
v-model="verificationCode"
type="text"
maxlength="6"
pattern="[0-9]{6}"
:label="$t('MFA_SETTINGS.SETUP.ENTER_CODE')"
:placeholder="$t('MFA_SETTINGS.SETUP.ENTER_CODE_PLACEHOLDER')"
:message="verificationError"
:message-type="verificationError ? 'error' : 'info'"
class="w-full"
@keyup.enter="verifyCode"
/>
<div class="flex gap-3 mt-1 w-full justify-between">
<Button
faded
color="slate"
class="flex-1"
:label="$t('MFA_SETTINGS.SETUP.CANCEL')"
@click="cancelSetup"
/>
<Button
class="flex-1"
:disabled="verificationCode.length !== 6"
:label="$t('MFA_SETTINGS.SETUP.VERIFY_BUTTON')"
@click="verifyCode"
/>
</div>
</div>
</div>
</div>
<!-- Step 2: Backup Codes -->
<div v-if="setupStep === 'backup'" class="space-y-6">
<div class="text-start">
<h3 class="text-lg font-medium text-n-slate-12 mb-2">
{{ $t('MFA_SETTINGS.BACKUP.TITLE') }}
</h3>
<p class="text-sm text-n-slate-11">
{{ $t('MFA_SETTINGS.BACKUP.DESCRIPTION') }}
</p>
</div>
<!-- Warning Alert -->
<div
class="flex items-start gap-2 p-4 bg-n-solid-1 outline outline-n-weak rounded-xl outline-1"
>
<Icon
icon="i-lucide-alert-circle"
class="size-4 text-n-slate-10 flex-shrink-0 mt-0.5"
/>
<p class="text-sm text-n-slate-11">
<strong>{{ $t('MFA_SETTINGS.BACKUP.IMPORTANT') }}</strong>
{{ $t('MFA_SETTINGS.BACKUP.IMPORTANT_NOTE') }}
</p>
</div>
<!-- Backup Codes Grid -->
<div
class="bg-n-solid-1 rounded-xl outline-1 outline-n-weak outline flex flex-col gap-6 p-6"
>
<div class="grid grid-cols-2 xs:grid-cols-4 sm:grid-cols-5 gap-3">
<span
v-for="(code, index) in backupCodes"
:key="index"
class="px-1 py-2 font-mono text-base text-center text-n-slate-12"
>
{{ code }}
</span>
</div>
<div class="flex items-center justify-center gap-3">
<Button
outline
slate
sm
icon="i-lucide-download"
:label="$t('MFA_SETTINGS.BACKUP.DOWNLOAD')"
@click="downloadBackupCodes"
/>
<Button
outline
slate
sm
icon="i-lucide-clipboard"
:label="$t('MFA_SETTINGS.BACKUP.COPY_ALL')"
@click="copyBackupCodes"
/>
</div>
</div>
<!-- Confirmation -->
<div class="space-y-4">
<label class="flex items-start gap-3">
<input
v-model="backupCodesConfirmed"
type="checkbox"
class="mt-1 rounded border-n-slate-4 text-n-blue-9 focus:ring-n-blue-8"
/>
<span class="text-sm text-n-slate-11">
{{ $t('MFA_SETTINGS.BACKUP.CONFIRM') }}
</span>
</label>
<Button
:disabled="!backupCodesConfirmed"
:label="$t('MFA_SETTINGS.BACKUP.COMPLETE_SETUP')"
@click="completeMfaSetup"
/>
</div>
</div>
</div>
<template v-else />
</template>

View File

@@ -0,0 +1,63 @@
<script setup>
import Button from 'dashboard/components-next/button/Button.vue';
import Icon from 'dashboard/components-next/icon/Icon.vue';
defineProps({
mfaEnabled: {
type: Boolean,
required: true,
},
showSetup: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(['enableMfa']);
const startSetup = () => {
emit('enableMfa');
};
</script>
<template>
<div v-if="!mfaEnabled && !showSetup" class="space-y-6">
<div
class="bg-n-solid-1 rounded-lg p-6 outline outline-n-weak outline-1 text-center"
>
<Icon
icon="i-lucide-lock-keyhole"
class="size-8 text-n-slate-10 mx-auto mb-4 block"
/>
<h3 class="text-lg font-medium text-n-slate-12 mb-2">
{{ $t('MFA_SETTINGS.ENHANCE_SECURITY') }}
</h3>
<p class="text-sm text-n-slate-11 mb-6 max-w-md mx-auto">
{{ $t('MFA_SETTINGS.ENHANCE_SECURITY_DESC') }}
</p>
<Button
icon="i-lucide-settings"
:label="$t('MFA_SETTINGS.ENABLE_BUTTON')"
@click="startSetup"
/>
</div>
</div>
<div v-else-if="mfaEnabled && !showSetup">
<div
class="bg-n-solid-1 rounded-xl outline-1 outline-n-weak outline p-4 flex-1 flex flex-col gap-2"
>
<div class="flex items-center gap-2">
<Icon
icon="i-lucide-lock-keyhole"
class="size-4 flex-shrink-0 text-n-slate-11"
/>
<h4 class="text-sm font-medium text-n-slate-12">
{{ $t('MFA_SETTINGS.STATUS_ENABLED') }}
</h4>
</div>
<p class="text-sm text-n-slate-11">
{{ $t('MFA_SETTINGS.STATUS_ENABLED_DESC') }}
</p>
</div>
</div>
</template>

View File

@@ -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();
}
},
},
],
},
],

View File

@@ -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;
};

View File

@@ -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();
});
});
});

View File

@@ -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');
}
};
},

View File

@@ -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;
}
};

View File

@@ -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 = '';
},
},
};
</script>
@@ -193,7 +230,19 @@ export default {
</router-link>
</p>
</section>
<!-- MFA Verification Section -->
<section v-if="mfaRequired" class="mt-11">
<MfaVerification
:mfa-token="mfaToken"
@verified="handleMfaVerified"
@cancel="handleMfaCancel"
/>
</section>
<!-- Regular Login Section -->
<section
v-else
class="bg-white shadow sm:mx-auto mt-11 sm:w-full sm:max-w-lg dark:bg-n-solid-2 p-11 sm:shadow-lg sm:rounded-lg"
:class="{
'mb-8 mt-15': !showGoogleOAuth,

View File

@@ -44,6 +44,7 @@
whatsappApiVersion: '<%= @global_config['WHATSAPP_API_VERSION'] %>',
signupEnabled: '<%= @global_config['ENABLE_ACCOUNT_SIGNUP'] %>',
isEnterprise: '<%= @global_config['IS_ENTERPRISE'] %>',
isMfaEnabled: '<%= Chatwoot.mfa_enabled? %>',
<% if @global_config['IS_ENTERPRISE'] %>
enterprisePlanName: '<%= @global_config['INSTALLATION_PRICING_PLAN'] %>',
<% end %>