Compare commits

...
Author SHA1 Message Date
iamsivin b73f663c5d chore: Minor fix 2026-04-22 15:14:31 +05:30
Tanmay Deep Sharma 867bcaf744 refactor(mfa): address review feedback on low-backup-codes banner
- use useMapGetter for current account id
- drop onBeforeUnmount cleanup; banner lives in the persistent app shell
- switch pluralization to the {n} convention used across en.json
2026-04-22 11:25:43 +07:00
Sivin VargheseandGitHub a80f7f21ef Merge branch 'feat/disable-mfa-with-backup-code' into feat/mfa-low-backup-codes-banner 2026-04-21 16:34:52 +05:30
Tanmay Deep Sharma c1521c5b4c test(mfa): cover disabling 2FA with backup code 2026-04-21 10:32:14 +07:00
Tanmay Deep Sharma 280d5bf295 fix(mfa): prevent backup-code toggle from submitting disable dialog 2026-04-21 10:29:36 +07:00
Tanmay Deep Sharma 88268055de refactor(mfa): address review feedback on low-backup-codes banner 2026-04-21 10:28:10 +07:00
Tanmay Deep Sharma 365e8c201a refactor(mfa): use Button link variant for toggle in disable dialog 2026-04-21 10:24:05 +07:00
Tanmay Deep Sharma 9a22d03375 feat(mfa): nudge users with a dashboard banner when backup codes run low 2026-04-20 15:05:17 +07:00
Tanmay Deep Sharma 0ac947e3be fix(mfa): validate password before consuming backup code on disable 2026-04-20 15:05:02 +07:00
Tanmay Deep SharmaandGitHub 5123b7880a Merge branch 'develop' into feat/disable-mfa-with-backup-code 2026-04-20 14:56:58 +07:00
Tanmay Deep Sharma 6094060381 feat(mfa): allow disabling 2FA with a backup code when authenticator is lost 2026-04-20 14:19:10 +07:00
11 changed files with 163 additions and 10 deletions
@@ -2,8 +2,8 @@ class Api::V1::Profile::MfaController < Api::BaseController
before_action :check_mfa_feature_available
before_action :check_mfa_enabled, only: [:destroy, :backup_codes]
before_action :check_mfa_disabled, only: [:create, :verify]
before_action :validate_otp, only: [:verify, :backup_codes, :destroy]
before_action :validate_password, only: [:destroy]
before_action :validate_otp, only: [:verify, :backup_codes, :destroy]
def show; end
@@ -48,7 +48,8 @@ class Api::V1::Profile::MfaController < Api::BaseController
def validate_otp
authenticated = Mfa::AuthenticationService.new(
user: current_user,
otp_code: mfa_params[:otp_code]
otp_code: mfa_params[:otp_code],
backup_code: mfa_params[:backup_code]
).authenticate
return if authenticated
@@ -63,6 +64,6 @@ class Api::V1::Profile::MfaController < Api::BaseController
end
def mfa_params
params.permit(:otp_code, :password)
params.permit(:otp_code, :backup_code, :password)
end
end
+3
View File
@@ -5,6 +5,7 @@ import NetworkNotification from './components/NetworkNotification.vue';
import UpdateBanner from './components/app/UpdateBanner.vue';
import PaymentPendingBanner from './components/app/PaymentPendingBanner.vue';
import PendingEmailVerificationBanner from './components/app/PendingEmailVerificationBanner.vue';
import LowBackupCodesBanner from './components/app/LowBackupCodesBanner.vue';
import vueActionCable from './helper/actionCable';
import { useRouter } from 'vue-router';
import { useStore } from 'dashboard/composables/store';
@@ -30,6 +31,7 @@ export default {
PaymentPendingBanner,
WootSnackbarBox,
PendingEmailVerificationBanner,
LowBackupCodesBanner,
},
setup() {
const router = useRouter();
@@ -140,6 +142,7 @@ export default {
<template v-if="currentAccountId">
<PendingEmailVerificationBanner v-if="hideOnOnboardingView" />
<PaymentPendingBanner v-if="hideOnOnboardingView" />
<LowBackupCodesBanner v-if="hideOnOnboardingView" />
</template>
<router-view v-slot="{ Component }">
<transition name="fade" mode="out-in">
+2 -2
View File
@@ -14,9 +14,9 @@ class MfaAPI extends ApiClient {
return axios.post(`${this.url}/verify`, { otp_code: otpCode });
}
disable(password, otpCode) {
disable(password, { otpCode, backupCode } = {}) {
return axios.delete(this.url, {
data: { password, otp_code: otpCode },
data: { password, otp_code: otpCode, backup_code: backupCode },
});
}
@@ -0,0 +1,79 @@
<script setup>
import { computed, onUnmounted, onMounted, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import { useRouter } from 'vue-router';
import { parseBoolean } from '@chatwoot/utils';
import { useMapGetter } from 'dashboard/composables/store';
import { emitter } from 'shared/helpers/mitt';
import { BUS_EVENTS } from 'shared/constants/busEvents';
import mfaAPI from 'dashboard/api/mfa';
import Banner from 'dashboard/components/ui/Banner.vue';
const LOW_BACKUP_CODES_THRESHOLD = 3;
const { t } = useI18n();
const router = useRouter();
const mfaEnabled = ref(false);
const remainingBackupCodes = ref(null);
const currentAccountId = useMapGetter('getCurrentAccountId');
const shouldShowBanner = computed(() => {
if (!mfaEnabled.value) return false;
if (remainingBackupCodes.value === null) return false;
return remainingBackupCodes.value <= LOW_BACKUP_CODES_THRESHOLD;
});
const bannerColorScheme = computed(() =>
remainingBackupCodes.value === 0 ? 'alert' : 'warning'
);
const bannerMessage = computed(() => {
if (remainingBackupCodes.value === 0) {
return t('MFA_SETTINGS.LOW_BACKUP_CODES.NONE_LEFT');
}
return t('MFA_SETTINGS.LOW_BACKUP_CODES.MESSAGE', remainingBackupCodes.value);
});
const fetchMfaStatus = async () => {
if (!parseBoolean(window.chatwootConfig?.isMfaEnabled)) return;
try {
const { data } = await mfaAPI.get();
mfaEnabled.value = data.enabled;
remainingBackupCodes.value = data.remaining_backup_codes ?? null;
} catch {
// ignore; banner stays hidden
}
};
const goToMfaSettings = () => {
router.push({
name: 'profile_settings_mfa',
params: { accountId: currentAccountId.value },
});
};
onMounted(() => {
fetchMfaStatus();
emitter.on(BUS_EVENTS.MFA_STATE_CHANGED, fetchMfaStatus);
});
onUnmounted(() => {
emitter.off(BUS_EVENTS.MFA_STATE_CHANGED, fetchMfaStatus);
});
</script>
<!-- eslint-disable-next-line vue/no-root-v-if -->
<template>
<Banner
v-if="shouldShowBanner"
:color-scheme="bannerColorScheme"
:banner-message="bannerMessage"
:action-button-label="$t('MFA_SETTINGS.LOW_BACKUP_CODES.ACTION')"
action-button-icon="i-lucide-key"
has-action-button
@primary-action="goToMfaSettings"
/>
</template>
@@ -49,12 +49,21 @@
"DISABLE_MFA_DESC": "Remove two-factor authentication from your account",
"DISABLE_BUTTON": "Disable Two-Factor Authentication"
},
"LOW_BACKUP_CODES": {
"MESSAGE": "You have {n} backup code remaining. Generate new codes to avoid getting locked out if you lose access to your authenticator. | You have {n} backup codes remaining. Generate new codes to avoid getting locked out if you lose access to your authenticator.",
"NONE_LEFT": "You have no backup codes remaining. Generate new codes now to avoid getting locked out if you lose access to your authenticator.",
"ACTION": "Generate codes"
},
"DISABLE": {
"TITLE": "Disable Two-Factor Authentication",
"DESCRIPTION": "You'll need to enter your password and a verification code to disable two-factor authentication.",
"DESCRIPTION": "You'll need to enter your password and either a verification code from your authenticator app or a backup code to disable two-factor authentication.",
"PASSWORD": "Password",
"OTP_CODE": "Verification Code",
"OTP_CODE_PLACEHOLDER": "000000",
"BACKUP_CODE": "Backup Code",
"BACKUP_CODE_PLACEHOLDER": "Enter one of your backup codes",
"USE_BACKUP_CODE": "Lost access to your authenticator? Use a backup code instead",
"USE_OTP_CODE": "Use a verification code from your authenticator app",
"CONFIRM": "Disable 2FA",
"CANCEL": "Cancel",
"SUCCESS": "Two-factor authentication has been disabled",
@@ -31,6 +31,8 @@ const backupCodesDialogRef = ref(null);
// Form values
const disablePassword = ref('');
const disableOtpCode = ref('');
const disableBackupCode = ref('');
const useBackupCodeToDisable = ref(false);
const regenerateOtpCode = ref('');
// Utility functions
@@ -54,10 +56,17 @@ const downloadBackupCodes = () => {
const handleDisableMfa = async () => {
emit('disableMfa', {
password: disablePassword.value,
otpCode: disableOtpCode.value,
otpCode: useBackupCodeToDisable.value ? '' : disableOtpCode.value,
backupCode: useBackupCodeToDisable.value ? disableBackupCode.value : '',
});
};
const toggleDisableMethod = () => {
useBackupCodeToDisable.value = !useBackupCodeToDisable.value;
disableOtpCode.value = '';
disableBackupCode.value = '';
};
const handleRegenerateBackupCodes = async () => {
emit('regenerateBackupCodes', {
otpCode: regenerateOtpCode.value,
@@ -68,6 +77,8 @@ const handleRegenerateBackupCodes = async () => {
const resetDisableForm = () => {
disablePassword.value = '';
disableOtpCode.value = '';
disableBackupCode.value = '';
useBackupCodeToDisable.value = false;
disableDialogRef.value?.close();
};
@@ -157,12 +168,32 @@ defineExpose({
:label="$t('MFA_SETTINGS.DISABLE.PASSWORD')"
/>
<Input
v-if="!useBackupCodeToDisable"
v-model="disableOtpCode"
type="text"
maxlength="6"
:label="$t('MFA_SETTINGS.DISABLE.OTP_CODE')"
:placeholder="$t('MFA_SETTINGS.DISABLE.OTP_CODE_PLACEHOLDER')"
/>
<Input
v-else
v-model="disableBackupCode"
type="text"
maxlength="8"
:label="$t('MFA_SETTINGS.DISABLE.BACKUP_CODE')"
:placeholder="$t('MFA_SETTINGS.DISABLE.BACKUP_CODE_PLACEHOLDER')"
/>
<Button
link
sm
type="button"
:label="
useBackupCodeToDisable
? $t('MFA_SETTINGS.DISABLE.USE_OTP_CODE')
: $t('MFA_SETTINGS.DISABLE.USE_BACKUP_CODE')
"
@click="toggleDisableMethod"
/>
</div>
</Dialog>
@@ -5,6 +5,8 @@ import { useRouter, useRoute } from 'vue-router';
import { parseBoolean } from '@chatwoot/utils';
import mfaAPI from 'dashboard/api/mfa';
import { useAlert } from 'dashboard/composables';
import { emitter } from 'shared/helpers/mitt';
import { BUS_EVENTS } from 'shared/constants/busEvents';
import MfaStatusCard from './MfaStatusCard.vue';
import MfaSetupWizard from './MfaSetupWizard.vue';
import MfaManagementActions from './MfaManagementActions.vue';
@@ -95,6 +97,7 @@ const completeMfaSetup = () => {
mfaEnabled.value = true;
backupCodesGenerated.value = true;
showSetup.value = false;
emitter.emit(BUS_EVENTS.MFA_STATE_CHANGED);
useAlert(t('MFA_SETTINGS.SETUP.SUCCESS'));
};
@@ -104,12 +107,13 @@ const cancelSetup = () => {
};
// Disable MFA
const disableMfa = async ({ password, otpCode }) => {
const disableMfa = async ({ password, otpCode, backupCode }) => {
try {
await mfaAPI.disable(password, otpCode);
await mfaAPI.disable(password, { otpCode, backupCode });
mfaEnabled.value = false;
backupCodesGenerated.value = false;
managementActionsRef.value?.resetDisableForm();
emitter.emit(BUS_EVENTS.MFA_STATE_CHANGED);
useAlert(t('MFA_SETTINGS.DISABLE.SUCCESS'));
} catch (error) {
useAlert(t('MFA_SETTINGS.DISABLE.ERROR'));
@@ -123,6 +127,7 @@ const regenerateBackupCodes = async ({ otpCode }) => {
backupCodes.value = response.data.backup_codes;
managementActionsRef.value?.resetRegenerateForm();
managementActionsRef.value?.showBackupCodesDialog();
emitter.emit(BUS_EVENTS.MFA_STATE_CHANGED);
useAlert(t('MFA_SETTINGS.REGENERATE.SUCCESS'));
} catch (error) {
useAlert(t('MFA_SETTINGS.REGENERATE.ERROR'));
@@ -13,4 +13,5 @@ export const BUS_EVENTS = {
NEW_CONVERSATION_MODAL: 'newConversationModal',
INSERT_INTO_RICH_EDITOR: 'insertIntoRichEditor',
INSERT_INTO_NORMAL_EDITOR: 'insertIntoNormalEditor',
MFA_STATE_CHANGED: 'MFA_STATE_CHANGED',
};
+4
View File
@@ -78,6 +78,10 @@ class Mfa::ManagementService
user.otp_backup_codes.present?
end
def remaining_backup_codes_count
Array(user.otp_backup_codes).count { |code| code != 'XXXXXXXX' }
end
def mfa_enabled?
user.otp_required_for_login?
end
@@ -1,3 +1,6 @@
json.feature_available Chatwoot.mfa_enabled?
json.enabled @user.mfa_enabled?
json.backup_codes_generated @user.mfa_service.backup_codes_generated? if Chatwoot.mfa_enabled?
if Chatwoot.mfa_enabled?
json.backup_codes_generated @user.mfa_service.backup_codes_generated?
json.remaining_backup_codes @user.mfa_service.remaining_backup_codes_count
end
@@ -205,6 +205,23 @@ RSpec.describe 'MFA API', type: :request do
expect(json_response['error']).to include('Invalid')
end
end
context 'with valid password and backup code' do
it 'disables 2FA successfully' do
backup_code = user.otp_backup_codes.first
delete '/api/v1/profile/mfa',
params: { password: 'Test@123456', backup_code: backup_code },
headers: user.create_new_auth_token,
as: :json
expect(response).to have_http_status(:success)
user.reload
expect(user.otp_required_for_login).to be_falsey
expect(user.otp_secret).to be_nil
expect(user.otp_backup_codes).to be_blank
end
end
end
context 'when 2FA is not enabled' do