1
0
mirror of https://github.com/bitwarden/browser synced 2026-02-09 13:10:17 +00:00

add exclamation badge for at risk passwords on tab

This commit is contained in:
Nick Krantz
2025-07-10 10:46:58 -05:00
parent 90b7197279
commit a9643c03d5
3 changed files with 185 additions and 12 deletions

View File

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

View File

@@ -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<chrome.tabs.Tab>();
private tabRemoved$ = new Subject<number>();
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),

View File

@@ -1820,6 +1820,7 @@ export default class MainBackground {
this.cipherService,
this.badgeSettingsService,
this.logService,
this.taskService,
);
this.tabsBackground = new TabsBackground(