From d3aef2c14bff6758dc1ca1b0d1c47f708ddb03c8 Mon Sep 17 00:00:00 2001 From: Brad <44413459+lastbestdev@users.noreply.github.com> Date: Fri, 30 Jan 2026 12:37:49 -0800 Subject: [PATCH 01/21] [PM-31385] Safari Report icon rendering fix #18641 * add full height tailwind class to report icons --- .../dirt/reports/shared/report-card/report-card.component.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/src/app/dirt/reports/shared/report-card/report-card.component.html b/apps/web/src/app/dirt/reports/shared/report-card/report-card.component.html index 6b201e7f6ae..ab0fe0c28ac 100644 --- a/apps/web/src/app/dirt/reports/shared/report-card/report-card.component.html +++ b/apps/web/src/app/dirt/reports/shared/report-card/report-card.component.html @@ -8,7 +8,7 @@ [ngClass]="{ 'tw-grayscale': disabled }" >
- +
From b667a84b44bcfb325df09c5dcdd07f3e4e823195 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Fri, 30 Jan 2026 16:14:28 -0500 Subject: [PATCH 02/21] [deps]: Update actions/cache action to v5.0.2 (#18568) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- .github/workflows/build-desktop.yml | 26 +++++++++++++------------- .github/workflows/chromatic.yml | 2 +- 2 files changed, 14 insertions(+), 14 deletions(-) diff --git a/.github/workflows/build-desktop.yml b/.github/workflows/build-desktop.yml index c021dedd8e1..6818064a808 100644 --- a/.github/workflows/build-desktop.yml +++ b/.github/workflows/build-desktop.yml @@ -236,7 +236,7 @@ jobs: npm link ../sdk-internal - name: Cache Native Module - uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1 + uses: actions/cache@8b402f58fbc84540c8b491a91e594a4576fec3d7 # v5.0.2 id: cache with: path: | @@ -399,7 +399,7 @@ jobs: npm link ../sdk-internal - name: Cache Native Module - uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1 + uses: actions/cache@8b402f58fbc84540c8b491a91e594a4576fec3d7 # v5.0.2 id: cache with: path: | @@ -562,7 +562,7 @@ jobs: npm link ../sdk-internal - name: Cache Native Module - uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1 + uses: actions/cache@8b402f58fbc84540c8b491a91e594a4576fec3d7 # v5.0.2 id: cache with: path: | @@ -827,7 +827,7 @@ jobs: npm link ../sdk-internal - name: Cache Native Module - uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1 + uses: actions/cache@8b402f58fbc84540c8b491a91e594a4576fec3d7 # v5.0.2 id: cache with: path: | @@ -1032,14 +1032,14 @@ jobs: - name: Cache Build id: build-cache - uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1 + uses: actions/cache@8b402f58fbc84540c8b491a91e594a4576fec3d7 # v5.0.2 with: path: apps/desktop/build key: ${{ runner.os }}-${{ github.run_id }}-build - name: Cache Safari id: safari-cache - uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1 + uses: actions/cache@8b402f58fbc84540c8b491a91e594a4576fec3d7 # v5.0.2 with: path: apps/browser/dist/Safari key: ${{ runner.os }}-${{ github.run_id }}-safari-extension @@ -1185,7 +1185,7 @@ jobs: npm link ../sdk-internal - name: Cache Native Module - uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1 + uses: actions/cache@8b402f58fbc84540c8b491a91e594a4576fec3d7 # v5.0.2 id: cache with: path: | @@ -1272,14 +1272,14 @@ jobs: - name: Get Build Cache id: build-cache - uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1 + uses: actions/cache@8b402f58fbc84540c8b491a91e594a4576fec3d7 # v5.0.2 with: path: apps/desktop/build key: ${{ runner.os }}-${{ github.run_id }}-build - name: Setup Safari Cache id: safari-cache - uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1 + uses: actions/cache@8b402f58fbc84540c8b491a91e594a4576fec3d7 # v5.0.2 with: path: apps/browser/dist/Safari key: ${{ runner.os }}-${{ github.run_id }}-safari-extension @@ -1409,7 +1409,7 @@ jobs: npm link ../sdk-internal - name: Cache Native Module - uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1 + uses: actions/cache@8b402f58fbc84540c8b491a91e594a4576fec3d7 # v5.0.2 id: cache with: path: | @@ -1547,14 +1547,14 @@ jobs: - name: Get Build Cache id: build-cache - uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1 + uses: actions/cache@8b402f58fbc84540c8b491a91e594a4576fec3d7 # v5.0.2 with: path: apps/desktop/build key: ${{ runner.os }}-${{ github.run_id }}-build - name: Setup Safari Cache id: safari-cache - uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1 + uses: actions/cache@8b402f58fbc84540c8b491a91e594a4576fec3d7 # v5.0.2 with: path: apps/browser/dist/Safari key: ${{ runner.os }}-${{ github.run_id }}-safari-extension @@ -1692,7 +1692,7 @@ jobs: npm link ../sdk-internal - name: Cache Native Module - uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1 + uses: actions/cache@8b402f58fbc84540c8b491a91e594a4576fec3d7 # v5.0.2 id: cache with: path: | diff --git a/.github/workflows/chromatic.yml b/.github/workflows/chromatic.yml index 6189744fe67..b1dc3165c3e 100644 --- a/.github/workflows/chromatic.yml +++ b/.github/workflows/chromatic.yml @@ -65,7 +65,7 @@ jobs: - name: Cache NPM id: npm-cache - uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1 + uses: actions/cache@8b402f58fbc84540c8b491a91e594a4576fec3d7 # v5.0.2 with: path: "~/.npm" key: ${{ runner.os }}-npm-chromatic-${{ hashFiles('**/package-lock.json') }} From a1bf6afad68cfaac2604e3af182173e367121a83 Mon Sep 17 00:00:00 2001 From: Jackson Engstrom Date: Fri, 30 Jan 2026 14:01:10 -0800 Subject: [PATCH 03/21] [PM-21564] Hide buttons when user has View access to an item * Changes attachment modal to remove choose file button and changes upload button to close button if the user doesn't have edit rights to the cipher. --- .../attachments-v2.component.spec.ts | 2 +- .../src/vault/app/vault/vault-v2.component.ts | 1 + .../vault/individual-vault/vault.component.ts | 1 + .../cipher-attachments.component.html | 102 +++++++++--------- .../cipher-attachments.component.spec.ts | 31 ++++++ .../cipher-attachments.component.ts | 10 +- .../attachments/attachments-v2.component.html | 3 +- .../attachments-v2.component.spec.ts | 8 ++ .../attachments/attachments-v2.component.ts | 14 +++ 9 files changed, 120 insertions(+), 52 deletions(-) diff --git a/apps/browser/src/vault/popup/components/vault-v2/attachments/attachments-v2.component.spec.ts b/apps/browser/src/vault/popup/components/vault-v2/attachments/attachments-v2.component.spec.ts index 1da2d352c14..d8f1d34ef9a 100644 --- a/apps/browser/src/vault/popup/components/vault-v2/attachments/attachments-v2.component.spec.ts +++ b/apps/browser/src/vault/popup/components/vault-v2/attachments/attachments-v2.component.spec.ts @@ -109,7 +109,7 @@ describe("AttachmentsV2Component", () => { }); it("passes the submit button to the cipher attachments component", () => { - const submitBtn = fixture.debugElement.queryAll(By.directive(ButtonComponent))[1] + const submitBtn = fixture.debugElement.queryAll(By.directive(ButtonComponent))[0] .componentInstance; expect(cipherAttachment.submitBtn()).toEqual(submitBtn); diff --git a/apps/desktop/src/vault/app/vault/vault-v2.component.ts b/apps/desktop/src/vault/app/vault/vault-v2.component.ts index fe2914216a3..458ddd666b8 100644 --- a/apps/desktop/src/vault/app/vault/vault-v2.component.ts +++ b/apps/desktop/src/vault/app/vault/vault-v2.component.ts @@ -518,6 +518,7 @@ export class VaultV2Component } const dialogRef = AttachmentsV2Component.open(this.dialogService, { cipherId: this.cipherId as CipherId, + canEditCipher: this.cipher().edit, }); const result = await firstValueFrom(dialogRef.closed).catch((): any => null); if ( diff --git a/apps/web/src/app/vault/individual-vault/vault.component.ts b/apps/web/src/app/vault/individual-vault/vault.component.ts index b07de88baf9..fe4b7f1f96f 100644 --- a/apps/web/src/app/vault/individual-vault/vault.component.ts +++ b/apps/web/src/app/vault/individual-vault/vault.component.ts @@ -925,6 +925,7 @@ export class VaultComponent implements OnInit, OnDestr const dialogRef = AttachmentsV2Component.open(this.dialogService, { cipherId: cipher.id as CipherId, organizationId: cipher.organizationId as OrganizationId, + canEditCipher: cipher.edit, }); const result: AttachmentDialogCloseResult = await lastValueFrom(dialogRef.closed); diff --git a/libs/vault/src/cipher-form/components/attachments/cipher-attachments.component.html b/libs/vault/src/cipher-form/components/attachments/cipher-attachments.component.html index 855c37ecab5..6aaaf033e0d 100644 --- a/libs/vault/src/cipher-form/components/attachments/cipher-attachments.component.html +++ b/libs/vault/src/cipher-form/components/attachments/cipher-attachments.component.html @@ -38,14 +38,16 @@ } - - - + @if (cipher().edit) { + + + + } @@ -54,46 +56,48 @@ }
- - -
- - - - - -

- {{ "maxFileSizeSansPunctuation" | i18n }} -

- +

+ {{ "maxFileSizeSansPunctuation" | i18n }} +

+ + } diff --git a/libs/vault/src/cipher-form/components/attachments/cipher-attachments.component.spec.ts b/libs/vault/src/cipher-form/components/attachments/cipher-attachments.component.spec.ts index 2e54d3b539a..002ad019653 100644 --- a/libs/vault/src/cipher-form/components/attachments/cipher-attachments.component.spec.ts +++ b/libs/vault/src/cipher-form/components/attachments/cipher-attachments.component.spec.ts @@ -51,6 +51,7 @@ describe("CipherAttachmentsComponent", () => { username: "username", password: "password", }, + edit: true, } as CipherView; const cipherDomain = { @@ -197,6 +198,10 @@ describe("CipherAttachmentsComponent", () => { let file: File; beforeEach(() => { + const nonEditableCipherView = { ...cipherView, edit: false }; + cipherServiceDecrypt.mockResolvedValue(nonEditableCipherView); + fixture.detectChanges(); + submitBtnFixture.componentInstance.disabled.set(undefined as unknown as boolean); file = new File([""], "attachment.txt", { type: "text/plain" }); @@ -371,6 +376,32 @@ describe("CipherAttachmentsComponent", () => { expect(emitSpy).toHaveBeenCalled(); }); }); + + describe("close", () => { + async function setup(): Promise { + fixture = TestBed.createComponent(CipherAttachmentsComponent); + component = fixture.componentInstance; + submitBtnFixture = TestBed.createComponent(ButtonComponent); + + // Set organizationId BEFORE cipherId so the effect picks it up + fixture.componentRef.setInput("organizationId", organization.id); + fixture.componentRef.setInput("submitBtn", submitBtnFixture.componentInstance); + fixture.componentRef.setInput("cipherId", "5555-444-3333" as CipherId); + await waitForInitialization(); + const nonEditableCipherView = { ...cipherView, edit: false }; + cipherServiceDecrypt.mockResolvedValue(nonEditableCipherView); + fixture.detectChanges(); + } + + it('emits "onCloseButtonPress"', async () => { + await setup(); + const emitSpy = jest.spyOn(component.onCloseButtonPress, "emit"); + + await component.submit(); + + expect(emitSpy).toHaveBeenCalled(); + }); + }); }); describe("removeAttachment", () => { diff --git a/libs/vault/src/cipher-form/components/attachments/cipher-attachments.component.ts b/libs/vault/src/cipher-form/components/attachments/cipher-attachments.component.ts index f75611b995e..e0a648e3107 100644 --- a/libs/vault/src/cipher-form/components/attachments/cipher-attachments.component.ts +++ b/libs/vault/src/cipher-form/components/attachments/cipher-attachments.component.ts @@ -105,6 +105,8 @@ export class CipherAttachmentsComponent { /** Emits after a file has been successfully removed */ readonly onRemoveSuccess = output(); + readonly onCloseButtonPress = output(); + protected readonly organization = signal(null); protected readonly cipher = signal(null); @@ -154,7 +156,7 @@ export class CipherAttachmentsComponent { // Update the initial state of the submit button const btn = this.submitBtn(); if (btn) { - btn.disabled.set(!this.attachmentForm.valid); + btn.disabled.set(!this.attachmentForm.valid && (this.cipher()?.edit ?? true)); } }); @@ -192,6 +194,12 @@ export class CipherAttachmentsComponent { /** Save the attachments to the cipher */ submit = async () => { + //user can't edit cipher and will close the bit-dialog + if (!(this.cipher()?.edit ?? false)) { + this.onCloseButtonPress.emit(); + return; + } + this.onUploadStarted.emit(); const file = this.attachmentForm.value.file; diff --git a/libs/vault/src/cipher-view/attachments/attachments-v2.component.html b/libs/vault/src/cipher-view/attachments/attachments-v2.component.html index a8dc22c75ac..964fba6a266 100644 --- a/libs/vault/src/cipher-view/attachments/attachments-v2.component.html +++ b/libs/vault/src/cipher-view/attachments/attachments-v2.component.html @@ -13,11 +13,12 @@ (onUploadSuccess)="uploadSuccessful()" (onUploadFailed)="uploadFailed()" (onRemoveSuccess)="removalSuccessful()" + (onCloseButtonPress)="closeButtonPressed()" > diff --git a/libs/vault/src/cipher-view/attachments/attachments-v2.component.spec.ts b/libs/vault/src/cipher-view/attachments/attachments-v2.component.spec.ts index a188d673601..03ddb386ad0 100644 --- a/libs/vault/src/cipher-view/attachments/attachments-v2.component.spec.ts +++ b/libs/vault/src/cipher-view/attachments/attachments-v2.component.spec.ts @@ -69,4 +69,12 @@ describe("AttachmentsV2Component", () => { expect(dialogRefCloseSpy).toHaveBeenCalledWith({ action: AttachmentDialogResult.Removed }); }); + + it("closes the dialog with 'closed' result on closedButtonPressed", () => { + const dialogRefCloseSpy = jest.spyOn(component["dialogRef"], "close"); + + component.closeButtonPressed(); + + expect(dialogRefCloseSpy).toHaveBeenCalledWith({ action: AttachmentDialogResult.Closed }); + }); }); diff --git a/libs/vault/src/cipher-view/attachments/attachments-v2.component.ts b/libs/vault/src/cipher-view/attachments/attachments-v2.component.ts index 218f5b2c6d3..9810aa929d6 100644 --- a/libs/vault/src/cipher-view/attachments/attachments-v2.component.ts +++ b/libs/vault/src/cipher-view/attachments/attachments-v2.component.ts @@ -3,6 +3,7 @@ import { CommonModule } from "@angular/common"; import { Component, HostListener, Inject } from "@angular/core"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { CipherId, OrganizationId } from "@bitwarden/common/types/guid"; import { UnionOfValues } from "@bitwarden/common/vault/types/union-of-values"; import { @@ -18,6 +19,7 @@ import { CipherAttachmentsComponent } from "../../cipher-form/components/attachm export interface AttachmentsDialogParams { cipherId: CipherId; + canEditCipher?: boolean; admin?: boolean; organizationId?: OrganizationId; } @@ -51,7 +53,9 @@ export class AttachmentsV2Component { cipherId: CipherId; admin: boolean = false; organizationId?: OrganizationId; + canEditCipher: boolean; attachmentFormId = CipherAttachmentsComponent.attachmentFormID; + buttonText: string; private isUploading = false; /** @@ -62,10 +66,14 @@ export class AttachmentsV2Component { constructor( private dialogRef: DialogRef, @Inject(DIALOG_DATA) public params: AttachmentsDialogParams, + private i18nService: I18nService, ) { this.cipherId = params.cipherId; this.organizationId = params.organizationId; this.admin = params.admin ?? false; + this.canEditCipher = params?.canEditCipher ?? false; + this.buttonText = + this.canEditCipher || this.admin ? this.i18nService.t("upload") : this.i18nService.t("close"); } /** @@ -140,4 +148,10 @@ export class AttachmentsV2Component { action: AttachmentDialogResult.Removed, }); } + + closeButtonPressed() { + this.dialogRef.close({ + action: AttachmentDialogResult.Closed, + }); + } } From 4a45414f4aaab20fccc97c29afb35e7c7ee76b31 Mon Sep 17 00:00:00 2001 From: Ike <137194738+ike-kottlowski@users.noreply.github.com> Date: Fri, 30 Jan 2026 17:16:32 -0500 Subject: [PATCH 04/21] [PM-30563] Improve Send Access enumeration protection (#18620) * feat: sync changes with SDK and server * Update libs/common/src/auth/send-access/types/invalid-request-errors.type.ts Co-authored-by: claude[bot] <209825114+claude[bot]@users.noreply.github.com> * feat: sync changes with SDK and Server projects sync: sdk version * chore: update sdk * chore: update sdk * chore: prettier --------- Co-authored-by: claude[bot] <209825114+claude[bot]@users.noreply.github.com> --- .../send/send-access/send-auth.component.ts | 5 ++--- .../default-send-token.service.spec.ts | 3 +-- .../types/invalid-grant-errors.type.ts | 7 ------- .../types/invalid-request-errors.type.ts | 12 ++++-------- package-lock.json | 18 ++++++++---------- package.json | 4 ++-- 6 files changed, 17 insertions(+), 32 deletions(-) diff --git a/apps/web/src/app/tools/send/send-access/send-auth.component.ts b/apps/web/src/app/tools/send/send-access/send-auth.component.ts index 13e82bd4cfa..9ed8106ad40 100644 --- a/apps/web/src/app/tools/send/send-access/send-auth.component.ts +++ b/apps/web/src/app/tools/send/send-access/send-auth.component.ts @@ -3,8 +3,7 @@ import { FormBuilder } from "@angular/forms"; import { firstValueFrom } from "rxjs"; import { - emailAndOtpRequiredEmailSent, - emailInvalid, + emailAndOtpRequired, emailRequired, otpInvalid, passwordHashB64Invalid, @@ -161,7 +160,7 @@ export class SendAuthComponent implements OnInit { this.expiredAuthAttempts = 0; if (emailRequired(response.error)) { this.sendAuthType.set(AuthType.Email); - } else if (emailAndOtpRequiredEmailSent(response.error) || emailInvalid(response.error)) { + } else if (emailAndOtpRequired(response.error)) { this.enterOtp.set(true); } else if (otpInvalid(response.error)) { this.toastService.showToast({ diff --git a/libs/common/src/auth/send-access/services/default-send-token.service.spec.ts b/libs/common/src/auth/send-access/services/default-send-token.service.spec.ts index 8db0532911f..1145abc2a76 100644 --- a/libs/common/src/auth/send-access/services/default-send-token.service.spec.ts +++ b/libs/common/src/auth/send-access/services/default-send-token.service.spec.ts @@ -64,14 +64,13 @@ describe("SendTokenService", () => { "send_id_required", "password_hash_b64_required", "email_required", - "email_and_otp_required_otp_sent", + "email_and_otp_required", "unknown", ]; const INVALID_GRANT_CODES: SendAccessTokenInvalidGrantError[] = [ "send_id_invalid", "password_hash_b64_invalid", - "email_invalid", "otp_invalid", "otp_generation_failed", "unknown", diff --git a/libs/common/src/auth/send-access/types/invalid-grant-errors.type.ts b/libs/common/src/auth/send-access/types/invalid-grant-errors.type.ts index befb869a89e..e9c7e80406e 100644 --- a/libs/common/src/auth/send-access/types/invalid-grant-errors.type.ts +++ b/libs/common/src/auth/send-access/types/invalid-grant-errors.type.ts @@ -31,13 +31,6 @@ export function passwordHashB64Invalid( return e.error === "invalid_grant" && e.send_access_error_type === "password_hash_b64_invalid"; } -export type EmailInvalid = InvalidGrant & { - send_access_error_type: "email_invalid"; -}; -export function emailInvalid(e: SendAccessTokenApiErrorResponse): e is EmailInvalid { - return e.error === "invalid_grant" && e.send_access_error_type === "email_invalid"; -} - export type OtpInvalid = InvalidGrant & { send_access_error_type: "otp_invalid"; }; diff --git a/libs/common/src/auth/send-access/types/invalid-request-errors.type.ts b/libs/common/src/auth/send-access/types/invalid-request-errors.type.ts index 57a70e62586..3e76a8f61f6 100644 --- a/libs/common/src/auth/send-access/types/invalid-request-errors.type.ts +++ b/libs/common/src/auth/send-access/types/invalid-request-errors.type.ts @@ -39,16 +39,12 @@ export function emailRequired(e: SendAccessTokenApiErrorResponse): e is EmailReq return e.error === "invalid_request" && e.send_access_error_type === "email_required"; } -export type EmailAndOtpRequiredEmailSent = InvalidRequest & { - send_access_error_type: "email_and_otp_required_otp_sent"; +export type EmailAndOtpRequired = InvalidRequest & { + send_access_error_type: "email_and_otp_required"; }; -export function emailAndOtpRequiredEmailSent( - e: SendAccessTokenApiErrorResponse, -): e is EmailAndOtpRequiredEmailSent { - return ( - e.error === "invalid_request" && e.send_access_error_type === "email_and_otp_required_otp_sent" - ); +export function emailAndOtpRequired(e: SendAccessTokenApiErrorResponse): e is EmailAndOtpRequired { + return e.error === "invalid_request" && e.send_access_error_type === "email_and_otp_required"; } export type UnknownInvalidRequest = InvalidRequest & { diff --git a/package-lock.json b/package-lock.json index 59bd89afce4..da9b3e7dcbe 100644 --- a/package-lock.json +++ b/package-lock.json @@ -23,8 +23,8 @@ "@angular/platform-browser": "20.3.16", "@angular/platform-browser-dynamic": "20.3.16", "@angular/router": "20.3.16", - "@bitwarden/commercial-sdk-internal": "0.2.0-main.470", - "@bitwarden/sdk-internal": "0.2.0-main.470", + "@bitwarden/commercial-sdk-internal": "0.2.0-main.506", + "@bitwarden/sdk-internal": "0.2.0-main.506", "@electron/fuses": "1.8.0", "@emotion/css": "11.13.5", "@koa/multer": "4.0.0", @@ -4982,10 +4982,9 @@ "link": true }, "node_modules/@bitwarden/commercial-sdk-internal": { - "version": "0.2.0-main.470", - "resolved": "https://registry.npmjs.org/@bitwarden/commercial-sdk-internal/-/commercial-sdk-internal-0.2.0-main.470.tgz", - "integrity": "sha512-QYhxv5eX6ouFJv94gMtBW7MjuK6t2KAN9FLz+/w1wnq8dScnA9Iky25phNPw+iHMgWwhq/dzZq45asKUFF//oA==", - "license": "BITWARDEN SOFTWARE DEVELOPMENT KIT LICENSE AGREEMENT", + "version": "0.2.0-main.506", + "resolved": "https://registry.npmjs.org/@bitwarden/commercial-sdk-internal/-/commercial-sdk-internal-0.2.0-main.506.tgz", + "integrity": "sha512-aRzcxOcj8vXxz0jN3q2xxj26zxBfjg3oRm5QXbWE7zXJ2PGrgxTaePca9pQYYpwgr7iufYMnZcq5dH+qttNEmA==", "dependencies": { "type-fest": "^4.41.0" } @@ -5087,10 +5086,9 @@ "link": true }, "node_modules/@bitwarden/sdk-internal": { - "version": "0.2.0-main.470", - "resolved": "https://registry.npmjs.org/@bitwarden/sdk-internal/-/sdk-internal-0.2.0-main.470.tgz", - "integrity": "sha512-XKvcUtoU6NnxeEzl3WK7bATiCh2RNxRmuX6JYNgcQHUtHUH+x3ckToR6II1qM3nha0VH0u1ijy3+07UdNQM+JQ==", - "license": "GPL-3.0", + "version": "0.2.0-main.506", + "resolved": "https://registry.npmjs.org/@bitwarden/sdk-internal/-/sdk-internal-0.2.0-main.506.tgz", + "integrity": "sha512-BbTSU5Acx74Hr32zDj2kV8sbdclyvdIti5t6kXnCvJmA5dZbu+5j5Xw1luS9mGL9Vfi4w3OjVug/TiSxyhwLzQ==", "dependencies": { "type-fest": "^4.41.0" } diff --git a/package.json b/package.json index 1cc4cabbceb..20ca9b20f8e 100644 --- a/package.json +++ b/package.json @@ -162,8 +162,8 @@ "@angular/platform-browser": "20.3.16", "@angular/platform-browser-dynamic": "20.3.16", "@angular/router": "20.3.16", - "@bitwarden/sdk-internal": "0.2.0-main.470", - "@bitwarden/commercial-sdk-internal": "0.2.0-main.470", + "@bitwarden/commercial-sdk-internal": "0.2.0-main.506", + "@bitwarden/sdk-internal": "0.2.0-main.506", "@electron/fuses": "1.8.0", "@emotion/css": "11.13.5", "@koa/multer": "4.0.0", From 1f0e0ca0980858f04eeb64aae04c2ffdd43517ad Mon Sep 17 00:00:00 2001 From: rr-bw <102181210+rr-bw@users.noreply.github.com> Date: Fri, 30 Jan 2026 15:11:59 -0800 Subject: [PATCH 05/21] refactor(input-password-flows): [Auth/PM-27086] JIT MP org user flow - remove masterKey generation from InputPasswordComponent (#18006) - Updates `InputPasswordComponent` to emit raw data instead of generating cryptographic properties (`newMasterKey`, `newServerMasterKeyHash`, `newLocalMasterKeyHash`). - This helps us in moving away from using the deprecated `makeMasterKey()` method in the component (which takes email as salt) as we seek to eventually separate the email from the salt. - Updates the `JIT_PROVISIONED_MP_ORG_USER` case of the switch to handle the flow when the `PM27086_UpdateAuthenticationApisForInputPassword` flag is on. Feature Flag: `PM27086_UpdateAuthenticationApisForInputPassword` --- ...sktop-set-initial-password.service.spec.ts | 6 ++ .../desktop-set-initial-password.service.ts | 3 + .../web-set-initial-password.service.spec.ts | 6 ++ .../web-set-initial-password.service.ts | 3 + ...initial-password.service.implementation.ts | 9 +++ ...fault-set-initial-password.service.spec.ts | 4 + .../set-initial-password.component.ts | 64 ++++++++++++++- ...et-initial-password.service.abstraction.ts | 2 + .../input-password.component.ts | 46 ++++++++++- .../angular/input-password/input-password.mdx | 81 +++++++------------ .../input-password/input-password.stories.ts | 8 ++ .../input-password/password-input-result.ts | 14 ++++ libs/common/src/enums/feature-flag.enum.ts | 2 + 13 files changed, 189 insertions(+), 59 deletions(-) diff --git a/apps/desktop/src/app/services/set-initial-password/desktop-set-initial-password.service.spec.ts b/apps/desktop/src/app/services/set-initial-password/desktop-set-initial-password.service.spec.ts index 9bb7d5077cf..430870a247b 100644 --- a/apps/desktop/src/app/services/set-initial-password/desktop-set-initial-password.service.spec.ts +++ b/apps/desktop/src/app/services/set-initial-password/desktop-set-initial-password.service.spec.ts @@ -87,6 +87,10 @@ describe("DesktopSetInitialPasswordService", () => { expect(sut).not.toBeFalsy(); }); + /** + * @deprecated To be removed in PM-28143. When you remove this, check also if there are any imports/properties + * in the test setup above that are now un-used and can also be removed. + */ describe("setInitialPassword(...)", () => { // Mock function parameters let credentials: SetInitialPasswordCredentials; @@ -116,6 +120,8 @@ describe("DesktopSetInitialPasswordService", () => { orgSsoIdentifier: "orgSsoIdentifier", orgId: "orgId", resetPasswordAutoEnroll: false, + newPassword: "Test@Password123!", + salt: "user@example.com" as MasterPasswordSalt, }; userId = "userId" as UserId; userType = SetInitialPasswordUserType.JIT_PROVISIONED_MP_ORG_USER; diff --git a/apps/desktop/src/app/services/set-initial-password/desktop-set-initial-password.service.ts b/apps/desktop/src/app/services/set-initial-password/desktop-set-initial-password.service.ts index f9fb8361056..3b1562075f9 100644 --- a/apps/desktop/src/app/services/set-initial-password/desktop-set-initial-password.service.ts +++ b/apps/desktop/src/app/services/set-initial-password/desktop-set-initial-password.service.ts @@ -54,6 +54,9 @@ export class DesktopSetInitialPasswordService ); } + /** + * @deprecated To be removed in PM-28143 + */ override async setInitialPassword( credentials: SetInitialPasswordCredentials, userType: SetInitialPasswordUserType, diff --git a/apps/web/src/app/auth/core/services/password-management/set-initial-password/web-set-initial-password.service.spec.ts b/apps/web/src/app/auth/core/services/password-management/set-initial-password/web-set-initial-password.service.spec.ts index b09b5f0bc9a..ead40739a50 100644 --- a/apps/web/src/app/auth/core/services/password-management/set-initial-password/web-set-initial-password.service.spec.ts +++ b/apps/web/src/app/auth/core/services/password-management/set-initial-password/web-set-initial-password.service.spec.ts @@ -90,6 +90,10 @@ describe("WebSetInitialPasswordService", () => { expect(sut).not.toBeFalsy(); }); + /** + * @deprecated To be removed in PM-28143. When you remove this, check also if there are any imports/properties + * in the test setup above that are now un-used and can also be removed. + */ describe("setInitialPassword(...)", () => { // Mock function parameters let credentials: SetInitialPasswordCredentials; @@ -119,6 +123,8 @@ describe("WebSetInitialPasswordService", () => { orgSsoIdentifier: "orgSsoIdentifier", orgId: "orgId", resetPasswordAutoEnroll: false, + newPassword: "Test@Password123!", + salt: "user@example.com" as MasterPasswordSalt, }; userId = "userId" as UserId; userType = SetInitialPasswordUserType.JIT_PROVISIONED_MP_ORG_USER; diff --git a/apps/web/src/app/auth/core/services/password-management/set-initial-password/web-set-initial-password.service.ts b/apps/web/src/app/auth/core/services/password-management/set-initial-password/web-set-initial-password.service.ts index 0b8dba6c40e..a6a902ab847 100644 --- a/apps/web/src/app/auth/core/services/password-management/set-initial-password/web-set-initial-password.service.ts +++ b/apps/web/src/app/auth/core/services/password-management/set-initial-password/web-set-initial-password.service.ts @@ -56,6 +56,9 @@ export class WebSetInitialPasswordService ); } + /** + * @deprecated To be removed in PM-28143 + */ override async setInitialPassword( credentials: SetInitialPasswordCredentials, userType: SetInitialPasswordUserType, diff --git a/libs/angular/src/auth/password-management/set-initial-password/default-set-initial-password.service.implementation.ts b/libs/angular/src/auth/password-management/set-initial-password/default-set-initial-password.service.implementation.ts index 3f6023c1205..85177a920e3 100644 --- a/libs/angular/src/auth/password-management/set-initial-password/default-set-initial-password.service.implementation.ts +++ b/libs/angular/src/auth/password-management/set-initial-password/default-set-initial-password.service.implementation.ts @@ -63,6 +63,10 @@ export class DefaultSetInitialPasswordService implements SetInitialPasswordServi protected registerSdkService: RegisterSdkService, ) {} + /** + * @deprecated To be removed in PM-28143. When you remove this, also check for any objects/methods + * in this default service that are now un-used and can also be removed. + */ async setInitialPassword( credentials: SetInitialPasswordCredentials, userType: SetInitialPasswordUserType, @@ -333,6 +337,9 @@ export class DefaultSetInitialPasswordService implements SetInitialPasswordServi ); } + /** + * @deprecated To be removed in PM-28143 + */ private async makeMasterKeyEncryptedUserKey( masterKey: MasterKey, userId: UserId, @@ -410,6 +417,8 @@ export class DefaultSetInitialPasswordService implements SetInitialPasswordServi } /** + * @deprecated To be removed in PM-28143 + * * As part of [PM-28494], adding this setting path to accommodate the changes that are * emerging with pm-23246-unlock-with-master-password-unlock-data. * Without this, immediately locking/unlocking the vault with the new password _may_ still fail diff --git a/libs/angular/src/auth/password-management/set-initial-password/default-set-initial-password.service.spec.ts b/libs/angular/src/auth/password-management/set-initial-password/default-set-initial-password.service.spec.ts index 6b3981a5231..91578fdd7b6 100644 --- a/libs/angular/src/auth/password-management/set-initial-password/default-set-initial-password.service.spec.ts +++ b/libs/angular/src/auth/password-management/set-initial-password/default-set-initial-password.service.spec.ts @@ -124,6 +124,10 @@ describe("DefaultSetInitialPasswordService", () => { expect(sut).not.toBeFalsy(); }); + /** + * @deprecated To be removed in PM-28143. When you remove this, check also if there are any imports/properties + * in the test setup above that are now un-used and can also be removed. + */ describe("setInitialPassword(...)", () => { // Mock function parameters let credentials: SetInitialPasswordCredentials; diff --git a/libs/angular/src/auth/password-management/set-initial-password/set-initial-password.component.ts b/libs/angular/src/auth/password-management/set-initial-password/set-initial-password.component.ts index 4ab26ecd09e..7850a980eef 100644 --- a/libs/angular/src/auth/password-management/set-initial-password/set-initial-password.component.ts +++ b/libs/angular/src/auth/password-management/set-initial-password/set-initial-password.component.ts @@ -29,6 +29,7 @@ import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.servic import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service"; import { ValidationService } from "@bitwarden/common/platform/abstractions/validation.service"; +import { HashPurpose } from "@bitwarden/common/platform/enums"; import { SyncService } from "@bitwarden/common/platform/sync"; import { OrganizationId, UserId } from "@bitwarden/common/types/guid"; import { @@ -38,6 +39,7 @@ import { DialogService, ToastService, } from "@bitwarden/components"; +import { KeyService } from "@bitwarden/key-management"; import { I18nPipe } from "@bitwarden/ui-common"; import { @@ -76,6 +78,7 @@ export class SetInitialPasswordComponent implements OnInit { private anonLayoutWrapperDataService: AnonLayoutWrapperDataService, private dialogService: DialogService, private i18nService: I18nService, + private keyService: KeyService, private logoutService: LogoutService, private logService: LogService, private masterPasswordService: InternalMasterPasswordServiceAbstraction, @@ -110,16 +113,72 @@ export class SetInitialPasswordComponent implements OnInit { switch (this.userType) { case SetInitialPasswordUserType.JIT_PROVISIONED_MP_ORG_USER: { + /** + * "KM flag" = EnableAccountEncryptionV2JitPasswordRegistration + * "Auth flag" = PM27086_UpdateAuthenticationApisForInputPassword (checked in InputPasswordComponent and + * passed through via PasswordInputResult) + * + * Flag unwinding for this specific `case` will depend on which flag gets unwound first: + * - If KM flag gets unwound first, remove all code (in this `case`) after the call + * to setInitialPasswordJitMPUserV2Encryption(), as the V2Encryption method is the + * end-goal for this `case`. + * - If Auth flag gets unwound first (in PM-28143), keep the KM code & early return, + * but unwind the auth flagging logic and then remove the method call marked with + * the "Default Scenario" comment. + */ + const accountEncryptionV2 = await this.configService.getFeatureFlag( FeatureFlag.EnableAccountEncryptionV2JitPasswordRegistration, ); + // Scenario 1: KM flag ON if (accountEncryptionV2) { await this.setInitialPasswordJitMPUserV2Encryption(passwordInputResult); return; } - await this.setInitialPassword(passwordInputResult); + // Scenario 2: KM flag OFF, Auth flag ON + if (passwordInputResult.newApisWithInputPasswordFlagEnabled) { + /** + * If the Auth flag is enabled, it means the InputPasswordComponent will not emit a newMasterKey, + * newServerMasterKeyHash, and newLocalMasterKeyHash. So we must create them here and add them late + * to the PasswordInputResult before calling setInitialPassword(). + * + * This is a temporary state. The end-goal will be to use KM's V2Encryption method above. + */ + const ctx = "Could not set initial password."; + assertTruthy(passwordInputResult.newPassword, "newPassword", ctx); + assertNonNullish(passwordInputResult.kdfConfig, "kdfConfig", ctx); + assertTruthy(this.email, "email", ctx); + + const newMasterKey = await this.keyService.makeMasterKey( + passwordInputResult.newPassword, + this.email.trim().toLowerCase(), + passwordInputResult.kdfConfig, + ); + + const newServerMasterKeyHash = await this.keyService.hashMasterKey( + passwordInputResult.newPassword, + newMasterKey, + HashPurpose.ServerAuthorization, + ); + + const newLocalMasterKeyHash = await this.keyService.hashMasterKey( + passwordInputResult.newPassword, + newMasterKey, + HashPurpose.LocalAuthorization, + ); + + passwordInputResult.newMasterKey = newMasterKey; + passwordInputResult.newServerMasterKeyHash = newServerMasterKeyHash; + passwordInputResult.newLocalMasterKeyHash = newLocalMasterKeyHash; + + await this.setInitialPassword(passwordInputResult); // passwordInputResult masterKey properties generated on the SetInitialPasswordComponent (just above) + return; + } + + // Default Scenario: both flags OFF + await this.setInitialPassword(passwordInputResult); // passwordInputResult masterKey properties generated on the InputPasswordComponent (default) break; } @@ -274,6 +333,9 @@ export class SetInitialPasswordComponent implements OnInit { } } + /** + * @deprecated To be removed in PM-28143 + */ private async setInitialPassword(passwordInputResult: PasswordInputResult) { const ctx = "Could not set initial password."; assertTruthy(passwordInputResult.newMasterKey, "newMasterKey", ctx); diff --git a/libs/angular/src/auth/password-management/set-initial-password/set-initial-password.service.abstraction.ts b/libs/angular/src/auth/password-management/set-initial-password/set-initial-password.service.abstraction.ts index 2667040c707..70318be3393 100644 --- a/libs/angular/src/auth/password-management/set-initial-password/set-initial-password.service.abstraction.ts +++ b/libs/angular/src/auth/password-management/set-initial-password/set-initial-password.service.abstraction.ts @@ -87,6 +87,8 @@ export interface InitializeJitPasswordCredentials { */ export abstract class SetInitialPasswordService { /** + * @deprecated To be removed in PM-28143 + * * Sets an initial password for an existing authed user who is either: * - {@link SetInitialPasswordUserType.JIT_PROVISIONED_MP_ORG_USER} * - {@link SetInitialPasswordUserType.TDE_ORG_USER_RESET_PASSWORD_PERMISSION_REQUIRES_MP} diff --git a/libs/auth/src/angular/input-password/input-password.component.ts b/libs/auth/src/angular/input-password/input-password.component.ts index 62294f037a0..b81e01156f1 100644 --- a/libs/auth/src/angular/input-password/input-password.component.ts +++ b/libs/auth/src/angular/input-password/input-password.component.ts @@ -10,7 +10,9 @@ import { import { AuditService } from "@bitwarden/common/abstractions/audit.service"; import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; import { MasterPasswordPolicyOptions } from "@bitwarden/common/admin-console/models/domain/master-password-policy-options"; +import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; import { MasterPasswordServiceAbstraction } from "@bitwarden/common/key-management/master-password/abstractions/master-password.service.abstraction"; +import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { ValidationService } from "@bitwarden/common/platform/abstractions/validation.service"; @@ -209,6 +211,7 @@ export class InputPasswordComponent implements OnInit { constructor( private auditService: AuditService, private cipherService: CipherService, + private configService: ConfigService, private dialogService: DialogService, private formBuilder: FormBuilder, private i18nService: I18nService, @@ -312,7 +315,7 @@ export class InputPasswordComponent implements OnInit { } if (!this.email) { - throw new Error("Email is required to create master key."); + throw new Error("Email not found."); } // 1. Determine kdfConfig @@ -320,13 +323,13 @@ export class InputPasswordComponent implements OnInit { this.kdfConfig = DEFAULT_KDF_CONFIG; } else { if (!this.userId) { - throw new Error("userId not passed down"); + throw new Error("userId not found."); } this.kdfConfig = await firstValueFrom(this.kdfConfigService.getKdfConfig$(this.userId)); } if (this.kdfConfig == null) { - throw new Error("KdfConfig is required to create master key."); + throw new Error("KdfConfig not found."); } const salt = @@ -334,7 +337,7 @@ export class InputPasswordComponent implements OnInit { ? await firstValueFrom(this.masterPasswordService.saltForUser$(this.userId)) : this.masterPasswordService.emailToSalt(this.email); if (salt == null) { - throw new Error("Salt is required to create master key."); + throw new Error("Salt not found."); } // 2. Verify current password is correct (if necessary) @@ -361,6 +364,41 @@ export class InputPasswordComponent implements OnInit { return; } + // When you unwind the flag in PM-28143, also remove the ConfigService if it is un-used. + const newApisWithInputPasswordFlagEnabled = await this.configService.getFeatureFlag( + FeatureFlag.PM27086_UpdateAuthenticationApisForInputPassword, + ); + + if (newApisWithInputPasswordFlagEnabled) { + // 4. Build a PasswordInputResult object + const passwordInputResult: PasswordInputResult = { + newPassword, + kdfConfig: this.kdfConfig, + salt, + newPasswordHint, + newApisWithInputPasswordFlagEnabled, // To be removed in PM-28143 + }; + + if ( + this.flow === InputPasswordFlow.ChangePassword || + this.flow === InputPasswordFlow.ChangePasswordWithOptionalUserKeyRotation + ) { + passwordInputResult.currentPassword = currentPassword; + } + + if (this.flow === InputPasswordFlow.ChangePasswordWithOptionalUserKeyRotation) { + passwordInputResult.rotateUserKey = this.formGroup.controls.rotateUserKey?.value; + } + + // 5. Emit and return PasswordInputResult object + this.onPasswordFormSubmit.emit(passwordInputResult); + return passwordInputResult; + } + + /******************************************************************* + * The following code (within this `try`) to be removed in PM-28143 + *******************************************************************/ + // 4. Create cryptographic keys and build a PasswordInputResult object const newMasterKey = await this.keyService.makeMasterKey( newPassword, diff --git a/libs/auth/src/angular/input-password/input-password.mdx b/libs/auth/src/angular/input-password/input-password.mdx index e3cdcbf08b9..4b174658d16 100644 --- a/libs/auth/src/angular/input-password/input-password.mdx +++ b/libs/auth/src/angular/input-password/input-password.mdx @@ -6,14 +6,12 @@ import * as stories from "./input-password.stories.ts"; # InputPassword Component -The `InputPasswordComponent` allows a user to enter master password related credentials. -Specifically, it does the following: +The `InputPasswordComponent` allows a user to enter a new master password for the purpose of setting +an initial password or changing an existing password. Specifically, it does the following: 1. Displays form fields in the UI 2. Validates form fields -3. Generates cryptographic properties based on the form inputs (e.g. `newMasterKey`, - `newServerMasterKeyHash`, etc.) -4. Emits the generated properties to the parent component +3. Emits values to the parent component The `InputPasswordComponent` is central to our set/change password flows, allowing us to keep our form UI and validation logic consistent. As such, it is intended for re-use in different set/change @@ -30,7 +28,6 @@ those values as needed. - [The InputPasswordFlow](#the-inputpasswordflow) - [Use Cases](#use-cases) - [HTML - Form Fields](#html---form-fields) - - [TypeScript - Credential Generation](#typescript---credential-generation) - [Difference between SetInitialPasswordAccountRegistration and SetInitialPasswordAuthedUser](#difference-between-setinitialpasswordaccountregistration-and-setinitialpasswordautheduser) - [Validation](#validation) - [Submit Logic](#submit-logic) @@ -44,20 +41,20 @@ those values as needed. **Required** - `flow` - the parent component must provide an `InputPasswordFlow`, which is used to determine - which form input elements will be displayed in the UI and which cryptographic keys will be created - and emitted. [Click here](#the-inputpasswordflow) to learn more about the different - `InputPasswordFlow` options. + which form input elements will be displayed in the UI and which values will be emitted. + [Click here](#the-inputpasswordflow) to learn more about the different `InputPasswordFlow` + options. **Optional (sometimes)** -These two `@Inputs` are optional on some flows, but required on others. Therefore these `@Inputs` -are not marked as `{ required: true }`, but there _is_ component logic that ensures (requires) that -the `email` and/or `userId` is present in certain flows, while not present in other flows. +These `@Inputs` are optional on some flows, but required on others. Therefore these `@Inputs` are +not marked as `{ required: true }`, but there _is_ component logic that ensures (requires) that the +`email` and/or `userId` is present in certain flows, while not present in other flows. -- `email` - allows the `InputPasswordComponent` to generate a master key +- `email` - allows the `InputPasswordComponent` to use the email as a salt (if needed) - `userId` - allows the `InputPasswordComponent` to do things like get the user's `kdfConfig`, - verify that a current password is correct, and perform validation prior to user key rotation on - the parent + verify that a current password is correct, and perform validation prior to user key rotation (if + selected) on the parent **Optional** @@ -87,8 +84,7 @@ These `@Inputs` are truly optional. ## The `InputPasswordFlow` The `InputPasswordFlow` is a crucial and required `@Input` that influences both the HTML and the -credential generation logic of the component. It is important for the dev to understand when to use -each flow. +logic of the component. It is important for the dev to understand when to use each flow. ### Use Cases @@ -106,8 +102,9 @@ Used in scenarios where we do have an existing and authed user, and thus an acti - A "just-in-time" (JIT) provisioned user joins a master password (MP) encryption org and must set their initial password -- A "just-in-time" (JIT) provisioned user joins a trusted device encryption (TDE) org with a - starting role that requires them to have/set their initial password +- A "just-in-time" (JIT) provisioned user joins a trusted device encryption (TDE) org with the reset + password permission ("manage account recovery") from the start, which requires them to have/set + their initial password - A note on JIT provisioned user flows: - Even though a JIT provisioned user is a brand-new user who was “just” created, we consider them to be an “existing authed user” _from the perspective of the set-password flow_. This is @@ -117,8 +114,9 @@ Used in scenarios where we do have an existing and authed user, and thus an acti registration when a user reaches the `/finish-signup` or `/trial-initiation` page to set their initial password, their account does not yet exist in the database, and will only be created once they set an initial password. -- An existing user in a TDE org logs in after the org admin upgraded the user to a role that now - requires them to have/set their initial password +- An existing user in a TDE org logs in after an org admin upgraded the user to have the reset + password persmission ("manage account recovery"), which now requires the user to have/set their + initial password - An existing user logs in after their org admin offboarded the org from TDE, and the user must now have/set their initial password

@@ -126,7 +124,7 @@ Used in scenarios where we do have an existing and authed user, and thus an acti Used in scenarios where we simply want to offer the user the ability to change their password: -- User clicks an org email invite link an logs in with their password which does not meet the org's +- User clicks an org email invite link and logs in with their password which does not meet the org's policy requirements - User logs in with password that does not meet the org's policy requirements - User logs in after their password was reset via Account Recovery (and now they must change their @@ -156,26 +154,10 @@ which form field UI elements get displayed.
-### TypeScript - Credential Generation - -- **`SetInitialPasswordAccountRegistration`** and **`SetInitialPasswordAuthedUser`** - - These flows involve a user setting their password for the first time. Therefore on submit the - component will only generate new credentials (`newMasterKey`) and not current credentials - (`currentMasterKey`).

-- **`ChangePassword`** and **`ChangePasswordWithOptionalUserKeyRotation`** - - These flows both require the user to enter a current password along with a new password. - Therefore on submit the component will generate current credentials (`currentMasterKey`) along - with new credentials (`newMasterKey`).

-- **`ChangePasswordDelegation`** - - This flow does not generate any credentials, but simply validates the new password and emits it - up to the parent. - -
- ### Difference between `SetInitialPasswordAccountRegistration` and `SetInitialPasswordAuthedUser` -These two flows are similar in that they display the same form fields and only generate new -credentials, but we need to keep them separate for the following reasons: +These two flows are similar in that they display the same form fields, but we need to keep them +separate for the following reasons: - `SetInitialPasswordAccountRegistration` involves scenarios where we have no existing user, and **thus NO active account `userId`**: @@ -183,7 +165,7 @@ credentials, but we need to keep them separate for the following reasons: and **thus an active account `userId`**: The presence or absence of an active account `userId` is important because it determines how we get -the correct `kdfConfig` prior to key generation: +the correct `kdfConfig`: - If there is no `userId` passed down from the parent, we default to `DEFAULT_KDF_CONFIG` - If there is a `userId` passed down from the parent, we get the `kdfConfig` from state using the @@ -223,25 +205,16 @@ When the form is submitted, the `InputPasswordComponent` does the following in o checkbox) - Checks that the new password adheres to any enforced master password policies that were optionally passed down by the parent -2. Uses the form inputs to create cryptographic properties (`newMasterKey`, - `newServerMasterKeyHash`, etc.) -3. Emits those cryptographic properties up to the parent (along with other values defined in - `PasswordInputResult`) to be used by the parent as needed. +2. Emits values up to the parent (along with other values defined in `PasswordInputResult`) to be + used by the parent as needed. ```typescript export interface PasswordInputResult { currentPassword?: string; - currentMasterKey?: MasterKey; - currentServerMasterKeyHash?: string; - currentLocalMasterKeyHash?: string; - newPassword: string; - newPasswordHint?: string; - newMasterKey?: MasterKey; - newServerMasterKeyHash?: string; - newLocalMasterKeyHash?: string; - kdfConfig?: KdfConfig; + salt?: MasterPasswordSalt; + newPasswordHint?: string; rotateUserKey?: boolean; } ``` diff --git a/libs/auth/src/angular/input-password/input-password.stories.ts b/libs/auth/src/angular/input-password/input-password.stories.ts index 285ce94b269..9e3a6419d2a 100644 --- a/libs/auth/src/angular/input-password/input-password.stories.ts +++ b/libs/auth/src/angular/input-password/input-password.stories.ts @@ -10,6 +10,7 @@ import { PolicyService } from "@bitwarden/common/admin-console/abstractions/poli import { MasterPasswordPolicyOptions } from "@bitwarden/common/admin-console/models/domain/master-password-policy-options"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { MasterPasswordServiceAbstraction } from "@bitwarden/common/key-management/master-password/abstractions/master-password.service.abstraction"; +import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { ValidationService } from "@bitwarden/common/platform/abstractions/validation.service"; import { PasswordStrengthServiceAbstraction } from "@bitwarden/common/tools/password-strength"; @@ -59,6 +60,13 @@ export default { getAllDecrypted: () => Promise.resolve([]), }, }, + // Can remove ConfigService from component and stories in PM-28143 (if it is no longer used) + { + provide: ConfigService, + useValue: { + getFeatureFlag: () => false, // default to false since flag does not effect UI + }, + }, { provide: KdfConfigService, useValue: { diff --git a/libs/auth/src/angular/input-password/password-input-result.ts b/libs/auth/src/angular/input-password/password-input-result.ts index 11c8f0d274d..575302a9ee0 100644 --- a/libs/auth/src/angular/input-password/password-input-result.ts +++ b/libs/auth/src/angular/input-password/password-input-result.ts @@ -10,6 +10,20 @@ export interface PasswordInputResult { newPasswordHint?: string; rotateUserKey?: boolean; + /** + * Temporary property that persists the flag state through the entire set/change password process. + * This allows flows to consume this value instead of re-checking the flag state via ConfigService themselves. + * + * The ChangePasswordDelegation flows (Emergency Access Takeover and Account Recovery), however, only ever + * require a raw newPassword from the InputPasswordComponent regardless of whether the flag is on or off. + * Flagging for those 2 flows will be done via the ConfigService in their respective services. + * + * To be removed in PM-28143 + */ + newApisWithInputPasswordFlagEnabled?: boolean; + + // The deprecated properties below will be removed in PM-28143: https://bitwarden.atlassian.net/browse/PM-28143 + /** @deprecated This low-level cryptographic state will be removed. It will be replaced by high level calls to masterpassword service, in the consumers of this interface. */ currentMasterKey?: MasterKey; /** @deprecated */ diff --git a/libs/common/src/enums/feature-flag.enum.ts b/libs/common/src/enums/feature-flag.enum.ts index 9d9de56c608..dc960baae1d 100644 --- a/libs/common/src/enums/feature-flag.enum.ts +++ b/libs/common/src/enums/feature-flag.enum.ts @@ -18,6 +18,7 @@ export enum FeatureFlag { /* Auth */ PM23801_PrefetchPasswordPrelogin = "pm-23801-prefetch-password-prelogin", + PM27086_UpdateAuthenticationApisForInputPassword = "pm-27086-update-authentication-apis-for-input-password", SafariAccountSwitching = "pm-5594-safari-account-switching", /* Autofill */ @@ -137,6 +138,7 @@ export const DefaultFeatureFlagValue = { /* Auth */ [FeatureFlag.PM23801_PrefetchPasswordPrelogin]: FALSE, + [FeatureFlag.PM27086_UpdateAuthenticationApisForInputPassword]: FALSE, [FeatureFlag.SafariAccountSwitching]: FALSE, /* Billing */ From b5c3735808993433ae48efa3d96532bf32284030 Mon Sep 17 00:00:00 2001 From: Bernd Schoolmann Date: Sun, 1 Feb 2026 16:06:10 +0100 Subject: [PATCH 06/21] Revert "[deps] KM: Update Rust crate rsa to v0.9.10 [SECURITY] (#18220)" (#18693) This reverts commit bea6fb26f87e815062465646ab8ee6d0ca583fe0. --- apps/desktop/desktop_native/Cargo.lock | 9 +++++---- apps/desktop/desktop_native/Cargo.toml | 2 +- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/apps/desktop/desktop_native/Cargo.lock b/apps/desktop/desktop_native/Cargo.lock index 5ff105a70c3..2a30f98dc36 100644 --- a/apps/desktop/desktop_native/Cargo.lock +++ b/apps/desktop/desktop_native/Cargo.lock @@ -2115,10 +2115,11 @@ dependencies = [ [[package]] name = "num-bigint-dig" -version = "0.8.6" +version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e661dda6640fad38e827a6d4a310ff4763082116fe217f279885c97f511bb0b7" +checksum = "dc84195820f291c7697304f3cbdadd1cb7199c0efc917ff5eafd71225c136151" dependencies = [ + "byteorder", "lazy_static", "libm", "num-integer", @@ -2805,9 +2806,9 @@ dependencies = [ [[package]] name = "rsa" -version = "0.9.10" +version = "0.9.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8573f03f5883dcaebdfcf4725caa1ecb9c15b2ef50c43a07b816e06799bb12d" +checksum = "5d0e5124fcb30e76a7e79bfee683a2746db83784b86289f6251b54b7950a0dfc" dependencies = [ "const-oid", "digest", diff --git a/apps/desktop/desktop_native/Cargo.toml b/apps/desktop/desktop_native/Cargo.toml index 6bddb234f45..f63b09de7ff 100644 --- a/apps/desktop/desktop_native/Cargo.toml +++ b/apps/desktop/desktop_native/Cargo.toml @@ -50,7 +50,7 @@ oo7 = "=0.5.0" pin-project = "=1.1.10" pkcs8 = "=0.10.2" rand = "=0.9.2" -rsa = "=0.9.10" +rsa = "=0.9.6" russh-cryptovec = "=0.7.3" scopeguard = "=1.2.0" secmem-proc = "=0.3.7" From 590bec21663f8c480b619c094f7676cfdbc37f61 Mon Sep 17 00:00:00 2001 From: Bernd Schoolmann Date: Mon, 2 Feb 2026 14:35:49 +0100 Subject: [PATCH 07/21] Fix rsa signing and add unit tests (#18702) * Fix rsa signing and add unit tests * Fix sorting * Fix sorting --- apps/desktop/desktop_native/Cargo.lock | 1 + apps/desktop/desktop_native/core/Cargo.toml | 4 + .../desktop_native/core/src/ssh_agent/mod.rs | 125 ++++++++++++++++++ 3 files changed, 130 insertions(+) diff --git a/apps/desktop/desktop_native/Cargo.lock b/apps/desktop/desktop_native/Cargo.lock index 2a30f98dc36..6dab7721f6d 100644 --- a/apps/desktop/desktop_native/Cargo.lock +++ b/apps/desktop/desktop_native/Cargo.lock @@ -918,6 +918,7 @@ dependencies = [ "oo7", "pin-project", "rand 0.9.2", + "rsa", "scopeguard", "secmem-proc", "security-framework", diff --git a/apps/desktop/desktop_native/core/Cargo.toml b/apps/desktop/desktop_native/core/Cargo.toml index aa5d564c9e5..6dfe0487ed0 100644 --- a/apps/desktop/desktop_native/core/Cargo.toml +++ b/apps/desktop/desktop_native/core/Cargo.toml @@ -31,6 +31,7 @@ futures = { workspace = true } interprocess = { workspace = true, features = ["tokio"] } memsec = { workspace = true, features = ["alloc_ext"] } rand = { workspace = true } +rsa = "=0.9.6" sha2 = { workspace = true } ssh-key = { workspace = true, features = [ "encryption", @@ -85,5 +86,8 @@ windows = { workspace = true, features = [ ], optional = true } windows-future = { workspace = true } +[dev-dependencies] +tokio = { workspace = true, features = ["rt-multi-thread", "macros"] } + [lints] workspace = true diff --git a/apps/desktop/desktop_native/core/src/ssh_agent/mod.rs b/apps/desktop/desktop_native/core/src/ssh_agent/mod.rs index 8ba64618ffa..a8938acb992 100644 --- a/apps/desktop/desktop_native/core/src/ssh_agent/mod.rs +++ b/apps/desktop/desktop_native/core/src/ssh_agent/mod.rs @@ -307,3 +307,128 @@ fn parse_key_safe(pem: &str) -> Result Err(anyhow::Error::msg(format!("Failed to parse key: {e}"))), } } + +#[cfg(test)] +mod tests { + use std::sync::atomic::Ordering; + + use ssh_key::Signature; + + use super::*; + + // Test Ed25519 key (unencrypted OpenSSH format) + const TEST_ED25519_KEY: &str = "-----BEGIN OPENSSH PRIVATE KEY----- +b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW +QyNTUxOQAAACAOYor3+kyAsXYs2sGikmUuhpxmVf2hAGd2TK7KwN4N9gAAAJj79ujB+/bo +wQAAAAtzc2gtZWQyNTUxOQAAACAOYor3+kyAsXYs2sGikmUuhpxmVf2hAGd2TK7KwN4N9g +AAAEAgAQkLDKjON00XO+Y09BoIBuQsAXAx6HUhQoTEodVzig5iivf6TICxdizawaKSZS6G +nGZV/aEAZ3ZMrsrA3g32AAAAEHRlc3RAZXhhbXBsZS5jb20BAgMEBQ== +-----END OPENSSH PRIVATE KEY-----"; + + // Test RSA 2048-bit key (unencrypted OpenSSH format) + const TEST_RSA_KEY: &str = "-----BEGIN OPENSSH PRIVATE KEY----- +b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAABFwAAAAdzc2gtcn +NhAAAAAwEAAQAAAQEAy0YUFvgBLMZXIKjsBfcdO6N2Kk2VmjSpxa2aFD1TrAcVyyIZ9v8o +slQITyFL4GCK5VCJX9bqXBwc9ml8G/zt21ue6nadeZLhp2iXeQ+VUxmola9HhaFvxSNqi0 +MOJaWIfmisH4jt7Msdv4jwlDE5AkHAFig8wiwDgvSV3kmfhyPs38aq8Pa+wT3zBneGXT17 +34OhH4nicuq+L0GcR9BJQ5+jXNQIgGdqd7sKa8JchPXLXAbTug2SfwRmKgiCM0L6JQ5NSQ +FdRHW/iz4ARacSkHP3w0pH6ZtAd8+glzvZn1KcXwrN/CYl3fqFwiwcQXIF0KDoOI/UyiKZ +uDE+DW5M1wAAA8g2Sf0XNkn9FwAAAAdzc2gtcnNhAAABAQDLRhQW+AEsxlcgqOwF9x07o3 +YqTZWaNKnFrZoUPVOsBxXLIhn2/yiyVAhPIUvgYIrlUIlf1upcHBz2aXwb/O3bW57qdp15 +kuGnaJd5D5VTGaiVr0eFoW/FI2qLQw4lpYh+aKwfiO3syx2/iPCUMTkCQcAWKDzCLAOC9J +XeSZ+HI+zfxqrw9r7BPfMGd4ZdPXvfg6EfieJy6r4vQZxH0ElDn6Nc1AiAZ2p3uwprwlyE +9ctcBtO6DZJ/BGYqCIIzQvolDk1JAV1Edb+LPgBFpxKQc/fDSkfpm0B3z6CXO9mfUpxfCs +38JiXd+oXCLBxBcgXQoOg4j9TKIpm4MT4NbkzXAAAAAwEAAQAAAQB9HWssIAYJGyNxlMeB +fHJfzOLkctCME7ITXCEkKAMiNVIyr5CvuKnB6XsbyXC8cG/NaV7EwLGLdDpXaOHdEDcO9z +u/MLcIp2GA+x2QhAjzFy3uw+4P0CfNfVkM0n8YqOR0edTHrC5Vu0daJt19OTbPrsyeVrHf +Cdw3dHfyU/p+4IMP9NRA5ZSmYuOacC7ZoZU7xeVBpeZ4KEzrO98iIWtscncaQv4AcaAehL +VpvZWG1QmRhdbooU2ce5KH3aFKiyszcMGPMzn4aTZS14ycLFzmrMSa+nYf+nHXmyR5KmBd +A5P6ZLtcpT1xw6CC/ItRsdD7E67bugG38lgQpzloHAsRAAAAgBVKGMFi+lP+HKYdSzPAQN +n3HxVuuZ5VIjM6Rq2SxfdyGKj5PH4+ofNGBrF5j1du1oqfPypMM/B75bkBNOlzn6TQcgyX +YlsVOF31aE1hRg8eN1BH2bc1DC43MyTHgunAFzIYfs1hbX8i+cMybzXSTDsIc/xvQHkJ2w +TrPuz7+MATAAAAgQDk6e4ywxrINaOcuDKmRQxTs7rlkJk/tX59OkkqD/gYLMBRMfeKeuFD +Y8M1f5vlDkGFD/Jy0RtTfEJh02VjKTrszaaGCDFHe9tt6DAHY457tzr856zsq5hKDFEU0+ +jd+yE8QaloegGrcpujrxHnrpZx/7mA2qjQxLveHyCGWH3Q2wAAAIEA41N7DKxeb0doXai7 +Sl8+RpZBoyCyNkexWKHAeATKb4abd+k5/EEoLAb6aKaGMzMPm+s82l0lozVreKvHdAdZsY +fq1lhaVvnRWZhN/DXf7Akgicrg/TLqHH9w6db0Vg5A+zHmbkUzZ4A30CYIgn4vzVv5YIq3 +CmfliIQWtUylhrUAAAAQdGVzdEBleGFtcGxlLmNvbQECAw== +-----END OPENSSH PRIVATE KEY-----"; + + fn create_test_agent() -> ( + BitwardenDesktopAgent, + tokio::sync::mpsc::Receiver, + tokio::sync::broadcast::Sender<(u32, bool)>, + ) { + let (request_tx, request_rx) = tokio::sync::mpsc::channel::(16); + let (response_tx, response_rx) = tokio::sync::broadcast::channel::<(u32, bool)>(16); + let response_rx = Arc::new(Mutex::new(response_rx)); + + let agent = BitwardenDesktopAgent::new(request_tx, response_rx); + (agent, request_rx, response_tx) + } + + #[tokio::test] + async fn test_agent_sign_with_ed25519_key() { + let (mut agent, _request_rx, _response_tx) = create_test_agent(); + agent.is_running.store(true, Ordering::Relaxed); + + let keys = vec![( + TEST_ED25519_KEY.to_string(), + "ed25519-key".to_string(), + "ed25519-uuid".to_string(), + )]; + agent.set_keys(keys).expect("set_keys should succeed"); + + let keystore = agent.keystore.0.read().expect("RwLock is not poisoned"); + assert_eq!(keystore.len(), 1); + let (_pub_bytes, ssh_key) = keystore.iter().next().expect("should have one key"); + + // Verify the key metadata + assert_eq!(ssh_key.name, "ed25519-key"); + assert_eq!(ssh_key.cipher_uuid, "ed25519-uuid"); + + // Verify the key can sign data + let signing_key = ssh_key.private_key().expect("should have signing key"); + let message = b"test message for ed25519"; + let signature: Signature = signing_key.try_sign(message).expect("signing should work"); + + // Verify signature is non-empty and has expected algorithm + assert!(!signature.as_bytes().is_empty()); + assert_eq!(signature.algorithm(), ssh_key::Algorithm::Ed25519); + } + + #[tokio::test] + async fn test_agent_sign_with_rsa_key() { + let (mut agent, _request_rx, _response_tx) = create_test_agent(); + agent.is_running.store(true, Ordering::Relaxed); + + let keys = vec![( + TEST_RSA_KEY.to_string(), + "rsa-key".to_string(), + "rsa-uuid".to_string(), + )]; + agent.set_keys(keys).expect("set_keys should succeed"); + + let keystore = agent.keystore.0.read().expect("RwLock is not poisoned"); + assert_eq!(keystore.len(), 1); + let (_pub_bytes, ssh_key) = keystore.iter().next().expect("should have one key"); + + // Verify the key metadata + assert_eq!(ssh_key.name, "rsa-key"); + assert_eq!(ssh_key.cipher_uuid, "rsa-uuid"); + + // Verify the key can sign data + let signing_key = ssh_key.private_key().expect("should have signing key"); + let message = b"test message for rsa"; + let signature: Signature = signing_key.try_sign(message).expect("signing should work"); + + // Verify signature is non-empty and has expected algorithm + assert!(!signature.as_bytes().is_empty()); + assert_eq!( + signature.algorithm(), + ssh_key::Algorithm::Rsa { + hash: Some(ssh_key::HashAlg::Sha512) + } + ); + } +} From 2c30fb72bae09dfc1df452bac5d215802479af7f Mon Sep 17 00:00:00 2001 From: Vijay Oommen Date: Mon, 2 Feb 2026 10:05:02 -0600 Subject: [PATCH 08/21] [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 15/21] [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 16/21] 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 17/21] 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 18/21] [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 19/21] [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 20/21] [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 21/21] [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}