From 4a4ce8312c4754968a4ea19fde01c3dc9034521b Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Tue, 2 Dec 2025 10:29:11 +0000 Subject: [PATCH 001/131] [deps]: Update Swatinem/rust-cache action to v2.8.2 (#17716) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Co-authored-by: Daniel James Smith <2670567+djsmith85@users.noreply.github.com> --- .github/workflows/build-desktop.yml | 14 +++++++------- .github/workflows/test.yml | 2 +- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/.github/workflows/build-desktop.yml b/.github/workflows/build-desktop.yml index 03a09ac8c48..949263b34b7 100644 --- a/.github/workflows/build-desktop.yml +++ b/.github/workflows/build-desktop.yml @@ -186,7 +186,7 @@ jobs: node-version: ${{ env._NODE_VERSION }} - name: Cache Rust dependencies - uses: Swatinem/rust-cache@f13886b937689c021905a6b90929199931d60db1 # v2.8.1 + uses: Swatinem/rust-cache@779680da715d629ac1d338a641029a2f4372abb5 # v2.8.2 with: workspaces: | apps/desktop/desktop_native -> target @@ -342,7 +342,7 @@ jobs: node-version: ${{ env._NODE_VERSION }} - name: Cache Rust dependencies - uses: Swatinem/rust-cache@f13886b937689c021905a6b90929199931d60db1 # v2.8.1 + uses: Swatinem/rust-cache@779680da715d629ac1d338a641029a2f4372abb5 # v2.8.2 with: workspaces: | apps/desktop/desktop_native -> target @@ -490,7 +490,7 @@ jobs: node-version: ${{ env._NODE_VERSION }} - name: Cache Rust dependencies - uses: Swatinem/rust-cache@f13886b937689c021905a6b90929199931d60db1 # v2.8.1 + uses: Swatinem/rust-cache@779680da715d629ac1d338a641029a2f4372abb5 # v2.8.2 with: workspaces: | apps/desktop/desktop_native -> target @@ -756,7 +756,7 @@ jobs: node-version: ${{ env._NODE_VERSION }} - name: Cache Rust dependencies - uses: Swatinem/rust-cache@f13886b937689c021905a6b90929199931d60db1 # v2.8.1 + uses: Swatinem/rust-cache@779680da715d629ac1d338a641029a2f4372abb5 # v2.8.2 with: workspaces: | apps/desktop/desktop_native -> target @@ -1007,7 +1007,7 @@ jobs: run: python3 -m pip install setuptools - name: Cache Rust dependencies - uses: Swatinem/rust-cache@f13886b937689c021905a6b90929199931d60db1 # v2.8.1 + uses: Swatinem/rust-cache@779680da715d629ac1d338a641029a2f4372abb5 # v2.8.2 with: workspaces: | apps/desktop/desktop_native -> target @@ -1244,7 +1244,7 @@ jobs: run: python3 -m pip install setuptools - name: Cache Rust dependencies - uses: Swatinem/rust-cache@f13886b937689c021905a6b90929199931d60db1 # v2.8.1 + uses: Swatinem/rust-cache@779680da715d629ac1d338a641029a2f4372abb5 # v2.8.2 with: workspaces: | apps/desktop/desktop_native -> target @@ -1516,7 +1516,7 @@ jobs: run: python3 -m pip install setuptools - name: Cache Rust dependencies - uses: Swatinem/rust-cache@f13886b937689c021905a6b90929199931d60db1 # v2.8.1 + uses: Swatinem/rust-cache@779680da715d629ac1d338a641029a2f4372abb5 # v2.8.2 with: workspaces: | apps/desktop/desktop_native -> target diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index f53bfc39d36..faee7220e7b 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -148,7 +148,7 @@ jobs: components: llvm-tools - name: Cache cargo registry - uses: Swatinem/rust-cache@f13886b937689c021905a6b90929199931d60db1 # v2.8.1 + uses: Swatinem/rust-cache@779680da715d629ac1d338a641029a2f4372abb5 # v2.8.2 with: workspaces: "apps/desktop/desktop_native -> target" From 2510844293565587599caeecbaa5a39e39c09f5f Mon Sep 17 00:00:00 2001 From: Alex Morask <144709477+amorask-bitwarden@users.noreply.github.com> Date: Tue, 2 Dec 2025 07:19:41 -0600 Subject: [PATCH 002/131] Clear premium interest on upgrade dialog open (#17518) --- .../unified-upgrade-dialog.component.spec.ts | 348 ++++++++---------- .../unified-upgrade-dialog.component.ts | 31 +- 2 files changed, 181 insertions(+), 198 deletions(-) diff --git a/apps/web/src/app/billing/individual/upgrade/unified-upgrade-dialog/unified-upgrade-dialog.component.spec.ts b/apps/web/src/app/billing/individual/upgrade/unified-upgrade-dialog/unified-upgrade-dialog.component.spec.ts index 7f698ae50d1..b28a7b8c4a2 100644 --- a/apps/web/src/app/billing/individual/upgrade/unified-upgrade-dialog/unified-upgrade-dialog.component.spec.ts +++ b/apps/web/src/app/billing/individual/upgrade/unified-upgrade-dialog/unified-upgrade-dialog.component.spec.ts @@ -1,4 +1,4 @@ -import { Component, input, output } from "@angular/core"; +import { ChangeDetectionStrategy, Component, input, output } from "@angular/core"; import { ComponentFixture, TestBed } from "@angular/core/testing"; import { NoopAnimationsModule } from "@angular/platform-browser/animations"; import { Router } from "@angular/router"; @@ -28,12 +28,11 @@ import { UnifiedUpgradeDialogStep, } from "./unified-upgrade-dialog.component"; -// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush -// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection @Component({ selector: "app-upgrade-account", template: "", standalone: true, + changeDetection: ChangeDetectionStrategy.OnPush, }) class MockUpgradeAccountComponent { readonly dialogTitleMessageOverride = input(null); @@ -42,12 +41,11 @@ class MockUpgradeAccountComponent { closeClicked = output(); } -// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush -// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection @Component({ selector: "app-upgrade-payment", template: "", standalone: true, + changeDetection: ChangeDetectionStrategy.OnPush, }) class MockUpgradePaymentComponent { readonly selectedPlanId = input(null); @@ -77,10 +75,56 @@ describe("UnifiedUpgradeDialogComponent", () => { planSelectionStepTitleOverride: null, }; + /** + * Helper function to create and configure a fresh component instance with custom dialog data + */ + async function createComponentWithDialogData( + dialogData: UnifiedUpgradeDialogParams, + waitForStable = false, + ): Promise<{ + fixture: ComponentFixture; + component: UnifiedUpgradeDialogComponent; + }> { + TestBed.resetTestingModule(); + jest.clearAllMocks(); + + await TestBed.configureTestingModule({ + imports: [NoopAnimationsModule, UnifiedUpgradeDialogComponent], + providers: [ + { provide: DialogRef, useValue: mockDialogRef }, + { provide: DIALOG_DATA, useValue: dialogData }, + { provide: Router, useValue: mockRouter }, + { provide: PremiumInterestStateService, useValue: mockPremiumInterestStateService }, + ], + }) + .overrideComponent(UnifiedUpgradeDialogComponent, { + remove: { + imports: [UpgradeAccountComponent, UpgradePaymentComponent], + }, + add: { + imports: [MockUpgradeAccountComponent, MockUpgradePaymentComponent], + }, + }) + .compileComponents(); + + const newFixture = TestBed.createComponent(UnifiedUpgradeDialogComponent); + const newComponent = newFixture.componentInstance; + newFixture.detectChanges(); + + if (waitForStable) { + await newFixture.whenStable(); + } + + return { fixture: newFixture, component: newComponent }; + } + beforeEach(async () => { // Reset mocks jest.clearAllMocks(); + // Default mock: no premium interest + mockPremiumInterestStateService.getPremiumInterest.mockResolvedValue(false); + await TestBed.configureTestingModule({ imports: [NoopAnimationsModule, UnifiedUpgradeDialogComponent], providers: [ @@ -117,49 +161,63 @@ describe("UnifiedUpgradeDialogComponent", () => { }); it("should initialize with custom initial step", async () => { - TestBed.resetTestingModule(); - const customDialogData: UnifiedUpgradeDialogParams = { account: mockAccount, initialStep: UnifiedUpgradeDialogStep.Payment, selectedPlan: PersonalSubscriptionPricingTierIds.Premium, }; - await TestBed.configureTestingModule({ - imports: [NoopAnimationsModule, UnifiedUpgradeDialogComponent], - providers: [ - { provide: DialogRef, useValue: mockDialogRef }, - { provide: DIALOG_DATA, useValue: customDialogData }, - { provide: Router, useValue: mockRouter }, - { provide: PremiumInterestStateService, useValue: mockPremiumInterestStateService }, - ], - }) - .overrideComponent(UnifiedUpgradeDialogComponent, { - remove: { - imports: [UpgradeAccountComponent, UpgradePaymentComponent], - }, - add: { - imports: [MockUpgradeAccountComponent, MockUpgradePaymentComponent], - }, - }) - .compileComponents(); - - const customFixture = TestBed.createComponent(UnifiedUpgradeDialogComponent); - const customComponent = customFixture.componentInstance; - customFixture.detectChanges(); + const { component: customComponent } = await createComponentWithDialogData(customDialogData); expect(customComponent["step"]()).toBe(UnifiedUpgradeDialogStep.Payment); expect(customComponent["selectedPlan"]()).toBe(PersonalSubscriptionPricingTierIds.Premium); }); + describe("ngOnInit premium interest handling", () => { + it("should check premium interest on initialization", async () => { + // Component already initialized in beforeEach + expect(mockPremiumInterestStateService.getPremiumInterest).toHaveBeenCalledWith( + mockAccount.id, + ); + }); + + it("should set hasPremiumInterest signal and clear premium interest when it exists", async () => { + mockPremiumInterestStateService.getPremiumInterest.mockResolvedValue(true); + mockPremiumInterestStateService.clearPremiumInterest.mockResolvedValue(undefined); + + const { component: customComponent } = await createComponentWithDialogData( + defaultDialogData, + true, + ); + + expect(mockPremiumInterestStateService.getPremiumInterest).toHaveBeenCalledWith( + mockAccount.id, + ); + expect(mockPremiumInterestStateService.clearPremiumInterest).toHaveBeenCalledWith( + mockAccount.id, + ); + expect(customComponent["hasPremiumInterest"]()).toBe(true); + }); + + it("should not set hasPremiumInterest signal or clear when premium interest does not exist", async () => { + mockPremiumInterestStateService.getPremiumInterest.mockResolvedValue(false); + + const { component: customComponent } = await createComponentWithDialogData(defaultDialogData); + + expect(mockPremiumInterestStateService.getPremiumInterest).toHaveBeenCalledWith( + mockAccount.id, + ); + expect(mockPremiumInterestStateService.clearPremiumInterest).not.toHaveBeenCalled(); + expect(customComponent["hasPremiumInterest"]()).toBe(false); + }); + }); + describe("custom dialog title", () => { it("should use null as default when no override is provided", () => { expect(component["planSelectionStepTitleOverride"]()).toBeNull(); }); it("should use custom title when provided in dialog config", async () => { - TestBed.resetTestingModule(); - const customDialogData: UnifiedUpgradeDialogParams = { account: mockAccount, initialStep: UnifiedUpgradeDialogStep.PlanSelection, @@ -167,28 +225,7 @@ describe("UnifiedUpgradeDialogComponent", () => { planSelectionStepTitleOverride: "upgradeYourPlan", }; - await TestBed.configureTestingModule({ - imports: [NoopAnimationsModule, UnifiedUpgradeDialogComponent], - providers: [ - { provide: DialogRef, useValue: mockDialogRef }, - { provide: DIALOG_DATA, useValue: customDialogData }, - { provide: Router, useValue: mockRouter }, - { provide: PremiumInterestStateService, useValue: mockPremiumInterestStateService }, - ], - }) - .overrideComponent(UnifiedUpgradeDialogComponent, { - remove: { - imports: [UpgradeAccountComponent, UpgradePaymentComponent], - }, - add: { - imports: [MockUpgradeAccountComponent, MockUpgradePaymentComponent], - }, - }) - .compileComponents(); - - const customFixture = TestBed.createComponent(UnifiedUpgradeDialogComponent); - const customComponent = customFixture.componentInstance; - customFixture.detectChanges(); + const { component: customComponent } = await createComponentWithDialogData(customDialogData); expect(customComponent["planSelectionStepTitleOverride"]()).toBe("upgradeYourPlan"); }); @@ -221,8 +258,6 @@ describe("UnifiedUpgradeDialogComponent", () => { }); it("should be set to true when provided in dialog config", async () => { - TestBed.resetTestingModule(); - const customDialogData: UnifiedUpgradeDialogParams = { account: mockAccount, initialStep: null, @@ -230,108 +265,32 @@ describe("UnifiedUpgradeDialogComponent", () => { hideContinueWithoutUpgradingButton: true, }; - await TestBed.configureTestingModule({ - imports: [NoopAnimationsModule, UnifiedUpgradeDialogComponent], - providers: [ - { provide: DialogRef, useValue: mockDialogRef }, - { provide: DIALOG_DATA, useValue: customDialogData }, - { provide: Router, useValue: mockRouter }, - { provide: PremiumInterestStateService, useValue: mockPremiumInterestStateService }, - ], - }) - .overrideComponent(UnifiedUpgradeDialogComponent, { - remove: { - imports: [UpgradeAccountComponent, UpgradePaymentComponent], - }, - add: { - imports: [MockUpgradeAccountComponent, MockUpgradePaymentComponent], - }, - }) - .compileComponents(); - - const customFixture = TestBed.createComponent(UnifiedUpgradeDialogComponent); - const customComponent = customFixture.componentInstance; - customFixture.detectChanges(); + const { component: customComponent } = await createComponentWithDialogData(customDialogData); expect(customComponent["hideContinueWithoutUpgradingButton"]()).toBe(true); }); }); - describe("onComplete with premium interest", () => { - it("should check premium interest, clear it, and route to /vault when premium interest exists", async () => { + describe("onComplete", () => { + it("should route to /vault when upgrading to premium with premium interest", async () => { + // Set up component with premium interest mockPremiumInterestStateService.getPremiumInterest.mockResolvedValue(true); - mockPremiumInterestStateService.clearPremiumInterest.mockResolvedValue(); + mockPremiumInterestStateService.clearPremiumInterest.mockResolvedValue(undefined); mockRouter.navigate.mockResolvedValue(true); - const result: UpgradePaymentResult = { - status: "upgradedToPremium", - organizationId: null, - }; - - await component["onComplete"](result); + const { component: customComponent } = await createComponentWithDialogData( + defaultDialogData, + true, + ); + // Premium interest should be set and cleared during ngOnInit expect(mockPremiumInterestStateService.getPremiumInterest).toHaveBeenCalledWith( mockAccount.id, ); expect(mockPremiumInterestStateService.clearPremiumInterest).toHaveBeenCalledWith( mockAccount.id, ); - expect(mockRouter.navigate).toHaveBeenCalledWith(["/vault"]); - expect(mockDialogRef.close).toHaveBeenCalledWith({ - status: "upgradedToPremium", - organizationId: null, - }); - }); - - it("should not clear premium interest when upgrading to families", async () => { - const result: UpgradePaymentResult = { - status: "upgradedToFamilies", - organizationId: "org-123", - }; - - await component["onComplete"](result); - - expect(mockPremiumInterestStateService.getPremiumInterest).not.toHaveBeenCalled(); - expect(mockPremiumInterestStateService.clearPremiumInterest).not.toHaveBeenCalled(); - expect(mockDialogRef.close).toHaveBeenCalledWith({ - status: "upgradedToFamilies", - organizationId: "org-123", - }); - }); - - it("should use standard redirect when no premium interest exists", async () => { - TestBed.resetTestingModule(); - - const customDialogData: UnifiedUpgradeDialogParams = { - account: mockAccount, - redirectOnCompletion: true, - }; - - mockPremiumInterestStateService.getPremiumInterest.mockResolvedValue(false); - mockRouter.navigate.mockResolvedValue(true); - - await TestBed.configureTestingModule({ - imports: [NoopAnimationsModule, UnifiedUpgradeDialogComponent], - providers: [ - { provide: DialogRef, useValue: mockDialogRef }, - { provide: DIALOG_DATA, useValue: customDialogData }, - { provide: Router, useValue: mockRouter }, - { provide: PremiumInterestStateService, useValue: mockPremiumInterestStateService }, - ], - }) - .overrideComponent(UnifiedUpgradeDialogComponent, { - remove: { - imports: [UpgradeAccountComponent, UpgradePaymentComponent], - }, - add: { - imports: [MockUpgradeAccountComponent, MockUpgradePaymentComponent], - }, - }) - .compileComponents(); - - const customFixture = TestBed.createComponent(UnifiedUpgradeDialogComponent); - const customComponent = customFixture.componentInstance; - customFixture.detectChanges(); + expect(customComponent["hasPremiumInterest"]()).toBe(true); const result: UpgradePaymentResult = { status: "upgradedToPremium", @@ -340,10 +299,55 @@ describe("UnifiedUpgradeDialogComponent", () => { await customComponent["onComplete"](result); - expect(mockPremiumInterestStateService.getPremiumInterest).toHaveBeenCalledWith( - mockAccount.id, - ); - expect(mockPremiumInterestStateService.clearPremiumInterest).not.toHaveBeenCalled(); + // Should route to /vault because hasPremiumInterest signal is true + // No additional service calls should be made in onComplete + expect(mockPremiumInterestStateService.getPremiumInterest).toHaveBeenCalledTimes(1); // Only from ngOnInit + expect(mockPremiumInterestStateService.clearPremiumInterest).toHaveBeenCalledTimes(1); // Only from ngOnInit + expect(mockRouter.navigate).toHaveBeenCalledWith(["/vault"]); + expect(mockDialogRef.close).toHaveBeenCalledWith({ + status: "upgradedToPremium", + organizationId: null, + }); + }); + + it("should close dialog when upgrading to families (premium interest not relevant)", async () => { + const result: UpgradePaymentResult = { + status: "upgradedToFamilies", + organizationId: "org-123", + }; + + await component["onComplete"](result); + + // Premium interest logic only runs for premium upgrades, not families + expect(mockDialogRef.close).toHaveBeenCalledWith({ + status: "upgradedToFamilies", + organizationId: "org-123", + }); + }); + + it("should use standard redirect when upgrading to premium without premium interest", async () => { + const customDialogData: UnifiedUpgradeDialogParams = { + account: mockAccount, + redirectOnCompletion: true, + }; + + // No premium interest + mockPremiumInterestStateService.getPremiumInterest.mockResolvedValue(false); + mockRouter.navigate.mockResolvedValue(true); + + const { component: customComponent } = await createComponentWithDialogData(customDialogData); + + // Verify no premium interest was set during ngOnInit + expect(customComponent["hasPremiumInterest"]()).toBe(false); + + const result: UpgradePaymentResult = { + status: "upgradedToPremium", + organizationId: null, + }; + + await customComponent["onComplete"](result); + + // Should use standard redirect because hasPremiumInterest signal is false expect(mockRouter.navigate).toHaveBeenCalledWith([ "/settings/subscription/user-subscription", ]); @@ -354,70 +358,44 @@ describe("UnifiedUpgradeDialogComponent", () => { }); }); - describe("onCloseClicked with premium interest", () => { - it("should clear premium interest when modal is closed", async () => { - mockPremiumInterestStateService.clearPremiumInterest.mockResolvedValue(); - + describe("onCloseClicked", () => { + it("should close dialog without clearing premium interest (cleared in ngOnInit)", async () => { await component["onCloseClicked"](); - expect(mockPremiumInterestStateService.clearPremiumInterest).toHaveBeenCalledWith( - mockAccount.id, - ); + // Premium interest should have been cleared only once during ngOnInit, not again here + expect(mockPremiumInterestStateService.clearPremiumInterest).toHaveBeenCalledTimes(0); expect(mockDialogRef.close).toHaveBeenCalledWith({ status: "closed" }); }); }); - describe("previousStep with premium interest", () => { - it("should NOT clear premium interest when navigating between steps", async () => { + describe("previousStep", () => { + it("should go back to plan selection when on payment step", async () => { component["step"].set(UnifiedUpgradeDialogStep.Payment); component["selectedPlan"].set(PersonalSubscriptionPricingTierIds.Premium); await component["previousStep"](); - expect(mockPremiumInterestStateService.clearPremiumInterest).not.toHaveBeenCalled(); expect(component["step"]()).toBe(UnifiedUpgradeDialogStep.PlanSelection); expect(component["selectedPlan"]()).toBeNull(); + expect(mockPremiumInterestStateService.clearPremiumInterest).toHaveBeenCalledTimes(0); }); - it("should clear premium interest when backing out of dialog completely", async () => { - TestBed.resetTestingModule(); - + it("should close dialog when backing out from plan selection step (no premium interest cleared)", async () => { const customDialogData: UnifiedUpgradeDialogParams = { account: mockAccount, initialStep: UnifiedUpgradeDialogStep.Payment, selectedPlan: PersonalSubscriptionPricingTierIds.Premium, }; - mockPremiumInterestStateService.clearPremiumInterest.mockResolvedValue(); + mockPremiumInterestStateService.getPremiumInterest.mockResolvedValue(false); - await TestBed.configureTestingModule({ - imports: [NoopAnimationsModule, UnifiedUpgradeDialogComponent], - providers: [ - { provide: DialogRef, useValue: mockDialogRef }, - { provide: DIALOG_DATA, useValue: customDialogData }, - { provide: Router, useValue: mockRouter }, - { provide: PremiumInterestStateService, useValue: mockPremiumInterestStateService }, - ], - }) - .overrideComponent(UnifiedUpgradeDialogComponent, { - remove: { - imports: [UpgradeAccountComponent, UpgradePaymentComponent], - }, - add: { - imports: [MockUpgradeAccountComponent, MockUpgradePaymentComponent], - }, - }) - .compileComponents(); - - const customFixture = TestBed.createComponent(UnifiedUpgradeDialogComponent); - const customComponent = customFixture.componentInstance; - customFixture.detectChanges(); + const { component: customComponent } = await createComponentWithDialogData(customDialogData); + // Start at payment step, go back once to reach plan selection, then go back again to close await customComponent["previousStep"](); - expect(mockPremiumInterestStateService.clearPremiumInterest).toHaveBeenCalledWith( - mockAccount.id, - ); + // Premium interest cleared only in ngOnInit, not in previousStep + expect(mockPremiumInterestStateService.clearPremiumInterest).toHaveBeenCalledTimes(0); expect(mockDialogRef.close).toHaveBeenCalledWith({ status: "closed" }); }); }); diff --git a/apps/web/src/app/billing/individual/upgrade/unified-upgrade-dialog/unified-upgrade-dialog.component.ts b/apps/web/src/app/billing/individual/upgrade/unified-upgrade-dialog/unified-upgrade-dialog.component.ts index 02d48e8d8f4..222bf77715c 100644 --- a/apps/web/src/app/billing/individual/upgrade/unified-upgrade-dialog/unified-upgrade-dialog.component.ts +++ b/apps/web/src/app/billing/individual/upgrade/unified-upgrade-dialog/unified-upgrade-dialog.component.ts @@ -1,6 +1,6 @@ import { DIALOG_DATA } from "@angular/cdk/dialog"; import { CommonModule } from "@angular/common"; -import { Component, Inject, OnInit, signal } from "@angular/core"; +import { ChangeDetectionStrategy, Component, Inject, OnInit, signal } from "@angular/core"; import { Router } from "@angular/router"; import { PremiumInterestStateService } from "@bitwarden/angular/billing/services/premium-interest/premium-interest-state.service.abstraction"; @@ -63,10 +63,9 @@ export type UnifiedUpgradeDialogParams = { redirectOnCompletion?: boolean; }; -// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush -// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection @Component({ selector: "app-unified-upgrade-dialog", + changeDetection: ChangeDetectionStrategy.OnPush, imports: [ CommonModule, DialogModule, @@ -87,6 +86,7 @@ export class UnifiedUpgradeDialogComponent implements OnInit { protected readonly account = signal(null); protected readonly planSelectionStepTitleOverride = signal(null); protected readonly hideContinueWithoutUpgradingButton = signal(false); + protected readonly hasPremiumInterest = signal(false); protected readonly PaymentStep = UnifiedUpgradeDialogStep.Payment; protected readonly PlanSelectionStep = UnifiedUpgradeDialogStep.PlanSelection; @@ -98,7 +98,7 @@ export class UnifiedUpgradeDialogComponent implements OnInit { private premiumInterestStateService: PremiumInterestStateService, ) {} - ngOnInit(): void { + async ngOnInit(): Promise { this.account.set(this.params.account); this.step.set(this.params.initialStep ?? UnifiedUpgradeDialogStep.PlanSelection); this.selectedPlan.set(this.params.selectedPlan ?? null); @@ -106,6 +106,19 @@ export class UnifiedUpgradeDialogComponent implements OnInit { this.hideContinueWithoutUpgradingButton.set( this.params.hideContinueWithoutUpgradingButton ?? false, ); + + /* + * Check if the user has premium interest at the point we open the dialog. + * If they do, record it on a component-level signal and clear the user's premium interest. + * This prevents us from having to clear it at every dialog conclusion point. + * */ + const hasPremiumInterest = await this.premiumInterestStateService.getPremiumInterest( + this.params.account.id, + ); + if (hasPremiumInterest) { + this.hasPremiumInterest.set(true); + await this.premiumInterestStateService.clearPremiumInterest(this.params.account.id); + } } protected onPlanSelected(planId: PersonalSubscriptionPricingTierId): void { @@ -113,8 +126,6 @@ export class UnifiedUpgradeDialogComponent implements OnInit { this.nextStep(); } protected async onCloseClicked(): Promise { - // Clear premium interest when user closes/abandons modal - await this.premiumInterestStateService.clearPremiumInterest(this.params.account.id); this.close({ status: UnifiedUpgradeDialogStatus.Closed }); } @@ -135,8 +146,6 @@ export class UnifiedUpgradeDialogComponent implements OnInit { this.step.set(UnifiedUpgradeDialogStep.PlanSelection); this.selectedPlan.set(null); } else { - // Clear premium interest when backing out of dialog completely - await this.premiumInterestStateService.clearPremiumInterest(this.params.account.id); this.close({ status: UnifiedUpgradeDialogStatus.Closed }); } } @@ -161,11 +170,7 @@ export class UnifiedUpgradeDialogComponent implements OnInit { // Check premium interest and route to vault for marketing-initiated premium upgrades if (status === UnifiedUpgradeDialogStatus.UpgradedToPremium) { - const hasPremiumInterest = await this.premiumInterestStateService.getPremiumInterest( - this.params.account.id, - ); - if (hasPremiumInterest) { - await this.premiumInterestStateService.clearPremiumInterest(this.params.account.id); + if (this.hasPremiumInterest()) { await this.router.navigate(["/vault"]); return; // Exit early, don't use redirectOnCompletion } From 2e8faa9994130259d317d87dca2999dc7e87712e Mon Sep 17 00:00:00 2001 From: Bernd Schoolmann Date: Tue, 2 Dec 2025 14:39:18 +0100 Subject: [PATCH 003/131] [PM-12628] Fix cli showing locked status when using session and check (#17515) * Fix cli showing locked status when using session and check * Cleanup --- apps/cli/src/program.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/apps/cli/src/program.ts b/apps/cli/src/program.ts index a5f12b34035..a47278db089 100644 --- a/apps/cli/src/program.ts +++ b/apps/cli/src/program.ts @@ -277,6 +277,11 @@ export class Program extends BaseProgram { }) .option("--check", "Check lock status.", async () => { await this.exitIfNotAuthed(); + const userId = (await firstValueFrom(this.serviceContainer.accountService.activeAccount$)) + ?.id; + await this.serviceContainer.userAutoUnlockKeyService.setUserKeyInMemoryIfAutoUserKeySet( + userId, + ); const authStatus = await this.serviceContainer.authService.getAuthStatus(); if (authStatus === AuthenticationStatus.Unlocked) { From 049acf1e12f4841d867d11c959a3224748a1f87a Mon Sep 17 00:00:00 2001 From: Bernd Schoolmann Date: Tue, 2 Dec 2025 14:39:32 +0100 Subject: [PATCH 004/131] Update sdk to build 403 and move webcrypto rsa to use sdk rsa extract public key (#17771) --- .../web-crypto-function.service.spec.ts | 12 ++++++++ .../services/web-crypto-function.service.ts | 28 ++++--------------- package-lock.json | 16 +++++------ package.json | 4 +-- 4 files changed, 28 insertions(+), 32 deletions(-) diff --git a/libs/common/src/key-management/crypto/services/web-crypto-function.service.spec.ts b/libs/common/src/key-management/crypto/services/web-crypto-function.service.spec.ts index c0b5150a720..af23a515de2 100644 --- a/libs/common/src/key-management/crypto/services/web-crypto-function.service.spec.ts +++ b/libs/common/src/key-management/crypto/services/web-crypto-function.service.spec.ts @@ -1,12 +1,20 @@ import { mock } from "jest-mock-extended"; import { PlatformUtilsService } from "../../../platform/abstractions/platform-utils.service"; +import { SdkLoadService } from "../../../platform/abstractions/sdk/sdk-load.service"; import { Utils } from "../../../platform/misc/utils"; import { EcbDecryptParameters } from "../../../platform/models/domain/decrypt-parameters"; import { SymmetricCryptoKey } from "../../../platform/models/domain/symmetric-crypto-key"; import { WebCryptoFunctionService } from "./web-crypto-function.service"; +class TestSdkLoadService extends SdkLoadService { + protected override load(): Promise { + // Simulate successful WASM load + return Promise.resolve(); + } +} + const RsaPublicKey = "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAl0Vawl/toXzkEvB82FEtqHP" + "4xlU2ab/v0crqIfXfIoWF/XXdHGIdrZeilnRXPPJT1B9dTsasttEZNnua/0Rek/cjNDHtzT52irfoZYS7X6HNIfOi54Q+egP" + @@ -40,6 +48,10 @@ const Sha512Mac = "5ea7817a0b7c5d4d9b00364ccd214669131fc17fe4aca"; describe("WebCrypto Function Service", () => { + beforeAll(async () => { + await new TestSdkLoadService().loadAndInit(); + }); + describe("pbkdf2", () => { const regular256Key = "pj9prw/OHPleXI6bRdmlaD+saJS4awrMiQsQiDjeu2I="; const utf8256Key = "yqvoFXgMRmHR3QPYr5pyR4uVuoHkltv9aHUP63p8n7I="; diff --git a/libs/common/src/key-management/crypto/services/web-crypto-function.service.ts b/libs/common/src/key-management/crypto/services/web-crypto-function.service.ts index 11c186bc393..829227cada9 100644 --- a/libs/common/src/key-management/crypto/services/web-crypto-function.service.ts +++ b/libs/common/src/key-management/crypto/services/web-crypto-function.service.ts @@ -1,5 +1,8 @@ import * as forge from "node-forge"; +import { SdkLoadService } from "@bitwarden/common/platform/abstractions/sdk/sdk-load.service"; +import { PureCrypto } from "@bitwarden/sdk-internal"; + import { EncryptionType } from "../../../platform/enums"; import { Utils } from "../../../platform/misc/utils"; import { @@ -289,28 +292,9 @@ export class WebCryptoFunctionService implements CryptoFunctionService { return new Uint8Array(buffer); } - async rsaExtractPublicKey(privateKey: Uint8Array): Promise { - const rsaParams = { - name: "RSA-OAEP", - // Have to specify some algorithm - hash: { name: this.toWebCryptoAlgorithm("sha1") }, - }; - const impPrivateKey = await this.subtle.importKey("pkcs8", privateKey, rsaParams, true, [ - "decrypt", - ]); - const jwkPrivateKey = await this.subtle.exportKey("jwk", impPrivateKey); - const jwkPublicKeyParams = { - kty: "RSA", - e: jwkPrivateKey.e, - n: jwkPrivateKey.n, - alg: "RSA-OAEP", - ext: true, - }; - const impPublicKey = await this.subtle.importKey("jwk", jwkPublicKeyParams, rsaParams, true, [ - "encrypt", - ]); - const buffer = await this.subtle.exportKey("spki", impPublicKey); - return new Uint8Array(buffer) as UnsignedPublicKey; + async rsaExtractPublicKey(privateKey: Uint8Array): Promise { + await SdkLoadService.Ready; + return PureCrypto.rsa_extract_public_key(privateKey) as UnsignedPublicKey; } async aesGenerateKey(bitLength = 128 | 192 | 256 | 512): Promise { diff --git a/package-lock.json b/package-lock.json index 878e8b78f1b..f362975c92c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -23,8 +23,8 @@ "@angular/platform-browser": "20.3.15", "@angular/platform-browser-dynamic": "20.3.15", "@angular/router": "20.3.15", - "@bitwarden/commercial-sdk-internal": "0.2.0-main.395", - "@bitwarden/sdk-internal": "0.2.0-main.395", + "@bitwarden/commercial-sdk-internal": "0.2.0-main.403", + "@bitwarden/sdk-internal": "0.2.0-main.403", "@electron/fuses": "1.8.0", "@emotion/css": "11.13.5", "@koa/multer": "4.0.0", @@ -4615,9 +4615,9 @@ "link": true }, "node_modules/@bitwarden/commercial-sdk-internal": { - "version": "0.2.0-main.395", - "resolved": "https://registry.npmjs.org/@bitwarden/commercial-sdk-internal/-/commercial-sdk-internal-0.2.0-main.395.tgz", - "integrity": "sha512-DrxL3iA29hzWpyxPyZjiXx0m+EHOgk4CVb+BAi2SoxsacmyHYuTgXuASFMieRz2rv85wS3UR0N64Ok9lC+xNYA==", + "version": "0.2.0-main.403", + "resolved": "https://registry.npmjs.org/@bitwarden/commercial-sdk-internal/-/commercial-sdk-internal-0.2.0-main.403.tgz", + "integrity": "sha512-M2ZUu29oua7CaDTNK7mCwY7PhaIUbNYogAAvxLOmkJuyHNxxqvS9usjjlD2CkQVNBeTUFqvAQpaZQo9vgzEEFA==", "license": "BITWARDEN SOFTWARE DEVELOPMENT KIT LICENSE AGREEMENT", "dependencies": { "type-fest": "^4.41.0" @@ -4720,9 +4720,9 @@ "link": true }, "node_modules/@bitwarden/sdk-internal": { - "version": "0.2.0-main.395", - "resolved": "https://registry.npmjs.org/@bitwarden/sdk-internal/-/sdk-internal-0.2.0-main.395.tgz", - "integrity": "sha512-biExeL2Grp11VQjjK6QM16+WOYk87mTgUhYKFm+Bu/A0zZBzhL/6AocpA9h2T5M8rLCGVVJVUMaXUW3YrSTqEA==", + "version": "0.2.0-main.403", + "resolved": "https://registry.npmjs.org/@bitwarden/sdk-internal/-/sdk-internal-0.2.0-main.403.tgz", + "integrity": "sha512-ROEZdTbeKU68kDh9WYm9wKsLQD5jdTRclXLKl8x0aTj+Tx0nKyyXmLyUfOP+qh3EHIetij4jwPx2z3uS+7r8mg==", "license": "GPL-3.0", "dependencies": { "type-fest": "^4.41.0" diff --git a/package.json b/package.json index 024323a8038..be4d25ec49f 100644 --- a/package.json +++ b/package.json @@ -157,8 +157,8 @@ "@angular/platform-browser": "20.3.15", "@angular/platform-browser-dynamic": "20.3.15", "@angular/router": "20.3.15", - "@bitwarden/sdk-internal": "0.2.0-main.395", - "@bitwarden/commercial-sdk-internal": "0.2.0-main.395", + "@bitwarden/sdk-internal": "0.2.0-main.403", + "@bitwarden/commercial-sdk-internal": "0.2.0-main.403", "@electron/fuses": "1.8.0", "@emotion/css": "11.13.5", "@koa/multer": "4.0.0", From a9bf66e689c33451f9014108ffb7d44655b49516 Mon Sep 17 00:00:00 2001 From: Stephon Brown Date: Tue, 2 Dec 2025 10:49:55 -0500 Subject: [PATCH 005/131] [PM-27600] Replace Hard-Coded Storage amount (#17393) * feat(billing): add provided as a required property to premium response * fix(billing): replace hard coded storage variables with retrieved plan * tests(billing): add tests to pricing-summary service * feat(billing): add optional property. * fix(billing): update storage logic * fix(billing): remove optional check * fix(billing): remove optionality * fix(billing): remove optionality * fix(billing): refactored storage calculation logic * feat(billing): add provided amounts to subscription-pricing-service * fix(billing): update cloud premium component * fix(billing): update desktop premium component * fix(billing): update org plans component * fix(billing) update stories and tests * fix(billing): update messages * fix(billing): replace storage sizes * fix(billing): update messages * fix(billing): update components * fix(billing): update components for pricing and storage retrieval * fix(billing): revert self-hosted change --- apps/browser/src/_locales/en/messages.json | 9 + .../popup/settings/premium-v2.component.html | 2 +- .../popup/settings/premium-v2.component.ts | 13 +- .../app/accounts/premium.component.html | 2 +- .../billing/app/accounts/premium.component.ts | 3 + apps/desktop/src/locales/en/messages.json | 9 + .../cloud-hosted-premium.component.html | 7 +- .../premium/cloud-hosted-premium.component.ts | 7 +- .../change-plan-dialog.component.ts | 12 +- .../organization-plans.component.html | 4 +- .../services/pricing-summary.service.spec.ts | 232 ++++++++++++++++++ .../services/pricing-summary.service.ts | 6 +- apps/web/src/locales/en/messages.json | 11 +- .../premium-upgrade-dialog.component.spec.ts | 2 + ...remium-upgrade-dialog.component.stories.ts | 1 + .../billing/components/premium.component.ts | 6 + .../models/response/premium-plan.response.ts | 7 + .../subscription-pricing.service.spec.ts | 18 ++ .../services/subscription-pricing.service.ts | 7 + .../types/subscription-pricing-tier.ts | 9 +- 20 files changed, 343 insertions(+), 24 deletions(-) create mode 100644 apps/web/src/app/billing/services/pricing-summary.service.spec.ts diff --git a/apps/browser/src/_locales/en/messages.json b/apps/browser/src/_locales/en/messages.json index 21149499485..6a7df1678bf 100644 --- a/apps/browser/src/_locales/en/messages.json +++ b/apps/browser/src/_locales/en/messages.json @@ -1475,6 +1475,15 @@ "ppremiumSignUpStorage": { "message": "1 GB encrypted storage for file attachments." }, + "premiumSignUpStorageV2": { + "message": "$SIZE$ encrypted storage for file attachments.", + "placeholders": { + "size": { + "content": "$1", + "example": "1 GB" + } + } + }, "premiumSignUpEmergency": { "message": "Emergency access." }, diff --git a/apps/browser/src/billing/popup/settings/premium-v2.component.html b/apps/browser/src/billing/popup/settings/premium-v2.component.html index 47d72751af3..fea3e558057 100644 --- a/apps/browser/src/billing/popup/settings/premium-v2.component.html +++ b/apps/browser/src/billing/popup/settings/premium-v2.component.html @@ -12,7 +12,7 @@
  • - {{ "ppremiumSignUpStorage" | i18n }} + {{ "premiumSignUpStorageV2" | i18n: `${storageProvidedGb} GB` }}
  • {{ "premiumSignUpTwoStepOptions" | i18n }} diff --git a/apps/browser/src/billing/popup/settings/premium-v2.component.ts b/apps/browser/src/billing/popup/settings/premium-v2.component.ts index b858b74242d..0c246d734e5 100644 --- a/apps/browser/src/billing/popup/settings/premium-v2.component.ts +++ b/apps/browser/src/billing/popup/settings/premium-v2.component.ts @@ -1,13 +1,14 @@ // FIXME: Update this file to be type safe and remove this and next line // @ts-strict-ignore import { CommonModule, CurrencyPipe, Location } from "@angular/common"; -import { Component } from "@angular/core"; +import { Component, OnInit } from "@angular/core"; import { RouterModule } from "@angular/router"; import { PremiumComponent as BasePremiumComponent } from "@bitwarden/angular/billing/components/premium.component"; import { JslibModule } from "@bitwarden/angular/jslib.module"; import { ApiService } from "@bitwarden/common/abstractions/api.service"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { BillingApiServiceAbstraction } from "@bitwarden/common/billing/abstractions"; import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service"; import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; @@ -44,7 +45,7 @@ import { PopupPageComponent } from "../../../platform/popup/layout/popup-page.co SectionComponent, ], }) -export class PremiumV2Component extends BasePremiumComponent { +export class PremiumV2Component extends BasePremiumComponent implements OnInit { priceString: string; constructor( @@ -59,6 +60,7 @@ export class PremiumV2Component extends BasePremiumComponent { billingAccountProfileStateService: BillingAccountProfileStateService, toastService: ToastService, accountService: AccountService, + billingApiService: BillingApiServiceAbstraction, ) { super( i18nService, @@ -70,15 +72,18 @@ export class PremiumV2Component extends BasePremiumComponent { billingAccountProfileStateService, toastService, accountService, + billingApiService, ); - + } + async ngOnInit() { + await super.ngOnInit(); // Support old price string. Can be removed in future once all translations are properly updated. const thePrice = this.currencyPipe.transform(this.price, "$"); // Safari extension crashes due to $1 appearing in the price string ($10.00). Escape the $ to fix. const formattedPrice = this.platformUtilsService.isSafari() ? thePrice.replace("$", "$$$") : thePrice; - this.priceString = i18nService.t("premiumPriceV2", formattedPrice); + this.priceString = this.i18nService.t("premiumPriceV2", formattedPrice); if (this.priceString.indexOf("%price%") > -1) { this.priceString = this.priceString.replace("%price%", thePrice); } diff --git a/apps/desktop/src/billing/app/accounts/premium.component.html b/apps/desktop/src/billing/app/accounts/premium.component.html index d88602bed1e..c5f9722f133 100644 --- a/apps/desktop/src/billing/app/accounts/premium.component.html +++ b/apps/desktop/src/billing/app/accounts/premium.component.html @@ -13,7 +13,7 @@
    • - {{ "premiumSignUpStorage" | i18n }} + {{ "premiumSignUpStorageV2" | i18n: `${storageProvidedGb} GB` }}
    • diff --git a/apps/desktop/src/billing/app/accounts/premium.component.ts b/apps/desktop/src/billing/app/accounts/premium.component.ts index 637969c1a21..4aff0cc03e1 100644 --- a/apps/desktop/src/billing/app/accounts/premium.component.ts +++ b/apps/desktop/src/billing/app/accounts/premium.component.ts @@ -3,6 +3,7 @@ import { Component } from "@angular/core"; import { PremiumComponent as BasePremiumComponent } from "@bitwarden/angular/billing/components/premium.component"; import { ApiService } from "@bitwarden/common/abstractions/api.service"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { BillingApiServiceAbstraction } from "@bitwarden/common/billing/abstractions"; import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service"; import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; @@ -28,6 +29,7 @@ export class PremiumComponent extends BasePremiumComponent { billingAccountProfileStateService: BillingAccountProfileStateService, toastService: ToastService, accountService: AccountService, + billingApiService: BillingApiServiceAbstraction, ) { super( i18nService, @@ -39,6 +41,7 @@ export class PremiumComponent extends BasePremiumComponent { billingAccountProfileStateService, toastService, accountService, + billingApiService, ); } } diff --git a/apps/desktop/src/locales/en/messages.json b/apps/desktop/src/locales/en/messages.json index f6f078611c9..757059c4e41 100644 --- a/apps/desktop/src/locales/en/messages.json +++ b/apps/desktop/src/locales/en/messages.json @@ -1490,6 +1490,15 @@ "premiumSignUpStorage": { "message": "1 GB encrypted storage for file attachments." }, + "premiumSignUpStorageV2": { + "message": "$SIZE$ encrypted storage for file attachments.", + "placeholders": { + "size": { + "content": "$1", + "example": "1 GB" + } + } + }, "premiumSignUpTwoStepOptions": { "message": "Proprietary two-step login options such as YubiKey and Duo." }, diff --git a/apps/web/src/app/billing/individual/premium/cloud-hosted-premium.component.html b/apps/web/src/app/billing/individual/premium/cloud-hosted-premium.component.html index 63c26bd61f1..33e89f21fc0 100644 --- a/apps/web/src/app/billing/individual/premium/cloud-hosted-premium.component.html +++ b/apps/web/src/app/billing/individual/premium/cloud-hosted-premium.component.html @@ -24,7 +24,7 @@
      • - {{ "premiumSignUpStorage" | i18n }} + {{ "premiumSignUpStorageV2" | i18n: `${(providedStorageGb$ | async)} GB` }}
      • @@ -82,7 +82,10 @@ /> {{ "additionalStorageIntervalDesc" - | i18n: "1 GB" : (storagePrice$ | async | currency: "$") : ("year" | i18n) + | i18n + : `${(providedStorageGb$ | async)} GB` + : (storagePrice$ | async | currency: "$") + : ("year" | i18n) }}
diff --git a/apps/web/src/app/billing/individual/premium/cloud-hosted-premium.component.ts b/apps/web/src/app/billing/individual/premium/cloud-hosted-premium.component.ts index fceeeedf170..86a508d2701 100644 --- a/apps/web/src/app/billing/individual/premium/cloud-hosted-premium.component.ts +++ b/apps/web/src/app/billing/individual/premium/cloud-hosted-premium.component.ts @@ -22,8 +22,8 @@ import { debounceTime } from "rxjs/operators"; import { ApiService } from "@bitwarden/common/abstractions/api.service"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions"; +import { SubscriptionPricingServiceAbstraction } from "@bitwarden/common/billing/abstractions/subscription-pricing.service.abstraction"; import { PaymentMethodType } from "@bitwarden/common/billing/enums"; -import { DefaultSubscriptionPricingService } from "@bitwarden/common/billing/services/subscription-pricing.service"; import { PersonalSubscriptionPricingTierIds } from "@bitwarden/common/billing/types/subscription-pricing-tier"; import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; @@ -75,6 +75,7 @@ export class CloudHostedPremiumComponent { return { seat: premiumPlan.passwordManager.annualPrice, storage: premiumPlan.passwordManager.annualPricePerAdditionalStorageGB, + providedStorageGb: premiumPlan.passwordManager.providedStorageGB, }; }), shareReplay({ bufferSize: 1, refCount: true }), @@ -84,6 +85,8 @@ export class CloudHostedPremiumComponent { storagePrice$ = this.premiumPrices$.pipe(map((prices) => prices.storage)); + providedStorageGb$ = this.premiumPrices$.pipe(map((prices) => prices.providedStorageGb)); + protected isLoadingPrices$ = this.premiumPrices$.pipe( map(() => false), startWith(true), @@ -134,7 +137,7 @@ export class CloudHostedPremiumComponent { private accountService: AccountService, private subscriberBillingClient: SubscriberBillingClient, private taxClient: TaxClient, - private subscriptionPricingService: DefaultSubscriptionPricingService, + private subscriptionPricingService: SubscriptionPricingServiceAbstraction, ) { this.hasPremiumFromAnyOrganization$ = this.accountService.activeAccount$.pipe( switchMap((account) => diff --git a/apps/web/src/app/billing/organizations/change-plan-dialog.component.ts b/apps/web/src/app/billing/organizations/change-plan-dialog.component.ts index 0fd7746fc9d..978bb35c5c7 100644 --- a/apps/web/src/app/billing/organizations/change-plan-dialog.component.ts +++ b/apps/web/src/app/billing/organizations/change-plan-dialog.component.ts @@ -620,7 +620,10 @@ export class ChangePlanDialogComponent implements OnInit, OnDestroy { } get storageGb() { - return this.sub?.maxStorageGb ? this.sub?.maxStorageGb - 1 : 0; + return Math.max( + 0, + (this.sub?.maxStorageGb ?? 0) - this.selectedPlan.PasswordManager.baseStorageGb, + ); } passwordManagerSeatTotal(plan: PlanResponse): number { @@ -644,12 +647,7 @@ export class ChangePlanDialogComponent implements OnInit, OnDestroy { return 0; } - return ( - plan.PasswordManager.additionalStoragePricePerGb * - // TODO: Eslint upgrade. Please resolve this since the null check does nothing - // eslint-disable-next-line no-constant-binary-expression - Math.abs(this.sub?.maxStorageGb ? this.sub?.maxStorageGb - 1 : 0 || 0) - ); + return plan.PasswordManager.additionalStoragePricePerGb * this.storageGb; } additionalStoragePriceMonthly(selectedPlan: PlanResponse) { diff --git a/apps/web/src/app/billing/organizations/organization-plans.component.html b/apps/web/src/app/billing/organizations/organization-plans.component.html index 6234fc6e6e3..d06604ba29e 100644 --- a/apps/web/src/app/billing/organizations/organization-plans.component.html +++ b/apps/web/src/app/billing/organizations/organization-plans.component.html @@ -104,7 +104,7 @@
  • {{ "gbEncryptedFileStorage" - | i18n: selectableProduct.PasswordManager.baseStorageGb + "GB" + | i18n: selectableProduct.PasswordManager.baseStorageGb + " GB" }}
  • @@ -239,7 +239,7 @@ {{ "additionalStorageIntervalDesc" | i18n - : "1 GB" + : `${selectedPlan.PasswordManager.baseStorageGb} GB` : (additionalStoragePriceMonthly(selectedPlan) | currency: "$") : ("month" | i18n) }} diff --git a/apps/web/src/app/billing/services/pricing-summary.service.spec.ts b/apps/web/src/app/billing/services/pricing-summary.service.spec.ts new file mode 100644 index 00000000000..4e15d318a03 --- /dev/null +++ b/apps/web/src/app/billing/services/pricing-summary.service.spec.ts @@ -0,0 +1,232 @@ +import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; +import { PlanInterval, ProductTierType } from "@bitwarden/common/billing/enums"; +import { + BillingCustomerDiscount, + OrganizationSubscriptionResponse, +} from "@bitwarden/common/billing/models/response/organization-subscription.response"; +import { + PasswordManagerPlanFeaturesResponse, + PlanResponse, + SecretsManagerPlanFeaturesResponse, +} from "@bitwarden/common/billing/models/response/plan.response"; + +import { PricingSummaryData } from "../shared/pricing-summary/pricing-summary.component"; + +import { PricingSummaryService } from "./pricing-summary.service"; + +describe("PricingSummaryService", () => { + let service: PricingSummaryService; + + beforeEach(() => { + service = new PricingSummaryService(); + }); + + describe("getPricingSummaryData", () => { + let mockPlan: PlanResponse; + let mockSub: OrganizationSubscriptionResponse; + let mockOrganization: Organization; + + beforeEach(() => { + // Create mock plan with password manager features + mockPlan = { + productTier: ProductTierType.Teams, + PasswordManager: { + basePrice: 0, + seatPrice: 48, + baseSeats: 0, + hasAdditionalSeatsOption: true, + hasPremiumAccessOption: false, + premiumAccessOptionPrice: 0, + hasAdditionalStorageOption: true, + additionalStoragePricePerGb: 6, + baseStorageGb: 1, + } as PasswordManagerPlanFeaturesResponse, + SecretsManager: { + basePrice: 0, + seatPrice: 72, + baseSeats: 3, + hasAdditionalSeatsOption: true, + hasAdditionalServiceAccountOption: true, + additionalPricePerServiceAccount: 6, + baseServiceAccount: 50, + } as SecretsManagerPlanFeaturesResponse, + } as PlanResponse; + + // Create mock subscription + mockSub = { + seats: 5, + smSeats: 5, + smServiceAccounts: 5, + maxStorageGb: 2, + customerDiscount: null, + } as OrganizationSubscriptionResponse; + + // Create mock organization + mockOrganization = { + useSecretsManager: false, + } as Organization; + }); + + it("should calculate pricing data correctly for password manager only", async () => { + const result = await service.getPricingSummaryData( + mockPlan, + mockSub, + mockOrganization, + PlanInterval.Monthly, + false, + 50, // estimatedTax + ); + + expect(result).toEqual({ + selectedPlanInterval: "month", + passwordManagerSeats: 5, + passwordManagerSeatTotal: 240, // 48 * 5 + secretsManagerSeatTotal: 360, // 72 * 5 + additionalStorageTotal: 6, // 6 * (2 - 1) + additionalStoragePriceMonthly: 6, + additionalServiceAccountTotal: 0, // No additional service accounts (50 base vs 5 used) + totalAppliedDiscount: 0, + secretsManagerSubtotal: 360, // 0 + 360 + 0 + passwordManagerSubtotal: 246, // 0 + 240 + 6 + total: 296, // 246 + 50 (tax) - organization doesn't use secrets manager + organization: mockOrganization, + sub: mockSub, + selectedPlan: mockPlan, + selectedInterval: PlanInterval.Monthly, + discountPercentageFromSub: 0, + discountPercentage: 20, + acceptingSponsorship: false, + additionalServiceAccount: 0, // 50 - 5 = 45, which is > 0, so return 0 + storageGb: 1, + isSecretsManagerTrial: false, + estimatedTax: 50, + }); + }); + + it("should calculate pricing data correctly with secrets manager enabled", async () => { + mockOrganization.useSecretsManager = true; + + const result = await service.getPricingSummaryData( + mockPlan, + mockSub, + mockOrganization, + PlanInterval.Monthly, + false, + 50, + ); + + expect(result.total).toBe(656); // passwordManagerSubtotal (246) + secretsManagerSubtotal (360) + tax (50) + }); + + it("should handle secrets manager trial", async () => { + const result = await service.getPricingSummaryData( + mockPlan, + mockSub, + mockOrganization, + PlanInterval.Monthly, + true, // isSecretsManagerTrial + 50, + ); + + expect(result.passwordManagerSeatTotal).toBe(0); // Should be 0 during trial + expect(result.discountPercentageFromSub).toBe(0); // Should be 0 during trial + }); + + it("should handle premium access option", async () => { + mockPlan.PasswordManager.hasPremiumAccessOption = true; + mockPlan.PasswordManager.premiumAccessOptionPrice = 25; + + const result = await service.getPricingSummaryData( + mockPlan, + mockSub, + mockOrganization, + PlanInterval.Monthly, + false, + 50, + ); + + expect(result.passwordManagerSubtotal).toBe(271); // 0 + 240 + 6 + 25 + }); + + it("should handle customer discount", async () => { + mockSub.customerDiscount = { + id: "discount1", + active: true, + percentOff: 10, + appliesTo: ["subscription"], + } as BillingCustomerDiscount; + + const result = await service.getPricingSummaryData( + mockPlan, + mockSub, + mockOrganization, + PlanInterval.Monthly, + false, + 50, + ); + + expect(result.discountPercentageFromSub).toBe(10); + }); + + it("should handle zero storage calculation", async () => { + mockSub.maxStorageGb = 1; // Same as base storage + + const result = await service.getPricingSummaryData( + mockPlan, + mockSub, + mockOrganization, + PlanInterval.Monthly, + false, + 50, + ); + + expect(result.additionalStorageTotal).toBe(0); + expect(result.storageGb).toBe(0); + }); + }); + + describe("getAdditionalServiceAccount", () => { + let mockPlan: PlanResponse; + let mockSub: OrganizationSubscriptionResponse; + + beforeEach(() => { + mockPlan = { + SecretsManager: { + baseServiceAccount: 50, + } as SecretsManagerPlanFeaturesResponse, + } as PlanResponse; + + mockSub = { + smServiceAccounts: 55, + } as OrganizationSubscriptionResponse; + }); + + it("should return additional service accounts when used exceeds base", () => { + const result = service.getAdditionalServiceAccount(mockPlan, mockSub); + expect(result).toBe(5); // Math.abs(50 - 55) = 5 + }); + + it("should return 0 when used is less than or equal to base", () => { + mockSub.smServiceAccounts = 40; + const result = service.getAdditionalServiceAccount(mockPlan, mockSub); + expect(result).toBe(0); + }); + + it("should return 0 when used equals base", () => { + mockSub.smServiceAccounts = 50; + const result = service.getAdditionalServiceAccount(mockPlan, mockSub); + expect(result).toBe(0); + }); + + it("should return 0 when plan is null", () => { + const result = service.getAdditionalServiceAccount(null, mockSub); + expect(result).toBe(0); + }); + + it("should return 0 when plan has no SecretsManager", () => { + mockPlan.SecretsManager = null; + const result = service.getAdditionalServiceAccount(mockPlan, mockSub); + expect(result).toBe(0); + }); + }); +}); diff --git a/apps/web/src/app/billing/services/pricing-summary.service.ts b/apps/web/src/app/billing/services/pricing-summary.service.ts index b3c071a8b88..da2fe0e8dbb 100644 --- a/apps/web/src/app/billing/services/pricing-summary.service.ts +++ b/apps/web/src/app/billing/services/pricing-summary.service.ts @@ -31,9 +31,10 @@ export class PricingSummaryService { const additionalServiceAccount = this.getAdditionalServiceAccount(plan, sub); + const storageGb = Math.max(0, (sub?.maxStorageGb ?? 0) - plan.PasswordManager.baseStorageGb); + const additionalStorageTotal = plan.PasswordManager?.hasAdditionalStorageOption - ? plan.PasswordManager.additionalStoragePricePerGb * - (sub?.maxStorageGb ? sub.maxStorageGb - 1 : 0) + ? plan.PasswordManager.additionalStoragePricePerGb * storageGb : 0; const additionalStoragePriceMonthly = plan.PasswordManager?.additionalStoragePricePerGb || 0; @@ -66,7 +67,6 @@ export class PricingSummaryService { : (sub?.customerDiscount?.percentOff ?? 0); const discountPercentage = 20; const acceptingSponsorship = false; - const storageGb = sub?.maxStorageGb ? sub?.maxStorageGb - 1 : 0; const total = organization?.useSecretsManager ? passwordManagerSubtotal + secretsManagerSubtotal + estimatedTax diff --git a/apps/web/src/locales/en/messages.json b/apps/web/src/locales/en/messages.json index 90468c61d5c..582efade7f4 100644 --- a/apps/web/src/locales/en/messages.json +++ b/apps/web/src/locales/en/messages.json @@ -3060,7 +3060,16 @@ "message": "Upgrade your account to a Premium membership and unlock some great additional features." }, "premiumSignUpStorage": { - "message": "1 GB encrypted storage for file attachments." + "message": "1 GB encrypted storage for file attachments." + }, + "premiumSignUpStorageV2": { + "message": "$SIZE$ encrypted storage for file attachments.", + "placeholders": { + "size": { + "content": "$1", + "example": "1 GB" + } + } }, "premiumSignUpTwoStepOptions": { "message": "Proprietary two-step login options such as YubiKey and Duo." diff --git a/libs/angular/src/billing/components/premium-upgrade-dialog/premium-upgrade-dialog.component.spec.ts b/libs/angular/src/billing/components/premium-upgrade-dialog/premium-upgrade-dialog.component.spec.ts index 107eb068e76..30a4d38b1df 100644 --- a/libs/angular/src/billing/components/premium-upgrade-dialog/premium-upgrade-dialog.component.spec.ts +++ b/libs/angular/src/billing/components/premium-upgrade-dialog/premium-upgrade-dialog.component.spec.ts @@ -40,6 +40,7 @@ describe("PremiumUpgradeDialogComponent", () => { type: "standalone", annualPrice: 10, annualPricePerAdditionalStorageGB: 4, + providedStorageGB: 1, features: [ { key: "feature1", value: "Feature 1" }, { key: "feature2", value: "Feature 2" }, @@ -58,6 +59,7 @@ describe("PremiumUpgradeDialogComponent", () => { users: 6, annualPrice: 40, annualPricePerAdditionalStorageGB: 4, + providedStorageGB: 1, features: [{ key: "featureA", value: "Feature A" }], }, }; diff --git a/libs/angular/src/billing/components/premium-upgrade-dialog/premium-upgrade-dialog.component.stories.ts b/libs/angular/src/billing/components/premium-upgrade-dialog/premium-upgrade-dialog.component.stories.ts index 7ba09192d3c..7fd66878cae 100644 --- a/libs/angular/src/billing/components/premium-upgrade-dialog/premium-upgrade-dialog.component.stories.ts +++ b/libs/angular/src/billing/components/premium-upgrade-dialog/premium-upgrade-dialog.component.stories.ts @@ -31,6 +31,7 @@ const mockPremiumTier: PersonalSubscriptionPricingTier = { type: "standalone", annualPrice: 10, annualPricePerAdditionalStorageGB: 4, + providedStorageGB: 1, features: [ { key: "builtInAuthenticator", value: "Built-in authenticator" }, { key: "secureFileStorage", value: "Secure file storage" }, diff --git a/libs/angular/src/billing/components/premium.component.ts b/libs/angular/src/billing/components/premium.component.ts index 6d0b90385ba..3f53d62e561 100644 --- a/libs/angular/src/billing/components/premium.component.ts +++ b/libs/angular/src/billing/components/premium.component.ts @@ -5,6 +5,7 @@ import { firstValueFrom, Observable, switchMap } from "rxjs"; import { ApiService } from "@bitwarden/common/abstractions/api.service"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { BillingApiServiceAbstraction } from "@bitwarden/common/billing/abstractions"; import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service"; import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; @@ -16,6 +17,7 @@ import { DialogService, SimpleDialogOptions, ToastService } from "@bitwarden/com export class PremiumComponent implements OnInit { isPremium$: Observable; price = 10; + storageProvidedGb = 0; refreshPromise: Promise; cloudWebVaultUrl: string; @@ -29,6 +31,7 @@ export class PremiumComponent implements OnInit { billingAccountProfileStateService: BillingAccountProfileStateService, private toastService: ToastService, accountService: AccountService, + private billingApiService: BillingApiServiceAbstraction, ) { this.isPremium$ = accountService.activeAccount$.pipe( switchMap((account) => @@ -39,6 +42,9 @@ export class PremiumComponent implements OnInit { async ngOnInit() { this.cloudWebVaultUrl = await firstValueFrom(this.environmentService.cloudWebVaultUrl$); + const premiumResponse = await this.billingApiService.getPremiumPlan(); + this.storageProvidedGb = premiumResponse.storage.provided; + this.price = premiumResponse.seat.price; } async refresh() { diff --git a/libs/common/src/billing/models/response/premium-plan.response.ts b/libs/common/src/billing/models/response/premium-plan.response.ts index f5df560a601..73e4f834c6f 100644 --- a/libs/common/src/billing/models/response/premium-plan.response.ts +++ b/libs/common/src/billing/models/response/premium-plan.response.ts @@ -4,10 +4,12 @@ export class PremiumPlanResponse extends BaseResponse { seat: { stripePriceId: string; price: number; + provided: number; }; storage: { stripePriceId: string; price: number; + provided: number; }; constructor(response: any) { @@ -30,6 +32,7 @@ export class PremiumPlanResponse extends BaseResponse { class PurchasableResponse extends BaseResponse { stripePriceId: string; price: number; + provided: number; constructor(response: any) { super(response); @@ -43,5 +46,9 @@ class PurchasableResponse extends BaseResponse { if (typeof this.price !== "number" || isNaN(this.price)) { throw new Error("PurchasableResponse: Missing or invalid 'Price' property"); } + this.provided = this.getResponseProperty("Provided"); + if (typeof this.provided !== "number" || isNaN(this.provided)) { + throw new Error("PurchasableResponse: Missing or invalid 'Provided' property"); + } } } diff --git a/libs/common/src/billing/services/subscription-pricing.service.spec.ts b/libs/common/src/billing/services/subscription-pricing.service.spec.ts index 8f5e9c0a3ab..76e15c646af 100644 --- a/libs/common/src/billing/services/subscription-pricing.service.spec.ts +++ b/libs/common/src/billing/services/subscription-pricing.service.spec.ts @@ -55,6 +55,7 @@ describe("DefaultSubscriptionPricingService", () => { basePrice: 36, seatPrice: 0, additionalStoragePricePerGb: 4, + providedStorageGB: 1, allowSeatAutoscale: false, maxSeats: 6, maxCollections: null, @@ -94,6 +95,7 @@ describe("DefaultSubscriptionPricingService", () => { basePrice: 0, seatPrice: 36, additionalStoragePricePerGb: 4, + providedStorageGB: 1, allowSeatAutoscale: true, maxSeats: null, maxCollections: null, @@ -359,6 +361,7 @@ describe("DefaultSubscriptionPricingService", () => { type: "standalone", annualPrice: 10, annualPricePerAdditionalStorageGB: 4, + providedStorageGB: 1, features: [ { key: "builtInAuthenticator", value: "Built-in authenticator" }, { key: "secureFileStorage", value: "Secure file storage" }, @@ -383,6 +386,7 @@ describe("DefaultSubscriptionPricingService", () => { annualPrice: mockFamiliesPlan.PasswordManager.basePrice, annualPricePerAdditionalStorageGB: mockFamiliesPlan.PasswordManager.additionalStoragePricePerGb, + providedStorageGB: mockFamiliesPlan.PasswordManager.baseStorageGb, features: [ { key: "premiumAccounts", value: "6 premium accounts" }, { key: "familiesUnlimitedSharing", value: "Unlimited sharing for families" }, @@ -456,6 +460,7 @@ describe("DefaultSubscriptionPricingService", () => { expect(premiumTier.passwordManager.annualPrice).toEqual(10); expect(premiumTier.passwordManager.annualPricePerAdditionalStorageGB).toEqual(4); + expect(premiumTier.passwordManager.providedStorageGB).toEqual(1); expect(familiesTier.passwordManager.annualPrice).toEqual( mockFamiliesPlan.PasswordManager.basePrice, @@ -463,6 +468,9 @@ describe("DefaultSubscriptionPricingService", () => { expect(familiesTier.passwordManager.annualPricePerAdditionalStorageGB).toEqual( mockFamiliesPlan.PasswordManager.additionalStoragePricePerGb, ); + expect(familiesTier.passwordManager.providedStorageGB).toEqual( + mockFamiliesPlan.PasswordManager.baseStorageGb, + ); done(); }); @@ -487,6 +495,7 @@ describe("DefaultSubscriptionPricingService", () => { annualPricePerUser: mockTeamsPlan.PasswordManager.seatPrice, annualPricePerAdditionalStorageGB: mockTeamsPlan.PasswordManager.additionalStoragePricePerGb, + providedStorageGB: mockTeamsPlan.PasswordManager.baseStorageGb, features: [ { key: "secureItemSharing", value: "Secure item sharing" }, { key: "eventLogMonitoring", value: "Event log monitoring" }, @@ -522,6 +531,7 @@ describe("DefaultSubscriptionPricingService", () => { annualPricePerUser: mockEnterprisePlan.PasswordManager.seatPrice, annualPricePerAdditionalStorageGB: mockEnterprisePlan.PasswordManager.additionalStoragePricePerGb, + providedStorageGB: mockEnterprisePlan.PasswordManager.baseStorageGb, features: [ { key: "enterpriseSecurityPolicies", value: "Enterprise security policies" }, { key: "passwordLessSso", value: "Passwordless SSO" }, @@ -648,6 +658,9 @@ describe("DefaultSubscriptionPricingService", () => { expect(teamsSecretsManager.annualPricePerAdditionalServiceAccount).toEqual( mockTeamsPlan.SecretsManager.additionalPricePerServiceAccount, ); + expect(teamsPasswordManager.providedStorageGB).toEqual( + mockTeamsPlan.PasswordManager.baseStorageGb, + ); const enterprisePasswordManager = enterpriseTier.passwordManager as any; const enterpriseSecretsManager = enterpriseTier.secretsManager as any; @@ -657,6 +670,9 @@ describe("DefaultSubscriptionPricingService", () => { expect(enterprisePasswordManager.annualPricePerAdditionalStorageGB).toEqual( mockEnterprisePlan.PasswordManager.additionalStoragePricePerGb, ); + expect(enterprisePasswordManager.providedStorageGB).toEqual( + mockEnterprisePlan.PasswordManager.baseStorageGb, + ); expect(enterpriseSecretsManager.annualPricePerUser).toEqual( mockEnterprisePlan.SecretsManager.seatPrice, ); @@ -729,6 +745,7 @@ describe("DefaultSubscriptionPricingService", () => { annualPricePerUser: mockTeamsPlan.PasswordManager.seatPrice, annualPricePerAdditionalStorageGB: mockTeamsPlan.PasswordManager.additionalStoragePricePerGb, + providedStorageGB: mockTeamsPlan.PasswordManager.baseStorageGb, features: [ { key: "secureItemSharing", value: "Secure item sharing" }, { key: "eventLogMonitoring", value: "Event log monitoring" }, @@ -764,6 +781,7 @@ describe("DefaultSubscriptionPricingService", () => { annualPricePerUser: mockEnterprisePlan.PasswordManager.seatPrice, annualPricePerAdditionalStorageGB: mockEnterprisePlan.PasswordManager.additionalStoragePricePerGb, + providedStorageGB: mockEnterprisePlan.PasswordManager.baseStorageGb, features: [ { key: "enterpriseSecurityPolicies", value: "Enterprise security policies" }, { key: "passwordLessSso", value: "Passwordless SSO" }, diff --git a/libs/common/src/billing/services/subscription-pricing.service.ts b/libs/common/src/billing/services/subscription-pricing.service.ts index f1502eb26e8..a3f048fee78 100644 --- a/libs/common/src/billing/services/subscription-pricing.service.ts +++ b/libs/common/src/billing/services/subscription-pricing.service.ts @@ -40,6 +40,7 @@ export class DefaultSubscriptionPricingService implements SubscriptionPricingSer */ private static readonly FALLBACK_PREMIUM_SEAT_PRICE = 10; private static readonly FALLBACK_PREMIUM_STORAGE_PRICE = 4; + private static readonly FALLBACK_PREMIUM_PROVIDED_STORAGE_GB = 1; constructor( private billingApiService: BillingApiServiceAbstraction, @@ -114,11 +115,13 @@ export class DefaultSubscriptionPricingService implements SubscriptionPricingSer map((premiumPlan) => ({ seat: premiumPlan.seat.price, storage: premiumPlan.storage.price, + provided: premiumPlan.storage.provided, })), ) : of({ seat: DefaultSubscriptionPricingService.FALLBACK_PREMIUM_SEAT_PRICE, storage: DefaultSubscriptionPricingService.FALLBACK_PREMIUM_STORAGE_PRICE, + provided: DefaultSubscriptionPricingService.FALLBACK_PREMIUM_PROVIDED_STORAGE_GB, }), ), map((premiumPrices) => ({ @@ -130,6 +133,7 @@ export class DefaultSubscriptionPricingService implements SubscriptionPricingSer type: "standalone", annualPrice: premiumPrices.seat, annualPricePerAdditionalStorageGB: premiumPrices.storage, + providedStorageGB: premiumPrices.provided, features: [ this.featureTranslations.builtInAuthenticator(), this.featureTranslations.secureFileStorage(), @@ -161,6 +165,7 @@ export class DefaultSubscriptionPricingService implements SubscriptionPricingSer annualPrice: familiesPlan.PasswordManager.basePrice, annualPricePerAdditionalStorageGB: familiesPlan.PasswordManager.additionalStoragePricePerGb, + providedStorageGB: familiesPlan.PasswordManager.baseStorageGb, features: [ this.featureTranslations.premiumAccounts(), this.featureTranslations.familiesUnlimitedSharing(), @@ -214,6 +219,7 @@ export class DefaultSubscriptionPricingService implements SubscriptionPricingSer annualPricePerUser: annualTeamsPlan.PasswordManager.seatPrice, annualPricePerAdditionalStorageGB: annualTeamsPlan.PasswordManager.additionalStoragePricePerGb, + providedStorageGB: annualTeamsPlan.PasswordManager.baseStorageGb, features: [ this.featureTranslations.secureItemSharing(), this.featureTranslations.eventLogMonitoring(), @@ -253,6 +259,7 @@ export class DefaultSubscriptionPricingService implements SubscriptionPricingSer annualPricePerUser: annualEnterprisePlan.PasswordManager.seatPrice, annualPricePerAdditionalStorageGB: annualEnterprisePlan.PasswordManager.additionalStoragePricePerGb, + providedStorageGB: annualEnterprisePlan.PasswordManager.baseStorageGb, features: [ this.featureTranslations.enterpriseSecurityPolicies(), this.featureTranslations.passwordLessSso(), diff --git a/libs/common/src/billing/types/subscription-pricing-tier.ts b/libs/common/src/billing/types/subscription-pricing-tier.ts index 8febc4b86db..3f5c076ba4f 100644 --- a/libs/common/src/billing/types/subscription-pricing-tier.ts +++ b/libs/common/src/billing/types/subscription-pricing-tier.ts @@ -30,13 +30,19 @@ type HasAdditionalStorage = { annualPricePerAdditionalStorageGB: number; }; +type HasProvidedStorage = { + providedStorageGB: number; +}; + type StandalonePasswordManager = HasFeatures & - HasAdditionalStorage & { + HasAdditionalStorage & + HasProvidedStorage & { type: "standalone"; annualPrice: number; }; type PackagedPasswordManager = HasFeatures & + HasProvidedStorage & HasAdditionalStorage & { type: "packaged"; users: number; @@ -52,6 +58,7 @@ type CustomPasswordManager = HasFeatures & { }; type ScalablePasswordManager = HasFeatures & + HasProvidedStorage & HasAdditionalStorage & { type: "scalable"; annualPricePerUser: number; From bbb5acba50e115864ff7944e65beede75ef597cc Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Tue, 2 Dec 2025 08:03:46 -0800 Subject: [PATCH 006/131] [deps] Autofill: Update tldts to v7.0.19 (#17676) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- apps/cli/package.json | 2 +- package-lock.json | 12 ++++++------ package.json | 2 +- 3 files changed, 8 insertions(+), 8 deletions(-) diff --git a/apps/cli/package.json b/apps/cli/package.json index adddc99b4d7..d041f818c29 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -88,7 +88,7 @@ "proper-lockfile": "4.1.2", "rxjs": "7.8.1", "semver": "7.7.3", - "tldts": "7.0.18", + "tldts": "7.0.19", "zxcvbn": "4.4.2" } } diff --git a/package-lock.json b/package-lock.json index f362975c92c..80a641f3a00 100644 --- a/package-lock.json +++ b/package-lock.json @@ -68,7 +68,7 @@ "rxjs": "7.8.1", "semver": "7.7.3", "tabbable": "6.3.0", - "tldts": "7.0.18", + "tldts": "7.0.19", "ts-node": "10.9.2", "utf-8-validate": "6.0.5", "vite-tsconfig-paths": "5.1.4", @@ -224,7 +224,7 @@ "proper-lockfile": "4.1.2", "rxjs": "7.8.1", "semver": "7.7.3", - "tldts": "7.0.18", + "tldts": "7.0.19", "zxcvbn": "4.4.2" }, "bin": { @@ -38727,12 +38727,12 @@ } }, "node_modules/tldts": { - "version": "7.0.18", - "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.0.18.tgz", - "integrity": "sha512-lCcgTAgMxQ1JKOWrVGo6E69Ukbnx4Gc1wiYLRf6J5NN4HRYJtCby1rPF8rkQ4a6qqoFBK5dvjJ1zJ0F7VfDSvw==", + "version": "7.0.19", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.0.19.tgz", + "integrity": "sha512-8PWx8tvC4jDB39BQw1m4x8y5MH1BcQ5xHeL2n7UVFulMPH/3Q0uiamahFJ3lXA0zO2SUyRXuVVbWSDmstlt9YA==", "license": "MIT", "dependencies": { - "tldts-core": "^7.0.18" + "tldts-core": "^7.0.19" }, "bin": { "tldts": "bin/cli.js" diff --git a/package.json b/package.json index be4d25ec49f..39b68f53718 100644 --- a/package.json +++ b/package.json @@ -202,7 +202,7 @@ "rxjs": "7.8.1", "semver": "7.7.3", "tabbable": "6.3.0", - "tldts": "7.0.18", + "tldts": "7.0.19", "ts-node": "10.9.2", "utf-8-validate": "6.0.5", "vite-tsconfig-paths": "5.1.4", From bf461879e36fcd576530992890b07e0d29454aa3 Mon Sep 17 00:00:00 2001 From: gitclonebrian <235774926+gitclonebrian@users.noreply.github.com> Date: Tue, 2 Dec 2025 11:06:26 -0500 Subject: [PATCH 007/131] added perms to both token generation steps (#17398) --- .github/workflows/repository-management.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/repository-management.yml b/.github/workflows/repository-management.yml index faf119cce2b..955ac9a9cf3 100644 --- a/.github/workflows/repository-management.yml +++ b/.github/workflows/repository-management.yml @@ -102,6 +102,7 @@ jobs: with: app-id: ${{ steps.get-kv-secrets.outputs.BW-GHAPP-ID }} private-key: ${{ steps.get-kv-secrets.outputs.BW-GHAPP-KEY }} + permission-contents: write # for committing and pushing to current branch - name: Check out branch uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 @@ -467,6 +468,7 @@ jobs: with: app-id: ${{ steps.get-kv-secrets.outputs.BW-GHAPP-ID }} private-key: ${{ steps.get-kv-secrets.outputs.BW-GHAPP-KEY }} + permission-contents: write # for creating and pushing new branch - name: Check out target ref uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 From ebd5793568befb0dad0afa29fc9d578ad25e465e Mon Sep 17 00:00:00 2001 From: Alex Morask <144709477+amorask-bitwarden@users.noreply.github.com> Date: Tue, 2 Dec 2025 10:16:44 -0600 Subject: [PATCH 008/131] [PM-24558] Remove FF: `pm-21821-provider-portal-takeover` (#17521) * Remove FF: pm-21821-provider-portal-takeover * Fix failing tests --- .../clients/manage-clients.component.ts | 13 +---------- .../providers/providers-layout.component.html | 2 +- .../providers/providers-layout.component.ts | 8 ------- .../provider-warnings.service.spec.ts | 23 ------------------- .../services/provider-warnings.service.ts | 13 +++-------- libs/common/src/enums/feature-flag.enum.ts | 2 -- 6 files changed, 5 insertions(+), 56 deletions(-) diff --git a/bitwarden_license/bit-web/src/app/admin-console/providers/clients/manage-clients.component.ts b/bitwarden_license/bit-web/src/app/admin-console/providers/clients/manage-clients.component.ts index eed3db87396..4e128a79369 100644 --- a/bitwarden_license/bit-web/src/app/admin-console/providers/clients/manage-clients.component.ts +++ b/bitwarden_license/bit-web/src/app/admin-console/providers/clients/manage-clients.component.ts @@ -21,8 +21,6 @@ import { AccountService } from "@bitwarden/common/auth/abstractions/account.serv import { getUserId } from "@bitwarden/common/auth/services/account.service"; import { BillingApiServiceAbstraction } from "@bitwarden/common/billing/abstractions"; import { PlanResponse } from "@bitwarden/common/billing/models/response/plan.response"; -import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; -import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { ValidationService } from "@bitwarden/common/platform/abstractions/validation.service"; import { @@ -100,19 +98,11 @@ export class ManageClientsComponent implements OnInit, OnDestroy { ), ); - protected providerPortalTakeover$ = this.configService.getFeatureFlag$( - FeatureFlag.PM21821_ProviderPortalTakeover, - ); - protected suspensionActive$ = combineLatest([ this.isAdminOrServiceUser$, - this.providerPortalTakeover$, this.provider$.pipe(map((provider) => provider?.enabled ?? false)), ]).pipe( - map( - ([isAdminOrServiceUser, portalTakeoverEnabled, providerEnabled]) => - isAdminOrServiceUser && portalTakeoverEnabled && !providerEnabled, - ), + map(([isAdminOrServiceUser, providerEnabled]) => isAdminOrServiceUser && !providerEnabled), ); private destroy$ = new Subject(); @@ -127,7 +117,6 @@ export class ManageClientsComponent implements OnInit, OnDestroy { private validationService: ValidationService, private webProviderService: WebProviderService, private billingNotificationService: BillingNotificationService, - private configService: ConfigService, private accountService: AccountService, private providerApiService: ProviderApiServiceAbstraction, ) {} diff --git a/bitwarden_license/bit-web/src/app/admin-console/providers/providers-layout.component.html b/bitwarden_license/bit-web/src/app/admin-console/providers/providers-layout.component.html index ab4aaa6bd69..15536b22ae6 100644 --- a/bitwarden_license/bit-web/src/app/admin-console/providers/providers-layout.component.html +++ b/bitwarden_license/bit-web/src/app/admin-console/providers/providers-layout.component.html @@ -12,7 +12,7 @@ route="clients" > ; protected clientsTranslationKey$: Observable; - protected providerPortalTakeover$: Observable; protected subscriber$: Observable; protected getTaxIdWarning$: () => Observable; @@ -56,7 +53,6 @@ export class ProvidersLayoutComponent implements OnInit, OnDestroy { constructor( private route: ActivatedRoute, private providerService: ProviderService, - private configService: ConfigService, private providerWarningsService: ProviderWarningsService, private accountService: AccountService, ) {} @@ -101,10 +97,6 @@ export class ProvidersLayoutComponent implements OnInit, OnDestroy { ) .subscribe(); - this.providerPortalTakeover$ = this.configService.getFeatureFlag$( - FeatureFlag.PM21821_ProviderPortalTakeover, - ); - this.subscriber$ = this.provider$.pipe( map((provider) => ({ type: "provider", diff --git a/bitwarden_license/bit-web/src/app/billing/providers/warnings/services/provider-warnings.service.spec.ts b/bitwarden_license/bit-web/src/app/billing/providers/warnings/services/provider-warnings.service.spec.ts index 0eb25bff524..b3e4cd9bcc7 100644 --- a/bitwarden_license/bit-web/src/app/billing/providers/warnings/services/provider-warnings.service.spec.ts +++ b/bitwarden_license/bit-web/src/app/billing/providers/warnings/services/provider-warnings.service.spec.ts @@ -5,7 +5,6 @@ import { of } from "rxjs"; import { ApiService } from "@bitwarden/common/abstractions/api.service"; import { Provider } from "@bitwarden/common/admin-console/models/domain/provider"; -import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { SyncService } from "@bitwarden/common/platform/sync"; import { ProviderId } from "@bitwarden/common/types/guid"; @@ -21,7 +20,6 @@ describe("ProviderWarningsService", () => { let service: ProviderWarningsService; let activatedRoute: MockProxy; let apiService: MockProxy; - let configService: MockProxy; let dialogService: MockProxy; let i18nService: MockProxy; let router: MockProxy; @@ -42,7 +40,6 @@ describe("ProviderWarningsService", () => { beforeEach(() => { activatedRoute = mock(); apiService = mock(); - configService = mock(); dialogService = mock(); i18nService = mock(); router = mock(); @@ -72,7 +69,6 @@ describe("ProviderWarningsService", () => { ProviderWarningsService, { provide: ActivatedRoute, useValue: activatedRoute }, { provide: ApiService, useValue: apiService }, - { provide: ConfigService, useValue: configService }, { provide: DialogService, useValue: dialogService }, { provide: I18nService, useValue: i18nService }, { provide: Router, useValue: router }, @@ -211,22 +207,7 @@ describe("ProviderWarningsService", () => { }); describe("showProviderSuspendedDialog$", () => { - it("should not show dialog when feature flag is disabled", (done) => { - configService.getFeatureFlag$.mockReturnValue(of(false)); - apiService.send.mockResolvedValue({ - Suspension: { Resolution: "add_payment_method" }, - }); - - service.showProviderSuspendedDialog$(provider).subscribe({ - complete: () => { - expect(dialogService.openSimpleDialog).not.toHaveBeenCalled(); - done(); - }, - }); - }); - it("should not show dialog when no suspension warning exists", (done) => { - configService.getFeatureFlag$.mockReturnValue(of(true)); apiService.send.mockResolvedValue({}); service.showProviderSuspendedDialog$(provider).subscribe({ @@ -239,7 +220,6 @@ describe("ProviderWarningsService", () => { it("should show add payment method dialog with cancellation date", (done) => { const cancelsAt = new Date(2024, 11, 31); - configService.getFeatureFlag$.mockReturnValue(of(true)); apiService.send.mockResolvedValue({ Suspension: { Resolution: "add_payment_method", @@ -282,7 +262,6 @@ describe("ProviderWarningsService", () => { }); it("should show add payment method dialog without cancellation date", (done) => { - configService.getFeatureFlag$.mockReturnValue(of(true)); apiService.send.mockResolvedValue({ Suspension: { Resolution: "add_payment_method", @@ -319,7 +298,6 @@ describe("ProviderWarningsService", () => { }); it("should show contact administrator dialog for contact_administrator resolution", (done) => { - configService.getFeatureFlag$.mockReturnValue(of(true)); apiService.send.mockResolvedValue({ Suspension: { Resolution: "contact_administrator", @@ -343,7 +321,6 @@ describe("ProviderWarningsService", () => { }); it("should show contact support dialog with action for contact_support resolution", (done) => { - configService.getFeatureFlag$.mockReturnValue(of(true)); apiService.send.mockResolvedValue({ Suspension: { Resolution: "contact_support", diff --git a/bitwarden_license/bit-web/src/app/billing/providers/warnings/services/provider-warnings.service.ts b/bitwarden_license/bit-web/src/app/billing/providers/warnings/services/provider-warnings.service.ts index 89ddf4b4788..7ff36cc2db8 100644 --- a/bitwarden_license/bit-web/src/app/billing/providers/warnings/services/provider-warnings.service.ts +++ b/bitwarden_license/bit-web/src/app/billing/providers/warnings/services/provider-warnings.service.ts @@ -2,7 +2,6 @@ import { Injectable } from "@angular/core"; import { ActivatedRoute, Router } from "@angular/router"; import { BehaviorSubject, - combineLatest, from, lastValueFrom, map, @@ -16,8 +15,6 @@ import { import { ApiService } from "@bitwarden/common/abstractions/api.service"; import { Provider } from "@bitwarden/common/admin-console/models/domain/provider"; -import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; -import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { SyncService } from "@bitwarden/common/platform/sync"; import { ProviderId } from "@bitwarden/common/types/guid"; @@ -39,7 +36,6 @@ export class ProviderWarningsService { constructor( private activatedRoute: ActivatedRoute, private apiService: ApiService, - private configService: ConfigService, private dialogService: DialogService, private i18nService: I18nService, private router: Router, @@ -61,12 +57,9 @@ export class ProviderWarningsService { refreshTaxIdWarning = () => this.refreshTaxIdWarningTrigger.next(); showProviderSuspendedDialog$ = (provider: Provider): Observable => - combineLatest([ - this.configService.getFeatureFlag$(FeatureFlag.PM21821_ProviderPortalTakeover), - this.getWarning$(provider, (response) => response.suspension), - ]).pipe( - switchMap(async ([providerPortalTakeover, warning]) => { - if (!providerPortalTakeover || !warning) { + this.getWarning$(provider, (response) => response.suspension).pipe( + switchMap(async (warning) => { + if (!warning) { return; } diff --git a/libs/common/src/enums/feature-flag.enum.ts b/libs/common/src/enums/feature-flag.enum.ts index 6010110f069..01ffdafcef9 100644 --- a/libs/common/src/enums/feature-flag.enum.ts +++ b/libs/common/src/enums/feature-flag.enum.ts @@ -24,7 +24,6 @@ export enum FeatureFlag { /* Billing */ TrialPaymentOptional = "PM-8163-trial-payment", - PM21821_ProviderPortalTakeover = "pm-21821-provider-portal-takeover", PM22415_TaxIDWarnings = "pm-22415-tax-id-warnings", PM24032_NewNavigationPremiumUpgradeButton = "pm-24032-new-navigation-premium-upgrade-button", PM25379_UseNewOrganizationMetadataStructure = "pm-25379-use-new-organization-metadata-structure", @@ -126,7 +125,6 @@ export const DefaultFeatureFlagValue = { /* Billing */ [FeatureFlag.TrialPaymentOptional]: FALSE, - [FeatureFlag.PM21821_ProviderPortalTakeover]: FALSE, [FeatureFlag.PM22415_TaxIDWarnings]: FALSE, [FeatureFlag.PM24032_NewNavigationPremiumUpgradeButton]: FALSE, [FeatureFlag.PM25379_UseNewOrganizationMetadataStructure]: FALSE, From f17890a26b50f0a95bf0c3d9afbd2c05f9b3b898 Mon Sep 17 00:00:00 2001 From: Jonathan Prusik Date: Tue, 2 Dec 2025 11:31:35 -0500 Subject: [PATCH 009/131] [PM-27798] Prevent inline menu from opening on the page outside of the viewport (#17664) * cleanup * prevent inline menu from opening on the page outside of the viewport * update inline menu viewport check to include checks on all sides of the viewport * use VisualViewport when available * update tests --- .../abstractions/overlay.background.ts | 4 +- .../autofill/background/overlay.background.ts | 10 +- ...utofill-inline-menu-iframe.service.spec.ts | 272 ++++++++++++++++-- .../autofill-inline-menu-iframe.service.ts | 45 +++ .../collect-autofill-content.service.ts | 2 +- 5 files changed, 306 insertions(+), 27 deletions(-) diff --git a/apps/browser/src/autofill/background/abstractions/overlay.background.ts b/apps/browser/src/autofill/background/abstractions/overlay.background.ts index 96809fa26b2..c8c938a99b2 100644 --- a/apps/browser/src/autofill/background/abstractions/overlay.background.ts +++ b/apps/browser/src/autofill/background/abstractions/overlay.background.ts @@ -69,8 +69,8 @@ export type FieldRect = { }; export type InlineMenuPosition = { - button?: InlineMenuElementPosition; - list?: InlineMenuElementPosition; + button?: InlineMenuElementPosition | null; + list?: InlineMenuElementPosition | null; }; export type NewLoginCipherData = { diff --git a/apps/browser/src/autofill/background/overlay.background.ts b/apps/browser/src/autofill/background/overlay.background.ts index af8141f1ab8..04a53395130 100644 --- a/apps/browser/src/autofill/background/overlay.background.ts +++ b/apps/browser/src/autofill/background/overlay.background.ts @@ -1424,11 +1424,11 @@ export class OverlayBackground implements OverlayBackgroundInterface { } /** - * calculates the postion and width for multi-input totp field inline menu - * @param totpFieldArray - the totp fields used to evaluate the position of the menu + * calculates the position and width for multi-input TOTP field inline menu + * @param totpFieldArray - the TOTP fields used to evaluate the position of the menu */ private calculateTotpMultiInputMenuBounds(totpFieldArray: AutofillField[]) { - // Filter the fields based on the provided totpfields + // Filter the fields based on the provided TOTP fields const filteredObjects = this.allFieldData.filter((obj) => totpFieldArray.some((o) => o.opid === obj.opid), ); @@ -1451,8 +1451,8 @@ export class OverlayBackground implements OverlayBackgroundInterface { } /** - * calculates the postion for multi-input totp field inline button - * @param totpFieldArray - the totp fields used to evaluate the position of the menu + * calculates the position for multi-input TOTP field inline button + * @param totpFieldArray - the TOTP fields used to evaluate the position of the menu */ private calculateTotpMultiInputButtonBounds(totpFieldArray: AutofillField[]) { const filteredObjects = this.allFieldData.filter((obj) => diff --git a/apps/browser/src/autofill/overlay/inline-menu/iframe-content/autofill-inline-menu-iframe.service.spec.ts b/apps/browser/src/autofill/overlay/inline-menu/iframe-content/autofill-inline-menu-iframe.service.spec.ts index 3bb86ee7876..f1ed6875f90 100644 --- a/apps/browser/src/autofill/overlay/inline-menu/iframe-content/autofill-inline-menu-iframe.service.spec.ts +++ b/apps/browser/src/autofill/overlay/inline-menu/iframe-content/autofill-inline-menu-iframe.service.spec.ts @@ -1,7 +1,7 @@ import { mock } from "jest-mock-extended"; import { EVENTS } from "@bitwarden/common/autofill/constants"; -import { ThemeType } from "@bitwarden/common/platform/enums"; +import { ThemeTypes } from "@bitwarden/common/platform/enums"; import { AutofillOverlayPort } from "../../../enums/autofill-overlay.enum"; import { createPortSpyMock } from "../../../spec/autofill-mocks"; @@ -66,17 +66,38 @@ describe("AutofillInlineMenuIframeService", () => { ); }); - // TODO CG - This test is brittle and failing due to how we are calling the private method. This needs to be reworked - it.skip("creates an aria alert element if the ariaAlert param is passed", () => { - const ariaAlert = "aria alert"; + it("creates an aria alert element if the ariaAlert param is passed to AutofillInlineMenuIframeService", () => { jest.spyOn(autofillInlineMenuIframeService as any, "createAriaAlertElement"); autofillInlineMenuIframeService.initMenuIframe(); - expect(autofillInlineMenuIframeService["createAriaAlertElement"]).toHaveBeenCalledWith( - ariaAlert, + expect(autofillInlineMenuIframeService["createAriaAlertElement"]).toHaveBeenCalled(); + expect(autofillInlineMenuIframeService["ariaAlertElement"]).toBeDefined(); + expect(autofillInlineMenuIframeService["ariaAlertElement"].getAttribute("role")).toBe( + "alert", ); - expect(autofillInlineMenuIframeService["ariaAlertElement"]).toMatchSnapshot(); + expect(autofillInlineMenuIframeService["ariaAlertElement"].getAttribute("aria-live")).toBe( + "polite", + ); + expect(autofillInlineMenuIframeService["ariaAlertElement"].getAttribute("aria-atomic")).toBe( + "true", + ); + }); + + it("does not create an aria alert element if the ariaAlert param is not passed to AutofillInlineMenuIframeService", () => { + const shadowWithoutAlert = document.createElement("div").attachShadow({ mode: "open" }); + const serviceWithoutAlert = new AutofillInlineMenuIframeService( + shadowWithoutAlert, + AutofillOverlayPort.Button, + { height: "0px" }, + "title", + ); + jest.spyOn(serviceWithoutAlert as any, "createAriaAlertElement"); + + serviceWithoutAlert.initMenuIframe(); + + expect(serviceWithoutAlert["createAriaAlertElement"]).not.toHaveBeenCalled(); + expect(serviceWithoutAlert["ariaAlertElement"]).toBeUndefined(); }); describe("on load of the iframe source", () => { @@ -200,7 +221,7 @@ describe("AutofillInlineMenuIframeService", () => { sendPortMessage(portSpy, { command: "updateAutofillInlineMenuPosition" }); expect( - autofillInlineMenuIframeService["iframe"].contentWindow.postMessage, + autofillInlineMenuIframeService["iframe"].contentWindow?.postMessage, ).not.toHaveBeenCalled(); }); @@ -216,7 +237,7 @@ describe("AutofillInlineMenuIframeService", () => { expect(autofillInlineMenuIframeService["portKey"]).toBe(portKey); expect( - autofillInlineMenuIframeService["iframe"].contentWindow.postMessage, + autofillInlineMenuIframeService["iframe"].contentWindow?.postMessage, ).toHaveBeenCalledWith(message, autofillInlineMenuIframeService["extensionOrigin"]); }); }); @@ -234,14 +255,14 @@ describe("AutofillInlineMenuIframeService", () => { it("passes the message on to the iframe element", () => { const message = { command: "initAutofillInlineMenuList", - theme: ThemeType.Light, + theme: ThemeTypes.Light, }; sendPortMessage(portSpy, message); expect(updateElementStylesSpy).not.toHaveBeenCalled(); expect( - autofillInlineMenuIframeService["iframe"].contentWindow.postMessage, + autofillInlineMenuIframeService["iframe"].contentWindow?.postMessage, ).toHaveBeenCalledWith(message, autofillInlineMenuIframeService["extensionOrigin"]); }); @@ -249,18 +270,18 @@ describe("AutofillInlineMenuIframeService", () => { window.matchMedia = jest.fn(() => mock({ matches: false })); const message = { command: "initAutofillInlineMenuList", - theme: ThemeType.System, + theme: ThemeTypes.System, }; sendPortMessage(portSpy, message); expect(window.matchMedia).toHaveBeenCalledWith("(prefers-color-scheme: dark)"); expect( - autofillInlineMenuIframeService["iframe"].contentWindow.postMessage, + autofillInlineMenuIframeService["iframe"].contentWindow?.postMessage, ).toHaveBeenCalledWith( { command: "initAutofillInlineMenuList", - theme: ThemeType.Light, + theme: ThemeTypes.Light, }, autofillInlineMenuIframeService["extensionOrigin"], ); @@ -270,18 +291,18 @@ describe("AutofillInlineMenuIframeService", () => { window.matchMedia = jest.fn(() => mock({ matches: true })); const message = { command: "initAutofillInlineMenuList", - theme: ThemeType.System, + theme: ThemeTypes.System, }; sendPortMessage(portSpy, message); expect(window.matchMedia).toHaveBeenCalledWith("(prefers-color-scheme: dark)"); expect( - autofillInlineMenuIframeService["iframe"].contentWindow.postMessage, + autofillInlineMenuIframeService["iframe"].contentWindow?.postMessage, ).toHaveBeenCalledWith( { command: "initAutofillInlineMenuList", - theme: ThemeType.Dark, + theme: ThemeTypes.Dark, }, autofillInlineMenuIframeService["extensionOrigin"], ); @@ -290,7 +311,7 @@ describe("AutofillInlineMenuIframeService", () => { it("updates the border to match the `dark` theme", () => { const message = { command: "initAutofillInlineMenuList", - theme: ThemeType.Dark, + theme: ThemeTypes.Dark, }; sendPortMessage(portSpy, message); @@ -364,6 +385,219 @@ describe("AutofillInlineMenuIframeService", () => { autofillInlineMenuIframeService["handleFadeInInlineMenuIframe"], ).toHaveBeenCalled(); }); + + it("closes the inline menu when iframe is outside the viewport (bottom)", () => { + const viewportHeight = 800; + jest.spyOn(globalThis.document, "hasFocus").mockReturnValue(true); + jest + .spyOn(autofillInlineMenuIframeService["iframe"], "getBoundingClientRect") + .mockReturnValue({ + top: 0, + left: 0, + right: 100, + bottom: viewportHeight + 1, + height: 98, + width: 262, + } as DOMRect); + Object.defineProperty(globalThis.window, "innerHeight", { + value: viewportHeight, + writable: true, + configurable: true, + }); + Object.defineProperty(globalThis.window, "innerWidth", { + value: 1200, + writable: true, + configurable: true, + }); + + sendPortMessage(portSpy, { + command: "updateAutofillInlineMenuPosition", + styles: {}, + }); + + expect(sendExtensionMessageSpy).toHaveBeenCalledWith("closeAutofillInlineMenu", { + forceCloseInlineMenu: true, + }); + }); + + it("closes the inline menu when iframe is outside the viewport (right)", () => { + const viewportWidth = 1200; + jest.spyOn(globalThis.document, "hasFocus").mockReturnValue(true); + jest + .spyOn(autofillInlineMenuIframeService["iframe"], "getBoundingClientRect") + .mockReturnValue({ + top: 0, + left: 0, + right: viewportWidth + 1, + bottom: 100, + height: 98, + width: 262, + } as DOMRect); + Object.defineProperty(globalThis.window, "innerHeight", { + value: 800, + writable: true, + configurable: true, + }); + Object.defineProperty(globalThis.window, "innerWidth", { + value: viewportWidth, + writable: true, + configurable: true, + }); + + sendPortMessage(portSpy, { + command: "updateAutofillInlineMenuPosition", + styles: {}, + }); + + expect(sendExtensionMessageSpy).toHaveBeenCalledWith("closeAutofillInlineMenu", { + forceCloseInlineMenu: true, + }); + }); + + it("closes the inline menu when iframe is outside the viewport (left)", () => { + jest.spyOn(globalThis.document, "hasFocus").mockReturnValue(true); + jest + .spyOn(autofillInlineMenuIframeService["iframe"], "getBoundingClientRect") + .mockReturnValue({ + top: 0, + left: -1, + right: 0, + bottom: 100, + height: 98, + width: 262, + } as DOMRect); + Object.defineProperty(globalThis.window, "innerHeight", { + value: 800, + writable: true, + configurable: true, + }); + Object.defineProperty(globalThis.window, "innerWidth", { + value: 1200, + writable: true, + configurable: true, + }); + + sendPortMessage(portSpy, { + command: "updateAutofillInlineMenuPosition", + styles: {}, + }); + + expect(sendExtensionMessageSpy).toHaveBeenCalledWith("closeAutofillInlineMenu", { + forceCloseInlineMenu: true, + }); + }); + + it("closes the inline menu when iframe is outside the viewport (top)", () => { + jest.spyOn(globalThis.document, "hasFocus").mockReturnValue(true); + jest + .spyOn(autofillInlineMenuIframeService["iframe"], "getBoundingClientRect") + .mockReturnValue({ + top: -1, + left: 0, + right: 100, + bottom: 0, + height: 98, + width: 262, + } as DOMRect); + Object.defineProperty(globalThis.window, "innerHeight", { + value: 800, + writable: true, + configurable: true, + }); + Object.defineProperty(globalThis.window, "innerWidth", { + value: 1200, + writable: true, + configurable: true, + }); + + sendPortMessage(portSpy, { + command: "updateAutofillInlineMenuPosition", + styles: {}, + }); + + expect(sendExtensionMessageSpy).toHaveBeenCalledWith("closeAutofillInlineMenu", { + forceCloseInlineMenu: true, + }); + }); + + it("allows iframe (do not close) when it has no dimensions", () => { + jest.spyOn(globalThis.document, "hasFocus").mockReturnValue(true); + jest + .spyOn(autofillInlineMenuIframeService["iframe"], "getBoundingClientRect") + .mockReturnValue({ + top: 0, + left: 0, + right: 0, + bottom: 0, + height: 0, + width: 0, + } as DOMRect); + + Object.defineProperty(globalThis.window, "innerHeight", { + value: 800, + writable: true, + configurable: true, + }); + + Object.defineProperty(globalThis.window, "innerWidth", { + value: 1200, + writable: true, + configurable: true, + }); + + sendPortMessage(portSpy, { + command: "updateAutofillInlineMenuPosition", + styles: {}, + }); + + expect(sendExtensionMessageSpy).not.toHaveBeenCalledWith("closeAutofillInlineMenu", { + forceCloseInlineMenu: true, + }); + }); + + it("uses visualViewport when available", () => { + jest.spyOn(globalThis.document, "hasFocus").mockReturnValue(true); + jest + .spyOn(autofillInlineMenuIframeService["iframe"], "getBoundingClientRect") + .mockReturnValue({ + top: 0, + left: 0, + right: 100, + bottom: 700, + height: 98, + width: 262, + } as DOMRect); + + Object.defineProperty(globalThis.window, "visualViewport", { + value: { + height: 600, + width: 1200, + } as VisualViewport, + writable: true, + configurable: true, + }); + + Object.defineProperty(globalThis.window, "innerHeight", { + value: 800, + writable: true, + configurable: true, + }); + + Object.defineProperty(globalThis.window, "innerWidth", { + value: 1200, + writable: true, + configurable: true, + }); + + sendPortMessage(portSpy, { + command: "updateAutofillInlineMenuPosition", + styles: {}, + }); + + expect(sendExtensionMessageSpy).toHaveBeenCalledWith("closeAutofillInlineMenu", { + forceCloseInlineMenu: true, + }); + }); }); it("updates the visibility of the iframe", () => { @@ -381,7 +615,7 @@ describe("AutofillInlineMenuIframeService", () => { }); expect( - autofillInlineMenuIframeService["iframe"].contentWindow.postMessage, + autofillInlineMenuIframeService["iframe"].contentWindow?.postMessage, ).toHaveBeenCalledWith( { command: "updateAutofillInlineMenuColorScheme", diff --git a/apps/browser/src/autofill/overlay/inline-menu/iframe-content/autofill-inline-menu-iframe.service.ts b/apps/browser/src/autofill/overlay/inline-menu/iframe-content/autofill-inline-menu-iframe.service.ts index 8b1423b1290..64ef7d180ed 100644 --- a/apps/browser/src/autofill/overlay/inline-menu/iframe-content/autofill-inline-menu-iframe.service.ts +++ b/apps/browser/src/autofill/overlay/inline-menu/iframe-content/autofill-inline-menu-iframe.service.ts @@ -282,6 +282,15 @@ export class AutofillInlineMenuIframeService implements AutofillInlineMenuIframe const styles = this.fadeInTimeout ? Object.assign(position, { opacity: "0" }) : position; this.updateElementStyles(this.iframe, styles); + const elementHeightCompletelyInViewport = this.isElementCompletelyWithinViewport( + this.iframe.getBoundingClientRect(), + ); + + if (!elementHeightCompletelyInViewport) { + this.forceCloseInlineMenu(); + return; + } + if (this.fadeInTimeout) { this.handleFadeInInlineMenuIframe(); } @@ -289,6 +298,42 @@ export class AutofillInlineMenuIframeService implements AutofillInlineMenuIframe this.announceAriaAlert(this.ariaAlert, 2000); } + /** + * Check if element is completely within the browser viewport. + */ + private isElementCompletelyWithinViewport(elementPosition: DOMRect) { + // An element that lacks size should be considered within the viewport + if (!elementPosition.height || !elementPosition.width) { + return true; + } + + const [viewportHeight, viewportWidth] = this.getViewportSize(); + + const rightSideIsWithinViewport = (elementPosition.right || 0) <= viewportWidth; + const leftSideIsWithinViewport = (elementPosition.left || 0) >= 0; + const topSideIsWithinViewport = (elementPosition.top || 0) >= 0; + const bottomSideIsWithinViewport = (elementPosition.bottom || 0) <= viewportHeight; + + return ( + rightSideIsWithinViewport && + leftSideIsWithinViewport && + topSideIsWithinViewport && + bottomSideIsWithinViewport + ); + } + + /** Use Visual Viewport API if available (better for mobile/zoom) */ + private getViewportSize(): [ + VisualViewport["height"] | Window["innerHeight"], + VisualViewport["width"] | Window["innerWidth"], + ] { + if ("visualViewport" in globalThis.window && globalThis.window.visualViewport) { + return [globalThis.window.visualViewport.height, globalThis.window.visualViewport.width]; + } + + return [globalThis.window.innerHeight, globalThis.window.innerWidth]; + } + /** * Gets the page color scheme meta tag and sends a message to the iframe * to update its color scheme. Will default to "normal" if the meta tag diff --git a/apps/browser/src/autofill/services/collect-autofill-content.service.ts b/apps/browser/src/autofill/services/collect-autofill-content.service.ts index 6f2c00a4dd4..367599f7ad0 100644 --- a/apps/browser/src/autofill/services/collect-autofill-content.service.ts +++ b/apps/browser/src/autofill/services/collect-autofill-content.service.ts @@ -1400,7 +1400,7 @@ export class CollectAutofillContentService implements CollectAutofillContentServ this.intersectionObserver = new IntersectionObserver(this.handleFormElementIntersection, { root: null, rootMargin: "0px", - threshold: 1.0, + threshold: 0.9999, // Safari doesn't seem to function properly with a threshold of 1, }); } From aa309e4e56e8326a7581ff3493629eebe641419d Mon Sep 17 00:00:00 2001 From: Todd Martin <106564991+trmartin4@users.noreply.github.com> Date: Tue, 2 Dec 2025 12:02:39 -0500 Subject: [PATCH 010/131] Revert "Bumped client version(s)". (#17765) This reverts commit 406dbc80666ba95b77b243ebc4c747c6438f75d3. --- apps/web/package.json | 2 +- package-lock.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/web/package.json b/apps/web/package.json index a5399de920e..344a78f2a2c 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -1,6 +1,6 @@ { "name": "@bitwarden/web-vault", - "version": "2025.12.1", + "version": "2025.12.0", "scripts": { "build:oss": "webpack", "build:bit": "webpack -c ../../bitwarden_license/bit-web/webpack.config.js", diff --git a/package-lock.json b/package-lock.json index 80a641f3a00..12149d25773 100644 --- a/package-lock.json +++ b/package-lock.json @@ -292,7 +292,7 @@ }, "apps/web": { "name": "@bitwarden/web-vault", - "version": "2025.12.1" + "version": "2025.12.0" }, "libs/admin-console": { "name": "@bitwarden/admin-console", From 12222e39b47cbeb10c87eb7a42c4f7490242f7d5 Mon Sep 17 00:00:00 2001 From: cyprain-okeke <108260115+cyprain-okeke@users.noreply.github.com> Date: Tue, 2 Dec 2025 18:05:39 +0100 Subject: [PATCH 011/131] [PM-28258]Fix [Defect] New Organization creation without payment method succeeds without organization creation (#17719) * Resolve the payment validation issue * remove the null error --- .../organization-plans.component.ts | 31 ++++++++++++++++--- 1 file changed, 26 insertions(+), 5 deletions(-) diff --git a/apps/web/src/app/billing/organizations/organization-plans.component.ts b/apps/web/src/app/billing/organizations/organization-plans.component.ts index 561a3e03deb..67f6f9b0a6b 100644 --- a/apps/web/src/app/billing/organizations/organization-plans.component.ts +++ b/apps/web/src/app/billing/organizations/organization-plans.component.ts @@ -654,6 +654,14 @@ export class OrganizationPlansComponent implements OnInit, OnDestroy { if (this.singleOrgPolicyBlock) { return; } + + // Validate billing form for paid plans during creation + if (this.createOrganization && this.selectedPlan.type !== PlanType.Free) { + this.billingFormGroup.markAllAsTouched(); + if (this.billingFormGroup.invalid) { + return; + } + } const doSubmit = async (): Promise => { let orgId: string; if (this.createOrganization) { @@ -703,11 +711,18 @@ export class OrganizationPlansComponent implements OnInit, OnDestroy { return orgId; }; - this.formPromise = doSubmit(); - const organizationId = await this.formPromise; - this.onSuccess.emit({ organizationId: organizationId }); - // TODO: No one actually listening to this message? - this.messagingService.send("organizationCreated", { organizationId }); + try { + this.formPromise = doSubmit(); + const organizationId = await this.formPromise; + this.onSuccess.emit({ organizationId: organizationId }); + // TODO: No one actually listening to this message? + this.messagingService.send("organizationCreated", { organizationId }); + } catch (error: unknown) { + if (error instanceof Error && error.message === "Payment method validation failed") { + return; + } + throw error; + } }; protected get showTaxIdField(): boolean { @@ -826,6 +841,9 @@ export class OrganizationPlansComponent implements OnInit, OnDestroy { return; } const paymentMethod = await this.enterPaymentMethodComponent.tokenize(); + if (!paymentMethod) { + throw new Error("Payment method validation failed"); + } await this.subscriberBillingClient.updatePaymentMethod( { type: "organization", data: this.organization }, paymentMethod, @@ -877,6 +895,9 @@ export class OrganizationPlansComponent implements OnInit, OnDestroy { } const paymentMethod = await this.enterPaymentMethodComponent.tokenize(); + if (!paymentMethod) { + throw new Error("Payment method validation failed"); + } const billingAddress = getBillingAddressFromForm( this.billingFormGroup.controls.billingAddress, From 92709e63afc1b0a862e0bfaa7919b7c05a545060 Mon Sep 17 00:00:00 2001 From: Todd Martin <106564991+trmartin4@users.noreply.github.com> Date: Tue, 2 Dec 2025 12:20:22 -0500 Subject: [PATCH 012/131] chore(workflows): Updated branch for checkout --- .github/workflows/repository-management.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/repository-management.yml b/.github/workflows/repository-management.yml index 955ac9a9cf3..0a343be878c 100644 --- a/.github/workflows/repository-management.yml +++ b/.github/workflows/repository-management.yml @@ -29,7 +29,7 @@ on: default: false target_ref: default: "main" - description: "Branch/Tag to target for cut" + description: "Branch/Tag to target for cut (ignored if not cutting rc)" required: true type: string version_number_override: @@ -107,7 +107,7 @@ jobs: - name: Check out branch uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 with: - ref: main + ref: ${{ github.ref }} token: ${{ steps.app-token.outputs.token }} persist-credentials: true From dd99190ca2d1a82971aa0f0ed4786534b4636fe9 Mon Sep 17 00:00:00 2001 From: Nick Krantz <125900171+nick-livefront@users.noreply.github.com> Date: Tue, 2 Dec 2025 11:43:02 -0600 Subject: [PATCH 013/131] allow for archived ciphers to be shared into an organization (#17592) --- libs/common/src/vault/models/view/cipher.view.ts | 4 ---- .../item-details/item-details-section.component.ts | 5 ----- 2 files changed, 9 deletions(-) diff --git a/libs/common/src/vault/models/view/cipher.view.ts b/libs/common/src/vault/models/view/cipher.view.ts index 0d4ab8e5207..db360f7f991 100644 --- a/libs/common/src/vault/models/view/cipher.view.ts +++ b/libs/common/src/vault/models/view/cipher.view.ts @@ -166,10 +166,6 @@ export class CipherView implements View, InitializerMetadata { } get canAssignToCollections(): boolean { - if (this.isArchived) { - return false; - } - if (this.organizationId == null) { return true; } diff --git a/libs/vault/src/cipher-form/components/item-details/item-details-section.component.ts b/libs/vault/src/cipher-form/components/item-details/item-details-section.component.ts index 6fd74d86525..58959a957a8 100644 --- a/libs/vault/src/cipher-form/components/item-details/item-details-section.component.ts +++ b/libs/vault/src/cipher-form/components/item-details/item-details-section.component.ts @@ -192,11 +192,6 @@ export class ItemDetailsSectionComponent implements OnInit { } get showOwnership() { - // Don't show ownership field for archived ciphers - if (this.originalCipherView?.isArchived) { - return false; - } - // Show ownership field when editing with available orgs const isEditingWithOrgs = this.organizations.length > 0 && this.config.mode === "edit"; From 57b6d8ba581efd5ecb0d45d77e85b1b4cb381b27 Mon Sep 17 00:00:00 2001 From: Todd Martin <106564991+trmartin4@users.noreply.github.com> Date: Tue, 2 Dec 2025 13:24:22 -0500 Subject: [PATCH 014/131] chore: [PM-28640] revert script injection change * chore: revert script injection change * Removed async * Adjust tests. * Revert fido2.background.ts changes. --------- Co-authored-by: Andreas Coroiu --- .../abstractions/fido2.background.ts | 1 - .../fido2/background/fido2.background.spec.ts | 1 - .../fido2/background/fido2.background.ts | 1 - .../fido2-page-script-append.mv2.spec.ts | 18 ++++-------------- .../fido2-page-script-delay-append.mv2.ts | 17 ++++------------- 5 files changed, 8 insertions(+), 30 deletions(-) diff --git a/apps/browser/src/autofill/fido2/background/abstractions/fido2.background.ts b/apps/browser/src/autofill/fido2/background/abstractions/fido2.background.ts index b341be28ebb..6ad069ad56e 100644 --- a/apps/browser/src/autofill/fido2/background/abstractions/fido2.background.ts +++ b/apps/browser/src/autofill/fido2/background/abstractions/fido2.background.ts @@ -13,7 +13,6 @@ type SharedFido2ScriptRegistrationOptions = SharedFido2ScriptInjectionDetails & matches: string[]; excludeMatches: string[]; allFrames: true; - world?: "MAIN" | "ISOLATED"; }; type Fido2ExtensionMessage = { diff --git a/apps/browser/src/autofill/fido2/background/fido2.background.spec.ts b/apps/browser/src/autofill/fido2/background/fido2.background.spec.ts index 76ad78a6cd8..752851b3d37 100644 --- a/apps/browser/src/autofill/fido2/background/fido2.background.spec.ts +++ b/apps/browser/src/autofill/fido2/background/fido2.background.spec.ts @@ -203,7 +203,6 @@ describe("Fido2Background", () => { { file: Fido2ContentScript.PageScriptDelayAppend }, { file: Fido2ContentScript.ContentScript }, ], - world: "ISOLATED", ...sharedRegistrationOptions, }); }); diff --git a/apps/browser/src/autofill/fido2/background/fido2.background.ts b/apps/browser/src/autofill/fido2/background/fido2.background.ts index 0ee7a43767f..22ee4a1822d 100644 --- a/apps/browser/src/autofill/fido2/background/fido2.background.ts +++ b/apps/browser/src/autofill/fido2/background/fido2.background.ts @@ -176,7 +176,6 @@ export class Fido2Background implements Fido2BackgroundInterface { { file: await this.getFido2PageScriptAppendFileName() }, { file: Fido2ContentScript.ContentScript }, ], - world: "ISOLATED", ...this.sharedRegistrationOptions, }); } diff --git a/apps/browser/src/autofill/fido2/content/fido2-page-script-append.mv2.spec.ts b/apps/browser/src/autofill/fido2/content/fido2-page-script-append.mv2.spec.ts index 0b10841e390..b444c967080 100644 --- a/apps/browser/src/autofill/fido2/content/fido2-page-script-append.mv2.spec.ts +++ b/apps/browser/src/autofill/fido2/content/fido2-page-script-append.mv2.spec.ts @@ -29,48 +29,38 @@ describe("FIDO2 page-script for manifest v2", () => { expect(window.document.createElement).not.toHaveBeenCalled(); }); - it("appends the `page-script.js` file to the document head when the contentType is `text/html`", async () => { - const scriptContents = "test-script-contents"; + it("appends the `page-script.js` file to the document head when the contentType is `text/html`", () => { jest.spyOn(window.document.head, "prepend").mockImplementation((node) => { createdScriptElement = node as HTMLScriptElement; return node; }); - window.fetch = jest.fn().mockResolvedValue({ - text: () => Promise.resolve(scriptContents), - } as Response); // FIXME: Remove when updating file. Eslint update // eslint-disable-next-line @typescript-eslint/no-require-imports require("./fido2-page-script-delay-append.mv2.ts"); - await jest.runAllTimersAsync(); expect(window.document.createElement).toHaveBeenCalledWith("script"); expect(chrome.runtime.getURL).toHaveBeenCalledWith(Fido2ContentScript.PageScript); expect(window.document.head.prepend).toHaveBeenCalledWith(expect.any(HTMLScriptElement)); - expect(createdScriptElement.innerHTML).toBe(scriptContents); + expect(createdScriptElement.src).toBe(`chrome-extension://id/${Fido2ContentScript.PageScript}`); }); - it("appends the `page-script.js` file to the document element if the head is not available", async () => { - const scriptContents = "test-script-contents"; + it("appends the `page-script.js` file to the document element if the head is not available", () => { window.document.documentElement.removeChild(window.document.head); jest.spyOn(window.document.documentElement, "prepend").mockImplementation((node) => { createdScriptElement = node as HTMLScriptElement; return node; }); - window.fetch = jest.fn().mockResolvedValue({ - text: () => Promise.resolve(scriptContents), - } as Response); // FIXME: Remove when updating file. Eslint update // eslint-disable-next-line @typescript-eslint/no-require-imports require("./fido2-page-script-delay-append.mv2.ts"); - await jest.runAllTimersAsync(); expect(window.document.createElement).toHaveBeenCalledWith("script"); expect(chrome.runtime.getURL).toHaveBeenCalledWith(Fido2ContentScript.PageScript); expect(window.document.documentElement.prepend).toHaveBeenCalledWith( expect.any(HTMLScriptElement), ); - expect(createdScriptElement.innerHTML).toBe(scriptContents); + expect(createdScriptElement.src).toBe(`chrome-extension://id/${Fido2ContentScript.PageScript}`); }); }); diff --git a/apps/browser/src/autofill/fido2/content/fido2-page-script-delay-append.mv2.ts b/apps/browser/src/autofill/fido2/content/fido2-page-script-delay-append.mv2.ts index 8c0d17c7e21..ffa6f7051c3 100644 --- a/apps/browser/src/autofill/fido2/content/fido2-page-script-delay-append.mv2.ts +++ b/apps/browser/src/autofill/fido2/content/fido2-page-script-delay-append.mv2.ts @@ -2,26 +2,17 @@ * This script handles injection of the FIDO2 override page script into the document. * This is required for manifest v2, but will be removed when we migrate fully to manifest v3. */ -void (async function (globalContext) { +(function (globalContext) { if (globalContext.document.contentType !== "text/html") { return; } const script = globalContext.document.createElement("script"); + // We're removing stack trace information in the page script instead + // eslint-disable-next-line @bitwarden/platform/no-page-script-url-leakage + script.src = chrome.runtime.getURL("content/fido2-page-script.js"); script.async = false; - const pageScriptUrl = chrome.runtime.getURL("content/fido2-page-script.js"); - // Inject the script contents directly to avoid leaking the extension URL - try { - const response = await fetch(pageScriptUrl); - const scriptContents = await response.text(); - script.innerHTML = scriptContents; - } catch { - // eslint-disable-next-line no-console - console.error("Failed to load FIDO2 page script contents. Injection failed."); - return; - } - // We are ensuring that the script injection is delayed in the event that we are loading // within an iframe element. This prevents an issue with web mail clients that load content // using ajax within iframes. In particular, Zimbra web mail client was observed to have this issue. From d373eefc9d2ed0eb2ddc49516669e0d99dcbce12 Mon Sep 17 00:00:00 2001 From: Leslie Xiong Date: Tue, 2 Dec 2025 13:26:28 -0500 Subject: [PATCH 015/131] [PM-27793] Create new v3 vault component (#17684) Created Vault component for desktop vault-v3 - copied content from vault-v2.component.ts/html - removed vault filters from html --- .../app/layout/desktop-layout.component.ts | 5 +- .../vault/app/vault-v3/vault.component.html | 70 ++ .../app/vault-v3/vault.component.spec.ts | 22 - .../src/vault/app/vault-v3/vault.component.ts | 1021 ++++++++++++++++- 4 files changed, 1089 insertions(+), 29 deletions(-) create mode 100644 apps/desktop/src/vault/app/vault-v3/vault.component.html delete mode 100644 apps/desktop/src/vault/app/vault-v3/vault.component.spec.ts diff --git a/apps/desktop/src/app/layout/desktop-layout.component.ts b/apps/desktop/src/app/layout/desktop-layout.component.ts index 5059a6e4d0b..006055f475f 100644 --- a/apps/desktop/src/app/layout/desktop-layout.component.ts +++ b/apps/desktop/src/app/layout/desktop-layout.component.ts @@ -1,4 +1,4 @@ -import { ChangeDetectionStrategy, Component } from "@angular/core"; +import { Component } from "@angular/core"; import { RouterModule } from "@angular/router"; import { PasswordManagerLogo } from "@bitwarden/assets/svg"; @@ -7,11 +7,12 @@ import { I18nPipe } from "@bitwarden/ui-common"; import { DesktopSideNavComponent } from "./desktop-side-nav.component"; +// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush +// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection @Component({ selector: "app-layout", imports: [RouterModule, I18nPipe, LayoutComponent, NavigationModule, DesktopSideNavComponent], templateUrl: "./desktop-layout.component.html", - changeDetection: ChangeDetectionStrategy.OnPush, }) export class DesktopLayoutComponent { protected readonly logo = PasswordManagerLogo; diff --git a/apps/desktop/src/vault/app/vault-v3/vault.component.html b/apps/desktop/src/vault/app/vault-v3/vault.component.html new file mode 100644 index 00000000000..a9a25f57994 --- /dev/null +++ b/apps/desktop/src/vault/app/vault-v3/vault.component.html @@ -0,0 +1,70 @@ +
    + + +
    + +
    +
    +
    + + + + + + + +
    +
    +
    +
    + +
    + diff --git a/apps/desktop/src/vault/app/vault-v3/vault.component.spec.ts b/apps/desktop/src/vault/app/vault-v3/vault.component.spec.ts deleted file mode 100644 index 89ba05055f8..00000000000 --- a/apps/desktop/src/vault/app/vault-v3/vault.component.spec.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { ComponentFixture, TestBed } from "@angular/core/testing"; - -import { VaultComponent } from "./vault.component"; - -describe("VaultComponent", () => { - let component: VaultComponent; - let fixture: ComponentFixture; - - beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [VaultComponent], - }).compileComponents(); - - fixture = TestBed.createComponent(VaultComponent); - component = fixture.componentInstance; - fixture.detectChanges(); - }); - - it("creates component", () => { - expect(component).toBeTruthy(); - }); -}); diff --git a/apps/desktop/src/vault/app/vault-v3/vault.component.ts b/apps/desktop/src/vault/app/vault-v3/vault.component.ts index b29b66225c7..21ba7547f8b 100644 --- a/apps/desktop/src/vault/app/vault-v3/vault.component.ts +++ b/apps/desktop/src/vault/app/vault-v3/vault.component.ts @@ -1,9 +1,1020 @@ -import { ChangeDetectionStrategy, Component } from "@angular/core"; +import { CommonModule } from "@angular/common"; +import { + ChangeDetectorRef, + Component, + NgZone, + OnDestroy, + OnInit, + ViewChild, + ViewContainerRef, +} from "@angular/core"; +import { ActivatedRoute, Router } from "@angular/router"; +import { firstValueFrom, Subject, takeUntil, switchMap, lastValueFrom, Observable } from "rxjs"; +import { filter, map, take } from "rxjs/operators"; +import { CollectionService, CollectionView } from "@bitwarden/admin-console/common"; +import { PremiumBadgeComponent } from "@bitwarden/angular/billing/components/premium-badge"; +import { VaultViewPasswordHistoryService } from "@bitwarden/angular/services/view-password-history.service"; +import { VaultFilter } from "@bitwarden/angular/vault/vault-filter/models/vault-filter.model"; +import { AuthRequestServiceAbstraction } from "@bitwarden/auth/common"; +import { ApiService } from "@bitwarden/common/abstractions/api.service"; +import { EventCollectionService } from "@bitwarden/common/abstractions/event/event-collection.service"; +import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; +import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; +import { PolicyType } from "@bitwarden/common/admin-console/enums"; +import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; +import { Account, AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { getUserId } from "@bitwarden/common/auth/services/account.service"; +import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service"; +import { EventType } from "@bitwarden/common/enums"; +import { BroadcasterService } from "@bitwarden/common/platform/abstractions/broadcaster.service"; +import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service"; +import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; +import { getByIds } from "@bitwarden/common/platform/misc"; +import { SyncService } from "@bitwarden/common/platform/sync"; +import { CipherId, OrganizationId, UserId } from "@bitwarden/common/types/guid"; +import { CipherArchiveService } from "@bitwarden/common/vault/abstractions/cipher-archive.service"; +import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; +import { FolderService } from "@bitwarden/common/vault/abstractions/folder/folder.service.abstraction"; +import { PremiumUpgradePromptService } from "@bitwarden/common/vault/abstractions/premium-upgrade-prompt.service"; +import { TotpService } from "@bitwarden/common/vault/abstractions/totp.service"; +import { ViewPasswordHistoryService } from "@bitwarden/common/vault/abstractions/view-password-history.service"; +import { CipherType, toCipherType } from "@bitwarden/common/vault/enums"; +import { CipherRepromptType } from "@bitwarden/common/vault/enums/cipher-reprompt-type"; +import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; +import { + CipherViewLike, + CipherViewLikeUtils, +} from "@bitwarden/common/vault/utils/cipher-view-like-utils"; +import { filterOutNullish } from "@bitwarden/common/vault/utils/observable-utilities"; +import { + BadgeModule, + ButtonModule, + DialogService, + ItemModule, + ToastService, + CopyClickListener, + COPY_CLICK_LISTENER, +} from "@bitwarden/components"; +import { I18nPipe } from "@bitwarden/ui-common"; +import { + AddEditFolderDialogComponent, + AddEditFolderDialogResult, + AttachmentDialogResult, + AttachmentsV2Component, + ChangeLoginPasswordService, + CipherFormConfig, + CipherFormConfigService, + CipherFormGenerationService, + CipherFormMode, + CipherFormModule, + CipherViewComponent, + CollectionAssignmentResult, + DecryptionFailureDialogComponent, + DefaultChangeLoginPasswordService, + DefaultCipherFormConfigService, + PasswordRepromptService, + CipherFormComponent, + ArchiveCipherUtilitiesService, +} from "@bitwarden/vault"; + +import { SearchBarService } from "../../../app/layout/search/search-bar.service"; +import { DesktopCredentialGenerationService } from "../../../services/desktop-cipher-form-generator.service"; +import { DesktopPremiumUpgradePromptService } from "../../../services/desktop-premium-upgrade-prompt.service"; +import { invokeMenu, RendererMenuItem } from "../../../utils"; +import { AssignCollectionsDesktopComponent } from "../vault/assign-collections"; +import { ItemFooterComponent } from "../vault/item-footer.component"; +import { VaultFilterComponent } from "../vault/vault-filter/vault-filter.component"; +import { VaultFilterModule } from "../vault/vault-filter/vault-filter.module"; +import { VaultItemsV2Component } from "../vault/vault-items-v2.component"; + +const BroadcasterSubscriptionId = "VaultComponent"; + +// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush +// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection @Component({ selector: "app-vault-v3", - imports: [], - template: "

    Vault V3 Component

    ", - changeDetection: ChangeDetectionStrategy.OnPush, + templateUrl: "vault.component.html", + imports: [ + BadgeModule, + CommonModule, + CipherFormModule, + CipherViewComponent, + ItemFooterComponent, + I18nPipe, + ItemModule, + ButtonModule, + PremiumBadgeComponent, + VaultFilterModule, + VaultItemsV2Component, + ], + providers: [ + { + provide: CipherFormConfigService, + useClass: DefaultCipherFormConfigService, + }, + { + provide: ChangeLoginPasswordService, + useClass: DefaultChangeLoginPasswordService, + }, + { + provide: ViewPasswordHistoryService, + useClass: VaultViewPasswordHistoryService, + }, + { + provide: PremiumUpgradePromptService, + useClass: DesktopPremiumUpgradePromptService, + }, + { provide: CipherFormGenerationService, useClass: DesktopCredentialGenerationService }, + { + provide: COPY_CLICK_LISTENER, + useExisting: VaultComponent, + }, + ], }) -export class VaultComponent {} +export class VaultComponent + implements OnInit, OnDestroy, CopyClickListener +{ + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals + @ViewChild(VaultItemsV2Component, { static: true }) + vaultItemsComponent: VaultItemsV2Component | null = null; + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals + @ViewChild(VaultFilterComponent, { static: true }) + vaultFilterComponent: VaultFilterComponent | null = null; + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals + @ViewChild("folderAddEdit", { read: ViewContainerRef, static: true }) + folderAddEditModalRef: ViewContainerRef | null = null; + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals + @ViewChild(CipherFormComponent) + cipherFormComponent: CipherFormComponent | null = null; + + action: CipherFormMode | "view" | null = null; + cipherId: string | null = null; + favorites = false; + type: CipherType | null = null; + folderId: string | null = null; + collectionId: string | null = null; + organizationId: string | null = null; + myVaultOnly = false; + addType: CipherType | undefined = undefined; + addOrganizationId: string | null = null; + addCollectionIds: string[] | null = null; + showingModal = false; + deleted = false; + userHasPremiumAccess = false; + activeFilter: VaultFilter = new VaultFilter(); + activeUserId: UserId | null = null; + cipherRepromptId: string | null = null; + cipher: CipherView | null = new CipherView(); + collections: CollectionView[] | null = null; + config: CipherFormConfig | null = null; + + /** Tracks the disabled status of the edit cipher form */ + protected formDisabled: boolean = false; + + private organizations$: Observable = this.accountService.activeAccount$.pipe( + map((a) => a?.id), + filterOutNullish(), + switchMap((id) => this.organizationService.organizations$(id)), + ); + + protected canAccessAttachments$ = this.accountService.activeAccount$.pipe( + filter((account): account is Account => !!account), + switchMap((account) => + this.billingAccountProfileStateService.hasPremiumFromAnySource$(account.id), + ), + ); + + private componentIsDestroyed$ = new Subject(); + private allOrganizations: Organization[] = []; + private allCollections: CollectionView[] = []; + + constructor( + private route: ActivatedRoute, + private router: Router, + private i18nService: I18nService, + private broadcasterService: BroadcasterService, + private changeDetectorRef: ChangeDetectorRef, + private ngZone: NgZone, + private syncService: SyncService, + private messagingService: MessagingService, + private platformUtilsService: PlatformUtilsService, + private eventCollectionService: EventCollectionService, + private totpService: TotpService, + private passwordRepromptService: PasswordRepromptService, + private searchBarService: SearchBarService, + private apiService: ApiService, + private dialogService: DialogService, + private billingAccountProfileStateService: BillingAccountProfileStateService, + private toastService: ToastService, + private accountService: AccountService, + private cipherService: CipherService, + private formConfigService: CipherFormConfigService, + private premiumUpgradePromptService: PremiumUpgradePromptService, + private collectionService: CollectionService, + private organizationService: OrganizationService, + private folderService: FolderService, + private configService: ConfigService, + private authRequestService: AuthRequestServiceAbstraction, + private cipherArchiveService: CipherArchiveService, + private policyService: PolicyService, + private archiveCipherUtilitiesService: ArchiveCipherUtilitiesService, + ) {} + + async ngOnInit() { + this.accountService.activeAccount$ + .pipe( + filter((account): account is Account => !!account), + switchMap((account) => + this.billingAccountProfileStateService.hasPremiumFromAnySource$(account.id), + ), + takeUntil(this.componentIsDestroyed$), + ) + .subscribe((canAccessPremium: boolean) => { + this.userHasPremiumAccess = canAccessPremium; + }); + + this.broadcasterService.subscribe(BroadcasterSubscriptionId, (message: any) => { + this.ngZone + .run(async () => { + let detectChanges = true; + try { + switch (message.command) { + case "newLogin": + await this.addCipher(CipherType.Login).catch(() => {}); + break; + case "newCard": + await this.addCipher(CipherType.Card).catch(() => {}); + break; + case "newIdentity": + await this.addCipher(CipherType.Identity).catch(() => {}); + break; + case "newSecureNote": + await this.addCipher(CipherType.SecureNote).catch(() => {}); + break; + case "newSshKey": + await this.addCipher(CipherType.SshKey).catch(() => {}); + break; + case "focusSearch": + (document.querySelector("#search") as HTMLInputElement)?.select(); + detectChanges = false; + break; + case "syncCompleted": + if (this.vaultItemsComponent) { + await this.vaultItemsComponent + .reload(this.activeFilter.buildFilter()) + .catch(() => {}); + } + if (this.vaultFilterComponent) { + await this.vaultFilterComponent + .reloadCollectionsAndFolders(this.activeFilter) + .catch(() => {}); + await this.vaultFilterComponent.reloadOrganizations().catch(() => {}); + } + break; + case "modalShown": + this.showingModal = true; + break; + case "modalClosed": + this.showingModal = false; + break; + case "copyUsername": { + if (this.cipher?.login?.username) { + this.copyValue(this.cipher, this.cipher?.login?.username, "username", "Username"); + } + break; + } + case "copyPassword": { + if (this.cipher?.login?.password && this.cipher.viewPassword) { + this.copyValue(this.cipher, this.cipher.login.password, "password", "Password"); + await this.eventCollectionService + .collect(EventType.Cipher_ClientCopiedPassword, this.cipher.id) + .catch(() => {}); + } + break; + } + case "copyTotp": { + if ( + this.cipher?.login?.hasTotp && + (this.cipher.organizationUseTotp || this.userHasPremiumAccess) + ) { + const value = await firstValueFrom( + this.totpService.getCode$(this.cipher.login.totp), + ).catch((): any => null); + if (value) { + this.copyValue(this.cipher, value.code, "verificationCodeTotp", "TOTP"); + } + } + break; + } + default: + detectChanges = false; + break; + } + } catch { + // Ignore errors + } + if (detectChanges) { + this.changeDetectorRef.detectChanges(); + } + }) + .catch(() => {}); + }); + + if (!this.syncService.syncInProgress) { + await this.load().catch(() => {}); + } + + this.searchBarService.setEnabled(true); + this.searchBarService.setPlaceholderText(this.i18nService.t("searchVault")); + + const authRequests = await firstValueFrom( + this.authRequestService.getLatestPendingAuthRequest$()!, + ); + if (authRequests != null) { + this.messagingService.send("openLoginApproval", { + notificationId: authRequests.id, + }); + } + + this.activeUserId = await firstValueFrom( + this.accountService.activeAccount$.pipe(getUserId), + ).catch((): any => null); + + if (this.activeUserId) { + this.cipherService + .failedToDecryptCiphers$(this.activeUserId) + .pipe( + map((ciphers) => ciphers?.filter((c) => !c.isDeleted) ?? []), + filter((ciphers) => ciphers.length > 0), + take(1), + takeUntil(this.componentIsDestroyed$), + ) + .subscribe((ciphers) => { + DecryptionFailureDialogComponent.open(this.dialogService, { + cipherIds: ciphers.map((c) => c.id as CipherId), + }); + }); + } + + this.organizations$.pipe(takeUntil(this.componentIsDestroyed$)).subscribe((orgs) => { + this.allOrganizations = orgs; + }); + + if (!this.activeUserId) { + throw new Error("No user found."); + } + + this.collectionService + .decryptedCollections$(this.activeUserId) + .pipe(takeUntil(this.componentIsDestroyed$)) + .subscribe((collections) => { + this.allCollections = collections; + }); + } + + ngOnDestroy() { + this.searchBarService.setEnabled(false); + this.broadcasterService.unsubscribe(BroadcasterSubscriptionId); + this.componentIsDestroyed$.next(true); + this.componentIsDestroyed$.complete(); + } + + async load() { + const params = await firstValueFrom(this.route.queryParams).catch(); + const paramCipherAddType = toCipherType(params.addType); + if (params.cipherId) { + const cipherView = new CipherView(); + cipherView.id = params.cipherId; + if (params.action === "clone") { + await this.cloneCipher(cipherView).catch(() => {}); + } else if (params.action === "edit") { + await this.editCipher(cipherView).catch(() => {}); + } else { + await this.viewCipher(cipherView).catch(() => {}); + } + } else if (params.action === "add" && paramCipherAddType) { + this.addType = paramCipherAddType; + await this.addCipher(this.addType).catch(() => {}); + } + + const paramCipherType = toCipherType(params.type); + this.activeFilter = new VaultFilter({ + status: params.deleted ? "trash" : params.favorites ? "favorites" : "all", + cipherType: params.action === "add" || paramCipherType == null ? undefined : paramCipherType, + selectedFolderId: params.folderId, + selectedCollectionId: params.selectedCollectionId, + selectedOrganizationId: params.selectedOrganizationId, + myVaultOnly: params.myVaultOnly ?? false, + }); + if (this.vaultItemsComponent) { + await this.vaultItemsComponent.reload(this.activeFilter.buildFilter()).catch(() => {}); + } + } + + /** + * Handler for Vault level CopyClickDirectives to send the minimizeOnCopy message + */ + onCopy() { + this.messagingService.send("minimizeOnCopy"); + } + + async viewCipher(c: CipherViewLike) { + if (CipherViewLikeUtils.decryptionFailure(c)) { + DecryptionFailureDialogComponent.open(this.dialogService, { + cipherIds: [c.id as CipherId], + }); + return; + } + const cipher = await this.cipherService.getFullCipherView(c); + if (await this.shouldReprompt(cipher, "view")) { + return; + } + this.cipherId = cipher.id; + this.cipher = cipher; + this.collections = + this.vaultFilterComponent?.collections?.fullList.filter((c) => + cipher.collectionIds.includes(c.id), + ) ?? null; + this.action = "view"; + + await this.go().catch(() => {}); + await this.eventCollectionService.collect( + EventType.Cipher_ClientViewed, + cipher.id, + false, + cipher.organizationId, + ); + } + + formStatusChanged(status: "disabled" | "enabled") { + this.formDisabled = status === "disabled"; + } + + async openAttachmentsDialog() { + if (!this.userHasPremiumAccess) { + return; + } + const dialogRef = AttachmentsV2Component.open(this.dialogService, { + cipherId: this.cipherId as CipherId, + }); + const result = await firstValueFrom(dialogRef.closed).catch((): any => null); + if ( + result?.action === AttachmentDialogResult.Removed || + result?.action === AttachmentDialogResult.Uploaded + ) { + await this.vaultItemsComponent?.refresh().catch(() => {}); + + if (this.cipherFormComponent == null) { + return; + } + + // The encrypted state of ciphers is updated when an attachment is added, + // but the cache is also cleared. Depending on timing, `cipherService.get` can return the + // old cipher. Retrieve the updated cipher from `cipherViews$`, + // which refreshes after the cached is cleared. + const updatedCipherView = await firstValueFrom( + this.cipherService.cipherViews$(this.activeUserId!).pipe( + filter((c) => !!c), + map((ciphers) => ciphers.find((c) => c.id === this.cipherId)), + ), + ); + + // `find` can return undefined but that shouldn't happen as + // this would mean that the cipher was deleted. + // To make TypeScript happy, exit early if it isn't found. + if (!updatedCipherView) { + return; + } + + this.cipherFormComponent.patchCipher((currentCipher) => { + currentCipher.attachments = updatedCipherView.attachments; + currentCipher.revisionDate = updatedCipherView.revisionDate; + + return currentCipher; + }); + } + } + + async viewCipherMenu(c: CipherViewLike) { + const cipher = await this.cipherService.getFullCipherView(c); + const userId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId)); + const userCanArchive = await firstValueFrom(this.cipherArchiveService.userCanArchive$(userId)); + const orgOwnershipPolicy = await firstValueFrom( + this.policyService.policyAppliesToUser$(PolicyType.OrganizationDataOwnership, userId), + ); + + const menu: RendererMenuItem[] = [ + { + label: this.i18nService.t("view"), + click: () => { + this.functionWithChangeDetection(() => { + this.viewCipher(cipher).catch(() => {}); + }); + }, + }, + ]; + + if (cipher.decryptionFailure) { + invokeMenu(menu); + } + + if (!cipher.isDeleted) { + menu.push({ + label: this.i18nService.t("edit"), + click: () => { + this.functionWithChangeDetection(() => { + this.editCipher(cipher).catch(() => {}); + }); + }, + }); + + const archivedWithOrgOwnership = cipher.isArchived && orgOwnershipPolicy; + const canCloneArchived = !cipher.isArchived || userCanArchive; + + if (!cipher.organizationId && !archivedWithOrgOwnership && canCloneArchived) { + menu.push({ + label: this.i18nService.t("clone"), + click: () => { + this.functionWithChangeDetection(() => { + this.cloneCipher(cipher).catch(() => {}); + }); + }, + }); + } + + const hasEditableCollections = this.allCollections.some((collection) => !collection.readOnly); + + if (cipher.canAssignToCollections && hasEditableCollections) { + menu.push({ + label: this.i18nService.t("assignToCollections"), + click: () => + this.functionWithChangeDetection(async () => { + await this.shareCipher(cipher); + }), + }); + } + } + + if (!cipher.organizationId && !cipher.isDeleted && !cipher.isArchived) { + menu.push({ + label: this.i18nService.t("archiveVerb"), + click: async () => { + if (!userCanArchive) { + await this.premiumUpgradePromptService.promptForPremium(); + return; + } + + await this.archiveCipherUtilitiesService.archiveCipher(cipher); + await this.refreshCurrentCipher(); + }, + }); + } + + if (cipher.isArchived) { + menu.push({ + label: this.i18nService.t("unArchive"), + click: async () => { + await this.archiveCipherUtilitiesService.unarchiveCipher(cipher); + await this.refreshCurrentCipher(); + }, + }); + } + + switch (cipher.type) { + case CipherType.Login: + if ( + cipher.login.canLaunch || + cipher.login.username != null || + cipher.login.password != null + ) { + menu.push({ type: "separator" }); + } + if (cipher.login.canLaunch) { + menu.push({ + label: this.i18nService.t("launch"), + click: () => this.platformUtilsService.launchUri(cipher.login.launchUri), + }); + } + if (cipher.login.username != null) { + menu.push({ + label: this.i18nService.t("copyUsername"), + click: () => this.copyValue(cipher, cipher.login.username, "username", "Username"), + }); + } + if (cipher.login.password != null && cipher.viewPassword) { + menu.push({ + label: this.i18nService.t("copyPassword"), + click: () => { + this.copyValue(cipher, cipher.login.password, "password", "Password"); + this.eventCollectionService + .collect(EventType.Cipher_ClientCopiedPassword, cipher.id) + .catch(() => {}); + }, + }); + } + if (cipher.login.hasTotp && (cipher.organizationUseTotp || this.userHasPremiumAccess)) { + menu.push({ + label: this.i18nService.t("copyVerificationCodeTotp"), + click: async () => { + const value = await firstValueFrom( + this.totpService.getCode$(cipher.login.totp), + ).catch((): any => null); + if (value) { + this.copyValue(cipher, value.code, "verificationCodeTotp", "TOTP"); + } + }, + }); + } + break; + case CipherType.Card: + if (cipher.card.number != null || cipher.card.code != null) { + menu.push({ type: "separator" }); + } + if (cipher.card.number != null) { + menu.push({ + label: this.i18nService.t("copyNumber"), + click: () => this.copyValue(cipher, cipher.card.number, "number", "Card Number"), + }); + } + if (cipher.card.code != null) { + menu.push({ + label: this.i18nService.t("copySecurityCode"), + click: () => { + this.copyValue(cipher, cipher.card.code, "securityCode", "Security Code"); + this.eventCollectionService + .collect(EventType.Cipher_ClientCopiedCardCode, cipher.id) + .catch(() => {}); + }, + }); + } + break; + default: + break; + } + invokeMenu(menu); + } + + async shouldReprompt(cipher: CipherView, action: "edit" | "clone" | "view"): Promise { + return !(await this.canNavigateAway(action, cipher)) || !(await this.passwordReprompt(cipher)); + } + + async buildFormConfig(action: CipherFormMode) { + this.config = await this.formConfigService + .buildConfig(action, this.cipherId as CipherId, this.addType) + .catch((): any => null); + } + + async editCipher(cipher: CipherView) { + if (await this.shouldReprompt(cipher, "edit")) { + return; + } + this.cipherId = cipher.id; + this.cipher = cipher; + await this.buildFormConfig("edit"); + if (!cipher.edit && this.config) { + this.config.mode = "partial-edit"; + } + this.action = "edit"; + await this.go().catch(() => {}); + } + + async cloneCipher(cipher: CipherView) { + if (await this.shouldReprompt(cipher, "clone")) { + return; + } + this.cipherId = cipher.id; + this.cipher = cipher; + await this.buildFormConfig("clone"); + this.action = "clone"; + await this.go().catch(() => {}); + } + + async shareCipher(cipher: CipherView) { + if (!cipher) { + this.toastService.showToast({ + variant: "error", + title: this.i18nService.t("errorOccurred"), + message: this.i18nService.t("nothingSelected"), + }); + return; + } + + if (!(await this.passwordReprompt(cipher))) { + return; + } + + const availableCollections = this.getAvailableCollections(cipher); + + const dialog = AssignCollectionsDesktopComponent.open(this.dialogService, { + data: { + ciphers: [cipher], + organizationId: cipher.organizationId as OrganizationId, + availableCollections, + }, + }); + + const result = await lastValueFrom(dialog.closed); + if (result === CollectionAssignmentResult.Saved) { + const updatedCipher = await firstValueFrom( + // Fetch the updated cipher from the service + this.cipherService.cipherViews$(this.activeUserId as UserId).pipe( + filter((ciphers) => ciphers != null), + map((ciphers) => ciphers!.find((c) => c.id === cipher.id)), + filter((foundCipher) => foundCipher != null), + ), + ); + await this.savedCipher(updatedCipher); + } + } + + async addCipher(type: CipherType) { + if (this.action === "add") { + return; + } + this.addType = type || this.activeFilter.cipherType; + this.cipher = new CipherView(); + this.cipherId = null; + await this.buildFormConfig("add"); + this.action = "add"; + this.prefillCipherFromFilter(); + await this.go().catch(() => {}); + + if (type === CipherType.SshKey) { + this.toastService.showToast({ + variant: "success", + title: "", + message: this.i18nService.t("sshKeyGenerated"), + }); + } + } + + async savedCipher(cipher: CipherView) { + this.cipherId = null; + this.action = "view"; + await this.vaultItemsComponent?.refresh().catch(() => {}); + + if (!this.activeUserId) { + throw new Error("No userId provided."); + } + + this.collections = await firstValueFrom( + this.collectionService + .decryptedCollections$(this.activeUserId) + .pipe(getByIds(cipher.collectionIds)), + ); + + this.cipherId = cipher.id; + this.cipher = cipher; + await this.go().catch(() => {}); + await this.vaultItemsComponent?.refresh().catch(() => {}); + } + + async deleteCipher() { + this.cipherId = null; + this.cipher = null; + this.action = null; + await this.go().catch(() => {}); + await this.vaultItemsComponent?.refresh().catch(() => {}); + } + + async restoreCipher() { + this.cipherId = null; + this.action = null; + await this.go().catch(() => {}); + await this.vaultItemsComponent?.refresh().catch(() => {}); + } + + async cancelCipher(cipher: CipherView) { + this.cipherId = cipher.id; + this.cipher = cipher; + this.action = this.cipherId ? "view" : null; + await this.go().catch(() => {}); + } + + async applyVaultFilter(vaultFilter: VaultFilter) { + this.searchBarService.setPlaceholderText( + this.i18nService.t(this.calculateSearchBarLocalizationString(vaultFilter)), + ); + this.activeFilter = vaultFilter; + await this.vaultItemsComponent + ?.reload( + this.activeFilter.buildFilter(), + vaultFilter.status === "trash", + vaultFilter.status === "archive", + ) + .catch(() => {}); + await this.go().catch(() => {}); + } + + private getAvailableCollections(cipher: CipherView): CollectionView[] { + const orgId = cipher.organizationId; + if (!orgId || orgId === "MyVault") { + return []; + } + + const organization = this.allOrganizations.find((o) => o.id === orgId); + return this.allCollections.filter((c) => c.organizationId === organization?.id && !c.readOnly); + } + + private calculateSearchBarLocalizationString(vaultFilter: VaultFilter): string { + if (vaultFilter.status === "favorites") { + return "searchFavorites"; + } + if (vaultFilter.status === "trash") { + return "searchTrash"; + } + if (vaultFilter.cipherType != null) { + return "searchType"; + } + if (vaultFilter.selectedFolderId != null && vaultFilter.selectedFolderId !== "none") { + return "searchFolder"; + } + if (vaultFilter.selectedCollectionId != null) { + return "searchCollection"; + } + if (vaultFilter.selectedOrganizationId != null) { + return "searchOrganization"; + } + if (vaultFilter.myVaultOnly) { + return "searchMyVault"; + } + return "searchVault"; + } + + async addFolder() { + this.messagingService.send("newFolder"); + } + + async editFolder(folderId: string) { + if (!this.activeUserId) { + return; + } + const folderView = await firstValueFrom( + this.folderService.getDecrypted$(folderId, this.activeUserId), + ); + + if (!folderView) { + return; + } + + const dialogRef = AddEditFolderDialogComponent.open(this.dialogService, { + editFolderConfig: { + folder: { + ...folderView, + }, + }, + }); + + const result = await lastValueFrom(dialogRef.closed); + + if ( + result === AddEditFolderDialogResult.Deleted || + result === AddEditFolderDialogResult.Created + ) { + await this.vaultFilterComponent?.reloadCollectionsAndFolders(this.activeFilter); + } + } + + /** Refresh the current cipher object */ + protected async refreshCurrentCipher() { + if (!this.cipher) { + return; + } + + this.cipher = await firstValueFrom( + this.cipherService.cipherViews$(this.activeUserId!).pipe( + filter((c) => !!c), + map((ciphers) => ciphers.find((c) => c.id === this.cipherId) ?? null), + ), + ); + } + + private dirtyInput(): boolean { + return ( + (this.action === "add" || this.action === "edit" || this.action === "clone") && + document.querySelectorAll("vault-cipher-form .ng-dirty").length > 0 + ); + } + + private async wantsToSaveChanges(): Promise { + const confirmed = await this.dialogService + .openSimpleDialog({ + title: { key: "unsavedChangesTitle" }, + content: { key: "unsavedChangesConfirmation" }, + type: "warning", + }) + .catch(() => false); + return !confirmed; + } + + private async go(queryParams: any = null) { + if (queryParams == null) { + queryParams = { + action: this.action, + cipherId: this.cipherId, + favorites: this.favorites ? true : null, + type: this.type, + folderId: this.folderId, + collectionId: this.collectionId, + deleted: this.deleted ? true : null, + organizationId: this.organizationId, + myVaultOnly: this.myVaultOnly, + }; + } + this.router + .navigate([], { + relativeTo: this.route, + queryParams: queryParams, + replaceUrl: true, + }) + .catch(() => {}); + } + + private copyValue(cipher: CipherView, value: string, labelI18nKey: string, aType: string) { + this.functionWithChangeDetection(() => { + (async () => { + if ( + cipher.reprompt !== CipherRepromptType.None && + this.passwordRepromptService.protectedFields().includes(aType) && + !(await this.passwordReprompt(cipher)) + ) { + return; + } + this.platformUtilsService.copyToClipboard(value); + this.toastService.showToast({ + variant: "info", + title: undefined, + message: this.i18nService.t("valueCopied", this.i18nService.t(labelI18nKey)), + }); + this.messagingService.send("minimizeOnCopy"); + })().catch(() => {}); + }); + } + + private functionWithChangeDetection(func: () => void) { + this.ngZone.run(() => { + func(); + this.changeDetectorRef.detectChanges(); + }); + } + + private prefillCipherFromFilter() { + if (this.activeFilter.selectedCollectionId != null && this.vaultFilterComponent != null) { + const collections = this.vaultFilterComponent.collections?.fullList.filter( + (c) => c.id === this.activeFilter.selectedCollectionId, + ); + if (collections.length > 0) { + this.addOrganizationId = collections[0].organizationId; + this.addCollectionIds = [this.activeFilter.selectedCollectionId]; + } + } else if (this.activeFilter.selectedOrganizationId) { + this.addOrganizationId = this.activeFilter.selectedOrganizationId; + } else { + // clear out organizationId when the user switches to a personal vault filter + this.addOrganizationId = null; + } + if (this.activeFilter.selectedFolderId && this.activeFilter.selectedFolder) { + this.folderId = this.activeFilter.selectedFolderId; + } + + if (this.config == null) { + return; + } + + this.config.initialValues = { + ...this.config.initialValues, + organizationId: this.addOrganizationId as OrganizationId, + }; + } + + private async canNavigateAway(action: string, cipher?: CipherView) { + if (this.action === action && (!cipher || this.cipherId === cipher.id)) { + return false; + } else if (this.dirtyInput() && (await this.wantsToSaveChanges())) { + return false; + } + return true; + } + + private async passwordReprompt(cipher: CipherView) { + if (cipher.reprompt === CipherRepromptType.None) { + this.cipherRepromptId = null; + return true; + } + if (this.cipherRepromptId === cipher.id) { + return true; + } + const repromptResult = await this.passwordRepromptService.showPasswordPrompt(); + if (repromptResult) { + this.cipherRepromptId = cipher.id; + } + return repromptResult; + } +} From 30f615767c40ed0ea4f201ce0266d226e4e7c2d1 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Tue, 2 Dec 2025 13:57:26 -0500 Subject: [PATCH 016/131] [deps] Autofill: Update prettier-plugin-tailwindcss to v0.7.1 (#17033) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- package-lock.json | 26 +++++++++++++------------- package.json | 2 +- 2 files changed, 14 insertions(+), 14 deletions(-) diff --git a/package-lock.json b/package-lock.json index 12149d25773..ea662c62b6e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -161,7 +161,7 @@ "postcss": "8.5.6", "postcss-loader": "8.2.0", "prettier": "3.6.2", - "prettier-plugin-tailwindcss": "0.6.11", + "prettier-plugin-tailwindcss": "0.7.1", "process": "0.11.10", "remark-gfm": "4.0.1", "rimraf": "6.0.1", @@ -34470,16 +34470,18 @@ } }, "node_modules/prettier-plugin-tailwindcss": { - "version": "0.6.11", - "resolved": "https://registry.npmjs.org/prettier-plugin-tailwindcss/-/prettier-plugin-tailwindcss-0.6.11.tgz", - "integrity": "sha512-YxaYSIvZPAqhrrEpRtonnrXdghZg1irNg4qrjboCXrpybLWVs55cW2N3juhspVJiO0JBvYJT8SYsJpc8OQSnsA==", + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/prettier-plugin-tailwindcss/-/prettier-plugin-tailwindcss-0.7.1.tgz", + "integrity": "sha512-Bzv1LZcuiR1Sk02iJTS1QzlFNp/o5l2p3xkopwOrbPmtMeh3fK9rVW5M3neBQzHq+kGKj/4LGQMTNcTH4NGPtQ==", "dev": true, "license": "MIT", "engines": { - "node": ">=14.21.3" + "node": ">=20.19" }, "peerDependencies": { "@ianvs/prettier-plugin-sort-imports": "*", + "@prettier/plugin-hermes": "*", + "@prettier/plugin-oxc": "*", "@prettier/plugin-pug": "*", "@shopify/prettier-plugin-liquid": "*", "@trivago/prettier-plugin-sort-imports": "*", @@ -34487,20 +34489,24 @@ "prettier": "^3.0", "prettier-plugin-astro": "*", "prettier-plugin-css-order": "*", - "prettier-plugin-import-sort": "*", "prettier-plugin-jsdoc": "*", "prettier-plugin-marko": "*", "prettier-plugin-multiline-arrays": "*", "prettier-plugin-organize-attributes": "*", "prettier-plugin-organize-imports": "*", "prettier-plugin-sort-imports": "*", - "prettier-plugin-style-order": "*", "prettier-plugin-svelte": "*" }, "peerDependenciesMeta": { "@ianvs/prettier-plugin-sort-imports": { "optional": true }, + "@prettier/plugin-hermes": { + "optional": true + }, + "@prettier/plugin-oxc": { + "optional": true + }, "@prettier/plugin-pug": { "optional": true }, @@ -34519,9 +34525,6 @@ "prettier-plugin-css-order": { "optional": true }, - "prettier-plugin-import-sort": { - "optional": true - }, "prettier-plugin-jsdoc": { "optional": true }, @@ -34540,9 +34543,6 @@ "prettier-plugin-sort-imports": { "optional": true }, - "prettier-plugin-style-order": { - "optional": true - }, "prettier-plugin-svelte": { "optional": true } diff --git a/package.json b/package.json index 39b68f53718..ab83b981b66 100644 --- a/package.json +++ b/package.json @@ -123,7 +123,7 @@ "postcss": "8.5.6", "postcss-loader": "8.2.0", "prettier": "3.6.2", - "prettier-plugin-tailwindcss": "0.6.11", + "prettier-plugin-tailwindcss": "0.7.1", "process": "0.11.10", "remark-gfm": "4.0.1", "rimraf": "6.0.1", From 44e3320a672a2b73f9931f26f5b8cb5d87973510 Mon Sep 17 00:00:00 2001 From: Bryan Cunningham Date: Tue, 2 Dec 2025 14:35:02 -0500 Subject: [PATCH 017/131] add back the aria-label when using the a11y title directive (#17776) * add back the aria-label when using the a11y title directive * add comment about why aria-label is being added back * fix storybook a11y tests * pass undefined to util function --- .storybook/preview.tsx | 2 +- .../src/a11y/a11y-title.directive.ts | 22 +++++++++++++++++-- 2 files changed, 21 insertions(+), 3 deletions(-) diff --git a/.storybook/preview.tsx b/.storybook/preview.tsx index 5a75a21dcd8..266cf79d8b1 100644 --- a/.storybook/preview.tsx +++ b/.storybook/preview.tsx @@ -28,7 +28,7 @@ const preview: Preview = { ], parameters: { a11y: { - element: "#storybook-root", + context: "#storybook-root", }, controls: { matchers: { diff --git a/libs/components/src/a11y/a11y-title.directive.ts b/libs/components/src/a11y/a11y-title.directive.ts index fa038172cb6..5864874b734 100644 --- a/libs/components/src/a11y/a11y-title.directive.ts +++ b/libs/components/src/a11y/a11y-title.directive.ts @@ -1,7 +1,9 @@ -import { Directive } from "@angular/core"; +import { Directive, effect, ElementRef, inject } from "@angular/core"; import { TooltipDirective } from "../tooltip/tooltip.directive"; +import { setA11yTitleAndAriaLabel } from "./set-a11y-title-and-aria-label"; + /** * @deprecated This function is deprecated in favor of `bitTooltip`. * Please use `bitTooltip` instead. @@ -18,4 +20,20 @@ import { TooltipDirective } from "../tooltip/tooltip.directive"; }, ], }) -export class A11yTitleDirective {} +export class A11yTitleDirective { + private readonly elementRef = inject(ElementRef); + private readonly tooltipDirective = inject(TooltipDirective); + + constructor() { + const originalAriaLabel = this.elementRef.nativeElement.getAttribute("aria-label"); + + // setting aria-label as a workaround for testing purposes. Should be removed once tests are updated to check element content. + effect(() => { + setA11yTitleAndAriaLabel({ + element: this.elementRef.nativeElement, + title: undefined, + label: originalAriaLabel ?? this.tooltipDirective.tooltipContent(), + }); + }); + } +} From dd623b136bc2f397c29206fd63dbd6c8ad0e79b2 Mon Sep 17 00:00:00 2001 From: Todd Martin <106564991+trmartin4@users.noreply.github.com> Date: Tue, 2 Dec 2025 15:16:09 -0500 Subject: [PATCH 018/131] chore(logs): Update "SSO login email not found" log level to debug * Update log level to debug * Fixed test. --- .../default-login-success-handler.service.spec.ts | 2 +- .../default-login-success-handler.service.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/libs/auth/src/common/services/login-success-handler/default-login-success-handler.service.spec.ts b/libs/auth/src/common/services/login-success-handler/default-login-success-handler.service.spec.ts index 975e065e21e..6fb355a8a1b 100644 --- a/libs/auth/src/common/services/login-success-handler/default-login-success-handler.service.spec.ts +++ b/libs/auth/src/common/services/login-success-handler/default-login-success-handler.service.spec.ts @@ -71,7 +71,7 @@ describe("DefaultLoginSuccessHandlerService", () => { it("should log error and return early", async () => { await service.run(userId); - expect(logService.error).toHaveBeenCalledWith("SSO login email not found."); + expect(logService.debug).toHaveBeenCalledWith("SSO login email not found."); expect(ssoLoginService.updateSsoRequiredCache).not.toHaveBeenCalled(); }); }); diff --git a/libs/auth/src/common/services/login-success-handler/default-login-success-handler.service.ts b/libs/auth/src/common/services/login-success-handler/default-login-success-handler.service.ts index 27d058c311a..2b9672f1c0b 100644 --- a/libs/auth/src/common/services/login-success-handler/default-login-success-handler.service.ts +++ b/libs/auth/src/common/services/login-success-handler/default-login-success-handler.service.ts @@ -25,7 +25,7 @@ export class DefaultLoginSuccessHandlerService implements LoginSuccessHandlerSer const ssoLoginEmail = await this.ssoLoginService.getSsoEmail(); if (!ssoLoginEmail) { - this.logService.error("SSO login email not found."); + this.logService.debug("SSO login email not found."); return; } From 365af52e335142a278182e3363678527d22b5326 Mon Sep 17 00:00:00 2001 From: Isaac Ivins Date: Tue, 2 Dec 2025 15:27:41 -0500 Subject: [PATCH 019/131] [PM-27794] create send component desktop migration (#17786) * wip * updated tests to work, and linter --- .../app/tools/send-v2/send-v2.component.html | 110 ++++++ .../tools/send-v2/send-v2.component.spec.ts | 344 +++++++++++++++++- .../app/tools/send-v2/send-v2.component.ts | 234 +++++++++++- 3 files changed, 682 insertions(+), 6 deletions(-) create mode 100644 apps/desktop/src/app/tools/send-v2/send-v2.component.html diff --git a/apps/desktop/src/app/tools/send-v2/send-v2.component.html b/apps/desktop/src/app/tools/send-v2/send-v2.component.html new file mode 100644 index 00000000000..20cac15138a --- /dev/null +++ b/apps/desktop/src/app/tools/send-v2/send-v2.component.html @@ -0,0 +1,110 @@ +
    +
    +
    +
    + +
    +
    + + + +

    {{ "noItemsInList" | i18n }}

    +
    +
    + +
    +
    + + +
    diff --git a/apps/desktop/src/app/tools/send-v2/send-v2.component.spec.ts b/apps/desktop/src/app/tools/send-v2/send-v2.component.spec.ts index 8055bc07667..5798df0989d 100644 --- a/apps/desktop/src/app/tools/send-v2/send-v2.component.spec.ts +++ b/apps/desktop/src/app/tools/send-v2/send-v2.component.spec.ts @@ -1,22 +1,364 @@ import { ComponentFixture, TestBed } from "@angular/core/testing"; +import { mock, MockProxy } from "jest-mock-extended"; +import { BehaviorSubject, of } from "rxjs"; + +import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; +import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { BroadcasterService } from "@bitwarden/common/platform/abstractions/broadcaster.service"; +import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; +import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; +import { SendType } from "@bitwarden/common/tools/send/enums/send-type"; +import { SendView } from "@bitwarden/common/tools/send/models/view/send.view"; +import { SendApiService } from "@bitwarden/common/tools/send/services/send-api.service.abstraction"; +import { SendService } from "@bitwarden/common/tools/send/services/send.service.abstraction"; +import { SearchService } from "@bitwarden/common/vault/abstractions/search.service"; +import { DialogService, ToastService } from "@bitwarden/components"; + +import * as utils from "../../../utils"; +import { SearchBarService } from "../../layout/search/search-bar.service"; +import { AddEditComponent } from "../send/add-edit.component"; import { SendV2Component } from "./send-v2.component"; +// Mock the invokeMenu utility function +jest.mock("../../../utils", () => ({ + invokeMenu: jest.fn(), +})); + describe("SendV2Component", () => { let component: SendV2Component; let fixture: ComponentFixture; + let sendService: MockProxy; + let searchBarService: MockProxy; + let broadcasterService: MockProxy; + let accountService: MockProxy; + let policyService: MockProxy; beforeEach(async () => { + sendService = mock(); + searchBarService = mock(); + broadcasterService = mock(); + accountService = mock(); + policyService = mock(); + + // Mock sendViews$ observable + sendService.sendViews$ = of([]); + searchBarService.searchText$ = new BehaviorSubject(""); + + // Mock activeAccount$ observable for parent class ngOnInit + accountService.activeAccount$ = of({ id: "test-user-id" } as any); + policyService.policyAppliesToUser$ = jest.fn().mockReturnValue(of(false)); + await TestBed.configureTestingModule({ imports: [SendV2Component], + providers: [ + { provide: SendService, useValue: sendService }, + { provide: I18nService, useValue: mock() }, + { provide: PlatformUtilsService, useValue: mock() }, + { provide: EnvironmentService, useValue: mock() }, + { provide: BroadcasterService, useValue: broadcasterService }, + { provide: SearchService, useValue: mock() }, + { provide: PolicyService, useValue: policyService }, + { provide: SearchBarService, useValue: searchBarService }, + { provide: LogService, useValue: mock() }, + { provide: SendApiService, useValue: mock() }, + { provide: DialogService, useValue: mock() }, + { provide: ToastService, useValue: mock() }, + { provide: AccountService, useValue: accountService }, + ], }).compileComponents(); fixture = TestBed.createComponent(SendV2Component); component = fixture.componentInstance; - fixture.detectChanges(); }); it("creates component", () => { expect(component).toBeTruthy(); }); + + it("initializes with correct default action", () => { + expect(component.action).toBe(""); + }); + + it("subscribes to broadcaster service on init", async () => { + await component.ngOnInit(); + expect(broadcasterService.subscribe).toHaveBeenCalledWith( + "SendV2Component", + expect.any(Function), + ); + }); + + it("unsubscribes from broadcaster service on destroy", () => { + component.ngOnDestroy(); + expect(broadcasterService.unsubscribe).toHaveBeenCalledWith("SendV2Component"); + }); + + it("enables search bar on init", async () => { + await component.ngOnInit(); + expect(searchBarService.setEnabled).toHaveBeenCalledWith(true); + }); + + it("disables search bar on destroy", () => { + component.ngOnDestroy(); + expect(searchBarService.setEnabled).toHaveBeenCalledWith(false); + }); + + describe("addSend", () => { + it("sets action to Add", async () => { + await component.addSend(); + expect(component.action).toBe("add"); + }); + + it("calls resetAndLoad on addEditComponent when component exists", async () => { + const mockAddEdit = mock(); + component.addEditComponent = mockAddEdit; + + await component.addSend(); + + expect(mockAddEdit.resetAndLoad).toHaveBeenCalled(); + }); + + it("does not throw when addEditComponent is null", async () => { + component.addEditComponent = null; + await expect(component.addSend()).resolves.not.toThrow(); + }); + }); + + describe("cancel", () => { + it("resets action to None", () => { + component.action = "edit"; + component.sendId = "test-id"; + + component.cancel(new SendView()); + + expect(component.action).toBe(""); + expect(component.sendId).toBeNull(); + }); + }); + + describe("deletedSend", () => { + it("refreshes the list and resets action and sendId", async () => { + component.action = "edit"; + component.sendId = "test-id"; + jest.spyOn(component, "refresh").mockResolvedValue(); + + const mockSend = new SendView(); + await component.deletedSend(mockSend); + + expect(component.refresh).toHaveBeenCalled(); + expect(component.action).toBe(""); + expect(component.sendId).toBeNull(); + }); + }); + + describe("savedSend", () => { + it("refreshes the list and selects the saved send", async () => { + jest.spyOn(component, "refresh").mockResolvedValue(); + jest.spyOn(component, "selectSend").mockResolvedValue(); + + const mockSend = new SendView(); + mockSend.id = "saved-send-id"; + + await component.savedSend(mockSend); + + expect(component.refresh).toHaveBeenCalled(); + expect(component.selectSend).toHaveBeenCalledWith("saved-send-id"); + }); + }); + + describe("selectSend", () => { + it("sets action to Edit and updates sendId", async () => { + await component.selectSend("new-send-id"); + + expect(component.action).toBe("edit"); + expect(component.sendId).toBe("new-send-id"); + }); + + it("updates addEditComponent when it exists", async () => { + const mockAddEdit = mock(); + component.addEditComponent = mockAddEdit; + + await component.selectSend("test-send-id"); + + expect(mockAddEdit.sendId).toBe("test-send-id"); + expect(mockAddEdit.refresh).toHaveBeenCalled(); + }); + + it("does not reload if same send is already selected in edit mode", async () => { + const mockAddEdit = mock(); + component.addEditComponent = mockAddEdit; + component.sendId = "same-id"; + component.action = "edit"; + + await component.selectSend("same-id"); + + expect(mockAddEdit.refresh).not.toHaveBeenCalled(); + }); + + it("reloads if selecting different send", async () => { + const mockAddEdit = mock(); + component.addEditComponent = mockAddEdit; + component.sendId = "old-id"; + component.action = "edit"; + + await component.selectSend("new-id"); + + expect(mockAddEdit.refresh).toHaveBeenCalled(); + }); + }); + + describe("selectedSendType", () => { + it("returns the type of the currently selected send", () => { + const mockSend1 = new SendView(); + mockSend1.id = "send-1"; + mockSend1.type = SendType.Text; + + const mockSend2 = new SendView(); + mockSend2.id = "send-2"; + mockSend2.type = SendType.File; + + component.sends = [mockSend1, mockSend2]; + component.sendId = "send-2"; + + expect(component.selectedSendType).toBe(SendType.File); + }); + + it("returns undefined when no send is selected", () => { + component.sends = []; + component.sendId = "non-existent"; + + expect(component.selectedSendType).toBeUndefined(); + }); + + it("returns undefined when sendId is null", () => { + const mockSend = new SendView(); + mockSend.id = "send-1"; + mockSend.type = SendType.Text; + + component.sends = [mockSend]; + component.sendId = null; + + expect(component.selectedSendType).toBeUndefined(); + }); + }); + + describe("viewSendMenu", () => { + let mockSend: SendView; + + beforeEach(() => { + mockSend = new SendView(); + mockSend.id = "test-send"; + mockSend.name = "Test Send"; + jest.clearAllMocks(); + }); + + it("creates menu with copy link option", () => { + jest.spyOn(component, "copy").mockResolvedValue(); + + component.viewSendMenu(mockSend); + + expect(utils.invokeMenu).toHaveBeenCalled(); + const menuItems = (utils.invokeMenu as jest.Mock).mock.calls[0][0]; + expect(menuItems.length).toBeGreaterThanOrEqual(2); // At minimum: copy link + delete + }); + + it("includes remove password option when send has password and is not disabled", () => { + mockSend.password = "test-password"; + mockSend.disabled = false; + jest.spyOn(component, "removePassword").mockResolvedValue(true); + + component.viewSendMenu(mockSend); + + expect(utils.invokeMenu).toHaveBeenCalled(); + const menuItems = (utils.invokeMenu as jest.Mock).mock.calls[0][0]; + expect(menuItems.length).toBe(3); // copy link + remove password + delete + }); + + it("excludes remove password option when send has no password", () => { + mockSend.password = null; + mockSend.disabled = false; + + component.viewSendMenu(mockSend); + + expect(utils.invokeMenu).toHaveBeenCalled(); + const menuItems = (utils.invokeMenu as jest.Mock).mock.calls[0][0]; + expect(menuItems.length).toBe(2); // copy link + delete (no remove password) + }); + + it("excludes remove password option when send is disabled", () => { + mockSend.password = "test-password"; + mockSend.disabled = true; + + component.viewSendMenu(mockSend); + + expect(utils.invokeMenu).toHaveBeenCalled(); + const menuItems = (utils.invokeMenu as jest.Mock).mock.calls[0][0]; + expect(menuItems.length).toBe(2); // copy link + delete (no remove password) + }); + + it("always includes delete option", () => { + jest.spyOn(component, "delete").mockResolvedValue(true); + jest.spyOn(component, "deletedSend").mockResolvedValue(); + + component.viewSendMenu(mockSend); + + expect(utils.invokeMenu).toHaveBeenCalled(); + const menuItems = (utils.invokeMenu as jest.Mock).mock.calls[0][0]; + // Delete is always the last item in the menu + expect(menuItems.length).toBeGreaterThan(0); + expect(menuItems[menuItems.length - 1]).toHaveProperty("label"); + expect(menuItems[menuItems.length - 1]).toHaveProperty("click"); + }); + }); + + describe("search bar subscription", () => { + it("updates searchText when search bar text changes", () => { + const searchSubject = new BehaviorSubject("initial"); + searchBarService.searchText$ = searchSubject; + + // Create new component to trigger constructor subscription + fixture = TestBed.createComponent(SendV2Component); + component = fixture.componentInstance; + + searchSubject.next("new search text"); + + expect(component.searchText).toBe("new search text"); + }); + }); + + describe("load", () => { + it("sets loading states correctly", async () => { + jest.spyOn(component, "search").mockResolvedValue(); + jest.spyOn(component, "selectAll"); + + expect(component.loaded).toBeFalsy(); + + await component.load(); + + expect(component.loading).toBe(false); + expect(component.loaded).toBe(true); + }); + + it("calls selectAll when onSuccessfulLoad is not set", async () => { + jest.spyOn(component, "search").mockResolvedValue(); + jest.spyOn(component, "selectAll"); + component.onSuccessfulLoad = null; + + await component.load(); + + expect(component.selectAll).toHaveBeenCalled(); + }); + + it("calls onSuccessfulLoad when it is set", async () => { + jest.spyOn(component, "search").mockResolvedValue(); + const mockCallback = jest.fn().mockResolvedValue(undefined); + component.onSuccessfulLoad = mockCallback; + + await component.load(); + + expect(mockCallback).toHaveBeenCalled(); + }); + }); }); diff --git a/apps/desktop/src/app/tools/send-v2/send-v2.component.ts b/apps/desktop/src/app/tools/send-v2/send-v2.component.ts index 4840cd4cce8..4afe02d9f98 100644 --- a/apps/desktop/src/app/tools/send-v2/send-v2.component.ts +++ b/apps/desktop/src/app/tools/send-v2/send-v2.component.ts @@ -1,9 +1,233 @@ -import { Component, ChangeDetectionStrategy } from "@angular/core"; +// FIXME: Update this file to be type safe and remove this and next line +// @ts-strict-ignore +import { CommonModule } from "@angular/common"; +import { Component, OnInit, OnDestroy, ViewChild, NgZone, ChangeDetectorRef } from "@angular/core"; +import { FormsModule } from "@angular/forms"; +import { mergeMap } from "rxjs"; +import { JslibModule } from "@bitwarden/angular/jslib.module"; +import { SendComponent as BaseSendComponent } from "@bitwarden/angular/tools/send/send.component"; +import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; +import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { BroadcasterService } from "@bitwarden/common/platform/abstractions/broadcaster.service"; +import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; +import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; +import { SendView } from "@bitwarden/common/tools/send/models/view/send.view"; +import { SendApiService } from "@bitwarden/common/tools/send/services/send-api.service.abstraction"; +import { SendService } from "@bitwarden/common/tools/send/services/send.service.abstraction"; +import { SearchService } from "@bitwarden/common/vault/abstractions/search.service"; +import { DialogService, ToastService } from "@bitwarden/components"; + +import { invokeMenu, RendererMenuItem } from "../../../utils"; +import { SearchBarService } from "../../layout/search/search-bar.service"; +import { AddEditComponent } from "../send/add-edit.component"; + +const Action = Object.freeze({ + /** No action is currently active. */ + None: "", + /** The user is adding a new Send. */ + Add: "add", + /** The user is editing an existing Send. */ + Edit: "edit", +} as const); + +type Action = (typeof Action)[keyof typeof Action]; + +const BroadcasterSubscriptionId = "SendV2Component"; + +// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush +// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection @Component({ selector: "app-send-v2", - imports: [], - template: "

    Sends V2 Component

    ", - changeDetection: ChangeDetectionStrategy.OnPush, + imports: [CommonModule, JslibModule, FormsModule, AddEditComponent], + templateUrl: "./send-v2.component.html", }) -export class SendV2Component {} +export class SendV2Component extends BaseSendComponent implements OnInit, OnDestroy { + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals + @ViewChild(AddEditComponent) addEditComponent: AddEditComponent; + + // The ID of the currently selected Send item being viewed or edited + sendId: string; + + // Tracks the current UI state: viewing list (None), adding new Send (Add), or editing existing Send (Edit) + action: Action = Action.None; + + constructor( + sendService: SendService, + i18nService: I18nService, + platformUtilsService: PlatformUtilsService, + environmentService: EnvironmentService, + private broadcasterService: BroadcasterService, + ngZone: NgZone, + searchService: SearchService, + policyService: PolicyService, + private searchBarService: SearchBarService, + logService: LogService, + sendApiService: SendApiService, + dialogService: DialogService, + toastService: ToastService, + accountService: AccountService, + private cdr: ChangeDetectorRef, + ) { + super( + sendService, + i18nService, + platformUtilsService, + environmentService, + ngZone, + searchService, + policyService, + logService, + sendApiService, + dialogService, + toastService, + accountService, + ); + + // Listen to search bar changes and update the Send list filter + // eslint-disable-next-line rxjs-angular/prefer-takeuntil + this.searchBarService.searchText$.subscribe((searchText) => { + this.searchText = searchText; + this.searchTextChanged(); + setTimeout(() => this.cdr.detectChanges(), 250); + }); + } + + // Initialize the component: enable search bar, subscribe to sync events, and load Send items + async ngOnInit() { + this.searchBarService.setEnabled(true); + this.searchBarService.setPlaceholderText(this.i18nService.t("searchSends")); + + await super.ngOnInit(); + + // Listen for sync completion events to refresh the Send list + this.broadcasterService.subscribe(BroadcasterSubscriptionId, (message: any) => { + // FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling. + // eslint-disable-next-line @typescript-eslint/no-floating-promises + this.ngZone.run(async () => { + switch (message.command) { + case "syncCompleted": + await this.load(); + break; + } + }); + }); + await this.load(); + } + + // Clean up subscriptions and disable search bar when component is destroyed + ngOnDestroy() { + this.broadcasterService.unsubscribe(BroadcasterSubscriptionId); + this.searchBarService.setEnabled(false); + } + + // Load Send items from the service and display them in the list. + // Subscribes to sendViews$ observable to get updates when Sends change. + // Manually triggers change detection to ensure UI updates immediately. + // Note: The filter parameter is ignored in this implementation for desktop-specific behavior. + async load(filter: (send: SendView) => boolean = null) { + this.loading = true; + this.sendService.sendViews$ + .pipe( + mergeMap(async (sends) => { + this.sends = sends; + await this.search(null); + // Trigger change detection after data updates + this.cdr.detectChanges(); + }), + ) + // eslint-disable-next-line rxjs-angular/prefer-takeuntil + .subscribe(); + if (this.onSuccessfulLoad != null) { + await this.onSuccessfulLoad(); + } else { + // Default action + this.selectAll(); + } + this.loading = false; + this.loaded = true; + } + + // Open the add Send form to create a new Send item + async addSend() { + this.action = Action.Add; + if (this.addEditComponent != null) { + await this.addEditComponent.resetAndLoad(); + } + } + + // Close the add/edit form and return to the list view + cancel(s: SendView) { + this.action = Action.None; + this.sendId = null; + } + + // Handle when a Send is deleted: refresh the list and close the edit form + async deletedSend(s: SendView) { + await this.refresh(); + this.action = Action.None; + this.sendId = null; + } + + // Handle when a Send is saved: refresh the list and re-select the saved Send + async savedSend(s: SendView) { + await this.refresh(); + // FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling. + // eslint-disable-next-line @typescript-eslint/no-floating-promises + this.selectSend(s.id); + } + + // Select a Send from the list and open it in the edit form. + // If the same Send is already selected and in edit mode, do nothing to avoid unnecessary reloads. + async selectSend(sendId: string) { + if (sendId === this.sendId && this.action === Action.Edit) { + return; + } + this.action = Action.Edit; + this.sendId = sendId; + if (this.addEditComponent != null) { + this.addEditComponent.sendId = sendId; + await this.addEditComponent.refresh(); + } + } + + // Get the type (text or file) of the currently selected Send for the edit form + get selectedSendType() { + return this.sends.find((s) => s.id === this.sendId)?.type; + } + + // Show the right-click context menu for a Send with options to copy link, remove password, or delete + viewSendMenu(send: SendView) { + const menu: RendererMenuItem[] = []; + menu.push({ + label: this.i18nService.t("copyLink"), + click: () => this.copy(send), + }); + if (send.password && !send.disabled) { + menu.push({ + label: this.i18nService.t("removePassword"), + click: async () => { + await this.removePassword(send); + if (this.sendId === send.id) { + this.sendId = null; + // FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling. + // eslint-disable-next-line @typescript-eslint/no-floating-promises + this.selectSend(send.id); + } + }, + }); + } + menu.push({ + label: this.i18nService.t("delete"), + click: async () => { + await this.delete(send); + await this.deletedSend(send); + }, + }); + + invokeMenu(menu); + } +} From dc953b3945b0fa383c714b35bb3254b37dfc9ccb Mon Sep 17 00:00:00 2001 From: Bryan Cunningham Date: Tue, 2 Dec 2025 16:03:06 -0500 Subject: [PATCH 020/131] Revert using tooltip in appA11yTitle directive (#17787) * revert using tooltip in title directive * add back tooltip delay from revert * add back label to carousel buttons * fix documentation that does not need reverted * remove unnecessary label attr --- .../setup-extension.component.html | 2 +- .../src/a11y/a11y-title.directive.ts | 32 +++++-------------- .../src/tooltip/tooltip.directive.ts | 1 + 3 files changed, 10 insertions(+), 25 deletions(-) diff --git a/apps/web/src/app/vault/components/setup-extension/setup-extension.component.html b/apps/web/src/app/vault/components/setup-extension/setup-extension.component.html index a4b21915620..1976321b4ee 100644 --- a/apps/web/src/app/vault/components/setup-extension/setup-extension.component.html +++ b/apps/web/src/app/vault/components/setup-extension/setup-extension.component.html @@ -2,7 +2,7 @@ *ngIf="state === SetupExtensionState.Loading" class="bwi bwi-spinner bwi-spin bwi-3x tw-text-muted" aria-hidden="true" - [title]="'loading' | i18n" + [appA11yTitle]="'loading' | i18n" >
    diff --git a/libs/components/src/a11y/a11y-title.directive.ts b/libs/components/src/a11y/a11y-title.directive.ts index 5864874b734..75c2967805f 100644 --- a/libs/components/src/a11y/a11y-title.directive.ts +++ b/libs/components/src/a11y/a11y-title.directive.ts @@ -1,38 +1,22 @@ -import { Directive, effect, ElementRef, inject } from "@angular/core"; - -import { TooltipDirective } from "../tooltip/tooltip.directive"; +import { Directive, effect, ElementRef, input } from "@angular/core"; import { setA11yTitleAndAriaLabel } from "./set-a11y-title-and-aria-label"; -/** - * @deprecated This function is deprecated in favor of `bitTooltip`. - * Please use `bitTooltip` instead. - * - * Directive that provides accessible tooltips by internally using TooltipDirective. - * This maintains the appA11yTitle API while leveraging the enhanced tooltip functionality. - */ @Directive({ selector: "[appA11yTitle]", - hostDirectives: [ - { - directive: TooltipDirective, - inputs: ["bitTooltip: appA11yTitle", "tooltipPosition"], - }, - ], }) export class A11yTitleDirective { - private readonly elementRef = inject(ElementRef); - private readonly tooltipDirective = inject(TooltipDirective); + readonly title = input.required({ alias: "appA11yTitle" }); - constructor() { - const originalAriaLabel = this.elementRef.nativeElement.getAttribute("aria-label"); + constructor(private el: ElementRef) { + const originalTitle = this.el.nativeElement.getAttribute("title"); + const originalAriaLabel = this.el.nativeElement.getAttribute("aria-label"); - // setting aria-label as a workaround for testing purposes. Should be removed once tests are updated to check element content. effect(() => { setA11yTitleAndAriaLabel({ - element: this.elementRef.nativeElement, - title: undefined, - label: originalAriaLabel ?? this.tooltipDirective.tooltipContent(), + element: this.el.nativeElement, + title: originalTitle ?? this.title(), + label: originalAriaLabel ?? this.title(), }); }); } diff --git a/libs/components/src/tooltip/tooltip.directive.ts b/libs/components/src/tooltip/tooltip.directive.ts index 12be865243e..cca52526c7d 100644 --- a/libs/components/src/tooltip/tooltip.directive.ts +++ b/libs/components/src/tooltip/tooltip.directive.ts @@ -17,6 +17,7 @@ import { TooltipPositionIdentifier, tooltipPositions } from "./tooltip-positions import { TooltipComponent, TOOLTIP_DATA } from "./tooltip.component"; export const TOOLTIP_DELAY_MS = 800; + /** * Directive to add a tooltip to any element. The tooltip content is provided via the `bitTooltip` input. * The position of the tooltip can be set via the `tooltipPosition` input. Default position is "above-center". From 6f9b25e98e18511dc1f9dc5330ad1476d8075de0 Mon Sep 17 00:00:00 2001 From: SmithThe4th Date: Tue, 2 Dec 2025 16:13:34 -0500 Subject: [PATCH 021/131] Prevented double decryption (#17768) --- .../abstractions/cipher-encryption.service.ts | 5 +- .../src/vault/services/cipher.service.spec.ts | 12 ++-- .../src/vault/services/cipher.service.ts | 14 ++-- .../default-cipher-encryption.service.spec.ts | 6 +- .../default-cipher-encryption.service.ts | 67 +++++++++++-------- 5 files changed, 63 insertions(+), 41 deletions(-) diff --git a/libs/common/src/vault/abstractions/cipher-encryption.service.ts b/libs/common/src/vault/abstractions/cipher-encryption.service.ts index 6057a91bae5..fdd42c0acf2 100644 --- a/libs/common/src/vault/abstractions/cipher-encryption.service.ts +++ b/libs/common/src/vault/abstractions/cipher-encryption.service.ts @@ -67,7 +67,10 @@ export abstract class CipherEncryptionService { * * @returns A promise that resolves to an array of decrypted cipher views */ - abstract decryptManyLegacy(ciphers: Cipher[], userId: UserId): Promise; + abstract decryptManyLegacy( + ciphers: Cipher[], + userId: UserId, + ): Promise<[CipherView[], CipherView[]]>; /** * Decrypts many ciphers using the SDK for the given userId, and returns a list of * failures. diff --git a/libs/common/src/vault/services/cipher.service.spec.ts b/libs/common/src/vault/services/cipher.service.spec.ts index 85ce8bd0423..fe2926144b8 100644 --- a/libs/common/src/vault/services/cipher.service.spec.ts +++ b/libs/common/src/vault/services/cipher.service.spec.ts @@ -807,7 +807,7 @@ describe("Cipher Service", () => { // Set up expected results const expectedSuccessCipherViews = [ - { id: mockCiphers[0].id, name: "Success 1" } as unknown as CipherListView, + { id: mockCiphers[0].id, name: "Success 1", decryptionFailure: false } as CipherView, ]; const expectedFailedCipher = new CipherView(mockCiphers[1]); @@ -815,6 +815,11 @@ describe("Cipher Service", () => { expectedFailedCipher.decryptionFailure = true; const expectedFailedCipherViews = [expectedFailedCipher]; + cipherEncryptionService.decryptManyLegacy.mockResolvedValue([ + expectedSuccessCipherViews, + expectedFailedCipherViews, + ]); + // Execute const [successes, failures] = await (cipherService as any).decryptCiphers( mockCiphers, @@ -822,10 +827,7 @@ describe("Cipher Service", () => { ); // Verify the SDK was used for decryption - expect(cipherEncryptionService.decryptManyWithFailures).toHaveBeenCalledWith( - mockCiphers, - userId, - ); + expect(cipherEncryptionService.decryptManyLegacy).toHaveBeenCalledWith(mockCiphers, userId); expect(successes).toEqual(expectedSuccessCipherViews); expect(failures).toEqual(expectedFailedCipherViews); diff --git a/libs/common/src/vault/services/cipher.service.ts b/libs/common/src/vault/services/cipher.service.ts index 72c1ca40913..b2c5ac8943c 100644 --- a/libs/common/src/vault/services/cipher.service.ts +++ b/libs/common/src/vault/services/cipher.service.ts @@ -2143,15 +2143,19 @@ export class CipherService implements CipherServiceAbstraction { userId: UserId, fullDecryption: boolean = true, ): Promise<[CipherViewLike[], CipherView[]]> { + if (fullDecryption) { + const [decryptedViews, failedViews] = await this.cipherEncryptionService.decryptManyLegacy( + ciphers, + userId, + ); + return [decryptedViews.sort(this.getLocaleSortingFunction()), failedViews]; + } + const [decrypted, failures] = await this.cipherEncryptionService.decryptManyWithFailures( ciphers, userId, ); - const decryptedViews = fullDecryption - ? await Promise.all(decrypted.map((c) => this.getFullCipherView(c))) - : decrypted; - const failedViews = failures.map((c) => { const cipher_view = new CipherView(c); cipher_view.name = "[error: cannot decrypt]"; @@ -2159,7 +2163,7 @@ export class CipherService implements CipherServiceAbstraction { return cipher_view; }); - return [decryptedViews.sort(this.getLocaleSortingFunction()), failedViews]; + return [decrypted.sort(this.getLocaleSortingFunction()), failedViews]; } /** Fetches the full `CipherView` when a `CipherListView` is passed. */ diff --git a/libs/common/src/vault/services/default-cipher-encryption.service.spec.ts b/libs/common/src/vault/services/default-cipher-encryption.service.spec.ts index 6d6341bd1fa..f54dfa17a38 100644 --- a/libs/common/src/vault/services/default-cipher-encryption.service.spec.ts +++ b/libs/common/src/vault/services/default-cipher-encryption.service.spec.ts @@ -496,9 +496,11 @@ describe("DefaultCipherEncryptionService", () => { .mockReturnValueOnce(expectedViews[0]) .mockReturnValueOnce(expectedViews[1]); - const result = await cipherEncryptionService.decryptManyLegacy(ciphers, userId); + const [successfulDecryptions, failedDecryptions] = + await cipherEncryptionService.decryptManyLegacy(ciphers, userId); - expect(result).toEqual(expectedViews); + expect(successfulDecryptions).toEqual(expectedViews); + expect(failedDecryptions).toEqual([]); expect(mockSdkClient.vault().ciphers().decrypt).toHaveBeenCalledTimes(2); expect(CipherView.fromSdkCipherView).toHaveBeenCalledTimes(2); }); diff --git a/libs/common/src/vault/services/default-cipher-encryption.service.ts b/libs/common/src/vault/services/default-cipher-encryption.service.ts index 3f03e0f5e9e..f1b737ed50f 100644 --- a/libs/common/src/vault/services/default-cipher-encryption.service.ts +++ b/libs/common/src/vault/services/default-cipher-encryption.service.ts @@ -168,7 +168,7 @@ export class DefaultCipherEncryptionService implements CipherEncryptionService { ); } - decryptManyLegacy(ciphers: Cipher[], userId: UserId): Promise { + decryptManyLegacy(ciphers: Cipher[], userId: UserId): Promise<[CipherView[], CipherView[]]> { return firstValueFrom( this.sdkService.userClient$(userId).pipe( map((sdk) => { @@ -178,38 +178,49 @@ export class DefaultCipherEncryptionService implements CipherEncryptionService { using ref = sdk.take(); - return ciphers.map((cipher) => { - const sdkCipherView = ref.value.vault().ciphers().decrypt(cipher.toSdkCipher()); - const clientCipherView = CipherView.fromSdkCipherView(sdkCipherView)!; + const successful: CipherView[] = []; + const failed: CipherView[] = []; - // Handle FIDO2 credentials if present - if ( - clientCipherView.type === CipherType.Login && - sdkCipherView.login?.fido2Credentials?.length - ) { - const fido2CredentialViews = ref.value - .vault() - .ciphers() - .decrypt_fido2_credentials(sdkCipherView); + ciphers.forEach((cipher) => { + try { + const sdkCipherView = ref.value.vault().ciphers().decrypt(cipher.toSdkCipher()); + const clientCipherView = CipherView.fromSdkCipherView(sdkCipherView)!; - // TODO (PM-21259): Remove manual keyValue decryption for FIDO2 credentials. - // This is a temporary workaround until we can use the SDK for FIDO2 authentication. - const decryptedKeyValue = ref.value - .vault() - .ciphers() - .decrypt_fido2_private_key(sdkCipherView); + // Handle FIDO2 credentials if present + if ( + clientCipherView.type === CipherType.Login && + sdkCipherView.login?.fido2Credentials?.length + ) { + const fido2CredentialViews = ref.value + .vault() + .ciphers() + .decrypt_fido2_credentials(sdkCipherView); - clientCipherView.login.fido2Credentials = fido2CredentialViews - .map((f) => { - const view = Fido2CredentialView.fromSdkFido2CredentialView(f)!; - view.keyValue = decryptedKeyValue; - return view; - }) - .filter((view): view is Fido2CredentialView => view !== undefined); + const decryptedKeyValue = ref.value + .vault() + .ciphers() + .decrypt_fido2_private_key(sdkCipherView); + + clientCipherView.login.fido2Credentials = fido2CredentialViews + .map((f) => { + const view = Fido2CredentialView.fromSdkFido2CredentialView(f)!; + view.keyValue = decryptedKeyValue; + return view; + }) + .filter((view): view is Fido2CredentialView => view !== undefined); + } + + successful.push(clientCipherView); + } catch (error) { + this.logService.error(`Failed to decrypt cipher ${cipher.id}: ${error}`); + const failedView = new CipherView(cipher); + failedView.name = "[error: cannot decrypt]"; + failedView.decryptionFailure = true; + failed.push(failedView); } - - return clientCipherView; }); + + return [successful, failed] as [CipherView[], CipherView[]]; }), catchError((error: unknown) => { this.logService.error(`Failed to decrypt ciphers: ${error}`); From cf416388d7c1e52bba0b42e2fa8ad1617b57435f Mon Sep 17 00:00:00 2001 From: Jeffrey Holland <124393578+jholland-livefront@users.noreply.github.com> Date: Wed, 3 Dec 2025 09:46:40 +0100 Subject: [PATCH 022/131] Fix stale data issue in new login popout (#17307) * Fix stale data issue in new login popout * Update the comments * Address critical claude code bot suggestions * Clean out all stale data from pop up * Fix cached cipher issue * Fix caching issue between tab and overlay flow * Address claude comments --- .../add-edit/add-edit-v2.component.spec.ts | 84 +++++++++++++++++++ .../add-edit/add-edit-v2.component.ts | 50 ++++++++++- .../popup/utils/vault-popout-window.spec.ts | 40 +++++++++ .../vault/popup/utils/vault-popout-window.ts | 24 +++++- .../components/cipher-form.component.spec.ts | 13 ++- .../components/cipher-form.component.ts | 24 +++++- .../default-cipher-form-cache.service.ts | 8 +- 7 files changed, 232 insertions(+), 11 deletions(-) diff --git a/apps/browser/src/vault/popup/components/vault-v2/add-edit/add-edit-v2.component.spec.ts b/apps/browser/src/vault/popup/components/vault-v2/add-edit/add-edit-v2.component.spec.ts index 1bffcd9ad51..f2c9d470816 100644 --- a/apps/browser/src/vault/popup/components/vault-v2/add-edit/add-edit-v2.component.spec.ts +++ b/apps/browser/src/vault/popup/components/vault-v2/add-edit/add-edit-v2.component.spec.ts @@ -381,4 +381,88 @@ describe("AddEditV2Component", () => { expect(navigate).toHaveBeenCalledWith(["/tabs/vault"]); }); }); + + describe("reloadAddEditCipherData", () => { + beforeEach(fakeAsync(() => { + addEditCipherInfo$.next({ + cipher: { + name: "InitialName", + type: CipherType.Login, + login: { + password: "initialPassword", + username: "initialUsername", + uris: [{ uri: "https://initial.com" }], + }, + }, + } as AddEditCipherInfo); + queryParams$.next({}); + tick(); + + cipherServiceMock.setAddEditCipherInfo.mockClear(); + })); + + it("replaces all initialValues with new data, clearing stale fields", fakeAsync(() => { + const newCipherInfo = { + cipher: { + name: "UpdatedName", + type: CipherType.Login, + login: { + password: "updatedPassword", + uris: [{ uri: "https://updated.com" }], + }, + }, + } as AddEditCipherInfo; + + addEditCipherInfo$.next(newCipherInfo); + + const messageListener = component["messageListener"]; + messageListener({ command: "reloadAddEditCipherData" }); + tick(); + + expect(component.config.initialValues).toEqual({ + name: "UpdatedName", + password: "updatedPassword", + loginUri: "https://updated.com", + } as OptionalInitialValues); + + expect(cipherServiceMock.setAddEditCipherInfo).toHaveBeenCalledWith(null, "UserId"); + })); + + it("does not reload data if config is not set", fakeAsync(() => { + component.config = null; + + const messageListener = component["messageListener"]; + messageListener({ command: "reloadAddEditCipherData" }); + tick(); + + expect(cipherServiceMock.setAddEditCipherInfo).not.toHaveBeenCalled(); + })); + + it("does not reload data if latestCipherInfo is null", fakeAsync(() => { + addEditCipherInfo$.next(null); + + const messageListener = component["messageListener"]; + messageListener({ command: "reloadAddEditCipherData" }); + tick(); + + expect(component.config.initialValues).toEqual({ + name: "InitialName", + password: "initialPassword", + username: "initialUsername", + loginUri: "https://initial.com", + } as OptionalInitialValues); + + expect(cipherServiceMock.setAddEditCipherInfo).not.toHaveBeenCalled(); + })); + + it("ignores messages with different commands", fakeAsync(() => { + const initialValues = component.config.initialValues; + + const messageListener = component["messageListener"]; + messageListener({ command: "someOtherCommand" }); + tick(); + + expect(component.config.initialValues).toBe(initialValues); + })); + }); }); diff --git a/apps/browser/src/vault/popup/components/vault-v2/add-edit/add-edit-v2.component.ts b/apps/browser/src/vault/popup/components/vault-v2/add-edit/add-edit-v2.component.ts index 60e44cefbdf..22aad854dd0 100644 --- a/apps/browser/src/vault/popup/components/vault-v2/add-edit/add-edit-v2.component.ts +++ b/apps/browser/src/vault/popup/components/vault-v2/add-edit/add-edit-v2.component.ts @@ -1,7 +1,7 @@ // FIXME: Update this file to be type safe and remove this and next line // @ts-strict-ignore import { CommonModule } from "@angular/common"; -import { Component, OnInit } from "@angular/core"; +import { Component, OnInit, OnDestroy } from "@angular/core"; import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; import { FormsModule } from "@angular/forms"; import { ActivatedRoute, Params, Router } from "@angular/router"; @@ -158,7 +158,7 @@ export type AddEditQueryParams = Partial>; IconButtonModule, ], }) -export class AddEditV2Component implements OnInit { +export class AddEditV2Component implements OnInit, OnDestroy { headerText: string; config: CipherFormConfig; canDeleteCipher$: Observable; @@ -200,12 +200,58 @@ export class AddEditV2Component implements OnInit { this.subscribeToParams(); } + private messageListener: (message: any) => void; + async ngOnInit() { this.fido2PopoutSessionData = await firstValueFrom(this.fido2PopoutSessionData$); if (BrowserPopupUtils.inPopout(window)) { this.popupCloseWarningService.enable(); } + + // Listen for messages to reload cipher data when the pop up is already open + this.messageListener = async (message: any) => { + if (message?.command === "reloadAddEditCipherData") { + try { + await this.reloadCipherData(); + } catch (error) { + this.logService.error("Failed to reload cipher data", error); + } + } + }; + BrowserApi.addListener(chrome.runtime.onMessage, this.messageListener); + } + + ngOnDestroy() { + if (this.messageListener) { + BrowserApi.removeListener(chrome.runtime.onMessage, this.messageListener); + } + } + + /** + * Reloads the cipher data when the popup is already open and new form data is submitted. + * This completely replaces the initialValues to clear any stale data from the previous submission. + */ + private async reloadCipherData() { + if (!this.config) { + return; + } + + const activeUserId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId)); + + const latestCipherInfo = await firstValueFrom( + this.cipherService.addEditCipherInfo$(activeUserId), + ); + + if (latestCipherInfo != null) { + this.config = { + ...this.config, + initialValues: mapAddEditCipherInfoToInitialValues(latestCipherInfo), + }; + + // Be sure to clear the "cached" cipher info, so it doesn't get used again + await this.cipherService.setAddEditCipherInfo(null, activeUserId); + } } /** diff --git a/apps/browser/src/vault/popup/utils/vault-popout-window.spec.ts b/apps/browser/src/vault/popup/utils/vault-popout-window.spec.ts index 4597c004290..3389228dda4 100644 --- a/apps/browser/src/vault/popup/utils/vault-popout-window.spec.ts +++ b/apps/browser/src/vault/popup/utils/vault-popout-window.spec.ts @@ -2,6 +2,7 @@ import { mock } from "jest-mock-extended"; import { CipherType } from "@bitwarden/common/vault/enums"; +import { BrowserApi } from "../../../platform/browser/browser-api"; import BrowserPopupUtils from "../../../platform/browser/browser-popup-utils"; import { @@ -23,6 +24,19 @@ describe("VaultPopoutWindow", () => { .spyOn(BrowserPopupUtils, "closeSingleActionPopout") .mockImplementation(); + beforeEach(() => { + jest.spyOn(BrowserApi, "tabsQuery").mockResolvedValue([]); + jest.spyOn(BrowserApi, "updateWindowProperties").mockResolvedValue(); + global.chrome = { + ...global.chrome, + runtime: { + ...global.chrome?.runtime, + sendMessage: jest.fn().mockResolvedValue(undefined), + getURL: jest.fn((path) => `chrome-extension://extension-id/${path}`), + }, + }; + }); + afterEach(() => { jest.clearAllMocks(); }); @@ -123,6 +137,32 @@ describe("VaultPopoutWindow", () => { }, ); }); + + it("sends a message to refresh data when the popup is already open", async () => { + const existingPopupTab = { + id: 123, + windowId: 456, + url: `chrome-extension://extension-id/popup/index.html#/edit-cipher?singleActionPopout=${VaultPopoutType.addEditVaultItem}_${CipherType.Login}`, + } as chrome.tabs.Tab; + + jest.spyOn(BrowserApi, "tabsQuery").mockResolvedValue([existingPopupTab]); + const sendMessageSpy = jest.spyOn(chrome.runtime, "sendMessage"); + const updateWindowSpy = jest.spyOn(BrowserApi, "updateWindowProperties"); + + await openAddEditVaultItemPopout( + mock({ windowId: 1, url: "https://jest-testing-website.com" }), + { + cipherType: CipherType.Login, + }, + ); + + expect(openPopoutSpy).not.toHaveBeenCalled(); + expect(sendMessageSpy).toHaveBeenCalledWith({ + command: "reloadAddEditCipherData", + data: { cipherId: undefined, cipherType: CipherType.Login }, + }); + expect(updateWindowSpy).toHaveBeenCalledWith(456, { focused: true }); + }); }); describe("closeAddEditVaultItemPopout", () => { diff --git a/apps/browser/src/vault/popup/utils/vault-popout-window.ts b/apps/browser/src/vault/popup/utils/vault-popout-window.ts index 3dae96b6cc7..cccf005cd2e 100644 --- a/apps/browser/src/vault/popup/utils/vault-popout-window.ts +++ b/apps/browser/src/vault/popup/utils/vault-popout-window.ts @@ -115,10 +115,26 @@ async function openAddEditVaultItemPopout( addEditCipherUrl += formatQueryString("uri", url); } - await BrowserPopupUtils.openPopout(addEditCipherUrl, { - singleActionKey, - senderWindowId: windowId, - }); + const extensionUrl = chrome.runtime.getURL("popup/index.html"); + const existingPopupTabs = await BrowserApi.tabsQuery({ url: `${extensionUrl}*` }); + const existingPopup = existingPopupTabs.find((tab) => + tab.url?.includes(`singleActionPopout=${singleActionKey}`), + ); + // Check if the an existing popup is already open + try { + await chrome.runtime.sendMessage({ + command: "reloadAddEditCipherData", + data: { cipherId, cipherType }, + }); + await BrowserApi.updateWindowProperties(existingPopup.windowId, { + focused: true, + }); + } catch { + await BrowserPopupUtils.openPopout(addEditCipherUrl, { + singleActionKey, + senderWindowId: windowId, + }); + } } /** diff --git a/libs/vault/src/cipher-form/components/cipher-form.component.spec.ts b/libs/vault/src/cipher-form/components/cipher-form.component.spec.ts index 1e60ad91fb1..9f3102239ae 100644 --- a/libs/vault/src/cipher-form/components/cipher-form.component.spec.ts +++ b/libs/vault/src/cipher-form/components/cipher-form.component.spec.ts @@ -42,9 +42,18 @@ describe("CipherFormComponent", () => { { provide: CipherFormService, useValue: mockAddEditFormService }, { provide: CipherFormCacheService, - useValue: { init: jest.fn(), getCachedCipherView: jest.fn() }, + useValue: { init: jest.fn(), getCachedCipherView: jest.fn(), clearCache: jest.fn() }, + }, + { + provide: ViewCacheService, + useValue: { + signal: jest.fn(() => { + const signalFn = (): any => null; + signalFn.set = jest.fn(); + return signalFn; + }), + }, }, - { provide: ViewCacheService, useValue: { signal: jest.fn(() => (): any => null) } }, { provide: ConfigService, useValue: mock() }, { provide: AccountService, useValue: mockAccountService }, { provide: CipherArchiveService, useValue: mockCipherArchiveService }, diff --git a/libs/vault/src/cipher-form/components/cipher-form.component.ts b/libs/vault/src/cipher-form/components/cipher-form.component.ts index f94af25e90a..c9e867f8d3a 100644 --- a/libs/vault/src/cipher-form/components/cipher-form.component.ts +++ b/libs/vault/src/cipher-form/components/cipher-form.component.ts @@ -304,13 +304,30 @@ export class CipherFormComponent implements AfterViewInit, OnInit, OnChanges, Ci * Updates `updatedCipherView` based on the value from the cache. */ setInitialCipherFromCache() { + // If we are coming from the overlay/popup flow clear the cache to avoid old cached data + const hasOverlayData = + this.config.initialValues && + (this.config.initialValues.username !== undefined || + this.config.initialValues.password !== undefined); + + if (hasOverlayData) { + this.cipherFormCacheService.clearCache(); + return; + } + const cachedCipher = this.cipherFormCacheService.getCachedCipherView(); if (cachedCipher === null) { return; } - // Use the cached cipher when it matches the cipher being edited - if (this.updatedCipherView.id === cachedCipher.id) { + const isEditingExistingCipher = + this.updatedCipherView.id && this.updatedCipherView.id === cachedCipher.id; + const isCreatingNewCipher = + !this.updatedCipherView.id && + !cachedCipher.id && + this.updatedCipherView.type === cachedCipher.type; + + if (isEditingExistingCipher || isCreatingNewCipher) { this.updatedCipherView = cachedCipher; } } @@ -382,6 +399,9 @@ export class CipherFormComponent implements AfterViewInit, OnInit, OnChanges, Ci this.config, ); + // Clear the cache after successful save + this.cipherFormCacheService.clearCache(); + this.toastService.showToast({ variant: "success", title: null, diff --git a/libs/vault/src/cipher-form/services/default-cipher-form-cache.service.ts b/libs/vault/src/cipher-form/services/default-cipher-form-cache.service.ts index 25581ae5ea1..d525dcd9afa 100644 --- a/libs/vault/src/cipher-form/services/default-cipher-form-cache.service.ts +++ b/libs/vault/src/cipher-form/services/default-cipher-form-cache.service.ts @@ -22,7 +22,6 @@ export class CipherFormCacheService { key: CIPHER_FORM_CACHE_KEY, initialValue: null, deserializer: CipherView.fromJSON, - clearOnTabChange: true, }); constructor() { @@ -45,4 +44,11 @@ export class CipherFormCacheService { getCachedCipherView(): CipherView | null { return this.cipherCache(); } + + /** + * Clear the cached CipherView. + */ + clearCache(): void { + this.cipherCache.set(null); + } } From a6100d8a0ebe67fbb453049627879fa54b7c45db Mon Sep 17 00:00:00 2001 From: Bernd Schoolmann Date: Wed, 3 Dec 2025 13:11:03 +0100 Subject: [PATCH 023/131] Replace webcrypto RSA with PureCrypto RSA (#17742) --- .../abstractions/crypto-function.service.ts | 6 +- .../encrypt.service.implementation.ts | 8 +-- .../web-crypto-function.service.spec.ts | 3 +- .../services/web-crypto-function.service.ts | 47 ++++--------- .../node-crypto-function.service.spec.ts | 15 +++- .../services/node-crypto-function.service.ts | 70 +++++-------------- 6 files changed, 48 insertions(+), 101 deletions(-) diff --git a/libs/common/src/key-management/crypto/abstractions/crypto-function.service.ts b/libs/common/src/key-management/crypto/abstractions/crypto-function.service.ts index 705a1c1a24e..b16371198b3 100644 --- a/libs/common/src/key-management/crypto/abstractions/crypto-function.service.ts +++ b/libs/common/src/key-management/crypto/abstractions/crypto-function.service.ts @@ -91,7 +91,7 @@ export abstract class CryptoFunctionService { abstract rsaEncrypt( data: Uint8Array, publicKey: Uint8Array, - algorithm: "sha1" | "sha256", + algorithm: "sha1", ): Promise; /** * @deprecated HAZMAT WARNING: DO NOT USE THIS FOR NEW CODE. Implement low-level crypto operations @@ -100,10 +100,10 @@ export abstract class CryptoFunctionService { abstract rsaDecrypt( data: Uint8Array, privateKey: Uint8Array, - algorithm: "sha1" | "sha256", + algorithm: "sha1", ): Promise; abstract rsaExtractPublicKey(privateKey: Uint8Array): Promise; - abstract rsaGenerateKeyPair(length: 1024 | 2048 | 4096): Promise<[Uint8Array, Uint8Array]>; + abstract rsaGenerateKeyPair(length: 2048): Promise<[Uint8Array, Uint8Array]>; /** * Generates a key of the given length suitable for use in AES encryption */ diff --git a/libs/common/src/key-management/crypto/services/encrypt.service.implementation.ts b/libs/common/src/key-management/crypto/services/encrypt.service.implementation.ts index 132bbc306cb..a5da0c82382 100644 --- a/libs/common/src/key-management/crypto/services/encrypt.service.implementation.ts +++ b/libs/common/src/key-management/crypto/services/encrypt.service.implementation.ts @@ -252,15 +252,9 @@ export class EncryptServiceImplementation implements EncryptService { throw new Error("[Encrypt service] rsaDecrypt: No data provided for decryption."); } - let algorithm: "sha1" | "sha256"; switch (data.encryptionType) { case EncryptionType.Rsa2048_OaepSha1_B64: case EncryptionType.Rsa2048_OaepSha1_HmacSha256_B64: - algorithm = "sha1"; - break; - case EncryptionType.Rsa2048_OaepSha256_B64: - case EncryptionType.Rsa2048_OaepSha256_HmacSha256_B64: - algorithm = "sha256"; break; default: throw new Error("Invalid encryption type."); @@ -270,6 +264,6 @@ export class EncryptServiceImplementation implements EncryptService { throw new Error("[Encrypt service] rsaDecrypt: No private key provided for decryption."); } - return this.cryptoFunctionService.rsaDecrypt(data.dataBytes, privateKey, algorithm); + return this.cryptoFunctionService.rsaDecrypt(data.dataBytes, privateKey, "sha1"); } } diff --git a/libs/common/src/key-management/crypto/services/web-crypto-function.service.spec.ts b/libs/common/src/key-management/crypto/services/web-crypto-function.service.spec.ts index af23a515de2..c64926e0e5b 100644 --- a/libs/common/src/key-management/crypto/services/web-crypto-function.service.spec.ts +++ b/libs/common/src/key-management/crypto/services/web-crypto-function.service.spec.ts @@ -299,7 +299,6 @@ describe("WebCrypto Function Service", () => { }); describe("rsaGenerateKeyPair", () => { - testRsaGenerateKeyPair(1024); testRsaGenerateKeyPair(2048); // Generating 4096 bit keys can be slow. Commenting it out to save CI. @@ -495,7 +494,7 @@ function testHmac(algorithm: "sha1" | "sha256" | "sha512", mac: string) { }); } -function testRsaGenerateKeyPair(length: 1024 | 2048 | 4096) { +function testRsaGenerateKeyPair(length: 2048) { it( "should successfully generate a " + length + " bit key pair", async () => { diff --git a/libs/common/src/key-management/crypto/services/web-crypto-function.service.ts b/libs/common/src/key-management/crypto/services/web-crypto-function.service.ts index 829227cada9..ee0b5cab902 100644 --- a/libs/common/src/key-management/crypto/services/web-crypto-function.service.ts +++ b/libs/common/src/key-management/crypto/services/web-crypto-function.service.ts @@ -263,33 +263,19 @@ export class WebCryptoFunctionService implements CryptoFunctionService { async rsaEncrypt( data: Uint8Array, publicKey: Uint8Array, - algorithm: "sha1" | "sha256", + _algorithm: "sha1", ): Promise { - // Note: Edge browser requires that we specify name and hash for both key import and decrypt. - // We cannot use the proper types here. - const rsaParams = { - name: "RSA-OAEP", - hash: { name: this.toWebCryptoAlgorithm(algorithm) }, - }; - const impKey = await this.subtle.importKey("spki", publicKey, rsaParams, false, ["encrypt"]); - const buffer = await this.subtle.encrypt(rsaParams, impKey, data); - return new Uint8Array(buffer); + await SdkLoadService.Ready; + return PureCrypto.rsa_encrypt_data(data, publicKey); } async rsaDecrypt( data: Uint8Array, privateKey: Uint8Array, - algorithm: "sha1" | "sha256", + _algorithm: "sha1", ): Promise { - // Note: Edge browser requires that we specify name and hash for both key import and decrypt. - // We cannot use the proper types here. - const rsaParams = { - name: "RSA-OAEP", - hash: { name: this.toWebCryptoAlgorithm(algorithm) }, - }; - const impKey = await this.subtle.importKey("pkcs8", privateKey, rsaParams, false, ["decrypt"]); - const buffer = await this.subtle.decrypt(rsaParams, impKey, data); - return new Uint8Array(buffer); + await SdkLoadService.Ready; + return PureCrypto.rsa_decrypt_data(data, privateKey); } async rsaExtractPublicKey(privateKey: Uint8Array): Promise { @@ -297,6 +283,13 @@ export class WebCryptoFunctionService implements CryptoFunctionService { return PureCrypto.rsa_extract_public_key(privateKey) as UnsignedPublicKey; } + async rsaGenerateKeyPair(_length: 2048): Promise<[UnsignedPublicKey, Uint8Array]> { + await SdkLoadService.Ready; + const privateKey = PureCrypto.rsa_generate_keypair(); + const publicKey = await this.rsaExtractPublicKey(privateKey); + return [publicKey, privateKey]; + } + async aesGenerateKey(bitLength = 128 | 192 | 256 | 512): Promise { if (bitLength === 512) { // 512 bit keys are not supported in WebCrypto, so we concat two 256 bit keys @@ -314,20 +307,6 @@ export class WebCryptoFunctionService implements CryptoFunctionService { return new Uint8Array(rawKey) as CsprngArray; } - async rsaGenerateKeyPair(length: 1024 | 2048 | 4096): Promise<[Uint8Array, Uint8Array]> { - const rsaParams = { - name: "RSA-OAEP", - modulusLength: length, - publicExponent: new Uint8Array([0x01, 0x00, 0x01]), // 65537 - // Have to specify some algorithm - hash: { name: this.toWebCryptoAlgorithm("sha1") }, - }; - const keyPair = await this.subtle.generateKey(rsaParams, true, ["encrypt", "decrypt"]); - const publicKey = await this.subtle.exportKey("spki", keyPair.publicKey); - const privateKey = await this.subtle.exportKey("pkcs8", keyPair.privateKey); - return [new Uint8Array(publicKey), new Uint8Array(privateKey)]; - } - randomBytes(length: number): Promise { const arr = new Uint8Array(length); this.crypto.getRandomValues(arr); diff --git a/libs/node/src/services/node-crypto-function.service.spec.ts b/libs/node/src/services/node-crypto-function.service.spec.ts index 3256d85110f..28a6c127d44 100644 --- a/libs/node/src/services/node-crypto-function.service.spec.ts +++ b/libs/node/src/services/node-crypto-function.service.spec.ts @@ -1,9 +1,17 @@ +import { SdkLoadService } from "@bitwarden/common/platform/abstractions/sdk/sdk-load.service"; import { Utils } from "@bitwarden/common/platform/misc/utils"; import { EcbDecryptParameters } from "@bitwarden/common/platform/models/domain/decrypt-parameters"; import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key"; import { NodeCryptoFunctionService } from "./node-crypto-function.service"; +class TestSdkLoadService extends SdkLoadService { + protected override load(): Promise { + // Simulate successful WASM load + return Promise.resolve(); + } +} + const RsaPublicKey = "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAl0Vawl/toXzkEvB82FEtqHP" + "4xlU2ab/v0crqIfXfIoWF/XXdHGIdrZeilnRXPPJT1B9dTsasttEZNnua/0Rek/cjNDHtzT52irfoZYS7X6HNIfOi54Q+egP" + @@ -37,6 +45,10 @@ const Sha512Mac = "5ea7817a0b7c5d4d9b00364ccd214669131fc17fe4aca"; describe("NodeCrypto Function Service", () => { + beforeAll(async () => { + await new TestSdkLoadService().loadAndInit(); + }); + describe("pbkdf2", () => { const regular256Key = "pj9prw/OHPleXI6bRdmlaD+saJS4awrMiQsQiDjeu2I="; const utf8256Key = "yqvoFXgMRmHR3QPYr5pyR4uVuoHkltv9aHUP63p8n7I="; @@ -279,7 +291,6 @@ describe("NodeCrypto Function Service", () => { }); describe("rsaGenerateKeyPair", () => { - testRsaGenerateKeyPair(1024); testRsaGenerateKeyPair(2048); // Generating 4096 bit keys is really slow with Forge lib. @@ -514,7 +525,7 @@ function testCompare(fast = false) { }); } -function testRsaGenerateKeyPair(length: 1024 | 2048 | 4096) { +function testRsaGenerateKeyPair(length: 2048) { it( "should successfully generate a " + length + " bit key pair", async () => { diff --git a/libs/node/src/services/node-crypto-function.service.ts b/libs/node/src/services/node-crypto-function.service.ts index 22cc5756f30..49dbc65ca84 100644 --- a/libs/node/src/services/node-crypto-function.service.ts +++ b/libs/node/src/services/node-crypto-function.service.ts @@ -4,6 +4,7 @@ import * as forge from "node-forge"; import { CryptoFunctionService } from "@bitwarden/common/key-management/crypto/abstractions/crypto-function.service"; import { UnsignedPublicKey } from "@bitwarden/common/key-management/types"; +import { SdkLoadService } from "@bitwarden/common/platform/abstractions/sdk/sdk-load.service"; import { EncryptionType } from "@bitwarden/common/platform/enums"; import { Utils } from "@bitwarden/common/platform/misc/utils"; import { @@ -12,6 +13,7 @@ import { } from "@bitwarden/common/platform/models/domain/decrypt-parameters"; import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key"; import { CsprngArray } from "@bitwarden/common/types/csprng"; +import { PureCrypto } from "@bitwarden/sdk-internal"; export class NodeCryptoFunctionService implements CryptoFunctionService { pbkdf2( @@ -205,72 +207,34 @@ export class NodeCryptoFunctionService implements CryptoFunctionService { return Promise.resolve(this.toUint8Buffer(decBuf)); } - rsaEncrypt( + async rsaEncrypt( data: Uint8Array, publicKey: Uint8Array, - algorithm: "sha1" | "sha256", + _algorithm: "sha1", ): Promise { - if (algorithm === "sha256") { - throw new Error("Node crypto does not support RSA-OAEP SHA-256"); - } - - const pem = this.toPemPublicKey(publicKey); - const decipher = crypto.publicEncrypt(pem, this.toNodeBuffer(data)); - return Promise.resolve(this.toUint8Buffer(decipher)); + await SdkLoadService.Ready; + return PureCrypto.rsa_encrypt_data(data, publicKey); } - rsaDecrypt( + async rsaDecrypt( data: Uint8Array, privateKey: Uint8Array, - algorithm: "sha1" | "sha256", + _algorithm: "sha1", ): Promise { - if (algorithm === "sha256") { - throw new Error("Node crypto does not support RSA-OAEP SHA-256"); - } - - const pem = this.toPemPrivateKey(privateKey); - const decipher = crypto.privateDecrypt(pem, this.toNodeBuffer(data)); - return Promise.resolve(this.toUint8Buffer(decipher)); + await SdkLoadService.Ready; + return PureCrypto.rsa_decrypt_data(data, privateKey); } async rsaExtractPublicKey(privateKey: Uint8Array): Promise { - const privateKeyByteString = Utils.fromBufferToByteString(privateKey); - const privateKeyAsn1 = forge.asn1.fromDer(privateKeyByteString); - const forgePrivateKey: any = forge.pki.privateKeyFromAsn1(privateKeyAsn1); - const forgePublicKey = (forge.pki as any).setRsaPublicKey(forgePrivateKey.n, forgePrivateKey.e); - const publicKeyAsn1 = forge.pki.publicKeyToAsn1(forgePublicKey); - const publicKeyByteString = forge.asn1.toDer(publicKeyAsn1).data; - const publicKeyArray = Utils.fromByteStringToArray(publicKeyByteString); - return publicKeyArray as UnsignedPublicKey; + await SdkLoadService.Ready; + return PureCrypto.rsa_extract_public_key(privateKey) as UnsignedPublicKey; } - async rsaGenerateKeyPair(length: 1024 | 2048 | 4096): Promise<[UnsignedPublicKey, Uint8Array]> { - return new Promise<[UnsignedPublicKey, Uint8Array]>((resolve, reject) => { - forge.pki.rsa.generateKeyPair( - { - bits: length, - workers: -1, - e: 0x10001, // 65537 - }, - (error, keyPair) => { - if (error != null) { - reject(error); - return; - } - - const publicKeyAsn1 = forge.pki.publicKeyToAsn1(keyPair.publicKey); - const publicKeyByteString = forge.asn1.toDer(publicKeyAsn1).getBytes(); - const publicKey = Utils.fromByteStringToArray(publicKeyByteString); - - const privateKeyAsn1 = forge.pki.privateKeyToAsn1(keyPair.privateKey); - const privateKeyPkcs8 = forge.pki.wrapRsaPrivateKey(privateKeyAsn1); - const privateKeyByteString = forge.asn1.toDer(privateKeyPkcs8).getBytes(); - const privateKey = Utils.fromByteStringToArray(privateKeyByteString); - - resolve([publicKey as UnsignedPublicKey, privateKey]); - }, - ); - }); + async rsaGenerateKeyPair(_length: 2048): Promise<[UnsignedPublicKey, Uint8Array]> { + await SdkLoadService.Ready; + const privateKey = PureCrypto.rsa_generate_keypair(); + const publicKey = await this.rsaExtractPublicKey(privateKey); + return [publicKey, privateKey]; } aesGenerateKey(bitLength: 128 | 192 | 256 | 512): Promise { From 17ebae11d7a28d67b513a68e1477ac9a7a063e9d Mon Sep 17 00:00:00 2001 From: cyprain-okeke <108260115+cyprain-okeke@users.noreply.github.com> Date: Wed, 3 Dec 2025 16:08:48 +0100 Subject: [PATCH 024/131] Fix the bug by hiding the add button (#17744) --- .../clients/manage-clients.component.html | 26 ++++++++++--------- 1 file changed, 14 insertions(+), 12 deletions(-) diff --git a/bitwarden_license/bit-web/src/app/admin-console/providers/clients/manage-clients.component.html b/bitwarden_license/bit-web/src/app/admin-console/providers/clients/manage-clients.component.html index 2ab82bd837b..ce89b3c068d 100644 --- a/bitwarden_license/bit-web/src/app/admin-console/providers/clients/manage-clients.component.html +++ b/bitwarden_license/bit-web/src/app/admin-console/providers/clients/manage-clients.component.html @@ -2,18 +2,20 @@ @let provider = provider$ | async; - + @if (provider?.type === ProviderUserType.ProviderAdmin) { + + } `; - const shadowRoot = document.getElementById("shadow-root").attachShadow({ mode: "open" }); + const shadowRoot = document.getElementById("shadow-root")!.attachShadow({ mode: "open" }); shadowRoot.innerHTML = ` `; @@ -1668,7 +1668,7 @@ describe("AutofillOverlayContentService", () => { pageDetailsMock, ); await flushPromises(); - buttonElement.dispatchEvent(new KeyboardEvent("keyup", { code: "Enter" })); + buttonElement?.dispatchEvent(new KeyboardEvent("keyup", { code: "Enter" })); expect(sendExtensionMessageSpy).toHaveBeenCalledWith( "formFieldSubmitted", @@ -1716,6 +1716,85 @@ describe("AutofillOverlayContentService", () => { }); }); + describe("refreshMenuLayerPosition", () => { + it("calls refreshTopLayerPosition on the inline menu content service", () => { + autofillOverlayContentService.refreshMenuLayerPosition(); + + expect(inlineMenuContentService.refreshTopLayerPosition).toHaveBeenCalled(); + }); + + it("does not throw if inline menu content service is not available", () => { + const serviceWithoutInlineMenu = new AutofillOverlayContentService( + domQueryService, + domElementVisibilityService, + inlineMenuFieldQualificationService, + ); + + expect(() => serviceWithoutInlineMenu.refreshMenuLayerPosition()).not.toThrow(); + }); + }); + + describe("getOwnedInlineMenuTagNames", () => { + it("returns tag names from the inline menu content service", () => { + inlineMenuContentService.getOwnedTagNames.mockReturnValue(["div", "span"]); + + const result = autofillOverlayContentService.getOwnedInlineMenuTagNames(); + + expect(result).toEqual(["div", "span"]); + }); + + it("returns an empty array if inline menu content service is not available", () => { + const serviceWithoutInlineMenu = new AutofillOverlayContentService( + domQueryService, + domElementVisibilityService, + inlineMenuFieldQualificationService, + ); + + const result = serviceWithoutInlineMenu.getOwnedInlineMenuTagNames(); + + expect(result).toEqual([]); + }); + }); + + describe("getUnownedTopLayerItems", () => { + it("returns unowned top layer items from the inline menu content service", () => { + const mockElements = document.querySelectorAll("div"); + inlineMenuContentService.getUnownedTopLayerItems.mockReturnValue(mockElements); + + const result = autofillOverlayContentService.getUnownedTopLayerItems(true); + + expect(result).toEqual(mockElements); + expect(inlineMenuContentService.getUnownedTopLayerItems).toHaveBeenCalledWith(true); + }); + + it("returns undefined if inline menu content service is not available", () => { + const serviceWithoutInlineMenu = new AutofillOverlayContentService( + domQueryService, + domElementVisibilityService, + inlineMenuFieldQualificationService, + ); + + const result = serviceWithoutInlineMenu.getUnownedTopLayerItems(); + + expect(result).toBeUndefined(); + }); + }); + + describe("clearUserFilledFields", () => { + it("deletes all user filled fields", () => { + const mockElement1 = document.createElement("input") as FillableFormFieldElement; + const mockElement2 = document.createElement("input") as FillableFormFieldElement; + autofillOverlayContentService["userFilledFields"] = { + username: mockElement1, + password: mockElement2, + }; + + autofillOverlayContentService.clearUserFilledFields(); + + expect(autofillOverlayContentService["userFilledFields"]).toEqual({}); + }); + }); + describe("handleOverlayRepositionEvent", () => { const repositionEvents = [EVENTS.SCROLL, EVENTS.RESIZE]; repositionEvents.forEach((repositionEvent) => { @@ -2049,7 +2128,7 @@ describe("AutofillOverlayContentService", () => { }); it("skips focusing an element if no recently focused field exists", async () => { - autofillOverlayContentService["mostRecentlyFocusedField"] = undefined; + (autofillOverlayContentService as any)["mostRecentlyFocusedField"] = null; sendMockExtensionMessage({ command: "redirectAutofillInlineMenuFocusOut", @@ -2149,7 +2228,6 @@ describe("AutofillOverlayContentService", () => { }); it("returns null if the sub frame URL cannot be parsed correctly", async () => { - delete globalThis.location; globalThis.location = { href: "invalid-base" } as Location; sendMockExtensionMessage( { diff --git a/apps/browser/src/autofill/services/inline-menu-field-qualification.service.ts b/apps/browser/src/autofill/services/inline-menu-field-qualification.service.ts index f7c46a9fa77..f6afaae202f 100644 --- a/apps/browser/src/autofill/services/inline-menu-field-qualification.service.ts +++ b/apps/browser/src/autofill/services/inline-menu-field-qualification.service.ts @@ -945,7 +945,8 @@ export class InlineMenuFieldQualificationService !fieldType || !this.usernameFieldTypes.has(fieldType) || this.isExcludedFieldType(field, this.excludedAutofillFieldTypesSet) || - this.fieldHasDisqualifyingAttributeValue(field) + this.fieldHasDisqualifyingAttributeValue(field) || + this.isTotpField(field) ) { return false; } From 5f9759fde13c86cf4cd580da61919f7e7bd50de7 Mon Sep 17 00:00:00 2001 From: Vince Grassia <593223+vgrassia@users.noreply.github.com> Date: Wed, 3 Dec 2025 12:10:42 -0500 Subject: [PATCH 026/131] Update Linux build job in Build Desktop workflow to free up space on disk (#17784) --- .github/workflows/build-desktop.yml | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/.github/workflows/build-desktop.yml b/.github/workflows/build-desktop.yml index 949263b34b7..c973796207c 100644 --- a/.github/workflows/build-desktop.yml +++ b/.github/workflows/build-desktop.yml @@ -175,9 +175,23 @@ jobs: - name: Check out repo uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 with: + fetch-depth: 1 ref: ${{ github.event.pull_request.head.sha }} persist-credentials: false + - name: Free disk space for build + run: | + sudo rm -rf /usr/share/dotnet + sudo rm -rf /usr/share/swift + sudo rm -rf /usr/local/.ghcup + sudo rm -rf /usr/share/miniconda + sudo rm -rf /usr/share/az_* + sudo rm -rf /usr/local/julia* + sudo rm -rf /usr/lib/mono + sudo rm -rf /usr/lib/heroku + sudo rm -rf /usr/local/aws-cli + sudo rm -rf /usr/local/aws-sam-cli + - name: Set up Node uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0 with: From 6ae096485af03192fb30f8a26af0428ffc0601f1 Mon Sep 17 00:00:00 2001 From: "Michael L." Date: Wed, 3 Dec 2025 18:14:07 +0100 Subject: [PATCH 027/131] Add support for Helium browser integration on mac (#17293) Co-authored-by: Addison Beck --- apps/desktop/resources/entitlements.mas.plist | 3 ++- apps/desktop/src/main/native-messaging.main.ts | 1 + 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/apps/desktop/resources/entitlements.mas.plist b/apps/desktop/resources/entitlements.mas.plist index 3ebd56f0fd7..2977e5fd786 100644 --- a/apps/desktop/resources/entitlements.mas.plist +++ b/apps/desktop/resources/entitlements.mas.plist @@ -32,8 +32,9 @@ /Library/Application Support/Microsoft Edge Beta/NativeMessagingHosts/ /Library/Application Support/Microsoft Edge Dev/NativeMessagingHosts/ /Library/Application Support/Microsoft Edge Canary/NativeMessagingHosts/ - /Library/Application Support/Vivaldi/NativeMessagingHosts/ + /Library/Application Support/Vivaldi/NativeMessagingHosts/ /Library/Application Support/Zen/NativeMessagingHosts/ + /Library/Application Support/net.imput.helium com.apple.security.cs.allow-jit diff --git a/apps/desktop/src/main/native-messaging.main.ts b/apps/desktop/src/main/native-messaging.main.ts index ba5d8616752..23d2e038635 100644 --- a/apps/desktop/src/main/native-messaging.main.ts +++ b/apps/desktop/src/main/native-messaging.main.ts @@ -314,6 +314,7 @@ export class NativeMessagingMain { "Microsoft Edge Canary": `${this.homedir()}/Library/Application\ Support/Microsoft\ Edge\ Canary/`, Vivaldi: `${this.homedir()}/Library/Application\ Support/Vivaldi/`, Zen: `${this.homedir()}/Library/Application\ Support/Zen/`, + Helium: `${this.homedir()}/Library/Application\ Support/net.imput.helium/`, }; /* eslint-enable no-useless-escape */ } From 6e2203d6d4b56ec33a39e26c68a97d5e869fe081 Mon Sep 17 00:00:00 2001 From: Bernd Schoolmann Date: Wed, 3 Dec 2025 19:04:18 +0100 Subject: [PATCH 028/131] [PM-18026] Implement forced, automatic KDF upgrades (#15937) * Implement automatic kdf upgrades * Fix kdf config not being updated * Update legacy kdf state on master password unlock sync * Fix cli build * Fix * Deduplicate prompts * Fix dismiss time * Fix default kdf setting * Fix build * Undo changes * Fix test * Fix prettier * Fix test * Update libs/angular/src/key-management/encrypted-migration/encrypted-migrations-scheduler.service.ts Co-authored-by: Maciej Zieniuk <167752252+mzieniukbw@users.noreply.github.com> * Update libs/common/src/key-management/master-password/abstractions/master-password.service.abstraction.ts Co-authored-by: Maciej Zieniuk <167752252+mzieniukbw@users.noreply.github.com> * Update libs/angular/src/key-management/encrypted-migration/encrypted-migrations-scheduler.service.ts Co-authored-by: Maciej Zieniuk <167752252+mzieniukbw@users.noreply.github.com> * Only sync when there is at least one migration * Relative imports * Add tech debt comment * Resolve inconsistent prefix * Clean up * Update docs * Use default PBKDF2 iteratinos instead of custom threshold * Undo type check * Fix build * Add comment * Cleanup * Cleanup * Address component feedback * Use isnullorwhitespace * Fix tests * Allow migration only on vault * Fix tests * Run prettier * Fix tests * Prevent await race condition * Fix min and default values in kdf migration * Run sync only when a migration was run * Update libs/common/src/key-management/encrypted-migrator/default-encrypted-migrator.ts Co-authored-by: Maciej Zieniuk <167752252+mzieniukbw@users.noreply.github.com> * Fix link not being blue * Fix later button on browser --------- Co-authored-by: Maciej Zieniuk <167752252+mzieniukbw@users.noreply.github.com> --- apps/browser/src/_locales/en/messages.json | 21 ++ apps/cli/src/auth/commands/login.command.ts | 4 + apps/cli/src/base-program.ts | 1 + .../commands/unlock.command.spec.ts | 3 + .../key-management/commands/unlock.command.ts | 4 + apps/cli/src/oss-serve-configurator.ts | 1 + apps/cli/src/program.ts | 2 + .../service-container/service-container.ts | 15 + apps/desktop/src/locales/en/messages.json | 18 ++ .../app/auth/recover-two-factor.component.ts | 2 +- .../change-kdf-confirmation.component.spec.ts | 2 +- .../change-kdf-confirmation.component.ts | 2 +- apps/web/src/locales/en/messages.json | 18 ++ .../login-via-webauthn.component.ts | 2 +- ...igrations-scheduler.service.abstraction.ts | 9 + ...ypted-migrations-scheduler.service.spec.ts | 270 ++++++++++++++++++ .../encrypted-migrations-scheduler.service.ts | 188 ++++++++++++ .../prompt-migration-password.component.html | 55 ++++ .../prompt-migration-password.component.ts | 85 ++++++ .../src/services/jslib-services.module.ts | 52 +++- .../login-via-auth-request.component.ts | 2 +- .../auth/src/angular/login/login.component.ts | 2 +- .../new-device-verification.component.ts | 4 +- .../registration-finish.component.ts | 5 +- libs/auth/src/angular/sso/sso.component.ts | 2 +- .../two-factor-auth.component.ts | 2 +- .../login-success-handler.service.ts | 3 +- .../login-strategies/login.strategy.spec.ts | 2 + .../common/login-strategies/login.strategy.ts | 5 + .../password-login.strategy.ts | 4 + ...ault-login-success-handler.service.spec.ts | 14 +- .../default-login-success-handler.service.ts | 10 +- .../src/auth/models/domain/auth-result.ts | 2 + .../default-encrypted-migrator.spec.ts | 194 +++++++++++++ .../default-encrypted-migrator.ts | 113 ++++++++ .../encrypted-migrator.abstraction.ts | 32 +++ .../migrations/encrypted-migration.ts | 36 +++ .../migrations/minimum-kdf-migration.spec.ts | 184 ++++++++++++ .../migrations/minimum-kdf-migration.ts | 68 +++++ ...n.ts => change-kdf.service.abstraction.ts} | 0 ...ice.spec.ts => change-kdf.service.spec.ts} | 2 +- ...e-kdf-service.ts => change-kdf.service.ts} | 2 +- .../master-password.service.abstraction.ts | 7 + .../services/fake-master-password.service.ts | 4 + .../services/master-password.service.ts | 15 + .../lock/components/lock.component.spec.ts | 3 + .../src/lock/components/lock.component.ts | 13 + libs/state/src/core/state-definitions.ts | 18 +- 48 files changed, 1471 insertions(+), 31 deletions(-) create mode 100644 libs/angular/src/key-management/encrypted-migration/encrypted-migrations-scheduler.service.abstraction.ts create mode 100644 libs/angular/src/key-management/encrypted-migration/encrypted-migrations-scheduler.service.spec.ts create mode 100644 libs/angular/src/key-management/encrypted-migration/encrypted-migrations-scheduler.service.ts create mode 100644 libs/angular/src/key-management/encrypted-migration/prompt-migration-password.component.html create mode 100644 libs/angular/src/key-management/encrypted-migration/prompt-migration-password.component.ts create mode 100644 libs/common/src/key-management/encrypted-migrator/default-encrypted-migrator.spec.ts create mode 100644 libs/common/src/key-management/encrypted-migrator/default-encrypted-migrator.ts create mode 100644 libs/common/src/key-management/encrypted-migrator/encrypted-migrator.abstraction.ts create mode 100644 libs/common/src/key-management/encrypted-migrator/migrations/encrypted-migration.ts create mode 100644 libs/common/src/key-management/encrypted-migrator/migrations/minimum-kdf-migration.spec.ts create mode 100644 libs/common/src/key-management/encrypted-migrator/migrations/minimum-kdf-migration.ts rename libs/common/src/key-management/kdf/{change-kdf-service.abstraction.ts => change-kdf.service.abstraction.ts} (100%) rename libs/common/src/key-management/kdf/{change-kdf-service.spec.ts => change-kdf.service.spec.ts} (99%) rename libs/common/src/key-management/kdf/{change-kdf-service.ts => change-kdf.service.ts} (97%) diff --git a/apps/browser/src/_locales/en/messages.json b/apps/browser/src/_locales/en/messages.json index 6a7df1678bf..bbdea838e62 100644 --- a/apps/browser/src/_locales/en/messages.json +++ b/apps/browser/src/_locales/en/messages.json @@ -1406,6 +1406,27 @@ "learnMore": { "message": "Learn more" }, + "migrationsFailed": { + "message": "An error occurred updating the encryption settings." + }, + "updateEncryptionSettingsTitle": { + "message": "Update your encryption settings" + }, + "updateEncryptionSettingsDesc": { + "message": "The new recommended encryption settings will improve your account security. Enter your master password to update now." + }, + "confirmIdentityToContinue": { + "message": "Confirm your identity to continue" + }, + "enterYourMasterPassword": { + "message": "Enter your master password" + }, + "updateSettings": { + "message": "Update settings" + }, + "later": { + "message": "Later" + }, "authenticatorKeyTotp": { "message": "Authenticator key (TOTP)" }, diff --git a/apps/cli/src/auth/commands/login.command.ts b/apps/cli/src/auth/commands/login.command.ts index d0ab062d0b3..661e052fb72 100644 --- a/apps/cli/src/auth/commands/login.command.ts +++ b/apps/cli/src/auth/commands/login.command.ts @@ -31,6 +31,7 @@ import { TwoFactorService, TwoFactorApiService } from "@bitwarden/common/auth/tw import { ClientType } from "@bitwarden/common/enums"; import { CryptoFunctionService } from "@bitwarden/common/key-management/crypto/abstractions/crypto-function.service"; import { EncString } from "@bitwarden/common/key-management/crypto/models/enc-string"; +import { EncryptedMigrator } from "@bitwarden/common/key-management/encrypted-migrator/encrypted-migrator.abstraction"; import { KeyConnectorService } from "@bitwarden/common/key-management/key-connector/abstractions/key-connector.service"; import { MasterPasswordServiceAbstraction } from "@bitwarden/common/key-management/master-password/abstractions/master-password.service.abstraction"; import { ErrorResponse } from "@bitwarden/common/models/response/error.response"; @@ -81,6 +82,7 @@ export class LoginCommand { protected ssoUrlService: SsoUrlService, protected i18nService: I18nService, protected masterPasswordService: MasterPasswordServiceAbstraction, + protected encryptedMigrator: EncryptedMigrator, ) {} async run(email: string, password: string, options: OptionValues) { @@ -367,6 +369,8 @@ export class LoginCommand { } } + await this.encryptedMigrator.runMigrations(response.userId, password); + return await this.handleSuccessResponse(response); } catch (e) { if ( diff --git a/apps/cli/src/base-program.ts b/apps/cli/src/base-program.ts index 69a5e4e1bde..71c3830b4cc 100644 --- a/apps/cli/src/base-program.ts +++ b/apps/cli/src/base-program.ts @@ -182,6 +182,7 @@ export abstract class BaseProgram { this.serviceContainer.organizationApiService, this.serviceContainer.logout, this.serviceContainer.i18nService, + this.serviceContainer.encryptedMigrator, this.serviceContainer.masterPasswordUnlockService, this.serviceContainer.configService, ); diff --git a/apps/cli/src/key-management/commands/unlock.command.spec.ts b/apps/cli/src/key-management/commands/unlock.command.spec.ts index 928a750dca6..70e9a8fd232 100644 --- a/apps/cli/src/key-management/commands/unlock.command.spec.ts +++ b/apps/cli/src/key-management/commands/unlock.command.spec.ts @@ -7,6 +7,7 @@ import { UserVerificationService } from "@bitwarden/common/auth/abstractions/use import { VerificationType } from "@bitwarden/common/auth/enums/verification-type"; import { MasterPasswordVerificationResponse } from "@bitwarden/common/auth/types/verification"; import { CryptoFunctionService } from "@bitwarden/common/key-management/crypto/abstractions/crypto-function.service"; +import { EncryptedMigrator } from "@bitwarden/common/key-management/encrypted-migrator/encrypted-migrator.abstraction"; import { KeyConnectorService } from "@bitwarden/common/key-management/key-connector/abstractions/key-connector.service"; import { MasterPasswordUnlockService } from "@bitwarden/common/key-management/master-password/abstractions/master-password-unlock.service"; import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/key-management/master-password/abstractions/master-password.service.abstraction"; @@ -40,6 +41,7 @@ describe("UnlockCommand", () => { const organizationApiService = mock(); const logout = jest.fn(); const i18nService = mock(); + const encryptedMigrator = mock(); const masterPasswordUnlockService = mock(); const configService = mock(); @@ -92,6 +94,7 @@ describe("UnlockCommand", () => { organizationApiService, logout, i18nService, + encryptedMigrator, masterPasswordUnlockService, configService, ); diff --git a/apps/cli/src/key-management/commands/unlock.command.ts b/apps/cli/src/key-management/commands/unlock.command.ts index 4ae8ce823a4..c88d9ae1cc4 100644 --- a/apps/cli/src/key-management/commands/unlock.command.ts +++ b/apps/cli/src/key-management/commands/unlock.command.ts @@ -9,6 +9,7 @@ import { VerificationType } from "@bitwarden/common/auth/enums/verification-type import { MasterPasswordVerification } from "@bitwarden/common/auth/types/verification"; import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; import { CryptoFunctionService } from "@bitwarden/common/key-management/crypto/abstractions/crypto-function.service"; +import { EncryptedMigrator } from "@bitwarden/common/key-management/encrypted-migrator/encrypted-migrator.abstraction"; import { KeyConnectorService } from "@bitwarden/common/key-management/key-connector/abstractions/key-connector.service"; import { MasterPasswordUnlockService } from "@bitwarden/common/key-management/master-password/abstractions/master-password-unlock.service"; import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/key-management/master-password/abstractions/master-password.service.abstraction"; @@ -38,6 +39,7 @@ export class UnlockCommand { private organizationApiService: OrganizationApiServiceAbstraction, private logout: () => Promise, private i18nService: I18nService, + private encryptedMigrator: EncryptedMigrator, private masterPasswordUnlockService: MasterPasswordUnlockService, private configService: ConfigService, ) {} @@ -116,6 +118,8 @@ export class UnlockCommand { } } + await this.encryptedMigrator.runMigrations(userId, password); + return this.successResponse(); } diff --git a/apps/cli/src/oss-serve-configurator.ts b/apps/cli/src/oss-serve-configurator.ts index bd51cf4dd91..dbe17224d07 100644 --- a/apps/cli/src/oss-serve-configurator.ts +++ b/apps/cli/src/oss-serve-configurator.ts @@ -176,6 +176,7 @@ export class OssServeConfigurator { this.serviceContainer.organizationApiService, async () => await this.serviceContainer.logout(), this.serviceContainer.i18nService, + this.serviceContainer.encryptedMigrator, this.serviceContainer.masterPasswordUnlockService, this.serviceContainer.configService, ); diff --git a/apps/cli/src/program.ts b/apps/cli/src/program.ts index a47278db089..3e5b5678629 100644 --- a/apps/cli/src/program.ts +++ b/apps/cli/src/program.ts @@ -195,6 +195,7 @@ export class Program extends BaseProgram { this.serviceContainer.ssoUrlService, this.serviceContainer.i18nService, this.serviceContainer.masterPasswordService, + this.serviceContainer.encryptedMigrator, ); const response = await command.run(email, password, options); this.processResponse(response, true); @@ -311,6 +312,7 @@ export class Program extends BaseProgram { this.serviceContainer.organizationApiService, async () => await this.serviceContainer.logout(), this.serviceContainer.i18nService, + this.serviceContainer.encryptedMigrator, this.serviceContainer.masterPasswordUnlockService, this.serviceContainer.configService, ); diff --git a/apps/cli/src/service-container/service-container.ts b/apps/cli/src/service-container/service-container.ts index c163b7581b4..e29bc517f24 100644 --- a/apps/cli/src/service-container/service-container.ts +++ b/apps/cli/src/service-container/service-container.ts @@ -76,6 +76,10 @@ import { import { EncryptServiceImplementation } from "@bitwarden/common/key-management/crypto/services/encrypt.service.implementation"; import { DeviceTrustServiceAbstraction } from "@bitwarden/common/key-management/device-trust/abstractions/device-trust.service.abstraction"; import { DeviceTrustService } from "@bitwarden/common/key-management/device-trust/services/device-trust.service.implementation"; +import { DefaultEncryptedMigrator } from "@bitwarden/common/key-management/encrypted-migrator/default-encrypted-migrator"; +import { EncryptedMigrator } from "@bitwarden/common/key-management/encrypted-migrator/encrypted-migrator.abstraction"; +import { DefaultChangeKdfApiService } from "@bitwarden/common/key-management/kdf/change-kdf-api.service"; +import { DefaultChangeKdfService } from "@bitwarden/common/key-management/kdf/change-kdf.service"; import { KeyConnectorService } from "@bitwarden/common/key-management/key-connector/services/key-connector.service"; import { MasterPasswordUnlockService } from "@bitwarden/common/key-management/master-password/abstractions/master-password-unlock.service"; import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/key-management/master-password/abstractions/master-password.service.abstraction"; @@ -324,6 +328,7 @@ export class ServiceContainer { cipherEncryptionService: CipherEncryptionService; restrictedItemTypesService: RestrictedItemTypesService; cliRestrictedItemTypesService: CliRestrictedItemTypesService; + encryptedMigrator: EncryptedMigrator; securityStateService: SecurityStateService; masterPasswordUnlockService: MasterPasswordUnlockService; cipherArchiveService: CipherArchiveService; @@ -975,6 +980,16 @@ export class ServiceContainer { ); this.masterPasswordApiService = new MasterPasswordApiService(this.apiService, this.logService); + const changeKdfApiService = new DefaultChangeKdfApiService(this.apiService); + const changeKdfService = new DefaultChangeKdfService(changeKdfApiService, this.sdkService); + this.encryptedMigrator = new DefaultEncryptedMigrator( + this.kdfConfigService, + changeKdfService, + this.logService, + this.configService, + this.masterPasswordService, + this.syncService, + ); } async logout() { diff --git a/apps/desktop/src/locales/en/messages.json b/apps/desktop/src/locales/en/messages.json index 757059c4e41..8da3ba54844 100644 --- a/apps/desktop/src/locales/en/messages.json +++ b/apps/desktop/src/locales/en/messages.json @@ -1093,6 +1093,24 @@ "learnMore": { "message": "Learn more" }, + "migrationsFailed": { + "message": "An error occurred updating the encryption settings." + }, + "updateEncryptionSettingsTitle": { + "message": "Update your encryption settings" + }, + "updateEncryptionSettingsDesc": { + "message": "The new recommended encryption settings will improve your account security. Enter your master password to update now." + }, + "confirmIdentityToContinue": { + "message": "Confirm your identity to continue" + }, + "enterYourMasterPassword": { + "message": "Enter your master password" + }, + "updateSettings": { + "message": "Update settings" + }, "featureUnavailable": { "message": "Feature unavailable" }, diff --git a/apps/web/src/app/auth/recover-two-factor.component.ts b/apps/web/src/app/auth/recover-two-factor.component.ts index 9c033b88a75..20f40b5319a 100644 --- a/apps/web/src/app/auth/recover-two-factor.component.ts +++ b/apps/web/src/app/auth/recover-two-factor.component.ts @@ -108,7 +108,7 @@ export class RecoverTwoFactorComponent implements OnInit { message: this.i18nService.t("twoStepRecoverDisabled"), }); - await this.loginSuccessHandlerService.run(authResult.userId); + await this.loginSuccessHandlerService.run(authResult.userId, this.masterPassword); await this.router.navigate(["/settings/security/two-factor"]); } catch (error: unknown) { diff --git a/apps/web/src/app/key-management/change-kdf/change-kdf-confirmation.component.spec.ts b/apps/web/src/app/key-management/change-kdf/change-kdf-confirmation.component.spec.ts index 525ddd89675..2c2caba7b3a 100644 --- a/apps/web/src/app/key-management/change-kdf/change-kdf-confirmation.component.spec.ts +++ b/apps/web/src/app/key-management/change-kdf/change-kdf-confirmation.component.spec.ts @@ -4,7 +4,7 @@ import { mock, MockProxy } from "jest-mock-extended"; import { of } from "rxjs"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; -import { ChangeKdfService } from "@bitwarden/common/key-management/kdf/change-kdf-service.abstraction"; +import { ChangeKdfService } from "@bitwarden/common/key-management/kdf/change-kdf.service.abstraction"; import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service"; diff --git a/apps/web/src/app/key-management/change-kdf/change-kdf-confirmation.component.ts b/apps/web/src/app/key-management/change-kdf/change-kdf-confirmation.component.ts index b730a3597ba..ffeabffa019 100644 --- a/apps/web/src/app/key-management/change-kdf/change-kdf-confirmation.component.ts +++ b/apps/web/src/app/key-management/change-kdf/change-kdf-confirmation.component.ts @@ -5,7 +5,7 @@ import { firstValueFrom, Observable } from "rxjs"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { getUserId } from "@bitwarden/common/auth/services/account.service"; import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; -import { ChangeKdfService } from "@bitwarden/common/key-management/kdf/change-kdf-service.abstraction"; +import { ChangeKdfService } from "@bitwarden/common/key-management/kdf/change-kdf.service.abstraction"; import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service"; diff --git a/apps/web/src/locales/en/messages.json b/apps/web/src/locales/en/messages.json index 582efade7f4..19eec245885 100644 --- a/apps/web/src/locales/en/messages.json +++ b/apps/web/src/locales/en/messages.json @@ -4621,6 +4621,24 @@ "learnMore": { "message": "Learn more" }, + "migrationsFailed": { + "message": "An error occurred updating the encryption settings." + }, + "updateEncryptionSettingsTitle": { + "message": "Update your encryption settings" + }, + "updateEncryptionSettingsDesc": { + "message": "The new recommended encryption settings will improve your account security. Enter your master password to update now." + }, + "confirmIdentityToContinue": { + "message": "Confirm your identity to continue" + }, + "enterYourMasterPassword": { + "message": "Enter your master password" + }, + "updateSettings": { + "message": "Update settings" + }, "deleteRecoverDesc": { "message": "Enter your email address below to recover and delete your account." }, diff --git a/libs/angular/src/auth/login-via-webauthn/login-via-webauthn.component.ts b/libs/angular/src/auth/login-via-webauthn/login-via-webauthn.component.ts index fa2a01fe8e1..764d9fe7733 100644 --- a/libs/angular/src/auth/login-via-webauthn/login-via-webauthn.component.ts +++ b/libs/angular/src/auth/login-via-webauthn/login-via-webauthn.component.ts @@ -120,7 +120,7 @@ export class LoginViaWebAuthnComponent implements OnInit { // Only run loginSuccessHandlerService if webAuthn is used for vault decryption. const userKey = await firstValueFrom(this.keyService.userKey$(authResult.userId)); if (userKey) { - await this.loginSuccessHandlerService.run(authResult.userId); + await this.loginSuccessHandlerService.run(authResult.userId, null); } await this.router.navigate([this.successRoute]); diff --git a/libs/angular/src/key-management/encrypted-migration/encrypted-migrations-scheduler.service.abstraction.ts b/libs/angular/src/key-management/encrypted-migration/encrypted-migrations-scheduler.service.abstraction.ts new file mode 100644 index 00000000000..565cbb02cf0 --- /dev/null +++ b/libs/angular/src/key-management/encrypted-migration/encrypted-migrations-scheduler.service.abstraction.ts @@ -0,0 +1,9 @@ +import { UserId } from "@bitwarden/common/types/guid"; + +export abstract class EncryptedMigrationsSchedulerService { + /** + * Runs migrations for a user if needed, handling both interactive and non-interactive cases + * @param userId The user ID to run migrations for + */ + abstract runMigrationsIfNeeded(userId: UserId): Promise; +} diff --git a/libs/angular/src/key-management/encrypted-migration/encrypted-migrations-scheduler.service.spec.ts b/libs/angular/src/key-management/encrypted-migration/encrypted-migrations-scheduler.service.spec.ts new file mode 100644 index 00000000000..76cfbc0bfdd --- /dev/null +++ b/libs/angular/src/key-management/encrypted-migration/encrypted-migrations-scheduler.service.spec.ts @@ -0,0 +1,270 @@ +import { Router } from "@angular/router"; +import { mock } from "jest-mock-extended"; +import { of } from "rxjs"; + +import { AccountInfo } from "@bitwarden/common/auth/abstractions/account.service"; +import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service"; +import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status"; +import { EncryptedMigrator } from "@bitwarden/common/key-management/encrypted-migrator/encrypted-migrator.abstraction"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { SingleUserState, StateProvider } from "@bitwarden/common/platform/state"; +import { SyncService } from "@bitwarden/common/platform/sync"; +import { FakeAccountService } from "@bitwarden/common/spec"; +import { UserId } from "@bitwarden/common/types/guid"; +import { DialogService, ToastService } from "@bitwarden/components"; +import { LogService } from "@bitwarden/logging"; + +import { + DefaultEncryptedMigrationsSchedulerService, + ENCRYPTED_MIGRATION_DISMISSED, +} from "./encrypted-migrations-scheduler.service"; +import { PromptMigrationPasswordComponent } from "./prompt-migration-password.component"; + +const SomeUser = "SomeUser" as UserId; +const AnotherUser = "SomeOtherUser" as UserId; +const accounts: Record = { + [SomeUser]: { + name: "some user", + email: "some.user@example.com", + emailVerified: true, + }, + [AnotherUser]: { + name: "some other user", + email: "some.other.user@example.com", + emailVerified: true, + }, +}; + +describe("DefaultEncryptedMigrationsSchedulerService", () => { + let service: DefaultEncryptedMigrationsSchedulerService; + const mockAccountService = new FakeAccountService(accounts); + const mockAuthService = mock(); + const mockEncryptedMigrator = mock(); + const mockStateProvider = mock(); + const mockSyncService = mock(); + const mockDialogService = mock(); + const mockToastService = mock(); + const mockI18nService = mock(); + const mockLogService = mock(); + const mockRouter = mock(); + + const mockUserId = "test-user-id" as UserId; + const mockMasterPassword = "test-master-password"; + + const createMockUserState = (value: T): jest.Mocked> => + ({ + state$: of(value), + userId: mockUserId, + update: jest.fn(), + combinedState$: of([mockUserId, value]), + }) as any; + + beforeEach(() => { + const mockDialogRef = { + closed: of(mockMasterPassword), + }; + + jest.spyOn(PromptMigrationPasswordComponent, "open").mockReturnValue(mockDialogRef as any); + mockI18nService.t.mockReturnValue("translated_migrationsFailed"); + (mockRouter as any)["events"] = of({ url: "/vault" }) as any; + + service = new DefaultEncryptedMigrationsSchedulerService( + mockSyncService, + mockAccountService, + mockStateProvider, + mockEncryptedMigrator, + mockAuthService, + mockLogService, + mockDialogService, + mockToastService, + mockI18nService, + mockRouter, + ); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe("runMigrationsIfNeeded", () => { + it("should return early if user is not unlocked", async () => { + mockAuthService.authStatusFor$.mockReturnValue(of(AuthenticationStatus.Locked)); + + await service.runMigrationsIfNeeded(mockUserId); + + expect(mockEncryptedMigrator.needsMigrations).not.toHaveBeenCalled(); + expect(mockLogService.info).not.toHaveBeenCalled(); + }); + + it("should log and return when no migration is needed", async () => { + mockAuthService.authStatusFor$.mockReturnValue(of(AuthenticationStatus.Unlocked)); + mockEncryptedMigrator.needsMigrations.mockResolvedValue("noMigrationNeeded"); + + await service.runMigrationsIfNeeded(mockUserId); + + expect(mockEncryptedMigrator.needsMigrations).toHaveBeenCalledWith(mockUserId); + expect(mockLogService.info).toHaveBeenCalledWith( + `[EncryptedMigrationsScheduler] No migrations needed for user ${mockUserId}`, + ); + expect(mockEncryptedMigrator.runMigrations).not.toHaveBeenCalled(); + }); + + it("should run migrations without interaction when master password is not required", async () => { + mockAuthService.authStatusFor$.mockReturnValue(of(AuthenticationStatus.Unlocked)); + mockEncryptedMigrator.needsMigrations.mockResolvedValue("needsMigration"); + + await service.runMigrationsIfNeeded(mockUserId); + + expect(mockEncryptedMigrator.needsMigrations).toHaveBeenCalledWith(mockUserId); + expect(mockLogService.info).toHaveBeenCalledWith( + `[EncryptedMigrationsScheduler] User ${mockUserId} needs migrations with master password`, + ); + expect(mockEncryptedMigrator.runMigrations).toHaveBeenCalledWith(mockUserId, null); + }); + + it("should run migrations with interaction when migration is needed", async () => { + mockAuthService.authStatusFor$.mockReturnValue(of(AuthenticationStatus.Unlocked)); + mockEncryptedMigrator.needsMigrations.mockResolvedValue("needsMigrationWithMasterPassword"); + const mockUserState = createMockUserState(null); + mockStateProvider.getUser.mockReturnValue(mockUserState); + + await service.runMigrationsIfNeeded(mockUserId); + + expect(mockEncryptedMigrator.needsMigrations).toHaveBeenCalledWith(mockUserId); + expect(mockLogService.info).toHaveBeenCalledWith( + `[EncryptedMigrationsScheduler] User ${mockUserId} needs migrations with master password`, + ); + expect(PromptMigrationPasswordComponent.open).toHaveBeenCalledWith(mockDialogService); + expect(mockEncryptedMigrator.runMigrations).toHaveBeenCalledWith( + mockUserId, + mockMasterPassword, + ); + }); + }); + + describe("runMigrationsWithoutInteraction", () => { + it("should run migrations without master password", async () => { + mockAuthService.authStatusFor$.mockReturnValue(of(AuthenticationStatus.Unlocked)); + mockEncryptedMigrator.needsMigrations.mockResolvedValue("needsMigration"); + + await service.runMigrationsIfNeeded(mockUserId); + + expect(mockEncryptedMigrator.runMigrations).toHaveBeenCalledWith(mockUserId, null); + expect(mockLogService.error).not.toHaveBeenCalled(); + }); + + it("should handle errors during migration without interaction", async () => { + const mockError = new Error("Migration failed"); + mockAuthService.authStatusFor$.mockReturnValue(of(AuthenticationStatus.Unlocked)); + mockEncryptedMigrator.needsMigrations.mockResolvedValue("needsMigration"); + mockEncryptedMigrator.runMigrations.mockRejectedValue(mockError); + + await service.runMigrationsIfNeeded(mockUserId); + + expect(mockEncryptedMigrator.runMigrations).toHaveBeenCalledWith(mockUserId, null); + expect(mockLogService.error).toHaveBeenCalledWith( + "[EncryptedMigrationsScheduler] Error during migration without interaction", + mockError, + ); + }); + }); + + describe("runMigrationsWithInteraction", () => { + beforeEach(() => { + mockAuthService.authStatusFor$.mockReturnValue(of(AuthenticationStatus.Unlocked)); + mockEncryptedMigrator.needsMigrations.mockResolvedValue("needsMigrationWithMasterPassword"); + }); + + it("should skip if migration was dismissed recently", async () => { + const recentDismissDate = new Date(Date.now() - 12 * 60 * 60 * 1000); // 12 hours ago + const mockUserState = createMockUserState(recentDismissDate); + mockStateProvider.getUser.mockReturnValue(mockUserState); + + await service.runMigrationsIfNeeded(mockUserId); + + expect(mockStateProvider.getUser).toHaveBeenCalledWith( + mockUserId, + ENCRYPTED_MIGRATION_DISMISSED, + ); + expect(mockLogService.info).toHaveBeenCalledWith( + "[EncryptedMigrationsScheduler] Migration prompt dismissed recently, skipping for now.", + ); + expect(PromptMigrationPasswordComponent.open).not.toHaveBeenCalled(); + }); + + it("should prompt for migration if dismissed date is older than 24 hours", async () => { + const oldDismissDate = new Date(Date.now() - 25 * 60 * 60 * 1000); // 25 hours ago + const mockUserState = createMockUserState(oldDismissDate); + mockStateProvider.getUser.mockReturnValue(mockUserState); + + await service.runMigrationsIfNeeded(mockUserId); + + expect(mockStateProvider.getUser).toHaveBeenCalledWith( + mockUserId, + ENCRYPTED_MIGRATION_DISMISSED, + ); + expect(PromptMigrationPasswordComponent.open).toHaveBeenCalledWith(mockDialogService); + expect(mockEncryptedMigrator.runMigrations).toHaveBeenCalledWith( + mockUserId, + mockMasterPassword, + ); + }); + + it("should prompt for migration if no dismiss date exists", async () => { + const mockUserState = createMockUserState(null); + mockStateProvider.getUser.mockReturnValue(mockUserState); + + await service.runMigrationsIfNeeded(mockUserId); + + expect(PromptMigrationPasswordComponent.open).toHaveBeenCalledWith(mockDialogService); + expect(mockEncryptedMigrator.runMigrations).toHaveBeenCalledWith( + mockUserId, + mockMasterPassword, + ); + }); + + it("should set dismiss date when empty password is provided", async () => { + const mockUserState = createMockUserState(null); + mockStateProvider.getUser.mockReturnValue(mockUserState); + + const mockDialogRef = { + closed: of(""), // Empty password + }; + jest.spyOn(PromptMigrationPasswordComponent, "open").mockReturnValue(mockDialogRef as any); + + await service.runMigrationsIfNeeded(mockUserId); + + expect(PromptMigrationPasswordComponent.open).toHaveBeenCalledWith(mockDialogService); + expect(mockEncryptedMigrator.runMigrations).not.toHaveBeenCalled(); + expect(mockStateProvider.setUserState).toHaveBeenCalledWith( + ENCRYPTED_MIGRATION_DISMISSED, + expect.any(Date), + mockUserId, + ); + }); + + it("should handle errors during migration prompt and show toast", async () => { + const mockUserState = createMockUserState(null); + mockStateProvider.getUser.mockReturnValue(mockUserState); + + const mockError = new Error("Migration failed"); + mockEncryptedMigrator.runMigrations.mockRejectedValue(mockError); + + await service.runMigrationsIfNeeded(mockUserId); + + expect(PromptMigrationPasswordComponent.open).toHaveBeenCalledWith(mockDialogService); + expect(mockEncryptedMigrator.runMigrations).toHaveBeenCalledWith( + mockUserId, + mockMasterPassword, + ); + expect(mockLogService.error).toHaveBeenCalledWith( + "[EncryptedMigrationsScheduler] Error during migration prompt", + mockError, + ); + expect(mockToastService.showToast).toHaveBeenCalledWith({ + variant: "error", + message: "translated_migrationsFailed", + }); + }); + }); +}); diff --git a/libs/angular/src/key-management/encrypted-migration/encrypted-migrations-scheduler.service.ts b/libs/angular/src/key-management/encrypted-migration/encrypted-migrations-scheduler.service.ts new file mode 100644 index 00000000000..1c50919d1cb --- /dev/null +++ b/libs/angular/src/key-management/encrypted-migration/encrypted-migrations-scheduler.service.ts @@ -0,0 +1,188 @@ +import { NavigationEnd, Router } from "@angular/router"; +import { + combineLatest, + switchMap, + of, + firstValueFrom, + filter, + concatMap, + Observable, + map, +} from "rxjs"; + +import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service"; +import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status"; +import { EncryptedMigrator } from "@bitwarden/common/key-management/encrypted-migrator/encrypted-migrator.abstraction"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { Utils } from "@bitwarden/common/platform/misc/utils"; +import { + UserKeyDefinition, + ENCRYPTED_MIGRATION_DISK, + StateProvider, +} from "@bitwarden/common/platform/state"; +import { SyncService } from "@bitwarden/common/platform/sync"; +import { UserId } from "@bitwarden/common/types/guid"; +import { DialogService, ToastService } from "@bitwarden/components"; +import { LogService } from "@bitwarden/logging"; + +import { EncryptedMigrationsSchedulerService } from "./encrypted-migrations-scheduler.service.abstraction"; +import { PromptMigrationPasswordComponent } from "./prompt-migration-password.component"; + +export const ENCRYPTED_MIGRATION_DISMISSED = new UserKeyDefinition( + ENCRYPTED_MIGRATION_DISK, + "encryptedMigrationDismissed", + { + deserializer: (obj: string) => (obj != null ? new Date(obj) : null), + clearOn: [], + }, +); +const DISMISS_TIME_HOURS = 24; +const VAULT_ROUTE = "/vault"; + +/** + * This services schedules encrypted migrations for users on clients that are interactive (non-cli), and handles manual interaction, + * if it is required by showing a UI prompt. It is only one means of triggering migrations, in case the user stays unlocked for a while, + * or regularly logs in without a master-password, when the migrations do require a master-password to run. + */ +export class DefaultEncryptedMigrationsSchedulerService + implements EncryptedMigrationsSchedulerService +{ + isMigrating = false; + url$: Observable; + + constructor( + private syncService: SyncService, + private accountService: AccountService, + private stateProvider: StateProvider, + private encryptedMigrator: EncryptedMigrator, + private authService: AuthService, + private logService: LogService, + private dialogService: DialogService, + private toastService: ToastService, + private i18nService: I18nService, + private router: Router, + ) { + this.url$ = this.router.events.pipe( + filter((event: any) => event instanceof NavigationEnd), + map((event: NavigationEnd) => event.url), + ); + + // For all accounts, if the auth status changes to unlocked or a sync happens, prompt for migration + this.accountService.accounts$ + .pipe( + switchMap((accounts) => { + const userIds = Object.keys(accounts) as UserId[]; + + if (userIds.length === 0) { + return of([]); + } + + return combineLatest( + userIds.map((userId) => + combineLatest([ + this.authService.authStatusFor$(userId), + this.syncService.lastSync$(userId).pipe(filter((lastSync) => lastSync != null)), + this.url$, + ]).pipe( + filter( + ([authStatus, _date, url]) => + authStatus === AuthenticationStatus.Unlocked && url === VAULT_ROUTE, + ), + concatMap(() => this.runMigrationsIfNeeded(userId)), + ), + ), + ); + }), + ) + .subscribe(); + } + + async runMigrationsIfNeeded(userId: UserId): Promise { + const authStatus = await firstValueFrom(this.authService.authStatusFor$(userId)); + if (authStatus !== AuthenticationStatus.Unlocked) { + return; + } + + if (this.isMigrating || this.encryptedMigrator.isRunningMigrations()) { + this.logService.info( + `[EncryptedMigrationsScheduler] Skipping migration check for user ${userId} because migrations are already in progress`, + ); + return; + } + + this.isMigrating = true; + switch (await this.encryptedMigrator.needsMigrations(userId)) { + case "noMigrationNeeded": + this.logService.info( + `[EncryptedMigrationsScheduler] No migrations needed for user ${userId}`, + ); + break; + case "needsMigrationWithMasterPassword": + this.logService.info( + `[EncryptedMigrationsScheduler] User ${userId} needs migrations with master password`, + ); + // If the user is unlocked, we can run migrations with the master password + await this.runMigrationsWithInteraction(userId); + break; + case "needsMigration": + this.logService.info( + `[EncryptedMigrationsScheduler] User ${userId} needs migrations with master password`, + ); + // If the user is unlocked, we can prompt for the master password + await this.runMigrationsWithoutInteraction(userId); + break; + } + this.isMigrating = false; + } + + private async runMigrationsWithoutInteraction(userId: UserId): Promise { + try { + await this.encryptedMigrator.runMigrations(userId, null); + } catch (error) { + this.logService.error( + "[EncryptedMigrationsScheduler] Error during migration without interaction", + error, + ); + } + } + + private async runMigrationsWithInteraction(userId: UserId): Promise { + // A dialog can be dismissed for a certain amount of time + const dismissedDate = await firstValueFrom( + this.stateProvider.getUser(userId, ENCRYPTED_MIGRATION_DISMISSED).state$, + ); + if (dismissedDate != null) { + const now = new Date(); + const timeDiff = now.getTime() - (dismissedDate as Date).getTime(); + const hoursDiff = timeDiff / (1000 * 60 * 60); + + if (hoursDiff < DISMISS_TIME_HOURS) { + this.logService.info( + "[EncryptedMigrationsScheduler] Migration prompt dismissed recently, skipping for now.", + ); + return; + } + } + + try { + const dialog = PromptMigrationPasswordComponent.open(this.dialogService); + const masterPassword = await firstValueFrom(dialog.closed); + if (Utils.isNullOrWhitespace(masterPassword)) { + await this.stateProvider.setUserState(ENCRYPTED_MIGRATION_DISMISSED, new Date(), userId); + } else { + await this.encryptedMigrator.runMigrations( + userId, + masterPassword === undefined ? null : masterPassword, + ); + } + } catch (error) { + this.logService.error("[EncryptedMigrationsScheduler] Error during migration prompt", error); + // If migrations failed when the user actively was prompted, show a toast + this.toastService.showToast({ + variant: "error", + message: this.i18nService.t("migrationsFailed"), + }); + } + } +} diff --git a/libs/angular/src/key-management/encrypted-migration/prompt-migration-password.component.html b/libs/angular/src/key-management/encrypted-migration/prompt-migration-password.component.html new file mode 100644 index 00000000000..6df08342885 --- /dev/null +++ b/libs/angular/src/key-management/encrypted-migration/prompt-migration-password.component.html @@ -0,0 +1,55 @@ +
    + +
    + {{ "updateEncryptionSettingsTitle" | i18n }} +
    +
    +

    + {{ "updateEncryptionSettingsDesc" | i18n }} + + {{ "learnMore" | i18n }} + + +

    + + {{ "masterPass" | i18n }} + {{ "confirmIdentityToContinue" | i18n }} + + + +
    + + + + +
    +
    diff --git a/libs/angular/src/key-management/encrypted-migration/prompt-migration-password.component.ts b/libs/angular/src/key-management/encrypted-migration/prompt-migration-password.component.ts new file mode 100644 index 00000000000..060901d68fb --- /dev/null +++ b/libs/angular/src/key-management/encrypted-migration/prompt-migration-password.component.ts @@ -0,0 +1,85 @@ +import { CommonModule } from "@angular/common"; +import { Component, inject, ChangeDetectionStrategy } from "@angular/core"; +import { FormBuilder, ReactiveFormsModule, Validators } from "@angular/forms"; +import { filter, firstValueFrom, map } from "rxjs"; + +import { JslibModule } from "@bitwarden/angular/jslib.module"; +import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { UserVerificationService } from "@bitwarden/common/auth/abstractions/user-verification/user-verification.service.abstraction"; +import { VerificationType } from "@bitwarden/common/auth/enums/verification-type"; +import { + LinkModule, + AsyncActionsModule, + ButtonModule, + DialogModule, + DialogRef, + DialogService, + FormFieldModule, + IconButtonModule, +} from "@bitwarden/components"; + +/** + * This is a generic prompt to run encryption migrations that require the master password. + */ +@Component({ + changeDetection: ChangeDetectionStrategy.OnPush, + templateUrl: "prompt-migration-password.component.html", + imports: [ + DialogModule, + LinkModule, + CommonModule, + JslibModule, + ButtonModule, + IconButtonModule, + ReactiveFormsModule, + AsyncActionsModule, + FormFieldModule, + ], +}) +export class PromptMigrationPasswordComponent { + private dialogRef = inject(DialogRef); + private formBuilder = inject(FormBuilder); + private uvService = inject(UserVerificationService); + private accountService = inject(AccountService); + + migrationPasswordForm = this.formBuilder.group({ + masterPassword: ["", [Validators.required]], + }); + + static open(dialogService: DialogService) { + return dialogService.open(PromptMigrationPasswordComponent); + } + + submit = async () => { + const masterPasswordControl = this.migrationPasswordForm.controls.masterPassword; + + if (!masterPasswordControl.value || masterPasswordControl.invalid) { + return; + } + + const { userId, email } = await firstValueFrom( + this.accountService.activeAccount$.pipe( + filter((account) => account != null), + map((account) => { + return { + userId: account!.id, + email: account!.email, + }; + }), + ), + ); + + if ( + !(await this.uvService.verifyUserByMasterPassword( + { type: VerificationType.MasterPassword, secret: masterPasswordControl.value }, + userId, + email, + )) + ) { + return; + } + + // Return the master password to the caller + this.dialogRef.close(masterPasswordControl.value); + }; +} diff --git a/libs/angular/src/services/jslib-services.module.ts b/libs/angular/src/services/jslib-services.module.ts index 1589f5c5f30..13935beab19 100644 --- a/libs/angular/src/services/jslib-services.module.ts +++ b/libs/angular/src/services/jslib-services.module.ts @@ -1,6 +1,7 @@ // FIXME: Update this file to be type safe and remove this and next line // @ts-strict-ignore -import { ErrorHandler, LOCALE_ID, NgModule } from "@angular/core"; +import { APP_INITIALIZER, ErrorHandler, LOCALE_ID, NgModule } from "@angular/core"; +import { Router } from "@angular/router"; import { Subject } from "rxjs"; // This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop. @@ -177,10 +178,12 @@ import { EncryptServiceImplementation } from "@bitwarden/common/key-management/c import { WebCryptoFunctionService } from "@bitwarden/common/key-management/crypto/services/web-crypto-function.service"; import { DeviceTrustServiceAbstraction } from "@bitwarden/common/key-management/device-trust/abstractions/device-trust.service.abstraction"; import { DeviceTrustService } from "@bitwarden/common/key-management/device-trust/services/device-trust.service.implementation"; +import { DefaultEncryptedMigrator } from "@bitwarden/common/key-management/encrypted-migrator/default-encrypted-migrator"; +import { EncryptedMigrator } from "@bitwarden/common/key-management/encrypted-migrator/encrypted-migrator.abstraction"; import { DefaultChangeKdfApiService } from "@bitwarden/common/key-management/kdf/change-kdf-api.service"; import { ChangeKdfApiService } from "@bitwarden/common/key-management/kdf/change-kdf-api.service.abstraction"; -import { DefaultChangeKdfService } from "@bitwarden/common/key-management/kdf/change-kdf-service"; -import { ChangeKdfService } from "@bitwarden/common/key-management/kdf/change-kdf-service.abstraction"; +import { DefaultChangeKdfService } from "@bitwarden/common/key-management/kdf/change-kdf.service"; +import { ChangeKdfService } from "@bitwarden/common/key-management/kdf/change-kdf.service.abstraction"; import { KeyConnectorService as KeyConnectorServiceAbstraction } from "@bitwarden/common/key-management/key-connector/abstractions/key-connector.service"; import { KeyConnectorService } from "@bitwarden/common/key-management/key-connector/services/key-connector.service"; import { KeyApiService } from "@bitwarden/common/key-management/keys/services/abstractions/key-api-service.abstraction"; @@ -328,6 +331,7 @@ import { DefaultTaskService, TaskService } from "@bitwarden/common/vault/tasks"; import { AnonLayoutWrapperDataService, DefaultAnonLayoutWrapperDataService, + DialogService, ToastService, } from "@bitwarden/components"; import { @@ -396,6 +400,8 @@ import { DeviceTrustToastService as DeviceTrustToastServiceAbstraction } from ". import { DeviceTrustToastService } from "../auth/services/device-trust-toast.service.implementation"; import { NoopPremiumInterestStateService } from "../billing/services/premium-interest/noop-premium-interest-state.service"; import { PremiumInterestStateService } from "../billing/services/premium-interest/premium-interest-state.service.abstraction"; +import { DefaultEncryptedMigrationsSchedulerService } from "../key-management/encrypted-migration/encrypted-migrations-scheduler.service"; +import { EncryptedMigrationsSchedulerService } from "../key-management/encrypted-migration/encrypted-migrations-scheduler.service.abstraction"; import { FormValidationErrorsService as FormValidationErrorsServiceAbstraction } from "../platform/abstractions/form-validation-errors.service"; import { DocumentLangSetter } from "../platform/i18n"; import { FormValidationErrorsService } from "../platform/services/form-validation-errors.service"; @@ -516,6 +522,23 @@ const safeProviders: SafeProvider[] = [ TokenServiceAbstraction, ], }), + safeProvider({ + provide: ChangeKdfService, + useClass: DefaultChangeKdfService, + deps: [ChangeKdfApiService, SdkService], + }), + safeProvider({ + provide: EncryptedMigrator, + useClass: DefaultEncryptedMigrator, + deps: [ + KdfConfigService, + ChangeKdfService, + LogService, + ConfigService, + MasterPasswordServiceAbstraction, + SyncService, + ], + }), safeProvider({ provide: LoginStrategyServiceAbstraction, useClass: LoginStrategyService, @@ -1665,6 +1688,7 @@ const safeProviders: SafeProvider[] = [ SsoLoginServiceAbstraction, SyncService, UserAsymmetricKeysRegenerationService, + EncryptedMigrator, LogService, ], }), @@ -1735,6 +1759,28 @@ const safeProviders: SafeProvider[] = [ InternalMasterPasswordServiceAbstraction, ], }), + safeProvider({ + provide: EncryptedMigrationsSchedulerService, + useClass: DefaultEncryptedMigrationsSchedulerService, + deps: [ + SyncService, + AccountService, + StateProvider, + EncryptedMigrator, + AuthServiceAbstraction, + LogService, + DialogService, + ToastService, + I18nServiceAbstraction, + Router, + ], + }), + safeProvider({ + provide: APP_INITIALIZER as SafeInjectionToken<() => Promise>, + useFactory: (encryptedMigrationsScheduler: EncryptedMigrationsSchedulerService) => () => {}, + deps: [EncryptedMigrationsSchedulerService], + multi: true, + }), safeProvider({ provide: LockService, useClass: DefaultLockService, diff --git a/libs/auth/src/angular/login-via-auth-request/login-via-auth-request.component.ts b/libs/auth/src/angular/login-via-auth-request/login-via-auth-request.component.ts index 2436593dfda..040d4d3c121 100644 --- a/libs/auth/src/angular/login-via-auth-request/login-via-auth-request.component.ts +++ b/libs/auth/src/angular/login-via-auth-request/login-via-auth-request.component.ts @@ -822,7 +822,7 @@ export class LoginViaAuthRequestComponent implements OnInit, OnDestroy { } private async handleSuccessfulLoginNavigation(userId: UserId) { - await this.loginSuccessHandlerService.run(userId); + await this.loginSuccessHandlerService.run(userId, null); await this.router.navigate(["vault"]); } } diff --git a/libs/auth/src/angular/login/login.component.ts b/libs/auth/src/angular/login/login.component.ts index 0b011b5641f..91ca2b614d1 100644 --- a/libs/auth/src/angular/login/login.component.ts +++ b/libs/auth/src/angular/login/login.component.ts @@ -382,7 +382,7 @@ export class LoginComponent implements OnInit, OnDestroy { } // User logged in successfully so execute side effects - await this.loginSuccessHandlerService.run(authResult.userId); + await this.loginSuccessHandlerService.run(authResult.userId, authResult.masterPassword); // Determine where to send the user next // The AuthGuard will handle routing to change-password based on state diff --git a/libs/auth/src/angular/new-device-verification/new-device-verification.component.ts b/libs/auth/src/angular/new-device-verification/new-device-verification.component.ts index c3d6ff5d1fe..726cfd7b3b5 100644 --- a/libs/auth/src/angular/new-device-verification/new-device-verification.component.ts +++ b/libs/auth/src/angular/new-device-verification/new-device-verification.component.ts @@ -152,9 +152,7 @@ export class NewDeviceVerificationComponent implements OnInit, OnDestroy { return; } - // FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling. - // eslint-disable-next-line @typescript-eslint/no-floating-promises - this.loginSuccessHandlerService.run(authResult.userId); + await this.loginSuccessHandlerService.run(authResult.userId, authResult.masterPassword); // TODO: PM-22663 use the new service to handle routing. const activeUserId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId)); diff --git a/libs/auth/src/angular/registration/registration-finish/registration-finish.component.ts b/libs/auth/src/angular/registration/registration-finish/registration-finish.component.ts index 19e7c1feabd..99eaa2404d9 100644 --- a/libs/auth/src/angular/registration/registration-finish/registration-finish.component.ts +++ b/libs/auth/src/angular/registration/registration-finish/registration-finish.component.ts @@ -206,7 +206,10 @@ export class RegistrationFinishComponent implements OnInit, OnDestroy { return; } - await this.loginSuccessHandlerService.run(authenticationResult.userId); + await this.loginSuccessHandlerService.run( + authenticationResult.userId, + authenticationResult.masterPassword ?? null, + ); if (this.premiumInterest) { await this.premiumInterestStateService.setPremiumInterest( diff --git a/libs/auth/src/angular/sso/sso.component.ts b/libs/auth/src/angular/sso/sso.component.ts index bf618ba39f4..d0cc2bd83e5 100644 --- a/libs/auth/src/angular/sso/sso.component.ts +++ b/libs/auth/src/angular/sso/sso.component.ts @@ -437,7 +437,7 @@ export class SsoComponent implements OnInit { // Everything after the 2FA check is considered a successful login // Just have to figure out where to send the user - await this.loginSuccessHandlerService.run(authResult.userId); + await this.loginSuccessHandlerService.run(authResult.userId, null); // Save off the OrgSsoIdentifier for use in the TDE flows (or elsewhere) // - TDE login decryption options component diff --git a/libs/auth/src/angular/two-factor-auth/two-factor-auth.component.ts b/libs/auth/src/angular/two-factor-auth/two-factor-auth.component.ts index ca19d3652bb..4c143cc59f9 100644 --- a/libs/auth/src/angular/two-factor-auth/two-factor-auth.component.ts +++ b/libs/auth/src/angular/two-factor-auth/two-factor-auth.component.ts @@ -450,7 +450,7 @@ export class TwoFactorAuthComponent implements OnInit, OnDestroy { } // User is fully logged in so handle any post login logic before executing navigation - await this.loginSuccessHandlerService.run(authResult.userId); + await this.loginSuccessHandlerService.run(authResult.userId, authResult.masterPassword); // Save off the OrgSsoIdentifier for use in the TDE flows // - TDE login decryption options component diff --git a/libs/auth/src/common/abstractions/login-success-handler.service.ts b/libs/auth/src/common/abstractions/login-success-handler.service.ts index 8dee1dd32b9..e6d75d661f5 100644 --- a/libs/auth/src/common/abstractions/login-success-handler.service.ts +++ b/libs/auth/src/common/abstractions/login-success-handler.service.ts @@ -5,6 +5,7 @@ export abstract class LoginSuccessHandlerService { * Runs any service calls required after a successful login. * Service calls that should be included in this method are only those required to be awaited after successful login. * @param userId The user id. + * @param masterPassword The master password, if available. Null when logging in with SSO or other non-master-password methods. */ - abstract run(userId: UserId): Promise; + abstract run(userId: UserId, masterPassword: string | null): Promise; } diff --git a/libs/auth/src/common/login-strategies/login.strategy.spec.ts b/libs/auth/src/common/login-strategies/login.strategy.spec.ts index 38d62cfdd83..ceb36a44633 100644 --- a/libs/auth/src/common/login-strategies/login.strategy.spec.ts +++ b/libs/auth/src/common/login-strategies/login.strategy.spec.ts @@ -308,6 +308,7 @@ describe("LoginStrategy", () => { const result = await passwordLoginStrategy.logIn(credentials); const expected = new AuthResult(); + expected.masterPassword = "password"; expected.userId = userId; expected.resetMasterPassword = true; expected.twoFactorProviders = null; @@ -323,6 +324,7 @@ describe("LoginStrategy", () => { const result = await passwordLoginStrategy.logIn(credentials); const expected = new AuthResult(); + expected.masterPassword = "password"; expected.userId = userId; expected.resetMasterPassword = false; expected.twoFactorProviders = null; diff --git a/libs/auth/src/common/login-strategies/login.strategy.ts b/libs/auth/src/common/login-strategies/login.strategy.ts index b8e4ee9e822..2e3c41da900 100644 --- a/libs/auth/src/common/login-strategies/login.strategy.ts +++ b/libs/auth/src/common/login-strategies/login.strategy.ts @@ -108,6 +108,8 @@ export abstract class LoginStrategy { data.tokenRequest.setTwoFactor(twoFactor); this.cache.next(data); const [authResult] = await this.startLogIn(); + // There is an import cycle between PasswordLoginStrategyData and LoginStrategy, which means this cast is necessary, which is solved by extracting the data classes. + authResult.masterPassword = (this.cache.value as any)["masterPassword"] ?? null; return authResult; } @@ -264,6 +266,9 @@ export abstract class LoginStrategy { await this.processForceSetPasswordReason(response.forcePasswordReset, userId); this.messagingService.send("loggedIn"); + // There is an import cycle between PasswordLoginStrategyData and LoginStrategy, which means this cast is necessary, which is solved by extracting the data classes. + // TODO: https://bitwarden.atlassian.net/browse/PM-27573 + result.masterPassword = (this.cache.value as any)["masterPassword"] ?? null; return result; } diff --git a/libs/auth/src/common/login-strategies/password-login.strategy.ts b/libs/auth/src/common/login-strategies/password-login.strategy.ts index 829351cc88f..ad49567b2ff 100644 --- a/libs/auth/src/common/login-strategies/password-login.strategy.ts +++ b/libs/auth/src/common/login-strategies/password-login.strategy.ts @@ -33,6 +33,8 @@ export class PasswordLoginStrategyData implements LoginStrategyData { localMasterKeyHash: string; /** The user's master key */ masterKey: MasterKey; + /** The user's master password */ + masterPassword: string; /** * Tracks if the user needs to update their password due to * a password that does not meet an organization's master password policy. @@ -83,6 +85,7 @@ export class PasswordLoginStrategy extends LoginStrategy { masterPassword, email, ); + data.masterPassword = masterPassword; data.userEnteredEmail = email; // Hash the password early (before authentication) so we don't persist it in memory in plaintext @@ -251,6 +254,7 @@ export class PasswordLoginStrategy extends LoginStrategy { this.cache.next(data); const [authResult] = await this.startLogIn(); + authResult.masterPassword = this.cache.value["masterPassword"] ?? null; return authResult; } diff --git a/libs/auth/src/common/services/login-success-handler/default-login-success-handler.service.spec.ts b/libs/auth/src/common/services/login-success-handler/default-login-success-handler.service.spec.ts index 6fb355a8a1b..caa5d8b3290 100644 --- a/libs/auth/src/common/services/login-success-handler/default-login-success-handler.service.spec.ts +++ b/libs/auth/src/common/services/login-success-handler/default-login-success-handler.service.spec.ts @@ -1,6 +1,7 @@ import { MockProxy, mock } from "jest-mock-extended"; import { SsoLoginServiceAbstraction } from "@bitwarden/common/auth/abstractions/sso-login.service.abstraction"; +import { EncryptedMigrator } from "@bitwarden/common/key-management/encrypted-migrator/encrypted-migrator.abstraction"; import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { SyncService } from "@bitwarden/common/platform/sync"; import { UserId } from "@bitwarden/common/types/guid"; @@ -19,6 +20,7 @@ describe("DefaultLoginSuccessHandlerService", () => { let ssoLoginService: MockProxy; let syncService: MockProxy; let userAsymmetricKeysRegenerationService: MockProxy; + let encryptedMigrator: MockProxy; let logService: MockProxy; const userId = "USER_ID" as UserId; @@ -30,6 +32,7 @@ describe("DefaultLoginSuccessHandlerService", () => { ssoLoginService = mock(); syncService = mock(); userAsymmetricKeysRegenerationService = mock(); + encryptedMigrator = mock(); logService = mock(); service = new DefaultLoginSuccessHandlerService( @@ -38,6 +41,7 @@ describe("DefaultLoginSuccessHandlerService", () => { ssoLoginService, syncService, userAsymmetricKeysRegenerationService, + encryptedMigrator, logService, ); @@ -50,7 +54,7 @@ describe("DefaultLoginSuccessHandlerService", () => { describe("run", () => { it("should call required services on successful login", async () => { - await service.run(userId); + await service.run(userId, null); expect(syncService.fullSync).toHaveBeenCalledWith(true, { skipTokenRefresh: true }); expect(userAsymmetricKeysRegenerationService.regenerateIfNeeded).toHaveBeenCalledWith(userId); @@ -58,7 +62,7 @@ describe("DefaultLoginSuccessHandlerService", () => { }); it("should get SSO email", async () => { - await service.run(userId); + await service.run(userId, null); expect(ssoLoginService.getSsoEmail).toHaveBeenCalled(); }); @@ -68,8 +72,8 @@ describe("DefaultLoginSuccessHandlerService", () => { ssoLoginService.getSsoEmail.mockResolvedValue(null); }); - it("should log error and return early", async () => { - await service.run(userId); + it("should not check SSO requirements", async () => { + await service.run(userId, null); expect(logService.debug).toHaveBeenCalledWith("SSO login email not found."); expect(ssoLoginService.updateSsoRequiredCache).not.toHaveBeenCalled(); @@ -82,7 +86,7 @@ describe("DefaultLoginSuccessHandlerService", () => { }); it("should call updateSsoRequiredCache() and clearSsoEmail()", async () => { - await service.run(userId); + await service.run(userId, null); expect(ssoLoginService.updateSsoRequiredCache).toHaveBeenCalledWith(testEmail, userId); expect(ssoLoginService.clearSsoEmail).toHaveBeenCalled(); diff --git a/libs/auth/src/common/services/login-success-handler/default-login-success-handler.service.ts b/libs/auth/src/common/services/login-success-handler/default-login-success-handler.service.ts index 2b9672f1c0b..9d4311868d7 100644 --- a/libs/auth/src/common/services/login-success-handler/default-login-success-handler.service.ts +++ b/libs/auth/src/common/services/login-success-handler/default-login-success-handler.service.ts @@ -1,4 +1,5 @@ import { SsoLoginServiceAbstraction } from "@bitwarden/common/auth/abstractions/sso-login.service.abstraction"; +import { EncryptedMigrator } from "@bitwarden/common/key-management/encrypted-migrator/encrypted-migrator.abstraction"; import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { SyncService } from "@bitwarden/common/platform/sync"; import { UserId } from "@bitwarden/common/types/guid"; @@ -15,12 +16,19 @@ export class DefaultLoginSuccessHandlerService implements LoginSuccessHandlerSer private ssoLoginService: SsoLoginServiceAbstraction, private syncService: SyncService, private userAsymmetricKeysRegenerationService: UserAsymmetricKeysRegenerationService, + private encryptedMigrator: EncryptedMigrator, private logService: LogService, ) {} - async run(userId: UserId): Promise { + + async run(userId: UserId, masterPassword: string | null): Promise { await this.syncService.fullSync(true, { skipTokenRefresh: true }); await this.userAsymmetricKeysRegenerationService.regenerateIfNeeded(userId); await this.loginEmailService.clearLoginEmail(); + try { + await this.encryptedMigrator.runMigrations(userId, masterPassword); + } catch { + // Don't block login success on migration failure + } const ssoLoginEmail = await this.ssoLoginService.getSsoEmail(); diff --git a/libs/common/src/auth/models/domain/auth-result.ts b/libs/common/src/auth/models/domain/auth-result.ts index a61a35eeb1d..ae3e9bdeda6 100644 --- a/libs/common/src/auth/models/domain/auth-result.ts +++ b/libs/common/src/auth/models/domain/auth-result.ts @@ -18,6 +18,8 @@ export class AuthResult { email: string; requiresEncryptionKeyMigration: boolean; requiresDeviceVerification: boolean; + // The master-password used in the authentication process + masterPassword: string | null; get requiresTwoFactor() { return this.twoFactorProviders != null; diff --git a/libs/common/src/key-management/encrypted-migrator/default-encrypted-migrator.spec.ts b/libs/common/src/key-management/encrypted-migrator/default-encrypted-migrator.spec.ts new file mode 100644 index 00000000000..5a681ec2913 --- /dev/null +++ b/libs/common/src/key-management/encrypted-migrator/default-encrypted-migrator.spec.ts @@ -0,0 +1,194 @@ +import { mock } from "jest-mock-extended"; + +// eslint-disable-next-line no-restricted-imports +import { KdfConfigService } from "@bitwarden/key-management"; +import { LogService } from "@bitwarden/logging"; + +import { ConfigService } from "../../platform/abstractions/config/config.service"; +import { SyncService } from "../../platform/sync"; +import { UserId } from "../../types/guid"; +import { ChangeKdfService } from "../kdf/change-kdf.service.abstraction"; +import { MasterPasswordServiceAbstraction } from "../master-password/abstractions/master-password.service.abstraction"; + +import { DefaultEncryptedMigrator } from "./default-encrypted-migrator"; +import { EncryptedMigration } from "./migrations/encrypted-migration"; +import { MinimumKdfMigration } from "./migrations/minimum-kdf-migration"; + +jest.mock("./migrations/minimum-kdf-migration"); + +describe("EncryptedMigrator", () => { + const mockKdfConfigService = mock(); + const mockChangeKdfService = mock(); + const mockLogService = mock(); + const configService = mock(); + const masterPasswordService = mock(); + const syncService = mock(); + + let sut: DefaultEncryptedMigrator; + const mockMigration = mock(); + + const mockUserId = "00000000-0000-0000-0000-000000000000" as UserId; + const mockMasterPassword = "masterPassword123"; + + beforeEach(() => { + jest.clearAllMocks(); + + // Mock the MinimumKdfMigration constructor to return our mock + (MinimumKdfMigration as jest.MockedClass).mockImplementation( + () => mockMigration, + ); + + sut = new DefaultEncryptedMigrator( + mockKdfConfigService, + mockChangeKdfService, + mockLogService, + configService, + masterPasswordService, + syncService, + ); + }); + + afterEach(() => { + jest.resetAllMocks(); + }); + + describe("runMigrations", () => { + it("should throw error when userId is null", async () => { + await expect(sut.runMigrations(null as any, null)).rejects.toThrow("userId"); + }); + + it("should throw error when userId is undefined", async () => { + await expect(sut.runMigrations(undefined as any, null)).rejects.toThrow("userId"); + }); + + it("should not run migration when needsMigration returns 'noMigrationNeeded'", async () => { + mockMigration.needsMigration.mockResolvedValue("noMigrationNeeded"); + + await sut.runMigrations(mockUserId, null); + + expect(mockMigration.needsMigration).toHaveBeenCalledWith(mockUserId); + expect(mockMigration.runMigrations).not.toHaveBeenCalled(); + }); + + it("should run migration when needsMigration returns 'needsMigration'", async () => { + mockMigration.needsMigration.mockResolvedValue("needsMigration"); + + await sut.runMigrations(mockUserId, mockMasterPassword); + + expect(mockMigration.needsMigration).toHaveBeenCalledWith(mockUserId); + expect(mockMigration.runMigrations).toHaveBeenCalledWith(mockUserId, mockMasterPassword); + }); + + it("should run migration when needsMigration returns 'needsMigrationWithMasterPassword'", async () => { + mockMigration.needsMigration.mockResolvedValue("needsMigrationWithMasterPassword"); + + await sut.runMigrations(mockUserId, mockMasterPassword); + + expect(mockMigration.needsMigration).toHaveBeenCalledWith(mockUserId); + expect(mockMigration.runMigrations).toHaveBeenCalledWith(mockUserId, mockMasterPassword); + }); + + it("should throw error when migration needs master password but null is provided", async () => { + mockMigration.needsMigration.mockResolvedValue("needsMigrationWithMasterPassword"); + + await sut.runMigrations(mockUserId, null); + expect(mockMigration.needsMigration).toHaveBeenCalledWith(mockUserId); + expect(mockMigration.runMigrations).not.toHaveBeenCalled(); + }); + + it("should run multiple migrations", async () => { + const mockSecondMigration = mock(); + mockSecondMigration.needsMigration.mockResolvedValue("needsMigration"); + + (sut as any).migrations.push({ + name: "Test Second Migration", + migration: mockSecondMigration, + }); + + mockMigration.needsMigration.mockResolvedValue("needsMigration"); + + await sut.runMigrations(mockUserId, mockMasterPassword); + + expect(mockMigration.needsMigration).toHaveBeenCalledWith(mockUserId); + expect(mockSecondMigration.needsMigration).toHaveBeenCalledWith(mockUserId); + expect(mockMigration.runMigrations).toHaveBeenCalledWith(mockUserId, mockMasterPassword); + expect(mockSecondMigration.runMigrations).toHaveBeenCalledWith( + mockUserId, + mockMasterPassword, + ); + }); + }); + + describe("needsMigrations", () => { + it("should return 'noMigrationNeeded' when no migrations are needed", async () => { + mockMigration.needsMigration.mockResolvedValue("noMigrationNeeded"); + + const result = await sut.needsMigrations(mockUserId); + + expect(result).toBe("noMigrationNeeded"); + expect(mockMigration.needsMigration).toHaveBeenCalledWith(mockUserId); + }); + + it("should return 'needsMigration' when at least one migration needs to run", async () => { + mockMigration.needsMigration.mockResolvedValue("needsMigration"); + + const result = await sut.needsMigrations(mockUserId); + + expect(result).toBe("needsMigration"); + expect(mockMigration.needsMigration).toHaveBeenCalledWith(mockUserId); + }); + + it("should return 'needsMigrationWithMasterPassword' when at least one migration needs master password", async () => { + mockMigration.needsMigration.mockResolvedValue("needsMigrationWithMasterPassword"); + + const result = await sut.needsMigrations(mockUserId); + + expect(result).toBe("needsMigrationWithMasterPassword"); + expect(mockMigration.needsMigration).toHaveBeenCalledWith(mockUserId); + }); + + it("should prioritize 'needsMigrationWithMasterPassword' over 'needsMigration'", async () => { + const mockSecondMigration = mock(); + mockSecondMigration.needsMigration.mockResolvedValue("needsMigration"); + + (sut as any).migrations.push({ + name: "Test Second Migration", + migration: mockSecondMigration, + }); + + mockMigration.needsMigration.mockResolvedValue("needsMigrationWithMasterPassword"); + + const result = await sut.needsMigrations(mockUserId); + + expect(result).toBe("needsMigrationWithMasterPassword"); + expect(mockMigration.needsMigration).toHaveBeenCalledWith(mockUserId); + expect(mockSecondMigration.needsMigration).toHaveBeenCalledWith(mockUserId); + }); + + it("should return 'needsMigration' when some migrations need running but none need master password", async () => { + const mockSecondMigration = mock(); + mockSecondMigration.needsMigration.mockResolvedValue("noMigrationNeeded"); + + (sut as any).migrations.push({ + name: "Test Second Migration", + migration: mockSecondMigration, + }); + + mockMigration.needsMigration.mockResolvedValue("needsMigration"); + + const result = await sut.needsMigrations(mockUserId); + + expect(result).toBe("needsMigration"); + expect(mockMigration.needsMigration).toHaveBeenCalledWith(mockUserId); + expect(mockSecondMigration.needsMigration).toHaveBeenCalledWith(mockUserId); + }); + + it("should throw error when userId is null", async () => { + await expect(sut.needsMigrations(null as any)).rejects.toThrow("userId"); + }); + + it("should throw error when userId is undefined", async () => { + await expect(sut.needsMigrations(undefined as any)).rejects.toThrow("userId"); + }); + }); +}); diff --git a/libs/common/src/key-management/encrypted-migrator/default-encrypted-migrator.ts b/libs/common/src/key-management/encrypted-migrator/default-encrypted-migrator.ts new file mode 100644 index 00000000000..bc91e24070a --- /dev/null +++ b/libs/common/src/key-management/encrypted-migrator/default-encrypted-migrator.ts @@ -0,0 +1,113 @@ +// eslint-disable-next-line no-restricted-imports +import { KdfConfigService } from "@bitwarden/key-management"; +import { LogService } from "@bitwarden/logging"; + +import { assertNonNullish } from "../../auth/utils"; +import { ConfigService } from "../../platform/abstractions/config/config.service"; +import { SyncService } from "../../platform/sync"; +import { UserId } from "../../types/guid"; +import { ChangeKdfService } from "../kdf/change-kdf.service.abstraction"; +import { MasterPasswordServiceAbstraction } from "../master-password/abstractions/master-password.service.abstraction"; + +import { EncryptedMigrator } from "./encrypted-migrator.abstraction"; +import { EncryptedMigration, MigrationRequirement } from "./migrations/encrypted-migration"; +import { MinimumKdfMigration } from "./migrations/minimum-kdf-migration"; + +export class DefaultEncryptedMigrator implements EncryptedMigrator { + private migrations: { name: string; migration: EncryptedMigration }[] = []; + private isRunningMigration = false; + + constructor( + readonly kdfConfigService: KdfConfigService, + readonly changeKdfService: ChangeKdfService, + private readonly logService: LogService, + readonly configService: ConfigService, + readonly masterPasswordService: MasterPasswordServiceAbstraction, + readonly syncService: SyncService, + ) { + // Register migrations here + this.migrations.push({ + name: "Minimum PBKDF2 Iteration Count Migration", + migration: new MinimumKdfMigration( + kdfConfigService, + changeKdfService, + logService, + configService, + masterPasswordService, + ), + }); + } + + async runMigrations(userId: UserId, masterPassword: string | null): Promise { + assertNonNullish(userId, "userId"); + + // Ensure that the requirements for running all migrations are met + const needsMigration = await this.needsMigrations(userId); + if (needsMigration === "noMigrationNeeded") { + return; + } else if (needsMigration === "needsMigrationWithMasterPassword" && masterPassword == null) { + // If a migration needs a password, but none is provided, the migrations are skipped. If a manual caller + // during a login / unlock flow calls without a master password in a login / unlock strategy that has no + // password, such as biometric unlock, the migrations are skipped. + // + // The fallback to this, the encrypted migrations scheduler, will first check if a migration needs a password + // and then prompt the user. If the user enters their password, runMigrations is called again with the password. + return; + } + + try { + // No concurrent migrations allowed, so acquire a service-wide lock + if (this.isRunningMigration) { + return; + } + this.isRunningMigration = true; + + // Run all migrations sequentially in the order they were registered + this.logService.mark("[Encrypted Migrator] Start"); + this.logService.info(`[Encrypted Migrator] Starting migrations for user: ${userId}`); + let ranMigration = false; + for (const { name, migration } of this.migrations) { + if ((await migration.needsMigration(userId)) !== "noMigrationNeeded") { + this.logService.info(`[Encrypted Migrator] Running migration: ${name}`); + const start = performance.now(); + await migration.runMigrations(userId, masterPassword); + this.logService.measure(start, "[Encrypted Migrator]", name, "ExecutionTime"); + ranMigration = true; + } + } + this.logService.mark("[Encrypted Migrator] Finish"); + this.logService.info(`[Encrypted Migrator] Completed migrations for user: ${userId}`); + if (ranMigration) { + await this.syncService.fullSync(true); + } + } catch (error) { + this.logService.error( + `[Encrypted Migrator] Error running migrations for user: ${userId}`, + error, + ); + throw error; // Re-throw the error to be handled by the caller + } finally { + this.isRunningMigration = false; + } + } + + async needsMigrations(userId: UserId): Promise { + assertNonNullish(userId, "userId"); + + const migrationRequirements = await Promise.all( + this.migrations.map(async ({ migration }) => migration.needsMigration(userId)), + ); + + if (migrationRequirements.includes("needsMigrationWithMasterPassword")) { + return "needsMigrationWithMasterPassword"; + } else if (migrationRequirements.includes("needsMigration")) { + return "needsMigration"; + } else { + return "noMigrationNeeded"; + } + } + + isRunningMigrations(): boolean { + return this.isRunningMigration; + } +} diff --git a/libs/common/src/key-management/encrypted-migrator/encrypted-migrator.abstraction.ts b/libs/common/src/key-management/encrypted-migrator/encrypted-migrator.abstraction.ts new file mode 100644 index 00000000000..7e408374f7e --- /dev/null +++ b/libs/common/src/key-management/encrypted-migrator/encrypted-migrator.abstraction.ts @@ -0,0 +1,32 @@ +import { UserId } from "../../types/guid"; + +import { MigrationRequirement } from "./migrations/encrypted-migration"; + +export abstract class EncryptedMigrator { + /** + * Runs migrations on a decrypted user, with the cryptographic state initialized. + * This only runs the migrations that are needed for the user. + * This needs to be run after the decrypted user key has been set to state. + * + * If the master password is required but not provided, the migrations will not run, and the function will return early. + * If migrations are already running, the migrations will not run again, and the function will return early. + * + * @param userId The ID of the user to run migrations for. + * @param masterPassword The user's current master password. + * @throws If the user does not exist + * @throws If the user is locked or logged out + * @throws If a migration fails + */ + abstract runMigrations(userId: UserId, masterPassword: string | null): Promise; + /** + * Checks if the user needs to run any migrations. + * This is used to determine if the user should be prompted to run migrations. + * @param userId The ID of the user to check migrations for. + */ + abstract needsMigrations(userId: UserId): Promise; + + /** + * Indicates whether migrations are currently running. + */ + abstract isRunningMigrations(): boolean; +} diff --git a/libs/common/src/key-management/encrypted-migrator/migrations/encrypted-migration.ts b/libs/common/src/key-management/encrypted-migrator/migrations/encrypted-migration.ts new file mode 100644 index 00000000000..e6b1dcc45f6 --- /dev/null +++ b/libs/common/src/key-management/encrypted-migrator/migrations/encrypted-migration.ts @@ -0,0 +1,36 @@ +import { UserId } from "../../../types/guid"; + +/** + * @internal + * IMPORTANT: Please read this when implementing new migrations. + * + * An encrypted migration defines an online migration that mutates the persistent state of the user on the server, or locally. + * It should only be run once per user (or for local migrations, once per device). Migrations get scheduled automatically, + * during actions such as login and unlock, or during sync. + * + * Migrations can require the master-password, which is provided by the user if required. + * Migrations are run as soon as possible non-lazily, and MAY block unlock / login, if they have to run. + * + * Most importantly, implementing a migration should be done such that concurrent migrations may fail, but must never + * leave the user in a broken state. Locally, these are scheduled with an application-global lock. However, no such guarantees + * are made for the server, and other devices may run the migration concurrently. + * + * When adding a migration, it *MUST* be feature-flagged for the initial roll-out. + */ +export interface EncryptedMigration { + /** + * Runs the migration. + * @throws If the migration fails, such as when no network is available. + * @throws If the requirements for migration are not met (e.g. the user is locked) + */ + runMigrations(userId: UserId, masterPassword: string | null): Promise; + /** + * Returns whether the migration needs to be run for the user, and if it does, whether the master password is required. + */ + needsMigration(userId: UserId): Promise; +} + +export type MigrationRequirement = + | "needsMigration" + | "needsMigrationWithMasterPassword" + | "noMigrationNeeded"; diff --git a/libs/common/src/key-management/encrypted-migrator/migrations/minimum-kdf-migration.spec.ts b/libs/common/src/key-management/encrypted-migrator/migrations/minimum-kdf-migration.spec.ts new file mode 100644 index 00000000000..cf2bd307b6c --- /dev/null +++ b/libs/common/src/key-management/encrypted-migrator/migrations/minimum-kdf-migration.spec.ts @@ -0,0 +1,184 @@ +import { mock } from "jest-mock-extended"; + +// eslint-disable-next-line no-restricted-imports +import { + Argon2KdfConfig, + KdfConfigService, + KdfType, + PBKDF2KdfConfig, +} from "@bitwarden/key-management"; +import { LogService } from "@bitwarden/logging"; + +import { FeatureFlag } from "../../../enums/feature-flag.enum"; +import { ConfigService } from "../../../platform/abstractions/config/config.service"; +import { UserId } from "../../../types/guid"; +import { ChangeKdfService } from "../../kdf/change-kdf.service.abstraction"; +import { MasterPasswordServiceAbstraction } from "../../master-password/abstractions/master-password.service.abstraction"; + +import { MinimumKdfMigration } from "./minimum-kdf-migration"; + +describe("MinimumKdfMigration", () => { + const mockKdfConfigService = mock(); + const mockChangeKdfService = mock(); + const mockLogService = mock(); + const mockConfigService = mock(); + const mockMasterPasswordService = mock(); + + let sut: MinimumKdfMigration; + + const mockUserId = "00000000-0000-0000-0000-000000000000" as UserId; + const mockMasterPassword = "masterPassword"; + + beforeEach(() => { + jest.clearAllMocks(); + + sut = new MinimumKdfMigration( + mockKdfConfigService, + mockChangeKdfService, + mockLogService, + mockConfigService, + mockMasterPasswordService, + ); + }); + + describe("needsMigration", () => { + it("should return 'noMigrationNeeded' when user does not have a master password`", async () => { + mockMasterPasswordService.userHasMasterPassword.mockResolvedValue(false); + const result = await sut.needsMigration(mockUserId); + expect(result).toBe("noMigrationNeeded"); + }); + + it("should return 'noMigrationNeeded' when user uses argon2id`", async () => { + mockMasterPasswordService.userHasMasterPassword.mockResolvedValue(true); + mockKdfConfigService.getKdfConfig.mockResolvedValue(new Argon2KdfConfig(3, 64, 4)); + const result = await sut.needsMigration(mockUserId); + expect(result).toBe("noMigrationNeeded"); + }); + + it("should return 'noMigrationNeeded' when PBKDF2 iterations are already above minimum", async () => { + const mockKdfConfig = { + kdfType: KdfType.PBKDF2_SHA256, + iterations: PBKDF2KdfConfig.ITERATIONS.min + 1000, + }; + mockKdfConfigService.getKdfConfig.mockResolvedValue(mockKdfConfig as any); + + const result = await sut.needsMigration(mockUserId); + + expect(result).toBe("noMigrationNeeded"); + expect(mockKdfConfigService.getKdfConfig).toHaveBeenCalledWith(mockUserId); + }); + + it("should return 'noMigrationNeeded' when PBKDF2 iterations equal minimum", async () => { + const mockKdfConfig = { + kdfType: KdfType.PBKDF2_SHA256, + iterations: PBKDF2KdfConfig.ITERATIONS.min, + }; + mockKdfConfigService.getKdfConfig.mockResolvedValue(mockKdfConfig as any); + mockConfigService.getFeatureFlag.mockResolvedValue(true); + + const result = await sut.needsMigration(mockUserId); + + expect(result).toBe("noMigrationNeeded"); + expect(mockKdfConfigService.getKdfConfig).toHaveBeenCalledWith(mockUserId); + }); + + it("should return 'noMigrationNeeded' when feature flag is disabled", async () => { + const mockKdfConfig = { + kdfType: KdfType.PBKDF2_SHA256, + iterations: PBKDF2KdfConfig.ITERATIONS.min - 1000, + }; + mockKdfConfigService.getKdfConfig.mockResolvedValue(mockKdfConfig as any); + mockConfigService.getFeatureFlag.mockResolvedValue(false); + + const result = await sut.needsMigration(mockUserId); + + expect(result).toBe("noMigrationNeeded"); + expect(mockKdfConfigService.getKdfConfig).toHaveBeenCalledWith(mockUserId); + expect(mockConfigService.getFeatureFlag).toHaveBeenCalledWith( + FeatureFlag.ForceUpdateKDFSettings, + ); + }); + + it("should return 'needsMigrationWithMasterPassword' when PBKDF2 iterations are below minimum and feature flag is enabled", async () => { + const mockKdfConfig = { + kdfType: KdfType.PBKDF2_SHA256, + iterations: PBKDF2KdfConfig.ITERATIONS.min - 1000, + }; + mockKdfConfigService.getKdfConfig.mockResolvedValue(mockKdfConfig as any); + mockConfigService.getFeatureFlag.mockResolvedValue(true); + + const result = await sut.needsMigration(mockUserId); + + expect(result).toBe("needsMigrationWithMasterPassword"); + expect(mockKdfConfigService.getKdfConfig).toHaveBeenCalledWith(mockUserId); + expect(mockConfigService.getFeatureFlag).toHaveBeenCalledWith( + FeatureFlag.ForceUpdateKDFSettings, + ); + }); + + it("should throw error when userId is null", async () => { + await expect(sut.needsMigration(null as any)).rejects.toThrow("userId"); + }); + + it("should throw error when userId is undefined", async () => { + await expect(sut.needsMigration(undefined as any)).rejects.toThrow("userId"); + }); + }); + + describe("runMigrations", () => { + it("should update KDF parameters with minimum PBKDF2 iterations", async () => { + await sut.runMigrations(mockUserId, mockMasterPassword); + + expect(mockLogService.info).toHaveBeenCalledWith( + `[MinimumKdfMigration] Updating user ${mockUserId} to minimum PBKDF2 iteration count ${PBKDF2KdfConfig.ITERATIONS.min}`, + ); + expect(mockChangeKdfService.updateUserKdfParams).toHaveBeenCalledWith( + mockMasterPassword, + expect.any(PBKDF2KdfConfig), + mockUserId, + ); + + // Verify the PBKDF2KdfConfig has the correct iteration count + const kdfConfigArg = (mockChangeKdfService.updateUserKdfParams as jest.Mock).mock.calls[0][1]; + expect(kdfConfigArg.iterations).toBe(PBKDF2KdfConfig.ITERATIONS.defaultValue); + }); + + it("should throw error when userId is null", async () => { + await expect(sut.runMigrations(null as any, mockMasterPassword)).rejects.toThrow("userId"); + }); + + it("should throw error when userId is undefined", async () => { + await expect(sut.runMigrations(undefined as any, mockMasterPassword)).rejects.toThrow( + "userId", + ); + }); + + it("should throw error when masterPassword is null", async () => { + await expect(sut.runMigrations(mockUserId, null as any)).rejects.toThrow("masterPassword"); + }); + + it("should throw error when masterPassword is undefined", async () => { + await expect(sut.runMigrations(mockUserId, undefined as any)).rejects.toThrow( + "masterPassword", + ); + }); + + it("should handle errors from changeKdfService", async () => { + const mockError = new Error("KDF update failed"); + mockChangeKdfService.updateUserKdfParams.mockRejectedValue(mockError); + + await expect(sut.runMigrations(mockUserId, mockMasterPassword)).rejects.toThrow( + "KDF update failed", + ); + + expect(mockLogService.info).toHaveBeenCalledWith( + `[MinimumKdfMigration] Updating user ${mockUserId} to minimum PBKDF2 iteration count ${PBKDF2KdfConfig.ITERATIONS.min}`, + ); + expect(mockChangeKdfService.updateUserKdfParams).toHaveBeenCalledWith( + mockMasterPassword, + expect.any(PBKDF2KdfConfig), + mockUserId, + ); + }); + }); +}); diff --git a/libs/common/src/key-management/encrypted-migrator/migrations/minimum-kdf-migration.ts b/libs/common/src/key-management/encrypted-migrator/migrations/minimum-kdf-migration.ts new file mode 100644 index 00000000000..0666064b36e --- /dev/null +++ b/libs/common/src/key-management/encrypted-migrator/migrations/minimum-kdf-migration.ts @@ -0,0 +1,68 @@ +import { UserId } from "@bitwarden/common/types/guid"; +// eslint-disable-next-line no-restricted-imports +import { KdfConfigService, KdfType, PBKDF2KdfConfig } from "@bitwarden/key-management"; +import { LogService } from "@bitwarden/logging"; + +import { assertNonNullish } from "../../../auth/utils"; +import { FeatureFlag } from "../../../enums/feature-flag.enum"; +import { ConfigService } from "../../../platform/abstractions/config/config.service"; +import { ChangeKdfService } from "../../kdf/change-kdf.service.abstraction"; +import { MasterPasswordServiceAbstraction } from "../../master-password/abstractions/master-password.service.abstraction"; + +import { EncryptedMigration, MigrationRequirement } from "./encrypted-migration"; + +/** + * @internal + * This migrator ensures the user's account has a minimum PBKDF2 iteration count. + * It will update the entire account, logging out old clients if necessary. + */ +export class MinimumKdfMigration implements EncryptedMigration { + constructor( + private readonly kdfConfigService: KdfConfigService, + private readonly changeKdfService: ChangeKdfService, + private readonly logService: LogService, + private readonly configService: ConfigService, + private readonly masterPasswordService: MasterPasswordServiceAbstraction, + ) {} + + async runMigrations(userId: UserId, masterPassword: string | null): Promise { + assertNonNullish(userId, "userId"); + assertNonNullish(masterPassword, "masterPassword"); + + this.logService.info( + `[MinimumKdfMigration] Updating user ${userId} to minimum PBKDF2 iteration count ${PBKDF2KdfConfig.ITERATIONS.defaultValue}`, + ); + await this.changeKdfService.updateUserKdfParams( + masterPassword!, + new PBKDF2KdfConfig(PBKDF2KdfConfig.ITERATIONS.defaultValue), + userId, + ); + await this.kdfConfigService.setKdfConfig( + userId, + new PBKDF2KdfConfig(PBKDF2KdfConfig.ITERATIONS.defaultValue), + ); + } + + async needsMigration(userId: UserId): Promise { + assertNonNullish(userId, "userId"); + + if (!(await this.masterPasswordService.userHasMasterPassword(userId))) { + return "noMigrationNeeded"; + } + + // Only PBKDF2 users below the minimum iteration count need migration + const kdfConfig = await this.kdfConfigService.getKdfConfig(userId); + if ( + kdfConfig.kdfType !== KdfType.PBKDF2_SHA256 || + kdfConfig.iterations >= PBKDF2KdfConfig.ITERATIONS.min + ) { + return "noMigrationNeeded"; + } + + if (!(await this.configService.getFeatureFlag(FeatureFlag.ForceUpdateKDFSettings))) { + return "noMigrationNeeded"; + } + + return "needsMigrationWithMasterPassword"; + } +} diff --git a/libs/common/src/key-management/kdf/change-kdf-service.abstraction.ts b/libs/common/src/key-management/kdf/change-kdf.service.abstraction.ts similarity index 100% rename from libs/common/src/key-management/kdf/change-kdf-service.abstraction.ts rename to libs/common/src/key-management/kdf/change-kdf.service.abstraction.ts diff --git a/libs/common/src/key-management/kdf/change-kdf-service.spec.ts b/libs/common/src/key-management/kdf/change-kdf.service.spec.ts similarity index 99% rename from libs/common/src/key-management/kdf/change-kdf-service.spec.ts rename to libs/common/src/key-management/kdf/change-kdf.service.spec.ts index c7df90f4790..12096155641 100644 --- a/libs/common/src/key-management/kdf/change-kdf-service.spec.ts +++ b/libs/common/src/key-management/kdf/change-kdf.service.spec.ts @@ -17,7 +17,7 @@ import { } from "../master-password/types/master-password.types"; import { ChangeKdfApiService } from "./change-kdf-api.service.abstraction"; -import { DefaultChangeKdfService } from "./change-kdf-service"; +import { DefaultChangeKdfService } from "./change-kdf.service"; describe("ChangeKdfService", () => { const changeKdfApiService = mock(); diff --git a/libs/common/src/key-management/kdf/change-kdf-service.ts b/libs/common/src/key-management/kdf/change-kdf.service.ts similarity index 97% rename from libs/common/src/key-management/kdf/change-kdf-service.ts rename to libs/common/src/key-management/kdf/change-kdf.service.ts index 64fbd1fce05..89d97e6704f 100644 --- a/libs/common/src/key-management/kdf/change-kdf-service.ts +++ b/libs/common/src/key-management/kdf/change-kdf.service.ts @@ -14,7 +14,7 @@ import { } from "../master-password/types/master-password.types"; import { ChangeKdfApiService } from "./change-kdf-api.service.abstraction"; -import { ChangeKdfService } from "./change-kdf-service.abstraction"; +import { ChangeKdfService } from "./change-kdf.service.abstraction"; export class DefaultChangeKdfService implements ChangeKdfService { constructor( diff --git a/libs/common/src/key-management/master-password/abstractions/master-password.service.abstraction.ts b/libs/common/src/key-management/master-password/abstractions/master-password.service.abstraction.ts index f982c2c5ce8..0e86761685f 100644 --- a/libs/common/src/key-management/master-password/abstractions/master-password.service.abstraction.ts +++ b/libs/common/src/key-management/master-password/abstractions/master-password.service.abstraction.ts @@ -106,6 +106,13 @@ export abstract class MasterPasswordServiceAbstraction { password: string, masterPasswordUnlockData: MasterPasswordUnlockData, ) => Promise; + + /** + * Returns whether the user has a master password set. + * @param userId The user ID. + * @throws If the user ID is missing. + */ + abstract userHasMasterPassword(userId: UserId): Promise; } export abstract class InternalMasterPasswordServiceAbstraction extends MasterPasswordServiceAbstraction { diff --git a/libs/common/src/key-management/master-password/services/fake-master-password.service.ts b/libs/common/src/key-management/master-password/services/fake-master-password.service.ts index 5db7f178b18..90fcaddb1a5 100644 --- a/libs/common/src/key-management/master-password/services/fake-master-password.service.ts +++ b/libs/common/src/key-management/master-password/services/fake-master-password.service.ts @@ -33,6 +33,10 @@ export class FakeMasterPasswordService implements InternalMasterPasswordServiceA this.masterKeyHashSubject.next(initialMasterKeyHash); } + userHasMasterPassword(userId: UserId): Promise { + return this.mock.userHasMasterPassword(userId); + } + emailToSalt(email: string): MasterPasswordSalt { return this.mock.emailToSalt(email); } diff --git a/libs/common/src/key-management/master-password/services/master-password.service.ts b/libs/common/src/key-management/master-password/services/master-password.service.ts index 8012a9230e7..c2947b2263d 100644 --- a/libs/common/src/key-management/master-password/services/master-password.service.ts +++ b/libs/common/src/key-management/master-password/services/master-password.service.ts @@ -25,6 +25,7 @@ import { MasterKey, UserKey } from "../../../types/key"; import { KeyGenerationService } from "../../crypto"; import { CryptoFunctionService } from "../../crypto/abstractions/crypto-function.service"; import { EncryptedString, EncString } from "../../crypto/models/enc-string"; +import { USES_KEY_CONNECTOR } from "../../key-connector/services/key-connector.service"; import { InternalMasterPasswordServiceAbstraction } from "../abstractions/master-password.service.abstraction"; import { MasterKeyWrappedUserKey, @@ -85,6 +86,19 @@ export class MasterPasswordService implements InternalMasterPasswordServiceAbstr private accountService: AccountService, ) {} + async userHasMasterPassword(userId: UserId): Promise { + assertNonNullish(userId, "userId"); + // A user has a master-password if they have a master-key encrypted user key *but* are not a key connector user + // Note: We can't use the key connector service as an abstraction here because it causes a run-time dependency injection cycle between KC service and MP service. + const usesKeyConnector = await firstValueFrom( + this.stateProvider.getUser(userId, USES_KEY_CONNECTOR).state$, + ); + const usesMasterKey = await firstValueFrom( + this.stateProvider.getUser(userId, MASTER_KEY_ENCRYPTED_USER_KEY).state$, + ); + return usesMasterKey && !usesKeyConnector; + } + saltForUser$(userId: UserId): Observable { assertNonNullish(userId, "userId"); return this.accountService.accounts$.pipe( @@ -307,6 +321,7 @@ export class MasterPasswordService implements InternalMasterPasswordServiceAbstr masterPasswordUnlockData.kdf.toSdkConfig(), ), ); + return userKey as UserKey; } diff --git a/libs/key-management-ui/src/lock/components/lock.component.spec.ts b/libs/key-management-ui/src/lock/components/lock.component.spec.ts index b51d4c13490..b708d101f82 100644 --- a/libs/key-management-ui/src/lock/components/lock.component.spec.ts +++ b/libs/key-management-ui/src/lock/components/lock.component.spec.ts @@ -22,6 +22,7 @@ import { } from "@bitwarden/common/auth/types/verification"; import { ClientType, DeviceType } from "@bitwarden/common/enums"; import { DeviceTrustServiceAbstraction } from "@bitwarden/common/key-management/device-trust/abstractions/device-trust.service.abstraction"; +import { EncryptedMigrator } from "@bitwarden/common/key-management/encrypted-migrator/encrypted-migrator.abstraction"; import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/key-management/master-password/abstractions/master-password.service.abstraction"; import { PinServiceAbstraction } from "@bitwarden/common/key-management/pin/pin.service.abstraction"; import { BroadcasterService } from "@bitwarden/common/platform/abstractions/broadcaster.service"; @@ -91,6 +92,7 @@ describe("LockComponent", () => { const mockLockComponentService = mock(); const mockAnonLayoutWrapperDataService = mock(); const mockBroadcasterService = mock(); + const mockEncryptedMigrator = mock(); const mockConfigService = mock(); beforeEach(async () => { @@ -149,6 +151,7 @@ describe("LockComponent", () => { { provide: LockComponentService, useValue: mockLockComponentService }, { provide: AnonLayoutWrapperDataService, useValue: mockAnonLayoutWrapperDataService }, { provide: BroadcasterService, useValue: mockBroadcasterService }, + { provide: EncryptedMigrator, useValue: mockEncryptedMigrator }, { provide: ConfigService, useValue: mockConfigService }, ], }) diff --git a/libs/key-management-ui/src/lock/components/lock.component.ts b/libs/key-management-ui/src/lock/components/lock.component.ts index 801a9d191f5..7f715d2215d 100644 --- a/libs/key-management-ui/src/lock/components/lock.component.ts +++ b/libs/key-management-ui/src/lock/components/lock.component.ts @@ -31,6 +31,7 @@ import { import { ClientType, DeviceType } from "@bitwarden/common/enums"; import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; import { DeviceTrustServiceAbstraction } from "@bitwarden/common/key-management/device-trust/abstractions/device-trust.service.abstraction"; +import { EncryptedMigrator } from "@bitwarden/common/key-management/encrypted-migrator/encrypted-migrator.abstraction"; import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/key-management/master-password/abstractions/master-password.service.abstraction"; import { PinServiceAbstraction } from "@bitwarden/common/key-management/pin/pin.service.abstraction"; import { BroadcasterService } from "@bitwarden/common/platform/abstractions/broadcaster.service"; @@ -177,6 +178,8 @@ export class LockComponent implements OnInit, OnDestroy { private logoutService: LogoutService, private lockComponentService: LockComponentService, private anonLayoutWrapperDataService: AnonLayoutWrapperDataService, + private encryptedMigrator: EncryptedMigrator, + private configService: ConfigService, // desktop deps private broadcasterService: BroadcasterService, @@ -639,6 +642,16 @@ export class LockComponent implements OnInit, OnDestroy { } await this.biometricStateService.resetUserPromptCancelled(); + + try { + await this.encryptedMigrator.runMigrations( + this.activeAccount.id, + afterUnlockActions.passwordEvaluation?.masterPassword ?? null, + ); + } catch { + // Don't block login success on migration failure + } + this.messagingService.send("unlocked"); if (afterUnlockActions.passwordEvaluation) { diff --git a/libs/state/src/core/state-definitions.ts b/libs/state/src/core/state-definitions.ts index 9d404f14dd7..156c03620b7 100644 --- a/libs/state/src/core/state-definitions.ts +++ b/libs/state/src/core/state-definitions.ts @@ -54,8 +54,6 @@ export const DEVICE_TRUST_DISK_LOCAL = new StateDefinition("deviceTrust", "disk" web: "disk-local", browser: "disk-backup-local-storage", }); -export const KDF_CONFIG_DISK = new StateDefinition("kdfConfig", "disk"); -export const KEY_CONNECTOR_DISK = new StateDefinition("keyConnector", "disk"); export const LOGIN_EMAIL_DISK = new StateDefinition("loginEmail", "disk", { web: "disk-local", }); @@ -64,8 +62,6 @@ export const LOGIN_STRATEGY_MEMORY = new StateDefinition("loginStrategy", "memor export const MASTER_PASSWORD_DISK = new StateDefinition("masterPassword", "disk"); export const MASTER_PASSWORD_MEMORY = new StateDefinition("masterPassword", "memory"); export const MASTER_PASSWORD_UNLOCK_DISK = new StateDefinition("masterPasswordUnlock", "disk"); -export const PIN_DISK = new StateDefinition("pinUnlock", "disk"); -export const PIN_MEMORY = new StateDefinition("pinUnlock", "memory"); export const ROUTER_DISK = new StateDefinition("router", "disk"); export const SSO_DISK = new StateDefinition("ssoLogin", "disk"); export const SSO_DISK_LOCAL = new StateDefinition("ssoLoginLocal", "disk", { web: "disk-local" }); @@ -117,13 +113,10 @@ export const PHISHING_DETECTION_DISK = new StateDefinition("phishingDetection", export const APPLICATION_ID_DISK = new StateDefinition("applicationId", "disk", { web: "disk-local", }); -export const BIOMETRIC_SETTINGS_DISK = new StateDefinition("biometricSettings", "disk"); export const CLEAR_EVENT_DISK = new StateDefinition("clearEvent", "disk"); export const CONFIG_DISK = new StateDefinition("config", "disk", { web: "disk-local", }); -export const CRYPTO_DISK = new StateDefinition("crypto", "disk"); -export const CRYPTO_MEMORY = new StateDefinition("crypto", "memory"); export const DESKTOP_SETTINGS_DISK = new StateDefinition("desktopSettings", "disk"); export const ENVIRONMENT_DISK = new StateDefinition("environment", "disk"); export const ENVIRONMENT_MEMORY = new StateDefinition("environment", "memory"); @@ -225,3 +218,14 @@ export const VAULT_BROWSER_INTRO_CAROUSEL = new StateDefinition( "disk", ); export const VAULT_AT_RISK_PASSWORDS_MEMORY = new StateDefinition("vaultAtRiskPasswords", "memory"); + +// KM + +export const BIOMETRIC_SETTINGS_DISK = new StateDefinition("biometricSettings", "disk"); +export const ENCRYPTED_MIGRATION_DISK = new StateDefinition("encryptedMigration", "disk"); +export const PIN_DISK = new StateDefinition("pinUnlock", "disk"); +export const PIN_MEMORY = new StateDefinition("pinUnlock", "memory"); +export const CRYPTO_DISK = new StateDefinition("crypto", "disk"); +export const CRYPTO_MEMORY = new StateDefinition("crypto", "memory"); +export const KDF_CONFIG_DISK = new StateDefinition("kdfConfig", "disk"); +export const KEY_CONNECTOR_DISK = new StateDefinition("keyConnector", "disk"); From 1bfff49ef5d6fc9489d0d6422fd4887388f8bd30 Mon Sep 17 00:00:00 2001 From: Bernd Schoolmann Date: Wed, 3 Dec 2025 20:10:10 +0100 Subject: [PATCH 029/131] [PM-29122] Fix debug build causing slow unlock (#17798) * Fix debug build causing slow unlock * Cleanup * Fix release mode build actually building debug --- .github/workflows/build-desktop.yml | 8 ++++++-- apps/desktop/desktop_native/napi/scripts/build.js | 2 +- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/.github/workflows/build-desktop.yml b/.github/workflows/build-desktop.yml index c973796207c..ab5a1a50c17 100644 --- a/.github/workflows/build-desktop.yml +++ b/.github/workflows/build-desktop.yml @@ -263,9 +263,11 @@ jobs: PKG_CONFIG_ALLOW_CROSS: true PKG_CONFIG_ALL_STATIC: true TARGET: musl + # Note: It is important that we use the release build because some compute heavy + # operations such as key derivation for oo7 on linux are too slow in debug mode run: | rustup target add x86_64-unknown-linux-musl - node build.js --target=x86_64-unknown-linux-musl + node build.js --target=x86_64-unknown-linux-musl --release - name: Build application run: npm run dist:lin @@ -426,9 +428,11 @@ jobs: PKG_CONFIG_ALLOW_CROSS: true PKG_CONFIG_ALL_STATIC: true TARGET: musl + # Note: It is important that we use the release build because some compute heavy + # operations such as key derivation for oo7 on linux are too slow in debug mode run: | rustup target add aarch64-unknown-linux-musl - node build.js --target=aarch64-unknown-linux-musl + node build.js --target=aarch64-unknown-linux-musl --release - name: Check index.d.ts generated if: github.event_name == 'pull_request' && steps.cache.outputs.cache-hit != 'true' diff --git a/apps/desktop/desktop_native/napi/scripts/build.js b/apps/desktop/desktop_native/napi/scripts/build.js index a6680f5d311..7b3dccf81e4 100644 --- a/apps/desktop/desktop_native/napi/scripts/build.js +++ b/apps/desktop/desktop_native/napi/scripts/build.js @@ -11,4 +11,4 @@ if (isRelease) { process.env.RUST_LOG = 'debug'; } -execSync(`napi build --platform --js false`, { stdio: 'inherit', env: process.env }); +execSync(`napi build --platform --js false ${isRelease ? '--release' : ''}`, { stdio: 'inherit', env: process.env }); From d64da69fa7488421c3063e3784a5ec5fd35768bf Mon Sep 17 00:00:00 2001 From: Alex <55413326+AlexRubik@users.noreply.github.com> Date: Wed, 3 Dec 2025 14:21:58 -0500 Subject: [PATCH 030/131] [PM-6979] Remove HIBP 404 handling (#17769) --- .../dirt/services/hibp-api.service.spec.ts | 21 +++++++++++++++++++ .../common/src/services/audit.service.spec.ts | 13 ++++++------ libs/common/src/services/audit.service.ts | 11 +--------- 3 files changed, 29 insertions(+), 16 deletions(-) diff --git a/libs/common/src/dirt/services/hibp-api.service.spec.ts b/libs/common/src/dirt/services/hibp-api.service.spec.ts index fd2a54bdd10..9e08b4d0623 100644 --- a/libs/common/src/dirt/services/hibp-api.service.spec.ts +++ b/libs/common/src/dirt/services/hibp-api.service.spec.ts @@ -35,5 +35,26 @@ describe("HibpApiService", () => { expect(result).toHaveLength(1); expect(result[0]).toBeInstanceOf(BreachAccountResponse); }); + + it("should return empty array when no breaches found (REST semantics)", async () => { + // Server now returns 200 OK with empty array [] instead of 404 + const mockResponse: any[] = []; + const username = "safe@example.com"; + + apiService.send.mockResolvedValue(mockResponse); + + const result = await sut.getHibpBreach(username); + + expect(apiService.send).toHaveBeenCalledWith( + "GET", + "/hibp/breach?username=" + encodeURIComponent(username), + null, + true, + true, + ); + expect(result).toEqual([]); + expect(result).toBeInstanceOf(Array); + expect(result).toHaveLength(0); + }); }); }); diff --git a/libs/common/src/services/audit.service.spec.ts b/libs/common/src/services/audit.service.spec.ts index b0e96eb5c5c..e653b026735 100644 --- a/libs/common/src/services/audit.service.spec.ts +++ b/libs/common/src/services/audit.service.spec.ts @@ -1,7 +1,6 @@ import { ApiService } from "../abstractions/api.service"; import { HibpApiService } from "../dirt/services/hibp-api.service"; import { CryptoFunctionService } from "../key-management/crypto/abstractions/crypto-function.service"; -import { ErrorResponse } from "../models/response/error.response"; import { AuditService } from "./audit.service"; @@ -73,14 +72,16 @@ describe("AuditService", () => { expect(mockApi.nativeFetch).toHaveBeenCalledTimes(4); }); - it("should return empty array for breachedAccounts on 404", async () => { - mockHibpApi.getHibpBreach.mockRejectedValueOnce({ statusCode: 404 } as ErrorResponse); + it("should return empty array for breachedAccounts when no breaches found", async () => { + // Server returns 200 with empty array (correct REST semantics) + mockHibpApi.getHibpBreach.mockResolvedValueOnce([]); const result = await auditService.breachedAccounts("user@example.com"); expect(result).toEqual([]); }); - it("should throw error for breachedAccounts on non-404 error", async () => { - mockHibpApi.getHibpBreach.mockRejectedValueOnce({ statusCode: 500 } as ErrorResponse); - await expect(auditService.breachedAccounts("user@example.com")).rejects.toThrow(); + it("should propagate errors from breachedAccounts", async () => { + const error = new Error("API error"); + mockHibpApi.getHibpBreach.mockRejectedValueOnce(error); + await expect(auditService.breachedAccounts("user@example.com")).rejects.toBe(error); }); }); diff --git a/libs/common/src/services/audit.service.ts b/libs/common/src/services/audit.service.ts index 0bdf45917de..7762c2cbd93 100644 --- a/libs/common/src/services/audit.service.ts +++ b/libs/common/src/services/audit.service.ts @@ -6,7 +6,6 @@ import { AuditService as AuditServiceAbstraction } from "../abstractions/audit.s import { BreachAccountResponse } from "../dirt/models/response/breach-account.response"; import { HibpApiService } from "../dirt/services/hibp-api.service"; import { CryptoFunctionService } from "../key-management/crypto/abstractions/crypto-function.service"; -import { ErrorResponse } from "../models/response/error.response"; import { Utils } from "../platform/misc/utils"; const PwnedPasswordsApi = "https://api.pwnedpasswords.com/range/"; @@ -70,14 +69,6 @@ export class AuditService implements AuditServiceAbstraction { } async breachedAccounts(username: string): Promise { - try { - return await this.hibpApiService.getHibpBreach(username); - } catch (e) { - const error = e as ErrorResponse; - if (error.statusCode === 404) { - return []; - } - throw new Error(); - } + return this.hibpApiService.getHibpBreach(username); } } From 28fbddb63f80653ea7f6bc3efed8545b3d2ae5ee Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Anders=20=C3=85berg?= Date: Wed, 3 Dec 2025 20:40:55 +0100 Subject: [PATCH 031/131] fix(passkeys): [PM-28324] Add a guard that conditionally forces a popout depending on platform * Add a guard that conditionally forces a popout depending on platform * Test the routeguard * Use mockImplementation instead. * autoclose popout --- .../guards/platform-popout.guard.spec.ts | 193 ++++++++++++++++++ .../popup/guards/platform-popout.guard.ts | 46 +++++ apps/browser/src/popup/app-routing.module.ts | 3 +- .../login-via-webauthn.component.ts | 21 +- 4 files changed, 261 insertions(+), 2 deletions(-) create mode 100644 apps/browser/src/auth/popup/guards/platform-popout.guard.spec.ts create mode 100644 apps/browser/src/auth/popup/guards/platform-popout.guard.ts diff --git a/apps/browser/src/auth/popup/guards/platform-popout.guard.spec.ts b/apps/browser/src/auth/popup/guards/platform-popout.guard.spec.ts new file mode 100644 index 00000000000..d39012fd88a --- /dev/null +++ b/apps/browser/src/auth/popup/guards/platform-popout.guard.spec.ts @@ -0,0 +1,193 @@ +import { TestBed } from "@angular/core/testing"; +import { ActivatedRouteSnapshot, RouterStateSnapshot } from "@angular/router"; + +import { BrowserApi } from "../../../platform/browser/browser-api"; +import BrowserPopupUtils from "../../../platform/browser/browser-popup-utils"; + +import { platformPopoutGuard } from "./platform-popout.guard"; + +describe("platformPopoutGuard", () => { + let getPlatformInfoSpy: jest.SpyInstance; + let inPopoutSpy: jest.SpyInstance; + let inSidebarSpy: jest.SpyInstance; + let openPopoutSpy: jest.SpyInstance; + let closePopupSpy: jest.SpyInstance; + + const mockRoute = {} as ActivatedRouteSnapshot; + const mockState: RouterStateSnapshot = { + url: "/login-with-passkey?param=value", + } as RouterStateSnapshot; + + beforeEach(() => { + getPlatformInfoSpy = jest.spyOn(BrowserApi, "getPlatformInfo"); + inPopoutSpy = jest.spyOn(BrowserPopupUtils, "inPopout"); + inSidebarSpy = jest.spyOn(BrowserPopupUtils, "inSidebar"); + openPopoutSpy = jest.spyOn(BrowserPopupUtils, "openPopout").mockImplementation(); + closePopupSpy = jest.spyOn(BrowserApi, "closePopup").mockImplementation(); + + TestBed.configureTestingModule({}); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe("when platform matches", () => { + beforeEach(() => { + getPlatformInfoSpy.mockResolvedValue({ os: "linux" }); + inPopoutSpy.mockReturnValue(false); + inSidebarSpy.mockReturnValue(false); + }); + + it("should open popout and block navigation when not already in popout or sidebar", async () => { + const guard = platformPopoutGuard(["linux"]); + const result = await TestBed.runInInjectionContext(() => guard(mockRoute, mockState)); + + expect(getPlatformInfoSpy).toHaveBeenCalled(); + expect(inPopoutSpy).toHaveBeenCalledWith(window); + expect(inSidebarSpy).toHaveBeenCalledWith(window); + expect(openPopoutSpy).toHaveBeenCalledWith( + "popup/index.html#/login-with-passkey?param=value&autoClosePopout=true", + ); + expect(closePopupSpy).toHaveBeenCalledWith(window); + expect(result).toBe(false); + }); + + it("should allow navigation when already in popout", async () => { + inPopoutSpy.mockReturnValue(true); + + const guard = platformPopoutGuard(["linux"]); + const result = await TestBed.runInInjectionContext(() => guard(mockRoute, mockState)); + + expect(openPopoutSpy).not.toHaveBeenCalled(); + expect(closePopupSpy).not.toHaveBeenCalled(); + expect(result).toBe(true); + }); + + it("should allow navigation when already in sidebar", async () => { + inSidebarSpy.mockReturnValue(true); + + const guard = platformPopoutGuard(["linux"]); + const result = await TestBed.runInInjectionContext(() => guard(mockRoute, mockState)); + + expect(openPopoutSpy).not.toHaveBeenCalled(); + expect(closePopupSpy).not.toHaveBeenCalled(); + expect(result).toBe(true); + }); + }); + + describe("when platform does not match", () => { + beforeEach(() => { + getPlatformInfoSpy.mockResolvedValue({ os: "win" }); + inPopoutSpy.mockReturnValue(false); + inSidebarSpy.mockReturnValue(false); + }); + + it("should allow navigation without opening popout", async () => { + const guard = platformPopoutGuard(["linux"]); + const result = await TestBed.runInInjectionContext(() => guard(mockRoute, mockState)); + + expect(getPlatformInfoSpy).toHaveBeenCalled(); + expect(openPopoutSpy).not.toHaveBeenCalled(); + expect(result).toBe(true); + }); + }); + + describe("when forcePopout is true", () => { + beforeEach(() => { + getPlatformInfoSpy.mockResolvedValue({ os: "win" }); + inPopoutSpy.mockReturnValue(false); + inSidebarSpy.mockReturnValue(false); + }); + + it("should open popout regardless of platform", async () => { + const guard = platformPopoutGuard(["linux"], true); + const result = await TestBed.runInInjectionContext(() => guard(mockRoute, mockState)); + + expect(openPopoutSpy).toHaveBeenCalledWith( + "popup/index.html#/login-with-passkey?param=value&autoClosePopout=true", + ); + expect(closePopupSpy).toHaveBeenCalledWith(window); + expect(result).toBe(false); + }); + + it("should not open popout when already in popout", async () => { + inPopoutSpy.mockReturnValue(true); + + const guard = platformPopoutGuard(["linux"], true); + const result = await TestBed.runInInjectionContext(() => guard(mockRoute, mockState)); + + expect(openPopoutSpy).not.toHaveBeenCalled(); + expect(result).toBe(true); + }); + }); + + describe("with multiple platforms", () => { + beforeEach(() => { + inPopoutSpy.mockReturnValue(false); + inSidebarSpy.mockReturnValue(false); + }); + + it.each(["linux", "mac", "win"])( + "should open popout when platform is %s and included in platforms array", + async (platform) => { + getPlatformInfoSpy.mockResolvedValue({ os: platform }); + + const guard = platformPopoutGuard(["linux", "mac", "win"]); + const result = await TestBed.runInInjectionContext(() => guard(mockRoute, mockState)); + + expect(openPopoutSpy).toHaveBeenCalledWith( + "popup/index.html#/login-with-passkey?param=value&autoClosePopout=true", + ); + expect(closePopupSpy).toHaveBeenCalledWith(window); + expect(result).toBe(false); + }, + ); + + it("should not open popout when platform is not in the array", async () => { + getPlatformInfoSpy.mockResolvedValue({ os: "android" }); + + const guard = platformPopoutGuard(["linux", "mac"]); + const result = await TestBed.runInInjectionContext(() => guard(mockRoute, mockState)); + + expect(openPopoutSpy).not.toHaveBeenCalled(); + expect(result).toBe(true); + }); + }); + + describe("url handling", () => { + beforeEach(() => { + getPlatformInfoSpy.mockResolvedValue({ os: "linux" }); + inPopoutSpy.mockReturnValue(false); + inSidebarSpy.mockReturnValue(false); + }); + + it("should preserve query parameters in the popout url", async () => { + const stateWithQuery: RouterStateSnapshot = { + url: "/path?foo=bar&baz=qux", + } as RouterStateSnapshot; + + const guard = platformPopoutGuard(["linux"]); + await TestBed.runInInjectionContext(() => guard(mockRoute, stateWithQuery)); + + expect(openPopoutSpy).toHaveBeenCalledWith( + "popup/index.html#/path?foo=bar&baz=qux&autoClosePopout=true", + ); + expect(closePopupSpy).toHaveBeenCalledWith(window); + }); + + it("should handle urls without query parameters", async () => { + const stateWithoutQuery: RouterStateSnapshot = { + url: "/simple-path", + } as RouterStateSnapshot; + + const guard = platformPopoutGuard(["linux"]); + await TestBed.runInInjectionContext(() => guard(mockRoute, stateWithoutQuery)); + + expect(openPopoutSpy).toHaveBeenCalledWith( + "popup/index.html#/simple-path?autoClosePopout=true", + ); + expect(closePopupSpy).toHaveBeenCalledWith(window); + }); + }); +}); diff --git a/apps/browser/src/auth/popup/guards/platform-popout.guard.ts b/apps/browser/src/auth/popup/guards/platform-popout.guard.ts new file mode 100644 index 00000000000..aad005e141b --- /dev/null +++ b/apps/browser/src/auth/popup/guards/platform-popout.guard.ts @@ -0,0 +1,46 @@ +import { ActivatedRouteSnapshot, CanActivateFn, RouterStateSnapshot } from "@angular/router"; + +import { BrowserApi } from "../../../platform/browser/browser-api"; +import BrowserPopupUtils from "../../../platform/browser/browser-popup-utils"; + +/** + * Guard that forces a popout window for specific platforms. + * Useful when popup context would close during operations (e.g., WebAuthn on Linux). + * + * @param platforms - Array of platform OS strings (e.g., ["linux", "mac", "win"]) + * @param forcePopout - If true, always force popout regardless of platform (useful for testing) + * @returns CanActivateFn that opens popout and blocks navigation if conditions met + */ +export function platformPopoutGuard( + platforms: string[], + forcePopout: boolean = false, +): CanActivateFn { + return async (route: ActivatedRouteSnapshot, state: RouterStateSnapshot) => { + // Check if current platform matches + const platformInfo = await BrowserApi.getPlatformInfo(); + const isPlatformMatch = platforms.includes(platformInfo.os); + + // Check if already in popout/sidebar + const inPopout = BrowserPopupUtils.inPopout(window); + const inSidebar = BrowserPopupUtils.inSidebar(window); + + // Open popout if conditions met + if ((isPlatformMatch || forcePopout) && !inPopout && !inSidebar) { + // Add autoClosePopout query param to signal the popout should close after completion + const [path, existingQuery] = state.url.split("?"); + const params = new URLSearchParams(existingQuery || ""); + params.set("autoClosePopout", "true"); + const urlWithAutoClose = `${path}?${params.toString()}`; + + // Open the popout window + await BrowserPopupUtils.openPopout(`popup/index.html#${urlWithAutoClose}`); + + // Close the original popup window + BrowserApi.closePopup(window); + + return false; // Block navigation - popout will reload + } + + return true; // Allow navigation + }; +} diff --git a/apps/browser/src/popup/app-routing.module.ts b/apps/browser/src/popup/app-routing.module.ts index a36396afa1a..48f06147cdf 100644 --- a/apps/browser/src/popup/app-routing.module.ts +++ b/apps/browser/src/popup/app-routing.module.ts @@ -48,6 +48,7 @@ import { LockComponent, ConfirmKeyConnectorDomainComponent } from "@bitwarden/ke import { AccountSwitcherComponent } from "../auth/popup/account-switching/account-switcher.component"; import { AuthExtensionRoute } from "../auth/popup/constants/auth-extension-route.constant"; import { fido2AuthGuard } from "../auth/popup/guards/fido2-auth.guard"; +import { platformPopoutGuard } from "../auth/popup/guards/platform-popout.guard"; import { AccountSecurityComponent } from "../auth/popup/settings/account-security.component"; import { ExtensionDeviceManagementComponent } from "../auth/popup/settings/extension-device-management.component"; import { Fido2Component } from "../autofill/popup/fido2/fido2.component"; @@ -414,7 +415,7 @@ const routes: Routes = [ }, { path: AuthRoute.LoginWithPasskey, - canActivate: [unauthGuardFn(unauthRouteOverrides)], + canActivate: [unauthGuardFn(unauthRouteOverrides), platformPopoutGuard(["linux"])], data: { pageIcon: TwoFactorAuthSecurityKeyIcon, pageTitle: { diff --git a/libs/angular/src/auth/login-via-webauthn/login-via-webauthn.component.ts b/libs/angular/src/auth/login-via-webauthn/login-via-webauthn.component.ts index 764d9fe7733..b4d856309ed 100644 --- a/libs/angular/src/auth/login-via-webauthn/login-via-webauthn.component.ts +++ b/libs/angular/src/auth/login-via-webauthn/login-via-webauthn.component.ts @@ -2,7 +2,7 @@ // @ts-strict-ignore import { CommonModule } from "@angular/common"; import { Component, OnInit } from "@angular/core"; -import { Router, RouterModule } from "@angular/router"; +import { ActivatedRoute, Router, RouterModule } from "@angular/router"; import { firstValueFrom } from "rxjs"; import { JslibModule } from "@bitwarden/angular/jslib.module"; @@ -19,6 +19,7 @@ import { ClientType } from "@bitwarden/common/enums"; import { ErrorResponse } from "@bitwarden/common/models/response/error.response"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; +import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { ValidationService } from "@bitwarden/common/platform/abstractions/validation.service"; import { @@ -49,6 +50,7 @@ export type State = "assert" | "assertFailed"; }) export class LoginViaWebAuthnComponent implements OnInit { protected currentState: State = "assert"; + private shouldAutoClosePopout = false; protected readonly Icons = { TwoFactorAuthSecurityKeyIcon, @@ -70,6 +72,7 @@ export class LoginViaWebAuthnComponent implements OnInit { constructor( private webAuthnLoginService: WebAuthnLoginServiceAbstraction, private router: Router, + private route: ActivatedRoute, private logService: LogService, private validationService: ValidationService, private i18nService: I18nService, @@ -77,9 +80,14 @@ export class LoginViaWebAuthnComponent implements OnInit { private keyService: KeyService, private platformUtilsService: PlatformUtilsService, private anonLayoutWrapperDataService: AnonLayoutWrapperDataService, + private messagingService: MessagingService, ) {} ngOnInit(): void { + // Check if we should auto-close the popout after successful authentication + this.shouldAutoClosePopout = + this.route.snapshot.queryParamMap.get("autoClosePopout") === "true"; + // FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling. // eslint-disable-next-line @typescript-eslint/no-floating-promises this.authenticate(); @@ -123,6 +131,17 @@ export class LoginViaWebAuthnComponent implements OnInit { await this.loginSuccessHandlerService.run(authResult.userId, null); } + // If autoClosePopout is enabled and we're in a browser extension, + // re-open the regular popup and close this popout window + if ( + this.shouldAutoClosePopout && + this.platformUtilsService.getClientType() === ClientType.Browser + ) { + this.messagingService.send("openPopup"); + window.close(); + return; + } + await this.router.navigate([this.successRoute]); } catch (error) { if (error instanceof ErrorResponse) { From 04d7744747e64e06feff308c2483152abe709238 Mon Sep 17 00:00:00 2001 From: Jonathan Prusik Date: Wed, 3 Dec 2025 15:12:12 -0500 Subject: [PATCH 032/131] normalize lowercasing for cipher compared against lowercased input value (#17803) --- apps/browser/src/autofill/background/notification.background.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/browser/src/autofill/background/notification.background.ts b/apps/browser/src/autofill/background/notification.background.ts index 547c5ba1575..17e3ec159c3 100644 --- a/apps/browser/src/autofill/background/notification.background.ts +++ b/apps/browser/src/autofill/background/notification.background.ts @@ -658,7 +658,7 @@ export default class NotificationBackground { if ( username !== null && newPassword === null && - cipher.login.username === normalizedUsername && + cipher.login.username.toLowerCase() === normalizedUsername && cipher.login.password === currentPassword ) { // Assumed to be a login From dab1a37bfe4b3a51aa7426533fa52795b2371474 Mon Sep 17 00:00:00 2001 From: Nick Krantz <125900171+nick-livefront@users.noreply.github.com> Date: Wed, 3 Dec 2025 14:19:26 -0600 Subject: [PATCH 033/131] PM-24535 Web premium upgrade path for archive (#16854) * add premium badge to web filter when the user does not have access to premium * remove feature flag pass through in favor of showing/hiding archive vault observable * refactor archive observable to be more generic * add archive premium badge for the web * show premium badge inline for archive filter * show premium subscription ended message when user has archived ciphers * fix missing refactor * remove unneeded can archive check * reference observable directly * reduce the number of firstValueFroms by combining observables into a single stream * fix failing tests * add import to storybook * update variable naming for premium filters * pass event to `promptForPremium` * remove check for organization * fix footer variable reference * refactor back to `hasArchiveFlagEnabled$` - more straight forward to the underlying logic * update archive service test with new feature flag format --- .../item-more-options.component.spec.ts | 2 +- .../item-more-options.component.ts | 2 +- .../settings/vault-settings-v2.component.ts | 2 +- .../vault/app/vault/item-footer.component.ts | 2 +- .../vault-filter/vault-filter.component.ts | 3 + .../vault-cipher-row.component.html | 20 +++- .../vault-cipher-row.component.spec.ts | 1 + .../vault-items/vault-cipher-row.component.ts | 18 +++- .../vault-items/vault-items.component.html | 1 + .../vault-items/vault-items.component.spec.ts | 7 ++ .../vault-items/vault-items.component.ts | 4 + .../vault-items/vault-items.module.ts | 2 + .../vault-items/vault-items.stories.ts | 7 ++ .../components/vault-filter.component.ts | 34 ++++++- .../vault-filter-section.component.html | 3 + .../vault-filter-section.component.ts | 9 ++ .../models/vault-filter-section.type.ts | 10 ++ .../shared/vault-filter-shared.module.ts | 3 +- .../individual-vault/vault.component.html | 10 ++ .../vault/individual-vault/vault.component.ts | 36 ++++--- apps/web/src/locales/en/messages.json | 9 ++ .../components/vault-filter.component.ts | 2 +- .../abstractions/cipher-archive.service.ts | 5 +- .../default-cipher-archive.service.spec.ts | 95 ++++++++++++++++++- .../default-cipher-archive.service.ts | 37 ++++---- 25 files changed, 265 insertions(+), 59 deletions(-) diff --git a/apps/browser/src/vault/popup/components/vault-v2/item-more-options/item-more-options.component.spec.ts b/apps/browser/src/vault/popup/components/vault-v2/item-more-options/item-more-options.component.spec.ts index 577b7d96771..b9f48b7407b 100644 --- a/apps/browser/src/vault/popup/components/vault-v2/item-more-options/item-more-options.component.spec.ts +++ b/apps/browser/src/vault/popup/components/vault-v2/item-more-options/item-more-options.component.spec.ts @@ -108,7 +108,7 @@ describe("ItemMoreOptionsComponent", () => { { provide: RestrictedItemTypesService, useValue: { restricted$: of([]) } }, { provide: CipherArchiveService, - useValue: { userCanArchive$: () => of(true), hasArchiveFlagEnabled$: () => of(true) }, + useValue: { userCanArchive$: () => of(true), hasArchiveFlagEnabled$: of(true) }, }, { provide: ToastService, useValue: { showToast: () => {} } }, { provide: Router, useValue: { navigate: () => Promise.resolve(true) } }, 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 4dfaf7bc66f..b65acc6ca8e 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 @@ -141,7 +141,7 @@ export class ItemMoreOptionsComponent { }), ); - protected showArchive$: Observable = this.cipherArchiveService.hasArchiveFlagEnabled$(); + protected showArchive$: Observable = this.cipherArchiveService.hasArchiveFlagEnabled$; protected canArchive$: Observable = this.accountService.activeAccount$.pipe( getUserId, diff --git a/apps/browser/src/vault/popup/settings/vault-settings-v2.component.ts b/apps/browser/src/vault/popup/settings/vault-settings-v2.component.ts index c6db820c232..e085cb21c2d 100644 --- a/apps/browser/src/vault/popup/settings/vault-settings-v2.component.ts +++ b/apps/browser/src/vault/popup/settings/vault-settings-v2.component.ts @@ -49,7 +49,7 @@ export class VaultSettingsV2Component implements OnInit, OnDestroy { this.userId$.pipe(switchMap((userId) => this.cipherArchiveService.userCanArchive$(userId))), ); - protected readonly showArchiveItem = toSignal(this.cipherArchiveService.hasArchiveFlagEnabled$()); + protected readonly showArchiveItem = toSignal(this.cipherArchiveService.hasArchiveFlagEnabled$); protected readonly userHasArchivedItems = toSignal( this.userId$.pipe( diff --git a/apps/desktop/src/vault/app/vault/item-footer.component.ts b/apps/desktop/src/vault/app/vault/item-footer.component.ts index 0034bd9a43c..0ac12c928f2 100644 --- a/apps/desktop/src/vault/app/vault/item-footer.component.ts +++ b/apps/desktop/src/vault/app/vault/item-footer.component.ts @@ -225,7 +225,7 @@ export class ItemFooterComponent implements OnInit, OnChanges { switchMap((id) => combineLatest([ this.cipherArchiveService.userCanArchive$(id), - this.cipherArchiveService.hasArchiveFlagEnabled$(), + this.cipherArchiveService.hasArchiveFlagEnabled$, ]), ), ), diff --git a/apps/web/src/app/admin-console/organizations/collections/vault-filter/vault-filter.component.ts b/apps/web/src/app/admin-console/organizations/collections/vault-filter/vault-filter.component.ts index 01e61f0ab28..a253bb87c50 100644 --- a/apps/web/src/app/admin-console/organizations/collections/vault-filter/vault-filter.component.ts +++ b/apps/web/src/app/admin-console/organizations/collections/vault-filter/vault-filter.component.ts @@ -11,6 +11,7 @@ import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.servic import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { CipherArchiveService } from "@bitwarden/common/vault/abstractions/cipher-archive.service"; import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; +import { PremiumUpgradePromptService } from "@bitwarden/common/vault/abstractions/premium-upgrade-prompt.service"; import { TreeNode } from "@bitwarden/common/vault/models/domain/tree-node"; import { RestrictedItemTypesService } from "@bitwarden/common/vault/services/restricted-item-types.service"; import { DialogService, ToastService } from "@bitwarden/components"; @@ -59,6 +60,7 @@ export class VaultFilterComponent protected restrictedItemTypesService: RestrictedItemTypesService, protected cipherService: CipherService, protected cipherArchiveService: CipherArchiveService, + premiumUpgradePromptService: PremiumUpgradePromptService, ) { super( vaultFilterService, @@ -72,6 +74,7 @@ export class VaultFilterComponent restrictedItemTypesService, cipherService, cipherArchiveService, + premiumUpgradePromptService, ); } diff --git a/apps/web/src/app/vault/components/vault-items/vault-cipher-row.component.html b/apps/web/src/app/vault/components/vault-items/vault-cipher-row.component.html index c8732154ef4..d56c9d15cff 100644 --- a/apps/web/src/app/vault/components/vault-items/vault-cipher-row.component.html +++ b/apps/web/src/app/vault/components/vault-items/vault-cipher-row.component.html @@ -203,10 +203,22 @@ {{ "eventLogs" | i18n }} @if (showArchiveButton) { - + @if (userCanArchive) { + + } + @if (!userCanArchive) { + + } } @if (showUnArchiveButton) { diff --git a/apps/web/src/app/vault/components/vault-items/vault-cipher-row.component.spec.ts b/apps/web/src/app/vault/components/vault-items/vault-cipher-row.component.spec.ts index d5f7b54f37a..9378ee54e51 100644 --- a/apps/web/src/app/vault/components/vault-items/vault-cipher-row.component.spec.ts +++ b/apps/web/src/app/vault/components/vault-items/vault-cipher-row.component.spec.ts @@ -72,6 +72,7 @@ describe("VaultCipherRowComponent", () => { fixture = TestBed.createComponent(VaultCipherRowComponent); component = fixture.componentInstance; + fixture.componentRef.setInput("archiveEnabled", false); overlayContainer = TestBed.inject(OverlayContainer); }); 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 4ea062db8d1..92c49ac218a 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 @@ -8,6 +8,7 @@ import { OnInit, Output, ViewChild, + input, } from "@angular/core"; import { CollectionView } from "@bitwarden/admin-console/common"; @@ -101,8 +102,10 @@ export class VaultCipherRowComponent implements OnInit // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals // eslint-disable-next-line @angular-eslint/prefer-signals @Input() userCanArchive: boolean; + /** Archive feature is enabled */ + readonly archiveEnabled = input.required(); /** - * Enforge Org Data Ownership Policy Status + * Enforce Org Data Ownership Policy Status */ // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals // eslint-disable-next-line @angular-eslint/prefer-signals @@ -142,16 +145,21 @@ export class VaultCipherRowComponent implements OnInit } protected get showArchiveButton() { + if (!this.archiveEnabled()) { + return false; + } + return ( - this.userCanArchive && - !CipherViewLikeUtils.isArchived(this.cipher) && - !CipherViewLikeUtils.isDeleted(this.cipher) && - !this.cipher.organizationId + !CipherViewLikeUtils.isArchived(this.cipher) && !CipherViewLikeUtils.isDeleted(this.cipher) ); } // If item is archived always show unarchive button, even if user is not premium protected get showUnArchiveButton() { + if (!this.archiveEnabled()) { + return false; + } + return CipherViewLikeUtils.isArchived(this.cipher); } diff --git a/apps/web/src/app/vault/components/vault-items/vault-items.component.html b/apps/web/src/app/vault/components/vault-items/vault-items.component.html index cb2af9a64e5..70c44e80a39 100644 --- a/apps/web/src/app/vault/components/vault-items/vault-items.component.html +++ b/apps/web/src/app/vault/components/vault-items/vault-items.component.html @@ -179,6 +179,7 @@ (onEvent)="event($event)" [userCanArchive]="userCanArchive" [enforceOrgDataOwnershipPolicy]="enforceOrgDataOwnershipPolicy" + [archiveEnabled]="archiveFeatureEnabled$ | async" > diff --git a/apps/web/src/app/vault/components/vault-items/vault-items.component.spec.ts b/apps/web/src/app/vault/components/vault-items/vault-items.component.spec.ts index 902fc2eb5a2..1eccb4c49ce 100644 --- a/apps/web/src/app/vault/components/vault-items/vault-items.component.spec.ts +++ b/apps/web/src/app/vault/components/vault-items/vault-items.component.spec.ts @@ -4,6 +4,7 @@ import { of } from "rxjs"; import { CollectionView } from "@bitwarden/admin-console/common"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { CipherArchiveService } from "@bitwarden/common/vault/abstractions/cipher-archive.service"; import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; import { CipherAuthorizationService } from "@bitwarden/common/vault/services/cipher-authorization.service"; import { RestrictedItemTypesService } from "@bitwarden/common/vault/services/restricted-item-types.service"; @@ -54,6 +55,12 @@ describe("VaultItemsComponent", () => { t: (key: string) => key, }, }, + { + provide: CipherArchiveService, + useValue: { + hasArchiveFlagEnabled$: of(true), + }, + }, ], }); diff --git a/apps/web/src/app/vault/components/vault-items/vault-items.component.ts b/apps/web/src/app/vault/components/vault-items/vault-items.component.ts index 3ab643927f1..a935314eb3a 100644 --- a/apps/web/src/app/vault/components/vault-items/vault-items.component.ts +++ b/apps/web/src/app/vault/components/vault-items/vault-items.component.ts @@ -7,6 +7,7 @@ import { Observable, combineLatest, map, of, startWith, switchMap } from "rxjs"; import { CollectionView, Unassigned, CollectionAdminView } from "@bitwarden/admin-console/common"; import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; +import { CipherArchiveService } from "@bitwarden/common/vault/abstractions/cipher-archive.service"; import { CipherAuthorizationService } from "@bitwarden/common/vault/services/cipher-authorization.service"; import { RestrictedCipherType, @@ -145,9 +146,12 @@ export class VaultItemsComponent { protected disableMenu$: Observable; private restrictedTypes: RestrictedCipherType[] = []; + protected archiveFeatureEnabled$ = this.cipherArchiveService.hasArchiveFlagEnabled$; + constructor( protected cipherAuthorizationService: CipherAuthorizationService, protected restrictedItemTypesService: RestrictedItemTypesService, + protected cipherArchiveService: CipherArchiveService, ) { this.canDeleteSelected$ = this.selection.changed.pipe( startWith(null), diff --git a/apps/web/src/app/vault/components/vault-items/vault-items.module.ts b/apps/web/src/app/vault/components/vault-items/vault-items.module.ts index a3a92559878..a7c264114b9 100644 --- a/apps/web/src/app/vault/components/vault-items/vault-items.module.ts +++ b/apps/web/src/app/vault/components/vault-items/vault-items.module.ts @@ -3,6 +3,7 @@ import { CommonModule } from "@angular/common"; import { NgModule } from "@angular/core"; import { RouterModule } from "@angular/router"; +import { PremiumBadgeComponent } from "@bitwarden/angular/billing/components/premium-badge"; import { ScrollLayoutDirective, TableModule } from "@bitwarden/components"; import { CopyCipherFieldDirective } from "@bitwarden/vault"; @@ -29,6 +30,7 @@ import { VaultItemsComponent } from "./vault-items.component"; PipesModule, CopyCipherFieldDirective, ScrollLayoutDirective, + PremiumBadgeComponent, ], declarations: [VaultItemsComponent, VaultCipherRowComponent, VaultCollectionRowComponent], exports: [VaultItemsComponent], diff --git a/apps/web/src/app/vault/components/vault-items/vault-items.stories.ts b/apps/web/src/app/vault/components/vault-items/vault-items.stories.ts index 043ae900b40..d973fbcbbc7 100644 --- a/apps/web/src/app/vault/components/vault-items/vault-items.stories.ts +++ b/apps/web/src/app/vault/components/vault-items/vault-items.stories.ts @@ -30,6 +30,7 @@ import { import { StateService } from "@bitwarden/common/platform/abstractions/state.service"; import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key"; import { CollectionId, OrganizationId } from "@bitwarden/common/types/guid"; +import { CipherArchiveService } from "@bitwarden/common/vault/abstractions/cipher-archive.service"; import { CipherType } from "@bitwarden/common/vault/enums"; import { AttachmentView } from "@bitwarden/common/vault/models/view/attachment.view"; import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; @@ -143,6 +144,12 @@ export default { isCipherRestricted: () => false, // No restrictions for this story }, }, + { + provide: CipherArchiveService, + useValue: { + hasArchiveFlagEnabled$: of(true), + }, + }, ], }), applicationConfig({ diff --git a/apps/web/src/app/vault/individual-vault/vault-filter/components/vault-filter.component.ts b/apps/web/src/app/vault/individual-vault/vault-filter/components/vault-filter.component.ts index e40a32dc8b9..8839fa5039d 100644 --- a/apps/web/src/app/vault/individual-vault/vault-filter/components/vault-filter.component.ts +++ b/apps/web/src/app/vault/individual-vault/vault-filter/components/vault-filter.component.ts @@ -19,8 +19,10 @@ import { getUserId } from "@bitwarden/common/auth/services/account.service"; import { BillingApiServiceAbstraction } from "@bitwarden/common/billing/abstractions/billing-api.service.abstraction"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; +import { UserId } from "@bitwarden/common/types/guid"; import { CipherArchiveService } from "@bitwarden/common/vault/abstractions/cipher-archive.service"; import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; +import { PremiumUpgradePromptService } from "@bitwarden/common/vault/abstractions/premium-upgrade-prompt.service"; import { CipherType } from "@bitwarden/common/vault/enums"; import { TreeNode } from "@bitwarden/common/vault/models/domain/tree-node"; import { RestrictedItemTypesService } from "@bitwarden/common/vault/services/restricted-item-types.service"; @@ -170,6 +172,7 @@ export class VaultFilterComponent implements OnInit, OnDestroy { protected restrictedItemTypesService: RestrictedItemTypesService, protected cipherService: CipherService, protected cipherArchiveService: CipherArchiveService, + private premiumUpgradePromptService: PremiumUpgradePromptService, ) {} async ngOnInit(): Promise { @@ -252,14 +255,20 @@ export class VaultFilterComponent implements OnInit, OnDestroy { }; async buildAllFilters(): Promise { - const hasArchiveFlag = await firstValueFrom(this.cipherArchiveService.hasArchiveFlagEnabled$()); + const [userId, showArchive] = await firstValueFrom( + combineLatest([ + this.accountService.activeAccount$.pipe(getUserId), + this.cipherArchiveService.hasArchiveFlagEnabled$, + ]), + ); + const builderFilter = {} as VaultFilterList; builderFilter.organizationFilter = await this.addOrganizationFilter(); builderFilter.typeFilter = await this.addTypeFilter(); builderFilter.folderFilter = await this.addFolderFilter(); builderFilter.collectionFilter = await this.addCollectionFilter(); - if (hasArchiveFlag) { - builderFilter.archiveFilter = await this.addArchiveFilter(); + if (showArchive) { + builderFilter.archiveFilter = await this.addArchiveFilter(userId); } builderFilter.trashFilter = await this.addTrashFilter(); return builderFilter; @@ -419,7 +428,18 @@ export class VaultFilterComponent implements OnInit, OnDestroy { return trashFilterSection; } - protected async addArchiveFilter(): Promise { + protected async addArchiveFilter(userId: UserId): Promise { + const [hasArchivedCiphers, userHasPremium] = await firstValueFrom( + combineLatest([ + this.cipherArchiveService + .archivedCiphers$(userId) + .pipe(map((archivedCiphers) => archivedCiphers.length > 0)), + this.cipherArchiveService.userHasPremium$(userId), + ]), + ); + + const promptForPremiumOnFilter = !userHasPremium && !hasArchivedCiphers; + const archiveFilterSection: VaultFilterSection = { data$: this.vaultFilterService.buildTypeTree( { @@ -442,6 +462,12 @@ export class VaultFilterComponent implements OnInit, OnDestroy { isSelectable: true, }, action: this.applyTypeFilter as (filterNode: TreeNode) => Promise, + premiumOptions: { + showBadgeForNonPremium: true, + blockFilterAction: promptForPremiumOnFilter + ? async () => await this.premiumUpgradePromptService.promptForPremium() + : undefined, + }, }; return archiveFilterSection; } diff --git a/apps/web/src/app/vault/individual-vault/vault-filter/shared/components/vault-filter-section.component.html b/apps/web/src/app/vault/individual-vault/vault-filter/shared/components/vault-filter-section.component.html index f7078d2a67a..66f14dcf2f6 100644 --- a/apps/web/src/app/vault/individual-vault/vault-filter/shared/components/vault-filter-section.component.html +++ b/apps/web/src/app/vault/individual-vault/vault-filter/shared/components/vault-filter-section.component.html @@ -105,6 +105,9 @@ *ngComponentOutlet="optionsInfo.component; injector: createInjector(f.node)" > + + +