mirror of
				https://github.com/lingble/chatwoot.git
				synced 2025-10-31 02:57:57 +00:00 
			
		
		
		
	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
					Tanmay Deep Sharma
				
			
				
					committed by
					
						 GitHub
						GitHub
					
				
			
			
				
	
			
			
			 GitHub
						GitHub
					
				
			
						parent
						
							239c4dcb91
						
					
				
				
					commit
					4014a846f0
				
			
							
								
								
									
										28
									
								
								app/javascript/dashboard/api/mfa.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										28
									
								
								app/javascript/dashboard/api/mfa.js
									
									
									
									
									
										Normal 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(); | ||||
| @@ -116,6 +116,7 @@ onMounted(() => { | ||||
|     <slot name="prefix" /> | ||||
|     <input | ||||
|       :id="uniqueId" | ||||
|       v-bind="$attrs" | ||||
|       ref="inputRef" | ||||
|       :value="modelValue" | ||||
|       :class="[ | ||||
|   | ||||
							
								
								
									
										328
									
								
								app/javascript/dashboard/components/auth/MfaVerification.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										328
									
								
								app/javascript/dashboard/components/auth/MfaVerification.vue
									
									
									
									
									
										Normal 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> | ||||
| @@ -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, | ||||
| }; | ||||
|   | ||||
							
								
								
									
										106
									
								
								app/javascript/dashboard/i18n/locale/en/mfa.json
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										106
									
								
								app/javascript/dashboard/i18n/locale/en/mfa.json
									
									
									
									
									
										Normal 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." | ||||
|   } | ||||
| } | ||||
| @@ -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", | ||||
|   | ||||
| @@ -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')" | ||||
|   | ||||
| @@ -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> | ||||
| @@ -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> | ||||
| @@ -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> | ||||
| @@ -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> | ||||
| @@ -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> | ||||
| @@ -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(); | ||||
|             } | ||||
|           }, | ||||
|         }, | ||||
|       ], | ||||
|     }, | ||||
|   ], | ||||
|   | ||||
| @@ -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; | ||||
| }; | ||||
|   | ||||
| @@ -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(); | ||||
|     }); | ||||
|   }); | ||||
| }); | ||||
|   | ||||
| @@ -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'); | ||||
|         } | ||||
|       }; | ||||
|     }, | ||||
|   | ||||
| @@ -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; | ||||
|   } | ||||
| }; | ||||
|  | ||||
|   | ||||
| @@ -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, | ||||
|   | ||||
| @@ -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 %> | ||||
|   | ||||
		Reference in New Issue
	
	Block a user