mirror of
https://github.com/lingble/chatwoot.git
synced 2025-11-02 12:08:01 +00:00
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>
329 lines
9.4 KiB
Vue
329 lines
9.4 KiB
Vue
<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>
|