feat: Add the frontend support for MFA (#12372)

FE support for https://github.com/chatwoot/chatwoot/pull/12290
## Linear:
- https://github.com/chatwoot/chatwoot/issues/486

## Description
This PR implements Multi-Factor Authentication (MFA) support for user
accounts, enhancing security by requiring a second form of verification
during login. The feature adds TOTP (Time-based One-Time Password)
authentication with QR code generation and backup codes for account
recovery.

## Type of change

- [ ] New feature (non-breaking change which adds functionality)

## How Has This Been Tested?

- Added comprehensive RSpec tests for MFA controller functionality
- Tested MFA setup flow with QR code generation
- Verified OTP validation and backup code generation
- Tested login flow with MFA enabled/disabled

## Checklist:

- [ ] My code follows the style guidelines of this project
- [ ] I have performed a self-review of my code
- [ ] I have commented on my code, particularly in hard-to-understand
areas
- [ ] I have made corresponding changes to the documentation
- [ ] My changes generate no new warnings
- [ ] I have added tests that prove my fix is effective or that my
feature works
- [ ] New and existing unit tests pass locally with my changes
- [ ] Any dependent changes have been merged and published in downstream
modules

---------

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

View File

@@ -15,8 +15,10 @@ export default {
setColorTheme() {
if (window.matchMedia('(prefers-color-scheme: dark)').matches) {
this.theme = 'dark';
document.documentElement.classList.add('dark');
} else {
this.theme = 'light ';
this.theme = 'light';
document.documentElement.classList.remove('dark');
}
},
listenToThemeChanges() {
@@ -25,8 +27,10 @@ export default {
mql.onchange = e => {
if (e.matches) {
this.theme = 'dark';
document.documentElement.classList.add('dark');
} else {
this.theme = 'light';
document.documentElement.classList.remove('dark');
}
};
},

View File

@@ -13,6 +13,16 @@ export const login = async ({
}) => {
try {
const response = await wootAPI.post('auth/sign_in', credentials);
// Check if MFA is required
if (response.status === 206 && response.data.mfa_required) {
// Return MFA data instead of throwing error
return {
mfaRequired: true,
mfaToken: response.data.mfa_token,
};
}
setAuthCredentials(response);
clearLocalStorageOnLogout();
window.location = getLoginRedirectURL({
@@ -20,8 +30,17 @@ export const login = async ({
ssoConversationId,
user: response.data.data,
});
return null;
} catch (error) {
// Check if it's an MFA required response
if (error.response?.status === 206 && error.response?.data?.mfa_required) {
return {
mfaRequired: true,
mfaToken: error.response.data.mfa_token,
};
}
throwErrorMessage(error);
return null;
}
};

View File

@@ -15,6 +15,7 @@ import FormInput from '../../components/Form/Input.vue';
import GoogleOAuthButton from '../../components/GoogleOauth/Button.vue';
import Spinner from 'shared/components/Spinner.vue';
import NextButton from 'dashboard/components-next/button/Button.vue';
import MfaVerification from 'dashboard/components/auth/MfaVerification.vue';
const ERROR_MESSAGES = {
'no-account-found': 'LOGIN.OAUTH.NO_ACCOUNT_FOUND',
@@ -29,6 +30,7 @@ export default {
GoogleOAuthButton,
Spinner,
NextButton,
MfaVerification,
},
props: {
ssoAuthToken: { type: String, default: '' },
@@ -58,6 +60,8 @@ export default {
hasErrored: false,
},
error: '',
mfaRequired: false,
mfaToken: null,
};
},
validations() {
@@ -87,8 +91,10 @@ export default {
this.submitLogin();
}
if (this.authError) {
const message = ERROR_MESSAGES[this.authError] ?? 'LOGIN.API.UNAUTH';
useAlert(this.$t(message));
const messageKey = ERROR_MESSAGES[this.authError] ?? 'LOGIN.API.UNAUTH';
// Use a method to get the translated text to avoid dynamic key warning
const translatedMessage = this.getTranslatedMessage(messageKey);
useAlert(translatedMessage);
// wait for idle state
this.requestIdleCallbackPolyfill(() => {
// Remove the error query param from the url
@@ -98,6 +104,18 @@ export default {
}
},
methods: {
getTranslatedMessage(key) {
// Avoid dynamic key warning by handling each case explicitly
switch (key) {
case 'LOGIN.OAUTH.NO_ACCOUNT_FOUND':
return this.$t('LOGIN.OAUTH.NO_ACCOUNT_FOUND');
case 'LOGIN.OAUTH.BUSINESS_ACCOUNTS_ONLY':
return this.$t('LOGIN.OAUTH.BUSINESS_ACCOUNTS_ONLY');
case 'LOGIN.API.UNAUTH':
default:
return this.$t('LOGIN.API.UNAUTH');
}
},
// TODO: Remove this when Safari gets wider support
// Ref: https://caniuse.com/requestidlecallback
//
@@ -140,7 +158,15 @@ export default {
};
login(credentials)
.then(() => {
.then(result => {
// Check if MFA is required
if (result?.mfaRequired) {
this.loginApi.showLoading = false;
this.mfaRequired = true;
this.mfaToken = result.mfaToken;
return;
}
this.handleImpersonation();
this.showAlertMessage(this.$t('LOGIN.API.SUCCESS_MESSAGE'));
})
@@ -163,6 +189,17 @@ export default {
this.submitLogin();
},
handleMfaVerified() {
// MFA verification successful, continue with login
this.handleImpersonation();
window.location = '/app';
},
handleMfaCancel() {
// User cancelled MFA, reset state
this.mfaRequired = false;
this.mfaToken = null;
this.credentials.password = '';
},
},
};
</script>
@@ -193,7 +230,19 @@ export default {
</router-link>
</p>
</section>
<!-- MFA Verification Section -->
<section v-if="mfaRequired" class="mt-11">
<MfaVerification
:mfa-token="mfaToken"
@verified="handleMfaVerified"
@cancel="handleMfaCancel"
/>
</section>
<!-- Regular Login Section -->
<section
v-else
class="bg-white shadow sm:mx-auto mt-11 sm:w-full sm:max-w-lg dark:bg-n-solid-2 p-11 sm:shadow-lg sm:rounded-lg"
:class="{
'mb-8 mt-15': !showGoogleOAuth,