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
@@ -15,8 +15,10 @@ export default {
|
||||
setColorTheme() {
|
||||
if (window.matchMedia('(prefers-color-scheme: dark)').matches) {
|
||||
this.theme = 'dark';
|
||||
document.documentElement.classList.add('dark');
|
||||
} else {
|
||||
this.theme = 'light ';
|
||||
this.theme = 'light';
|
||||
document.documentElement.classList.remove('dark');
|
||||
}
|
||||
},
|
||||
listenToThemeChanges() {
|
||||
@@ -25,8 +27,10 @@ export default {
|
||||
mql.onchange = e => {
|
||||
if (e.matches) {
|
||||
this.theme = 'dark';
|
||||
document.documentElement.classList.add('dark');
|
||||
} else {
|
||||
this.theme = 'light';
|
||||
document.documentElement.classList.remove('dark');
|
||||
}
|
||||
};
|
||||
},
|
||||
|
||||
@@ -13,6 +13,16 @@ export const login = async ({
|
||||
}) => {
|
||||
try {
|
||||
const response = await wootAPI.post('auth/sign_in', credentials);
|
||||
|
||||
// Check if MFA is required
|
||||
if (response.status === 206 && response.data.mfa_required) {
|
||||
// Return MFA data instead of throwing error
|
||||
return {
|
||||
mfaRequired: true,
|
||||
mfaToken: response.data.mfa_token,
|
||||
};
|
||||
}
|
||||
|
||||
setAuthCredentials(response);
|
||||
clearLocalStorageOnLogout();
|
||||
window.location = getLoginRedirectURL({
|
||||
@@ -20,8 +30,17 @@ export const login = async ({
|
||||
ssoConversationId,
|
||||
user: response.data.data,
|
||||
});
|
||||
return null;
|
||||
} catch (error) {
|
||||
// Check if it's an MFA required response
|
||||
if (error.response?.status === 206 && error.response?.data?.mfa_required) {
|
||||
return {
|
||||
mfaRequired: true,
|
||||
mfaToken: error.response.data.mfa_token,
|
||||
};
|
||||
}
|
||||
throwErrorMessage(error);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@ import FormInput from '../../components/Form/Input.vue';
|
||||
import GoogleOAuthButton from '../../components/GoogleOauth/Button.vue';
|
||||
import Spinner from 'shared/components/Spinner.vue';
|
||||
import NextButton from 'dashboard/components-next/button/Button.vue';
|
||||
import MfaVerification from 'dashboard/components/auth/MfaVerification.vue';
|
||||
|
||||
const ERROR_MESSAGES = {
|
||||
'no-account-found': 'LOGIN.OAUTH.NO_ACCOUNT_FOUND',
|
||||
@@ -29,6 +30,7 @@ export default {
|
||||
GoogleOAuthButton,
|
||||
Spinner,
|
||||
NextButton,
|
||||
MfaVerification,
|
||||
},
|
||||
props: {
|
||||
ssoAuthToken: { type: String, default: '' },
|
||||
@@ -58,6 +60,8 @@ export default {
|
||||
hasErrored: false,
|
||||
},
|
||||
error: '',
|
||||
mfaRequired: false,
|
||||
mfaToken: null,
|
||||
};
|
||||
},
|
||||
validations() {
|
||||
@@ -87,8 +91,10 @@ export default {
|
||||
this.submitLogin();
|
||||
}
|
||||
if (this.authError) {
|
||||
const message = ERROR_MESSAGES[this.authError] ?? 'LOGIN.API.UNAUTH';
|
||||
useAlert(this.$t(message));
|
||||
const messageKey = ERROR_MESSAGES[this.authError] ?? 'LOGIN.API.UNAUTH';
|
||||
// Use a method to get the translated text to avoid dynamic key warning
|
||||
const translatedMessage = this.getTranslatedMessage(messageKey);
|
||||
useAlert(translatedMessage);
|
||||
// wait for idle state
|
||||
this.requestIdleCallbackPolyfill(() => {
|
||||
// Remove the error query param from the url
|
||||
@@ -98,6 +104,18 @@ export default {
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
getTranslatedMessage(key) {
|
||||
// Avoid dynamic key warning by handling each case explicitly
|
||||
switch (key) {
|
||||
case 'LOGIN.OAUTH.NO_ACCOUNT_FOUND':
|
||||
return this.$t('LOGIN.OAUTH.NO_ACCOUNT_FOUND');
|
||||
case 'LOGIN.OAUTH.BUSINESS_ACCOUNTS_ONLY':
|
||||
return this.$t('LOGIN.OAUTH.BUSINESS_ACCOUNTS_ONLY');
|
||||
case 'LOGIN.API.UNAUTH':
|
||||
default:
|
||||
return this.$t('LOGIN.API.UNAUTH');
|
||||
}
|
||||
},
|
||||
// TODO: Remove this when Safari gets wider support
|
||||
// Ref: https://caniuse.com/requestidlecallback
|
||||
//
|
||||
@@ -140,7 +158,15 @@ export default {
|
||||
};
|
||||
|
||||
login(credentials)
|
||||
.then(() => {
|
||||
.then(result => {
|
||||
// Check if MFA is required
|
||||
if (result?.mfaRequired) {
|
||||
this.loginApi.showLoading = false;
|
||||
this.mfaRequired = true;
|
||||
this.mfaToken = result.mfaToken;
|
||||
return;
|
||||
}
|
||||
|
||||
this.handleImpersonation();
|
||||
this.showAlertMessage(this.$t('LOGIN.API.SUCCESS_MESSAGE'));
|
||||
})
|
||||
@@ -163,6 +189,17 @@ export default {
|
||||
|
||||
this.submitLogin();
|
||||
},
|
||||
handleMfaVerified() {
|
||||
// MFA verification successful, continue with login
|
||||
this.handleImpersonation();
|
||||
window.location = '/app';
|
||||
},
|
||||
handleMfaCancel() {
|
||||
// User cancelled MFA, reset state
|
||||
this.mfaRequired = false;
|
||||
this.mfaToken = null;
|
||||
this.credentials.password = '';
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
@@ -193,7 +230,19 @@ export default {
|
||||
</router-link>
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<!-- MFA Verification Section -->
|
||||
<section v-if="mfaRequired" class="mt-11">
|
||||
<MfaVerification
|
||||
:mfa-token="mfaToken"
|
||||
@verified="handleMfaVerified"
|
||||
@cancel="handleMfaCancel"
|
||||
/>
|
||||
</section>
|
||||
|
||||
<!-- Regular Login Section -->
|
||||
<section
|
||||
v-else
|
||||
class="bg-white shadow sm:mx-auto mt-11 sm:w-full sm:max-w-lg dark:bg-n-solid-2 p-11 sm:shadow-lg sm:rounded-lg"
|
||||
:class="{
|
||||
'mb-8 mt-15': !showGoogleOAuth,
|
||||
|
||||
Reference in New Issue
Block a user