From 2c30fb72bae09dfc1df452bac5d215802479af7f Mon Sep 17 00:00:00 2001 From: Vijay Oommen Date: Mon, 2 Feb 2026 10:05:02 -0600 Subject: [PATCH 01/22] [PM-30544] Added Critical app badge to Access Intelligence (#18658) --- apps/web/src/locales/en/messages.json | 3 ++ .../applications.component.html | 2 +- ...pp-table-row-scrollable-m11.component.html | 49 ++++++++----------- .../app-table-row-scrollable-m11.component.ts | 3 -- 4 files changed, 25 insertions(+), 32 deletions(-) diff --git a/apps/web/src/locales/en/messages.json b/apps/web/src/locales/en/messages.json index 872509a81c2..d3b975e5834 100644 --- a/apps/web/src/locales/en/messages.json +++ b/apps/web/src/locales/en/messages.json @@ -32,6 +32,9 @@ } } }, + "criticalBadge":{ + "message": "Critical" + }, "accessIntelligence": { "message": "Access Intelligence" }, diff --git a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/all-applications/applications.component.html b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/all-applications/applications.component.html index 1bfe41901c8..7c4f6d04a6b 100644 --- a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/all-applications/applications.component.html +++ b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/all-applications/applications.component.html @@ -19,6 +19,7 @@ [ngModel]="selectedFilter()" (ngModelChange)="setFilterApplicationsByStatus($event)" fullWidth="false" + class="tw-min-w-48" > @if (!decryptionFailure) { - + - + diff --git a/apps/browser/src/vault/popup/components/vault-v2/item-more-options/item-more-options.component.ts b/apps/browser/src/vault/popup/components/vault-v2/item-more-options/item-more-options.component.ts index 7a6c1db8026..d7de51ad20f 100644 --- a/apps/browser/src/vault/popup/components/vault-v2/item-more-options/item-more-options.component.ts +++ b/apps/browser/src/vault/popup/components/vault-v2/item-more-options/item-more-options.component.ts @@ -1,5 +1,5 @@ import { CommonModule } from "@angular/common"; -import { booleanAttribute, Component, input, Input } from "@angular/core"; +import { booleanAttribute, Component, Input } from "@angular/core"; import { Router, RouterModule } from "@angular/router"; import { BehaviorSubject, combineLatest, firstValueFrom, map, Observable, switchMap } from "rxjs"; import { filter } from "rxjs/operators"; @@ -76,10 +76,22 @@ export class ItemMoreOptionsComponent { } /** - * Flag to show the autofill menu options. Used for items that are + * Flag to show view item menu option. Used when something else is + * assigned as the primary action for the item, such as autofill. + */ + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals + @Input({ transform: booleanAttribute }) + showViewOption = false; + + /** + * Flag to hide the autofill menu options. Used for items that are * already in the autofill list suggestion. */ - readonly showAutofill = input(false, { transform: booleanAttribute }); + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals + @Input({ transform: booleanAttribute }) + hideAutofillOptions = false; protected autofillAllowed$ = this.vaultPopupAutofillService.autofillAllowed$; diff --git a/apps/browser/src/vault/popup/components/vault-v2/vault-list-items-container/vault-list-items-container.component.html b/apps/browser/src/vault/popup/components/vault-v2/vault-list-items-container/vault-list-items-container.component.html index d3bc025905e..3dac158b8e1 100644 --- a/apps/browser/src/vault/popup/components/vault-v2/vault-list-items-container/vault-list-items-container.component.html +++ b/apps/browser/src/vault/popup/components/vault-v2/vault-list-items-container/vault-list-items-container.component.html @@ -90,11 +90,11 @@ - + - + diff --git a/apps/desktop/src/vault/app/vault/vault-filter/filters/organization-filter.component.ts b/apps/desktop/src/vault/app/vault/vault-filter/filters/organization-filter.component.ts index 99338ddbb7c..22ad8dc40db 100644 --- a/apps/desktop/src/vault/app/vault/vault-filter/filters/organization-filter.component.ts +++ b/apps/desktop/src/vault/app/vault/vault-filter/filters/organization-filter.component.ts @@ -5,6 +5,7 @@ import { Component } from "@angular/core"; import { OrganizationFilterComponent as BaseOrganizationFilterComponent } from "@bitwarden/angular/vault/vault-filter/components/organization-filter.component"; import { DisplayMode } from "@bitwarden/angular/vault/vault-filter/models/display-mode"; import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; +import { ProductTierType } from "@bitwarden/common/billing/enums"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { ToastService } from "@bitwarden/components"; @@ -50,4 +51,15 @@ export class OrganizationFilterComponent extends BaseOrganizationFilterComponent }); } } + + getIconString(organization: Organization): string { + if ( + organization?.productTierType === ProductTierType.Free || + organization?.productTierType === ProductTierType.Families + ) { + return "bwi-family"; + } else { + return "bwi-business"; + } + } } diff --git a/libs/vault/src/services/vault-filter.service.ts b/libs/vault/src/services/vault-filter.service.ts index b21e140e023..445764827eb 100644 --- a/libs/vault/src/services/vault-filter.service.ts +++ b/libs/vault/src/services/vault-filter.service.ts @@ -26,6 +26,7 @@ import { Organization } from "@bitwarden/common/admin-console/models/domain/orga import { cloneCollection } from "@bitwarden/common/admin-console/utils/collection-utils"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { getUserId } from "@bitwarden/common/auth/services/account.service"; +import { ProductTierType } from "@bitwarden/common/billing/enums"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { SingleUserState, StateProvider } from "@bitwarden/common/platform/state"; import { OrganizationId, UserId } from "@bitwarden/common/types/guid"; @@ -184,7 +185,14 @@ export class VaultFilterService implements VaultFilterServiceAbstraction { const orgNodes: TreeNode[] = []; orgs.forEach((org) => { const orgCopy = org as OrganizationFilter; - orgCopy.icon = "bwi-business"; + if ( + org?.productTierType === ProductTierType.Free || + org?.productTierType === ProductTierType.Families + ) { + orgCopy.icon = "bwi-family"; + } else { + orgCopy.icon = "bwi-business"; + } const node = new TreeNode(orgCopy, headNode, orgCopy.name); orgNodes.push(node); }); From 5a397fb44e4a7fbd503e39fbea963b353521e21a Mon Sep 17 00:00:00 2001 From: Jonathan Prusik Date: Mon, 2 Feb 2026 15:01:24 -0500 Subject: [PATCH 08/22] [PM-29236] Refactor of post-submit notification triggering logic (#18395) * refactor triggerChangedPasswordNotification logic * improve triggerChangedPasswordNotification and test coverage to handle scenarios more comprehensively * restore triggerChangedPasswordNotification logic and move new logic and testing to triggerCipherNotification * add branching qualification logic for cipher notifications * add and implement undetermined-cipher-scenario-logic feature flag * add optional chaining to username comparison of existing login ciphers * cleanup * update tests * prefer explicit length comparisons --- .../overlay-notifications.background.ts | 17 + .../notification.background.spec.ts | 1513 ++++++++++++++++- .../background/notification.background.ts | 457 ++++- .../overlay-notifications.background.spec.ts | 128 ++ .../overlay-notifications.background.ts | 76 +- .../abstractions/notification-bar.ts | 4 + libs/common/src/enums/feature-flag.enum.ts | 2 + 7 files changed, 2153 insertions(+), 44 deletions(-) diff --git a/apps/browser/src/autofill/background/abstractions/overlay-notifications.background.ts b/apps/browser/src/autofill/background/abstractions/overlay-notifications.background.ts index a70ffe25310..ae5026c9566 100644 --- a/apps/browser/src/autofill/background/abstractions/overlay-notifications.background.ts +++ b/apps/browser/src/autofill/background/abstractions/overlay-notifications.background.ts @@ -2,6 +2,7 @@ import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; import { SecurityTask } from "@bitwarden/common/vault/tasks"; import AutofillPageDetails from "../../models/autofill-page-details"; +import { NotificationTypes } from "../../notification/abstractions/notification-bar"; export type NotificationTypeData = { isVaultLocked?: boolean; @@ -17,10 +18,26 @@ export type LoginSecurityTaskInfo = { uri: ModifyLoginCipherFormData["uri"]; }; +/** + * Distinguished from `NotificationTypes` in that this represents the + * pre-resolved notification scenario, vs the notification component + * (e.g. "Add" and "Change" will be removed + * post-`useUndeterminedCipherScenarioTriggeringLogic` migration) + */ +export const NotificationScenarios = { + ...NotificationTypes, + /** represents scenarios handling saving new and updated ciphers after form submit */ + Cipher: "cipher", +} as const; + +export type NotificationScenario = + (typeof NotificationScenarios)[keyof typeof NotificationScenarios]; + export type WebsiteOriginsWithFields = Map>; export type ActiveFormSubmissionRequests = Set; +/** This type represents an expectation of nullish values being represented as empty strings */ export type ModifyLoginCipherFormData = { uri: string; username: string; diff --git a/apps/browser/src/autofill/background/notification.background.spec.ts b/apps/browser/src/autofill/background/notification.background.spec.ts index a927c75dba0..0be6e5c0ac1 100644 --- a/apps/browser/src/autofill/background/notification.background.spec.ts +++ b/apps/browser/src/autofill/background/notification.background.spec.ts @@ -67,8 +67,10 @@ describe("NotificationBackground", () => { }); const folderService = mock(); const enableChangedPasswordPromptMock$ = new BehaviorSubject(true); + const enableAddedLoginPromptMock$ = new BehaviorSubject(true); const userNotificationSettingsService = mock(); userNotificationSettingsService.enableChangedPasswordPrompt$ = enableChangedPasswordPromptMock$; + userNotificationSettingsService.enableAddedLoginPrompt$ = enableAddedLoginPromptMock$; const domainSettingsService = mock(); const environmentService = mock(); @@ -90,7 +92,9 @@ describe("NotificationBackground", () => { }); beforeEach(() => { - activeAccountStatusMock$ = new BehaviorSubject(AuthenticationStatus.Locked); + activeAccountStatusMock$ = new BehaviorSubject( + AuthenticationStatus.Locked as AuthenticationStatus, + ); authService = mock(); authService.activeAccountStatus$ = activeAccountStatusMock$; accountService.activeAccount$ = activeAccountSubject; @@ -290,7 +294,7 @@ describe("NotificationBackground", () => { username: "test", password: "password", uri: "https://example.com", - newPassword: null, + newPassword: "", }; beforeEach(() => { tab = createChromeTabMock(); @@ -323,7 +327,7 @@ describe("NotificationBackground", () => { ...mockModifyLoginCipherFormData, uri: "", }; - activeAccountStatusMock$.next(AuthenticationStatus.Locked); + activeAccountStatusMock$.next(AuthenticationStatus.Unlocked); await notificationBackground.triggerAddLoginNotification(data, tab); @@ -389,14 +393,14 @@ describe("NotificationBackground", () => { password: data.password, }, sender.tab, - true, + true, // will yield an unlock followed by a new password notification ); }); it("adds the login to the queue if the user has an unlocked account and the login is new", async () => { const data: ModifyLoginCipherFormData = { ...mockModifyLoginCipherFormData, - username: null, + username: "", }; activeAccountStatusMock$.next(AuthenticationStatus.Unlocked); @@ -426,8 +430,8 @@ describe("NotificationBackground", () => { let pushChangePasswordToQueueSpy: jest.SpyInstance; let getAllDecryptedForUrlSpy: jest.SpyInstance; const mockModifyLoginCipherFormData: ModifyLoginCipherFormData = { - username: null, - uri: null, + username: "", + uri: "", password: "currentPassword", newPassword: "newPassword", }; @@ -527,7 +531,7 @@ describe("NotificationBackground", () => { ...mockModifyLoginCipherFormData, uri: "https://example.com", password: "newPasswordUpdatedElsewhere", - newPassword: null, + newPassword: "", }; activeAccountStatusMock$.next(AuthenticationStatus.Unlocked); getAllDecryptedForUrlSpy.mockResolvedValueOnce([ @@ -589,7 +593,7 @@ describe("NotificationBackground", () => { "example.com", data?.newPassword, sender.tab, - true, + true, // will yield an unlock followed by an update password notification ); }); @@ -597,8 +601,8 @@ describe("NotificationBackground", () => { const data: ModifyLoginCipherFormData = { ...mockModifyLoginCipherFormData, uri: "https://example.com", - password: null, - newPassword: null, + password: "", + newPassword: "", }; activeAccountStatusMock$.next(AuthenticationStatus.Unlocked); getAllDecryptedForUrlSpy.mockResolvedValueOnce([ @@ -637,7 +641,7 @@ describe("NotificationBackground", () => { const data: ModifyLoginCipherFormData = { ...mockModifyLoginCipherFormData, uri: "https://example.com", - password: null, + password: "", }; activeAccountStatusMock$.next(AuthenticationStatus.Unlocked); getAllDecryptedForUrlSpy.mockResolvedValueOnce([ @@ -665,7 +669,7 @@ describe("NotificationBackground", () => { const data: ModifyLoginCipherFormData = { ...mockModifyLoginCipherFormData, uri: "https://example.com", - password: null, + password: "", }; activeAccountStatusMock$.next(AuthenticationStatus.Unlocked); getAllDecryptedForUrlSpy.mockResolvedValueOnce([ @@ -686,6 +690,1489 @@ describe("NotificationBackground", () => { }); }); + describe("triggerCipherNotification message handler", () => { + let tab: chrome.tabs.Tab; + let sender: chrome.runtime.MessageSender; + let getEnableChangedPasswordPromptSpy: jest.SpyInstance; + let getEnableAddedLoginPromptSpy: jest.SpyInstance; + let pushChangePasswordToQueueSpy: jest.SpyInstance; + let pushAddLoginToQueueSpy: jest.SpyInstance; + let getAllDecryptedForUrlSpy: jest.SpyInstance; + const mockFormattedURI = "archive.org"; + const mockFormURI = "https://www.archive.org"; + const expectSkippedCheckingNotification = () => { + expect(getAllDecryptedForUrlSpy).not.toHaveBeenCalled(); + expect(pushAddLoginToQueueSpy).not.toHaveBeenCalled(); + expect(pushChangePasswordToQueueSpy).not.toHaveBeenCalled(); + }; + + beforeEach(() => { + tab = createChromeTabMock(); + sender = mock({ tab }); + getEnableAddedLoginPromptSpy = jest.spyOn( + notificationBackground as any, + "getEnableAddedLoginPrompt", + ); + getEnableChangedPasswordPromptSpy = jest.spyOn( + notificationBackground as any, + "getEnableChangedPasswordPrompt", + ); + + pushChangePasswordToQueueSpy = jest.spyOn( + notificationBackground as any, + "pushChangePasswordToQueue", + ); + pushAddLoginToQueueSpy = jest.spyOn(notificationBackground as any, "pushAddLoginToQueue"); + getAllDecryptedForUrlSpy = jest.spyOn(cipherService, "getAllDecryptedForUrl"); + }); + + afterEach(() => { + getEnableAddedLoginPromptSpy.mockRestore(); + getEnableChangedPasswordPromptSpy.mockRestore(); + pushChangePasswordToQueueSpy.mockRestore(); + pushAddLoginToQueueSpy.mockRestore(); + getAllDecryptedForUrlSpy.mockRestore(); + }); + + it("skips checking if a notification should trigger if no fields were filled", async () => { + const formEntryData: ModifyLoginCipherFormData = { + newPassword: "", + password: "", + uri: mockFormURI, + username: "", + }; + + const storedCiphersForURL = [ + mock({ + id: "cipher-id-1", + login: { password: "I<3VogonPoetry", username: "ADent" }, + }), + ]; + + activeAccountStatusMock$.next(AuthenticationStatus.Unlocked); + getAllDecryptedForUrlSpy.mockResolvedValueOnce(storedCiphersForURL); + + await notificationBackground.triggerCipherNotification(formEntryData, tab); + + expectSkippedCheckingNotification(); + }); + + it("skips checking if a notification should trigger if the passed url is not valid", async () => { + const formEntryData: ModifyLoginCipherFormData = { + newPassword: "Bab3lPhs5h", + password: "I<3VogonPoetry", + uri: "", + username: "ADent", + }; + + const storedCiphersForURL = [ + mock({ + id: "cipher-id-1", + login: { password: "I<3VogonPoetry", username: "ADent" }, + }), + ]; + + activeAccountStatusMock$.next(AuthenticationStatus.Unlocked); + getAllDecryptedForUrlSpy.mockResolvedValueOnce(storedCiphersForURL); + + await notificationBackground.triggerCipherNotification(formEntryData, tab); + + expectSkippedCheckingNotification(); + }); + + it("skips checking if a notification should trigger if the user has disabled both the new login and update password notification", async () => { + const formEntryData: ModifyLoginCipherFormData = { + newPassword: "Bab3lPhs5h", + password: "I<3VogonPoetry", + uri: mockFormURI, + username: "ADent", + }; + + const storedCiphersForURL = [ + mock({ login: { username: "ADent", password: "I<3VogonPoetry" } }), + ]; + + activeAccountStatusMock$.next(AuthenticationStatus.Unlocked); + getEnableChangedPasswordPromptSpy.mockReturnValueOnce(false); + getEnableAddedLoginPromptSpy.mockReturnValueOnce(false); + getAllDecryptedForUrlSpy.mockResolvedValueOnce(storedCiphersForURL); + + await notificationBackground.triggerCipherNotification(formEntryData, tab); + + expectSkippedCheckingNotification(); + }); + + it("skips checking if a notification should trigger if the user is logged out", async () => { + const formEntryData: ModifyLoginCipherFormData = { + newPassword: "Bab3lPhs5h", + password: "I<3VogonPoetry", + uri: mockFormURI, + username: "ADent", + }; + + const storedCiphersForURL = [ + mock({ login: { username: "ADent", password: "I<3VogonPoetry" } }), + ]; + + activeAccountStatusMock$.next(AuthenticationStatus.LoggedOut); + getAllDecryptedForUrlSpy.mockResolvedValueOnce(storedCiphersForURL); + + await notificationBackground.triggerCipherNotification(formEntryData, tab); + + expectSkippedCheckingNotification(); + }); + + it("skips checking if a notification should trigger if there is no active account", async () => { + const formEntryData: ModifyLoginCipherFormData = { + newPassword: "Bab3lPhs5h", + password: "I<3VogonPoetry", + uri: mockFormURI, + username: "ADent", + }; + + const storedCiphersForURL = [ + mock({ login: { username: "ADent", password: "I<3VogonPoetry" } }), + ]; + + accountService.activeAccount$ = new BehaviorSubject(null); + getAllDecryptedForUrlSpy.mockResolvedValueOnce(storedCiphersForURL); + + await notificationBackground.triggerCipherNotification(formEntryData, tab); + + expectSkippedCheckingNotification(); + }); + + it("skips checking if a notification should trigger if the values for the `password` and `newPassword` fields match (no change)", async () => { + const formEntryData: ModifyLoginCipherFormData = { + newPassword: "Beeblebrox4Prez", + password: "Beeblebrox4Prez", + uri: mockFormURI, + username: "ADent", + }; + + const storedCiphersForURL = [ + mock({ login: { username: "ADent", password: "I<3VogonPoetry" } }), + ]; + + getAllDecryptedForUrlSpy.mockResolvedValueOnce(storedCiphersForURL); + + await notificationBackground.triggerCipherNotification(formEntryData, tab); + + expectSkippedCheckingNotification(); + }); + + it("skips checking if a notification should trigger if the vault is locked and there is no value for the `newPassword` field", async () => { + const formEntryData: ModifyLoginCipherFormData = { + newPassword: "", + password: "Beeblebrox4Prez", + uri: mockFormURI, + username: "ADent", + }; + + const storedCiphersForURL = [ + mock({ login: { username: "ADent", password: "I<3VogonPoetry" } }), + ]; + + activeAccountStatusMock$.next(AuthenticationStatus.Locked); + getAllDecryptedForUrlSpy.mockResolvedValueOnce(storedCiphersForURL); + + await notificationBackground.triggerCipherNotification(formEntryData, tab); + + expectSkippedCheckingNotification(); + }); + + describe("when `username` and `password` and `newPassword` fields are filled, ", () => { + const formEntryData: ModifyLoginCipherFormData = { + newPassword: "Edro2x", + password: "UShallKnotPassword", + uri: mockFormURI, + username: "gandalfG", + }; + + it("and the user vault is locked, trigger an unlock notification", async () => { + const storedCiphersForURL = [ + mock({ + id: "cipher-id-1", + login: { password: "galadriel4Eva", username: "gandalfW" }, + }), + mock({ + id: "cipher-id-2", + login: { password: "Edro2x", username: "shadowfax" }, + }), + mock({ + id: "cipher-id-3", + login: { password: "sting123", username: "BBaggins" }, + }), + ]; + + activeAccountStatusMock$.next(AuthenticationStatus.Locked); + getAllDecryptedForUrlSpy.mockResolvedValueOnce(storedCiphersForURL); + + await notificationBackground.triggerCipherNotification(formEntryData, tab); + + expect(getAllDecryptedForUrlSpy).not.toHaveBeenCalled(); + expect(pushAddLoginToQueueSpy).not.toHaveBeenCalled(); + + expect(pushChangePasswordToQueueSpy).toHaveBeenCalledWith( + null, + mockFormattedURI, + formEntryData.newPassword, + tab, + true, // will yield an unlock prompt followed by an update password prompt + ); + }); + + it("and cipher update candidates match `newPassword` only, trigger a new cipher notification", async () => { + const storedCiphersForURL = [ + mock({ + id: "cipher-id-1", + login: { password: "galadriel4Eva", username: "gandalfW" }, + }), + mock({ + id: "cipher-id-2", + login: { password: "Edro2x", username: "shadowfax" }, + }), + mock({ + id: "cipher-id-3", + login: { password: "sting123", username: "BBaggins" }, + }), + ]; + + activeAccountStatusMock$.next(AuthenticationStatus.Unlocked); + getAllDecryptedForUrlSpy.mockResolvedValueOnce(storedCiphersForURL); + + await notificationBackground.triggerCipherNotification(formEntryData, tab); + + expect(pushChangePasswordToQueueSpy).not.toHaveBeenCalled(); + expect(pushAddLoginToQueueSpy).toHaveBeenCalledWith( + mockFormattedURI, + { + password: formEntryData.newPassword, + url: formEntryData.uri, + username: formEntryData.username, + }, + sender.tab, + ); + }); + + it("and cipher update candidates match `newPassword` only, do not trigger a new cipher notification if the new cipher notification setting is disabled", async () => { + const storedCiphersForURL = [ + mock({ + id: "cipher-id-1", + login: { password: "galadriel4Eva", username: "gandalfW" }, + }), + mock({ + id: "cipher-id-2", + login: { password: "Edro2x", username: "shadowfax" }, + }), + mock({ + id: "cipher-id-3", + login: { password: "sting123", username: "BBaggins" }, + }), + ]; + + activeAccountStatusMock$.next(AuthenticationStatus.Unlocked); + getAllDecryptedForUrlSpy.mockResolvedValueOnce(storedCiphersForURL); + getEnableAddedLoginPromptSpy.mockReturnValueOnce(false); + + await notificationBackground.triggerCipherNotification(formEntryData, tab); + + expect(pushChangePasswordToQueueSpy).not.toHaveBeenCalled(); + expect(pushAddLoginToQueueSpy).not.toHaveBeenCalled(); + }); + + it("and cipher update candidates match `password` only, trigger an update cipher notification with those candidates", async () => { + const storedCiphersForURL = [ + mock({ + id: "cipher-id-1", + login: { password: "UShallKnotPassword", username: "gandalfW" }, + }), + mock({ + id: "cipher-id-2", + login: { password: "UShallKnotPassword", username: "shadowfax" }, + }), + mock({ + id: "cipher-id-3", + login: { password: "sting123", username: "BBaggins" }, + }), + ]; + + activeAccountStatusMock$.next(AuthenticationStatus.Unlocked); + getAllDecryptedForUrlSpy.mockResolvedValueOnce(storedCiphersForURL); + + await notificationBackground.triggerCipherNotification(formEntryData, tab); + + expect(pushAddLoginToQueueSpy).not.toHaveBeenCalled(); + expect(pushChangePasswordToQueueSpy).toHaveBeenCalledWith( + ["cipher-id-1", "cipher-id-2"], + mockFormattedURI, + formEntryData.newPassword, + sender.tab, + ); + }); + + it("and cipher update candidates match `password` only, do not trigger an update cipher notification if the update notification setting is disabled", async () => { + const storedCiphersForURL = [ + mock({ + id: "cipher-id-1", + login: { password: "UShallKnotPassword", username: "gandalfW" }, + }), + mock({ + id: "cipher-id-2", + login: { password: "UShallKnotPassword", username: "shadowfax" }, + }), + mock({ + id: "cipher-id-3", + login: { password: "sting123", username: "BBaggins" }, + }), + ]; + + activeAccountStatusMock$.next(AuthenticationStatus.Unlocked); + getAllDecryptedForUrlSpy.mockResolvedValueOnce(storedCiphersForURL); + getEnableChangedPasswordPromptSpy.mockReturnValueOnce(false); + + await notificationBackground.triggerCipherNotification(formEntryData, tab); + + expect(pushChangePasswordToQueueSpy).not.toHaveBeenCalled(); + expect(pushAddLoginToQueueSpy).not.toHaveBeenCalled(); + }); + + it("and cipher update candidates match `password` only, as well as `newPassword` only, trigger a new cipher notification", async () => { + const storedCiphersForURL = [ + mock({ + id: "cipher-id-1", + login: { password: "UShallKnotPassword", username: "gandalfW" }, + }), + mock({ + id: "cipher-id-2", + login: { password: "Edro2x", username: "TBombadil" }, + }), + mock({ + id: "cipher-id-3", + login: { password: "UShallKnotPassword", username: "shadowfax" }, + }), + mock({ + id: "cipher-id-4", + login: { password: "sting123", username: "BBaggins" }, + }), + ]; + + activeAccountStatusMock$.next(AuthenticationStatus.Unlocked); + getAllDecryptedForUrlSpy.mockResolvedValueOnce(storedCiphersForURL); + + await notificationBackground.triggerCipherNotification(formEntryData, tab); + + expect(pushChangePasswordToQueueSpy).not.toHaveBeenCalled(); + expect(pushAddLoginToQueueSpy).toHaveBeenCalledWith( + mockFormattedURI, + { + password: formEntryData.newPassword, + url: formEntryData.uri, + username: formEntryData.username, + }, + sender.tab, + ); + }); + + it("and cipher update candidates match `username` only, trigger an update cipher notification with those candidates", async () => { + const storedCiphersForURL = [ + mock({ + id: "cipher-id-1", + login: { password: "galadriel4Eva", username: "gandalfW" }, + }), + mock({ + id: "cipher-id-2", + login: { password: "EdroEdro", username: "gandalfG" }, + }), + mock({ + id: "cipher-id-3", + login: { password: "sting123", username: "BBaggins" }, + }), + ]; + + activeAccountStatusMock$.next(AuthenticationStatus.Unlocked); + getAllDecryptedForUrlSpy.mockResolvedValueOnce(storedCiphersForURL); + + await notificationBackground.triggerCipherNotification(formEntryData, tab); + + expect(pushAddLoginToQueueSpy).not.toHaveBeenCalled(); + expect(pushChangePasswordToQueueSpy).toHaveBeenCalledWith( + ["cipher-id-2"], + mockFormattedURI, + formEntryData.newPassword, + sender.tab, + ); + }); + + it("and cipher update candidates match `username` only, do not trigger an update cipher notification if the update notification setting is disabled", async () => { + const storedCiphersForURL = [ + mock({ + id: "cipher-id-1", + login: { password: "galadriel4Eva", username: "gandalfW" }, + }), + mock({ + id: "cipher-id-2", + login: { password: "EdroEdro", username: "gandalfG" }, + }), + mock({ + id: "cipher-id-3", + login: { password: "sting123", username: "BBaggins" }, + }), + ]; + + activeAccountStatusMock$.next(AuthenticationStatus.Unlocked); + getAllDecryptedForUrlSpy.mockResolvedValueOnce(storedCiphersForURL); + getEnableChangedPasswordPromptSpy.mockReturnValueOnce(false); + + await notificationBackground.triggerCipherNotification(formEntryData, tab); + + expect(pushChangePasswordToQueueSpy).not.toHaveBeenCalled(); + expect(pushAddLoginToQueueSpy).not.toHaveBeenCalled(); + }); + + it("and cipher update candidates match `username` only, as well as `password` or `newPassword` only, trigger an update cipher notification with the candidates `username`", async () => { + const storedCiphersForURL = [ + mock({ + id: "cipher-id-1", + login: { password: "UShallKnotPassword", username: "gandalfW" }, + }), + mock({ + id: "cipher-id-2", + login: { password: "Edro2x", username: "BBaggins" }, + }), + mock({ + id: "cipher-id-3", + login: { password: "EdroEdro", username: "gandalfG" }, + }), + ]; + + activeAccountStatusMock$.next(AuthenticationStatus.Unlocked); + getAllDecryptedForUrlSpy.mockResolvedValueOnce(storedCiphersForURL); + + await notificationBackground.triggerCipherNotification(formEntryData, tab); + + expect(pushAddLoginToQueueSpy).not.toHaveBeenCalled(); + expect(pushChangePasswordToQueueSpy).toHaveBeenCalledWith( + ["cipher-id-3"], + mockFormattedURI, + formEntryData.newPassword, + sender.tab, + ); + }); + + it("and cipher update candidates match `username` and `newPassword`, do not trigger an update (nothing to change)", async () => { + const storedCiphersForURL = [ + mock({ + id: "cipher-id-1", + login: { password: "galadriel4Eva", username: "gandalfW" }, + }), + mock({ + id: "cipher-id-2", + login: { password: "sting123", username: "BBaggins" }, + }), + mock({ + id: "cipher-id-3", + login: { password: "Edro2x", username: "gandalfG" }, + }), + ]; + + activeAccountStatusMock$.next(AuthenticationStatus.Unlocked); + getAllDecryptedForUrlSpy.mockResolvedValueOnce(storedCiphersForURL); + + await notificationBackground.triggerCipherNotification(formEntryData, tab); + + expect(pushChangePasswordToQueueSpy).not.toHaveBeenCalled(); + expect(pushAddLoginToQueueSpy).not.toHaveBeenCalled(); + }); + + it("and cipher update candidates match `username` and `newPassword` as well as any other combination of `username`, `password`, and/or `newPassword`, do not trigger an update (nothing to change)", async () => { + const storedCiphersForURL = [ + mock({ + id: "cipher-id-1", + login: { password: "galadriel4Eva", username: "gandalfW" }, + }), + mock({ + id: "cipher-id-2", + login: { password: "sting123", username: "BBaggins" }, + }), + mock({ + id: "cipher-id-3", + login: { password: "Edro2x", username: "gandalfG" }, + }), + mock({ + id: "cipher-id-4", + login: { password: "UShallKnotPassword", username: "gandalfG" }, + }), + mock({ + id: "cipher-id-5", + login: { password: "Edro2x", username: "FBaggins" }, + }), + mock({ + id: "cipher-id-6", + login: { password: "UShallKnotPassword", username: "TBombadil" }, + }), + mock({ + id: "cipher-id-7", + login: { password: "ShyerH1re", username: "gandalfG" }, + }), + ]; + + activeAccountStatusMock$.next(AuthenticationStatus.Unlocked); + getAllDecryptedForUrlSpy.mockResolvedValueOnce(storedCiphersForURL); + + await notificationBackground.triggerCipherNotification(formEntryData, tab); + + expect(pushChangePasswordToQueueSpy).not.toHaveBeenCalled(); + expect(pushAddLoginToQueueSpy).not.toHaveBeenCalled(); + }); + + it("and cipher update candidates match `username` and `password`, trigger an update cipher notification with those candidates", async () => { + const storedCiphersForURL = [ + mock({ + id: "cipher-id-1", + login: { password: "UShallKnotPassword", username: "gandalfG" }, + }), + mock({ + id: "cipher-id-2", + login: { password: "galadriel4Eva", username: "gandalfW" }, + }), + mock({ + id: "cipher-id-3", + login: { password: "sting123", username: "BBaggins" }, + }), + ]; + + activeAccountStatusMock$.next(AuthenticationStatus.Unlocked); + getAllDecryptedForUrlSpy.mockResolvedValueOnce(storedCiphersForURL); + + await notificationBackground.triggerCipherNotification(formEntryData, tab); + + expect(pushAddLoginToQueueSpy).not.toHaveBeenCalled(); + expect(pushChangePasswordToQueueSpy).toHaveBeenCalledWith( + ["cipher-id-1"], + mockFormattedURI, + formEntryData.newPassword, + sender.tab, + ); + }); + + it("and cipher update candidates match `username` and `password`, do not trigger an update cipher notification if the update notification setting is disabled", async () => { + const storedCiphersForURL = [ + mock({ + id: "cipher-id-1", + login: { password: "UShallKnotPassword", username: "gandalfG" }, + }), + mock({ + id: "cipher-id-2", + login: { password: "galadriel4Eva", username: "gandalfW" }, + }), + mock({ + id: "cipher-id-3", + login: { password: "sting123", username: "BBaggins" }, + }), + ]; + + activeAccountStatusMock$.next(AuthenticationStatus.Unlocked); + getAllDecryptedForUrlSpy.mockResolvedValueOnce(storedCiphersForURL); + getEnableChangedPasswordPromptSpy.mockReturnValueOnce(false); + + await notificationBackground.triggerCipherNotification(formEntryData, tab); + + expect(pushChangePasswordToQueueSpy).not.toHaveBeenCalled(); + expect(pushAddLoginToQueueSpy).not.toHaveBeenCalled(); + }); + + it("and cipher update candidates match `username` AND `password` as well as any OTHER combination of `username`, `password`, and/or `newPassword` (excluding `username` AND `newPassword`), trigger an update cipher notification with the candidates matching `username` AND `password`", async () => { + const storedCiphersForURL = [ + mock({ + id: "cipher-id-1", + login: { password: "UShallKnotPassword", username: "TBombadil" }, + }), + mock({ + id: "cipher-id-2", + login: { password: "Edro2x", username: "shadowfax" }, + }), + mock({ + id: "cipher-id-3", + login: { password: "UShallKnotPassword", username: "gandalfG" }, + }), + mock({ + id: "cipher-id-4", + login: { password: "flyUPh00lz", username: "gandalfG" }, + }), + mock({ + id: "cipher-id-5", + login: { password: "galadriel4Eva", username: "gandalfW" }, + }), + mock({ + id: "cipher-id-6", + login: { password: "sting123", username: "BBaggins" }, + }), + ]; + + activeAccountStatusMock$.next(AuthenticationStatus.Unlocked); + getAllDecryptedForUrlSpy.mockResolvedValueOnce(storedCiphersForURL); + + await notificationBackground.triggerCipherNotification(formEntryData, tab); + + expect(pushAddLoginToQueueSpy).not.toHaveBeenCalled(); + expect(pushChangePasswordToQueueSpy).toHaveBeenCalledWith( + ["cipher-id-3"], + mockFormattedURI, + formEntryData.newPassword, + sender.tab, + ); + }); + + it("and no cipher update candidates match `username`, `password`, nor `newPassword`, trigger a new cipher notification", async () => { + const storedCiphersForURL = [ + mock({ + id: "cipher-id-1", + login: { password: "galadriel4Eva", username: "gandalfW" }, + }), + mock({ + id: "cipher-id-2", + login: { password: "EdroEdro", username: "shadowfax" }, + }), + mock({ + id: "cipher-id-3", + login: { password: "sting123", username: "BBaggins" }, + }), + ]; + + activeAccountStatusMock$.next(AuthenticationStatus.Unlocked); + getAllDecryptedForUrlSpy.mockResolvedValueOnce(storedCiphersForURL); + + await notificationBackground.triggerCipherNotification(formEntryData, tab); + + expect(pushChangePasswordToQueueSpy).not.toHaveBeenCalled(); + expect(pushAddLoginToQueueSpy).toHaveBeenCalledWith( + mockFormattedURI, + { + password: formEntryData.newPassword, + url: formEntryData.uri, + username: formEntryData.username, + }, + sender.tab, + ); + }); + + it("and no cipher update candidates match `username`, `password`, nor `newPassword`, do not trigger a new cipher notification if the new cipher notification setting is disabled", async () => { + const storedCiphersForURL = [ + mock({ + id: "cipher-id-1", + login: { password: "galadriel4Eva", username: "gandalfW" }, + }), + mock({ + id: "cipher-id-2", + login: { password: "EdroEdro", username: "shadowfax" }, + }), + mock({ + id: "cipher-id-3", + login: { password: "sting123", username: "BBaggins" }, + }), + ]; + + activeAccountStatusMock$.next(AuthenticationStatus.Unlocked); + getAllDecryptedForUrlSpy.mockResolvedValueOnce(storedCiphersForURL); + getEnableAddedLoginPromptSpy.mockReturnValueOnce(false); + + await notificationBackground.triggerCipherNotification(formEntryData, tab); + + expect(pushChangePasswordToQueueSpy).not.toHaveBeenCalled(); + expect(pushAddLoginToQueueSpy).not.toHaveBeenCalled(); + }); + }); + + describe("when `username` and `newPassword` fields are filled, ", () => { + const formEntryData: ModifyLoginCipherFormData = { + newPassword: "2ndBreakf4st", + password: "", + uri: mockFormURI, + username: "BBaggins", + }; + + it("and the user vault is locked, trigger an unlock notification", async () => { + activeAccountStatusMock$.next(AuthenticationStatus.Locked); + + await notificationBackground.triggerCipherNotification(formEntryData, tab); + + expect(getAllDecryptedForUrlSpy).not.toHaveBeenCalled(); + expect(pushAddLoginToQueueSpy).not.toHaveBeenCalled(); + + expect(pushChangePasswordToQueueSpy).toHaveBeenCalledWith( + null, + mockFormattedURI, + formEntryData.newPassword, + tab, + true, // will yield an unlock followed by an update password notification + ); + }); + + it("and cipher update candidates match only `newPassword`, do not trigger a notification", async () => { + const storedCiphersForURL = [ + mock({ + id: "cipher-id-1", + login: { username: "Frodo", password: "oldPassword" }, + }), + mock({ + id: "cipher-id-2", + login: { username: "Pippin", password: "2ndBreakf4st" }, + }), + ]; + + activeAccountStatusMock$.next(AuthenticationStatus.Unlocked); + getAllDecryptedForUrlSpy.mockResolvedValueOnce(storedCiphersForURL); + + await notificationBackground.triggerCipherNotification(formEntryData, tab); + + expect(pushChangePasswordToQueueSpy).not.toHaveBeenCalled(); + expect(pushAddLoginToQueueSpy).not.toHaveBeenCalled(); + }); + + it("and cipher update candidates match only `username`, trigger an update cipher notification with those candidates", async () => { + const storedCiphersForURL = [ + mock({ + id: "cipher-id-1", + login: { username: "BBaggins", password: "oldPassword" }, + }), + mock({ + id: "cipher-id-2", + login: { username: "Frodo", password: "differentPassword" }, + }), + mock({ + id: "cipher-id-3", + login: { username: "Pippin", password: "2ndBreakf4st" }, + }), + ]; + + activeAccountStatusMock$.next(AuthenticationStatus.Unlocked); + getAllDecryptedForUrlSpy.mockResolvedValueOnce(storedCiphersForURL); + + await notificationBackground.triggerCipherNotification(formEntryData, tab); + + expect(pushAddLoginToQueueSpy).not.toHaveBeenCalled(); + expect(pushChangePasswordToQueueSpy).toHaveBeenCalledWith( + ["cipher-id-1"], + mockFormattedURI, + formEntryData.newPassword, + sender.tab, + ); + }); + + it("and at least one cipher update candidate matches both `username` and `newPassword`, do not trigger an update (nothing to change)", async () => { + const storedCiphersForURL = [ + mock({ + id: "cipher-id-1", + login: { username: "BBaggins", password: "oldPassword" }, + }), + mock({ + id: "cipher-id-2", + login: { username: "BBaggins", password: "2ndBreakf4st" }, + }), + mock({ + id: "cipher-id-3", + login: { username: "Frodo", password: "differentPassword" }, + }), + ]; + + activeAccountStatusMock$.next(AuthenticationStatus.Unlocked); + getAllDecryptedForUrlSpy.mockResolvedValueOnce(storedCiphersForURL); + + await notificationBackground.triggerCipherNotification(formEntryData, tab); + + expect(pushChangePasswordToQueueSpy).not.toHaveBeenCalled(); + expect(pushAddLoginToQueueSpy).not.toHaveBeenCalled(); + }); + + it("and no cipher update candidates match `username` nor `newPassword`, trigger a new cipher notification", async () => { + const storedCiphersForURL = [ + mock({ + id: "cipher-id-1", + login: { username: "Frodo", password: "oldPassword" }, + }), + mock({ + id: "cipher-id-2", + login: { username: "Pippin", password: "differentPassword" }, + }), + ]; + + activeAccountStatusMock$.next(AuthenticationStatus.Unlocked); + getAllDecryptedForUrlSpy.mockResolvedValueOnce(storedCiphersForURL); + + await notificationBackground.triggerCipherNotification(formEntryData, tab); + + expect(pushChangePasswordToQueueSpy).not.toHaveBeenCalled(); + expect(pushAddLoginToQueueSpy).toHaveBeenCalledWith( + mockFormattedURI, + { + password: formEntryData.newPassword, + url: formEntryData.uri, + username: formEntryData.username, + }, + sender.tab, + ); + }); + + it("and no cipher update candidates match `username` nor `newPassword`, do not trigger a new cipher notification if the new cipher notification setting is disabled", async () => { + const storedCiphersForURL = [ + mock({ + id: "cipher-id-1", + login: { username: "Frodo", password: "oldPassword" }, + }), + mock({ + id: "cipher-id-2", + login: { username: "Pippin", password: "differentPassword" }, + }), + ]; + + activeAccountStatusMock$.next(AuthenticationStatus.Unlocked); + getAllDecryptedForUrlSpy.mockResolvedValueOnce(storedCiphersForURL); + getEnableAddedLoginPromptSpy.mockReturnValueOnce(false); + await notificationBackground.triggerCipherNotification(formEntryData, tab); + + expect(pushChangePasswordToQueueSpy).not.toHaveBeenCalled(); + expect(pushAddLoginToQueueSpy).not.toHaveBeenCalled(); + }); + }); + + describe("when only `username` field is filled, ", () => { + const formEntryData: ModifyLoginCipherFormData = { + newPassword: "", + password: "", + uri: mockFormURI, + username: "BBaggins", + }; + + it("and the user vault is locked, do not trigger a notification", async () => { + activeAccountStatusMock$.next(AuthenticationStatus.Locked); + + await notificationBackground.triggerCipherNotification(formEntryData, tab); + + expectSkippedCheckingNotification(); + }); + + it("and at least one cipher update candidate matches `username`, do not trigger a notification (nothing to change)", async () => { + const storedCiphersForURL = [ + mock({ + id: "cipher-id-1", + login: { username: "BBaggins", password: "password1" }, + }), + mock({ + id: "cipher-id-2", + login: { username: "Frodo", password: "password2" }, + }), + ]; + + activeAccountStatusMock$.next(AuthenticationStatus.Unlocked); + getAllDecryptedForUrlSpy.mockResolvedValueOnce(storedCiphersForURL); + + await notificationBackground.triggerCipherNotification(formEntryData, tab); + + expect(pushChangePasswordToQueueSpy).not.toHaveBeenCalled(); + expect(pushAddLoginToQueueSpy).not.toHaveBeenCalled(); + }); + + it("and no cipher update candidates match `username`, trigger a new cipher notification", async () => { + const storedCiphersForURL = [ + mock({ + id: "cipher-id-1", + login: { username: "Frodo", password: "password1" }, + }), + mock({ + id: "cipher-id-2", + login: { username: "Pippin", password: "password2" }, + }), + ]; + + activeAccountStatusMock$.next(AuthenticationStatus.Unlocked); + getAllDecryptedForUrlSpy.mockResolvedValueOnce(storedCiphersForURL); + + await notificationBackground.triggerCipherNotification(formEntryData, tab); + + expect(pushChangePasswordToQueueSpy).not.toHaveBeenCalled(); + expect(pushAddLoginToQueueSpy).toHaveBeenCalledWith( + mockFormattedURI, + { + password: "", + url: formEntryData.uri, + username: formEntryData.username, + }, + sender.tab, + ); + }); + + it("and no cipher update candidates match `username`, do not trigger a new cipher notification if the new cipher notification setting is disabled", async () => { + const storedCiphersForURL = [ + mock({ + id: "cipher-id-1", + login: { username: "Frodo", password: "password1" }, + }), + mock({ + id: "cipher-id-2", + login: { username: "Pippin", password: "password2" }, + }), + ]; + + activeAccountStatusMock$.next(AuthenticationStatus.Unlocked); + getAllDecryptedForUrlSpy.mockResolvedValueOnce(storedCiphersForURL); + getEnableAddedLoginPromptSpy.mockReturnValueOnce(false); + + await notificationBackground.triggerCipherNotification(formEntryData, tab); + + expect(pushChangePasswordToQueueSpy).not.toHaveBeenCalled(); + expect(pushAddLoginToQueueSpy).not.toHaveBeenCalled(); + }); + }); + + describe("when `password` and `newPassword` fields are filled, ", () => { + const formEntryData: ModifyLoginCipherFormData = { + newPassword: "4WzrdIzN0tLa7e", + password: "UShallKnotPassword", + username: "", + uri: mockFormURI, + }; + + it("and the user vault is locked, trigger an unlock notification", async () => { + activeAccountStatusMock$.next(AuthenticationStatus.Locked); + + await notificationBackground.triggerCipherNotification(formEntryData, tab); + + expect(getAllDecryptedForUrlSpy).not.toHaveBeenCalled(); + expect(pushAddLoginToQueueSpy).not.toHaveBeenCalled(); + + expect(pushChangePasswordToQueueSpy).toHaveBeenCalledWith( + null, + mockFormattedURI, + formEntryData.newPassword, + tab, + true, // will yield an unlock followed by an update password notification + ); + }); + + it("and cipher update candidates only match `newPassword`, do not trigger a notification (nothing to change)", async () => { + const storedCiphersForURL = [ + mock({ + id: "cipher-id-1", + login: { username: "GaldalfG", password: "4WzrdIzN0tLa7e" }, + }), + mock({ + id: "cipher-id-2", + login: { username: "GaldalfW", password: "4WzrdIzN0tLa7e" }, + }), + ]; + + activeAccountStatusMock$.next(AuthenticationStatus.Unlocked); + getAllDecryptedForUrlSpy.mockResolvedValueOnce(storedCiphersForURL); + + await notificationBackground.triggerCipherNotification(formEntryData, tab); + + expect(pushChangePasswordToQueueSpy).not.toHaveBeenCalled(); + expect(pushAddLoginToQueueSpy).not.toHaveBeenCalled(); + }); + + it("and cipher update candidates only match `password`, trigger an update cipher notification with those candidates", async () => { + const storedCiphersForURL = [ + mock({ + id: "cipher-id-1", + login: { username: "Frodo", password: "PutAR1ngOnIt" }, + }), + mock({ + id: "cipher-id-2", + login: { username: "Pippin", password: "UShallKnotPassword" }, + }), + mock({ + id: "cipher-id-3", + login: { username: "Merry", password: "UShallKnotPassword" }, + }), + ]; + + activeAccountStatusMock$.next(AuthenticationStatus.Unlocked); + getAllDecryptedForUrlSpy.mockResolvedValueOnce(storedCiphersForURL); + + await notificationBackground.triggerCipherNotification(formEntryData, tab); + + expect(pushAddLoginToQueueSpy).not.toHaveBeenCalled(); + expect(pushChangePasswordToQueueSpy).toHaveBeenCalledWith( + ["cipher-id-2", "cipher-id-3"], + mockFormattedURI, + formEntryData.newPassword, + sender.tab, + ); + }); + + it("and cipher update candidates only match `password`, do not trigger an update cipher notification if the update cipher notification setting is disabled", async () => { + const storedCiphersForURL = [ + mock({ + id: "cipher-id-1", + login: { username: "Frodo", password: "PutAR1ngOnIt" }, + }), + mock({ + id: "cipher-id-2", + login: { username: "Pippin", password: "UShallKnotPassword" }, + }), + mock({ + id: "cipher-id-3", + login: { username: "Merry", password: "UShallKnotPassword" }, + }), + ]; + + activeAccountStatusMock$.next(AuthenticationStatus.Unlocked); + getAllDecryptedForUrlSpy.mockResolvedValueOnce(storedCiphersForURL); + getEnableChangedPasswordPromptSpy.mockReturnValueOnce(false); + + await notificationBackground.triggerCipherNotification(formEntryData, tab); + + expect(pushChangePasswordToQueueSpy).not.toHaveBeenCalled(); + expect(pushAddLoginToQueueSpy).not.toHaveBeenCalled(); + }); + + it("and no cipher update candidates match `password` or `newPassword`, do not trigger a notification", async () => { + const storedCiphersForURL = [ + mock({ + id: "cipher-id-1", + login: { username: "Frodo", password: "PutAR1ngOnIt" }, + }), + mock({ + id: "cipher-id-2", + login: { username: "PTook", password: "11sies" }, + }), + ]; + + activeAccountStatusMock$.next(AuthenticationStatus.Unlocked); + getAllDecryptedForUrlSpy.mockResolvedValueOnce(storedCiphersForURL); + + await notificationBackground.triggerCipherNotification(formEntryData, tab); + + expect(pushChangePasswordToQueueSpy).not.toHaveBeenCalled(); + expect(pushAddLoginToQueueSpy).not.toHaveBeenCalled(); + }); + }); + + describe("when only `password` field is filled, ", () => { + const formEntryData: ModifyLoginCipherFormData = { + newPassword: "", + password: "UShallKnotPassword", + uri: mockFormURI, + username: "", + }; + + it("and the user vault is locked, do not trigger an unlock notification", async () => { + activeAccountStatusMock$.next(AuthenticationStatus.Locked); + + await notificationBackground.triggerCipherNotification(formEntryData, tab); + + expectSkippedCheckingNotification(); + }); + + it("and cipher update candidates only match `password`, do not trigger a notification (nothing to change)", async () => { + const storedCiphersForURL = [ + mock({ + id: "cipher-id-1", + login: { username: "FBaggins", password: "UShallKnotPassword" }, + }), + mock({ + id: "cipher-id-2", + login: { username: "BBaggins", password: "UShallKnotPassword" }, + }), + ]; + + activeAccountStatusMock$.next(AuthenticationStatus.Unlocked); + getAllDecryptedForUrlSpy.mockResolvedValueOnce(storedCiphersForURL); + + await notificationBackground.triggerCipherNotification(formEntryData, tab); + + expect(pushChangePasswordToQueueSpy).not.toHaveBeenCalled(); + expect(pushAddLoginToQueueSpy).not.toHaveBeenCalled(); + }); + + it("and no cipher update candidates match `password`, trigger an update cipher notification with ALL cipher update candidates", async () => { + const storedCiphersForURL = [ + mock({ + id: "cipher-id-1", + login: { username: "FBaggins", password: "PutAR1ngOnIt" }, + }), + mock({ + id: "cipher-id-2", + login: { username: "BBaggins", password: "MahPr3c10us" }, + }), + mock({ + id: "cipher-id-3", + login: { username: "PTook", password: "f00lOfAT00k" }, + }), + ]; + + activeAccountStatusMock$.next(AuthenticationStatus.Unlocked); + getAllDecryptedForUrlSpy.mockResolvedValueOnce(storedCiphersForURL); + + await notificationBackground.triggerCipherNotification(formEntryData, tab); + + expect(pushAddLoginToQueueSpy).not.toHaveBeenCalled(); + expect(pushChangePasswordToQueueSpy).toHaveBeenCalledWith( + ["cipher-id-1", "cipher-id-2", "cipher-id-3"], + mockFormattedURI, + formEntryData.password, + sender.tab, + ); + }); + + it("and no cipher update candidates match `password`, do not trigger an update cipher notification if the update cipher notification setting is disabled", async () => { + const storedCiphersForURL = [ + mock({ + id: "cipher-id-1", + login: { username: "FBaggins", password: "PutAR1ngOnIt" }, + }), + mock({ + id: "cipher-id-2", + login: { username: "BBaggins", password: "MahPr3c10us" }, + }), + mock({ + id: "cipher-id-3", + login: { username: "PTook", password: "f00lOfAT00k" }, + }), + ]; + + activeAccountStatusMock$.next(AuthenticationStatus.Unlocked); + getAllDecryptedForUrlSpy.mockResolvedValueOnce(storedCiphersForURL); + getEnableChangedPasswordPromptSpy.mockReturnValueOnce(false); + + await notificationBackground.triggerCipherNotification(formEntryData, tab); + + expect(pushChangePasswordToQueueSpy).not.toHaveBeenCalled(); + expect(pushAddLoginToQueueSpy).not.toHaveBeenCalled(); + }); + }); + + describe("when `username` and `password` fields are filled, ", () => { + const formEntryData: ModifyLoginCipherFormData = { + newPassword: "", + password: "ShyerH1re", + uri: mockFormURI, + username: "BBaggins", + }; + + it("and cipher update candidates only match `password`, trigger an update cipher notification with those candidates", async () => { + const storedCiphersForURL = [ + mock({ + id: "cipher-id-1", + login: { username: "FBaggins", password: "ShyerH1re" }, + }), + mock({ + id: "cipher-id-2", + login: { username: "PTook", password: "ShyerH1re" }, + }), + mock({ + id: "cipher-id-3", + login: { username: "FrodoB", password: "UShallKnotPassword" }, + }), + ]; + + activeAccountStatusMock$.next(AuthenticationStatus.Unlocked); + getAllDecryptedForUrlSpy.mockResolvedValueOnce(storedCiphersForURL); + + await notificationBackground.triggerCipherNotification(formEntryData, tab); + + expect(pushAddLoginToQueueSpy).not.toHaveBeenCalled(); + expect(pushChangePasswordToQueueSpy).toHaveBeenCalledWith( + ["cipher-id-1", "cipher-id-2"], + mockFormattedURI, + formEntryData.password, + sender.tab, + ); + }); + + it("and cipher update candidates only match `password`, do not trigger an update cipher notification if the update cipher notification setting is disabled", async () => { + const storedCiphersForURL = [ + mock({ + id: "cipher-id-1", + login: { username: "FBaggins", password: "ShyerH1re" }, + }), + mock({ + id: "cipher-id-2", + login: { username: "PTook", password: "ShyerH1re" }, + }), + mock({ + id: "cipher-id-3", + login: { username: "FrodoB", password: "UShallKnotPassword" }, + }), + ]; + + activeAccountStatusMock$.next(AuthenticationStatus.Unlocked); + getAllDecryptedForUrlSpy.mockResolvedValueOnce(storedCiphersForURL); + getEnableChangedPasswordPromptSpy.mockReturnValueOnce(false); + + await notificationBackground.triggerCipherNotification(formEntryData, tab); + + expect(pushChangePasswordToQueueSpy).not.toHaveBeenCalled(); + expect(pushAddLoginToQueueSpy).not.toHaveBeenCalled(); + }); + + it("and cipher update candidates only match `username`, trigger an update cipher notification with those candidates", async () => { + const storedCiphersForURL = [ + mock({ + id: "cipher-id-1", + login: { username: "BBaggins", password: "W0nWr1ng" }, + }), + mock({ + id: "cipher-id-2", + login: { username: "BBaggins", password: "UShallKnotPassword" }, + }), + mock({ + id: "cipher-id-3", + login: { username: "BilboB", password: "UShallKnotPassword" }, + }), + ]; + + activeAccountStatusMock$.next(AuthenticationStatus.Unlocked); + getAllDecryptedForUrlSpy.mockResolvedValueOnce(storedCiphersForURL); + + await notificationBackground.triggerCipherNotification(formEntryData, tab); + + expect(pushAddLoginToQueueSpy).not.toHaveBeenCalled(); + expect(pushChangePasswordToQueueSpy).toHaveBeenCalledWith( + ["cipher-id-1", "cipher-id-2"], + mockFormattedURI, + formEntryData.password, + sender.tab, + ); + }); + + it("and cipher update candidates only match `username`, do not trigger an update cipher notification if the update cipher notification setting is disabled", async () => { + const storedCiphersForURL = [ + mock({ + id: "cipher-id-1", + login: { username: "BBaggins", password: "W0nWr1ng" }, + }), + mock({ + id: "cipher-id-2", + login: { username: "BBaggins", password: "UShallKnotPassword" }, + }), + mock({ + id: "cipher-id-3", + login: { username: "BilboB", password: "UShallKnotPassword" }, + }), + ]; + + activeAccountStatusMock$.next(AuthenticationStatus.Unlocked); + getAllDecryptedForUrlSpy.mockResolvedValueOnce(storedCiphersForURL); + getEnableChangedPasswordPromptSpy.mockReturnValueOnce(false); + + await notificationBackground.triggerCipherNotification(formEntryData, tab); + + expect(pushChangePasswordToQueueSpy).not.toHaveBeenCalled(); + expect(pushAddLoginToQueueSpy).not.toHaveBeenCalled(); + }); + + it("and cipher update candidates match `username` and `password`, do not trigger a notification (nothing to change)", async () => { + const storedCiphersForURL = [ + mock({ + id: "cipher-id-1", + login: { username: "BBaggins", password: "ShyerH1re" }, + }), + mock({ + id: "cipher-id-2", + login: { username: "FBaggins", password: "W0nWr1ng" }, + }), + mock({ + id: "cipher-id-3", + login: { username: "BBaggins", password: "ShyerH1re" }, + }), + ]; + + activeAccountStatusMock$.next(AuthenticationStatus.Unlocked); + getAllDecryptedForUrlSpy.mockResolvedValueOnce(storedCiphersForURL); + + await notificationBackground.triggerCipherNotification(formEntryData, tab); + + expect(pushChangePasswordToQueueSpy).not.toHaveBeenCalled(); + expect(pushAddLoginToQueueSpy).not.toHaveBeenCalled(); + }); + + it("and cipher update candidates match `username` AND `password` and additionally `username` OR `password`, do not trigger a notification (nothing to change)", async () => { + const storedCiphersForURL = [ + mock({ + id: "cipher-id-1", + login: { username: "BBaggins", password: "UShallKnotPassword" }, + }), + mock({ + id: "cipher-id-2", + login: { username: "BBaggins", password: "ShyerH1re" }, + }), + mock({ + id: "cipher-id-3", + login: { username: "FBaggins", password: "W0nWr1ng" }, + }), + mock({ + id: "cipher-id-4", + login: { username: "TBombadil", password: "ShyerH1re" }, + }), + ]; + + activeAccountStatusMock$.next(AuthenticationStatus.Unlocked); + getAllDecryptedForUrlSpy.mockResolvedValueOnce(storedCiphersForURL); + + await notificationBackground.triggerCipherNotification(formEntryData, tab); + + expect(pushChangePasswordToQueueSpy).not.toHaveBeenCalled(); + expect(pushAddLoginToQueueSpy).not.toHaveBeenCalled(); + }); + + it("and no cipher update candidates match `username` or `password`, trigger a new cipher notification", async () => { + const storedCiphersForURL = [ + mock({ + id: "cipher-id-1", + login: { username: "FBaggins", password: "W0nWr1ng" }, + }), + mock({ + id: "cipher-id-2", + login: { username: "BilboB", password: "PutAR1ngOnIt" }, + }), + ]; + + activeAccountStatusMock$.next(AuthenticationStatus.Unlocked); + getAllDecryptedForUrlSpy.mockResolvedValueOnce(storedCiphersForURL); + + await notificationBackground.triggerCipherNotification(formEntryData, tab); + + expect(pushChangePasswordToQueueSpy).not.toHaveBeenCalled(); + expect(pushAddLoginToQueueSpy).toHaveBeenCalledWith( + mockFormattedURI, + { + password: formEntryData.password, + url: formEntryData.uri, + username: formEntryData.username, + }, + sender.tab, + ); + }); + + it("and no cipher update candidates match `username` or `password`, do not trigger a new cipher notification if the new cipher notification setting is disabled", async () => { + const storedCiphersForURL = [ + mock({ + id: "cipher-id-1", + login: { username: "FBaggins", password: "W0nWr1ng" }, + }), + mock({ + id: "cipher-id-2", + login: { username: "BilboB", password: "PutAR1ngOnIt" }, + }), + ]; + + activeAccountStatusMock$.next(AuthenticationStatus.Unlocked); + getAllDecryptedForUrlSpy.mockResolvedValueOnce(storedCiphersForURL); + getEnableAddedLoginPromptSpy.mockReturnValueOnce(false); + + await notificationBackground.triggerCipherNotification(formEntryData, tab); + + expect(pushChangePasswordToQueueSpy).not.toHaveBeenCalled(); + expect(pushAddLoginToQueueSpy).not.toHaveBeenCalled(); + }); + }); + + describe("when only `newPassword` field is filled, ", () => { + const formEntryData: ModifyLoginCipherFormData = { + newPassword: "ShyerH1re", + password: "", + uri: mockFormURI, + username: "", + }; + + it("and the user vault is locked, trigger an unlock notification", async () => { + activeAccountStatusMock$.next(AuthenticationStatus.Locked); + + await notificationBackground.triggerCipherNotification(formEntryData, tab); + + expect(getAllDecryptedForUrlSpy).not.toHaveBeenCalled(); + expect(pushAddLoginToQueueSpy).not.toHaveBeenCalled(); + + expect(pushChangePasswordToQueueSpy).toHaveBeenCalledWith( + null, + mockFormattedURI, + formEntryData.newPassword, + tab, + true, // will yield an unlock followed by an update password notification + ); + }); + + it("and cipher update candidates only match `newPassword`, do not trigger a notification (nothing to change)", async () => { + const storedCiphersForURL = [ + mock({ + id: "cipher-id-1", + login: { username: "FBaggins", password: "ShyerH1re" }, + }), + mock({ + id: "cipher-id-2", + login: { username: "PTook", password: "ShyerH1re" }, + }), + ]; + + activeAccountStatusMock$.next(AuthenticationStatus.Unlocked); + getAllDecryptedForUrlSpy.mockResolvedValueOnce(storedCiphersForURL); + + await notificationBackground.triggerCipherNotification(formEntryData, tab); + + expect(pushChangePasswordToQueueSpy).not.toHaveBeenCalled(); + expect(pushAddLoginToQueueSpy).not.toHaveBeenCalled(); + }); + + it("and no cipher update candidates match `newPassword`, trigger an update cipher notification with ALL cipher update candidates", async () => { + const storedCiphersForURL = [ + mock({ + id: "cipher-id-1", + login: { username: "FBaggins", password: "W0nWr1ng" }, + }), + mock({ + id: "cipher-id-2", + login: { username: "PTook", password: "PutAR1ngOnIt" }, + }), + mock({ + id: "cipher-id-3", + login: { username: "SamwiseG", password: "P0t4toes" }, + }), + ]; + + activeAccountStatusMock$.next(AuthenticationStatus.Unlocked); + getAllDecryptedForUrlSpy.mockResolvedValueOnce(storedCiphersForURL); + + await notificationBackground.triggerCipherNotification(formEntryData, tab); + + expect(pushAddLoginToQueueSpy).not.toHaveBeenCalled(); + expect(pushChangePasswordToQueueSpy).toHaveBeenCalledWith( + ["cipher-id-1", "cipher-id-2", "cipher-id-3"], + mockFormattedURI, + formEntryData.newPassword, + sender.tab, + ); + }); + + it("and no cipher update candidates match `newPassword`, do not trigger an update cipher notification if the update cipher notification setting is disabled", async () => { + const storedCiphersForURL = [ + mock({ + id: "cipher-id-1", + login: { username: "FBaggins", password: "W0nWr1ng" }, + }), + mock({ + id: "cipher-id-2", + login: { username: "PTook", password: "PutAR1ngOnIt" }, + }), + mock({ + id: "cipher-id-3", + login: { username: "SamwiseG", password: "P0t4toes" }, + }), + ]; + + activeAccountStatusMock$.next(AuthenticationStatus.Unlocked); + getAllDecryptedForUrlSpy.mockResolvedValueOnce(storedCiphersForURL); + getEnableChangedPasswordPromptSpy.mockReturnValueOnce(false); + + await notificationBackground.triggerCipherNotification(formEntryData, tab); + + expect(pushChangePasswordToQueueSpy).not.toHaveBeenCalled(); + expect(pushAddLoginToQueueSpy).not.toHaveBeenCalled(); + }); + }); + }); + describe("bgRemoveTabFromNotificationQueue message handler", () => { it("splices a notification queue item based on the passed tab", async () => { const tab = createChromeTabMock({ id: 2 }); diff --git a/apps/browser/src/autofill/background/notification.background.ts b/apps/browser/src/autofill/background/notification.background.ts index f8459cf8f23..33d65391c25 100644 --- a/apps/browser/src/autofill/background/notification.background.ts +++ b/apps/browser/src/autofill/background/notification.background.ts @@ -22,6 +22,7 @@ import { import { DomainSettingsService } from "@bitwarden/common/autofill/services/domain-settings.service"; import { UserNotificationSettingsServiceAbstraction } from "@bitwarden/common/autofill/services/user-notification-settings.service"; import { ProductTierType } from "@bitwarden/common/billing/enums/product-tier-type.enum"; +import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; import { NeverDomains } from "@bitwarden/common/models/domain/domain-service"; import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { ServerConfig } from "@bitwarden/common/platform/abstractions/config/server-config"; @@ -79,6 +80,30 @@ import { } from "./abstractions/overlay-notifications.background"; import { OverlayBackgroundExtensionMessage } from "./abstractions/overlay.background"; +const inputScenarios = { + usernamePasswordNewPassword: "usernamePasswordNewPassword", + usernameNewPassword: "usernameNewPassword", + usernamePassword: "usernamePassword", + username: "username", + passwordNewPassword: "passwordNewPassword", + newPassword: "newPassword", + password: "password", +} as const; + +type InputScenarioKey = keyof typeof inputScenarios; +type InputScenario = (typeof inputScenarios)[InputScenarioKey]; + +type CiphersByInputMatchCategory = { + allFieldMatches: CipherView["id"][]; + newPasswordOnlyMatches: CipherView["id"][]; + noFieldMatches: CipherView["id"][]; + passwordNewPasswordMatches: CipherView["id"][]; + passwordOnlyMatches: CipherView["id"][]; + usernameNewPasswordMatches: CipherView["id"][]; + usernameOnlyMatches: CipherView["id"][]; + usernamePasswordMatches: CipherView["id"][]; +}; + export default class NotificationBackground { private openUnlockPopout = openUnlockPopout; private openAddEditVaultItemPopout = openAddEditVaultItemPopout; @@ -152,6 +177,10 @@ export default class NotificationBackground { this.cleanupNotificationQueue(); } + useUndeterminedCipherScenarioTriggeringLogic$ = this.configService.getFeatureFlag$( + FeatureFlag.UseUndeterminedCipherScenarioTriggeringLogic, + ); + /** * Gets the enableChangedPasswordPrompt setting from the user notification settings service. */ @@ -292,7 +321,7 @@ export default class NotificationBackground { type: CipherType.Login, reprompt, favorite, - ...(organizationCategories.length ? { organizationCategories } : {}), + ...(organizationCategories.length > 0 ? { organizationCategories } : {}), icon: buildCipherIcon(iconsServerUrl, view, showFavicons), login: login && { username: login.username }, }; @@ -309,7 +338,7 @@ export default class NotificationBackground { activeUserId: UserId, ): Promise { const tasks: SecurityTask[] = await this.getSecurityTasks(activeUserId); - if (!tasks?.length) { + if (!(tasks?.length > 0)) { return null; } @@ -317,7 +346,7 @@ export default class NotificationBackground { modifyLoginData.uri, activeUserId, ); - if (!urlCiphers?.length) { + if (!(urlCiphers?.length > 0)) { return null; } @@ -596,6 +625,216 @@ export default class NotificationBackground { await this.checkNotificationQueue(tab); } + /** + * Receives filled form values and determines if a notification should be + * triggered, and if so, what kind and with what data. + * + * If an update scenario is identified, a change password message is added to the + * notification queue, prompting the user to update a stored login that has changed. + * + * A new cipher notification is triggered in other defined scenarios + * with the user's form input. + * + * Returns `true` or `false` to indicate if such a notification was + * triggered or not. + * + * For the purposes of this function, form field inputs should be assumed to be + * qualified accurately. + */ + async triggerCipherNotification( + data: ModifyLoginCipherFormData, + tab: chrome.tabs.Tab, + ): Promise { + const usernameFieldValue: string | null = data.username || null; + const currentPasswordFieldValue = data.password || null; + const newPasswordFieldValue = data.newPassword || null; + + // If no values were entered, exit early + if (!usernameFieldValue && !currentPasswordFieldValue && !newPasswordFieldValue) { + return false; + } + + // If the entered data doesn't have an associated URI, exit early + const loginDomain = Utils.getDomain(data.uri); + if (loginDomain === null) { + return false; + } + + // If no cipher add/update notifications are enabled, we can exit early + const changePasswordNotificationIsEnabled = await this.getEnableChangedPasswordPrompt(); + const newLoginNotificationIsEnabled = await this.getEnableAddedLoginPrompt(); + if (!changePasswordNotificationIsEnabled && !newLoginNotificationIsEnabled) { + return false; + } + + // If there is no account logged in (as opposed to only being locked), exit early + const authStatus = await this.getAuthStatus(); + if (authStatus === AuthenticationStatus.LoggedOut) { + return false; + } + + // If there is no active user, exit early + const activeUserId = await firstValueFrom( + this.accountService.activeAccount$.pipe(getOptionalUserId), + ); + if (activeUserId === null) { + return false; + } + + const normalizedUsername: string = usernameFieldValue ? usernameFieldValue.toLowerCase() : ""; + const currentPasswordFieldHasValue = + typeof currentPasswordFieldValue === "string" && currentPasswordFieldValue.length > 0; + const newPasswordFieldHasValue = + typeof newPasswordFieldValue === "string" && newPasswordFieldValue.length > 0; + const usernameFieldHasValue = + typeof usernameFieldValue === "string" && usernameFieldValue.length > 0; + + // If the current and new password inputs both have values and those values + // match, return early, since no change was made + if ( + currentPasswordFieldHasValue && + newPasswordFieldHasValue && + currentPasswordFieldValue === newPasswordFieldValue + ) { + return false; + } + + /* + * We only show the unlock notification if a new password field was filled, since + * it's very likely to blindly represent an updated cipher value whereas other + * scenarios below require the vault to be unlocked in order to determine + * if an update has been made. + */ + if (authStatus === AuthenticationStatus.Locked) { + if (!newPasswordFieldHasValue) { + return false; + } + // This needs to be the call that includes the full form data + await this.pushChangePasswordToQueue(null, loginDomain, newPasswordFieldValue, tab, true); + + return true; + } + + const ciphersForURL: CipherView[] = await this.cipherService.getAllDecryptedForUrl( + data.uri, + activeUserId, + ); + + // Reducer structured to avoid subsequent array iterations + const ciphersByInputMatchCategory = ciphersForURL.reduce( + (acc, { id, login }) => { + const usernameInputMatchesCipher = + usernameFieldHasValue && login.username?.toLowerCase() === normalizedUsername; + const passwordInputMatchesCipher = + currentPasswordFieldHasValue && login.password === currentPasswordFieldValue; + const newPasswordInputMatchesCipher = + newPasswordFieldHasValue && login.password === newPasswordFieldValue; + + if ( + !newPasswordInputMatchesCipher && + !usernameInputMatchesCipher && + !passwordInputMatchesCipher + ) { + return { ...acc, noFieldMatches: [...acc.noFieldMatches, id] }; + } else if ( + newPasswordInputMatchesCipher && + usernameInputMatchesCipher && + passwordInputMatchesCipher + ) { + // Note: this case should be unreachable due to the early exit comparing + // the password input values against each other, but leaving this bit here + // as a defense against future changes to the pre-match checks. + return { ...acc, allFieldMatches: [...acc.allFieldMatches, id] }; + } else if ( + newPasswordInputMatchesCipher && + !usernameInputMatchesCipher && + !passwordInputMatchesCipher + ) { + return { ...acc, newPasswordOnlyMatches: [...acc.newPasswordOnlyMatches, id] }; + } else if ( + passwordInputMatchesCipher && + !usernameInputMatchesCipher && + !newPasswordInputMatchesCipher + ) { + return { ...acc, passwordOnlyMatches: [...acc.passwordOnlyMatches, id] }; + } else if ( + passwordInputMatchesCipher && + newPasswordInputMatchesCipher && + !usernameInputMatchesCipher + ) { + // Note: this case should be unreachable due to the early exit comparing + // the password input values against each other, but leaving this bit here + // as a defense against future changes to the pre-match checks. + return { ...acc, passwordNewPasswordMatches: [...acc.passwordNewPasswordMatches, id] }; + } else if ( + usernameInputMatchesCipher && + !passwordInputMatchesCipher && + !newPasswordInputMatchesCipher + ) { + return { ...acc, usernameOnlyMatches: [...acc.usernameOnlyMatches, id] }; + } else if ( + usernameInputMatchesCipher && + passwordInputMatchesCipher && + !newPasswordInputMatchesCipher + ) { + return { ...acc, usernamePasswordMatches: [...acc.usernamePasswordMatches, id] }; + } else if ( + usernameInputMatchesCipher && + newPasswordInputMatchesCipher && + !passwordInputMatchesCipher + ) { + return { ...acc, usernameNewPasswordMatches: [...acc.usernameNewPasswordMatches, id] }; + } + + return acc; + }, + { + allFieldMatches: [], + newPasswordOnlyMatches: [], + noFieldMatches: [], + passwordNewPasswordMatches: [], + passwordOnlyMatches: [], + usernameNewPasswordMatches: [], + usernameOnlyMatches: [], + usernamePasswordMatches: [], + }, + ); + + // Handle different field fill combinations and determine the input scenario + const inputScenariosByKey = { + upn: inputScenarios.usernamePasswordNewPassword, + un: inputScenarios.usernameNewPassword, + up: inputScenarios.usernamePassword, + u: inputScenarios.username, + pn: inputScenarios.passwordNewPassword, + n: inputScenarios.newPassword, + p: inputScenarios.password, + } as const; + + type InputScenarioKeys = keyof typeof inputScenariosByKey; + + const key = ((usernameFieldHasValue ? "u" : "") + + (currentPasswordFieldHasValue ? "p" : "") + + (newPasswordFieldHasValue ? "n" : "")) as InputScenarioKeys; + + const inputScenario = key in inputScenariosByKey ? inputScenariosByKey[key] : null; + + if (inputScenario) { + return await this.handleInputMatchScenario({ + ciphersByInputMatchCategory, + ciphersForURL, + loginDomain, + tab, + data, + inputScenario, + changePasswordNotificationIsEnabled, + newLoginNotificationIsEnabled, + }); + } + + return false; + } + /** * Adds a change password message to the notification queue, prompting the user * to update the password for a login that has changed. @@ -668,13 +907,14 @@ export default class NotificationBackground { if ( ciphers.length > 0 && - currentPasswordFieldValue?.length && + (currentPasswordFieldValue?.length || 0) > 0 && // Only use current password for change if no new password present. !newPasswordFieldValue ) { const currentPasswordMatchesAnExistingValue = ciphers.some( (cipher) => - cipher.login?.password?.length && cipher.login.password === currentPasswordFieldValue, + (cipher.login?.password?.length || 0) > 0 && + cipher.login.password === currentPasswordFieldValue, ); // The password entered matched a stored cipher value with @@ -710,6 +950,212 @@ export default class NotificationBackground { return false; } + private async handleInputMatchScenario({ + inputScenario, + ciphersByInputMatchCategory, + ciphersForURL, + loginDomain, + tab, + data, + changePasswordNotificationIsEnabled, + newLoginNotificationIsEnabled, + }: { + ciphersByInputMatchCategory: CiphersByInputMatchCategory; + ciphersForURL: CipherView[]; + loginDomain: string; + tab: chrome.tabs.Tab; + data: ModifyLoginCipherFormData; + inputScenario: InputScenario; + changePasswordNotificationIsEnabled: boolean; + newLoginNotificationIsEnabled: boolean; + }): Promise { + const { + newPasswordOnlyMatches, + noFieldMatches, + passwordOnlyMatches, + usernameNewPasswordMatches, + usernameOnlyMatches, + usernamePasswordMatches, + } = ciphersByInputMatchCategory; + // IMPORTANT! The order of statements matters here; later evaluations + // depend on the assumptions of the early exits in preceding logic + + // If no ciphers match any filled input values + // (Note, this block may uniquely exit early since this match scenario + // involves all ciphers, making it mutually exclusive from any other scenario) + if (noFieldMatches.length === ciphersForURL.length) { + // trigger a new cipher notification in these input scenarios + if ( + ( + [ + inputScenarios.usernamePasswordNewPassword, + inputScenarios.usernameNewPassword, + inputScenarios.usernamePassword, + inputScenarios.username, + ] as InputScenario[] + ).includes(inputScenario) && + newLoginNotificationIsEnabled + ) { + await this.pushAddLoginToQueue( + loginDomain, + { username: data.username, url: data.uri, password: data.newPassword || data.password }, + tab, + ); + + return true; + } + + // Trigger an update cipher notification with all URI ciphers + // in these input scenarios + if ( + ([inputScenarios.password, inputScenarios.newPassword] as InputScenario[]).includes( + inputScenario, + ) && + changePasswordNotificationIsEnabled + ) { + await this.pushChangePasswordToQueue( + ciphersForURL.map((c) => c.id), + loginDomain, + // @TODO handle empty strings / incomplete data structure + data.newPassword || data.password, + tab, + ); + + return true; + } + + return false; + } + + // If ciphers match entered username and new password values + if (usernameNewPasswordMatches.length > 0) { + // Early exit in these scenarios as they represent "no change" + if ( + ( + [ + inputScenarios.usernamePasswordNewPassword, + inputScenarios.usernameNewPassword, + ] as InputScenario[] + ).includes(inputScenario) + ) { + return false; + } + } + + // If ciphers match entered username and password values + if (usernamePasswordMatches.length > 0) { + // and username, password, and new password values were entered + if ( + inputScenario === inputScenarios.usernamePasswordNewPassword && + changePasswordNotificationIsEnabled + ) { + await this.pushChangePasswordToQueue( + usernamePasswordMatches, + loginDomain, + // @TODO handle empty strings / incomplete data structure + data.newPassword || data.password, + tab, + ); + + return true; + } + + if (inputScenario === inputScenarios.usernamePassword) { + return false; + } + } + + // If ciphers match entered username value (only) + if (usernameOnlyMatches.length > 0) { + if ( + ( + [ + inputScenarios.usernamePasswordNewPassword, + inputScenarios.usernameNewPassword, + inputScenarios.usernamePassword, + ] as InputScenario[] + ).includes(inputScenario) && + changePasswordNotificationIsEnabled + ) { + await this.pushChangePasswordToQueue( + usernameOnlyMatches, + loginDomain, + // @TODO handle empty strings / incomplete data structure + data.newPassword || data.password, + tab, + ); + + return true; + } + + // Early exit in this scenario as it represents "no change" + if (inputScenario === inputScenarios.username) { + return false; + } + } + + // If ciphers match entered new password value (only) + if (newPasswordOnlyMatches.length > 0) { + // Early exit in these scenarios + if ( + ( + [ + inputScenarios.usernameNewPassword, // unclear user expectation + inputScenarios.password, // likely nothing to change + inputScenarios.newPassword, // nothing to change + ] as InputScenario[] + ).includes(inputScenario) + ) { + return false; + } + + // and username, password, and new password values were entered + if ( + inputScenario === inputScenarios.usernamePasswordNewPassword && + newLoginNotificationIsEnabled + ) { + await this.pushAddLoginToQueue( + loginDomain, + { username: data.username, url: data.uri, password: data.newPassword || data.password }, + tab, + ); + + return true; + } + } + + // If ciphers match entered password value (only) + if (passwordOnlyMatches.length > 0) { + if ( + ( + [ + inputScenarios.usernamePasswordNewPassword, + inputScenarios.usernamePassword, + inputScenarios.passwordNewPassword, + ] as InputScenario[] + ).includes(inputScenario) && + changePasswordNotificationIsEnabled + ) { + await this.pushChangePasswordToQueue( + passwordOnlyMatches, + loginDomain, + // @TODO handle empty strings / incomplete data structure + data.newPassword || data.password, + tab, + ); + + return true; + } + + // Early exit in this scenario as it represents "no change" + if (inputScenario === inputScenarios.password) { + return false; + } + } + + return false; + } + /** * Sends the page details to the notification bar. Will query all * forms with a password field and pass them to the notification bar. @@ -730,6 +1176,7 @@ export default class NotificationBackground { }); } + // @TODO this needs the whole input record, and not just newPassword private async pushChangePasswordToQueue( cipherIds: CipherView["id"][], loginDomain: string, 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 c596a1ba774..28e03b64621 100644 --- a/apps/browser/src/autofill/background/overlay-notifications.background.spec.ts +++ b/apps/browser/src/autofill/background/overlay-notifications.background.spec.ts @@ -1,4 +1,5 @@ import { mock, MockProxy } from "jest-mock-extended"; +import { of } from "rxjs"; import { CLEAR_NOTIFICATION_LOGIN_DATA_DURATION } from "@bitwarden/common/autofill/constants"; import { ServerConfig } from "@bitwarden/common/platform/abstractions/config/server-config"; @@ -32,6 +33,7 @@ describe("OverlayNotificationsBackground", () => { jest.useFakeTimers(); logService = mock(); notificationBackground = mock(); + notificationBackground.useUndeterminedCipherScenarioTriggeringLogic$ = of(false); getEnableChangedPasswordPromptSpy = jest .spyOn(notificationBackground, "getEnableChangedPasswordPrompt") .mockResolvedValue(true); @@ -323,6 +325,7 @@ describe("OverlayNotificationsBackground", () => { const pageDetails = mock({ fields: [mock()] }); let notificationChangedPasswordSpy: jest.SpyInstance; let notificationAddLoginSpy: jest.SpyInstance; + let cipherNotificationSpy: jest.SpyInstance; beforeEach(async () => { sender = mock({ @@ -334,6 +337,7 @@ describe("OverlayNotificationsBackground", () => { "triggerChangedPasswordNotification", ); notificationAddLoginSpy = jest.spyOn(notificationBackground, "triggerAddLoginNotification"); + cipherNotificationSpy = jest.spyOn(notificationBackground, "triggerCipherNotification"); sendMockExtensionMessage( { command: "collectPageDetailsResponse", details: pageDetails }, @@ -456,6 +460,7 @@ describe("OverlayNotificationsBackground", () => { const pageDetails = mock({ fields: [mock()] }); beforeEach(async () => { + notificationBackground.useUndeterminedCipherScenarioTriggeringLogic$ = of(false); sendMockExtensionMessage( { command: "collectPageDetailsResponse", details: pageDetails }, sender, @@ -519,6 +524,44 @@ describe("OverlayNotificationsBackground", () => { expect(notificationAddLoginSpy).toHaveBeenCalled(); }); + it("with `useUndeterminedCipherScenarioTriggeringLogic` on, waits for the tab's navigation to complete using the web navigation API before initializing the notification", async () => { + notificationBackground.useUndeterminedCipherScenarioTriggeringLogic$ = of(true); + chrome.tabs.get = jest.fn().mockImplementationOnce((tabId, callback) => { + callback( + mock({ + status: "loading", + url: sender.url, + }), + ); + }); + triggerWebRequestOnCompletedEvent( + mock({ + url: sender.url, + tabId: sender.tab.id, + requestId, + }), + ); + await flushPromises(); + + chrome.tabs.get = jest.fn().mockImplementationOnce((tabId, callback) => { + callback( + mock({ + status: "complete", + url: sender.url, + }), + ); + }); + triggerWebNavigationOnCompletedEvent( + mock({ + tabId: sender.tab.id, + url: sender.url, + }), + ); + await flushPromises(); + + expect(cipherNotificationSpy).toHaveBeenCalled(); + }); + it("initializes the notification immediately when the tab's navigation is complete", async () => { sendMockExtensionMessage( { @@ -552,6 +595,40 @@ describe("OverlayNotificationsBackground", () => { expect(notificationAddLoginSpy).toHaveBeenCalled(); }); + it("with `useUndeterminedCipherScenarioTriggeringLogic` on, initializes the notification immediately when the tab's navigation is complete", async () => { + notificationBackground.useUndeterminedCipherScenarioTriggeringLogic$ = of(true); + sendMockExtensionMessage( + { + command: "formFieldSubmitted", + uri: "example.com", + username: "username", + password: "password", + newPassword: "newPassword", + }, + sender, + ); + await flushPromises(); + chrome.tabs.get = jest.fn().mockImplementationOnce((tabId, callback) => { + callback( + mock({ + status: "complete", + url: sender.url, + }), + ); + }); + + triggerWebRequestOnCompletedEvent( + mock({ + url: sender.url, + tabId: sender.tab.id, + requestId, + }), + ); + await flushPromises(); + + expect(cipherNotificationSpy).toHaveBeenCalled(); + }); + it("triggers the notification on the beforeRequest listener when a post-submission redirection is encountered", async () => { sender.tab = mock({ id: 4 }); sendMockExtensionMessage( @@ -601,6 +678,57 @@ describe("OverlayNotificationsBackground", () => { expect(notificationChangedPasswordSpy).toHaveBeenCalled(); }); + + it("with `useUndeterminedCipherScenarioTriggeringLogic` on, triggers the notification on the beforeRequest listener when a post-submission redirection is encountered", async () => { + notificationBackground.useUndeterminedCipherScenarioTriggeringLogic$ = of(true); + sender.tab = mock({ id: 4 }); + sendMockExtensionMessage( + { command: "collectPageDetailsResponse", details: pageDetails }, + sender, + ); + await flushPromises(); + sendMockExtensionMessage( + { + command: "formFieldSubmitted", + uri: "example.com", + username: "", + password: "password", + newPassword: "newPassword", + }, + sender, + ); + await flushPromises(); + chrome.tabs.get = jest.fn().mockImplementation((tabId, callback) => { + callback( + mock({ + status: "complete", + url: sender.url, + }), + ); + }); + + triggerWebRequestOnBeforeRequestEvent( + mock({ + url: sender.url, + tabId: sender.tab.id, + method: "POST", + requestId, + }), + ); + await flushPromises(); + + triggerWebRequestOnBeforeRequestEvent( + mock({ + url: "https://example.com/redirect", + tabId: sender.tab.id, + method: "GET", + requestId, + }), + ); + await flushPromises(); + + expect(cipherNotificationSpy).toHaveBeenCalled(); + }); }); }); diff --git a/apps/browser/src/autofill/background/overlay-notifications.background.ts b/apps/browser/src/autofill/background/overlay-notifications.background.ts index 4f55e68fb41..dea6dc5c44c 100644 --- a/apps/browser/src/autofill/background/overlay-notifications.background.ts +++ b/apps/browser/src/autofill/background/overlay-notifications.background.ts @@ -1,10 +1,9 @@ -import { Subject, switchMap, timer } from "rxjs"; +import { firstValueFrom, Subject, switchMap, timer } from "rxjs"; import { CLEAR_NOTIFICATION_LOGIN_DATA_DURATION } from "@bitwarden/common/autofill/constants"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; import { BrowserApi } from "../../platform/browser/browser-api"; -import { NotificationType, NotificationTypes } from "../notification/abstractions/notification-bar"; import { generateDomainMatchPatterns, isInvalidResponseStatusCode } from "../utils"; import { @@ -14,6 +13,8 @@ import { OverlayNotificationsBackground as OverlayNotificationsBackgroundInterface, OverlayNotificationsExtensionMessage, OverlayNotificationsExtensionMessageHandlers, + NotificationScenarios, + NotificationScenario, WebsiteOriginsWithFields, } from "./abstractions/overlay-notifications.background"; import NotificationBackground from "./notification.background"; @@ -32,7 +33,6 @@ export class OverlayNotificationsBackground implements OverlayNotificationsBackg collectPageDetailsResponse: ({ message, sender }) => this.handleCollectPageDetailsResponse(message, sender), }; - constructor( private logService: LogService, private notificationBackground: NotificationBackground, @@ -281,7 +281,7 @@ export class OverlayNotificationsBackground implements OverlayNotificationsBackg const shouldAttemptAddNotification = this.shouldAttemptNotification( modifyLoginData, - NotificationTypes.Add, + NotificationScenarios.Add, ); if (shouldAttemptAddNotification) { @@ -290,7 +290,7 @@ export class OverlayNotificationsBackground implements OverlayNotificationsBackg const shouldAttemptChangeNotification = this.shouldAttemptNotification( modifyLoginData, - NotificationTypes.Change, + NotificationScenarios.Change, ); if (shouldAttemptChangeNotification) { @@ -445,29 +445,45 @@ export class OverlayNotificationsBackground implements OverlayNotificationsBackg requestId: chrome.webRequest.WebRequestDetails["requestId"], modifyLoginData: ModifyLoginCipherFormData, tab: chrome.tabs.Tab, - config: { skippable: NotificationType[] } = { skippable: [] }, + config: { skippable: NotificationScenario[] } = { skippable: [] }, ) => { - const notificationCandidates = [ - { - type: NotificationTypes.Change, - trigger: this.notificationBackground.triggerChangedPasswordNotification, - }, - { - type: NotificationTypes.Add, - trigger: this.notificationBackground.triggerAddLoginNotification, - }, - { - type: NotificationTypes.AtRiskPassword, - trigger: this.notificationBackground.triggerAtRiskPasswordNotification, - }, - ].filter( + const useUndeterminedCipherScenarioTriggeringLogic = await firstValueFrom( + this.notificationBackground.useUndeterminedCipherScenarioTriggeringLogic$, + ); + + const notificationCandidates = useUndeterminedCipherScenarioTriggeringLogic + ? [ + { + type: NotificationScenarios.Cipher, + trigger: this.notificationBackground.triggerCipherNotification, + }, + { + type: NotificationScenarios.AtRiskPassword, + trigger: this.notificationBackground.triggerAtRiskPasswordNotification, + }, + ] + : [ + { + type: NotificationScenarios.Change, + trigger: this.notificationBackground.triggerChangedPasswordNotification, + }, + { + type: NotificationScenarios.Add, + trigger: this.notificationBackground.triggerAddLoginNotification, + }, + { + type: NotificationScenarios.AtRiskPassword, + trigger: this.notificationBackground.triggerAtRiskPasswordNotification, + }, + ]; + const filteredNotificationCandidates = notificationCandidates.filter( (candidate) => this.shouldAttemptNotification(modifyLoginData, candidate.type) || config.skippable.includes(candidate.type), ); const results: string[] = []; - for (const { trigger, type } of notificationCandidates) { + for (const { trigger, type } of filteredNotificationCandidates) { const success = await trigger.bind(this.notificationBackground)(modifyLoginData, tab); if (success) { results.push(`Success: ${type}`); @@ -489,8 +505,16 @@ export class OverlayNotificationsBackground implements OverlayNotificationsBackg */ private shouldAttemptNotification = ( modifyLoginData: ModifyLoginCipherFormData, - notificationType: NotificationType, + notificationType: NotificationScenario, ): boolean => { + if (notificationType === NotificationScenarios.Cipher) { + // The logic after this block pre-qualifies some cipher add/update scenarios + // prematurely (where matching against vault contents is required) and should be + // skipped for this case (these same checks are performed early in the + // notification triggering logic). + return true; + } + // Intentionally not stripping whitespace characters here as they // represent user entry. const usernameFieldHasValue = !!(modifyLoginData?.username || "").length; @@ -504,15 +528,15 @@ export class OverlayNotificationsBackground implements OverlayNotificationsBackg // `Add` case included because all forms with cached usernames (from previous // visits) will appear to be "password only" and otherwise trigger the new login // save notification. - case NotificationTypes.Add: + case NotificationScenarios.Add: // Can be values for nonstored login or account creation return usernameFieldHasValue && (passwordFieldHasValue || newPasswordFieldHasValue); - case NotificationTypes.Change: + case NotificationScenarios.Change: // Can be login with nonstored login changes or account password update return canBeUserLogin || canBePasswordUpdate; - case NotificationTypes.AtRiskPassword: + case NotificationScenarios.AtRiskPassword: return !newPasswordFieldHasValue; - case NotificationTypes.Unlock: + case NotificationScenarios.Unlock: // Unlock notifications are handled separately and do not require form data return false; default: diff --git a/apps/browser/src/autofill/notification/abstractions/notification-bar.ts b/apps/browser/src/autofill/notification/abstractions/notification-bar.ts index b23c3c17abb..923db8d4b5c 100644 --- a/apps/browser/src/autofill/notification/abstractions/notification-bar.ts +++ b/apps/browser/src/autofill/notification/abstractions/notification-bar.ts @@ -8,9 +8,13 @@ import { } from "../../../autofill/content/components/common-types"; const NotificationTypes = { + /** represents scenarios handling saving new ciphers after form submit */ Add: "add", + /** represents scenarios handling saving updated ciphers after form submit */ Change: "change", + /** represents scenarios where user has interacted with an unlock action prompt or action otherwise requiring unlock as a prerequisite */ Unlock: "unlock", + /** represents scenarios where the user has security tasks after updating ciphers */ AtRiskPassword: "at-risk-password", } as const; diff --git a/libs/common/src/enums/feature-flag.enum.ts b/libs/common/src/enums/feature-flag.enum.ts index dc960baae1d..9941e7671f4 100644 --- a/libs/common/src/enums/feature-flag.enum.ts +++ b/libs/common/src/enums/feature-flag.enum.ts @@ -22,6 +22,7 @@ export enum FeatureFlag { SafariAccountSwitching = "pm-5594-safari-account-switching", /* Autofill */ + UseUndeterminedCipherScenarioTriggeringLogic = "undetermined-cipher-scenario-logic", MacOsNativeCredentialSync = "macos-native-credential-sync", WindowsDesktopAutotype = "windows-desktop-autotype", WindowsDesktopAutotypeGA = "windows-desktop-autotype-ga", @@ -110,6 +111,7 @@ export const DefaultFeatureFlagValue = { [FeatureFlag.MembersComponentRefactor]: FALSE, /* Autofill */ + [FeatureFlag.UseUndeterminedCipherScenarioTriggeringLogic]: FALSE, [FeatureFlag.MacOsNativeCredentialSync]: FALSE, [FeatureFlag.WindowsDesktopAutotype]: FALSE, [FeatureFlag.WindowsDesktopAutotypeGA]: FALSE, From 8ceb28f2b93b6eeaa8e8a0db6a11c05d61d61bd8 Mon Sep 17 00:00:00 2001 From: Alex <55413326+AlexRubik@users.noreply.github.com> Date: Mon, 2 Feb 2026 15:49:11 -0500 Subject: [PATCH 09/22] default weakness sort to descending order (PM-31164) (#18719) --- .../app/dirt/reports/pages/weak-passwords-report.component.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/src/app/dirt/reports/pages/weak-passwords-report.component.html b/apps/web/src/app/dirt/reports/pages/weak-passwords-report.component.html index d96d083ffe0..5f047316a29 100644 --- a/apps/web/src/app/dirt/reports/pages/weak-passwords-report.component.html +++ b/apps/web/src/app/dirt/reports/pages/weak-passwords-report.component.html @@ -54,7 +54,7 @@ {{ "owner" | i18n }} } - + {{ "weakness" | i18n }} From a048827c0ebf90dcdb9e513911c9afb08c9e96af Mon Sep 17 00:00:00 2001 From: Jordan Aasen <166539328+jaasen-livefront@users.noreply.github.com> Date: Mon, 2 Feb 2026 13:21:18 -0800 Subject: [PATCH 10/22] don't allow unarchiving in AC (#18637) --- .../vault/components/vault-items/vault-cipher-row.component.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/src/app/vault/components/vault-items/vault-cipher-row.component.ts b/apps/web/src/app/vault/components/vault-items/vault-cipher-row.component.ts index f795f9533eb..6400c0ca9a8 100644 --- a/apps/web/src/app/vault/components/vault-items/vault-cipher-row.component.ts +++ b/apps/web/src/app/vault/components/vault-items/vault-cipher-row.component.ts @@ -157,7 +157,7 @@ export class VaultCipherRowComponent implements OnInit // If item is archived always show unarchive button, even if user is not premium protected get showUnArchiveButton() { - if (!this.archiveEnabled()) { + if (!this.archiveEnabled() || this.viewingOrgVault) { return false; } From 50b8dde03157461e273604430cf40173402c01b9 Mon Sep 17 00:00:00 2001 From: Jordan Aasen <166539328+jaasen-livefront@users.noreply.github.com> Date: Mon, 2 Feb 2026 13:23:13 -0800 Subject: [PATCH 11/22] [PM-31240[ - [Defect] Toast message archiving an item in Edit/View item modal is in plural form (#18578) * fix archive toast * fix bulk share in vault * Revert "fix bulk share in vault" This reverts commit dfb309c8c5445d9a45f6f089e6f304cc0ad21d14. --- .../components/vault-item-dialog/vault-item-dialog.component.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/src/app/vault/components/vault-item-dialog/vault-item-dialog.component.ts b/apps/web/src/app/vault/components/vault-item-dialog/vault-item-dialog.component.ts index 90452ba573a..ef861b7cab3 100644 --- a/apps/web/src/app/vault/components/vault-item-dialog/vault-item-dialog.component.ts +++ b/apps/web/src/app/vault/components/vault-item-dialog/vault-item-dialog.component.ts @@ -593,7 +593,7 @@ export class VaultItemDialogComponent implements OnInit, OnDestroy { this.toastService.showToast({ variant: "success", - message: this.i18nService.t("itemsWereSentToArchive"), + message: this.i18nService.t("itemWasSentToArchive"), }); } catch { this.toastService.showToast({ From 2fb63e8f41d5d1f8011f2974faa1d274b69b1295 Mon Sep 17 00:00:00 2001 From: lif <1835304752@qq.com> Date: Tue, 3 Feb 2026 05:36:43 +0800 Subject: [PATCH 12/22] [PM-30266] Improve Buttercup CSV import mapping (#18135) - Handle url field case-insensitively (URL, url, Url) - Map note field to cipher notes - Add !type to official props to exclude from custom fields - Only add non-empty custom fields - Add comprehensive unit tests Fixes #17119 Signed-off-by: majiayu000 <1835304752@qq.com> Co-authored-by: John Harrington <84741727+harr1424@users.noreply.github.com> --- .../importers/buttercup-csv-importer.spec.ts | 87 +++++++++++++++++++ .../src/importers/buttercup-csv-importer.ts | 33 +++++-- .../spec-data/buttercup-csv/testdata.csv.ts | 16 ++++ 3 files changed, 129 insertions(+), 7 deletions(-) create mode 100644 libs/importer/src/importers/buttercup-csv-importer.spec.ts create mode 100644 libs/importer/src/importers/spec-data/buttercup-csv/testdata.csv.ts diff --git a/libs/importer/src/importers/buttercup-csv-importer.spec.ts b/libs/importer/src/importers/buttercup-csv-importer.spec.ts new file mode 100644 index 00000000000..51c9d4cb2d8 --- /dev/null +++ b/libs/importer/src/importers/buttercup-csv-importer.spec.ts @@ -0,0 +1,87 @@ +import { ButtercupCsvImporter } from "./buttercup-csv-importer"; +import { + buttercupCsvTestData, + buttercupCsvWithCustomFieldsTestData, + buttercupCsvWithNoteTestData, + buttercupCsvWithSubfoldersTestData, + buttercupCsvWithUrlFieldTestData, +} from "./spec-data/buttercup-csv/testdata.csv"; + +describe("Buttercup CSV Importer", () => { + let importer: ButtercupCsvImporter; + + beforeEach(() => { + importer = new ButtercupCsvImporter(); + }); + + describe("given basic login data", () => { + it("should parse login data when provided valid CSV", async () => { + const result = await importer.parse(buttercupCsvTestData); + expect(result.success).toBe(true); + expect(result.ciphers.length).toBe(2); + + const cipher = result.ciphers[0]; + expect(cipher.name).toEqual("Test Entry"); + expect(cipher.login.username).toEqual("testuser"); + expect(cipher.login.password).toEqual("testpass123"); + expect(cipher.login.uris.length).toEqual(1); + expect(cipher.login.uris[0].uri).toEqual("https://example.com"); + }); + + it("should assign entries to folders based on group_name", async () => { + const result = await importer.parse(buttercupCsvTestData); + expect(result.success).toBe(true); + expect(result.folders.length).toBe(1); + expect(result.folders[0].name).toEqual("General"); + expect(result.folderRelationships.length).toBe(2); + }); + }); + + describe("given URL field variations", () => { + it("should handle lowercase url field", async () => { + const result = await importer.parse(buttercupCsvWithUrlFieldTestData); + expect(result.success).toBe(true); + + const cipher = result.ciphers[0]; + expect(cipher.login.uris.length).toEqual(1); + expect(cipher.login.uris[0].uri).toEqual("https://lowercase-url.com"); + }); + }); + + describe("given note field", () => { + it("should map note field to notes", async () => { + const result = await importer.parse(buttercupCsvWithNoteTestData); + expect(result.success).toBe(true); + + const cipher = result.ciphers[0]; + expect(cipher.notes).toEqual("This is a note"); + }); + }); + + describe("given custom fields", () => { + it("should import custom fields and exclude official props", async () => { + const result = await importer.parse(buttercupCsvWithCustomFieldsTestData); + expect(result.success).toBe(true); + + const cipher = result.ciphers[0]; + expect(cipher.fields.length).toBe(2); + expect(cipher.fields[0].name).toEqual("custom_field"); + expect(cipher.fields[0].value).toEqual("custom value"); + expect(cipher.fields[1].name).toEqual("another_field"); + expect(cipher.fields[1].value).toEqual("another value"); + }); + }); + + describe("given subfolders", () => { + it("should create nested folder structure", async () => { + const result = await importer.parse(buttercupCsvWithSubfoldersTestData); + expect(result.success).toBe(true); + + const folderNames = result.folders.map((f) => f.name); + expect(folderNames).toContain("Work/Projects"); + expect(folderNames).toContain("Work"); + expect(folderNames).toContain("Personal/Finance"); + expect(folderNames).toContain("Personal"); + }); + }); +}); diff --git a/libs/importer/src/importers/buttercup-csv-importer.ts b/libs/importer/src/importers/buttercup-csv-importer.ts index ac3a4cd2512..07fe53bc625 100644 --- a/libs/importer/src/importers/buttercup-csv-importer.ts +++ b/libs/importer/src/importers/buttercup-csv-importer.ts @@ -3,7 +3,18 @@ import { ImportResult } from "../models/import-result"; import { BaseImporter } from "./base-importer"; import { Importer } from "./importer"; -const OfficialProps = ["!group_id", "!group_name", "title", "username", "password", "URL", "id"]; +const OfficialProps = [ + "!group_id", + "!group_name", + "!type", + "title", + "username", + "password", + "URL", + "url", + "note", + "id", +]; export class ButtercupCsvImporter extends BaseImporter implements Importer { parse(data: string): Promise { @@ -21,16 +32,24 @@ export class ButtercupCsvImporter extends BaseImporter implements Importer { cipher.name = this.getValueOrDefault(value.title, "--"); cipher.login.username = this.getValueOrDefault(value.username); cipher.login.password = this.getValueOrDefault(value.password); - cipher.login.uris = this.makeUriArray(value.URL); - let processingCustomFields = false; + // Handle URL field (case-insensitive) + const urlValue = value.URL || value.url || value.Url; + cipher.login.uris = this.makeUriArray(urlValue); + + // Handle note field (case-insensitive) + const noteValue = value.note || value.Note || value.notes || value.Notes; + if (noteValue) { + cipher.notes = noteValue; + } + + // Process custom fields, excluding official props (case-insensitive) for (const prop in value) { // eslint-disable-next-line if (value.hasOwnProperty(prop)) { - if (!processingCustomFields && OfficialProps.indexOf(prop) === -1) { - processingCustomFields = true; - } - if (processingCustomFields) { + const lowerProp = prop.toLowerCase(); + const isOfficialProp = OfficialProps.some((p) => p.toLowerCase() === lowerProp); + if (!isOfficialProp && value[prop]) { this.processKvp(cipher, prop, value[prop]); } } diff --git a/libs/importer/src/importers/spec-data/buttercup-csv/testdata.csv.ts b/libs/importer/src/importers/spec-data/buttercup-csv/testdata.csv.ts new file mode 100644 index 00000000000..5e2f7a8d38c --- /dev/null +++ b/libs/importer/src/importers/spec-data/buttercup-csv/testdata.csv.ts @@ -0,0 +1,16 @@ +export const buttercupCsvTestData = `!group_id,!group_name,title,username,password,URL,id +1,General,Test Entry,testuser,testpass123,https://example.com,entry1 +1,General,Another Entry,anotheruser,anotherpass,https://another.com,entry2`; + +export const buttercupCsvWithUrlFieldTestData = `!group_id,!group_name,title,username,password,url,id +1,General,Entry With Lowercase URL,user1,pass1,https://lowercase-url.com,entry1`; + +export const buttercupCsvWithNoteTestData = `!group_id,!group_name,title,username,password,URL,note,id +1,General,Entry With Note,user1,pass1,https://example.com,This is a note,entry1`; + +export const buttercupCsvWithCustomFieldsTestData = `!group_id,!group_name,title,username,password,URL,custom_field,another_field,id +1,General,Entry With Custom Fields,user1,pass1,https://example.com,custom value,another value,entry1`; + +export const buttercupCsvWithSubfoldersTestData = `!group_id,!group_name,title,username,password,URL,id +1,Work/Projects,Project Entry,projectuser,projectpass,https://project.com,entry1 +2,Personal/Finance,Finance Entry,financeuser,financepass,https://finance.com,entry2`; From 201d36201f72a7a9e7d2524aa6a9fd505ccb7f49 Mon Sep 17 00:00:00 2001 From: John Harrington <84741727+harr1424@users.noreply.github.com> Date: Mon, 2 Feb 2026 14:38:24 -0700 Subject: [PATCH 13/22] [PM-30247] Previously archived items are not archived after import (#18546) --- .../bitwarden/bitwarden-csv-importer.spec.ts | 29 +++++++++++++++++++ .../bitwarden/bitwarden-csv-importer.ts | 9 ++++++ .../src/services/base-vault-export.service.ts | 1 + .../src/types/bitwarden-csv-export-type.ts | 1 + 4 files changed, 40 insertions(+) diff --git a/libs/importer/src/importers/bitwarden/bitwarden-csv-importer.spec.ts b/libs/importer/src/importers/bitwarden/bitwarden-csv-importer.spec.ts index e66779f0372..8f1a281050f 100644 --- a/libs/importer/src/importers/bitwarden/bitwarden-csv-importer.spec.ts +++ b/libs/importer/src/importers/bitwarden/bitwarden-csv-importer.spec.ts @@ -91,4 +91,33 @@ describe("BitwardenCsvImporter", () => { expect(result.collections[0].name).toBe("collection1/collection2"); expect(result.collections[1].name).toBe("collection1"); }); + + it("should parse archived items correctly", async () => { + const archivedDate = "2025-01-15T10:30:00.000Z"; + const data = + `name,type,archivedDate,login_uri,login_username,login_password` + + `\nArchived Login,login,${archivedDate},https://example.com,user,pass`; + + importer.organizationId = null; + const result = await importer.parse(data); + + expect(result.success).toBe(true); + expect(result.ciphers.length).toBe(1); + + const cipher = result.ciphers[0]; + expect(cipher.name).toBe("Archived Login"); + expect(cipher.archivedDate).toBeDefined(); + expect(cipher.archivedDate.toISOString()).toBe(archivedDate); + }); + + it("should handle missing archivedDate gracefully", async () => { + const data = `name,type,login_uri` + `\nTest Login,login,https://example.com`; + + importer.organizationId = null; + const result = await importer.parse(data); + + expect(result.success).toBe(true); + expect(result.ciphers.length).toBe(1); + expect(result.ciphers[0].archivedDate).toBeUndefined(); + }); }); diff --git a/libs/importer/src/importers/bitwarden/bitwarden-csv-importer.ts b/libs/importer/src/importers/bitwarden/bitwarden-csv-importer.ts index b900e9e8d7a..cca1b80e3bd 100644 --- a/libs/importer/src/importers/bitwarden/bitwarden-csv-importer.ts +++ b/libs/importer/src/importers/bitwarden/bitwarden-csv-importer.ts @@ -51,6 +51,15 @@ export class BitwardenCsvImporter extends BaseImporter implements Importer { cipher.reprompt = CipherRepromptType.None; } + if (!this.isNullOrWhitespace(value.archivedDate)) { + try { + cipher.archivedDate = new Date(value.archivedDate); + } catch (e) { + // eslint-disable-next-line + console.error("Unable to parse archivedDate value", e); + } + } + if (!this.isNullOrWhitespace(value.fields)) { const fields = this.splitNewLine(value.fields); for (let i = 0; i < fields.length; i++) { diff --git a/libs/tools/export/vault-export/vault-export-core/src/services/base-vault-export.service.ts b/libs/tools/export/vault-export/vault-export-core/src/services/base-vault-export.service.ts index 620f465789c..7adf7b4138f 100644 --- a/libs/tools/export/vault-export/vault-export-core/src/services/base-vault-export.service.ts +++ b/libs/tools/export/vault-export/vault-export-core/src/services/base-vault-export.service.ts @@ -59,6 +59,7 @@ export class BaseVaultExportService { cipher.notes = c.notes; cipher.fields = null; cipher.reprompt = c.reprompt; + cipher.archivedDate = c.archivedDate ? c.archivedDate.toISOString() : null; // Login props cipher.login_uri = null; cipher.login_username = null; diff --git a/libs/tools/export/vault-export/vault-export-core/src/types/bitwarden-csv-export-type.ts b/libs/tools/export/vault-export/vault-export-core/src/types/bitwarden-csv-export-type.ts index 30c6bb89bc1..efe15a844fc 100644 --- a/libs/tools/export/vault-export/vault-export-core/src/types/bitwarden-csv-export-type.ts +++ b/libs/tools/export/vault-export/vault-export-core/src/types/bitwarden-csv-export-type.ts @@ -12,6 +12,7 @@ export type BitwardenCsvExportType = { login_password: string; login_totp: string; favorite: number | null; + archivedDate: string | null; }; export type BitwardenCsvIndividualExportType = BitwardenCsvExportType & { From 9db65f889586d475ff76d45fa67a909bfb07e9bb Mon Sep 17 00:00:00 2001 From: Andy Pixley <3723676+pixman20@users.noreply.github.com> Date: Mon, 2 Feb 2026 17:13:56 -0500 Subject: [PATCH 14/22] [BRE-1531] Adding ability to build web with custom SDK branch (#18677) --- .github/workflows/build-web.yml | 18 ++++++++++++++++++ apps/web/Dockerfile | 6 ++++++ 2 files changed, 24 insertions(+) diff --git a/.github/workflows/build-web.yml b/.github/workflows/build-web.yml index 7b92de0f22a..71a2c62ec1a 100644 --- a/.github/workflows/build-web.yml +++ b/.github/workflows/build-web.yml @@ -63,6 +63,11 @@ jobs: node_version: ${{ steps.retrieve-node-version.outputs.node_version }} has_secrets: ${{ steps.check-secrets.outputs.has_secrets }} steps: + - name: Log inputs to job summary + uses: bitwarden/ios/.github/actions/log-inputs@main + with: + inputs: "${{ toJson(inputs) }}" + - name: Check out repo uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 with: @@ -181,6 +186,19 @@ jobs: ref: ${{ steps.set-server-ref.outputs.server_ref }} persist-credentials: false + - name: Download SDK Artifacts + if: ${{ inputs.sdk_branch != '' }} + uses: bitwarden/gh-actions/download-artifacts@main + with: + github_token: ${{ secrets.GITHUB_TOKEN }} + workflow: build-wasm-internal.yml + workflow_conclusion: success + branch: ${{ inputs.sdk_branch }} + artifacts: sdk-internal + repo: bitwarden/sdk-internal + path: sdk-internal + if_no_artifact_found: fail + - name: Check Branch to Publish env: PUBLISH_BRANCHES: "main,rc,hotfix-rc-web" diff --git a/apps/web/Dockerfile b/apps/web/Dockerfile index 6d27e12537a..27036e16240 100644 --- a/apps/web/Dockerfile +++ b/apps/web/Dockerfile @@ -15,6 +15,12 @@ RUN if [ "${LICENSE_TYPE}" != "commercial" ] ; then \ rm -rf node_modules/@bitwarden/commercial-sdk-internal ; \ fi +# Override SDK if custom artifacts are present +RUN if [ -d "sdk-internal" ]; then \ + echo "Overriding SDK with custom artifacts from sdk-internal" ; \ + npm link ./sdk-internal ; \ + fi + WORKDIR /source/apps/web ARG NPM_COMMAND=dist:bit:selfhost RUN npm run ${NPM_COMMAND} From 971f264c3921092d41539d9d8f27062c3427cb68 Mon Sep 17 00:00:00 2001 From: Jason Ng Date: Mon, 2 Feb 2026 18:43:46 -0500 Subject: [PATCH 15/22] [PM-31387] Desktop Footer update archive/trash btn values (#18640) * update footer component when action changes for desktop --- .../desktop/src/vault/app/vault/item-footer.component.ts | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/apps/desktop/src/vault/app/vault/item-footer.component.ts b/apps/desktop/src/vault/app/vault/item-footer.component.ts index d601f46e430..8164a1f4a67 100644 --- a/apps/desktop/src/vault/app/vault/item-footer.component.ts +++ b/apps/desktop/src/vault/app/vault/item-footer.component.ts @@ -97,7 +97,7 @@ export class ItemFooterComponent implements OnInit, OnChanges { } async ngOnChanges(changes: SimpleChanges) { - if (changes.cipher) { + if (changes.cipher || changes.action) { await this.checkArchiveState(); } } @@ -255,12 +255,15 @@ export class ItemFooterComponent implements OnInit, OnChanges { this.userCanArchive = userCanArchive; this.showArchiveButton = - cipherCanBeArchived && userCanArchive && this.action === "view" && !this.cipher.isArchived; + cipherCanBeArchived && + userCanArchive && + (this.action === "view" || this.action === "edit") && + !this.cipher.isArchived; // A user should always be able to unarchive an archived item this.showUnarchiveButton = hasArchiveFlagEnabled && - this.action === "view" && + (this.action === "view" || this.action === "edit") && this.cipher.isArchived && !this.cipher.isDeleted; } From 4141b864dac9299ff4e55f92701df21db014bb54 Mon Sep 17 00:00:00 2001 From: Jackson Engstrom Date: Mon, 2 Feb 2026 16:18:30 -0800 Subject: [PATCH 16/22] [PM-24187] Improve labeling of owner filter in vault table --- .../organization-name-badge.component.html | 2 +- apps/web/src/locales/en/messages.json | 9 +++++++++ 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/apps/web/src/app/vault/individual-vault/organization-badge/organization-name-badge.component.html b/apps/web/src/app/vault/individual-vault/organization-badge/organization-name-badge.component.html index 4fd9539f049..5c1dc5c7f3a 100644 --- a/apps/web/src/app/vault/individual-vault/organization-badge/organization-name-badge.component.html +++ b/apps/web/src/app/vault/individual-vault/organization-badge/organization-name-badge.component.html @@ -4,7 +4,7 @@ [disabled]="disabled" [style.color]="textColor" [style.background-color]="color" - appA11yTitle="{{ organizationName }}" + appA11yTitle="{{ 'ownerBadgeA11yDescription' | i18n: name }}" routerLink [queryParams]="{ organizationId: organizationIdLink }" queryParamsHandling="merge" diff --git a/apps/web/src/locales/en/messages.json b/apps/web/src/locales/en/messages.json index d3b975e5834..1304d291235 100644 --- a/apps/web/src/locales/en/messages.json +++ b/apps/web/src/locales/en/messages.json @@ -12742,6 +12742,15 @@ "whenYouRemoveStorage": { "message": "When you remove storage, you will receive a prorated account credit that will automatically go toward your next bill." }, + "ownerBadgeA11yDescription":{ + "message": "Owner, $OWNER$, show all items owned by $OWNER$", + "placeholders":{ + "owner": { + "content": "$1", + "example": "My Org Name" + } + } + }, "youHavePremium": { "message": "You have Premium" }, From c595767688cd2362bff89acfc40dad5219cfdbb8 Mon Sep 17 00:00:00 2001 From: Daniel James Smith <2670567+djsmith85@users.noreply.github.com> Date: Tue, 3 Feb 2026 13:08:44 +0100 Subject: [PATCH 17/22] [PM-29239] Create proxy cookie redirect connector (#18476) * Create a subfolder for platform-owned connectors and ensure it's included in the web builds * Add platform as codeowner of apps/web/src/connectors/platform * Create proxy-cookie-redirect connector * Create section within CODEOWNERS for Web connectors * Swap order of codeowners * Use kebap-style route * Update url to redirect to * Add override to test locally --------- Co-authored-by: Daniel James Smith --- .github/CODEOWNERS | 6 ++-- .../platform/proxy-cookie-redirect.html | 29 +++++++++++++++++++ .../platform/proxy-cookie-redirect.ts | 17 +++++++++++ apps/web/tsconfig.build.json | 2 +- apps/web/tsconfig.json | 7 ++++- apps/web/webpack.base.js | 9 ++++++ bitwarden_license/bit-web/tsconfig.build.json | 2 +- bitwarden_license/bit-web/tsconfig.json | 1 + 8 files changed, 68 insertions(+), 5 deletions(-) create mode 100644 apps/web/src/connectors/platform/proxy-cookie-redirect.html create mode 100644 apps/web/src/connectors/platform/proxy-cookie-redirect.ts diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index baec07ca28d..8bb15d37fdf 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -15,6 +15,10 @@ apps/desktop/desktop_native/core/src/secure_memory @bitwarden/team-key-managemen apps/desktop/desktop_native/Cargo.lock apps/desktop/desktop_native/Cargo.toml +# Web connectors +apps/web/src/connectors @bitwarden/team-auth-dev +apps/web/src/connectors/platform @bitwarden/team-platform-dev + ## Auth team files ## apps/browser/src/auth @bitwarden/team-auth-dev apps/cli/src/auth @bitwarden/team-auth-dev @@ -22,8 +26,6 @@ apps/desktop/src/auth @bitwarden/team-auth-dev apps/web/src/app/auth @bitwarden/team-auth-dev libs/auth @bitwarden/team-auth-dev libs/user-core @bitwarden/team-auth-dev -# web connectors used for auth -apps/web/src/connectors @bitwarden/team-auth-dev bitwarden_license/bit-web/src/app/auth @bitwarden/team-auth-dev libs/angular/src/auth @bitwarden/team-auth-dev libs/common/src/auth @bitwarden/team-auth-dev diff --git a/apps/web/src/connectors/platform/proxy-cookie-redirect.html b/apps/web/src/connectors/platform/proxy-cookie-redirect.html new file mode 100644 index 00000000000..1daa6d2e412 --- /dev/null +++ b/apps/web/src/connectors/platform/proxy-cookie-redirect.html @@ -0,0 +1,29 @@ + + + + + + + + Bitwarden Web vault + + + + + + + + + +
+ Bitwarden +
+ +
+
+ + diff --git a/apps/web/src/connectors/platform/proxy-cookie-redirect.ts b/apps/web/src/connectors/platform/proxy-cookie-redirect.ts new file mode 100644 index 00000000000..79c5092caab --- /dev/null +++ b/apps/web/src/connectors/platform/proxy-cookie-redirect.ts @@ -0,0 +1,17 @@ +/** + * ONLY FOR SELF-HOSTED SETUPS + * Redirects the user to the SSO cookie vendor endpoint when the window finishes loading. + * + * This script listens for the window's load event and automatically redirects the browser + * to the `api/sso-cookie-vendor` path on the current origin. This is used as part + * of an authentication flow where cookies need to be set or validated through a vendor endpoint. + */ +window.addEventListener("DOMContentLoaded", () => { + const origin = window.location.origin; + let apiURL = `${window.location.origin}/api/sso-cookie-vendor`; + // Override for local testing + if (origin.startsWith("https://localhost")) { + apiURL = "http://localhost:4000/sso-cookie-vendor"; + } + window.location.href = apiURL; +}); diff --git a/apps/web/tsconfig.build.json b/apps/web/tsconfig.build.json index 273cddb21d2..c1e7a88f4a8 100644 --- a/apps/web/tsconfig.build.json +++ b/apps/web/tsconfig.build.json @@ -1,5 +1,5 @@ { "extends": "./tsconfig.json", "files": ["src/polyfills.ts", "src/main.ts", "src/theme.ts"], - "include": ["src/connectors/*.ts"] + "include": ["src/connectors/*.ts", "src/connectors/platform/*.ts"] } diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json index fd655b0a56b..6bfa9c8703b 100644 --- a/apps/web/tsconfig.json +++ b/apps/web/tsconfig.json @@ -4,5 +4,10 @@ "strictTemplates": true }, "files": ["src/polyfills.ts", "src/main.ts", "src/theme.ts"], - "include": ["src/connectors/*.ts", "src/**/*.stories.ts", "src/**/*.spec.ts"] + "include": [ + "src/connectors/*.ts", + "src/connectors/platform/*.ts", + "src/**/*.stories.ts", + "src/**/*.spec.ts" + ] } diff --git a/apps/web/webpack.base.js b/apps/web/webpack.base.js index 016d2b0fe61..2ef9abe09a6 100644 --- a/apps/web/webpack.base.js +++ b/apps/web/webpack.base.js @@ -166,6 +166,11 @@ module.exports.buildConfig = function buildConfig(params) { filename: "duo-redirect-connector.html", chunks: ["connectors/duo-redirect", "styles"], }), + new HtmlWebpackPlugin({ + template: path.resolve(__dirname, "src/connectors/platform/proxy-cookie-redirect.html"), + filename: "proxy-cookie-redirect-connector.html", + chunks: ["connectors/platform/proxy-cookie-redirect", "styles"], + }), new HtmlWebpackPlugin({ template: path.resolve(__dirname, "src/404.html"), filename: "404.html", @@ -403,6 +408,10 @@ module.exports.buildConfig = function buildConfig(params) { "connectors/sso": path.resolve(__dirname, "src/connectors/sso.ts"), "connectors/duo-redirect": path.resolve(__dirname, "src/connectors/duo-redirect.ts"), "connectors/redirect": path.resolve(__dirname, "src/connectors/redirect.ts"), + "connectors/platform/proxy-cookie-redirect": path.resolve( + __dirname, + "src/connectors/platform/proxy-cookie-redirect.ts", + ), styles: [ path.resolve(__dirname, "src/scss/styles.scss"), path.resolve(__dirname, "src/scss/tailwind.css"), diff --git a/bitwarden_license/bit-web/tsconfig.build.json b/bitwarden_license/bit-web/tsconfig.build.json index 58acbf09392..cc55f69bc4f 100644 --- a/bitwarden_license/bit-web/tsconfig.build.json +++ b/bitwarden_license/bit-web/tsconfig.build.json @@ -9,5 +9,5 @@ "../../bitwarden_license/bit-web/src/main.ts" ], - "include": ["../../apps/web/src/connectors/*.ts"] + "include": ["../../apps/web/src/connectors/*.ts", "../../apps/web/src/connectors/platform/*.ts"] } diff --git a/bitwarden_license/bit-web/tsconfig.json b/bitwarden_license/bit-web/tsconfig.json index 8c19f771a26..8dcd128ae6b 100644 --- a/bitwarden_license/bit-web/tsconfig.json +++ b/bitwarden_license/bit-web/tsconfig.json @@ -11,6 +11,7 @@ ], "include": [ "../../apps/web/src/connectors/*.ts", + "../../apps/web/src/connectors/platform/*.ts", "../../apps/web/src/**/*.stories.ts", "../../apps/web/src/**/*.spec.ts", "src/**/*.stories.ts", From 3333e5696dfbcffcd3115d57fe8e5f40ec0c912d Mon Sep 17 00:00:00 2001 From: Jared Date: Tue, 3 Feb 2026 10:19:30 -0500 Subject: [PATCH 18/22] Update collection dialog to conditionally display "view" or "edit" title based on dialog state; add "viewCollection" translation to messages.json (#18724) --- .../collection-dialog/collection-dialog.component.html | 2 +- apps/web/src/locales/en/messages.json | 3 +++ 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/apps/web/src/app/admin-console/organizations/shared/components/collection-dialog/collection-dialog.component.html b/apps/web/src/app/admin-console/organizations/shared/components/collection-dialog/collection-dialog.component.html index 431d7711331..a2c510b78df 100644 --- a/apps/web/src/app/admin-console/organizations/shared/components/collection-dialog/collection-dialog.component.html +++ b/apps/web/src/app/admin-console/organizations/shared/components/collection-dialog/collection-dialog.component.html @@ -2,7 +2,7 @@ - {{ "editCollection" | i18n }} + {{ (dialogReadonly ? "viewCollection" : "editCollection") | i18n }} {{ collection.name }} diff --git a/apps/web/src/locales/en/messages.json b/apps/web/src/locales/en/messages.json index 1304d291235..fe93d419035 100644 --- a/apps/web/src/locales/en/messages.json +++ b/apps/web/src/locales/en/messages.json @@ -3805,6 +3805,9 @@ "editCollection": { "message": "Edit collection" }, + "viewCollection": { + "message": "View collection" + }, "collectionInfo": { "message": "Collection info" }, From 8fb84a46703ede9949b32d6782c57f9500af5154 Mon Sep 17 00:00:00 2001 From: tbmc Date: Tue, 3 Feb 2026 17:01:10 +0100 Subject: [PATCH 19/22] Fix layout of download Bitwarden link in settings popup (#18309) Co-authored-by: John Harrington <84741727+harr1424@users.noreply.github.com> --- .../browser/src/tools/popup/settings/settings-v2.component.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/browser/src/tools/popup/settings/settings-v2.component.html b/apps/browser/src/tools/popup/settings/settings-v2.component.html index c6f1c9dbc3b..19f2445b61d 100644 --- a/apps/browser/src/tools/popup/settings/settings-v2.component.html +++ b/apps/browser/src/tools/popup/settings/settings-v2.component.html @@ -110,7 +110,7 @@ -
+

{{ "downloadBitwardenOnAllDevices" | i18n }}

Date: Tue, 3 Feb 2026 11:05:02 -0500 Subject: [PATCH 20/22] when only password and new password fields have values and do not match any vault ciphers, trigger a new cipher notification (#18729) --- .../background/notification.background.spec.ts | 12 ++++++++++-- .../autofill/background/notification.background.ts | 1 + 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/apps/browser/src/autofill/background/notification.background.spec.ts b/apps/browser/src/autofill/background/notification.background.spec.ts index 0be6e5c0ac1..7d33d79a697 100644 --- a/apps/browser/src/autofill/background/notification.background.spec.ts +++ b/apps/browser/src/autofill/background/notification.background.spec.ts @@ -1727,7 +1727,7 @@ describe("NotificationBackground", () => { expect(pushAddLoginToQueueSpy).not.toHaveBeenCalled(); }); - it("and no cipher update candidates match `password` or `newPassword`, do not trigger a notification", async () => { + it("and no cipher update candidates match `password` or `newPassword`, trigger a new cipher notification", async () => { const storedCiphersForURL = [ mock({ id: "cipher-id-1", @@ -1745,7 +1745,15 @@ describe("NotificationBackground", () => { await notificationBackground.triggerCipherNotification(formEntryData, tab); expect(pushChangePasswordToQueueSpy).not.toHaveBeenCalled(); - expect(pushAddLoginToQueueSpy).not.toHaveBeenCalled(); + expect(pushAddLoginToQueueSpy).toHaveBeenCalledWith( + mockFormattedURI, + { + password: formEntryData.newPassword, + url: formEntryData.uri, + username: formEntryData.username, + }, + sender.tab, + ); }); }); diff --git a/apps/browser/src/autofill/background/notification.background.ts b/apps/browser/src/autofill/background/notification.background.ts index 33d65391c25..e97672c1f0d 100644 --- a/apps/browser/src/autofill/background/notification.background.ts +++ b/apps/browser/src/autofill/background/notification.background.ts @@ -992,6 +992,7 @@ export default class NotificationBackground { inputScenarios.usernameNewPassword, inputScenarios.usernamePassword, inputScenarios.username, + inputScenarios.passwordNewPassword, ] as InputScenario[] ).includes(inputScenario) && newLoginNotificationIsEnabled From 86907d68c285de3463879b01901b16c0a32cf2b0 Mon Sep 17 00:00:00 2001 From: Stephon Brown Date: Tue, 3 Feb 2026 11:11:00 -0500 Subject: [PATCH 21/22] [PM-29600] Rename Tax Client and Add Endpoints for Upgrade and Proration (#18462) * BREAKING CHANGE: rename tax-client and add proration endpoint update * fix(billing)!: rename tax-client in components * feat(billing): Add upgrade endpoint * fix(billing): update preview client error * fix(billing): add billing address to clients * fix(billing): add additional prorated amount of months * fix(billing): update client call parameter * feat(billing): Enhance ProrationPreviewResponse with new plan details --- .../billing/clients/account-billing.client.ts | 25 ++++++++++ apps/web/src/app/billing/clients/index.ts | 2 +- ...ax.client.ts => preview-invoice.client.ts} | 47 +++++++++++++++++-- .../unified-upgrade-dialog.component.ts | 4 +- .../services/upgrade-payment.service.spec.ts | 45 ++++++++++-------- .../services/upgrade-payment.service.ts | 28 ++++++----- .../change-plan-dialog.component.ts | 17 +++---- .../organization-plans.component.ts | 22 +++++---- .../trial-payment-dialog.component.ts | 11 +++-- .../trial-billing-step.component.ts | 4 +- .../trial-billing-step.service.ts | 6 +-- 11 files changed, 146 insertions(+), 65 deletions(-) rename apps/web/src/app/billing/clients/{tax.client.ts => preview-invoice.client.ts} (65%) diff --git a/apps/web/src/app/billing/clients/account-billing.client.ts b/apps/web/src/app/billing/clients/account-billing.client.ts index e520e70bf70..1334ff643dd 100644 --- a/apps/web/src/app/billing/clients/account-billing.client.ts +++ b/apps/web/src/app/billing/clients/account-billing.client.ts @@ -1,7 +1,9 @@ import { Injectable } from "@angular/core"; import { ApiService } from "@bitwarden/common/abstractions/api.service"; +import { ProductTierType } from "@bitwarden/common/billing/enums"; import { BitwardenSubscriptionResponse } from "@bitwarden/common/billing/models/response/bitwarden-subscription.response"; +import { SubscriptionCadence } from "@bitwarden/common/billing/types/subscription-pricing-tier"; import { BitwardenSubscription } from "@bitwarden/subscription"; import { @@ -53,4 +55,27 @@ export class AccountBillingClient { const path = `${this.endpoint}/subscription/storage`; await this.apiService.send("PUT", path, { additionalStorageGb }, true, false); }; + + upgradePremiumToOrganization = async ( + organizationName: string, + organizationKey: string, + planTier: ProductTierType, + cadence: SubscriptionCadence, + billingAddress: Pick, + ): Promise => { + const path = `${this.endpoint}/upgrade`; + await this.apiService.send( + "POST", + path, + { + organizationName, + key: organizationKey, + targetProductTierType: planTier, + cadence, + billingAddress, + }, + true, + false, + ); + }; } diff --git a/apps/web/src/app/billing/clients/index.ts b/apps/web/src/app/billing/clients/index.ts index 0251693a3b2..02e0f688d9d 100644 --- a/apps/web/src/app/billing/clients/index.ts +++ b/apps/web/src/app/billing/clients/index.ts @@ -1,4 +1,4 @@ export * from "./organization-billing.client"; export * from "./subscriber-billing.client"; -export * from "./tax.client"; +export * from "./preview-invoice.client"; export * from "./account-billing.client"; diff --git a/apps/web/src/app/billing/clients/tax.client.ts b/apps/web/src/app/billing/clients/preview-invoice.client.ts similarity index 65% rename from apps/web/src/app/billing/clients/tax.client.ts rename to apps/web/src/app/billing/clients/preview-invoice.client.ts index 09debd5a210..16fb1ca0762 100644 --- a/apps/web/src/app/billing/clients/tax.client.ts +++ b/apps/web/src/app/billing/clients/preview-invoice.client.ts @@ -1,6 +1,7 @@ import { Injectable } from "@angular/core"; import { ApiService } from "@bitwarden/common/abstractions/api.service"; +import { ProductTierType } from "@bitwarden/common/billing/enums"; import { BaseResponse } from "@bitwarden/common/models/response/base.response"; import { BillingAddress } from "@bitwarden/web-vault/app/billing/payment/types"; @@ -16,6 +17,24 @@ class TaxAmountResponse extends BaseResponse implements TaxAmounts { } } +export class ProrationPreviewResponse extends BaseResponse { + tax: number; + total: number; + credit: number; + newPlanProratedMonths: number; + newPlanProratedAmount: number; + + constructor(response: any) { + super(response); + + this.tax = this.getResponseProperty("Tax"); + this.total = this.getResponseProperty("Total"); + this.credit = this.getResponseProperty("Credit"); + this.newPlanProratedMonths = this.getResponseProperty("NewPlanProratedMonths"); + this.newPlanProratedAmount = this.getResponseProperty("NewPlanProratedAmount"); + } +} + export type OrganizationSubscriptionPlan = { tier: "families" | "teams" | "enterprise"; cadence: "annually" | "monthly"; @@ -51,7 +70,7 @@ export interface TaxAmounts { } @Injectable() -export class TaxClient { +export class PreviewInvoiceClient { constructor(private apiService: ApiService) {} previewTaxForOrganizationSubscriptionPurchase = async ( @@ -60,7 +79,7 @@ export class TaxClient { ): Promise => { const json = await this.apiService.send( "POST", - "/billing/tax/organizations/subscriptions/purchase", + "/billing/preview-invoice/organizations/subscriptions/purchase", { purchase, billingAddress, @@ -82,7 +101,7 @@ export class TaxClient { ): Promise => { const json = await this.apiService.send( "POST", - `/billing/tax/organizations/${organizationId}/subscription/plan-change`, + `/billing/preview-invoice/organizations/${organizationId}/subscription/plan-change`, { plan, billingAddress, @@ -100,7 +119,7 @@ export class TaxClient { ): Promise => { const json = await this.apiService.send( "POST", - `/billing/tax/organizations/${organizationId}/subscription/update`, + `/billing/preview-invoice/organizations/${organizationId}/subscription/update`, { update, }, @@ -117,7 +136,7 @@ export class TaxClient { ): Promise => { const json = await this.apiService.send( "POST", - `/billing/tax/premium/subscriptions/purchase`, + `/billing/preview-invoice/premium/subscriptions/purchase`, { additionalStorage, billingAddress, @@ -128,4 +147,22 @@ export class TaxClient { return new TaxAmountResponse(json); }; + + previewProrationForPremiumUpgrade = async ( + planTier: ProductTierType, + billingAddress: Pick, + ): Promise => { + const prorationResponse = await this.apiService.send( + "POST", + `/billing/preview-invoice/premium/subscriptions/upgrade`, + { + targetProductTierType: planTier, + billingAddress, + }, + true, + true, + ); + + return new ProrationPreviewResponse(prorationResponse); + }; } diff --git a/apps/web/src/app/billing/individual/upgrade/unified-upgrade-dialog/unified-upgrade-dialog.component.ts b/apps/web/src/app/billing/individual/upgrade/unified-upgrade-dialog/unified-upgrade-dialog.component.ts index 222bf77715c..63017760195 100644 --- a/apps/web/src/app/billing/individual/upgrade/unified-upgrade-dialog/unified-upgrade-dialog.component.ts +++ b/apps/web/src/app/billing/individual/upgrade/unified-upgrade-dialog/unified-upgrade-dialog.component.ts @@ -15,7 +15,7 @@ import { DialogService, } from "@bitwarden/components"; -import { AccountBillingClient, TaxClient } from "../../../clients"; +import { AccountBillingClient, PreviewInvoiceClient } from "../../../clients"; import { BillingServicesModule } from "../../../services"; import { UpgradeAccountComponent } from "../upgrade-account/upgrade-account.component"; import { UpgradePaymentService } from "../upgrade-payment/services/upgrade-payment.service"; @@ -74,7 +74,7 @@ export type UnifiedUpgradeDialogParams = { UpgradePaymentComponent, BillingServicesModule, ], - providers: [UpgradePaymentService, AccountBillingClient, TaxClient], + providers: [UpgradePaymentService, AccountBillingClient, PreviewInvoiceClient], templateUrl: "./unified-upgrade-dialog.component.html", }) export class UnifiedUpgradeDialogComponent implements OnInit { diff --git a/apps/web/src/app/billing/individual/upgrade/upgrade-payment/services/upgrade-payment.service.spec.ts b/apps/web/src/app/billing/individual/upgrade/upgrade-payment/services/upgrade-payment.service.spec.ts index 83440646b48..bbb89bd622f 100644 --- a/apps/web/src/app/billing/individual/upgrade/upgrade-payment/services/upgrade-payment.service.spec.ts +++ b/apps/web/src/app/billing/individual/upgrade/upgrade-payment/services/upgrade-payment.service.spec.ts @@ -21,7 +21,7 @@ import { AccountBillingClient, SubscriberBillingClient, TaxAmounts, - TaxClient, + PreviewInvoiceClient, } from "../../../../clients"; import { BillingAddress, @@ -35,7 +35,7 @@ import { UpgradePaymentService, PlanDetails } from "./upgrade-payment.service"; describe("UpgradePaymentService", () => { const mockOrganizationBillingService = mock(); const mockAccountBillingClient = mock(); - const mockTaxClient = mock(); + const mockPreviewInvoiceClient = mock(); const mockLogService = mock(); const mockSyncService = mock(); const mockOrganizationService = mock(); @@ -112,7 +112,7 @@ describe("UpgradePaymentService", () => { beforeEach(() => { mockReset(mockOrganizationBillingService); mockReset(mockAccountBillingClient); - mockReset(mockTaxClient); + mockReset(mockPreviewInvoiceClient); mockReset(mockLogService); mockReset(mockOrganizationService); mockReset(mockAccountService); @@ -133,7 +133,7 @@ describe("UpgradePaymentService", () => { useValue: mockOrganizationBillingService, }, { provide: AccountBillingClient, useValue: mockAccountBillingClient }, - { provide: TaxClient, useValue: mockTaxClient }, + { provide: PreviewInvoiceClient, useValue: mockPreviewInvoiceClient }, { provide: LogService, useValue: mockLogService }, { provide: SyncService, useValue: mockSyncService }, { provide: OrganizationService, useValue: mockOrganizationService }, @@ -183,7 +183,7 @@ describe("UpgradePaymentService", () => { const service = new UpgradePaymentService( mockOrganizationBillingService, mockAccountBillingClient, - mockTaxClient, + mockPreviewInvoiceClient, mockLogService, mockSyncService, mockOrganizationService, @@ -236,7 +236,7 @@ describe("UpgradePaymentService", () => { const service = new UpgradePaymentService( mockOrganizationBillingService, mockAccountBillingClient, - mockTaxClient, + mockPreviewInvoiceClient, mockLogService, mockSyncService, mockOrganizationService, @@ -271,7 +271,7 @@ describe("UpgradePaymentService", () => { const service = new UpgradePaymentService( mockOrganizationBillingService, mockAccountBillingClient, - mockTaxClient, + mockPreviewInvoiceClient, mockLogService, mockSyncService, mockOrganizationService, @@ -307,7 +307,7 @@ describe("UpgradePaymentService", () => { const service = new UpgradePaymentService( mockOrganizationBillingService, mockAccountBillingClient, - mockTaxClient, + mockPreviewInvoiceClient, mockLogService, mockSyncService, mockOrganizationService, @@ -333,7 +333,7 @@ describe("UpgradePaymentService", () => { const service = new UpgradePaymentService( mockOrganizationBillingService, mockAccountBillingClient, - mockTaxClient, + mockPreviewInvoiceClient, mockLogService, mockSyncService, mockOrganizationService, @@ -389,7 +389,7 @@ describe("UpgradePaymentService", () => { const service = new UpgradePaymentService( mockOrganizationBillingService, mockAccountBillingClient, - mockTaxClient, + mockPreviewInvoiceClient, mockLogService, mockSyncService, mockOrganizationService, @@ -412,17 +412,18 @@ describe("UpgradePaymentService", () => { const mockResponse = mock(); mockResponse.tax = 2.5; - mockTaxClient.previewTaxForPremiumSubscriptionPurchase.mockResolvedValue(mockResponse); + mockPreviewInvoiceClient.previewTaxForPremiumSubscriptionPurchase.mockResolvedValue( + mockResponse, + ); // Act const result = await sut.calculateEstimatedTax(mockPremiumPlanDetails, mockBillingAddress); // Assert expect(result).toEqual(2.5); - expect(mockTaxClient.previewTaxForPremiumSubscriptionPurchase).toHaveBeenCalledWith( - 0, - mockBillingAddress, - ); + expect( + mockPreviewInvoiceClient.previewTaxForPremiumSubscriptionPurchase, + ).toHaveBeenCalledWith(0, mockBillingAddress); }); it("should calculate tax for families plan", async () => { @@ -430,14 +431,18 @@ describe("UpgradePaymentService", () => { const mockResponse = mock(); mockResponse.tax = 5.0; - mockTaxClient.previewTaxForOrganizationSubscriptionPurchase.mockResolvedValue(mockResponse); + mockPreviewInvoiceClient.previewTaxForOrganizationSubscriptionPurchase.mockResolvedValue( + mockResponse, + ); // Act const result = await sut.calculateEstimatedTax(mockFamiliesPlanDetails, mockBillingAddress); // Assert expect(result).toEqual(5.0); - expect(mockTaxClient.previewTaxForOrganizationSubscriptionPurchase).toHaveBeenCalledWith( + expect( + mockPreviewInvoiceClient.previewTaxForOrganizationSubscriptionPurchase, + ).toHaveBeenCalledWith( { cadence: "annually", tier: "families", @@ -454,7 +459,7 @@ describe("UpgradePaymentService", () => { it("should throw and log error if personal tax calculation fails", async () => { // Arrange const error = new Error("Tax service error"); - mockTaxClient.previewTaxForPremiumSubscriptionPurchase.mockRejectedValue(error); + mockPreviewInvoiceClient.previewTaxForPremiumSubscriptionPurchase.mockRejectedValue(error); // Act & Assert await expect( @@ -466,7 +471,9 @@ describe("UpgradePaymentService", () => { it("should throw and log error if organization tax calculation fails", async () => { // Arrange const error = new Error("Tax service error"); - mockTaxClient.previewTaxForOrganizationSubscriptionPurchase.mockRejectedValue(error); + mockPreviewInvoiceClient.previewTaxForOrganizationSubscriptionPurchase.mockRejectedValue( + error, + ); // Act & Assert await expect( sut.calculateEstimatedTax(mockFamiliesPlanDetails, mockBillingAddress), diff --git a/apps/web/src/app/billing/individual/upgrade/upgrade-payment/services/upgrade-payment.service.ts b/apps/web/src/app/billing/individual/upgrade/upgrade-payment/services/upgrade-payment.service.ts index b8d5637e471..06c28123848 100644 --- a/apps/web/src/app/billing/individual/upgrade/upgrade-payment/services/upgrade-payment.service.ts +++ b/apps/web/src/app/billing/individual/upgrade/upgrade-payment/services/upgrade-payment.service.ts @@ -26,7 +26,7 @@ import { OrganizationSubscriptionPurchase, SubscriberBillingClient, TaxAmounts, - TaxClient, + PreviewInvoiceClient, } from "../../../../clients"; import { BillingAddress, @@ -58,7 +58,7 @@ export class UpgradePaymentService { constructor( private organizationBillingService: OrganizationBillingServiceAbstraction, private accountBillingClient: AccountBillingClient, - private taxClient: TaxClient, + private previewInvoiceClient: PreviewInvoiceClient, private logService: LogService, private syncService: SyncService, private organizationService: OrganizationService, @@ -101,7 +101,7 @@ export class UpgradePaymentService { const isFamiliesPlan = planDetails.tier === PersonalSubscriptionPricingTierIds.Families; const isPremiumPlan = planDetails.tier === PersonalSubscriptionPricingTierIds.Premium; - let taxClientCall: Promise | null = null; + let previewInvoiceClientCall: Promise | null = null; if (isFamiliesPlan) { // Currently, only Families plan is supported for organization plans @@ -111,22 +111,26 @@ export class UpgradePaymentService { passwordManager: { seats: 1, additionalStorage: 0, sponsored: false }, }; - taxClientCall = this.taxClient.previewTaxForOrganizationSubscriptionPurchase( - request, + previewInvoiceClientCall = + this.previewInvoiceClient.previewTaxForOrganizationSubscriptionPurchase( + request, + billingAddress, + ); + } + + if (isPremiumPlan) { + previewInvoiceClientCall = this.previewInvoiceClient.previewTaxForPremiumSubscriptionPurchase( + 0, billingAddress, ); } - if (isPremiumPlan) { - taxClientCall = this.taxClient.previewTaxForPremiumSubscriptionPurchase(0, billingAddress); - } - - if (taxClientCall === null) { - throw new Error("Tax client call is not defined"); + if (previewInvoiceClientCall === null) { + throw new Error("Preview client call is not defined"); } try { - const preview = await taxClientCall; + const preview = await previewInvoiceClientCall; return preview.tax; } catch (error) { this.logService.error("Tax calculation failed:", error); diff --git a/apps/web/src/app/billing/organizations/change-plan-dialog.component.ts b/apps/web/src/app/billing/organizations/change-plan-dialog.component.ts index d14f627127a..0a22ef5ddac 100644 --- a/apps/web/src/app/billing/organizations/change-plan-dialog.component.ts +++ b/apps/web/src/app/billing/organizations/change-plan-dialog.component.ts @@ -50,7 +50,7 @@ import { KeyService } from "@bitwarden/key-management"; import { OrganizationSubscriptionPlan, SubscriberBillingClient, - TaxClient, + PreviewInvoiceClient, } from "@bitwarden/web-vault/app/billing/clients"; import { OrganizationWarningsService } from "@bitwarden/web-vault/app/billing/organizations/warnings/services"; import { @@ -117,7 +117,7 @@ interface OnSuccessArgs { EnterBillingAddressComponent, CardComponent, ], - providers: [SubscriberBillingClient, TaxClient], + providers: [SubscriberBillingClient, PreviewInvoiceClient], }) export class ChangePlanDialogComponent implements OnInit, OnDestroy { // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals @@ -248,7 +248,7 @@ export class ChangePlanDialogComponent implements OnInit, OnDestroy { private accountService: AccountService, private billingNotificationService: BillingNotificationService, private subscriberBillingClient: SubscriberBillingClient, - private taxClient: TaxClient, + private previewInvoiceClient: PreviewInvoiceClient, private organizationWarningsService: OrganizationWarningsService, private configService: ConfigService, ) {} @@ -1068,11 +1068,12 @@ export class ChangePlanDialogComponent implements OnInit, OnDestroy { ? getBillingAddressFromForm(this.billingFormGroup.controls.billingAddress) : this.billingAddress; - const taxAmounts = await this.taxClient.previewTaxForOrganizationSubscriptionPlanChange( - this.organizationId, - getPlanFromLegacyEnum(this.selectedPlan.type), - billingAddress, - ); + const taxAmounts = + await this.previewInvoiceClient.previewTaxForOrganizationSubscriptionPlanChange( + this.organizationId, + getPlanFromLegacyEnum(this.selectedPlan.type), + billingAddress, + ); this.estimatedTax = taxAmounts.tax; } diff --git a/apps/web/src/app/billing/organizations/organization-plans.component.ts b/apps/web/src/app/billing/organizations/organization-plans.component.ts index 67f6f9b0a6b..3364ce2cbea 100644 --- a/apps/web/src/app/billing/organizations/organization-plans.component.ts +++ b/apps/web/src/app/billing/organizations/organization-plans.component.ts @@ -52,8 +52,8 @@ import { KeyService } from "@bitwarden/key-management"; import { OrganizationSubscriptionPlan, OrganizationSubscriptionPurchase, + PreviewInvoiceClient, SubscriberBillingClient, - TaxClient, } from "@bitwarden/web-vault/app/billing/clients"; import { EnterBillingAddressComponent, @@ -87,7 +87,7 @@ const Allowed2020PlansForLegacyProviders = [ EnterPaymentMethodComponent, EnterBillingAddressComponent, ], - providers: [SubscriberBillingClient, TaxClient], + providers: [SubscriberBillingClient, PreviewInvoiceClient], }) export class OrganizationPlansComponent implements OnInit, OnDestroy { // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals @@ -219,7 +219,7 @@ export class OrganizationPlansComponent implements OnInit, OnDestroy { private toastService: ToastService, private accountService: AccountService, private subscriberBillingClient: SubscriberBillingClient, - private taxClient: TaxClient, + private previewInvoiceClient: PreviewInvoiceClient, private configService: ConfigService, ) { this.selfHosted = this.platformUtilsService.isSelfHost(); @@ -793,11 +793,11 @@ export class OrganizationPlansComponent implements OnInit, OnDestroy { // by comparing tax on base+storage vs tax on base only //TODO: Move this logic to PreviewOrganizationTaxCommand - https://bitwarden.atlassian.net/browse/PM-27585 const [baseTaxAmounts, fullTaxAmounts] = await Promise.all([ - this.taxClient.previewTaxForOrganizationSubscriptionPurchase( + this.previewInvoiceClient.previewTaxForOrganizationSubscriptionPurchase( this.buildTaxPreviewRequest(0, false), billingAddress, ), - this.taxClient.previewTaxForOrganizationSubscriptionPurchase( + this.previewInvoiceClient.previewTaxForOrganizationSubscriptionPurchase( this.buildTaxPreviewRequest(this.formGroup.value.additionalStorage, false), billingAddress, ), @@ -806,10 +806,14 @@ export class OrganizationPlansComponent implements OnInit, OnDestroy { // Tax on storage = Tax on (base + storage) - Tax on (base only) this.estimatedTax = fullTaxAmounts.tax - baseTaxAmounts.tax; } else { - const taxAmounts = await this.taxClient.previewTaxForOrganizationSubscriptionPurchase( - this.buildTaxPreviewRequest(this.formGroup.value.additionalStorage, sponsoredForTaxPreview), - billingAddress, - ); + const taxAmounts = + await this.previewInvoiceClient.previewTaxForOrganizationSubscriptionPurchase( + this.buildTaxPreviewRequest( + this.formGroup.value.additionalStorage, + sponsoredForTaxPreview, + ), + billingAddress, + ); this.estimatedTax = taxAmounts.tax; } diff --git a/apps/web/src/app/billing/shared/trial-payment-dialog/trial-payment-dialog.component.ts b/apps/web/src/app/billing/shared/trial-payment-dialog/trial-payment-dialog.component.ts index 64af7be948e..19ccbf28ee9 100644 --- a/apps/web/src/app/billing/shared/trial-payment-dialog/trial-payment-dialog.component.ts +++ b/apps/web/src/app/billing/shared/trial-payment-dialog/trial-payment-dialog.component.ts @@ -34,7 +34,10 @@ import { DialogService, ToastService, } from "@bitwarden/components"; -import { SubscriberBillingClient, TaxClient } from "@bitwarden/web-vault/app/billing/clients"; +import { + SubscriberBillingClient, + PreviewInvoiceClient, +} from "@bitwarden/web-vault/app/billing/clients"; import { EnterBillingAddressComponent, EnterPaymentMethodComponent, @@ -73,7 +76,7 @@ interface OnSuccessArgs { selector: "app-trial-payment-dialog", templateUrl: "./trial-payment-dialog.component.html", standalone: false, - providers: [SubscriberBillingClient, TaxClient], + providers: [SubscriberBillingClient, PreviewInvoiceClient], }) export class TrialPaymentDialogComponent implements OnInit, OnDestroy { // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals @@ -118,7 +121,7 @@ export class TrialPaymentDialogComponent implements OnInit, OnDestroy { private toastService: ToastService, private organizationBillingApiServiceAbstraction: OrganizationBillingApiServiceAbstraction, private subscriberBillingClient: SubscriberBillingClient, - private taxClient: TaxClient, + private previewInvoiceClient: PreviewInvoiceClient, ) { this.initialPaymentMethod = this.dialogParams.initialPaymentMethod ?? PaymentMethodType.Card; } @@ -300,7 +303,7 @@ export class TrialPaymentDialogComponent implements OnInit, OnDestroy { const tier = getTierFromLegacyEnum(this.organization); if (tier && cadence) { - const costs = await this.taxClient.previewTaxForOrganizationSubscriptionPlanChange( + const costs = await this.previewInvoiceClient.previewTaxForOrganizationSubscriptionPlanChange( this.organization.id, { tier, diff --git a/apps/web/src/app/billing/trial-initiation/trial-billing-step/trial-billing-step.component.ts b/apps/web/src/app/billing/trial-initiation/trial-billing-step/trial-billing-step.component.ts index 04ee7931cf3..9b86a9ba81b 100644 --- a/apps/web/src/app/billing/trial-initiation/trial-billing-step/trial-billing-step.component.ts +++ b/apps/web/src/app/billing/trial-initiation/trial-billing-step/trial-billing-step.component.ts @@ -15,7 +15,7 @@ import { import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { ToastService } from "@bitwarden/components"; -import { TaxClient } from "@bitwarden/web-vault/app/billing/clients"; +import { PreviewInvoiceClient } from "@bitwarden/web-vault/app/billing/clients"; import { BillingAddressControls, EnterBillingAddressComponent, @@ -41,7 +41,7 @@ export interface OrganizationCreatedEvent { selector: "app-trial-billing-step", templateUrl: "./trial-billing-step.component.html", imports: [EnterPaymentMethodComponent, EnterBillingAddressComponent, SharedModule], - providers: [TaxClient, TrialBillingStepService], + providers: [PreviewInvoiceClient, TrialBillingStepService], }) export class TrialBillingStepComponent implements OnInit, OnDestroy { // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals diff --git a/apps/web/src/app/billing/trial-initiation/trial-billing-step/trial-billing-step.service.ts b/apps/web/src/app/billing/trial-initiation/trial-billing-step/trial-billing-step.service.ts index 0888ef07afc..99eaf5c7988 100644 --- a/apps/web/src/app/billing/trial-initiation/trial-billing-step/trial-billing-step.service.ts +++ b/apps/web/src/app/billing/trial-initiation/trial-billing-step/trial-billing-step.service.ts @@ -12,7 +12,7 @@ import { import { PaymentMethodType, PlanType } from "@bitwarden/common/billing/enums"; import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; -import { TaxClient } from "@bitwarden/web-vault/app/billing/clients"; +import { PreviewInvoiceClient } from "@bitwarden/web-vault/app/billing/clients"; import { BillingAddressControls, getBillingAddressFromControls, @@ -63,7 +63,7 @@ export class TrialBillingStepService { private accountService: AccountService, private apiService: ApiService, private organizationBillingService: OrganizationBillingServiceAbstraction, - private taxClient: TaxClient, + private previewInvoiceClient: PreviewInvoiceClient, private configService: ConfigService, ) {} @@ -129,7 +129,7 @@ export class TrialBillingStepService { total: number; }> => { const billingAddress = getBillingAddressFromControls(billingAddressControls); - return await this.taxClient.previewTaxForOrganizationSubscriptionPurchase( + return await this.previewInvoiceClient.previewTaxForOrganizationSubscriptionPurchase( { tier, cadence, From 1e0b64a55b1b4c09422252596dd128f97d7c96c9 Mon Sep 17 00:00:00 2001 From: Mike Amirault Date: Tue, 3 Feb 2026 11:15:46 -0500 Subject: [PATCH 22/22] [PM-31430] Add specific messages for creating password and email protected Sends (#18692) * [PM-31430] Add specific messages for creating password and email protected Sends * [PM-31430] Fix tests, one bug in Send success drawer component --- .../send-success-drawer-dialog.component.html | 10 +- ...nd-success-drawer-dialog.component.spec.ts | 162 ++++++++++++++++++ .../send-success-drawer-dialog.component.ts | 9 +- apps/web/src/locales/en/messages.json | 20 +++ 4 files changed, 195 insertions(+), 6 deletions(-) create mode 100644 apps/web/src/app/tools/send/shared/send-success-drawer-dialog.component.spec.ts diff --git a/apps/web/src/app/tools/send/shared/send-success-drawer-dialog.component.html b/apps/web/src/app/tools/send/shared/send-success-drawer-dialog.component.html index 90210df4658..ce5b0e36728 100644 --- a/apps/web/src/app/tools/send/shared/send-success-drawer-dialog.component.html +++ b/apps/web/src/app/tools/send/shared/send-success-drawer-dialog.component.html @@ -1,6 +1,6 @@ - {{ dialogTitle() | i18n }} + {{ dialogTitle | i18n }}

- {{ "sendCreatedDescriptionV2" | i18n: formattedExpirationTime }} + @let translationKey = + send.authType === AuthType.Email + ? "sendCreatedDescriptionEmail" + : send.authType === AuthType.Password + ? "sendCreatedDescriptionPassword" + : "sendCreatedDescriptionV2"; + {{ translationKey | i18n: formattedExpirationTime }}

diff --git a/apps/web/src/app/tools/send/shared/send-success-drawer-dialog.component.spec.ts b/apps/web/src/app/tools/send/shared/send-success-drawer-dialog.component.spec.ts new file mode 100644 index 00000000000..bfc35f208ed --- /dev/null +++ b/apps/web/src/app/tools/send/shared/send-success-drawer-dialog.component.spec.ts @@ -0,0 +1,162 @@ +import { ComponentFixture, TestBed } from "@angular/core/testing"; +import { mock, MockProxy } from "jest-mock-extended"; +import { of } from "rxjs"; + +import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; +import { SelfHostedEnvironment } from "@bitwarden/common/platform/services/default-environment.service"; +import { SendView } from "@bitwarden/common/tools/send/models/view/send.view"; +import { AuthType } from "@bitwarden/common/tools/send/types/auth-type"; +import { SendType } from "@bitwarden/common/tools/send/types/send-type"; +import { + DIALOG_DATA, + DialogModule, + I18nMockService, + ToastService, + TypographyModule, +} from "@bitwarden/components"; +import { SharedModule } from "@bitwarden/web-vault/app/shared"; + +import { SendSuccessDrawerDialogComponent } from "./send-success-drawer-dialog.component"; + +describe("SendSuccessDrawerDialogComponent", () => { + let fixture: ComponentFixture; + let component: SendSuccessDrawerDialogComponent; + let environmentService: MockProxy; + let platformUtilsService: MockProxy; + let toastService: MockProxy; + + let sendView: SendView; + + // Translation Keys + const newTextSend = "New Text Send"; + const newFileSend = "New File Send"; + const oneHour = "1 hour"; + const oneDay = "1 day"; + const sendCreatedSuccessfully = "Send has been created successfully"; + const sendCreatedDescriptionV2 = "Send ready to share with anyone"; + const sendCreatedDescriptionEmail = "Email-verified Send ready to share"; + const sendCreatedDescriptionPassword = "Password-protected Send ready to share"; + + beforeEach(async () => { + environmentService = mock(); + platformUtilsService = mock(); + toastService = mock(); + + sendView = { + id: "test-send-id", + authType: AuthType.None, + deletionDate: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000), + type: SendType.Text, + accessId: "abc", + urlB64Key: "123", + } as SendView; + + Object.defineProperty(environmentService, "environment$", { + configurable: true, + get: () => of(new SelfHostedEnvironment({ webVault: "https://example.com" })), + }); + + await TestBed.configureTestingModule({ + imports: [SharedModule, DialogModule, TypographyModule], + providers: [ + { + provide: DIALOG_DATA, + useValue: sendView, + }, + { provide: EnvironmentService, useValue: environmentService }, + { + provide: I18nService, + useFactory: () => { + return new I18nMockService({ + newTextSend, + newFileSend, + sendCreatedSuccessfully, + sendCreatedDescriptionEmail, + sendCreatedDescriptionPassword, + sendCreatedDescriptionV2, + sendLink: "Send link", + copyLink: "Copy Send Link", + close: "Close", + oneHour, + durationTimeHours: (hours) => `${hours} hours`, + oneDay, + days: (days) => `${days} days`, + loading: "loading", + }); + }, + }, + { provide: PlatformUtilsService, useValue: platformUtilsService }, + { provide: ToastService, useValue: toastService }, + ], + }).compileComponents(); + + fixture = TestBed.createComponent(SendSuccessDrawerDialogComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it("should create", () => { + expect(component).toBeTruthy(); + }); + + it("should have the correct title for text Sends", () => { + sendView.type = SendType.Text; + fixture.detectChanges(); + expect(component.dialogTitle).toBe("newTextSend"); + }); + + it("should have the correct title for file Sends", () => { + fixture.componentInstance.send.type = SendType.File; + fixture.detectChanges(); + expect(component.dialogTitle).toBe("newFileSend"); + }); + + it("should show the correct message for Sends with an expiration time of one hour from now", () => { + sendView.deletionDate = new Date(Date.now() + 1 * 60 * 60 * 1000); + fixture.detectChanges(); + expect(component.formattedExpirationTime).toBe(oneHour); + }); + + it("should show the correct message for Sends with an expiration time more than an hour but less than a day from now", () => { + const numHours = 8; + sendView.deletionDate = new Date(Date.now() + numHours * 60 * 60 * 1000); + fixture.detectChanges(); + expect(component.formattedExpirationTime).toBe(`${numHours} hours`); + }); + + it("should have the correct title for Sends with an expiration time of one day from now", () => { + sendView.deletionDate = new Date(Date.now() + 24 * 60 * 60 * 1000); + fixture.detectChanges(); + expect(component.formattedExpirationTime).toBe(oneDay); + }); + + it("should have the correct title for Sends with an expiration time of multiple days from now", () => { + const numDays = 3; + sendView.deletionDate = new Date(Date.now() + numDays * 24 * 60 * 60 * 1000); + fixture.detectChanges(); + expect(component.formattedExpirationTime).toBe(`${numDays} days`); + }); + + it("should show the correct message for successfully-created Sends with no authentication", () => { + sendView.authType = AuthType.None; + fixture.detectChanges(); + expect(fixture.nativeElement.textContent).toContain(sendCreatedSuccessfully); + expect(fixture.nativeElement.textContent).toContain(sendCreatedDescriptionV2); + }); + + it("should show the correct message for successfully-created Sends with password authentication", () => { + sendView.authType = AuthType.Password; + fixture.detectChanges(); + expect(fixture.nativeElement.textContent).toContain(sendCreatedSuccessfully); + expect(fixture.nativeElement.textContent).toContain(sendCreatedDescriptionPassword); + }); + + it("should show the correct message for successfully-created Sends with email authentication", () => { + sendView.authType = AuthType.Email; + fixture.detectChanges(); + expect(fixture.nativeElement.textContent).toContain(sendCreatedSuccessfully); + expect(fixture.nativeElement.textContent).toContain(sendCreatedDescriptionEmail); + }); +}); diff --git a/apps/web/src/app/tools/send/shared/send-success-drawer-dialog.component.ts b/apps/web/src/app/tools/send/shared/send-success-drawer-dialog.component.ts index 67e01cd9ff0..9d812bc77ba 100644 --- a/apps/web/src/app/tools/send/shared/send-success-drawer-dialog.component.ts +++ b/apps/web/src/app/tools/send/shared/send-success-drawer-dialog.component.ts @@ -1,4 +1,4 @@ -import { Component, ChangeDetectionStrategy, Inject, signal, computed } from "@angular/core"; +import { Component, ChangeDetectionStrategy, Inject, signal } from "@angular/core"; import { firstValueFrom } from "rxjs"; import { ActiveSendIcon } from "@bitwarden/assets/svg"; @@ -6,6 +6,7 @@ import { EnvironmentService } from "@bitwarden/common/platform/abstractions/envi import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { SendView } from "@bitwarden/common/tools/send/models/view/send.view"; +import { AuthType } from "@bitwarden/common/tools/send/types/auth-type"; import { SendType } from "@bitwarden/common/tools/send/types/send-type"; import { DIALOG_DATA, DialogModule, ToastService, TypographyModule } from "@bitwarden/components"; import { SharedModule } from "@bitwarden/web-vault/app/shared"; @@ -16,13 +17,13 @@ import { SharedModule } from "@bitwarden/web-vault/app/shared"; changeDetection: ChangeDetectionStrategy.OnPush, }) export class SendSuccessDrawerDialogComponent { + readonly AuthType = AuthType; readonly sendLink = signal(""); activeSendIcon = ActiveSendIcon; - // Computed property to get the dialog title based on send type - readonly dialogTitle = computed(() => { + get dialogTitle(): string { return this.send.type === SendType.Text ? "newTextSend" : "newFileSend"; - }); + } constructor( @Inject(DIALOG_DATA) public send: SendView, diff --git a/apps/web/src/locales/en/messages.json b/apps/web/src/locales/en/messages.json index fe93d419035..a894b328d56 100644 --- a/apps/web/src/locales/en/messages.json +++ b/apps/web/src/locales/en/messages.json @@ -5675,6 +5675,26 @@ } } }, + "sendCreatedDescriptionPassword": { + "message": "Copy and share this Send link. The Send will be available to anyone with the link and password you set for the next $TIME$.", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated.", + "placeholders": { + "time": { + "content": "$1", + "example": "7 days, 1 hour, 1 day" + } + } + }, + "sendCreatedDescriptionEmail": { + "message": "Copy and share this Send link. It can be viewed by the people you specified for the next $TIME$.", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated.", + "placeholders": { + "time": { + "content": "$1", + "example": "7 days, 1 hour, 1 day" + } + } + }, "durationTimeHours": { "message": "$HOURS$ hours", "placeholders": {