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:
committed by
GitHub
parent
cefb3fea54
commit
473ac39489
@@ -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">
|
||||
|
||||
121
app/javascript/dashboard/components/app/LowBackupCodesBanner.vue
Normal file
121
app/javascript/dashboard/components/app/LowBackupCodesBanner.vue
Normal 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>
|
||||
@@ -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')"
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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'));
|
||||
|
||||
@@ -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',
|
||||
};
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user