feat: nudge users with a dashboard banner when backup codes run low (#14103)

## Linear Ticket
-
https://linear.app/chatwoot/issue/CW-6884/nudge-users-with-a-dashboard-banner-when-2fa-backup-codes-run-low

## Description

Shows a dashboard-wide banner when the signed-in user has 3 or fewer
unused backup codes left (amber), turning to an alert style at 0
remaining. Clicking "Generate codes" takes the user to the MFA settings
page so they can regenerate codes before they get locked out. Inspired
by Google's post-backup-code-use nudges.

## How to test

1. Sign in as a user with MFA enabled.
<img width="1512" height="824" alt="Screenshot 2026-08-05 at 4 52 15 PM"
src="https://github.com/user-attachments/assets/08138f3e-cc15-451e-bbcc-7772dc2a875c"
/>
<img width="1507" height="701" alt="Screenshot 2026-08-05 at 4 54 02 PM"
src="https://github.com/user-attachments/assets/580051fa-cbb5-47c1-81a3-258b7c5b5a03"
/>


2. In a Rails console, simulate a low state by marking most backup codes
as used:
   ```ruby
   u = User.find_by(email: '<your user>')
   codes = u.otp_backup_codes.dup
   (0...8).each { |i| codes[i] = 'XXXXXXXX' }
   u.otp_backup_codes = codes
   u.save!
   ```
3. Reload any dashboard page — the amber banner should appear with a
"Generate codes" CTA.
4. Click the CTA — it should route to **Profile → Two-Factor
Authentication**, where you can regenerate codes.
5. Set the count to 0 (mark all 10 as `'XXXXXXXX'`) — banner should
switch to the red/alert style.
6. Regenerate codes — banner should disappear on the next dashboard page
load.



## 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


> Base is the [disable-with-backup-code PR
branch](https://github.com/chatwoot/chatwoot/pull/14102) so CTAs around
recovery are consistent; rebase onto `develop` once that merges.

---------

Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com>
Co-authored-by: iamsivin <iamsivin@gmail.com>
Co-authored-by: Sony Mathew <sony@chatwoot.com>
This commit is contained in:
Tanmay Deep Sharma
2026-08-06 16:17:58 +05:30
committed by GitHub
parent cefb3fea54
commit 473ac39489
8 changed files with 144 additions and 1 deletions

View File

@@ -6,6 +6,7 @@ import UpdateBanner from './components/app/UpdateBanner.vue';
import StatusBanner from './components/app/StatusBanner.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';
@@ -32,6 +33,7 @@ export default {
PaymentPendingBanner,
WootSnackbarBox,
PendingEmailVerificationBanner,
LowBackupCodesBanner,
},
setup() {
const router = useRouter();
@@ -143,6 +145,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">

View File

@@ -0,0 +1,121 @@
<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';
const LOW_BACKUP_CODES_THRESHOLD = 3;
// Matches the amber/ruby subtle treatment used for warning callouts elsewhere
// in the design system (e.g. CoverageBanner, InboxBanner, NextBanner).
const SEVERITY_CLASSES = {
warning: {
container: 'bg-n-amber-3 border-n-amber-4 text-n-amber-11',
action: 'bg-n-amber-4 hover:bg-n-amber-5',
dismiss: 'hover:bg-n-amber-4',
},
critical: {
container: 'bg-n-ruby-3 border-n-ruby-4 text-n-ruby-11',
action: 'bg-n-ruby-4 hover:bg-n-ruby-5',
dismiss: 'hover:bg-n-ruby-4',
},
};
const { t } = useI18n();
const router = useRouter();
const mfaEnabled = ref(false);
const remainingBackupCodes = ref(null);
const dismissed = ref(false);
const currentAccountId = useMapGetter('getCurrentAccountId');
const shouldShowBanner = computed(() => {
if (dismissed.value) return false;
if (!mfaEnabled.value) return false;
if (remainingBackupCodes.value === null) return false;
return remainingBackupCodes.value <= LOW_BACKUP_CODES_THRESHOLD;
});
const severity = computed(() =>
remainingBackupCodes.value === 0 ? 'critical' : 'warning'
);
const severityClasses = computed(() => SEVERITY_CLASSES[severity.value]);
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 },
});
};
const dismissBanner = () => {
dismissed.value = true;
};
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>
<div
v-if="shouldShowBanner"
class="flex items-center justify-between gap-3 px-4 py-2 text-sm border-b"
:class="severityClasses.container"
>
<div class="flex items-center min-w-0 gap-2">
<span class="shrink-0 i-lucide-triangle-alert size-4" />
<span class="truncate">{{ bannerMessage }}</span>
</div>
<div class="flex items-center gap-1 shrink-0">
<button
type="button"
class="px-3 py-1 rounded-lg whitespace-nowrap"
:class="severityClasses.action"
@click="goToMfaSettings"
>
{{ $t('MFA_SETTINGS.LOW_BACKUP_CODES.ACTION') }}
</button>
<button
type="button"
class="grid rounded-lg size-7 place-content-center"
:class="severityClasses.dismiss"
:aria-label="$t('GENERAL_SETTINGS.DISMISS')"
@click="dismissBanner"
>
<span class="i-lucide-x size-4" />
</button>
</div>
</div>
</template>

View File

@@ -108,6 +108,7 @@ export default {
<NextButton
v-if="hasCloseButton"
xs
variant="ghost"
icon="i-lucide-circle-x"
:color="getButtonColor"
:label="$t('GENERAL_SETTINGS.DISMISS')"

View File

@@ -49,6 +49,11 @@
"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 either a verification code from your authenticator app or a backup code to disable two-factor authentication.",

View File

@@ -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'));
};
@@ -110,6 +113,7 @@ const disableMfa = async ({ 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'));

View File

@@ -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',
};

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

View File

@@ -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