From d8c544fd65036c38db91d6d5a223e895065e78c8 Mon Sep 17 00:00:00 2001 From: Miles Blackwood Date: Wed, 11 Jun 2025 10:20:53 -0400 Subject: [PATCH 01/91] PM-19741 Adds a notification at login for at-risk passwords. (#14555) Co-authored-by: Jonathan Prusik --- apps/browser/src/_locales/en/messages.json | 27 ++++ .../abstractions/notification.background.ts | 38 ++++- .../notification.background.spec.ts | 45 +++--- .../background/notification.background.ts | 102 ++++++++++--- .../overlay-notifications.background.spec.ts | 19 ++- .../overlay-notifications.background.ts | 135 ++++++++++++++++-- .../components/buttons/action-button.ts | 16 ++- .../additional-tasks/button-content.ts | 29 ++++ .../components/lit-stories/mock-data.ts | 6 + .../container.lit-stories.ts | 44 ++++++ .../notification/at-risk-password/body.ts | 49 +++++++ .../at-risk-password/container.ts | 72 ++++++++++ .../notification/at-risk-password/footer.ts | 42 ++++++ .../notification/at-risk-password/message.ts | 44 ++++++ .../notification-queue-message-type.enum.ts | 1 + .../abstractions/notification-bar.ts | 10 +- apps/browser/src/autofill/notification/bar.ts | 37 ++++- .../overlay-notifications-content.service.ts | 1 + .../overlay-notifications-content.service.ts | 26 ++-- ...ification-change-login-password.service.ts | 99 +++++++++++++ .../src/background/main.background.spec.ts | 4 +- .../browser/src/background/main.background.ts | 3 + 22 files changed, 769 insertions(+), 80 deletions(-) create mode 100644 apps/browser/src/autofill/content/components/buttons/additional-tasks/button-content.ts create mode 100644 apps/browser/src/autofill/content/components/lit-stories/notification/at-risk-notification/container.lit-stories.ts create mode 100644 apps/browser/src/autofill/content/components/notification/at-risk-password/body.ts create mode 100644 apps/browser/src/autofill/content/components/notification/at-risk-password/container.ts create mode 100644 apps/browser/src/autofill/content/components/notification/at-risk-password/footer.ts create mode 100644 apps/browser/src/autofill/content/components/notification/at-risk-password/message.ts create mode 100644 apps/browser/src/autofill/services/notification-change-login-password.service.ts diff --git a/apps/browser/src/_locales/en/messages.json b/apps/browser/src/_locales/en/messages.json index 74eb5992dc7..3a8c7f14bc0 100644 --- a/apps/browser/src/_locales/en/messages.json +++ b/apps/browser/src/_locales/en/messages.json @@ -2515,6 +2515,10 @@ "change": { "message": "Change" }, + "changePassword": { + "message": "Change password", + "description": "Change password button for browser at risk notification on login." + }, "changeButtonTitle": { "message": "Change password - $ITEMNAME$", "placeholders": { @@ -2524,6 +2528,9 @@ } } }, + "atRiskPassword": { + "message": "At-risk password" + }, "atRiskPasswords": { "message": "At-risk passwords" }, @@ -2558,6 +2565,26 @@ } } }, + "atRiskChangePrompt": { + "message": "Your password for this site is at-risk. $ORGANIZATION$ has requested that you change it.", + "placeholders": { + "organization": { + "content": "$1", + "example": "Acme Corp" + } + }, + "description": "Notification body when a login triggers an at-risk password change request and the change password domain is known." + }, + "atRiskNavigatePrompt": { + "message": "$ORGANIZATION$ wants you to change this password because it is at-risk. Navigate to your account settings to change the password.", + "placeholders": { + "organization": { + "content": "$1", + "example": "Acme Corp" + } + }, + "description": "Notification body when a login triggers an at-risk password change request and no change password domain is provided." + }, "reviewAndChangeAtRiskPassword": { "message": "Review and change one at-risk password" }, diff --git a/apps/browser/src/autofill/background/abstractions/notification.background.ts b/apps/browser/src/autofill/background/abstractions/notification.background.ts index db110319d20..9c9c5c0e243 100644 --- a/apps/browser/src/autofill/background/abstractions/notification.background.ts +++ b/apps/browser/src/autofill/background/abstractions/notification.background.ts @@ -1,6 +1,9 @@ import { NeverDomains } from "@bitwarden/common/models/domain/domain-service"; import { ServerConfig } from "@bitwarden/common/platform/abstractions/config/server-config"; +import { UserId } from "@bitwarden/common/types/guid"; +import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; import { FolderView } from "@bitwarden/common/vault/models/view/folder.view"; +import { SecurityTask } from "@bitwarden/common/vault/tasks"; import { CollectionView } from "../../content/components/common-types"; import { NotificationQueueMessageTypes } from "../../enums/notification-queue-message-type.enum"; @@ -32,10 +35,17 @@ interface AddUnlockVaultQueueMessage extends NotificationQueueMessage { type: "unlock"; } +interface AtRiskPasswordQueueMessage extends NotificationQueueMessage { + type: "at-risk-password"; + organizationName: string; + passwordChangeUri?: string; +} + type NotificationQueueMessageItem = | AddLoginQueueMessage | AddChangePasswordQueueMessage - | AddUnlockVaultQueueMessage; + | AddUnlockVaultQueueMessage + | AtRiskPasswordQueueMessage; type LockedVaultPendingNotificationsData = { commandToRetry: { @@ -50,6 +60,13 @@ type LockedVaultPendingNotificationsData = { target: string; }; +type AtRiskPasswordNotificationsData = { + activeUserId: UserId; + cipher: CipherView; + securityTask: SecurityTask; + uri: string; +}; + type AdjustNotificationBarMessageData = { height: number; }; @@ -76,7 +93,8 @@ type NotificationBackgroundExtensionMessage = { data?: Partial & Partial & Partial & - Partial; + Partial & + Partial; login?: AddLoginMessageData; folder?: string; edit?: boolean; @@ -101,10 +119,20 @@ type NotificationBackgroundExtensionMessageHandlers = { sender, }: BackgroundOnMessageHandlerParams) => Promise; bgCloseNotificationBar: ({ message, sender }: BackgroundOnMessageHandlerParams) => Promise; - bgOpenAtRisksPasswords: ({ message, sender }: BackgroundOnMessageHandlerParams) => Promise; + bgOpenAtRiskPasswords: ({ message, sender }: BackgroundOnMessageHandlerParams) => Promise; bgAdjustNotificationBar: ({ message, sender }: BackgroundOnMessageHandlerParams) => Promise; - bgAddLogin: ({ message, sender }: BackgroundOnMessageHandlerParams) => Promise; - bgChangedPassword: ({ message, sender }: BackgroundOnMessageHandlerParams) => Promise; + bgTriggerAddLoginNotification: ({ + message, + sender, + }: BackgroundOnMessageHandlerParams) => Promise; + bgTriggerChangedPasswordNotification: ({ + message, + sender, + }: BackgroundOnMessageHandlerParams) => Promise; + bgTriggerAtRiskPasswordNotification: ({ + message, + sender, + }: BackgroundOnMessageHandlerParams) => Promise; bgRemoveTabFromNotificationQueue: ({ sender }: BackgroundSenderParam) => void; bgSaveCipher: ({ message, sender }: BackgroundOnMessageHandlerParams) => void; bgOpenAddEditVaultItemPopout: ({ diff --git a/apps/browser/src/autofill/background/notification.background.spec.ts b/apps/browser/src/autofill/background/notification.background.spec.ts index b161200215a..5e7e3ed30f5 100644 --- a/apps/browser/src/autofill/background/notification.background.spec.ts +++ b/apps/browser/src/autofill/background/notification.background.spec.ts @@ -275,7 +275,7 @@ describe("NotificationBackground", () => { }); }); - describe("bgAddLogin message handler", () => { + describe("bgTriggerAddLoginNotification message handler", () => { let tab: chrome.tabs.Tab; let sender: chrome.runtime.MessageSender; let getEnableAddedLoginPromptSpy: jest.SpyInstance; @@ -305,7 +305,7 @@ describe("NotificationBackground", () => { it("skips attempting to add the login if the user is logged out", async () => { const message: NotificationBackgroundExtensionMessage = { - command: "bgAddLogin", + command: "bgTriggerAddLoginNotification", login: { username: "test", password: "password", url: "https://example.com" }, }; activeAccountStatusMock$.next(AuthenticationStatus.LoggedOut); @@ -319,7 +319,7 @@ describe("NotificationBackground", () => { it("skips attempting to add the login if the login data does not contain a valid url", async () => { const message: NotificationBackgroundExtensionMessage = { - command: "bgAddLogin", + command: "bgTriggerAddLoginNotification", login: { username: "test", password: "password", url: "" }, }; activeAccountStatusMock$.next(AuthenticationStatus.Locked); @@ -333,7 +333,7 @@ describe("NotificationBackground", () => { it("skips attempting to add the login if the user with a locked vault has disabled the login notification", async () => { const message: NotificationBackgroundExtensionMessage = { - command: "bgAddLogin", + command: "bgTriggerAddLoginNotification", login: { username: "test", password: "password", url: "https://example.com" }, }; activeAccountStatusMock$.next(AuthenticationStatus.Locked); @@ -350,7 +350,7 @@ describe("NotificationBackground", () => { it("skips attempting to add the login if the user with an unlocked vault has disabled the login notification", async () => { const message: NotificationBackgroundExtensionMessage = { - command: "bgAddLogin", + command: "bgTriggerAddLoginNotification", login: { username: "test", password: "password", url: "https://example.com" }, }; activeAccountStatusMock$.next(AuthenticationStatus.Unlocked); @@ -368,7 +368,7 @@ describe("NotificationBackground", () => { it("skips attempting to change the password for an existing login if the user has disabled changing the password notification", async () => { const message: NotificationBackgroundExtensionMessage = { - command: "bgAddLogin", + command: "bgTriggerAddLoginNotification", login: { username: "test", password: "password", url: "https://example.com" }, }; activeAccountStatusMock$.next(AuthenticationStatus.Unlocked); @@ -390,7 +390,7 @@ describe("NotificationBackground", () => { it("skips attempting to change the password for an existing login if the password has not changed", async () => { const message: NotificationBackgroundExtensionMessage = { - command: "bgAddLogin", + command: "bgTriggerAddLoginNotification", login: { username: "test", password: "password", url: "https://example.com" }, }; activeAccountStatusMock$.next(AuthenticationStatus.Unlocked); @@ -410,7 +410,10 @@ describe("NotificationBackground", () => { it("adds the login to the queue if the user has a locked account", async () => { const login = { username: "test", password: "password", url: "https://example.com" }; - const message: NotificationBackgroundExtensionMessage = { command: "bgAddLogin", login }; + const message: NotificationBackgroundExtensionMessage = { + command: "bgTriggerAddLoginNotification", + login, + }; activeAccountStatusMock$.next(AuthenticationStatus.Locked); getEnableAddedLoginPromptSpy.mockReturnValueOnce(true); @@ -426,7 +429,10 @@ describe("NotificationBackground", () => { password: "password", url: "https://example.com", } as any; - const message: NotificationBackgroundExtensionMessage = { command: "bgAddLogin", login }; + const message: NotificationBackgroundExtensionMessage = { + command: "bgTriggerAddLoginNotification", + login, + }; activeAccountStatusMock$.next(AuthenticationStatus.Unlocked); getEnableAddedLoginPromptSpy.mockReturnValueOnce(true); getAllDecryptedForUrlSpy.mockResolvedValueOnce([ @@ -441,7 +447,10 @@ describe("NotificationBackground", () => { it("adds a change password message to the queue if the user has changed an existing cipher's password", async () => { const login = { username: "tEsT", password: "password", url: "https://example.com" }; - const message: NotificationBackgroundExtensionMessage = { command: "bgAddLogin", login }; + const message: NotificationBackgroundExtensionMessage = { + command: "bgTriggerAddLoginNotification", + login, + }; activeAccountStatusMock$.next(AuthenticationStatus.Unlocked); getEnableAddedLoginPromptSpy.mockResolvedValueOnce(true); getEnableChangedPasswordPromptSpy.mockResolvedValueOnce(true); @@ -464,7 +473,7 @@ describe("NotificationBackground", () => { }); }); - describe("bgChangedPassword message handler", () => { + describe("bgTriggerChangedPasswordNotification message handler", () => { let tab: chrome.tabs.Tab; let sender: chrome.runtime.MessageSender; let pushChangePasswordToQueueSpy: jest.SpyInstance; @@ -482,7 +491,7 @@ describe("NotificationBackground", () => { it("skips attempting to add the change password message to the queue if the passed url is not valid", async () => { const message: NotificationBackgroundExtensionMessage = { - command: "bgChangedPassword", + command: "bgTriggerChangedPasswordNotification", data: { newPassword: "newPassword", currentPassword: "currentPassword", url: "" }, }; @@ -494,7 +503,7 @@ describe("NotificationBackground", () => { it("adds a change password message to the queue if the user does not have an unlocked account", async () => { const message: NotificationBackgroundExtensionMessage = { - command: "bgChangedPassword", + command: "bgTriggerChangedPasswordNotification", data: { newPassword: "newPassword", currentPassword: "currentPassword", @@ -517,7 +526,7 @@ describe("NotificationBackground", () => { it("skips adding a change password message to the queue if the multiple ciphers exist for the passed URL and the current password is not found within the list of ciphers", async () => { const message: NotificationBackgroundExtensionMessage = { - command: "bgChangedPassword", + command: "bgTriggerChangedPasswordNotification", data: { newPassword: "newPassword", currentPassword: "currentPassword", @@ -538,7 +547,7 @@ describe("NotificationBackground", () => { it("skips adding a change password message if more than one existing cipher is found with a matching password ", async () => { const message: NotificationBackgroundExtensionMessage = { - command: "bgChangedPassword", + command: "bgTriggerChangedPasswordNotification", data: { newPassword: "newPassword", currentPassword: "currentPassword", @@ -560,7 +569,7 @@ describe("NotificationBackground", () => { it("adds a change password message to the queue if a single cipher matches the passed current password", async () => { const message: NotificationBackgroundExtensionMessage = { - command: "bgChangedPassword", + command: "bgTriggerChangedPasswordNotification", data: { newPassword: "newPassword", currentPassword: "currentPassword", @@ -588,7 +597,7 @@ describe("NotificationBackground", () => { it("skips adding a change password message if no current password is passed in the message and more than one cipher is found for a url", async () => { const message: NotificationBackgroundExtensionMessage = { - command: "bgChangedPassword", + command: "bgTriggerChangedPasswordNotification", data: { newPassword: "newPassword", url: "https://example.com", @@ -609,7 +618,7 @@ describe("NotificationBackground", () => { it("adds a change password message to the queue if no current password is passed with the message, but a single cipher is matched for the uri", async () => { const message: NotificationBackgroundExtensionMessage = { - command: "bgChangedPassword", + command: "bgTriggerChangedPasswordNotification", data: { newPassword: "newPassword", url: "https://example.com", diff --git a/apps/browser/src/autofill/background/notification.background.ts b/apps/browser/src/autofill/background/notification.background.ts index cb6a67c8137..3c63d423aaa 100644 --- a/apps/browser/src/autofill/background/notification.background.ts +++ b/apps/browser/src/autofill/background/notification.background.ts @@ -3,7 +3,10 @@ import { firstValueFrom, switchMap, map, of } from "rxjs"; import { CollectionService } from "@bitwarden/admin-console/common"; -import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; +import { + getOrganizationById, + OrganizationService, +} from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; import { PolicyType } from "@bitwarden/common/admin-console/enums"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; @@ -55,6 +58,7 @@ import { import { CollectionView } from "../content/components/common-types"; import { NotificationQueueMessageType } from "../enums/notification-queue-message-type.enum"; import { AutofillService } from "../services/abstractions/autofill.service"; +import { TemporaryNotificationChangeLoginService } from "../services/notification-change-login-password.service"; import { AddChangePasswordQueueMessage, @@ -81,14 +85,18 @@ export default class NotificationBackground { ExtensionCommand.AutofillIdentity, ]); private readonly extensionMessageHandlers: NotificationBackgroundExtensionMessageHandlers = { - bgAddLogin: ({ message, sender }) => this.addLogin(message, sender), bgAdjustNotificationBar: ({ message, sender }) => this.handleAdjustNotificationBarMessage(message, sender), - bgChangedPassword: ({ message, sender }) => this.changedPassword(message, sender), + bgTriggerAddLoginNotification: ({ message, sender }) => + this.triggerAddLoginNotification(message, sender), + bgTriggerChangedPasswordNotification: ({ message, sender }) => + this.triggerChangedPasswordNotification(message, sender), + bgTriggerAtRiskPasswordNotification: ({ message, sender }) => + this.triggerAtRiskPasswordNotification(message, sender), bgCloseNotificationBar: ({ message, sender }) => this.handleCloseNotificationBarMessage(message, sender), - bgOpenAtRisksPasswords: ({ message, sender }) => - this.handleOpenAtRisksPasswordsMessage(message, sender), + bgOpenAtRiskPasswords: ({ message, sender }) => + this.handleOpenAtRiskPasswordsMessage(message, sender), bgGetActiveUserServerConfig: () => this.getActiveUserServerConfig(), bgGetDecryptedCiphers: () => this.getNotificationCipherData(), bgGetEnableChangedPasswordPrompt: () => this.getEnableChangedPasswordPrompt(), @@ -341,12 +349,17 @@ export default class NotificationBackground { tab: chrome.tabs.Tab, notificationQueueMessage: NotificationQueueMessageItem, ) { - const notificationType = notificationQueueMessage.type; + const { + type: notificationType, + wasVaultLocked: isVaultLocked, + launchTimestamp, + ...params + } = notificationQueueMessage; const typeData: NotificationTypeData = { - isVaultLocked: notificationQueueMessage.wasVaultLocked, + isVaultLocked, theme: await firstValueFrom(this.themeStateService.selectedTheme$), - launchTimestamp: notificationQueueMessage.launchTimestamp, + launchTimestamp, }; switch (notificationType) { @@ -358,6 +371,7 @@ export default class NotificationBackground { await BrowserApi.tabSendMessageData(tab, "openNotificationBar", { type: notificationType, typeData, + params, }); } @@ -375,6 +389,48 @@ export default class NotificationBackground { } } + /** + * Sends a message to trigger the at risk password notification + * + * @param message - The extension message + * @param sender - The contextual sender of the message + */ + async triggerAtRiskPasswordNotification( + message: NotificationBackgroundExtensionMessage, + sender: chrome.runtime.MessageSender, + ): Promise { + const { activeUserId, securityTask, cipher } = message.data; + const domain = Utils.getDomain(sender.tab.url); + const passwordChangeUri = + await new TemporaryNotificationChangeLoginService().getChangePasswordUrl(cipher); + + const authStatus = await this.getAuthStatus(); + + const wasVaultLocked = authStatus === AuthenticationStatus.Locked; + + const organization = await firstValueFrom( + this.organizationService + .organizations$(activeUserId) + .pipe(getOrganizationById(securityTask.organizationId)), + ); + + this.removeTabFromNotificationQueue(sender.tab); + const launchTimestamp = new Date().getTime(); + const queueMessage: NotificationQueueMessageItem = { + domain, + wasVaultLocked, + type: NotificationQueueMessageType.AtRiskPassword, + passwordChangeUri, + organizationName: organization.name, + tab: sender.tab, + launchTimestamp, + expires: new Date(launchTimestamp + NOTIFICATION_BAR_LIFESPAN_MS), + }; + this.notificationQueue.push(queueMessage); + await this.checkNotificationQueue(sender.tab); + return true; + } + /** * Adds a login message to the notification queue, prompting the user to save * the login if it does not already exist in the vault. If the cipher exists @@ -383,20 +439,20 @@ export default class NotificationBackground { * @param message - The message to add to the queue * @param sender - The contextual sender of the message */ - async addLogin( + async triggerAddLoginNotification( message: NotificationBackgroundExtensionMessage, sender: chrome.runtime.MessageSender, - ) { + ): Promise { const authStatus = await this.getAuthStatus(); if (authStatus === AuthenticationStatus.LoggedOut) { - return; + return false; } const loginInfo = message.login; const normalizedUsername = loginInfo.username ? loginInfo.username.toLowerCase() : ""; const loginDomain = Utils.getDomain(loginInfo.url); if (loginDomain == null) { - return; + return false; } const addLoginIsEnabled = await this.getEnableAddedLoginPrompt(); @@ -406,14 +462,14 @@ export default class NotificationBackground { await this.pushAddLoginToQueue(loginDomain, loginInfo, sender.tab, true); } - return; + return false; } const activeUserId = await firstValueFrom( this.accountService.activeAccount$.pipe(getOptionalUserId), ); if (activeUserId == null) { - return; + return false; } const ciphers = await this.cipherService.getAllDecryptedForUrl(loginInfo.url, activeUserId); @@ -422,7 +478,7 @@ export default class NotificationBackground { ); if (addLoginIsEnabled && usernameMatches.length === 0) { await this.pushAddLoginToQueue(loginDomain, loginInfo, sender.tab); - return; + return true; } const changePasswordIsEnabled = await this.getEnableChangedPasswordPrompt(); @@ -438,7 +494,9 @@ export default class NotificationBackground { loginInfo.password, sender.tab, ); + return true; } + return false; } private async pushAddLoginToQueue( @@ -472,14 +530,14 @@ export default class NotificationBackground { * @param message - The message to add to the queue * @param sender - The contextual sender of the message */ - async changedPassword( + async triggerChangedPasswordNotification( message: NotificationBackgroundExtensionMessage, sender: chrome.runtime.MessageSender, ) { const changeData = message.data as ChangePasswordMessageData; const loginDomain = Utils.getDomain(changeData.url); if (loginDomain == null) { - return; + return false; } if ((await this.getAuthStatus()) < AuthenticationStatus.Unlocked) { @@ -490,7 +548,7 @@ export default class NotificationBackground { sender.tab, true, ); - return; + return true; } let id: string = null; @@ -498,7 +556,7 @@ export default class NotificationBackground { this.accountService.activeAccount$.pipe(getOptionalUserId), ); if (activeUserId == null) { - return; + return false; } const ciphers = await this.cipherService.getAllDecryptedForUrl(changeData.url, activeUserId); @@ -514,7 +572,9 @@ export default class NotificationBackground { } if (id != null) { await this.pushChangePasswordToQueue(id, loginDomain, changeData.newPassword, sender.tab); + return true; } + return false; } /** @@ -900,7 +960,7 @@ export default class NotificationBackground { return null; } - private async getSecurityTasks(userId: UserId) { + async getSecurityTasks(userId: UserId) { let tasks: SecurityTask[] = []; if (userId) { @@ -1074,7 +1134,7 @@ export default class NotificationBackground { * @param message - The extension message * @param sender - The contextual sender of the message */ - private async handleOpenAtRisksPasswordsMessage( + private async handleOpenAtRiskPasswordsMessage( message: NotificationBackgroundExtensionMessage, sender: chrome.runtime.MessageSender, ) { diff --git a/apps/browser/src/autofill/background/overlay-notifications.background.spec.ts b/apps/browser/src/autofill/background/overlay-notifications.background.spec.ts index a51757dabea..00114330bc4 100644 --- a/apps/browser/src/autofill/background/overlay-notifications.background.spec.ts +++ b/apps/browser/src/autofill/background/overlay-notifications.background.spec.ts @@ -1,9 +1,12 @@ import { mock, MockProxy } from "jest-mock-extended"; +import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { CLEAR_NOTIFICATION_LOGIN_DATA_DURATION } from "@bitwarden/common/autofill/constants"; import { ServerConfig } from "@bitwarden/common/platform/abstractions/config/server-config"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; import { EnvironmentServerConfigData } from "@bitwarden/common/platform/models/data/server-config.data"; +import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; +import { TaskService } from "@bitwarden/common/vault/tasks"; import { BrowserApi } from "../../platform/browser/browser-api"; import AutofillField from "../models/autofill-field"; @@ -24,6 +27,9 @@ import { OverlayNotificationsBackground } from "./overlay-notifications.backgrou describe("OverlayNotificationsBackground", () => { let logService: MockProxy; let notificationBackground: NotificationBackground; + let taskService: TaskService; + let accountService: AccountService; + let cipherService: CipherService; let getEnableChangedPasswordPromptSpy: jest.SpyInstance; let getEnableAddedLoginPromptSpy: jest.SpyInstance; let overlayNotificationsBackground: OverlayNotificationsBackground; @@ -32,6 +38,9 @@ describe("OverlayNotificationsBackground", () => { jest.useFakeTimers(); logService = mock(); notificationBackground = mock(); + taskService = mock(); + accountService = mock(); + cipherService = mock(); getEnableChangedPasswordPromptSpy = jest .spyOn(notificationBackground, "getEnableChangedPasswordPrompt") .mockResolvedValue(true); @@ -41,6 +50,9 @@ describe("OverlayNotificationsBackground", () => { overlayNotificationsBackground = new OverlayNotificationsBackground( logService, notificationBackground, + taskService, + accountService, + cipherService, ); await overlayNotificationsBackground.init(); }); @@ -329,8 +341,11 @@ describe("OverlayNotificationsBackground", () => { tab: { id: 1 }, url: "https://example.com", }); - notificationChangedPasswordSpy = jest.spyOn(notificationBackground, "changedPassword"); - notificationAddLoginSpy = jest.spyOn(notificationBackground, "addLogin"); + notificationChangedPasswordSpy = jest.spyOn( + notificationBackground, + "triggerChangedPasswordNotification", + ); + notificationAddLoginSpy = jest.spyOn(notificationBackground, "triggerAddLoginNotification"); sendMockExtensionMessage( { command: "collectPageDetailsResponse", details: pageDetails }, diff --git a/apps/browser/src/autofill/background/overlay-notifications.background.ts b/apps/browser/src/autofill/background/overlay-notifications.background.ts index 5c85ce132d7..93357113fc4 100644 --- a/apps/browser/src/autofill/background/overlay-notifications.background.ts +++ b/apps/browser/src/autofill/background/overlay-notifications.background.ts @@ -1,9 +1,15 @@ // FIXME: Update this file to be type safe and remove this and next line // @ts-strict-ignore -import { Subject, switchMap, timer } from "rxjs"; +import { firstValueFrom, Subject, switchMap, timer } from "rxjs"; +import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { getOptionalUserId } from "@bitwarden/common/auth/services/account.service"; import { CLEAR_NOTIFICATION_LOGIN_DATA_DURATION } from "@bitwarden/common/autofill/constants"; 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 { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; +import { SecurityTask, SecurityTaskStatus, TaskService } from "@bitwarden/common/vault/tasks"; import { BrowserApi } from "../../platform/browser/browser-api"; import { generateDomainMatchPatterns, isInvalidResponseStatusCode } from "../utils"; @@ -19,6 +25,12 @@ import { } from "./abstractions/overlay-notifications.background"; import NotificationBackground from "./notification.background"; +type LoginSecurityTaskInfo = { + securityTask: SecurityTask; + cipher: CipherView; + uri: ModifyLoginCipherFormData["uri"]; +}; + export class OverlayNotificationsBackground implements OverlayNotificationsBackgroundInterface { private websiteOriginsWithFields: WebsiteOriginsWithFields = new Map(); private activeFormSubmissionRequests: ActiveFormSubmissionRequests = new Set(); @@ -35,6 +47,9 @@ export class OverlayNotificationsBackground implements OverlayNotificationsBackg constructor( private logService: LogService, private notificationBackground: NotificationBackground, + private taskService: TaskService, + private accountService: AccountService, + private cipherService: CipherService, ) {} /** @@ -259,8 +274,8 @@ export class OverlayNotificationsBackground implements OverlayNotificationsBackg const modifyLoginData = this.modifyLoginCipherFormData.get(tabId); return ( !modifyLoginData || - !this.shouldTriggerAddLoginNotification(modifyLoginData) || - !this.shouldTriggerChangePasswordNotification(modifyLoginData) + !this.shouldAttemptAddLoginNotification(modifyLoginData) || + !this.shouldAttemptChangedPasswordNotification(modifyLoginData) ); }; @@ -404,10 +419,11 @@ export class OverlayNotificationsBackground implements OverlayNotificationsBackg modifyLoginData: ModifyLoginCipherFormData, tab: chrome.tabs.Tab, ) => { - if (this.shouldTriggerChangePasswordNotification(modifyLoginData)) { + let result: string; + if (this.shouldAttemptChangedPasswordNotification(modifyLoginData)) { // These notifications are temporarily setup as "messages" to the notification background. // This will be structured differently in a future refactor. - await this.notificationBackground.changedPassword( + const success = await this.notificationBackground.triggerChangedPasswordNotification( { command: "bgChangedPassword", data: { @@ -418,14 +434,15 @@ export class OverlayNotificationsBackground implements OverlayNotificationsBackg }, { tab }, ); - this.clearCompletedWebRequest(requestId, tab); - return; + if (!success) { + result = "Unqualified changedPassword notification attempt."; + } } - if (this.shouldTriggerAddLoginNotification(modifyLoginData)) { - await this.notificationBackground.addLogin( + if (this.shouldAttemptAddLoginNotification(modifyLoginData)) { + const success = await this.notificationBackground.triggerAddLoginNotification( { - command: "bgAddLogin", + command: "bgTriggerAddLoginNotification", login: { url: modifyLoginData.uri, username: modifyLoginData.username, @@ -434,8 +451,44 @@ export class OverlayNotificationsBackground implements OverlayNotificationsBackg }, { tab }, ); - this.clearCompletedWebRequest(requestId, tab); + if (!success) { + result = "Unqualified addLogin notification attempt."; + } } + + const shouldGetTasks = + (await this.notificationBackground.getNotificationFlag()) && !modifyLoginData.newPassword; + + if (shouldGetTasks) { + const activeUserId = await firstValueFrom( + this.accountService.activeAccount$.pipe(getOptionalUserId), + ); + + if (activeUserId) { + const loginSecurityTaskInfo = await this.getSecurityTaskAndCipherForLoginData( + modifyLoginData, + activeUserId, + ); + + if (loginSecurityTaskInfo) { + await this.notificationBackground.triggerAtRiskPasswordNotification( + { + command: "bgTriggerAtRiskPasswordNotification", + data: { + activeUserId, + cipher: loginSecurityTaskInfo.cipher, + securityTask: loginSecurityTaskInfo.securityTask, + }, + }, + { tab }, + ); + } else { + result = "Unqualified atRiskPassword notification attempt."; + } + } + } + this.clearCompletedWebRequest(requestId, tab); + return result; }; /** @@ -443,7 +496,7 @@ export class OverlayNotificationsBackground implements OverlayNotificationsBackg * * @param modifyLoginData - The modified login form data */ - private shouldTriggerChangePasswordNotification = ( + private shouldAttemptChangedPasswordNotification = ( modifyLoginData: ModifyLoginCipherFormData, ) => { return modifyLoginData?.newPassword && !modifyLoginData.username; @@ -454,10 +507,66 @@ export class OverlayNotificationsBackground implements OverlayNotificationsBackg * * @param modifyLoginData - The modified login form data */ - private shouldTriggerAddLoginNotification = (modifyLoginData: ModifyLoginCipherFormData) => { + private shouldAttemptAddLoginNotification = (modifyLoginData: ModifyLoginCipherFormData) => { return modifyLoginData?.username && (modifyLoginData.password || modifyLoginData.newPassword); }; + /** + * If there is a security task for this cipher at login, return the task, cipher view, and uri. + * + * @param modifyLoginData - The modified login form data + * @param activeUserId - The currently logged in user ID + */ + private async getSecurityTaskAndCipherForLoginData( + modifyLoginData: ModifyLoginCipherFormData, + activeUserId: UserId, + ): Promise { + const tasks: SecurityTask[] = await this.notificationBackground.getSecurityTasks(activeUserId); + if (!tasks?.length) { + return null; + } + + const urlCiphers: CipherView[] = await this.cipherService.getAllDecryptedForUrl( + modifyLoginData.uri, + activeUserId, + ); + if (!urlCiphers?.length) { + return null; + } + + const securityTaskForLogin = urlCiphers.reduce( + (taskInfo: LoginSecurityTaskInfo | null, cipher: CipherView) => { + if ( + // exit early if info was found already + taskInfo || + // exit early if the cipher was deleted + cipher.deletedDate || + // exit early if the entered login info doesn't match an existing cipher + modifyLoginData.username !== cipher.login.username || + modifyLoginData.password !== cipher.login.password + ) { + return taskInfo; + } + + // Find the first security task for the cipherId belonging to the entered login + const cipherSecurityTask = tasks.find( + ({ cipherId, status }) => + cipher.id === cipherId && // match security task cipher id to url cipher id + status === SecurityTaskStatus.Pending, // security task has not been completed + ); + + if (cipherSecurityTask) { + return { securityTask: cipherSecurityTask, cipher, uri: modifyLoginData.uri }; + } + + return taskInfo; + }, + null, + ); + + return securityTaskForLogin; + } + /** * Clears the completed web request and removes the modified login form data for the tab. * diff --git a/apps/browser/src/autofill/content/components/buttons/action-button.ts b/apps/browser/src/autofill/content/components/buttons/action-button.ts index 8d8bfacec77..74ac2518226 100644 --- a/apps/browser/src/autofill/content/components/buttons/action-button.ts +++ b/apps/browser/src/autofill/content/components/buttons/action-button.ts @@ -10,6 +10,7 @@ export type ActionButtonProps = { disabled?: boolean; theme: Theme; handleClick: (e: Event) => void; + fullWidth?: boolean; }; export function ActionButton({ @@ -17,6 +18,7 @@ export function ActionButton({ disabled = false, theme, handleClick, + fullWidth = true, }: ActionButtonProps) { const handleButtonClick = (event: Event) => { if (!disabled) { @@ -26,7 +28,7 @@ export function ActionButton({ return html` - + @if (!isSelfHosted && !sponsoredFamily.validUntil) { + + } - + @if (!isSelfHosted && !sponsoredFamily.validUntil) {
-
+ }
@@ -87,7 +88,7 @@ } -
+
} @else if (!loading()) {
diff --git a/apps/web/src/app/shared/loose-components.module.ts b/apps/web/src/app/shared/loose-components.module.ts index 44323614f17..63e54c46a8f 100644 --- a/apps/web/src/app/shared/loose-components.module.ts +++ b/apps/web/src/app/shared/loose-components.module.ts @@ -26,6 +26,7 @@ import { UpdatePasswordComponent } from "../auth/update-password.component"; import { UpdateTempPasswordComponent } from "../auth/update-temp-password.component"; import { VerifyEmailTokenComponent } from "../auth/verify-email-token.component"; import { VerifyRecoverDeleteComponent } from "../auth/verify-recover-delete.component"; +import { FreeBitwardenFamiliesComponent } from "../billing/members/free-bitwarden-families.component"; import { SponsoredFamiliesComponent } from "../billing/settings/sponsored-families.component"; import { SponsoringOrgRowComponent } from "../billing/settings/sponsoring-org-row.component"; // eslint-disable-next-line no-restricted-imports -- Temporarily disabled until DIRT refactors these out of this module @@ -46,7 +47,6 @@ import { OrganizationBadgeModule } from "../vault/individual-vault/organization- import { PipesModule } from "../vault/individual-vault/pipes/pipes.module"; import { PurgeVaultComponent } from "../vault/settings/purge-vault.component"; -import { FreeBitwardenFamiliesComponent } from "./../billing/members/free-bitwarden-families.component"; import { AccountFingerprintComponent } from "./components/account-fingerprint/account-fingerprint.component"; import { SharedModule } from "./shared.module"; diff --git a/apps/web/src/images/search.svg b/apps/web/src/images/search.svg index 36e0ea4bd23..7f1521fdd04 100644 --- a/apps/web/src/images/search.svg +++ b/apps/web/src/images/search.svg @@ -9,4 +9,4 @@ - + \ No newline at end of file From 8b42edf9dc96baef3b6174f2f9d27baf520af9f8 Mon Sep 17 00:00:00 2001 From: Bryan Cunningham Date: Wed, 11 Jun 2025 11:54:15 -0400 Subject: [PATCH 04/91] [CL-687] Updated dark mode color variables (#15123) * updated dark mode color variables * update light versions to match --- libs/components/src/tw-theme.css | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/libs/components/src/tw-theme.css b/libs/components/src/tw-theme.css index 24f0b7adaad..103b90e0752 100644 --- a/libs/components/src/tw-theme.css +++ b/libs/components/src/tw-theme.css @@ -29,19 +29,19 @@ --color-info-100: 219 229 246; --color-info-600: 121 161 233; - --color-info-700: 26 65 172; + --color-info-700: 13 36 123; - --color-warning-100: 255 248 228; + --color-warning-100: 255 244 212; --color-warning-600: 255 191 0; - --color-warning-700: 172 88 0; + --color-warning-700: 142 64 0; --color-danger-100: 255 236 239; --color-danger-600: 203 38 58; --color-danger-700: 149 27 42; - --color-success-100: 191 236 195; + --color-success-100: 213 243 216; --color-success-600: 12 128 24; - --color-success-700: 11 111 21; + --color-success-700: 8 81 15; --color-notification-100: 255 225 247; --color-notification-600: 192 17 118; @@ -85,19 +85,19 @@ --color-secondary-600: 143 152 166; --color-secondary-700: 158 167 181; - --color-success-100: 11 111 21; + --color-success-100: 8 81 15; --color-success-600: 107 241 120; - --color-success-700: 191 236 195; + --color-success-700: 213 243 216; --color-danger-100: 149 27 42; --color-danger-600: 255 78 99; --color-danger-700: 255 236 239; - --color-warning-100: 172 88 0; + --color-warning-100: 142 64 0; --color-warning-600: 255 191 0; - --color-warning-700: 255 248 228; + --color-warning-700: 255 244 212; - --color-info-100: 26 65 172; + --color-info-100: 13 36 123; --color-info-600: 121 161 233; --color-info-700: 219 229 246; From 1175da38459fcf0dd4aedd075d9766516880832b Mon Sep 17 00:00:00 2001 From: Jordan Aasen <166539328+jaasen-livefront@users.noreply.github.com> Date: Wed, 11 Jun 2025 09:30:12 -0700 Subject: [PATCH 05/91] [PM-20642] - [Vault] [Web App] Front End Changes to Enforce "Remove card item type policy" (#15097) * add restricted item types service and apply it to filter web cipher * code cleanup. add shareReplay * account for multiple orgs when restricting item types * restrict item types for specific orgs * clean up logic. use policiesByType$ * track by item.type * clean up filtering. prefer observable. do not exempt owners for restricted item types * simplify in vault-filter. move item filter logic to vault. fix tests * don't return early in filter-function --- .../vault-filter/vault-filter.component.ts | 3 + .../restricted-item-types.component.ts | 2 +- .../vault-items/vault-items.component.ts | 2 - .../vault-items/vault-items.stories.ts | 7 + .../components/vault-filter.component.ts | 146 +++++++++++------- .../shared/models/filter-function.spec.ts | 41 +++++ .../shared/models/filter-function.ts | 24 ++- .../vault-header/vault-header.component.html | 26 +--- .../vault-header/vault-header.component.ts | 32 ++-- .../vault/individual-vault/vault.component.ts | 7 +- .../admin-console/enums/policy-type.enum.ts | 2 +- .../services/policy/default-policy.service.ts | 12 +- libs/vault/src/index.ts | 4 + .../restricted-item-types.service.spec.ts | 137 ++++++++++++++++ .../services/restricted-item-types.service.ts | 80 ++++++++++ 15 files changed, 423 insertions(+), 102 deletions(-) create mode 100644 libs/vault/src/services/restricted-item-types.service.spec.ts create mode 100644 libs/vault/src/services/restricted-item-types.service.ts diff --git a/apps/web/src/app/admin-console/organizations/collections/vault-filter/vault-filter.component.ts b/apps/web/src/app/admin-console/organizations/collections/vault-filter/vault-filter.component.ts index f7d7acfdc2d..ff6ec9af0af 100644 --- a/apps/web/src/app/admin-console/organizations/collections/vault-filter/vault-filter.component.ts +++ b/apps/web/src/app/admin-console/organizations/collections/vault-filter/vault-filter.component.ts @@ -12,6 +12,7 @@ import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.servic import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { TreeNode } from "@bitwarden/common/vault/models/domain/tree-node"; import { DialogService, ToastService } from "@bitwarden/components"; +import { RestrictedItemTypesService } from "@bitwarden/vault"; import { VaultFilterComponent as BaseVaultFilterComponent } from "../../../../vault/individual-vault/vault-filter/components/vault-filter.component"; import { VaultFilterService } from "../../../../vault/individual-vault/vault-filter/services/abstractions/vault-filter.service"; @@ -51,6 +52,7 @@ export class VaultFilterComponent protected dialogService: DialogService, protected configService: ConfigService, protected accountService: AccountService, + protected restrictedItemTypesService: RestrictedItemTypesService, ) { super( vaultFilterService, @@ -62,6 +64,7 @@ export class VaultFilterComponent dialogService, configService, accountService, + restrictedItemTypesService, ); } diff --git a/apps/web/src/app/admin-console/organizations/policies/restricted-item-types.component.ts b/apps/web/src/app/admin-console/organizations/policies/restricted-item-types.component.ts index 8dd8720a220..406014973f0 100644 --- a/apps/web/src/app/admin-console/organizations/policies/restricted-item-types.component.ts +++ b/apps/web/src/app/admin-console/organizations/policies/restricted-item-types.component.ts @@ -7,7 +7,7 @@ import { BasePolicy, BasePolicyComponent } from "./base-policy.component"; export class RestrictedItemTypesPolicy extends BasePolicy { name = "restrictedItemTypesPolicy"; description = "restrictedItemTypesPolicyDesc"; - type = PolicyType.RestrictedItemTypesPolicy; + type = PolicyType.RestrictedItemTypes; component = RestrictedItemTypesPolicyComponent; } diff --git a/apps/web/src/app/vault/components/vault-items/vault-items.component.ts b/apps/web/src/app/vault/components/vault-items/vault-items.component.ts index 9679f0879b9..9d94fb044b5 100644 --- a/apps/web/src/app/vault/components/vault-items/vault-items.component.ts +++ b/apps/web/src/app/vault/components/vault-items/vault-items.component.ts @@ -342,8 +342,6 @@ export class VaultItemsComponent { const ciphers: VaultItem[] = this.ciphers.map((cipher) => ({ cipher })); const items: VaultItem[] = [].concat(collections).concat(ciphers); - this.selection.clear(); - // All ciphers are selectable, collections only if they can be edited or deleted this.editableItems = items.filter( (item) => diff --git a/apps/web/src/app/vault/components/vault-items/vault-items.stories.ts b/apps/web/src/app/vault/components/vault-items/vault-items.stories.ts index 55807ed855f..e2c6f204d72 100644 --- a/apps/web/src/app/vault/components/vault-items/vault-items.stories.ts +++ b/apps/web/src/app/vault/components/vault-items/vault-items.stories.ts @@ -29,6 +29,7 @@ import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; import { LoginUriView } from "@bitwarden/common/vault/models/view/login-uri.view"; import { LoginView } from "@bitwarden/common/vault/models/view/login.view"; import { CipherAuthorizationService } from "@bitwarden/common/vault/services/cipher-authorization.service"; +import { RestrictedItemTypesService } from "@bitwarden/vault"; import { GroupView } from "../../../admin-console/organizations/core"; import { PreloadedEnglishI18nModule } from "../../../core/tests"; @@ -125,6 +126,12 @@ export default { }, }, }, + { + provide: RestrictedItemTypesService, + useValue: { + restricted$: of([]), // No restricted item types for this story + }, + }, ], }), applicationConfig({ diff --git a/apps/web/src/app/vault/individual-vault/vault-filter/components/vault-filter.component.ts b/apps/web/src/app/vault/individual-vault/vault-filter/components/vault-filter.component.ts index 6b974296f21..d21896e26fe 100644 --- a/apps/web/src/app/vault/individual-vault/vault-filter/components/vault-filter.component.ts +++ b/apps/web/src/app/vault/individual-vault/vault-filter/components/vault-filter.component.ts @@ -1,8 +1,15 @@ -// FIXME: Update this file to be type safe and remove this and next line -// @ts-strict-ignore import { Component, EventEmitter, inject, Input, OnDestroy, OnInit, Output } from "@angular/core"; import { Router } from "@angular/router"; -import { firstValueFrom, merge, Subject, switchMap, takeUntil } from "rxjs"; +import { + distinctUntilChanged, + firstValueFrom, + map, + merge, + shareReplay, + Subject, + switchMap, + takeUntil, +} from "rxjs"; import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; import { PolicyType } from "@bitwarden/common/admin-console/enums"; @@ -16,6 +23,7 @@ import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/pl import { CipherType } from "@bitwarden/common/vault/enums"; import { TreeNode } from "@bitwarden/common/vault/models/domain/tree-node"; import { DialogService, ToastService } from "@bitwarden/components"; +import { RestrictedItemTypesService } from "@bitwarden/vault"; import { TrialFlowService } from "../../../../billing/services/trial-flow.service"; import { VaultFilterService } from "../services/abstractions/vault-filter.service"; @@ -56,6 +64,45 @@ export class VaultFilterComponent implements OnInit, OnDestroy { return this.filters ? Object.values(this.filters) : []; } + allTypeFilters: CipherTypeFilter[] = [ + { + id: "favorites", + name: this.i18nService.t("favorites"), + type: "favorites", + icon: "bwi-star", + }, + { + id: "login", + name: this.i18nService.t("typeLogin"), + type: CipherType.Login, + icon: "bwi-globe", + }, + { + id: "card", + name: this.i18nService.t("typeCard"), + type: CipherType.Card, + icon: "bwi-credit-card", + }, + { + id: "identity", + name: this.i18nService.t("typeIdentity"), + type: CipherType.Identity, + icon: "bwi-id-card", + }, + { + id: "note", + name: this.i18nService.t("note"), + type: CipherType.SecureNote, + icon: "bwi-sticky-note", + }, + { + id: "sshKey", + name: this.i18nService.t("typeSshKey"), + type: CipherType.SshKey, + icon: "bwi-key", + }, + ]; + get searchPlaceholder() { if (this.activeFilter.isFavorites) { return "searchFavorites"; @@ -107,12 +154,17 @@ export class VaultFilterComponent implements OnInit, OnDestroy { protected dialogService: DialogService, protected configService: ConfigService, protected accountService: AccountService, + protected restrictedItemTypesService: RestrictedItemTypesService, ) {} async ngOnInit(): Promise { this.filters = await this.buildAllFilters(); - this.activeFilter.selectedCipherTypeNode = - (await this.getDefaultFilter()) as TreeNode; + if (this.filters?.typeFilter?.data$) { + this.activeFilter.selectedCipherTypeNode = (await firstValueFrom( + this.filters?.typeFilter.data$, + )) as TreeNode; + } + this.isLoaded = true; // Without refactoring the entire component, we need to manually update the organization filter whenever the policies update @@ -133,6 +185,9 @@ export class VaultFilterComponent implements OnInit, OnDestroy { takeUntil(this.destroy$), ) .subscribe((orgFilters) => { + if (!this.filters) { + return; + } this.filters.organizationFilter = orgFilters; }); } @@ -151,7 +206,6 @@ export class VaultFilterComponent implements OnInit, OnDestroy { if (!orgNode?.node.enabled) { this.toastService.showToast({ variant: "error", - title: null, message: this.i18nService.t("disabledOrganizationFilterError"), }); const metadata = await this.billingApiService.getOrganizationBillingMetadata(orgNode.node.id); @@ -190,10 +244,6 @@ export class VaultFilterComponent implements OnInit, OnDestroy { this.onEditFolder.emit(folder); }; - async getDefaultFilter(): Promise> { - return await firstValueFrom(this.filters?.typeFilter.data$); - } - async buildAllFilters(): Promise { const builderFilter = {} as VaultFilterList; builderFilter.organizationFilter = await this.addOrganizationFilter(); @@ -225,7 +275,7 @@ export class VaultFilterComponent implements OnInit, OnDestroy { const addAction = !singleOrgPolicy ? { text: "newOrganization", route: "/create-organization" } - : null; + : undefined; const orgFilterSection: VaultFilterSection = { data$: this.vaultFilterService.organizationTree$, @@ -233,7 +283,7 @@ export class VaultFilterComponent implements OnInit, OnDestroy { showHeader: !(singleOrgPolicy && personalVaultPolicy), isSelectable: true, }, - action: this.applyOrganizationFilter, + action: this.applyOrganizationFilter as (orgNode: TreeNode) => Promise, options: { component: OrganizationOptionsComponent }, add: addAction, divider: true, @@ -243,55 +293,31 @@ export class VaultFilterComponent implements OnInit, OnDestroy { } protected async addTypeFilter(excludeTypes: CipherStatus[] = []): Promise { - const allTypeFilters: CipherTypeFilter[] = [ - { - id: "favorites", - name: this.i18nService.t("favorites"), - type: "favorites", - icon: "bwi-star", - }, - { - id: "login", - name: this.i18nService.t("typeLogin"), - type: CipherType.Login, - icon: "bwi-globe", - }, - { - id: "card", - name: this.i18nService.t("typeCard"), - type: CipherType.Card, - icon: "bwi-credit-card", - }, - { - id: "identity", - name: this.i18nService.t("typeIdentity"), - type: CipherType.Identity, - icon: "bwi-id-card", - }, - { - id: "note", - name: this.i18nService.t("note"), - type: CipherType.SecureNote, - icon: "bwi-sticky-note", - }, - { - id: "sshKey", - name: this.i18nService.t("typeSshKey"), - type: CipherType.SshKey, - icon: "bwi-key", - }, - ]; + const allFilter: CipherTypeFilter = { id: "AllItems", name: "allItems", type: "all", icon: "" }; + + const data$ = this.restrictedItemTypesService.restricted$.pipe( + map((restricted) => { + // List of types restricted by all orgs + const restrictedByAll = restricted + .filter((r) => r.allowViewOrgIds.length === 0) + .map((r) => r.cipherType); + const toExclude = [...excludeTypes, ...restrictedByAll]; + return this.allTypeFilters.filter( + (f) => typeof f.type !== "string" && !toExclude.includes(f.type), + ); + }), + switchMap((allowed) => this.vaultFilterService.buildTypeTree(allFilter, allowed)), + distinctUntilChanged(), + shareReplay({ bufferSize: 1, refCount: true }), + ); const typeFilterSection: VaultFilterSection = { - data$: this.vaultFilterService.buildTypeTree( - { id: "AllItems", name: "allItems", type: "all", icon: "" }, - allTypeFilters.filter((f) => !excludeTypes.includes(f.type)), - ), + data$, header: { showHeader: true, isSelectable: true, }, - action: this.applyTypeFilter, + action: this.applyTypeFilter as (filterNode: TreeNode) => Promise, }; return typeFilterSection; } @@ -303,10 +329,10 @@ export class VaultFilterComponent implements OnInit, OnDestroy { showHeader: true, isSelectable: false, }, - action: this.applyFolderFilter, + action: this.applyFolderFilter as (filterNode: TreeNode) => Promise, edit: { filterName: this.i18nService.t("folder"), - action: this.editFolder, + action: this.editFolder as (filter: VaultFilterType) => void, }, }; return folderFilterSection; @@ -319,7 +345,9 @@ export class VaultFilterComponent implements OnInit, OnDestroy { showHeader: true, isSelectable: true, }, - action: this.applyCollectionFilter, + action: this.applyCollectionFilter as ( + filterNode: TreeNode, + ) => Promise, }; return collectionFilterSection; } @@ -346,7 +374,7 @@ export class VaultFilterComponent implements OnInit, OnDestroy { showHeader: false, isSelectable: true, }, - action: this.applyTypeFilter, + action: this.applyTypeFilter as (filterNode: TreeNode) => Promise, }; return trashFilterSection; } diff --git a/apps/web/src/app/vault/individual-vault/vault-filter/shared/models/filter-function.spec.ts b/apps/web/src/app/vault/individual-vault/vault-filter/shared/models/filter-function.spec.ts index 3082d7cb809..660aeb293a4 100644 --- a/apps/web/src/app/vault/individual-vault/vault-filter/shared/models/filter-function.spec.ts +++ b/apps/web/src/app/vault/individual-vault/vault-filter/shared/models/filter-function.spec.ts @@ -3,6 +3,7 @@ import { Unassigned } from "@bitwarden/admin-console/common"; import { CipherType } from "@bitwarden/common/vault/enums"; import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; +import { RestrictedCipherType } from "@bitwarden/vault"; import { createFilterFunction } from "./filter-function"; import { All } from "./routed-vault-filter.model"; @@ -214,6 +215,46 @@ describe("createFilter", () => { expect(result).toBe(true); }); }); + + describe("given restricted types", () => { + const restrictedTypes: RestrictedCipherType[] = [ + { cipherType: CipherType.Login, allowViewOrgIds: [] }, + ]; + + it("should filter out a cipher whose type is fully restricted", () => { + const cipher = createCipher({ type: CipherType.Login }); + const filterFunction = createFilterFunction({}, restrictedTypes); + + expect(filterFunction(cipher)).toBe(false); + }); + + it("should allow a cipher when the cipher's organization allows it", () => { + const cipher = createCipher({ type: CipherType.Login, organizationId: "org1" }); + const restricted: RestrictedCipherType[] = [ + { cipherType: CipherType.Login, allowViewOrgIds: ["org1"] }, + ]; + const filterFunction2 = createFilterFunction({}, restricted); + + expect(filterFunction2(cipher)).toBe(true); + }); + + it("should filter out a personal vault cipher when the owning orgs does not allow it", () => { + const cipher = createCipher({ type: CipherType.Card, organizationId: "org1" }); + const restricted2: RestrictedCipherType[] = [ + { cipherType: CipherType.Card, allowViewOrgIds: [] }, + ]; + const filterFunction3 = createFilterFunction({}, restricted2); + + expect(filterFunction3(cipher)).toBe(false); + }); + + it("should not filter a cipher if there are no restricted types", () => { + const cipher = createCipher({ type: CipherType.Login }); + const filterFunction = createFilterFunction({}, []); + + expect(filterFunction(cipher)).toBe(true); + }); + }); }); function createCipher(options: Partial = {}) { diff --git a/apps/web/src/app/vault/individual-vault/vault-filter/shared/models/filter-function.ts b/apps/web/src/app/vault/individual-vault/vault-filter/shared/models/filter-function.ts index a39918df4a7..61305fa5e49 100644 --- a/apps/web/src/app/vault/individual-vault/vault-filter/shared/models/filter-function.ts +++ b/apps/web/src/app/vault/individual-vault/vault-filter/shared/models/filter-function.ts @@ -1,12 +1,16 @@ import { Unassigned } from "@bitwarden/admin-console/common"; import { CipherType } from "@bitwarden/common/vault/enums"; import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; +import { RestrictedCipherType } from "@bitwarden/vault"; import { All, RoutedVaultFilterModel } from "./routed-vault-filter.model"; export type FilterFunction = (cipher: CipherView) => boolean; -export function createFilterFunction(filter: RoutedVaultFilterModel): FilterFunction { +export function createFilterFunction( + filter: RoutedVaultFilterModel, + restrictedTypes?: RestrictedCipherType[], +): FilterFunction { return (cipher) => { if (filter.type === "favorites" && !cipher.favorite) { return false; @@ -80,6 +84,24 @@ export function createFilterFunction(filter: RoutedVaultFilterModel): FilterFunc return false; } + // Restricted types + if (restrictedTypes && restrictedTypes.length > 0) { + // Filter the cipher if that type is restricted unless + // - The cipher belongs to an organization and that organization allows viewing the cipher type + // OR + // - The cipher belongs to the user's personal vault and at least one other organization does not restrict that type + if ( + restrictedTypes.some( + (restrictedType) => + restrictedType.cipherType === cipher.type && + (cipher.organizationId + ? !restrictedType.allowViewOrgIds.includes(cipher.organizationId) + : restrictedType.allowViewOrgIds.length === 0), + ) + ) { + return false; + } + } return true; }; } diff --git a/apps/web/src/app/vault/individual-vault/vault-header/vault-header.component.html b/apps/web/src/app/vault/individual-vault/vault-header/vault-header.component.html index af95a71ba8d..4ef8204cdfc 100644 --- a/apps/web/src/app/vault/individual-vault/vault-header/vault-header.component.html +++ b/apps/web/src/app/vault/individual-vault/vault-header/vault-header.component.html @@ -81,26 +81,12 @@ {{ "new" | i18n }} - - - - - + @for (item of cipherMenuItems$ | async; track item.type) { + + } `; } const actionButtonStyles = ({ disabled, - theme, fullWidth, + isLoading, + theme, }: { disabled: boolean; - theme: Theme; fullWidth: boolean; + isLoading: boolean; + theme: Theme; }) => css` ${typography.body2} user-select: none; + display: flex; + align-items: center; + justify-content: center; border: 1px solid transparent; border-radius: ${border.radius.full}; padding: ${spacing["1"]} ${spacing["3"]}; @@ -59,7 +67,7 @@ const actionButtonStyles = ({ text-overflow: ellipsis; font-weight: 700; - ${disabled + ${disabled || isLoading ? ` background-color: ${themes[theme].secondary["300"]}; color: ${themes[theme].text.muted}; @@ -81,7 +89,8 @@ const actionButtonStyles = ({ `} svg { - width: fit-content; + padding: 2px 0; /* Match line-height of button body2 typography */ + width: auto; height: 16px; } `; diff --git a/apps/browser/src/autofill/content/components/constants/styles.ts b/apps/browser/src/autofill/content/components/constants/styles.ts index 08c8671ce14..55130781808 100644 --- a/apps/browser/src/autofill/content/components/constants/styles.ts +++ b/apps/browser/src/autofill/content/components/constants/styles.ts @@ -174,6 +174,17 @@ export const buildIconColorRule = (color: string, rule: RuleName = ruleNames.fil ${rule}: ${color}; `; +export const animations = { + spin: ` + 0% { + transform: rotate(0deg); + } + 100% { + transform: rotate(359deg); + } + `, +}; + export function scrollbarStyles(theme: Theme, color?: { thumb?: string; track?: string }) { const thumbColor = color?.thumb || themes[theme].secondary["500"]; const trackColor = color?.track || themes[theme].background.alt; diff --git a/apps/browser/src/autofill/content/components/icons/index.ts b/apps/browser/src/autofill/content/components/icons/index.ts index 65ec6301ac4..d1538e1543f 100644 --- a/apps/browser/src/autofill/content/components/icons/index.ts +++ b/apps/browser/src/autofill/content/components/icons/index.ts @@ -11,4 +11,5 @@ export { Folder } from "./folder"; export { Globe } from "./globe"; export { PencilSquare } from "./pencil-square"; export { Shield } from "./shield"; +export { Spinner } from "./spinner"; export { User } from "./user"; diff --git a/apps/browser/src/autofill/content/components/icons/spinner.ts b/apps/browser/src/autofill/content/components/icons/spinner.ts new file mode 100644 index 00000000000..20f53a43d44 --- /dev/null +++ b/apps/browser/src/autofill/content/components/icons/spinner.ts @@ -0,0 +1,34 @@ +import { css, keyframes } from "@emotion/css"; +import { html } from "lit"; + +import { IconProps } from "../common-types"; +import { buildIconColorRule, ruleNames, themes, animations } from "../constants/styles"; + +export function Spinner({ + ariaHidden = true, + color, + disabled, + theme, + disableSpin = false, +}: IconProps & { disableSpin?: boolean }) { + const shapeColor = disabled ? themes[theme].secondary["300"] : color || themes[theme].text.main; + + return html` + + + + `; +} + +const animation = css` + animation: ${keyframes(animations.spin)} 2s infinite linear; +`; diff --git a/apps/browser/src/autofill/content/components/lit-stories/buttons/action-button.lit-stories.ts b/apps/browser/src/autofill/content/components/lit-stories/buttons/action-button.lit-stories.ts index 77769bc67dc..dc630e537b0 100644 --- a/apps/browser/src/autofill/content/components/lit-stories/buttons/action-button.lit-stories.ts +++ b/apps/browser/src/autofill/content/components/lit-stories/buttons/action-button.lit-stories.ts @@ -1,9 +1,12 @@ import { Meta, StoryObj } from "@storybook/web-components"; +import { html } from "lit"; import { ThemeTypes } from "@bitwarden/common/platform/enums/theme-type.enum"; import { ActionButton, ActionButtonProps } from "../../buttons/action-button"; +type ComponentAndControls = ActionButtonProps & { width: number }; + export default { title: "Components/Buttons/Action Button", argTypes: { @@ -11,12 +14,15 @@ export default { disabled: { control: "boolean" }, theme: { control: "select", options: [...Object.values(ThemeTypes)] }, handleClick: { control: false }, + width: { control: "number", min: 10, max: 100, step: 1 }, }, args: { buttonText: "Click Me", disabled: false, + isLoading: false, theme: ThemeTypes.Light, handleClick: () => alert("Clicked"), + width: 150, }, parameters: { design: { @@ -24,10 +30,18 @@ export default { url: "https://www.figma.com/design/LEhqLAcBPY8uDKRfU99n9W/Autofill-notification-redesign?node-id=487-14755&t=2O7uCAkwRZCcjumm-4", }, }, -} as Meta; +} as Meta; -const Template = (args: ActionButtonProps) => ActionButton({ ...args }); +const Template = (args: ComponentAndControls) => { + const { width, ...componentProps } = args; + return html`
${ActionButton({ ...componentProps })}
`; +}; + +export const Default: StoryObj = { + args: { + isLoading: true, + theme: "dark", + }, -export const Default: StoryObj = { render: Template, }; diff --git a/apps/browser/src/autofill/content/components/lit-stories/icons/icons.lit-stories.ts b/apps/browser/src/autofill/content/components/lit-stories/icons/icons.lit-stories.ts index 3741ccbcb69..4e18008b94a 100644 --- a/apps/browser/src/autofill/content/components/lit-stories/icons/icons.lit-stories.ts +++ b/apps/browser/src/autofill/content/components/lit-stories/icons/icons.lit-stories.ts @@ -6,9 +6,10 @@ import { ThemeTypes } from "@bitwarden/common/platform/enums/theme-type.enum"; import { IconProps } from "../../common-types"; import * as Icons from "../../icons"; +const { Spinner, ...StaticIcons } = Icons; + type Args = IconProps & { size: number; - iconLink: URL; }; export default { @@ -26,7 +27,10 @@ export default { }, } as Meta; -const Template = (args: Args, IconComponent: (props: IconProps) => ReturnType) => html` +const Template = ( + args: Args, + IconComponent: (props: IconProps & { disableSpin?: boolean }) => ReturnType, +) => html`
@@ -34,18 +38,26 @@ const Template = (args: Args, IconComponent: (props: IconProps) => ReturnType `; -const createIconStory = (iconName: keyof typeof Icons): StoryObj => { +const createIconStory = ( + iconName: keyof typeof StaticIcons, +): StoryObj => { const story = { - render: (args) => Template(args, Icons[iconName]), + render: (args) => Template(args, StaticIcons[iconName]), } as StoryObj; - story.argTypes = { - iconLink: { table: { disable: true } }, - }; - return story; }; +const SpinnerIconStory: StoryObj = { + render: (args) => Template(args, Spinner), + argTypes: { + disableSpin: { control: "boolean" }, + }, + args: { + disableSpin: false, + }, +}; + export const AngleDownIcon = createIconStory("AngleDown"); export const AngleUpIcon = createIconStory("AngleUp"); export const BusinessIcon = createIconStory("Business"); @@ -58,4 +70,5 @@ export const FolderIcon = createIconStory("Folder"); export const GlobeIcon = createIconStory("Globe"); export const PencilSquareIcon = createIconStory("PencilSquare"); export const ShieldIcon = createIconStory("Shield"); +export const SpinnerIcon = SpinnerIconStory; export const UserIcon = createIconStory("User"); diff --git a/apps/browser/src/autofill/content/components/notification/button-row.ts b/apps/browser/src/autofill/content/components/notification/button-row.ts index 470147cb469..04b79c1951a 100644 --- a/apps/browser/src/autofill/content/components/notification/button-row.ts +++ b/apps/browser/src/autofill/content/components/notification/button-row.ts @@ -34,6 +34,7 @@ export type NotificationButtonRowProps = { organizations?: OrgView[]; primaryButton: { text: string; + isLoading?: boolean; handlePrimaryButtonClick: (args: any) => void; }; personalVaultIsAllowed: boolean; diff --git a/apps/browser/src/autofill/content/components/notification/container.ts b/apps/browser/src/autofill/content/components/notification/container.ts index cc7f0fc72c0..0c70e0da63c 100644 --- a/apps/browser/src/autofill/content/components/notification/container.ts +++ b/apps/browser/src/autofill/content/components/notification/container.ts @@ -29,6 +29,7 @@ export type NotificationContainerProps = NotificationBarIframeInitData & { folders?: FolderView[]; headerMessage?: string; i18n: I18n; + isLoading?: boolean; organizations?: OrgView[]; personalVaultIsAllowed?: boolean; notificationTestId: string; @@ -44,6 +45,7 @@ export function NotificationContainer({ folders, headerMessage, i18n, + isLoading, organizations, personalVaultIsAllowed = true, notificationTestId, @@ -74,6 +76,7 @@ export function NotificationContainer({ collections, folders, i18n, + isLoading, notificationType: type, organizations, personalVaultIsAllowed, diff --git a/apps/browser/src/autofill/content/components/notification/footer.ts b/apps/browser/src/autofill/content/components/notification/footer.ts index b47dd5cc094..d37547a6fae 100644 --- a/apps/browser/src/autofill/content/components/notification/footer.ts +++ b/apps/browser/src/autofill/content/components/notification/footer.ts @@ -16,6 +16,7 @@ export type NotificationFooterProps = { collections?: CollectionView[]; folders?: FolderView[]; i18n: I18n; + isLoading?: boolean; notificationType?: NotificationType; organizations?: OrgView[]; personalVaultIsAllowed: boolean; @@ -27,6 +28,7 @@ export function NotificationFooter({ collections, folders, i18n, + isLoading, notificationType, organizations, personalVaultIsAllowed, @@ -52,6 +54,7 @@ export function NotificationFooter({ i18n, primaryButton: { handlePrimaryButtonClick: handleSaveAction, + isLoading, text: primaryButtonText, }, personalVaultIsAllowed, diff --git a/apps/browser/src/autofill/content/components/rows/button-row.ts b/apps/browser/src/autofill/content/components/rows/button-row.ts index 041d0a6b696..8b4eabfec50 100644 --- a/apps/browser/src/autofill/content/components/rows/button-row.ts +++ b/apps/browser/src/autofill/content/components/rows/button-row.ts @@ -12,6 +12,7 @@ export type ButtonRowProps = { theme: Theme; primaryButton: { text: string; + isLoading?: boolean; handlePrimaryButtonClick: (args: any) => void; }; selectButtons?: { @@ -29,6 +30,7 @@ export function ButtonRow({ theme, primaryButton, selectButtons }: ButtonRowProp ${ActionButton({ handleClick: primaryButton.handlePrimaryButtonClick, buttonText: primaryButton.text, + isLoading: primaryButton.isLoading, theme, })}
diff --git a/apps/browser/src/autofill/notification/bar.ts b/apps/browser/src/autofill/notification/bar.ts index 275e6cb0721..285ae4aa257 100644 --- a/apps/browser/src/autofill/notification/bar.ts +++ b/apps/browser/src/autofill/notification/bar.ts @@ -249,25 +249,34 @@ async function initNotificationBar(message: NotificationBarWindowMessage) { document.head.querySelectorAll('link[rel="stylesheet"]').forEach((node) => node.remove()); if (isVaultLocked) { - return render( - NotificationContainer({ - ...notificationBarIframeInitData, - headerMessage, - type: resolvedType, - notificationTestId, - theme: resolvedTheme, - personalVaultIsAllowed: !personalVaultDisallowed, - handleCloseNotification, - handleSaveAction: (e) => { - sendSaveCipherMessage(true); + const notificationConfig = { + ...notificationBarIframeInitData, + headerMessage, + type: resolvedType, + notificationTestId, + theme: resolvedTheme, + personalVaultIsAllowed: !personalVaultDisallowed, + handleCloseNotification, + handleEditOrUpdateAction, + i18n, + }; - // @TODO can't close before vault has finished decrypting, but can't leave open during long decrypt because it looks like the experience has failed - }, - handleEditOrUpdateAction, - i18n, - }), - document.body, - ); + const handleSaveAction = () => { + sendSaveCipherMessage(true); + + render( + NotificationContainer({ + ...notificationConfig, + handleSaveAction: () => {}, + isLoading: true, + }), + document.body, + ); + }; + + const UnlockNotification = NotificationContainer({ ...notificationConfig, handleSaveAction }); + + return render(UnlockNotification, document.body); } // Handle AtRiskPasswordNotification render From 381e7fa45ec009b229112969c366d574dfc8f600 Mon Sep 17 00:00:00 2001 From: Thomas Avery <43214426+Thomas-Avery@users.noreply.github.com> Date: Thu, 12 Jun 2025 10:11:44 -0500 Subject: [PATCH 15/91] [PM-22563] Add awaiting the SDK to be ready to EncryptService (#15138) --- .../encrypt.service.implementation.ts | 3 ++ .../crypto/services/encrypt.service.spec.ts | 39 +++++++++++++++++++ 2 files changed, 42 insertions(+) diff --git a/libs/common/src/key-management/crypto/services/encrypt.service.implementation.ts b/libs/common/src/key-management/crypto/services/encrypt.service.implementation.ts index 5bb946b25bf..525e8a6b5f7 100644 --- a/libs/common/src/key-management/crypto/services/encrypt.service.implementation.ts +++ b/libs/common/src/key-management/crypto/services/encrypt.service.implementation.ts @@ -26,6 +26,7 @@ import { getFeatureFlagValue, } from "../../../enums/feature-flag.enum"; import { ServerConfig } from "../../../platform/abstractions/config/server-config"; +import { SdkLoadService } from "../../../platform/abstractions/sdk/sdk-load.service"; import { EncryptService } from "../abstractions/encrypt.service"; export class EncryptServiceImplementation implements EncryptService { @@ -242,6 +243,7 @@ export class EncryptServiceImplementation implements EncryptService { if (encString == null || encString.encryptedString == null) { throw new Error("encString is null or undefined"); } + await SdkLoadService.Ready; return PureCrypto.symmetric_decrypt(encString.encryptedString, key.toEncoded()); } this.logService.debug("decrypting with javascript"); @@ -324,6 +326,7 @@ export class EncryptServiceImplementation implements EncryptService { encThing.dataBytes, encThing.macBytes, ).buffer; + await SdkLoadService.Ready; return PureCrypto.symmetric_decrypt_array_buffer(buffer, key.toEncoded()); } this.logService.debug("[EncryptService] Decrypting bytes with javascript"); diff --git a/libs/common/src/key-management/crypto/services/encrypt.service.spec.ts b/libs/common/src/key-management/crypto/services/encrypt.service.spec.ts index d19de6c0414..813dd693dd9 100644 --- a/libs/common/src/key-management/crypto/services/encrypt.service.spec.ts +++ b/libs/common/src/key-management/crypto/services/encrypt.service.spec.ts @@ -11,10 +11,12 @@ import { SymmetricCryptoKey, } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key"; import { CsprngArray } from "@bitwarden/common/types/csprng"; +import { PureCrypto } from "@bitwarden/sdk-internal"; import { makeStaticByteArray } from "../../../../spec"; import { DefaultFeatureFlagValue, FeatureFlag } from "../../../enums/feature-flag.enum"; import { ServerConfig } from "../../../platform/abstractions/config/server-config"; +import { SdkLoadService } from "../../../platform/abstractions/sdk/sdk-load.service"; import { EncryptServiceImplementation } from "./encrypt.service.implementation"; @@ -343,6 +345,24 @@ describe("EncryptService", () => { ); }); + it("calls PureCrypto when useSDKForDecryption is true", async () => { + (encryptService as any).useSDKForDecryption = true; + const decryptedBytes = makeStaticByteArray(10, 200); + Object.defineProperty(SdkLoadService, "Ready", { + value: Promise.resolve(), + configurable: true, + }); + jest.spyOn(PureCrypto, "symmetric_decrypt_array_buffer").mockReturnValue(decryptedBytes); + + const actual = await encryptService.decryptToBytes(encBuffer, key); + + expect(PureCrypto.symmetric_decrypt_array_buffer).toHaveBeenCalledWith( + encBuffer.buffer, + key.toEncoded(), + ); + expect(actual).toEqualBuffer(decryptedBytes); + }); + it("decrypts data with provided key for Aes256CbcHmac", async () => { const decryptedBytes = makeStaticByteArray(10, 200); @@ -450,6 +470,25 @@ describe("EncryptService", () => { ); }); + it("calls PureCrypto when useSDKForDecryption is true", async () => { + (encryptService as any).useSDKForDecryption = true; + const key = new SymmetricCryptoKey(makeStaticByteArray(64, 0)); + const encString = new EncString(EncryptionType.AesCbc256_HmacSha256_B64, "data", "iv", "mac"); + Object.defineProperty(SdkLoadService, "Ready", { + value: Promise.resolve(), + configurable: true, + }); + jest.spyOn(PureCrypto, "symmetric_decrypt").mockReturnValue("data"); + + const actual = await encryptService.decryptToUtf8(encString, key); + + expect(actual).toEqual("data"); + expect(PureCrypto.symmetric_decrypt).toHaveBeenCalledWith( + encString.encryptedString, + key.toEncoded(), + ); + }); + it("decrypts data with provided key for AesCbc256_HmacSha256", async () => { const key = new SymmetricCryptoKey(makeStaticByteArray(64, 0)); const encString = new EncString(EncryptionType.AesCbc256_HmacSha256_B64, "data", "iv", "mac"); From 6a579ed99f65e7fb7111c1f0a2aa853f11c984a0 Mon Sep 17 00:00:00 2001 From: Leslie Tilton <23057410+Banrion@users.noreply.github.com> Date: Thu, 12 Jun 2025 10:52:04 -0500 Subject: [PATCH 16/91] [PM-15001] Replace throttle decorator (#15015) * Add comments to AuditService Abstraction * Replace throttle usage with rxjs mergeMap with concurrent limit * Add test cases for audit service * Remove throttle --- libs/common/src/abstractions/audit.service.ts | 17 +++- .../common/src/platform/misc/throttle.spec.ts | 97 ------------------- libs/common/src/platform/misc/throttle.ts | 71 -------------- .../common/src/services/audit.service.spec.ts | 81 ++++++++++++++++ libs/common/src/services/audit.service.ts | 43 +++++++- 5 files changed, 134 insertions(+), 175 deletions(-) delete mode 100644 libs/common/src/platform/misc/throttle.spec.ts delete mode 100644 libs/common/src/platform/misc/throttle.ts create mode 100644 libs/common/src/services/audit.service.spec.ts diff --git a/libs/common/src/abstractions/audit.service.ts b/libs/common/src/abstractions/audit.service.ts index a54beb59a78..b019ebe1fe8 100644 --- a/libs/common/src/abstractions/audit.service.ts +++ b/libs/common/src/abstractions/audit.service.ts @@ -1,8 +1,17 @@ -// FIXME: Update this file to be type safe and remove this and next line -// @ts-strict-ignore import { BreachAccountResponse } from "../models/response/breach-account.response"; export abstract class AuditService { - passwordLeaked: (password: string) => Promise; - breachedAccounts: (username: string) => Promise; + /** + * Checks how many times a password has been leaked. + * @param password The password to check. + * @returns A promise that resolves to the number of times the password has been leaked. + */ + abstract passwordLeaked: (password: string) => Promise; + + /** + * Retrieves accounts that have been breached for a given username. + * @param username The username to check for breaches. + * @returns A promise that resolves to an array of BreachAccountResponse objects. + */ + abstract breachedAccounts: (username: string) => Promise; } diff --git a/libs/common/src/platform/misc/throttle.spec.ts b/libs/common/src/platform/misc/throttle.spec.ts deleted file mode 100644 index 1c1ff6324a6..00000000000 --- a/libs/common/src/platform/misc/throttle.spec.ts +++ /dev/null @@ -1,97 +0,0 @@ -import { throttle } from "./throttle"; - -describe("throttle decorator", () => { - it("should call the function once at a time", async () => { - const foo = new Foo(); - const promises = []; - for (let i = 0; i < 10; i++) { - promises.push(foo.bar(1)); - } - await Promise.all(promises); - - expect(foo.calls).toBe(10); - }); - - it("should call the function once at a time for each object", async () => { - const foo = new Foo(); - const foo2 = new Foo(); - const promises = []; - for (let i = 0; i < 10; i++) { - promises.push(foo.bar(1)); - promises.push(foo2.bar(1)); - } - await Promise.all(promises); - - expect(foo.calls).toBe(10); - expect(foo2.calls).toBe(10); - }); - - it("should call the function limit at a time", async () => { - const foo = new Foo(); - const promises = []; - for (let i = 0; i < 10; i++) { - promises.push(foo.baz(1)); - } - await Promise.all(promises); - - expect(foo.calls).toBe(10); - }); - - it("should call the function limit at a time for each object", async () => { - const foo = new Foo(); - const foo2 = new Foo(); - const promises = []; - for (let i = 0; i < 10; i++) { - promises.push(foo.baz(1)); - promises.push(foo2.baz(1)); - } - await Promise.all(promises); - - expect(foo.calls).toBe(10); - expect(foo2.calls).toBe(10); - }); -}); - -class Foo { - calls = 0; - inflight = 0; - - @throttle(1, () => "bar") - bar(a: number) { - this.calls++; - this.inflight++; - return new Promise((res) => { - setTimeout(() => { - expect(this.inflight).toBe(1); - this.inflight--; - res(a * 2); - }, Math.random() * 10); - }); - } - - @throttle(5, () => "baz") - baz(a: number) { - this.calls++; - this.inflight++; - return new Promise((res) => { - setTimeout(() => { - expect(this.inflight).toBeLessThanOrEqual(5); - this.inflight--; - res(a * 3); - }, Math.random() * 10); - }); - } - - @throttle(1, () => "qux") - qux(a: number) { - this.calls++; - this.inflight++; - return new Promise((res) => { - setTimeout(() => { - expect(this.inflight).toBe(1); - this.inflight--; - res(a * 3); - }, Math.random() * 10); - }); - } -} diff --git a/libs/common/src/platform/misc/throttle.ts b/libs/common/src/platform/misc/throttle.ts deleted file mode 100644 index 643cce8f6ba..00000000000 --- a/libs/common/src/platform/misc/throttle.ts +++ /dev/null @@ -1,71 +0,0 @@ -// FIXME: Update this file to be type safe and remove this and next line -// @ts-strict-ignore -/** - * Use as a Decorator on async functions, it will limit how many times the function can be - * in-flight at a time. - * - * Calls beyond the limit will be queued, and run when one of the active calls finishes - */ -export function throttle(limit: number, throttleKey: (args: any[]) => string) { - return ( - target: any, - propertyKey: string | symbol, - descriptor: TypedPropertyDescriptor<(...args: any[]) => Promise>, - ) => { - const originalMethod: () => Promise = descriptor.value; - const allThrottles = new Map void)[]>>(); - - const getThrottles = (obj: any) => { - let throttles = allThrottles.get(obj); - if (throttles != null) { - return throttles; - } - throttles = new Map void)[]>(); - allThrottles.set(obj, throttles); - return throttles; - }; - - return { - value: function (...args: any[]) { - const throttles = getThrottles(this); - const argsThrottleKey = throttleKey(args); - let queue = throttles.get(argsThrottleKey); - if (queue == null) { - queue = []; - throttles.set(argsThrottleKey, queue); - } - - return new Promise((resolve, reject) => { - const exec = () => { - const onFinally = () => { - queue.splice(queue.indexOf(exec), 1); - if (queue.length >= limit) { - queue[limit - 1](); - } else if (queue.length === 0) { - throttles.delete(argsThrottleKey); - if (throttles.size === 0) { - allThrottles.delete(this); - } - } - }; - originalMethod - .apply(this, args) - .then((val: any) => { - onFinally(); - return val; - }) - .catch((err: any) => { - onFinally(); - throw err; - }) - .then(resolve, reject); - }; - queue.push(exec); - if (queue.length <= limit) { - exec(); - } - }); - }, - }; - }; -} diff --git a/libs/common/src/services/audit.service.spec.ts b/libs/common/src/services/audit.service.spec.ts new file mode 100644 index 00000000000..ce594823a7b --- /dev/null +++ b/libs/common/src/services/audit.service.spec.ts @@ -0,0 +1,81 @@ +import { ApiService } from "../abstractions/api.service"; +import { CryptoFunctionService } from "../key-management/crypto/abstractions/crypto-function.service"; +import { ErrorResponse } from "../models/response/error.response"; + +import { AuditService } from "./audit.service"; + +jest.useFakeTimers(); + +// Polyfill global Request for Jest environment if not present +if (typeof global.Request === "undefined") { + global.Request = jest.fn((input: string | URL, init?: RequestInit) => { + return { url: typeof input === "string" ? input : input.toString(), ...init }; + }) as any; +} + +describe("AuditService", () => { + let auditService: AuditService; + let mockCrypto: jest.Mocked; + let mockApi: jest.Mocked; + + beforeEach(() => { + mockCrypto = { + hash: jest.fn().mockResolvedValue(Buffer.from("AABBCCDDEEFF", "hex")), + } as unknown as jest.Mocked; + + mockApi = { + nativeFetch: jest.fn().mockResolvedValue({ + text: jest.fn().mockResolvedValue(`CDDEEFF:4\nDDEEFF:2\n123456:1`), + }), + getHibpBreach: jest.fn(), + } as unknown as jest.Mocked; + + auditService = new AuditService(mockCrypto, mockApi, 2); + }); + + it("should not exceed max concurrent passwordLeaked requests", async () => { + const inFlight: string[] = []; + const maxInFlight: number[] = []; + + // Patch fetchLeakedPasswordCount to track concurrency + const origFetch = (auditService as any).fetchLeakedPasswordCount.bind(auditService); + jest + .spyOn(auditService as any, "fetchLeakedPasswordCount") + .mockImplementation(async (password: string) => { + inFlight.push(password); + maxInFlight.push(inFlight.length); + // Simulate async work to allow concurrency limiter to take effect + await new Promise((resolve) => setTimeout(resolve, 100)); + inFlight.splice(inFlight.indexOf(password), 1); + return origFetch(password); + }); + + const p1 = auditService.passwordLeaked("password1"); + const p2 = auditService.passwordLeaked("password2"); + const p3 = auditService.passwordLeaked("password3"); + const p4 = auditService.passwordLeaked("password4"); + + jest.advanceTimersByTime(250); + + // Flush all pending timers and microtasks + await jest.runAllTimersAsync(); + await Promise.all([p1, p2, p3, p4]); + + // The max value in maxInFlight should not exceed 2 (the concurrency limit) + expect(Math.max(...maxInFlight)).toBeLessThanOrEqual(2); + expect((auditService as any).fetchLeakedPasswordCount).toHaveBeenCalledTimes(4); + expect(mockCrypto.hash).toHaveBeenCalledTimes(4); + expect(mockApi.nativeFetch).toHaveBeenCalledTimes(4); + }); + + it("should return empty array for breachedAccounts on 404", async () => { + mockApi.getHibpBreach.mockRejectedValueOnce({ statusCode: 404 } as ErrorResponse); + const result = await auditService.breachedAccounts("user@example.com"); + expect(result).toEqual([]); + }); + + it("should throw error for breachedAccounts on non-404 error", async () => { + mockApi.getHibpBreach.mockRejectedValueOnce({ statusCode: 500 } as ErrorResponse); + await expect(auditService.breachedAccounts("user@example.com")).rejects.toThrow(); + }); +}); diff --git a/libs/common/src/services/audit.service.ts b/libs/common/src/services/audit.service.ts index 10654267687..d1eddbbdf82 100644 --- a/libs/common/src/services/audit.service.ts +++ b/libs/common/src/services/audit.service.ts @@ -1,21 +1,58 @@ +import { Subject } from "rxjs"; +import { mergeMap } from "rxjs/operators"; + import { ApiService } from "../abstractions/api.service"; import { AuditService as AuditServiceAbstraction } from "../abstractions/audit.service"; import { CryptoFunctionService } from "../key-management/crypto/abstractions/crypto-function.service"; import { BreachAccountResponse } from "../models/response/breach-account.response"; import { ErrorResponse } from "../models/response/error.response"; -import { throttle } from "../platform/misc/throttle"; import { Utils } from "../platform/misc/utils"; const PwnedPasswordsApi = "https://api.pwnedpasswords.com/range/"; export class AuditService implements AuditServiceAbstraction { + private passwordLeakedSubject = new Subject<{ + password: string; + resolve: (count: number) => void; + reject: (err: any) => void; + }>(); + constructor( private cryptoFunctionService: CryptoFunctionService, private apiService: ApiService, - ) {} + private readonly maxConcurrent: number = 100, // default to 100, can be overridden + ) { + this.maxConcurrent = maxConcurrent; + this.passwordLeakedSubject + .pipe( + mergeMap( + // Handle each password leak request, resolving or rejecting the associated promise. + async (req) => { + try { + const count = await this.fetchLeakedPasswordCount(req.password); + req.resolve(count); + } catch (err) { + req.reject(err); + } + }, + this.maxConcurrent, // Limit concurrent API calls + ), + ) + .subscribe(); + } - @throttle(100, () => "passwordLeaked") async passwordLeaked(password: string): Promise { + return new Promise((resolve, reject) => { + this.passwordLeakedSubject.next({ password, resolve, reject }); + }); + } + + /** + * Fetches the count of leaked passwords from the Pwned Passwords API. + * @param password The password to check. + * @returns A promise that resolves to the number of times the password has been leaked. + */ + protected async fetchLeakedPasswordCount(password: string): Promise { const hashBytes = await this.cryptoFunctionService.hash(password, "sha1"); const hash = Utils.fromBufferToHex(hashBytes).toUpperCase(); const hashStart = hash.substr(0, 5); From bef6182243b45029ddd06dd3d94c8f2f50a80555 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Anders=20=C3=85berg?= Date: Thu, 12 Jun 2025 18:53:35 +0200 Subject: [PATCH 17/91] PM-22221: Fix a race condition with cipher creation (#15157) * PM-22221: Fix a race condition with cipher creation * Mocked ciphers$ in tests * Neater tests --------- Co-authored-by: Robyn MacCallum --- .../fido2/fido2-authenticator.service.spec.ts | 14 +++++++----- .../fido2/fido2-authenticator.service.ts | 22 +++++++++++++++++-- 2 files changed, 29 insertions(+), 7 deletions(-) diff --git a/libs/common/src/platform/services/fido2/fido2-authenticator.service.spec.ts b/libs/common/src/platform/services/fido2/fido2-authenticator.service.spec.ts index 78ae8253ee2..fef64399b40 100644 --- a/libs/common/src/platform/services/fido2/fido2-authenticator.service.spec.ts +++ b/libs/common/src/platform/services/fido2/fido2-authenticator.service.spec.ts @@ -5,11 +5,12 @@ import { BehaviorSubject, of } from "rxjs"; import { mockAccountServiceWith } from "../../../../spec"; import { Account } from "../../../auth/abstractions/account.service"; -import { UserId } from "../../../types/guid"; +import { CipherId, UserId } from "../../../types/guid"; import { CipherService, EncryptionContext } from "../../../vault/abstractions/cipher.service"; import { SyncService } from "../../../vault/abstractions/sync/sync.service.abstraction"; import { CipherRepromptType } from "../../../vault/enums/cipher-reprompt-type"; import { CipherType } from "../../../vault/enums/cipher-type"; +import { CipherData } from "../../../vault/models/data/cipher.data"; import { Cipher } from "../../../vault/models/domain/cipher"; import { CipherView } from "../../../vault/models/view/cipher.view"; import { Fido2CredentialView } from "../../../vault/models/view/fido2-credential.view"; @@ -218,9 +219,11 @@ describe("FidoAuthenticatorService", () => { beforeEach(async () => { existingCipher = createCipherView({ type: CipherType.Login }); params = await createParams({ requireResidentKey: false }); - cipherService.get.mockImplementation(async (id) => - id === existingCipher.id ? ({ decrypt: () => existingCipher } as any) : undefined, + + cipherService.ciphers$.mockImplementation((userId: UserId) => + of({ [existingCipher.id as CipherId]: {} as CipherData }), ); + cipherService.getAllDecrypted.mockResolvedValue([existingCipher]); cipherService.decrypt.mockResolvedValue(existingCipher); }); @@ -351,9 +354,10 @@ describe("FidoAuthenticatorService", () => { cipherId, userVerified: false, }); - cipherService.get.mockImplementation(async (cipherId) => - cipherId === cipher.id ? ({ decrypt: () => cipher } as any) : undefined, + cipherService.ciphers$.mockImplementation((userId: UserId) => + of({ [cipher.id as CipherId]: {} as CipherData }), ); + cipherService.getAllDecrypted.mockResolvedValue([await cipher]); cipherService.decrypt.mockResolvedValue(cipher); cipherService.encrypt.mockImplementation(async (cipher) => { diff --git a/libs/common/src/platform/services/fido2/fido2-authenticator.service.ts b/libs/common/src/platform/services/fido2/fido2-authenticator.service.ts index a605e466338..bac1b218657 100644 --- a/libs/common/src/platform/services/fido2/fido2-authenticator.service.ts +++ b/libs/common/src/platform/services/fido2/fido2-authenticator.service.ts @@ -1,13 +1,15 @@ // FIXME: Update this file to be type safe and remove this and next line // @ts-strict-ignore -import { firstValueFrom } from "rxjs"; +import { filter, firstValueFrom, map, timeout } from "rxjs"; import { AccountService } from "../../../auth/abstractions/account.service"; import { getUserId } from "../../../auth/services/account.service"; +import { CipherId } from "../../../types/guid"; import { CipherService } from "../../../vault/abstractions/cipher.service"; import { SyncService } from "../../../vault/abstractions/sync/sync.service.abstraction"; import { CipherRepromptType } from "../../../vault/enums/cipher-reprompt-type"; import { CipherType } from "../../../vault/enums/cipher-type"; +import { Cipher } from "../../../vault/models/domain/cipher"; import { CipherView } from "../../../vault/models/view/cipher.view"; import { Fido2CredentialView } from "../../../vault/models/view/fido2-credential.view"; import { @@ -149,7 +151,23 @@ export class Fido2AuthenticatorService const activeUserId = await firstValueFrom( this.accountService.activeAccount$.pipe(getUserId), ); - const encrypted = await this.cipherService.get(cipherId, activeUserId); + + const encrypted = await firstValueFrom( + this.cipherService.ciphers$(activeUserId).pipe( + map((ciphers) => ciphers[cipherId as CipherId]), + filter((c) => c !== undefined), + timeout({ + first: 5000, + with: () => { + this.logService?.error( + `[Fido2Authenticator] Aborting because cipher with ID ${cipherId} could not be found within timeout.`, + ); + throw new Fido2AuthenticatorError(Fido2AuthenticatorErrorCode.Unknown); + }, + }), + map((c) => new Cipher(c, null)), + ), + ); cipher = await this.cipherService.decrypt(encrypted, activeUserId); From 3881192753a8a951ba3ac2dee1cb11aaee51d468 Mon Sep 17 00:00:00 2001 From: Jordan Aasen <166539328+jaasen-livefront@users.noreply.github.com> Date: Thu, 12 Jun 2025 10:40:43 -0700 Subject: [PATCH 18/91] ensure favorites is included in vault filters (#15166) --- .../vault-filter/components/vault-filter.component.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/src/app/vault/individual-vault/vault-filter/components/vault-filter.component.ts b/apps/web/src/app/vault/individual-vault/vault-filter/components/vault-filter.component.ts index d21896e26fe..8987fff04cf 100644 --- a/apps/web/src/app/vault/individual-vault/vault-filter/components/vault-filter.component.ts +++ b/apps/web/src/app/vault/individual-vault/vault-filter/components/vault-filter.component.ts @@ -303,7 +303,7 @@ export class VaultFilterComponent implements OnInit, OnDestroy { .map((r) => r.cipherType); const toExclude = [...excludeTypes, ...restrictedByAll]; return this.allTypeFilters.filter( - (f) => typeof f.type !== "string" && !toExclude.includes(f.type), + (f) => typeof f.type === "string" || !toExclude.includes(f.type), ); }), switchMap((allowed) => this.vaultFilterService.buildTypeTree(allFilter, allowed)), From 93ab8b7ec10c46ef31d6aaa5bcd9737c19ceb2d3 Mon Sep 17 00:00:00 2001 From: Jonathan Prusik Date: Thu, 12 Jun 2025 16:15:50 -0400 Subject: [PATCH 19/91] add optional chaining to possibly nullish orgKeys (#15172) --- libs/common/src/vault/services/cipher.service.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/libs/common/src/vault/services/cipher.service.ts b/libs/common/src/vault/services/cipher.service.ts index 762b2bd3688..798821f0567 100644 --- a/libs/common/src/vault/services/cipher.service.ts +++ b/libs/common/src/vault/services/cipher.service.ts @@ -448,12 +448,12 @@ export class CipherService implements CipherServiceAbstraction { if (await this.configService.getFeatureFlag(FeatureFlag.PM4154_BulkEncryptionService)) { return await this.bulkEncryptService.decryptItems( groupedCiphers, - keys.orgKeys[orgId as OrganizationId] ?? keys.userKey, + keys.orgKeys?.[orgId as OrganizationId] ?? keys.userKey, ); } else { return await this.encryptService.decryptItems( groupedCiphers, - keys.orgKeys[orgId as OrganizationId] ?? keys.userKey, + keys.orgKeys?.[orgId as OrganizationId] ?? keys.userKey, ); } }), From 0e1d48179d94883fa8ce493e19ec36d8c47040e1 Mon Sep 17 00:00:00 2001 From: Vicki League Date: Thu, 12 Jun 2025 16:27:26 -0400 Subject: [PATCH 20/91] [PM-13196] Hide decorative chip select icons from screenreaders (#14990) --- libs/components/src/chip-select/chip-select.component.html | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/libs/components/src/chip-select/chip-select.component.html b/libs/components/src/chip-select/chip-select.component.html index 78321afa9b9..9591194e6a6 100644 --- a/libs/components/src/chip-select/chip-select.component.html +++ b/libs/components/src/chip-select/chip-select.component.html @@ -28,13 +28,14 @@ #chipSelectButton > - + {{ label }} @if (!selectedOption) { } @@ -51,7 +52,7 @@ }" (click)="clear()" > - + }
@@ -101,7 +102,7 @@ } {{ option.label }} @if (option.children?.length) { - + } } From 64e577e2e6480ea1d35962b410aaf5fa71ff4e06 Mon Sep 17 00:00:00 2001 From: Miles Blackwood Date: Thu, 12 Jun 2025 17:15:16 -0400 Subject: [PATCH 21/91] Failsafe for Chromium browsers' forced rendering of opaque bkgd (#15098) --- apps/browser/src/autofill/notification/bar.ts | 11 ++++++++++- ...notifications-content.service.spec.ts.snap | 2 +- .../overlay-notifications-content.service.ts | 9 +++++++-- apps/browser/src/autofill/utils/index.ts | 19 +++++++++++++++++++ 4 files changed, 37 insertions(+), 4 deletions(-) diff --git a/apps/browser/src/autofill/notification/bar.ts b/apps/browser/src/autofill/notification/bar.ts index 285ae4aa257..a83e9fce531 100644 --- a/apps/browser/src/autofill/notification/bar.ts +++ b/apps/browser/src/autofill/notification/bar.ts @@ -12,7 +12,7 @@ import { NotificationConfirmationContainer } from "../content/components/notific import { NotificationContainer } from "../content/components/notification/container"; import { selectedFolder as selectedFolderSignal } from "../content/components/signals/selected-folder"; import { selectedVault as selectedVaultSignal } from "../content/components/signals/selected-vault"; -import { buildSvgDomElement } from "../utils"; +import { buildSvgDomElement, matchAllowedColorSchemes } from "../utils"; import { circleCheckIcon } from "../utils/svg-icons"; import { @@ -238,6 +238,15 @@ async function initNotificationBar(message: NotificationBarWindowMessage) { const i18n = getI18n(); const resolvedTheme = getResolvedTheme(theme ?? ThemeTypes.Light); + // https://drafts.csswg.org/css-color-adjust-1/#preferred + // Prevents preferred color scheme from forcing an opaque background in the iframe + const colorScheme = new URLSearchParams(window.location.search).get("colorScheme") || ""; + const allowedColorScheme = matchAllowedColorSchemes(colorScheme); + const meta = document.createElement("meta"); + meta.setAttribute("name", "color-scheme"); + meta.setAttribute("content", allowedColorScheme); + document.getElementsByTagName("head")[0].appendChild(meta); + if (useComponentBar) { const resolvedType = resolveNotificationType(notificationBarIframeInitData); const headerMessage = getNotificationHeaderMessage(i18n, resolvedType); diff --git a/apps/browser/src/autofill/overlay/notifications/content/__snapshots__/overlay-notifications-content.service.spec.ts.snap b/apps/browser/src/autofill/overlay/notifications/content/__snapshots__/overlay-notifications-content.service.spec.ts.snap index c20626212fa..1b5d9a73888 100644 --- a/apps/browser/src/autofill/overlay/notifications/content/__snapshots__/overlay-notifications-content.service.spec.ts.snap +++ b/apps/browser/src/autofill/overlay/notifications/content/__snapshots__/overlay-notifications-content.service.spec.ts.snap @@ -7,7 +7,7 @@ exports[`OverlayNotificationsContentService opening the notification bar creates >