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:
committed by
GitHub
parent
239c4dcb91
commit
4014a846f0
@@ -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();
|
||||
}
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
|
||||
Reference in New Issue
Block a user