From a9643c03d5ff812a88d554b4a4bcb13f0d5444f0 Mon Sep 17 00:00:00 2001 From: Nick Krantz Date: Thu, 10 Jul 2025 10:46:58 -0500 Subject: [PATCH] add exclamation badge for at risk passwords on tab --- .../autofill-badge-updater.service.spec.ts | 128 ++++++++++++++++++ .../autofill-badge-updater.service.ts | 68 ++++++++-- .../browser/src/background/main.background.ts | 1 + 3 files changed, 185 insertions(+), 12 deletions(-) create mode 100644 apps/browser/src/autofill/services/autofill-badge-updater.service.spec.ts diff --git a/apps/browser/src/autofill/services/autofill-badge-updater.service.spec.ts b/apps/browser/src/autofill/services/autofill-badge-updater.service.spec.ts new file mode 100644 index 00000000000..a66645b89c8 --- /dev/null +++ b/apps/browser/src/autofill/services/autofill-badge-updater.service.spec.ts @@ -0,0 +1,128 @@ +import { BehaviorSubject } from "rxjs"; + +import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { BadgeSettingsServiceAbstraction } from "@bitwarden/common/autofill/services/badge-settings.service"; +import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; +import { SecurityTask, TaskService } from "@bitwarden/common/vault/tasks"; +import { LogService } from "@bitwarden/logging"; +import { UserId } from "@bitwarden/user-core"; + +import { BadgeService } from "../../platform/badge/badge.service"; +import { BadgeStatePriority } from "../../platform/badge/priority"; + +import { AutofillBadgeUpdaterService } from "./autofill-badge-updater.service"; + +describe("AutofillBadgeUpdaterService", () => { + let service: AutofillBadgeUpdaterService; + + let setState: jest.Mock; + let clearState: jest.Mock; + let warning: jest.Mock; + let getAllDecryptedForUrl: jest.Mock; + + const activeAccount$ = new BehaviorSubject({ id: "test-account-id" }); + const cipherViews$ = new BehaviorSubject([]); + const enableBadgeCounter$ = new BehaviorSubject(true); + const pendingTasks$ = new BehaviorSubject([]); + + beforeEach(() => { + setState = jest.fn().mockResolvedValue(undefined); + clearState = jest.fn().mockResolvedValue(undefined); + warning = jest.fn(); + getAllDecryptedForUrl = jest.fn().mockResolvedValue([]); + + service = new AutofillBadgeUpdaterService( + { setState, clearState } as unknown as BadgeService, + { activeAccount$ } as unknown as AccountService, + { cipherViews$, getAllDecryptedForUrl } as unknown as CipherService, + { enableBadgeCounter$ } as unknown as BadgeSettingsServiceAbstraction, + { warning } as unknown as LogService, + { pendingTasks$ } as unknown as TaskService, + ); + }); + + describe("setTabState", () => { + const userId = "test-user-id" as UserId; + + it("clears the tab state when there are no ciphers and no pending tasks", async () => { + const tab = { id: 1 } as chrome.tabs.Tab; + + await service["setTabState"](tab, userId, true, []); + + expect(clearState).toHaveBeenCalledWith("autofill-badge-1"); + }); + + it("clears the tab state when the enableBadgeCounter is false and no pending tasks", async () => { + const tab = { id: 2, url: "https://bitwarden.com" } as chrome.tabs.Tab; + getAllDecryptedForUrl.mockResolvedValueOnce([{ id: "cipher1" }]); + + await service["setTabState"](tab, userId, false, []); + + expect(clearState).toHaveBeenCalledWith("autofill-badge-2"); + }); + + it("sets state when there are pending tasks for the tab", async () => { + const tab = { id: 3, url: "https://bitwarden.com" } as chrome.tabs.Tab; + const pendingTasks: SecurityTask[] = [{ id: "task1", cipherId: "cipher1" } as SecurityTask]; + getAllDecryptedForUrl.mockResolvedValueOnce([{ id: "cipher1" }]); + + await service["setTabState"](tab, userId, true, pendingTasks); + + expect(setState).toHaveBeenCalledWith( + "autofill-badge-3", + BadgeStatePriority.High, + { backgroundColor: "#cb263a", text: "!" }, + 3, + ); + }); + + it("sets state when there are pending tasks for the tab even when badge counter is false", async () => { + const tab = { id: 4, url: "https://bitwarden.com" } as chrome.tabs.Tab; + const pendingTasks: SecurityTask[] = [{ id: "task2", cipherId: "cipher2" } as SecurityTask]; + getAllDecryptedForUrl.mockResolvedValueOnce([{ id: "cipher2" }]); + + await service["setTabState"](tab, userId, false, pendingTasks); + + expect(setState).toHaveBeenCalledWith( + "autofill-badge-4", + BadgeStatePriority.High, + { backgroundColor: "#cb263a", text: "!" }, + 4, + ); + }); + + it("sets cipher count", async () => { + const tab = { id: 5, url: "https://bitwarden.com" } as chrome.tabs.Tab; + getAllDecryptedForUrl.mockResolvedValueOnce([ + { id: "cipher2" }, + { id: "cipher3" }, + { id: "cipher4" }, + ]); + + await service["setTabState"](tab, userId, true, []); + + expect(setState).toHaveBeenCalledWith( + "autofill-badge-5", + BadgeStatePriority.Default, + { text: "3" }, + 5, + ); + }); + + it("sets cipher count to 9+ when there are more than 9 ciphers", async () => { + const tab = { id: 6, url: "https://bitwarden.com" } as chrome.tabs.Tab; + getAllDecryptedForUrl.mockResolvedValueOnce( + new Array(12).fill(null).map((_, i) => ({ id: `cipher-${i + 1}` })), + ); + + await service["setTabState"](tab, userId, true, []); + + expect(setState).toHaveBeenCalledWith( + "autofill-badge-6", + BadgeStatePriority.Default, + { text: "9+" }, + 6, + ); + }); + }); +}); diff --git a/apps/browser/src/autofill/services/autofill-badge-updater.service.ts b/apps/browser/src/autofill/services/autofill-badge-updater.service.ts index 42cb8886216..e7f8d5d6ebc 100644 --- a/apps/browser/src/autofill/services/autofill-badge-updater.service.ts +++ b/apps/browser/src/autofill/services/autofill-badge-updater.service.ts @@ -1,10 +1,12 @@ -import { combineLatest, distinctUntilChanged, mergeMap, of, Subject, switchMap } from "rxjs"; +import { combineLatest, distinctUntilChanged, map, mergeMap, of, Subject, switchMap } from "rxjs"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { BadgeSettingsServiceAbstraction } from "@bitwarden/common/autofill/services/badge-settings.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; import { UserId } from "@bitwarden/common/types/guid"; import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; +import { SecurityTask, SecurityTaskType, TaskService } from "@bitwarden/common/vault/tasks"; +import { filterOutNullish } from "@bitwarden/common/vault/utils/observable-utilities"; import { BadgeService } from "../../platform/badge/badge.service"; import { BadgeStatePriority } from "../../platform/badge/priority"; @@ -17,12 +19,26 @@ export class AutofillBadgeUpdaterService { private tabUpdated$ = new Subject(); private tabRemoved$ = new Subject(); + private activeAccount$ = this.accountService.activeAccount$; + + private pendingTasks$ = this.activeAccount$.pipe( + filterOutNullish(), + switchMap((account) => + this.taskService + .pendingTasks$(account.id) + .pipe( + map((tasks) => tasks.filter((t) => t.type === SecurityTaskType.UpdateAtRiskCredential)), + ), + ), + ); + constructor( private badgeService: BadgeService, private accountService: AccountService, private cipherService: CipherService, private badgeSettingsService: BadgeSettingsServiceAbstraction, private logService: LogService, + private taskService: TaskService, ) { const cipherViews$ = this.accountService.activeAccount$.pipe( switchMap((account) => (account?.id ? this.cipherService.cipherViews$(account?.id) : of([]))), @@ -33,9 +49,10 @@ export class AutofillBadgeUpdaterService { enableBadgeCounter: this.badgeSettingsService.enableBadgeCounter$.pipe(distinctUntilChanged()), ciphers: cipherViews$, + pendingTasks: this.pendingTasks$, }) .pipe( - mergeMap(async ({ account, enableBadgeCounter, ciphers }) => { + mergeMap(async ({ account, enableBadgeCounter, pendingTasks }) => { if (!account) { return; } @@ -46,8 +63,9 @@ export class AutofillBadgeUpdaterService { continue; } - if (enableBadgeCounter) { - await this.setTabState(tab, account.id); + // When the badge counter is disabled, a tab state may be applicable based on the pending tasks. + if (enableBadgeCounter || pendingTasks.length > 0) { + await this.setTabState(tab, account.id, enableBadgeCounter, pendingTasks); } else { await this.clearTabState(tab.id); } @@ -61,15 +79,16 @@ export class AutofillBadgeUpdaterService { enableBadgeCounter: this.badgeSettingsService.enableBadgeCounter$, replaced: this.tabReplaced$, ciphers: cipherViews$, + pendingTasks: this.pendingTasks$, }) .pipe( - mergeMap(async ({ account, enableBadgeCounter, replaced }) => { - if (!account || !enableBadgeCounter) { + mergeMap(async ({ account, enableBadgeCounter, replaced, pendingTasks }) => { + if (!account) { return; } await this.clearTabState(replaced.removedTabId); - await this.setTabState(replaced.addedTab, account.id); + await this.setTabState(replaced.addedTab, account.id, enableBadgeCounter, pendingTasks); }), ) .subscribe(); @@ -79,14 +98,15 @@ export class AutofillBadgeUpdaterService { enableBadgeCounter: this.badgeSettingsService.enableBadgeCounter$, tab: this.tabUpdated$, ciphers: cipherViews$, + pendingTasks: this.pendingTasks$, }) .pipe( - mergeMap(async ({ account, enableBadgeCounter, tab }) => { - if (!account || !enableBadgeCounter) { + mergeMap(async ({ account, enableBadgeCounter, tab, pendingTasks }) => { + if (!account) { return; } - await this.setTabState(tab, account.id); + await this.setTabState(tab, account.id, enableBadgeCounter, pendingTasks); }), ) .subscribe(); @@ -132,7 +152,12 @@ export class AutofillBadgeUpdaterService { BrowserApi.addListener(chrome.tabs.onRemoved, (tabId, _) => this.tabRemoved$.next(tabId)); } - private async setTabState(tab: chrome.tabs.Tab, userId: UserId) { + private async setTabState( + tab: chrome.tabs.Tab, + userId: UserId, + enableBadgeCounter: boolean, + pendingTasks?: SecurityTask[], + ) { if (!tab.id) { this.logService.warning("Tab event received but tab id is undefined"); return; @@ -141,11 +166,30 @@ export class AutofillBadgeUpdaterService { const ciphers = tab.url ? await this.cipherService.getAllDecryptedForUrl(tab.url, userId) : []; const cipherCount = ciphers.length; - if (cipherCount === 0) { + const hasPendingTasksForTab = (pendingTasks ?? []).some((task) => + ciphers.some((cipher) => cipher.id === task.cipherId && !cipher.isDeleted), + ); + + const skipBadgeUpdate = !enableBadgeCounter && !hasPendingTasksForTab; + + if (cipherCount === 0 || skipBadgeUpdate) { await this.clearTabState(tab.id); return; } + if (hasPendingTasksForTab) { + await this.badgeService.setState( + StateName(tab.id), + BadgeStatePriority.High, + { + text: "!", + backgroundColor: "#cb263a", // equivalent to danger-600 + }, + tab.id, + ); + return; + } + const countText = cipherCount > 9 ? "9+" : cipherCount.toString(); await this.badgeService.setState( StateName(tab.id), diff --git a/apps/browser/src/background/main.background.ts b/apps/browser/src/background/main.background.ts index 3f29151a1b7..ae03f9203b8 100644 --- a/apps/browser/src/background/main.background.ts +++ b/apps/browser/src/background/main.background.ts @@ -1820,6 +1820,7 @@ export default class MainBackground { this.cipherService, this.badgeSettingsService, this.logService, + this.taskService, ); this.tabsBackground = new TabsBackground(