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" /> |     <slot name="prefix" /> | ||||||
|     <input |     <input | ||||||
|       :id="uniqueId" |       :id="uniqueId" | ||||||
|  |       v-bind="$attrs" | ||||||
|       ref="inputRef" |       ref="inputRef" | ||||||
|       :value="modelValue" |       :value="modelValue" | ||||||
|       :class="[ |       :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 teamsSettings from './teamsSettings.json'; | ||||||
| import whatsappTemplates from './whatsappTemplates.json'; | import whatsappTemplates from './whatsappTemplates.json'; | ||||||
| import contentTemplates from './contentTemplates.json'; | import contentTemplates from './contentTemplates.json'; | ||||||
|  | import mfa from './mfa.json'; | ||||||
|  |  | ||||||
| export default { | export default { | ||||||
|   ...advancedFilters, |   ...advancedFilters, | ||||||
| @@ -76,4 +77,5 @@ export default { | |||||||
|   ...teamsSettings, |   ...teamsSettings, | ||||||
|   ...whatsappTemplates, |   ...whatsappTemplates, | ||||||
|   ...contentTemplates, |   ...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.", |         "NOTE": "Updating your password would reset your logins in multiple devices.", | ||||||
|         "BTN_TEXT": "Change password" |         "BTN_TEXT": "Change password" | ||||||
|       }, |       }, | ||||||
|  |       "SECURITY_SECTION": { | ||||||
|  |         "TITLE": "Security", | ||||||
|  |         "NOTE": "Manage additional security features for your account.", | ||||||
|  |         "MFA_BUTTON": "Manage Two-Factor Authentication" | ||||||
|  |       }, | ||||||
|       "ACCESS_TOKEN": { |       "ACCESS_TOKEN": { | ||||||
|         "TITLE": "Access Token", |         "TITLE": "Access Token", | ||||||
|         "NOTE": "This token can be used if you are building an API based integration", |         "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 { clearCookiesOnLogout } from 'dashboard/store/utils/api.js'; | ||||||
| import { copyTextToClipboard } from 'shared/helpers/clipboard'; | import { copyTextToClipboard } from 'shared/helpers/clipboard'; | ||||||
| import { parseAPIErrorResponse } from 'dashboard/store/utils/api'; | import { parseAPIErrorResponse } from 'dashboard/store/utils/api'; | ||||||
|  | import { parseBoolean } from '@chatwoot/utils'; | ||||||
| import UserProfilePicture from './UserProfilePicture.vue'; | import UserProfilePicture from './UserProfilePicture.vue'; | ||||||
| import UserBasicDetails from './UserBasicDetails.vue'; | import UserBasicDetails from './UserBasicDetails.vue'; | ||||||
| import MessageSignature from './MessageSignature.vue'; | import MessageSignature from './MessageSignature.vue'; | ||||||
| @@ -18,6 +19,7 @@ import NotificationPreferences from './NotificationPreferences.vue'; | |||||||
| import AudioNotifications from './AudioNotifications.vue'; | import AudioNotifications from './AudioNotifications.vue'; | ||||||
| import FormSection from 'dashboard/components/FormSection.vue'; | import FormSection from 'dashboard/components/FormSection.vue'; | ||||||
| import AccessToken from './AccessToken.vue'; | import AccessToken from './AccessToken.vue'; | ||||||
|  | import MfaSettingsCard from './MfaSettingsCard.vue'; | ||||||
| import Policy from 'dashboard/components/policy.vue'; | import Policy from 'dashboard/components/policy.vue'; | ||||||
| import { | import { | ||||||
|   ROLES, |   ROLES, | ||||||
| @@ -38,6 +40,7 @@ export default { | |||||||
|     NotificationPreferences, |     NotificationPreferences, | ||||||
|     AudioNotifications, |     AudioNotifications, | ||||||
|     AccessToken, |     AccessToken, | ||||||
|  |     MfaSettingsCard, | ||||||
|   }, |   }, | ||||||
|   setup() { |   setup() { | ||||||
|     const { isEditorHotKeyEnabled, updateUISettings } = useUISettings(); |     const { isEditorHotKeyEnabled, updateUISettings } = useUISettings(); | ||||||
| @@ -95,6 +98,9 @@ export default { | |||||||
|       currentUserId: 'getCurrentUserID', |       currentUserId: 'getCurrentUserID', | ||||||
|       globalConfig: 'globalConfig/get', |       globalConfig: 'globalConfig/get', | ||||||
|     }), |     }), | ||||||
|  |     isMfaEnabled() { | ||||||
|  |       return parseBoolean(window.chatwootConfig?.isMfaEnabled); | ||||||
|  |     }, | ||||||
|   }, |   }, | ||||||
|   mounted() { |   mounted() { | ||||||
|     if (this.currentUserId) { |     if (this.currentUserId) { | ||||||
| @@ -283,6 +289,13 @@ export default { | |||||||
|     > |     > | ||||||
|       <ChangePassword /> |       <ChangePassword /> | ||||||
|     </FormSection> |     </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"> |     <Policy :permissions="audioNotificationPermissions"> | ||||||
|       <FormSection |       <FormSection | ||||||
|         :title="$t('PROFILE_SETTINGS.FORM.AUDIO_NOTIFICATIONS_SECTION.TITLE')" |         :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 { frontendURL } from '../../../../helper/URLHelper'; | ||||||
|  | import { parseBoolean } from '@chatwoot/utils'; | ||||||
|  |  | ||||||
| import SettingsContent from './Wrapper.vue'; | import SettingsContent from './Wrapper.vue'; | ||||||
| import Index from './Index.vue'; | import Index from './Index.vue'; | ||||||
|  | import MfaSettings from './MfaSettings.vue'; | ||||||
|  |  | ||||||
| export default { | export default { | ||||||
|   routes: [ |   routes: [ | ||||||
| @@ -21,6 +23,23 @@ export default { | |||||||
|             permissions: ['administrator', 'agent', 'custom_role'], |             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}`); |     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(); | const mockWriteText = vi.fn(); | ||||||
| Object.assign(navigator, { | 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() { |     setColorTheme() { | ||||||
|       if (window.matchMedia('(prefers-color-scheme: dark)').matches) { |       if (window.matchMedia('(prefers-color-scheme: dark)').matches) { | ||||||
|         this.theme = 'dark'; |         this.theme = 'dark'; | ||||||
|  |         document.documentElement.classList.add('dark'); | ||||||
|       } else { |       } else { | ||||||
|         this.theme = 'light'; |         this.theme = 'light'; | ||||||
|  |         document.documentElement.classList.remove('dark'); | ||||||
|       } |       } | ||||||
|     }, |     }, | ||||||
|     listenToThemeChanges() { |     listenToThemeChanges() { | ||||||
| @@ -25,8 +27,10 @@ export default { | |||||||
|       mql.onchange = e => { |       mql.onchange = e => { | ||||||
|         if (e.matches) { |         if (e.matches) { | ||||||
|           this.theme = 'dark'; |           this.theme = 'dark'; | ||||||
|  |           document.documentElement.classList.add('dark'); | ||||||
|         } else { |         } else { | ||||||
|           this.theme = 'light'; |           this.theme = 'light'; | ||||||
|  |           document.documentElement.classList.remove('dark'); | ||||||
|         } |         } | ||||||
|       }; |       }; | ||||||
|     }, |     }, | ||||||
|   | |||||||
| @@ -13,6 +13,16 @@ export const login = async ({ | |||||||
| }) => { | }) => { | ||||||
|   try { |   try { | ||||||
|     const response = await wootAPI.post('auth/sign_in', credentials); |     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); |     setAuthCredentials(response); | ||||||
|     clearLocalStorageOnLogout(); |     clearLocalStorageOnLogout(); | ||||||
|     window.location = getLoginRedirectURL({ |     window.location = getLoginRedirectURL({ | ||||||
| @@ -20,8 +30,17 @@ export const login = async ({ | |||||||
|       ssoConversationId, |       ssoConversationId, | ||||||
|       user: response.data.data, |       user: response.data.data, | ||||||
|     }); |     }); | ||||||
|  |     return null; | ||||||
|   } catch (error) { |   } 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); |     throwErrorMessage(error); | ||||||
|  |     return null; | ||||||
|   } |   } | ||||||
| }; | }; | ||||||
|  |  | ||||||
|   | |||||||
| @@ -15,6 +15,7 @@ import FormInput from '../../components/Form/Input.vue'; | |||||||
| import GoogleOAuthButton from '../../components/GoogleOauth/Button.vue'; | import GoogleOAuthButton from '../../components/GoogleOauth/Button.vue'; | ||||||
| import Spinner from 'shared/components/Spinner.vue'; | import Spinner from 'shared/components/Spinner.vue'; | ||||||
| import NextButton from 'dashboard/components-next/button/Button.vue'; | import NextButton from 'dashboard/components-next/button/Button.vue'; | ||||||
|  | import MfaVerification from 'dashboard/components/auth/MfaVerification.vue'; | ||||||
|  |  | ||||||
| const ERROR_MESSAGES = { | const ERROR_MESSAGES = { | ||||||
|   'no-account-found': 'LOGIN.OAUTH.NO_ACCOUNT_FOUND', |   'no-account-found': 'LOGIN.OAUTH.NO_ACCOUNT_FOUND', | ||||||
| @@ -29,6 +30,7 @@ export default { | |||||||
|     GoogleOAuthButton, |     GoogleOAuthButton, | ||||||
|     Spinner, |     Spinner, | ||||||
|     NextButton, |     NextButton, | ||||||
|  |     MfaVerification, | ||||||
|   }, |   }, | ||||||
|   props: { |   props: { | ||||||
|     ssoAuthToken: { type: String, default: '' }, |     ssoAuthToken: { type: String, default: '' }, | ||||||
| @@ -58,6 +60,8 @@ export default { | |||||||
|         hasErrored: false, |         hasErrored: false, | ||||||
|       }, |       }, | ||||||
|       error: '', |       error: '', | ||||||
|  |       mfaRequired: false, | ||||||
|  |       mfaToken: null, | ||||||
|     }; |     }; | ||||||
|   }, |   }, | ||||||
|   validations() { |   validations() { | ||||||
| @@ -87,8 +91,10 @@ export default { | |||||||
|       this.submitLogin(); |       this.submitLogin(); | ||||||
|     } |     } | ||||||
|     if (this.authError) { |     if (this.authError) { | ||||||
|       const message = ERROR_MESSAGES[this.authError] ?? 'LOGIN.API.UNAUTH'; |       const messageKey = ERROR_MESSAGES[this.authError] ?? 'LOGIN.API.UNAUTH'; | ||||||
|       useAlert(this.$t(message)); |       // Use a method to get the translated text to avoid dynamic key warning | ||||||
|  |       const translatedMessage = this.getTranslatedMessage(messageKey); | ||||||
|  |       useAlert(translatedMessage); | ||||||
|       // wait for idle state |       // wait for idle state | ||||||
|       this.requestIdleCallbackPolyfill(() => { |       this.requestIdleCallbackPolyfill(() => { | ||||||
|         // Remove the error query param from the url |         // Remove the error query param from the url | ||||||
| @@ -98,6 +104,18 @@ export default { | |||||||
|     } |     } | ||||||
|   }, |   }, | ||||||
|   methods: { |   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 |     // TODO: Remove this when Safari gets wider support | ||||||
|     // Ref: https://caniuse.com/requestidlecallback |     // Ref: https://caniuse.com/requestidlecallback | ||||||
|     // |     // | ||||||
| @@ -140,7 +158,15 @@ export default { | |||||||
|       }; |       }; | ||||||
|  |  | ||||||
|       login(credentials) |       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.handleImpersonation(); | ||||||
|           this.showAlertMessage(this.$t('LOGIN.API.SUCCESS_MESSAGE')); |           this.showAlertMessage(this.$t('LOGIN.API.SUCCESS_MESSAGE')); | ||||||
|         }) |         }) | ||||||
| @@ -163,6 +189,17 @@ export default { | |||||||
|  |  | ||||||
|       this.submitLogin(); |       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> | </script> | ||||||
| @@ -193,7 +230,19 @@ export default { | |||||||
|         </router-link> |         </router-link> | ||||||
|       </p> |       </p> | ||||||
|     </section> |     </section> | ||||||
|  |  | ||||||
|  |     <!-- MFA Verification Section --> | ||||||
|  |     <section v-if="mfaRequired" class="mt-11"> | ||||||
|  |       <MfaVerification | ||||||
|  |         :mfa-token="mfaToken" | ||||||
|  |         @verified="handleMfaVerified" | ||||||
|  |         @cancel="handleMfaCancel" | ||||||
|  |       /> | ||||||
|  |     </section> | ||||||
|  |  | ||||||
|  |     <!-- Regular Login Section --> | ||||||
|     <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="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="{ |       :class="{ | ||||||
|         'mb-8 mt-15': !showGoogleOAuth, |         'mb-8 mt-15': !showGoogleOAuth, | ||||||
|   | |||||||
| @@ -44,6 +44,7 @@ | |||||||
|         whatsappApiVersion: '<%= @global_config['WHATSAPP_API_VERSION'] %>', |         whatsappApiVersion: '<%= @global_config['WHATSAPP_API_VERSION'] %>', | ||||||
|         signupEnabled: '<%= @global_config['ENABLE_ACCOUNT_SIGNUP'] %>', |         signupEnabled: '<%= @global_config['ENABLE_ACCOUNT_SIGNUP'] %>', | ||||||
|         isEnterprise: '<%= @global_config['IS_ENTERPRISE'] %>', |         isEnterprise: '<%= @global_config['IS_ENTERPRISE'] %>', | ||||||
|  |         isMfaEnabled: '<%= Chatwoot.mfa_enabled? %>', | ||||||
|         <% if @global_config['IS_ENTERPRISE'] %> |         <% if @global_config['IS_ENTERPRISE'] %> | ||||||
|         enterprisePlanName: '<%= @global_config['INSTALLATION_PRICING_PLAN'] %>', |         enterprisePlanName: '<%= @global_config['INSTALLATION_PRICING_PLAN'] %>', | ||||||
|         <% end %> |         <% end %> | ||||||
|   | |||||||
		Reference in New Issue
	
	Block a user