diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 47b39130024..c2e04d94f95 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -66,6 +66,7 @@ apps/web/src/locales @bitwarden/team-platform-dev apps/browser/src/vault @bitwarden/team-vault-dev apps/cli/src/vault @bitwarden/team-vault-dev apps/desktop/src/vault @bitwarden/team-vault-dev +apps/web/src/app/shared/components/onboarding @bitwarden/team-vault-dev apps/web/src/app/vault @bitwarden/team-vault-dev libs/angular/src/vault @bitwarden/team-vault-dev libs/common/src/vault @bitwarden/team-vault-dev diff --git a/.github/workflows/release-web.yml b/.github/workflows/release-web.yml index f6feb3386a7..8fb9e2487e1 100644 --- a/.github/workflows/release-web.yml +++ b/.github/workflows/release-web.yml @@ -97,4 +97,3 @@ jobs: artifacts: "apps/web/artifacts/web-${{ needs.setup.outputs.release_version }}-selfhosted-COMMERCIAL.zip, apps/web/artifacts/web-${{ needs.setup.outputs.release_version }}-selfhosted-open-source.zip" token: ${{ secrets.GITHUB_TOKEN }} - draft: true diff --git a/apps/browser/package.json b/apps/browser/package.json index 745c9d6f3e3..53103643374 100644 --- a/apps/browser/package.json +++ b/apps/browser/package.json @@ -1,6 +1,6 @@ { "name": "@bitwarden/browser", - "version": "2026.1.0", + "version": "2026.1.1", "scripts": { "build": "npm run build:chrome", "build:bit": "npm run build:bit:chrome", diff --git a/apps/browser/src/_locales/en/messages.json b/apps/browser/src/_locales/en/messages.json index a221dc4f338..fbfaa17a87d 100644 --- a/apps/browser/src/_locales/en/messages.json +++ b/apps/browser/src/_locales/en/messages.json @@ -896,6 +896,9 @@ "invalidVerificationCode": { "message": "Invalid verification code" }, + "invalidEmailOrVerificationCode": { + "message": "Invalid email or verification code" + }, "valueCopied": { "message": "$VALUE$ copied", "description": "Value has been copied to the clipboard.", @@ -6160,10 +6163,12 @@ "enterMultipleEmailsSeparatedByComma": { "message": "Enter multiple emails by separating with a comma." }, + "emailsRequiredChangeAccessType": { + "message": "Email verification requires at least one email address. To remove all emails, change the access type above." + }, "emailPlaceholder": { "message": "user@bitwarden.com , user@acme.com" }, - "downloadBitwardenApps": { "message": "Download Bitwarden apps" }, @@ -6173,5 +6178,8 @@ "sendPasswordHelperText": { "message": "Individuals will need to enter the password to view this Send", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, + "userVerificationFailed": { + "message": "User verification failed." } } diff --git a/apps/browser/src/manifest.json b/apps/browser/src/manifest.json index ce5311f848a..c2e0b422985 100644 --- a/apps/browser/src/manifest.json +++ b/apps/browser/src/manifest.json @@ -2,7 +2,7 @@ "manifest_version": 2, "name": "__MSG_extName__", "short_name": "Bitwarden", - "version": "2026.1.0", + "version": "2026.1.1", "description": "__MSG_extDesc__", "default_locale": "en", "author": "Bitwarden Inc.", diff --git a/apps/browser/src/manifest.v3.json b/apps/browser/src/manifest.v3.json index 9cb77aa3040..603d3e06ba7 100644 --- a/apps/browser/src/manifest.v3.json +++ b/apps/browser/src/manifest.v3.json @@ -3,7 +3,7 @@ "minimum_chrome_version": "102.0", "name": "__MSG_extName__", "short_name": "Bitwarden", - "version": "2026.1.0", + "version": "2026.1.1", "description": "__MSG_extDesc__", "default_locale": "en", "author": "Bitwarden Inc.", diff --git a/apps/browser/src/popup/services/services.module.ts b/apps/browser/src/popup/services/services.module.ts index 7988bec29b9..8f446b32197 100644 --- a/apps/browser/src/popup/services/services.module.ts +++ b/apps/browser/src/popup/services/services.module.ts @@ -3,11 +3,7 @@ import { APP_INITIALIZER, NgModule, NgZone } from "@angular/core"; import { merge, of, Subject } from "rxjs"; -import { - CollectionService, - OrganizationUserApiService, - OrganizationUserService, -} from "@bitwarden/admin-console/common"; +import { CollectionService } from "@bitwarden/admin-console/common"; import { DeviceManagementComponentServiceAbstraction } from "@bitwarden/angular/auth/device-management/device-management-component.service.abstraction"; import { ChangePasswordService } from "@bitwarden/angular/auth/password-management/change-password"; import { AngularThemingService } from "@bitwarden/angular/platform/services/theming/angular-theming.service"; @@ -48,19 +44,13 @@ import { LogoutService, UserDecryptionOptionsServiceAbstraction, } from "@bitwarden/auth/common"; -import { - AutomaticUserConfirmationService, - DefaultAutomaticUserConfirmationService, -} from "@bitwarden/auto-confirm"; +import { AutomaticUserConfirmationService } from "@bitwarden/auto-confirm"; import { ExtensionAuthRequestAnsweringService } from "@bitwarden/browser/auth/services/auth-request-answering/extension-auth-request-answering.service"; import { ExtensionNewDeviceVerificationComponentService } from "@bitwarden/browser/auth/services/new-device-verification/extension-new-device-verification-component.service"; import { BrowserRouterService } from "@bitwarden/browser/platform/popup/services/browser-router.service"; import { ApiService } from "@bitwarden/common/abstractions/api.service"; import { EventCollectionService as EventCollectionServiceAbstraction } from "@bitwarden/common/abstractions/event/event-collection.service"; -import { - InternalOrganizationServiceAbstraction, - OrganizationService, -} from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; +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 { AccountService, @@ -776,19 +766,6 @@ const safeProviders: SafeProvider[] = [ useClass: ExtensionNewDeviceVerificationComponentService, deps: [], }), - safeProvider({ - provide: AutomaticUserConfirmationService, - useClass: DefaultAutomaticUserConfirmationService, - deps: [ - ConfigService, - ApiService, - OrganizationUserService, - StateProvider, - InternalOrganizationServiceAbstraction, - OrganizationUserApiService, - PolicyService, - ], - }), safeProvider({ provide: SessionTimeoutTypeService, useClass: BrowserSessionTimeoutTypeService, diff --git a/apps/browser/src/vault/popup/components/vault/autofill-vault-list-items/autofill-vault-list-items.component.html b/apps/browser/src/vault/popup/components/vault/autofill-vault-list-items/autofill-vault-list-items.component.html index 38d60233200..8ea65e77c5e 100644 --- a/apps/browser/src/vault/popup/components/vault/autofill-vault-list-items/autofill-vault-list-items.component.html +++ b/apps/browser/src/vault/popup/components/vault/autofill-vault-list-items/autofill-vault-list-items.component.html @@ -6,8 +6,8 @@ (onRefresh)="refreshCurrentTab()" [description]="(showEmptyAutofillTip$ | async) ? ('autofillSuggestionsTip' | i18n) : undefined" isAutofillList + showAutofillButton [disableDescriptionMargin]="showEmptyAutofillTip$ | async" [groupByType]="groupByType()" - [showAutofillButton]="(clickItemsToAutofillVaultView$ | async) === false" [primaryActionAutofill]="clickItemsToAutofillVaultView$ | async" > diff --git a/apps/browser/src/vault/popup/components/vault/vault-list-items-container/vault-list-items-container.component.html b/apps/browser/src/vault/popup/components/vault/vault-list-items-container/vault-list-items-container.component.html index e9e89776dde..69c548540eb 100644 --- a/apps/browser/src/vault/popup/components/vault/vault-list-items-container/vault-list-items-container.component.html +++ b/apps/browser/src/vault/popup/components/vault/vault-list-items-container/vault-list-items-container.component.html @@ -90,7 +90,13 @@ - + } @if (showAutofillBadge()) { diff --git a/apps/browser/src/vault/popup/components/vault/vault-list-items-container/vault-list-items-container.component.ts b/apps/browser/src/vault/popup/components/vault/vault-list-items-container/vault-list-items-container.component.ts index fb8d20c5cf6..331ea799169 100644 --- a/apps/browser/src/vault/popup/components/vault/vault-list-items-container/vault-list-items-container.component.ts +++ b/apps/browser/src/vault/popup/components/vault/vault-list-items-container/vault-list-items-container.component.ts @@ -302,8 +302,9 @@ export class VaultListItemsContainerComponent implements AfterViewInit { if (this.currentUriIsBlocked()) { return false; } - return this.isAutofillList() - ? this.simplifiedItemActionEnabled() + + return this.simplifiedItemActionEnabled() + ? this.isAutofillList() : this.primaryActionAutofill(); }); diff --git a/apps/browser/src/vault/popup/components/vault/vault.component.html b/apps/browser/src/vault/popup/components/vault/vault.component.html index 28abb92b8a9..2f43d29d776 100644 --- a/apps/browser/src/vault/popup/components/vault/vault.component.html +++ b/apps/browser/src/vault/popup/components/vault/vault.component.html @@ -127,7 +127,7 @@ { }); }); - describe("remainingCiphers$", () => { - beforeEach(() => { - searchService.isSearchable.mockImplementation(async (text) => text.length > 2); - }); - - it("should exclude autofill and favorite ciphers", (done) => { - service.remainingCiphers$.subscribe((ciphers) => { - // 2 autofill ciphers, 2 favorite ciphers = 6 remaining ciphers to show - expect(ciphers.length).toBe(6); - done(); - }); - }); - - it("should filter remainingCiphers$ down to search term", (done) => { - const cipherList = Object.values(allCiphers); - const searchText = "Login"; - - searchService.searchCiphers.mockImplementation(async () => { - return cipherList.filter((cipher) => { - return cipher.name.includes(searchText); - }); - }); - - service.remainingCiphers$.subscribe((ciphers) => { - // There are 6 remaining ciphers but only 2 with "Login" in the name - expect(ciphers.length).toBe(2); - done(); - }); - }); - }); - describe("emptyVault$", () => { it("should return true if there are no ciphers", (done) => { cipherServiceMock.cipherListViews$.mockReturnValue(of([])); @@ -493,8 +462,8 @@ describe("VaultPopupItemsService", () => { // Start tracking loading$ emissions tracked = new ObservableTracker(service.loading$); - // Track remainingCiphers$ to make cipher observables active - trackedCiphers = new ObservableTracker(service.remainingCiphers$); + // Track favoriteCiphers$ to make cipher observables active + trackedCiphers = new ObservableTracker(service.favoriteCiphers$); }); it("should initialize with true first", async () => { diff --git a/apps/browser/src/vault/popup/services/vault-popup-items.service.ts b/apps/browser/src/vault/popup/services/vault-popup-items.service.ts index 93f2734e6b8..0055d683f22 100644 --- a/apps/browser/src/vault/popup/services/vault-popup-items.service.ts +++ b/apps/browser/src/vault/popup/services/vault-popup-items.service.ts @@ -2,7 +2,6 @@ import { inject, Injectable, NgZone } from "@angular/core"; import { toObservable } from "@angular/core/rxjs-interop"; import { combineLatest, - concatMap, distinctUntilChanged, distinctUntilKeyChanged, filter, @@ -119,7 +118,7 @@ export class VaultPopupItemsService { this.cipherService .cipherListViews$(userId) .pipe(filter((ciphers) => ciphers != null)), - this.cipherService.failedToDecryptCiphers$(userId), + this.cipherService.failedToDecryptCiphers$(userId).pipe(startWith([])), this.restrictedItemTypesService.restricted$, ]), ), @@ -242,31 +241,12 @@ export class VaultPopupItemsService { shareReplay({ refCount: false, bufferSize: 1 }), ); - /** - * List of all remaining ciphers that are not currently suggested for autofill or marked as favorite. - * Ciphers are sorted by name. - */ - remainingCiphers$: Observable = this.favoriteCiphers$.pipe( - concatMap( - ( - favoriteCiphers, // concatMap->of is used to make withLatestFrom lazy to avoid race conditions with autoFillCiphers$ - ) => - of(favoriteCiphers).pipe(withLatestFrom(this._filteredCipherList$, this.autoFillCiphers$)), - ), - map(([favoriteCiphers, ciphers, autoFillCiphers]) => - ciphers.filter( - (cipher) => !autoFillCiphers.includes(cipher) && !favoriteCiphers.includes(cipher), - ), - ), - shareReplay({ refCount: false, bufferSize: 1 }), - ); - /** * Observable that indicates whether the service is currently loading ciphers. */ loading$: Observable = merge( this._ciphersLoading$.pipe(map(() => true)), - this.remainingCiphers$.pipe(map(() => false)), + this.favoriteCiphers$.pipe(map(() => false)), ).pipe(startWith(true), distinctUntilChanged(), shareReplay({ refCount: false, bufferSize: 1 })); /** Observable that indicates whether there is search text present. diff --git a/apps/browser/src/vault/popup/settings/folders.component.spec.ts b/apps/browser/src/vault/popup/settings/folders.component.spec.ts index 678e6d3f10e..7e08cc684a1 100644 --- a/apps/browser/src/vault/popup/settings/folders.component.spec.ts +++ b/apps/browser/src/vault/popup/settings/folders.component.spec.ts @@ -94,11 +94,12 @@ describe("FoldersComponent", () => { fixture.detectChanges(); }); - it("removes the last option in the folder array", (done) => { + it("should show all folders", (done) => { component.folders$.subscribe((folders) => { expect(folders).toEqual([ { id: "1", name: "Folder 1" }, { id: "2", name: "Folder 2" }, + { id: "0", name: "No Folder" }, ]); done(); }); diff --git a/apps/browser/src/vault/popup/settings/folders.component.ts b/apps/browser/src/vault/popup/settings/folders.component.ts index b70c17bd6a5..a38f6630949 100644 --- a/apps/browser/src/vault/popup/settings/folders.component.ts +++ b/apps/browser/src/vault/popup/settings/folders.component.ts @@ -53,13 +53,6 @@ export class FoldersComponent { this.folders$ = this.activeUserId$.pipe( filter((userId): userId is UserId => userId !== null), switchMap((userId) => this.folderService.folderViews$(userId)), - map((folders) => { - // Remove the last folder, which is the "no folder" option folder - if (folders.length > 0) { - return folders.slice(0, folders.length - 1); - } - return folders; - }), ); } diff --git a/apps/cli/src/locales/en/messages.json b/apps/cli/src/locales/en/messages.json index 18079bd2409..824b03b99cf 100644 --- a/apps/cli/src/locales/en/messages.json +++ b/apps/cli/src/locales/en/messages.json @@ -35,6 +35,9 @@ "invalidVerificationCode": { "message": "Invalid verification code." }, + "invalidEmailOrVerificationCode": { + "message": "Invalid email or verification code" + }, "masterPassRequired": { "message": "Master password is required." }, diff --git a/apps/desktop/custom-appx-manifest.xml b/apps/desktop/custom-appx-manifest.xml index 166b852588b..8a5c36e7da6 100644 --- a/apps/desktop/custom-appx-manifest.xml +++ b/apps/desktop/custom-appx-manifest.xml @@ -1,17 +1,9 @@ - + xmlns:desktop="http://schemas.microsoft.com/appx/manifest/desktop/windows10" + xmlns:rescap="http://schemas.microsoft.com/appx/manifest/foundation/windows10/restrictedcapabilities"> + @@ -87,8 +80,9 @@ xmlns:rescap="http://schemas.microsoft.com/appx/manifest/foundation/windows10/re - + + @@ -106,6 +100,13 @@ xmlns:rescap="http://schemas.microsoft.com/appx/manifest/foundation/windows10/re + + + + Bitwarden + + + diff --git a/apps/desktop/electron-builder.beta.json b/apps/desktop/electron-builder.beta.json index 9c66b17aa1f..f0746e6d408 100644 --- a/apps/desktop/electron-builder.beta.json +++ b/apps/desktop/electron-builder.beta.json @@ -61,7 +61,6 @@ "appx": { "artifactName": "Bitwarden-Beta-${version}-${arch}.${ext}", "backgroundColor": "#175DDC", - "customManifestPath": "./custom-appx-manifest.xml", "applicationId": "BitwardenBeta", "identityName": "8bitSolutionsLLC.BitwardenBeta", "publisher": "CN=14D52771-DE3C-4886-B8BF-825BA7690418", diff --git a/apps/desktop/electron-builder.json b/apps/desktop/electron-builder.json index 151ce72182d..f876b7ff680 100644 --- a/apps/desktop/electron-builder.json +++ b/apps/desktop/electron-builder.json @@ -176,7 +176,6 @@ "appx": { "artifactName": "${productName}-${version}-${arch}.${ext}", "backgroundColor": "#175DDC", - "customManifestPath": "./custom-appx-manifest.xml", "applicationId": "bitwardendesktop", "identityName": "8bitSolutionsLLC.bitwardendesktop", "publisher": "CN=14D52771-DE3C-4886-B8BF-825BA7690418", diff --git a/apps/desktop/scripts/appx-cross-build.ps1 b/apps/desktop/scripts/appx-cross-build.ps1 index ef2ab09104c..c47567695ed 100755 --- a/apps/desktop/scripts/appx-cross-build.ps1 +++ b/apps/desktop/scripts/appx-cross-build.ps1 @@ -72,6 +72,7 @@ param( # Whether to build in release mode. $Release=$false ) + $ErrorActionPreference = "Stop" $PSNativeCommandUseErrorActionPreference = $true $startTime = Get-Date @@ -113,7 +114,7 @@ else { $builderConfig = Get-Content $electronConfigFile | ConvertFrom-Json $packageConfig = Get-Content package.json | ConvertFrom-Json -$manifestTemplate = Get-Content $builderConfig.appx.customManifestPath +$manifestTemplate = Get-Content ($builderConfig.appx.customManifestPath ?? "custom-appx-manifest.xml") $srcDir = Get-Location $assetsDir = Get-Item $builderConfig.directories.buildResources diff --git a/apps/desktop/src/app/services/set-initial-password/desktop-set-initial-password.service.spec.ts b/apps/desktop/src/app/services/set-initial-password/desktop-set-initial-password.service.spec.ts index 430870a247b..6ceb2871b3f 100644 --- a/apps/desktop/src/app/services/set-initial-password/desktop-set-initial-password.service.spec.ts +++ b/apps/desktop/src/app/services/set-initial-password/desktop-set-initial-password.service.spec.ts @@ -7,6 +7,7 @@ import { InitializeJitPasswordCredentials, SetInitialPasswordCredentials, SetInitialPasswordService, + SetInitialPasswordTdeUserWithPermissionCredentials, SetInitialPasswordUserType, } from "@bitwarden/angular/auth/password-management/set-initial-password/set-initial-password.service.abstraction"; import { @@ -30,6 +31,7 @@ import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/sym import { CsprngArray } from "@bitwarden/common/types/csprng"; import { OrganizationId, UserId } from "@bitwarden/common/types/guid"; import { MasterKey, UserKey } from "@bitwarden/common/types/key"; +import { newGuid } from "@bitwarden/guid"; import { DEFAULT_KDF_CONFIG, KdfConfigService, KeyService } from "@bitwarden/key-management"; import { DesktopSetInitialPasswordService } from "./desktop-set-initial-password.service"; @@ -224,4 +226,68 @@ describe("DesktopSetInitialPasswordService", () => { superSpy.mockRestore(); }); }); + + describe("setInitialPasswordTdeUserWithPermission()", () => { + let credentials: SetInitialPasswordTdeUserWithPermissionCredentials; + let userId: UserId; + let superSpy: jest.SpyInstance; + + beforeEach(() => { + credentials = { + newPassword: "newPassword123!", + salt: "user@example.com" as MasterPasswordSalt, + kdfConfig: DEFAULT_KDF_CONFIG, + newPasswordHint: "newPasswordHint", + orgSsoIdentifier: "orgSsoIdentifier", + orgId: "orgId" as OrganizationId, + resetPasswordAutoEnroll: false, + }; + userId = newGuid() as UserId; + + superSpy = jest + .spyOn( + DefaultSetInitialPasswordService.prototype, + "setInitialPasswordTdeUserWithPermission", + ) + .mockResolvedValue(undefined); // undefined = successful + }); + + afterEach(() => { + superSpy.mockRestore(); + }); + + it("should call the setInitialPasswordTdeUserWithPermission() method on the default service", async () => { + // Act + await sut.setInitialPasswordTdeUserWithPermission(credentials, userId); + + // Assert + expect(superSpy).toHaveBeenCalledWith(credentials, userId); + }); + + describe("given the initial password was successfully set", () => { + it("should send a 'redrawMenu' message", async () => { + // Act + await sut.setInitialPasswordTdeUserWithPermission(credentials, userId); + + // Assert + expect(messagingService.send).toHaveBeenCalledTimes(1); + expect(messagingService.send).toHaveBeenCalledWith("redrawMenu"); + }); + }); + + describe("given the initial password was NOT successfully set (due an error on the default service)", () => { + it("should NOT send a 'redrawMenu' message", async () => { + // Arrange + const error = new Error("error on DefaultSetInitialPasswordService"); + superSpy.mockRejectedValue(error); + + // Act + const promise = sut.setInitialPasswordTdeUserWithPermission(credentials, userId); + + // Assert + await expect(promise).rejects.toThrow(error); + expect(messagingService.send).not.toHaveBeenCalled(); + }); + }); + }); }); diff --git a/apps/desktop/src/app/services/set-initial-password/desktop-set-initial-password.service.ts b/apps/desktop/src/app/services/set-initial-password/desktop-set-initial-password.service.ts index 3b1562075f9..b03d87870f9 100644 --- a/apps/desktop/src/app/services/set-initial-password/desktop-set-initial-password.service.ts +++ b/apps/desktop/src/app/services/set-initial-password/desktop-set-initial-password.service.ts @@ -4,6 +4,7 @@ import { InitializeJitPasswordCredentials, SetInitialPasswordCredentials, SetInitialPasswordService, + SetInitialPasswordTdeUserWithPermissionCredentials, SetInitialPasswordUserType, } from "@bitwarden/angular/auth/password-management/set-initial-password/set-initial-password.service.abstraction"; import { InternalUserDecryptionOptionsServiceAbstraction } from "@bitwarden/auth/common"; @@ -75,4 +76,13 @@ export class DesktopSetInitialPasswordService this.messagingService.send("redrawMenu"); } + + override async setInitialPasswordTdeUserWithPermission( + credentials: SetInitialPasswordTdeUserWithPermissionCredentials, + userId: UserId, + ) { + await super.setInitialPasswordTdeUserWithPermission(credentials, userId); + + this.messagingService.send("redrawMenu"); + } } 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 271418ae5b2..fc058c1a17f 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,6 +1,6 @@ // FIXME: Update this file to be type safe and remove this and next line // @ts-strict-ignore -import { Component, computed, inject, signal, viewChild } from "@angular/core"; +import { Component, computed, DestroyRef, inject, signal, viewChild } from "@angular/core"; import { toSignal } from "@angular/core/rxjs-interop"; import { combineLatest, map, switchMap, lastValueFrom } from "rxjs"; @@ -20,7 +20,7 @@ import { SendApiService } from "@bitwarden/common/tools/send/services/send-api.s import { SendType } from "@bitwarden/common/tools/send/types/send-type"; import { SendId } from "@bitwarden/common/types/guid"; import { PremiumUpgradePromptService } from "@bitwarden/common/vault/abstractions/premium-upgrade-prompt.service"; -import { ButtonModule, DialogService, ToastService } from "@bitwarden/components"; +import { ButtonModule, DialogRef, DialogService, ToastService } from "@bitwarden/components"; import { NewSendDropdownV2Component, SendItemsService, @@ -28,6 +28,7 @@ import { SendListState, SendAddEditDialogComponent, DefaultSendFormConfigService, + SendItemDialogResult, } from "@bitwarden/send-ui"; import { DesktopPremiumUpgradePromptService } from "../../../services/desktop-premium-upgrade-prompt.service"; @@ -84,6 +85,9 @@ export class SendV2Component { private dialogService = inject(DialogService); private toastService = inject(ToastService); private logService = inject(LogService); + private destroyRef = inject(DestroyRef); + + private activeDrawerRef?: DialogRef; protected readonly useDrawerEditMode = toSignal( this.configService.getFeatureFlag$(FeatureFlag.DesktopUiMigrationMilestone2), @@ -128,6 +132,12 @@ export class SendV2Component { { initialValue: null }, ); + constructor() { + this.destroyRef.onDestroy(() => { + this.activeDrawerRef?.close(); + }); + } + protected readonly selectedSendType = computed(() => { const action = this.action(); @@ -143,11 +153,12 @@ export class SendV2Component { if (this.useDrawerEditMode()) { const formConfig = await this.sendFormConfigService.buildConfig("add", undefined, type); - const dialogRef = SendAddEditDialogComponent.openDrawer(this.dialogService, { + this.activeDrawerRef = SendAddEditDialogComponent.openDrawer(this.dialogService, { formConfig, }); - await lastValueFrom(dialogRef.closed); + await lastValueFrom(this.activeDrawerRef.closed); + this.activeDrawerRef = null; } else { this.action.set(Action.Add); this.sendId.set(null); @@ -173,11 +184,12 @@ export class SendV2Component { if (this.useDrawerEditMode()) { const formConfig = await this.sendFormConfigService.buildConfig("edit", sendId as SendId); - const dialogRef = SendAddEditDialogComponent.openDrawer(this.dialogService, { + this.activeDrawerRef = SendAddEditDialogComponent.openDrawer(this.dialogService, { formConfig, }); - await lastValueFrom(dialogRef.closed); + await lastValueFrom(this.activeDrawerRef.closed); + this.activeDrawerRef = null; } else { if (sendId === this.sendId() && this.action() === Action.Edit) { return; diff --git a/apps/desktop/src/locales/en/messages.json b/apps/desktop/src/locales/en/messages.json index f444265877d..97a38235fd7 100644 --- a/apps/desktop/src/locales/en/messages.json +++ b/apps/desktop/src/locales/en/messages.json @@ -1023,6 +1023,9 @@ "invalidVerificationCode": { "message": "Invalid verification code" }, + "invalidEmailOrVerificationCode": { + "message": "Invalid email or verification code" + }, "continue": { "message": "Continue" }, @@ -4615,7 +4618,13 @@ "enterMultipleEmailsSeparatedByComma": { "message": "Enter multiple emails by separating with a comma." }, + "emailsRequiredChangeAccessType": { + "message": "Email verification requires at least one email address. To remove all emails, change the access type above." + }, "emailPlaceholder": { "message": "user@bitwarden.com , user@acme.com" + }, + "userVerificationFailed": { + "message": "User verification failed." } } diff --git a/apps/desktop/src/main/window.main.ts b/apps/desktop/src/main/window.main.ts index 2872154aa44..b4ced4471fa 100644 --- a/apps/desktop/src/main/window.main.ts +++ b/apps/desktop/src/main/window.main.ts @@ -4,7 +4,7 @@ import { once } from "node:events"; import * as path from "path"; import * as url from "url"; -import { app, BrowserWindow, dialog, ipcMain, nativeTheme, screen, session } from "electron"; +import { app, BrowserWindow, ipcMain, nativeTheme, screen, session } from "electron"; import { concatMap, firstValueFrom, pairwise } from "rxjs"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; @@ -127,7 +127,6 @@ export class WindowMain { if (!isMacAppStore()) { const gotTheLock = app.requestSingleInstanceLock(); if (!gotTheLock) { - dialog.showErrorBox("Error", "An instance of Bitwarden Desktop is already running."); app.quit(); return; } else { diff --git a/apps/web/src/app/admin-console/organizations/settings/organization-settings.module.ts b/apps/web/src/app/admin-console/organizations/settings/organization-settings.module.ts index 27a6226f964..13467e222d2 100644 --- a/apps/web/src/app/admin-console/organizations/settings/organization-settings.module.ts +++ b/apps/web/src/app/admin-console/organizations/settings/organization-settings.module.ts @@ -4,9 +4,9 @@ import { PremiumBadgeComponent } from "@bitwarden/angular/billing/components/pre import { ItemModule } from "@bitwarden/components"; import { DangerZoneComponent } from "../../../auth/settings/account/danger-zone.component"; +import { AccountFingerprintComponent } from "../../../key-management/account-fingerprint/account-fingerprint.component"; import { HeaderModule } from "../../../layouts/header/header.module"; import { SharedModule } from "../../../shared"; -import { AccountFingerprintComponent } from "../../../shared/components/account-fingerprint/account-fingerprint.component"; import { AccountComponent } from "./account.component"; import { OrganizationSettingsRoutingModule } from "./organization-settings-routing.module"; diff --git a/apps/web/src/app/auth/settings/account/profile.component.ts b/apps/web/src/app/auth/settings/account/profile.component.ts index fd96f343b3a..24e8a370e2a 100644 --- a/apps/web/src/app/auth/settings/account/profile.component.ts +++ b/apps/web/src/app/auth/settings/account/profile.component.ts @@ -18,8 +18,8 @@ import { DialogService, ToastService } from "@bitwarden/components"; import { KeyService } from "@bitwarden/key-management"; import { DynamicAvatarComponent } from "../../../components/dynamic-avatar.component"; +import { AccountFingerprintComponent } from "../../../key-management/account-fingerprint/account-fingerprint.component"; import { SharedModule } from "../../../shared"; -import { AccountFingerprintComponent } from "../../../shared/components/account-fingerprint/account-fingerprint.component"; import { ChangeAvatarDialogComponent } from "./change-avatar-dialog.component"; diff --git a/apps/web/src/app/billing/organizations/organization-plans.component.spec.ts b/apps/web/src/app/billing/organizations/organization-plans.component.spec.ts new file mode 100644 index 00000000000..aa4cbdab40e --- /dev/null +++ b/apps/web/src/app/billing/organizations/organization-plans.component.spec.ts @@ -0,0 +1,2199 @@ +// These are disabled until we can migrate to signals and remove the use of @Input properties that are used within the mocked child components +/* eslint-disable @angular-eslint/prefer-output-emitter-ref */ +/* eslint-disable @angular-eslint/prefer-signals */ +import { ChangeDetectionStrategy, Component, EventEmitter, Input, Output } from "@angular/core"; +import { ComponentFixture, fakeAsync, TestBed, tick } from "@angular/core/testing"; +import { FormBuilder, Validators } from "@angular/forms"; +import { Router } from "@angular/router"; +import { BehaviorSubject, of } from "rxjs"; + +import { ApiService } from "@bitwarden/common/abstractions/api.service"; +import { OrganizationApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/organization/organization-api.service.abstraction"; +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 { ProviderApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/provider/provider-api.service.abstraction"; +import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; +import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { PlanType, ProductTierType } from "@bitwarden/common/billing/enums"; +import { PlanResponse } from "@bitwarden/common/billing/models/response/plan.response"; +import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.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 { SyncService } from "@bitwarden/common/vault/abstractions/sync/sync.service.abstraction"; +import { ToastService } from "@bitwarden/components"; +import { KeyService } from "@bitwarden/key-management"; +import { + PreviewInvoiceClient, + SubscriberBillingClient, +} from "@bitwarden/web-vault/app/billing/clients"; + +import { OrganizationInformationComponent } from "../../admin-console/organizations/create/organization-information.component"; +import { EnterBillingAddressComponent, EnterPaymentMethodComponent } from "../payment/components"; +import { SecretsManagerSubscribeComponent } from "../shared"; +import { OrganizationSelfHostingLicenseUploaderComponent } from "../shared/self-hosting-license-uploader/organization-self-hosting-license-uploader.component"; + +import { OrganizationPlansComponent } from "./organization-plans.component"; + +// Mocked Child Components +@Component({ + changeDetection: ChangeDetectionStrategy.OnPush, + selector: "app-org-info", + template: "", + standalone: true, +}) +class MockOrgInfoComponent { + @Input() formGroup: any; + @Input() createOrganization = true; + @Input() isProvider = false; + @Input() acceptingSponsorship = false; + @Output() changedBusinessOwned = new EventEmitter(); +} + +@Component({ + changeDetection: ChangeDetectionStrategy.OnPush, + selector: "sm-subscribe", + template: "", + standalone: true, +}) +class MockSmSubscribeComponent { + @Input() formGroup: any; + @Input() selectedPlan: any; + @Input() upgradeOrganization = false; +} + +@Component({ + changeDetection: ChangeDetectionStrategy.OnPush, + selector: "app-enter-payment-method", + template: "", + standalone: true, +}) +class MockEnterPaymentMethodComponent { + @Input() group: any; + + static getFormGroup() { + const fb = new FormBuilder(); + return fb.group({ + type: fb.control("card"), + bankAccount: fb.group({ + routingNumber: fb.control(""), + accountNumber: fb.control(""), + accountHolderName: fb.control(""), + accountHolderType: fb.control(""), + }), + billingAddress: fb.group({ + country: fb.control("US"), + postalCode: fb.control(""), + }), + }); + } + + tokenize = jest.fn().mockResolvedValue({ + token: "mock_token", + type: "card", + }); +} + +@Component({ + changeDetection: ChangeDetectionStrategy.OnPush, + selector: "app-enter-billing-address", + template: "", + standalone: true, +}) +class MockEnterBillingAddressComponent { + @Input() group: any; + @Input() scenario: any; + + static getFormGroup() { + return new FormBuilder().group({ + country: ["US", Validators.required], + postalCode: ["", Validators.required], + taxId: [""], + line1: [""], + line2: [""], + city: [""], + state: [""], + }); + } +} + +@Component({ + changeDetection: ChangeDetectionStrategy.OnPush, + selector: "organization-self-hosting-license-uploader", + template: "", + standalone: true, +}) +class MockOrganizationSelfHostingLicenseUploaderComponent { + @Output() onLicenseFileUploaded = new EventEmitter(); +} + +// Test Helper Functions + +/** + * Sets up mock encryption keys and org key services + */ +const setupMockEncryptionKeys = ( + mockKeyService: jest.Mocked, + mockEncryptService: jest.Mocked, +) => { + mockKeyService.makeOrgKey.mockResolvedValue([{ encryptedString: "mock-key" }, {} as any] as any); + + mockEncryptService.encryptString.mockResolvedValue({ + encryptedString: "mock-collection", + } as any); + + mockKeyService.makeKeyPair.mockResolvedValue([ + "public-key", + { encryptedString: "private-key" }, + ] as any); +}; + +/** + * Sets up a mock payment method component that returns a successful tokenization + */ +const setupMockPaymentMethodComponent = ( + component: OrganizationPlansComponent, + token = "mock_token", + type = "card", +) => { + component["enterPaymentMethodComponent"] = { + tokenize: jest.fn().mockResolvedValue({ token, type }), + } as any; +}; + +/** + * Patches billing address form with standard test values + */ +const patchBillingAddress = ( + component: OrganizationPlansComponent, + overrides: Partial<{ + country: string; + postalCode: string; + line1: string; + line2: string; + city: string; + state: string; + taxId: string; + }> = {}, +) => { + component.billingFormGroup.controls.billingAddress.patchValue({ + country: "US", + postalCode: "12345", + line1: "123 Street", + line2: "", + city: "City", + state: "CA", + taxId: "", + ...overrides, + }); +}; + +/** + * Sets up a mock organization for upgrade scenarios + */ +const setupMockUpgradeOrganization = ( + mockOrganizationApiService: jest.Mocked, + organizationsSubject: BehaviorSubject, + orgConfig: { + id?: string; + productTierType?: ProductTierType; + hasPaymentSource?: boolean; + planType?: PlanType; + seats?: number; + maxStorageGb?: number; + hasPublicAndPrivateKeys?: boolean; + useSecretsManager?: boolean; + smSeats?: number; + smServiceAccounts?: number; + } = {}, +) => { + const { + id = "org-123", + productTierType = ProductTierType.Free, + hasPaymentSource = true, + planType = PlanType.Free, + seats = 5, + maxStorageGb, + hasPublicAndPrivateKeys = true, + useSecretsManager = false, + smSeats, + smServiceAccounts, + } = orgConfig; + + const mockOrganization = { + id, + name: "Test Org", + productTierType, + seats, + maxStorageGb, + hasPublicAndPrivateKeys, + useSecretsManager, + } as Organization; + + organizationsSubject.next([mockOrganization]); + + mockOrganizationApiService.getBilling.mockResolvedValue({ + paymentSource: hasPaymentSource ? { type: "card" } : null, + } as any); + + mockOrganizationApiService.getSubscription.mockResolvedValue({ + planType, + smSeats, + smServiceAccounts, + } as any); + + return mockOrganization; +}; + +/** + * Patches organization form with basic test values + */ +const patchOrganizationForm = ( + component: OrganizationPlansComponent, + values: { + name?: string; + billingEmail?: string; + productTier?: ProductTierType; + plan?: PlanType; + additionalSeats?: number; + additionalStorage?: number; + }, +) => { + component.formGroup.patchValue({ + name: "Test Org", + billingEmail: "test@example.com", + productTier: ProductTierType.Free, + plan: PlanType.Free, + additionalSeats: 0, + additionalStorage: 0, + ...values, + }); +}; + +/** + * Returns plan details + * + */ + +const createMockPlans = (): PlanResponse[] => { + return [ + { + type: PlanType.Free, + productTier: ProductTierType.Free, + name: "Free", + isAnnual: true, + upgradeSortOrder: 1, + displaySortOrder: 1, + PasswordManager: { + basePrice: 0, + seatPrice: 0, + maxSeats: 2, + baseSeats: 2, + hasAdditionalSeatsOption: false, + hasAdditionalStorageOption: false, + hasPremiumAccessOption: false, + baseStorageGb: 0, + }, + SecretsManager: null, + } as PlanResponse, + { + type: PlanType.FamiliesAnnually, + productTier: ProductTierType.Families, + name: "Families", + isAnnual: true, + upgradeSortOrder: 2, + displaySortOrder: 2, + PasswordManager: { + basePrice: 40, + seatPrice: 0, + maxSeats: 6, + baseSeats: 6, + hasAdditionalSeatsOption: false, + hasAdditionalStorageOption: true, + hasPremiumAccessOption: false, + baseStorageGb: 1, + additionalStoragePricePerGb: 4, + }, + SecretsManager: null, + } as PlanResponse, + { + type: PlanType.TeamsAnnually, + productTier: ProductTierType.Teams, + name: "Teams", + isAnnual: true, + canBeUsedByBusiness: true, + upgradeSortOrder: 3, + displaySortOrder: 3, + PasswordManager: { + basePrice: 0, + seatPrice: 48, + hasAdditionalSeatsOption: true, + hasAdditionalStorageOption: true, + hasPremiumAccessOption: true, + baseStorageGb: 1, + additionalStoragePricePerGb: 4, + premiumAccessOptionPrice: 40, + }, + SecretsManager: { + basePrice: 0, + seatPrice: 72, + hasAdditionalSeatsOption: true, + hasAdditionalServiceAccountOption: true, + baseServiceAccount: 50, + additionalPricePerServiceAccount: 6, + }, + } as PlanResponse, + { + type: PlanType.EnterpriseAnnually, + productTier: ProductTierType.Enterprise, + name: "Enterprise", + isAnnual: true, + canBeUsedByBusiness: true, + trialPeriodDays: 7, + upgradeSortOrder: 4, + displaySortOrder: 4, + PasswordManager: { + basePrice: 0, + seatPrice: 72, + hasAdditionalSeatsOption: true, + hasAdditionalStorageOption: true, + hasPremiumAccessOption: true, + baseStorageGb: 1, + additionalStoragePricePerGb: 4, + premiumAccessOptionPrice: 40, + }, + SecretsManager: { + basePrice: 0, + seatPrice: 144, + hasAdditionalSeatsOption: true, + hasAdditionalServiceAccountOption: true, + baseServiceAccount: 200, + additionalPricePerServiceAccount: 6, + }, + } as PlanResponse, + ]; +}; + +describe("OrganizationPlansComponent", () => { + let component: OrganizationPlansComponent; + let fixture: ComponentFixture; + + // Mock services + let mockApiService: jest.Mocked; + let mockI18nService: jest.Mocked; + let mockPlatformUtilsService: jest.Mocked; + let mockKeyService: jest.Mocked; + let mockEncryptService: jest.Mocked; + let mockRouter: jest.Mocked; + let mockSyncService: jest.Mocked; + let mockPolicyService: jest.Mocked; + let mockOrganizationService: jest.Mocked; + let mockMessagingService: jest.Mocked; + let mockOrganizationApiService: jest.Mocked; + let mockProviderApiService: jest.Mocked; + let mockToastService: jest.Mocked; + let mockAccountService: jest.Mocked; + let mockSubscriberBillingClient: jest.Mocked; + let mockPreviewInvoiceClient: jest.Mocked; + let mockConfigService: jest.Mocked; + + // Mock data + let mockPasswordManagerPlans: PlanResponse[]; + let mockOrganization: Organization; + let activeAccountSubject: BehaviorSubject; + let organizationsSubject: BehaviorSubject; + + beforeEach(async () => { + jest.clearAllMocks(); + + // Mock the static getFormGroup methods to return forms without validators + jest + .spyOn(EnterPaymentMethodComponent, "getFormGroup") + .mockReturnValue(MockEnterPaymentMethodComponent.getFormGroup() as any); + jest + .spyOn(EnterBillingAddressComponent, "getFormGroup") + .mockReturnValue(MockEnterBillingAddressComponent.getFormGroup() as any); + + // Initialize mock services + mockApiService = { + getPlans: jest.fn(), + postProviderCreateOrganization: jest.fn(), + refreshIdentityToken: jest.fn(), + } as any; + + mockI18nService = { + t: jest.fn((key: string) => key), + } as any; + + mockPlatformUtilsService = { + isSelfHost: jest.fn().mockReturnValue(false), + } as any; + + mockKeyService = { + makeOrgKey: jest.fn(), + makeKeyPair: jest.fn(), + orgKeys$: jest.fn().mockReturnValue(of({})), + providerKeys$: jest.fn().mockReturnValue(of({})), + } as any; + + mockEncryptService = { + encryptString: jest.fn(), + wrapSymmetricKey: jest.fn(), + } as any; + + mockRouter = { + navigate: jest.fn(), + } as any; + + mockSyncService = { + fullSync: jest.fn().mockResolvedValue(undefined), + } as any; + + mockPolicyService = { + policyAppliesToUser$: jest.fn().mockReturnValue(of(false)), + } as any; + + // Setup subjects for observables + activeAccountSubject = new BehaviorSubject({ + id: "user-id", + email: "test@example.com", + }); + organizationsSubject = new BehaviorSubject([]); + + mockAccountService = { + activeAccount$: activeAccountSubject.asObservable(), + } as any; + + mockOrganizationService = { + organizations$: jest.fn().mockReturnValue(organizationsSubject.asObservable()), + } as any; + + mockMessagingService = { + send: jest.fn(), + } as any; + + mockOrganizationApiService = { + getBilling: jest.fn(), + getSubscription: jest.fn(), + create: jest.fn(), + createLicense: jest.fn(), + upgrade: jest.fn(), + updateKeys: jest.fn(), + } as any; + + mockProviderApiService = { + getProvider: jest.fn(), + } as any; + + mockToastService = { + showToast: jest.fn(), + } as any; + + mockSubscriberBillingClient = { + getBillingAddress: jest.fn().mockResolvedValue({ + country: "US", + postalCode: "12345", + }), + updatePaymentMethod: jest.fn().mockResolvedValue(undefined), + } as any; + + mockPreviewInvoiceClient = { + previewTaxForOrganizationSubscriptionPurchase: jest.fn().mockResolvedValue({ + tax: 5.0, + total: 50.0, + }), + } as any; + + mockConfigService = { + getFeatureFlag: jest.fn().mockResolvedValue(true), + } as any; + + // Setup mock plan data + mockPasswordManagerPlans = createMockPlans(); + + mockApiService.getPlans.mockResolvedValue({ + data: mockPasswordManagerPlans, + } as any); + + await TestBed.configureTestingModule({ + providers: [ + { provide: ApiService, useValue: mockApiService }, + { provide: I18nService, useValue: mockI18nService }, + { provide: PlatformUtilsService, useValue: mockPlatformUtilsService }, + { provide: KeyService, useValue: mockKeyService }, + { provide: EncryptService, useValue: mockEncryptService }, + { provide: Router, useValue: mockRouter }, + { provide: SyncService, useValue: mockSyncService }, + { provide: PolicyService, useValue: mockPolicyService }, + { provide: OrganizationService, useValue: mockOrganizationService }, + { provide: MessagingService, useValue: mockMessagingService }, + FormBuilder, // Use real FormBuilder + { provide: OrganizationApiServiceAbstraction, useValue: mockOrganizationApiService }, + { provide: ProviderApiServiceAbstraction, useValue: mockProviderApiService }, + { provide: ToastService, useValue: mockToastService }, + { provide: AccountService, useValue: mockAccountService }, + { provide: SubscriberBillingClient, useValue: mockSubscriberBillingClient }, + { provide: PreviewInvoiceClient, useValue: mockPreviewInvoiceClient }, + { provide: ConfigService, useValue: mockConfigService }, + ], + }) + // Override the component to replace child components with mocks and provide mock services + .overrideComponent(OrganizationPlansComponent, { + remove: { + imports: [ + OrganizationInformationComponent, + SecretsManagerSubscribeComponent, + EnterPaymentMethodComponent, + EnterBillingAddressComponent, + OrganizationSelfHostingLicenseUploaderComponent, + ], + providers: [PreviewInvoiceClient, SubscriberBillingClient], + }, + add: { + imports: [ + MockOrgInfoComponent, + MockSmSubscribeComponent, + MockEnterPaymentMethodComponent, + MockEnterBillingAddressComponent, + MockOrganizationSelfHostingLicenseUploaderComponent, + ], + providers: [ + { provide: PreviewInvoiceClient, useValue: mockPreviewInvoiceClient }, + { provide: SubscriberBillingClient, useValue: mockSubscriberBillingClient }, + ], + }, + }) + .compileComponents(); + + fixture = TestBed.createComponent(OrganizationPlansComponent); + component = fixture.componentInstance; + }); + + describe("component creation", () => { + it("should create", () => { + expect(component).toBeTruthy(); + }); + + it("should initialize with default values", () => { + expect(component.loading).toBe(true); + expect(component.showFree).toBe(true); + expect(component.showCancel).toBe(false); + expect(component.productTier).toBe(ProductTierType.Free); + }); + }); + + describe("ngOnInit", () => { + describe("create organization flow", () => { + it("should load plans from API", async () => { + fixture.detectChanges(); + await fixture.whenStable(); + + expect(mockApiService.getPlans).toHaveBeenCalled(); + expect(component.passwordManagerPlans).toEqual(mockPasswordManagerPlans); + expect(component.loading).toBe(false); + }); + + it("should set required validators on name and billing email", async () => { + fixture.detectChanges(); + await fixture.whenStable(); + + component.formGroup.controls.name.setValue(""); + component.formGroup.controls.billingEmail.setValue(""); + + expect(component.formGroup.controls.name.hasError("required")).toBe(true); + expect(component.formGroup.controls.billingEmail.hasError("required")).toBe(true); + }); + + it("should not load organization data for create flow", async () => { + fixture.detectChanges(); + await fixture.whenStable(); + + expect(mockOrganizationApiService.getBilling).not.toHaveBeenCalled(); + expect(mockOrganizationApiService.getSubscription).not.toHaveBeenCalled(); + }); + }); + + describe("upgrade organization flow", () => { + beforeEach(() => { + mockOrganization = setupMockUpgradeOrganization( + mockOrganizationApiService, + organizationsSubject, + { + planType: PlanType.FamiliesAnnually2025, + }, + ); + + component.organizationId = mockOrganization.id; + }); + + it("should load existing organization data", async () => { + fixture.detectChanges(); + await fixture.whenStable(); + + expect(component.organization).toEqual(mockOrganization); + expect(mockOrganizationApiService.getBilling).toHaveBeenCalledWith(mockOrganization.id); + expect(mockOrganizationApiService.getSubscription).toHaveBeenCalledWith( + mockOrganization.id, + ); + expect(mockSubscriberBillingClient.getBillingAddress).toHaveBeenCalledWith({ + type: "organization", + data: mockOrganization, + }); + // Verify the form was updated + expect(component.billingFormGroup.controls.billingAddress.value.country).toBe("US"); + expect(component.billingFormGroup.controls.billingAddress.value.postalCode).toBe("12345"); + }); + + it("should not add validators for name and billingEmail in upgrade flow", async () => { + fixture.detectChanges(); + await fixture.whenStable(); + + component.formGroup.controls.name.setValue(""); + component.formGroup.controls.billingEmail.setValue(""); + + // In upgrade flow, these should not be required + expect(component.formGroup.controls.name.hasError("required")).toBe(false); + expect(component.formGroup.controls.billingEmail.hasError("required")).toBe(false); + }); + }); + + describe("feature flags", () => { + it("should use FamiliesAnnually when PM26462_Milestone_3 is enabled", async () => { + mockConfigService.getFeatureFlag.mockResolvedValue(true); + + fixture.detectChanges(); + await fixture.whenStable(); + + const familyPlan = component["_familyPlan"]; + expect(familyPlan).toBe(PlanType.FamiliesAnnually); + }); + + it("should use FamiliesAnnually2025 when feature flag is disabled", async () => { + mockConfigService.getFeatureFlag.mockResolvedValue(false); + + fixture.detectChanges(); + await fixture.whenStable(); + + const familyPlan = component["_familyPlan"]; + expect(familyPlan).toBe(PlanType.FamiliesAnnually2025); + }); + }); + }); + + describe("organization creation validation flow", () => { + beforeEach(async () => { + fixture.detectChanges(); + await fixture.whenStable(); + }); + + it("should prevent submission with invalid form data", async () => { + component.formGroup.patchValue({ + name: "", + billingEmail: "invalid-email", + additionalStorage: -1, + additionalSeats: 200000, + }); + + await component.submit(); + + expect(mockOrganizationApiService.create).not.toHaveBeenCalled(); + expect(component.formGroup.invalid).toBe(true); + }); + + it("should allow submission with valid form data", async () => { + patchOrganizationForm(component, { + name: "Valid Organization", + billingEmail: "valid@example.com", + productTier: ProductTierType.Free, + plan: PlanType.Free, + }); + + setupMockEncryptionKeys(mockKeyService, mockEncryptService); + mockOrganizationApiService.create.mockResolvedValue({ + id: "new-org-id", + } as any); + + await component.submit(); + + expect(mockOrganizationApiService.create).toHaveBeenCalled(); + }); + }); + + describe("plan selection flow", () => { + beforeEach(async () => { + fixture.detectChanges(); + await fixture.whenStable(); + }); + + it("should configure form appropriately when switching between product tiers", () => { + // Start with Families plan with unsupported features + component.productTier = ProductTierType.Families; + component.formGroup.controls.additionalSeats.setValue(10); + component.formGroup.controls.additionalStorage.setValue(5); + component.changedProduct(); + + // Families doesn't support additional seats + expect(component.formGroup.controls.additionalSeats.value).toBe(0); + expect(component.formGroup.controls.plan.value).toBe(PlanType.FamiliesAnnually); + + // Switch to Teams plan which supports additional seats + component.productTier = ProductTierType.Teams; + component.changedProduct(); + + expect(component.formGroup.controls.plan.value).toBe(PlanType.TeamsAnnually); + // Teams initializes with 1 seat by default + expect(component.formGroup.controls.additionalSeats.value).toBeGreaterThan(0); + + // Switch to Free plan which doesn't support additional storage + component.formGroup.controls.additionalStorage.setValue(10); + component.productTier = ProductTierType.Free; + component.changedProduct(); + + expect(component.formGroup.controls.additionalStorage.value).toBe(0); + }); + }); + + describe("subscription pricing flow", () => { + beforeEach(async () => { + fixture.detectChanges(); + await fixture.whenStable(); + }); + + it("should calculate total price based on selected plan options", () => { + // Select Teams plan and configure options + component.productTier = ProductTierType.Teams; + component.changedProduct(); + component.formGroup.controls.additionalSeats.setValue(5); + component.formGroup.controls.additionalStorage.setValue(10); + component.formGroup.controls.premiumAccessAddon.setValue(true); + + const pmSubtotal = component.passwordManagerSubtotal; + // Verify pricing includes all selected options + expect(pmSubtotal).toBeGreaterThan(0); + expect(pmSubtotal).toBe(5 * 48 + 10 * 4 + 40); // seats + storage + premium + }); + + it("should calculate pricing with Secrets Manager addon", () => { + component.productTier = ProductTierType.Teams; + component.plan = PlanType.TeamsAnnually; + + // Enable Secrets Manager with additional options + component.secretsManagerForm.patchValue({ + enabled: true, + userSeats: 3, + additionalServiceAccounts: 10, + }); + + const smSubtotal = component.secretsManagerSubtotal; + expect(smSubtotal).toBeGreaterThan(0); + + // Disable Secrets Manager + component.secretsManagerForm.patchValue({ + enabled: false, + }); + + expect(component.secretsManagerSubtotal).toBe(0); + }); + }); + + describe("tax calculation", () => { + beforeEach(async () => { + fixture.detectChanges(); + await fixture.whenStable(); + }); + + it("should calculate tax after debounce period", fakeAsync(() => { + component.productTier = ProductTierType.Teams; + component.changedProduct(); + component.formGroup.controls.additionalSeats.setValue(1); + component.billingFormGroup.controls.billingAddress.patchValue({ + country: "US", + postalCode: "12345", + }); + + tick(1500); // Wait for debounce (1000ms) + + expect( + mockPreviewInvoiceClient.previewTaxForOrganizationSubscriptionPurchase, + ).toHaveBeenCalled(); + expect(component["estimatedTax"]).toBe(5.0); + })); + + it("should not calculate tax with invalid billing address", fakeAsync(() => { + component.billingFormGroup.controls.billingAddress.patchValue({ + country: "", + postalCode: "", + }); + + tick(1500); + + expect( + mockPreviewInvoiceClient.previewTaxForOrganizationSubscriptionPurchase, + ).not.toHaveBeenCalled(); + })); + }); + + describe("submit", () => { + beforeEach(async () => { + fixture.detectChanges(); + await fixture.whenStable(); + }); + + it("should create organization successfully", async () => { + patchOrganizationForm(component, { + name: "New Org", + billingEmail: "test@example.com", + }); + + setupMockEncryptionKeys(mockKeyService, mockEncryptService); + mockOrganizationApiService.create.mockResolvedValue({ + id: "new-org-id", + } as any); + + await component.submit(); + + expect(mockOrganizationApiService.create).toHaveBeenCalled(); + expect(mockToastService.showToast).toHaveBeenCalledWith({ + variant: "success", + title: "organizationCreated", + message: "organizationReadyToGo", + }); + expect(mockSyncService.fullSync).toHaveBeenCalledWith(true); + }); + + it("should emit onSuccess after successful creation", async () => { + const onSuccessSpy = jest.spyOn(component.onSuccess, "emit"); + + patchOrganizationForm(component, { + name: "New Org", + billingEmail: "test@example.com", + }); + + setupMockEncryptionKeys(mockKeyService, mockEncryptService); + mockOrganizationApiService.create.mockResolvedValue({ + id: "new-org-id", + } as any); + + await component.submit(); + + expect(onSuccessSpy).toHaveBeenCalledWith({ + organizationId: "new-org-id", + }); + }); + + it("should handle payment method validation failure", async () => { + patchOrganizationForm(component, { + name: "New Org", + billingEmail: "test@example.com", + productTier: ProductTierType.Teams, + plan: PlanType.TeamsAnnually, + additionalSeats: 5, + }); + + patchBillingAddress(component); + setupMockEncryptionKeys(mockKeyService, mockEncryptService); + + // Mock payment method component to return null (failure) + component["enterPaymentMethodComponent"] = { + tokenize: jest.fn().mockResolvedValue(null), + } as any; + + await component.submit(); + + // Should not create organization if payment method validation fails + expect(mockOrganizationApiService.create).not.toHaveBeenCalled(); + }); + + it("should block submission when single org policy applies", async () => { + mockPolicyService.policyAppliesToUser$.mockReturnValue(of(true)); + + // Need to reinitialize after changing policy mock + const policyFixture = TestBed.createComponent(OrganizationPlansComponent); + const policyComponent = policyFixture.componentInstance; + policyFixture.detectChanges(); + await policyFixture.whenStable(); + + policyComponent.formGroup.patchValue({ + name: "Test", + billingEmail: "test@example.com", + }); + + await policyComponent.submit(); + + expect(mockOrganizationApiService.create).not.toHaveBeenCalled(); + }); + }); + + describe("provider flow", () => { + beforeEach(() => { + component.providerId = "provider-123"; + }); + + it("should load provider data", async () => { + mockProviderApiService.getProvider.mockResolvedValue({ + id: "provider-123", + name: "Test Provider", + } as any); + + fixture.detectChanges(); + await fixture.whenStable(); + + expect(mockProviderApiService.getProvider).toHaveBeenCalledWith("provider-123"); + expect(component.provider).toBeDefined(); + }); + + it("should default to Teams Annual plan for providers", async () => { + mockProviderApiService.getProvider.mockResolvedValue({} as any); + + fixture.detectChanges(); + await fixture.whenStable(); + + expect(component.plan).toBe(PlanType.TeamsAnnually); + }); + + it("should require clientOwnerEmail for provider flow", async () => { + mockProviderApiService.getProvider.mockResolvedValue({} as any); + + fixture.detectChanges(); + await fixture.whenStable(); + + const clientOwnerEmailControl = component.formGroup.controls.clientOwnerEmail; + clientOwnerEmailControl.setValue(""); + + expect(clientOwnerEmailControl.hasError("required")).toBe(true); + }); + + it("should set businessOwned to true for provider flow", async () => { + mockProviderApiService.getProvider.mockResolvedValue({} as any); + + fixture.detectChanges(); + await fixture.whenStable(); + + expect(component.formGroup.controls.businessOwned.value).toBe(true); + }); + }); + + describe("self-hosted flow", () => { + beforeEach(async () => { + mockPlatformUtilsService.isSelfHost.mockReturnValue(true); + }); + + it("should render organization self-hosted license and not load plans", async () => { + mockPlatformUtilsService.isSelfHost.mockReturnValue(true); + const selfHostedFixture = TestBed.createComponent(OrganizationPlansComponent); + const selfHostedComponent = selfHostedFixture.componentInstance; + + expect(selfHostedComponent.selfHosted).toBe(true); + expect(mockApiService.getPlans).not.toHaveBeenCalled(); + }); + + it("should handle license file upload success", async () => { + const successSpy = jest.spyOn(component.onSuccess, "emit"); + + await component["onLicenseFileUploaded"]("uploaded-org-id"); + + expect(mockToastService.showToast).toHaveBeenCalledWith({ + variant: "success", + title: "organizationCreated", + message: "organizationReadyToGo", + }); + + expect(successSpy).toHaveBeenCalledWith({ + organizationId: "uploaded-org-id", + }); + + expect(mockMessagingService.send).toHaveBeenCalledWith("organizationCreated", { + organizationId: "uploaded-org-id", + }); + }); + + it("should navigate after license upload if not in trial or sponsorship flow", async () => { + component.acceptingSponsorship = false; + component["isInTrialFlow"] = false; + + await component["onLicenseFileUploaded"]("uploaded-org-id"); + + expect(mockRouter.navigate).toHaveBeenCalledWith(["/organizations/uploaded-org-id"]); + }); + + it("should not navigate after license upload if accepting sponsorship", async () => { + component.acceptingSponsorship = true; + + await component["onLicenseFileUploaded"]("uploaded-org-id"); + + expect(mockRouter.navigate).not.toHaveBeenCalled(); + }); + + it("should emit trial success after license upload in trial flow", async () => { + component["isInTrialFlow"] = true; + + fixture.detectChanges(); + await fixture.whenStable(); + + const trialSpy = jest.spyOn(component.onTrialBillingSuccess, "emit"); + + await component["onLicenseFileUploaded"]("uploaded-org-id"); + + expect(trialSpy).toHaveBeenCalled(); + }); + }); + + describe("policy enforcement", () => { + it("should check single org policy", async () => { + mockPolicyService.policyAppliesToUser$.mockReturnValue(of(true)); + + fixture.detectChanges(); + await fixture.whenStable(); + + expect(component.singleOrgPolicyAppliesToActiveUser).toBe(true); + }); + + it("should not block provider flow with single org policy", async () => { + mockPolicyService.policyAppliesToUser$.mockReturnValue(of(true)); + component.providerId = "provider-123"; + mockProviderApiService.getProvider.mockResolvedValue({} as any); + + fixture.detectChanges(); + await fixture.whenStable(); + + expect(component.singleOrgPolicyBlock).toBe(false); + }); + }); + + describe("business ownership change flow", () => { + beforeEach(async () => { + fixture.detectChanges(); + await fixture.whenStable(); + }); + + it("should automatically upgrade to business-compatible plan when marking as business-owned", () => { + // Start with a personal plan + component.formGroup.controls.businessOwned.setValue(false); + component.productTier = ProductTierType.Families; + component.plan = PlanType.FamiliesAnnually; + + // Mark as business-owned + component.formGroup.controls.businessOwned.setValue(true); + component.changedOwnedBusiness(); + + // Should automatically switch to Teams (lowest business plan) + expect(component.formGroup.controls.productTier.value).toBe(ProductTierType.Teams); + expect(component.formGroup.controls.plan.value).toBe(PlanType.TeamsAnnually); + + // Unchecking businessOwned should not force a downgrade + component.formGroup.controls.businessOwned.setValue(false); + component.changedOwnedBusiness(); + + expect(component.formGroup.controls.productTier.value).toBe(ProductTierType.Teams); + }); + }); + + describe("business organization plan selection flow", () => { + beforeEach(async () => { + fixture.detectChanges(); + await fixture.whenStable(); + }); + + it("should restrict available plans based on business ownership and upgrade context", () => { + // Upgrade flow (showFree = false) should exclude Free plan + component.showFree = false; + let products = component.selectableProducts; + expect(products.find((p) => p.type === PlanType.Free)).toBeUndefined(); + + // Create flow (showFree = true) should include Free plan + component.showFree = true; + products = component.selectableProducts; + expect(products.find((p) => p.type === PlanType.Free)).toBeDefined(); + + // Business organizations should only see business-compatible plans + component.formGroup.controls.businessOwned.setValue(true); + products = component.selectableProducts; + const nonFreeBusinessPlans = products.filter((p) => p.type !== PlanType.Free); + nonFreeBusinessPlans.forEach((plan) => { + expect(plan.canBeUsedByBusiness).toBe(true); + }); + }); + }); + + describe("accepting sponsorship flow", () => { + beforeEach(() => { + component.acceptingSponsorship = true; + }); + + it("should configure Families plan with full discount when accepting sponsorship", async () => { + fixture.detectChanges(); + await fixture.whenStable(); + + // Only Families plan should be available + const products = component.selectableProducts; + expect(products.length).toBe(1); + expect(products[0].productTier).toBe(ProductTierType.Families); + + // Full discount should be applied making the base price free + component.productTier = ProductTierType.Families; + component.plan = PlanType.FamiliesAnnually; + + const subtotal = component.passwordManagerSubtotal; + expect(subtotal).toBe(0); // Discount covers the full base price + expect(component.discount).toBe(products[0].PasswordManager.basePrice); + }); + }); + + describe("upgrade flow", () => { + it("should successfully upgrade organization", async () => { + setupMockUpgradeOrganization(mockOrganizationApiService, organizationsSubject, { + maxStorageGb: 2, + }); + + const upgradeFixture = TestBed.createComponent(OrganizationPlansComponent); + const upgradeComponent = upgradeFixture.componentInstance; + upgradeComponent.organizationId = "org-123"; + upgradeComponent.currentPlan = mockPasswordManagerPlans[0]; // Free plan + + upgradeFixture.detectChanges(); + await upgradeFixture.whenStable(); + + upgradeComponent.productTier = ProductTierType.Teams; + upgradeComponent.plan = PlanType.TeamsAnnually; + upgradeComponent.formGroup.controls.additionalSeats.setValue(5); + + mockOrganizationApiService.upgrade.mockResolvedValue(undefined); + + await upgradeComponent.submit(); + + expect(mockOrganizationApiService.upgrade).toHaveBeenCalledWith( + "org-123", + expect.objectContaining({ + planType: PlanType.TeamsAnnually, + additionalSeats: 5, + }), + ); + + expect(mockToastService.showToast).toHaveBeenCalledWith({ + variant: "success", + title: null, + message: "organizationUpgraded", + }); + }); + + it("should handle upgrade requiring payment method", async () => { + setupMockUpgradeOrganization(mockOrganizationApiService, organizationsSubject, { + hasPaymentSource: false, + maxStorageGb: 2, + }); + + const upgradeFixture = TestBed.createComponent(OrganizationPlansComponent); + const upgradeComponent = upgradeFixture.componentInstance; + upgradeComponent.organizationId = "org-123"; + upgradeComponent.showFree = false; // Required for upgradeRequiresPaymentMethod + + upgradeFixture.detectChanges(); + await upgradeFixture.whenStable(); + + expect(upgradeComponent.upgradeRequiresPaymentMethod).toBe(true); + }); + }); + + describe("billing form display flow", () => { + beforeEach(async () => { + fixture.detectChanges(); + await fixture.whenStable(); + }); + + it("should show appropriate billing fields based on plan type", () => { + // Personal plans (Free, Families) should not require tax ID + component.productTier = ProductTierType.Free; + expect(component["showTaxIdField"]).toBe(false); + + component.productTier = ProductTierType.Families; + expect(component["showTaxIdField"]).toBe(false); + + // Business plans (Teams, Enterprise) should show tax ID field + component.productTier = ProductTierType.Teams; + expect(component["showTaxIdField"]).toBe(true); + + component.productTier = ProductTierType.Enterprise; + expect(component["showTaxIdField"]).toBe(true); + }); + }); + + describe("secrets manager handling flow", () => { + beforeEach(async () => { + fixture.detectChanges(); + await fixture.whenStable(); + }); + + it("should prefill SM seats from existing subscription", async () => { + mockOrganization = { + id: "org-123", + name: "Test Org", + productTierType: ProductTierType.Teams, + useSecretsManager: true, + } as Organization; + + organizationsSubject.next([mockOrganization]); + + mockOrganizationApiService.getBilling.mockResolvedValue({ + paymentSource: { type: "card" }, + } as any); + + mockOrganizationApiService.getSubscription.mockResolvedValue({ + planType: PlanType.TeamsAnnually, + smSeats: 5, + smServiceAccounts: 75, + } as any); + + const upgradeFixture = TestBed.createComponent(OrganizationPlansComponent); + const upgradeComponent = upgradeFixture.componentInstance; + upgradeComponent.organizationId = "org-123"; + upgradeComponent.currentPlan = mockPasswordManagerPlans[2]; // Teams plan + + upgradeFixture.detectChanges(); + await upgradeFixture.whenStable(); + + upgradeComponent.changedProduct(); + + expect(upgradeComponent.secretsManagerForm.controls.enabled.value).toBe(true); + expect(upgradeComponent.secretsManagerForm.controls.userSeats.value).toBe(5); + expect(upgradeComponent.secretsManagerForm.controls.additionalServiceAccounts.value).toBe(25); + }); + + it("should enable SM by default when enableSecretsManagerByDefault is true", async () => { + const smFixture = TestBed.createComponent(OrganizationPlansComponent); + const smComponent = smFixture.componentInstance; + smComponent.enableSecretsManagerByDefault = true; + smComponent.productTier = ProductTierType.Teams; + + smFixture.detectChanges(); + await smFixture.whenStable(); + + expect(smComponent.secretsManagerForm.value.enabled).toBe(true); + expect(smComponent.secretsManagerForm.value.userSeats).toBe(1); + expect(smComponent.secretsManagerForm.value.additionalServiceAccounts).toBe(0); + }); + + it("should trigger tax recalculation when SM form changes", fakeAsync(() => { + component.productTier = ProductTierType.Teams; + component.changedProduct(); + component.billingFormGroup.controls.billingAddress.patchValue({ + country: "US", + postalCode: "90210", + }); + + // Clear previous calls + jest.clearAllMocks(); + + // Change SM form + component.secretsManagerForm.patchValue({ + enabled: true, + userSeats: 3, + }); + + tick(1500); // Wait for debounce + + expect( + mockPreviewInvoiceClient.previewTaxForOrganizationSubscriptionPurchase, + ).toHaveBeenCalled(); + })); + }); + + describe("form update helpers flow", () => { + beforeEach(async () => { + fixture.detectChanges(); + await fixture.whenStable(); + }); + + it("should handle premium addon access based on plan features", () => { + // Plan without premium access option should set addon to true (meaning it's included) + component.productTier = ProductTierType.Families; + component.changedProduct(); + + expect(component.formGroup.controls.premiumAccessAddon.value).toBe(true); + + // Plan with premium access option should set addon to false (user can opt-in) + component.productTier = ProductTierType.Teams; + component.changedProduct(); + + expect(component.formGroup.controls.premiumAccessAddon.value).toBe(false); + }); + + it("should handle additional storage for upgrade with existing data", async () => { + mockOrganization = { + id: "org-123", + name: "Test Org", + productTierType: ProductTierType.Free, + maxStorageGb: 5, + } as Organization; + + organizationsSubject.next([mockOrganization]); + + mockOrganizationApiService.getBilling.mockResolvedValue({ + paymentSource: { type: "card" }, + } as any); + + mockOrganizationApiService.getSubscription.mockResolvedValue({ + planType: PlanType.Free, + } as any); + + const upgradeFixture = TestBed.createComponent(OrganizationPlansComponent); + const upgradeComponent = upgradeFixture.componentInstance; + upgradeComponent.organizationId = "org-123"; + upgradeComponent.currentPlan = mockPasswordManagerPlans[0]; // Free plan with 0 GB base + + upgradeFixture.detectChanges(); + await upgradeFixture.whenStable(); + + upgradeComponent.productTier = ProductTierType.Teams; + upgradeComponent.changedProduct(); + + expect(upgradeComponent.formGroup.controls.additionalStorage.value).toBe(5); + }); + + it("should reset additional storage when plan doesn't support it", () => { + component.formGroup.controls.additionalStorage.setValue(10); + component.productTier = ProductTierType.Free; + component.changedProduct(); + + expect(component.formGroup.controls.additionalStorage.value).toBe(0); + }); + + it("should handle additional seats for various scenarios", () => { + // Plan without additional seats option should reset to 0 + component.formGroup.controls.additionalSeats.setValue(10); + component.productTier = ProductTierType.Families; + component.changedProduct(); + + expect(component.formGroup.controls.additionalSeats.value).toBe(0); + + // Default to 1 seat for new org with seats option + component.productTier = ProductTierType.Teams; + component.changedProduct(); + + expect(component.formGroup.controls.additionalSeats.value).toBeGreaterThanOrEqual(1); + }); + + it("should prefill seats from current plan when upgrading from non-seats plan", async () => { + mockOrganization = { + id: "org-123", + name: "Test Org", + productTierType: ProductTierType.Free, + seats: 2, + } as Organization; + + organizationsSubject.next([mockOrganization]); + + mockOrganizationApiService.getBilling.mockResolvedValue({ + paymentSource: { type: "card" }, + } as any); + + mockOrganizationApiService.getSubscription.mockResolvedValue({ + planType: PlanType.Free, + } as any); + + const upgradeFixture = TestBed.createComponent(OrganizationPlansComponent); + const upgradeComponent = upgradeFixture.componentInstance; + upgradeComponent.organizationId = "org-123"; + upgradeComponent.currentPlan = mockPasswordManagerPlans[0]; // Free plan (no additional seats) + + upgradeFixture.detectChanges(); + await upgradeFixture.whenStable(); + + upgradeComponent.productTier = ProductTierType.Teams; + upgradeComponent.changedProduct(); + + // Should use base seats from current plan + expect(upgradeComponent.formGroup.controls.additionalSeats.value).toBe(2); + }); + }); + + describe("provider creation flow", () => { + beforeEach(() => { + component.providerId = "provider-123"; + mockProviderApiService.getProvider.mockResolvedValue({ + id: "provider-123", + name: "Test Provider", + } as any); + }); + + it("should create organization through provider with wrapped key", async () => { + fixture.detectChanges(); + await fixture.whenStable(); + + patchOrganizationForm(component, { + name: "Provider Client Org", + billingEmail: "client@example.com", + productTier: ProductTierType.Teams, + plan: PlanType.TeamsAnnually, + additionalSeats: 5, + }); + component.formGroup.patchValue({ + clientOwnerEmail: "owner@client.com", + }); + + patchBillingAddress(component); + + const mockOrgKey = {} as any; + const mockProviderKey = {} as any; + + mockKeyService.makeOrgKey.mockResolvedValue([ + { encryptedString: "mock-key" }, + mockOrgKey, + ] as any); + + mockEncryptService.encryptString.mockResolvedValue({ + encryptedString: "mock-collection", + } as any); + + mockKeyService.makeKeyPair.mockResolvedValue([ + "public-key", + { encryptedString: "private-key" }, + ] as any); + + mockKeyService.providerKeys$.mockReturnValue(of({ "provider-123": mockProviderKey })); + + mockEncryptService.wrapSymmetricKey.mockResolvedValue({ + encryptedString: "wrapped-key", + } as any); + + mockApiService.postProviderCreateOrganization.mockResolvedValue({ + organizationId: "provider-org-id", + } as any); + + setupMockPaymentMethodComponent(component); + + await component.submit(); + + expect(mockApiService.postProviderCreateOrganization).toHaveBeenCalledWith( + "provider-123", + expect.objectContaining({ + clientOwnerEmail: "owner@client.com", + }), + ); + + expect(mockEncryptService.wrapSymmetricKey).toHaveBeenCalledWith(mockOrgKey, mockProviderKey); + }); + }); + + describe("upgrade with missing keys flow", () => { + beforeEach(async () => { + mockOrganization = { + id: "org-123", + name: "Test Org", + productTierType: ProductTierType.Free, + seats: 5, + hasPublicAndPrivateKeys: false, // Missing keys + } as Organization; + + organizationsSubject.next([mockOrganization]); + + mockOrganizationApiService.getBilling.mockResolvedValue({ + paymentSource: { type: "card" }, + } as any); + + mockOrganizationApiService.getSubscription.mockResolvedValue({ + planType: PlanType.Free, + } as any); + + component.organizationId = "org-123"; + + fixture.detectChanges(); + await fixture.whenStable(); + }); + + it("should backfill organization keys during upgrade", async () => { + component.productTier = ProductTierType.Teams; + component.plan = PlanType.TeamsAnnually; + component.formGroup.controls.additionalSeats.setValue(5); + + const mockOrgShareKey = {} as any; + mockKeyService.orgKeys$.mockReturnValue(of({ "org-123": mockOrgShareKey })); + + mockKeyService.makeKeyPair.mockResolvedValue([ + "public-key", + { encryptedString: "private-key" }, + ] as any); + + mockOrganizationApiService.upgrade.mockResolvedValue(undefined); + + await component.submit(); + + expect(mockOrganizationApiService.upgrade).toHaveBeenCalledWith( + "org-123", + expect.objectContaining({ + keys: expect.any(Object), + }), + ); + }); + }); + + describe("trial flow", () => { + beforeEach(async () => { + fixture.detectChanges(); + await fixture.whenStable(); + }); + + it("should emit onTrialBillingSuccess when in trial flow", async () => { + component["isInTrialFlow"] = true; + const trialSpy = jest.spyOn(component.onTrialBillingSuccess, "emit"); + + component.formGroup.patchValue({ + name: "Trial Org", + billingEmail: "trial@example.com", + productTier: ProductTierType.Enterprise, + plan: PlanType.EnterpriseAnnually, + additionalSeats: 10, + }); + + component.billingFormGroup.controls.billingAddress.patchValue({ + country: "US", + postalCode: "12345", + line1: "123 Street", + city: "City", + state: "CA", + }); + + mockKeyService.makeOrgKey.mockResolvedValue([ + { encryptedString: "mock-key" }, + {} as any, + ] as any); + + mockEncryptService.encryptString.mockResolvedValue({ + encryptedString: "mock-collection", + } as any); + + mockKeyService.makeKeyPair.mockResolvedValue([ + "public-key", + { encryptedString: "private-key" }, + ] as any); + + mockOrganizationApiService.create.mockResolvedValue({ + id: "trial-org-id", + } as any); + + component["enterPaymentMethodComponent"] = { + tokenize: jest.fn().mockResolvedValue({ + token: "mock_token", + type: "card", + }), + } as any; + + await component.submit(); + + expect(trialSpy).toHaveBeenCalledWith({ + orgId: "trial-org-id", + subLabelText: expect.stringContaining("annual"), + }); + }); + + it("should not navigate away when in trial flow", async () => { + component["isInTrialFlow"] = true; + + component.formGroup.patchValue({ + name: "Trial Org", + billingEmail: "trial@example.com", + productTier: ProductTierType.Free, + plan: PlanType.Free, + }); + + mockKeyService.makeOrgKey.mockResolvedValue([ + { encryptedString: "mock-key" }, + {} as any, + ] as any); + + mockEncryptService.encryptString.mockResolvedValue({ + encryptedString: "mock-collection", + } as any); + + mockKeyService.makeKeyPair.mockResolvedValue([ + "public-key", + { encryptedString: "private-key" }, + ] as any); + + mockOrganizationApiService.create.mockResolvedValue({ + id: "trial-org-id", + } as any); + + await component.submit(); + + expect(mockRouter.navigate).not.toHaveBeenCalled(); + }); + }); + + describe("upgrade prefill flow", () => { + it("should prefill Families plan for Free tier upgrade", async () => { + mockOrganization = { + id: "org-123", + name: "Test Org", + productTierType: ProductTierType.Free, + } as Organization; + + organizationsSubject.next([mockOrganization]); + + mockOrganizationApiService.getBilling.mockResolvedValue({ + paymentSource: null, + } as any); + + mockOrganizationApiService.getSubscription.mockResolvedValue({ + planType: PlanType.Free, + } as any); + + const upgradeFixture = TestBed.createComponent(OrganizationPlansComponent); + const upgradeComponent = upgradeFixture.componentInstance; + upgradeComponent.organizationId = "org-123"; + upgradeComponent.currentPlan = mockPasswordManagerPlans[0]; // Free + + upgradeFixture.detectChanges(); + await upgradeFixture.whenStable(); + + expect(upgradeComponent.plan).toBe(PlanType.FamiliesAnnually); + expect(upgradeComponent.productTier).toBe(ProductTierType.Families); + }); + + it("should prefill Teams plan for Families tier upgrade when TeamsStarter unavailable", async () => { + mockOrganization = { + id: "org-123", + name: "Test Org", + productTierType: ProductTierType.Families, + } as Organization; + + organizationsSubject.next([mockOrganization]); + + mockOrganizationApiService.getBilling.mockResolvedValue({ + paymentSource: { type: "card" }, + } as any); + + mockOrganizationApiService.getSubscription.mockResolvedValue({ + planType: PlanType.FamiliesAnnually, + } as any); + + const upgradeFixture = TestBed.createComponent(OrganizationPlansComponent); + const upgradeComponent = upgradeFixture.componentInstance; + upgradeComponent.organizationId = "org-123"; + upgradeComponent.currentPlan = mockPasswordManagerPlans[1]; // Families + + upgradeFixture.detectChanges(); + await upgradeFixture.whenStable(); + + expect(upgradeComponent.plan).toBe(PlanType.TeamsAnnually); + expect(upgradeComponent.productTier).toBe(ProductTierType.Teams); + }); + + it("should use upgradeSortOrder for sequential plan upgrades", async () => { + mockOrganization = { + id: "org-123", + name: "Test Org", + productTierType: ProductTierType.Teams, + } as Organization; + + organizationsSubject.next([mockOrganization]); + + mockOrganizationApiService.getBilling.mockResolvedValue({ + paymentSource: { type: "card" }, + } as any); + + mockOrganizationApiService.getSubscription.mockResolvedValue({ + planType: PlanType.TeamsAnnually, + } as any); + + const upgradeFixture = TestBed.createComponent(OrganizationPlansComponent); + const upgradeComponent = upgradeFixture.componentInstance; + upgradeComponent.organizationId = "org-123"; + upgradeComponent.currentPlan = mockPasswordManagerPlans[2]; // Teams + + upgradeFixture.detectChanges(); + await upgradeFixture.whenStable(); + + expect(upgradeComponent.plan).toBe(PlanType.EnterpriseAnnually); + expect(upgradeComponent.productTier).toBe(ProductTierType.Enterprise); + }); + + it("should not prefill for Enterprise tier (no upgrade available)", async () => { + mockOrganization = { + id: "org-123", + name: "Test Org", + productTierType: ProductTierType.Enterprise, + } as Organization; + + organizationsSubject.next([mockOrganization]); + + mockOrganizationApiService.getBilling.mockResolvedValue({ + paymentSource: { type: "card" }, + } as any); + + mockOrganizationApiService.getSubscription.mockResolvedValue({ + planType: PlanType.EnterpriseAnnually, + } as any); + + const upgradeFixture = TestBed.createComponent(OrganizationPlansComponent); + const upgradeComponent = upgradeFixture.componentInstance; + upgradeComponent.organizationId = "org-123"; + upgradeComponent.currentPlan = mockPasswordManagerPlans[3]; // Enterprise + + upgradeFixture.detectChanges(); + await upgradeFixture.whenStable(); + + // Should not change from default Free + expect(upgradeComponent.productTier).toBe(ProductTierType.Free); + }); + }); + + describe("plan filtering logic", () => { + beforeEach(async () => { + fixture.detectChanges(); + await fixture.whenStable(); + }); + + it("should check if provider is qualified for 2020 plans", () => { + component.providerId = "provider-123"; + component["provider"] = { + id: "provider-123", + creationDate: "2023-01-01", // Before cutoff + } as any; + + const isQualified = component["isProviderQualifiedFor2020Plan"](); + + expect(isQualified).toBe(true); + }); + + it("should not qualify provider created after 2020 plan cutoff", () => { + component.providerId = "provider-123"; + component["provider"] = { + id: "provider-123", + creationDate: "2023-12-01", // After cutoff (2023-11-06) + } as any; + + const isQualified = component["isProviderQualifiedFor2020Plan"](); + + expect(isQualified).toBe(false); + }); + + it("should return false if provider has no creation date", () => { + component.providerId = "provider-123"; + component["provider"] = { + id: "provider-123", + creationDate: null, + } as any; + + const isQualified = component["isProviderQualifiedFor2020Plan"](); + + expect(isQualified).toBe(false); + }); + + it("should exclude upgrade-ineligible plans", async () => { + mockOrganization = { + id: "org-123", + name: "Test Org", + productTierType: ProductTierType.Teams, + } as Organization; + + organizationsSubject.next([mockOrganization]); + + mockOrganizationApiService.getBilling.mockResolvedValue({ + paymentSource: { type: "card" }, + } as any); + + mockOrganizationApiService.getSubscription.mockResolvedValue({ + planType: PlanType.TeamsAnnually, + } as any); + + const upgradeFixture = TestBed.createComponent(OrganizationPlansComponent); + const upgradeComponent = upgradeFixture.componentInstance; + upgradeComponent.organizationId = "org-123"; + upgradeComponent.currentPlan = mockPasswordManagerPlans[2]; // Teams + + upgradeFixture.detectChanges(); + await upgradeFixture.whenStable(); + + const products = upgradeComponent.selectableProducts; + + // Should not include plans with lower or equal upgradeSortOrder + expect(products.find((p) => p.type === PlanType.Free)).toBeUndefined(); + expect(products.find((p) => p.type === PlanType.FamiliesAnnually)).toBeUndefined(); + expect(products.find((p) => p.type === PlanType.TeamsAnnually)).toBeUndefined(); + }); + }); + + describe("helper calculation methods", () => { + beforeEach(async () => { + fixture.detectChanges(); + await fixture.whenStable(); + }); + + it("should calculate monthly seat price correctly", () => { + const annualPlan = mockPasswordManagerPlans[2]; // Teams Annual - 48/year + const monthlyPrice = component.seatPriceMonthly(annualPlan); + + expect(monthlyPrice).toBe(4); // 48 / 12 + }); + + it("should calculate monthly storage price correctly", () => { + const annualPlan = mockPasswordManagerPlans[2]; // 4/GB/year + const monthlyPrice = component.additionalStoragePriceMonthly(annualPlan); + + expect(monthlyPrice).toBeCloseTo(0.333, 2); // 4 / 12 + }); + + it("should generate billing sublabel text for annual plan", () => { + component.productTier = ProductTierType.Teams; + component.plan = PlanType.TeamsAnnually; + + const sublabel = component["billingSubLabelText"](); + + expect(sublabel).toContain("annual"); + expect(sublabel).toContain("$48"); // Seat price + expect(sublabel).toContain("yr"); + }); + + it("should generate billing sublabel text for plan with base price", () => { + component.productTier = ProductTierType.Families; + component.plan = PlanType.FamiliesAnnually; + + const sublabel = component["billingSubLabelText"](); + + expect(sublabel).toContain("annual"); + expect(sublabel).toContain("$40"); // Base price + }); + }); + + describe("template rendering and UI visibility", () => { + beforeEach(async () => { + fixture.detectChanges(); + await fixture.whenStable(); + }); + + it("should control form visibility based on loading state", () => { + // Initially not loading after setup + expect(component.loading).toBe(false); + + // When loading + component.loading = true; + expect(component.loading).toBe(true); + + // When not loading + component.loading = false; + expect(component.loading).toBe(false); + }); + + it("should determine createOrganization based on organizationId", () => { + // Create flow - no organizationId + expect(component.createOrganization).toBe(true); + + // Upgrade flow - has organizationId + component.organizationId = "org-123"; + expect(component.createOrganization).toBe(false); + }); + + it("should calculate passwordManagerSubtotal correctly for paid plans", () => { + component.productTier = ProductTierType.Teams; + component.changedProduct(); + component.formGroup.controls.additionalSeats.setValue(5); + + const subtotal = component.passwordManagerSubtotal; + + expect(typeof subtotal).toBe("number"); + expect(subtotal).toBeGreaterThan(0); + }); + + it("should show payment description based on plan type", () => { + component.productTier = ProductTierType.Teams; + component.changedProduct(); + + const paymentDesc = component.paymentDesc; + + expect(typeof paymentDesc).toBe("string"); + expect(paymentDesc.length).toBeGreaterThan(0); + }); + + it("should display tax ID field for business plans", () => { + component.productTier = ProductTierType.Free; + expect(component["showTaxIdField"]).toBe(false); + + component.productTier = ProductTierType.Families; + expect(component["showTaxIdField"]).toBe(false); + + component.productTier = ProductTierType.Teams; + expect(component["showTaxIdField"]).toBe(true); + + component.productTier = ProductTierType.Enterprise; + expect(component["showTaxIdField"]).toBe(true); + }); + + it("should show single org policy block when applicable", () => { + component.singleOrgPolicyAppliesToActiveUser = false; + expect(component.singleOrgPolicyBlock).toBe(false); + + component.singleOrgPolicyAppliesToActiveUser = true; + expect(component.singleOrgPolicyBlock).toBe(true); + + // But not when has provider + component.providerId = "provider-123"; + expect(component.singleOrgPolicyBlock).toBe(false); + }); + + it("should determine upgrade requires payment method correctly", async () => { + // Create flow - no organization + expect(component.upgradeRequiresPaymentMethod).toBe(false); + + // Create new component with organization setup + const mockOrg = setupMockUpgradeOrganization( + mockOrganizationApiService, + organizationsSubject, + { + productTierType: ProductTierType.Free, + hasPaymentSource: false, + }, + ); + + const upgradeFixture = TestBed.createComponent(OrganizationPlansComponent); + const upgradeComponent = upgradeFixture.componentInstance; + upgradeComponent.organizationId = mockOrg.id; + upgradeComponent.showFree = false; + + upgradeFixture.detectChanges(); + await upgradeFixture.whenStable(); + + expect(upgradeComponent.upgradeRequiresPaymentMethod).toBe(true); + }); + }); + + describe("user interactions and form controls", () => { + beforeEach(async () => { + fixture.detectChanges(); + await fixture.whenStable(); + }); + + it("should update component state when product tier changes", () => { + component.productTier = ProductTierType.Free; + + // Simulate changing product tier + component.productTier = ProductTierType.Teams; + component.formGroup.controls.productTier.setValue(ProductTierType.Teams); + component.changedProduct(); + + expect(component.productTier).toBe(ProductTierType.Teams); + expect(component.formGroup.controls.plan.value).toBe(PlanType.TeamsAnnually); + }); + + it("should update plan when changedOwnedBusiness is called", () => { + component.formGroup.controls.businessOwned.setValue(false); + component.productTier = ProductTierType.Families; + + component.formGroup.controls.businessOwned.setValue(true); + component.changedOwnedBusiness(); + + // Should switch to a business-compatible plan + expect(component.formGroup.controls.productTier.value).toBe(ProductTierType.Teams); + }); + + it("should emit onCanceled when cancel is called", () => { + const cancelSpy = jest.spyOn(component.onCanceled, "emit"); + + component["cancel"](); + + expect(cancelSpy).toHaveBeenCalled(); + }); + + it("should update form value when additional seats changes", () => { + component.productTier = ProductTierType.Teams; + component.changedProduct(); + + component.formGroup.controls.additionalSeats.setValue(10); + + expect(component.formGroup.controls.additionalSeats.value).toBe(10); + }); + + it("should update form value when additional storage changes", () => { + component.productTier = ProductTierType.Teams; + component.changedProduct(); + + component.formGroup.controls.additionalStorage.setValue(5); + + expect(component.formGroup.controls.additionalStorage.value).toBe(5); + }); + + it("should mark form as invalid when required fields are empty", () => { + component.formGroup.controls.name.setValue(""); + component.formGroup.controls.billingEmail.setValue(""); + component.formGroup.markAllAsTouched(); + + expect(component.formGroup.invalid).toBe(true); + }); + + it("should mark form as valid when all required fields are filled correctly", () => { + patchOrganizationForm(component, { + name: "Valid Org", + billingEmail: "valid@example.com", + }); + + expect(component.formGroup.valid).toBe(true); + }); + + it("should calculate subtotals based on form values", () => { + component.productTier = ProductTierType.Teams; + component.changedProduct(); + component.formGroup.controls.additionalSeats.setValue(5); + component.formGroup.controls.additionalStorage.setValue(10); + + const subtotal = component.passwordManagerSubtotal; + + // Should include cost of seats and storage + expect(subtotal).toBeGreaterThan(0); + }); + + it("should enable Secrets Manager form when plan supports it", () => { + // Free plan doesn't offer Secrets Manager + component.productTier = ProductTierType.Free; + component.formGroup.controls.productTier.setValue(ProductTierType.Free); + component.changedProduct(); + expect(component.planOffersSecretsManager).toBe(false); + + // Teams plan offers Secrets Manager + component.productTier = ProductTierType.Teams; + component.formGroup.controls.productTier.setValue(ProductTierType.Teams); + component.changedProduct(); + expect(component.planOffersSecretsManager).toBe(true); + expect(component.secretsManagerForm.disabled).toBe(false); + }); + + it("should update Secrets Manager subtotal when values change", () => { + component.productTier = ProductTierType.Teams; + component.changedProduct(); + + component.secretsManagerForm.patchValue({ + enabled: false, + }); + expect(component.secretsManagerSubtotal).toBe(0); + + component.secretsManagerForm.patchValue({ + enabled: true, + userSeats: 3, + additionalServiceAccounts: 10, + }); + + const smSubtotal = component.secretsManagerSubtotal; + expect(smSubtotal).toBeGreaterThan(0); + }); + }); + + describe("payment method and billing flow", () => { + beforeEach(async () => { + fixture.detectChanges(); + await fixture.whenStable(); + }); + + it("should update payment method during upgrade when required", async () => { + mockOrganization = { + id: "org-123", + name: "Test Org", + productTierType: ProductTierType.Free, + seats: 5, + hasPublicAndPrivateKeys: true, + } as Organization; + + organizationsSubject.next([mockOrganization]); + + mockOrganizationApiService.getBilling.mockResolvedValue({ + paymentSource: null, // No existing payment source + } as any); + + mockOrganizationApiService.getSubscription.mockResolvedValue({ + planType: PlanType.Free, + } as any); + + const upgradeFixture = TestBed.createComponent(OrganizationPlansComponent); + const upgradeComponent = upgradeFixture.componentInstance; + upgradeComponent.organizationId = "org-123"; + upgradeComponent.showFree = false; // Triggers upgradeRequiresPaymentMethod + + upgradeFixture.detectChanges(); + await upgradeFixture.whenStable(); + + upgradeComponent.productTier = ProductTierType.Teams; + upgradeComponent.plan = PlanType.TeamsAnnually; + upgradeComponent.formGroup.controls.additionalSeats.setValue(5); + + upgradeComponent.billingFormGroup.controls.billingAddress.patchValue({ + country: "US", + postalCode: "12345", + line1: "123 Street", + city: "City", + state: "CA", + }); + + upgradeComponent["enterPaymentMethodComponent"] = { + tokenize: jest.fn().mockResolvedValue({ + token: "new_token", + type: "card", + }), + } as any; + + mockOrganizationApiService.upgrade.mockResolvedValue(undefined); + + await upgradeComponent.submit(); + + expect(mockSubscriberBillingClient.updatePaymentMethod).toHaveBeenCalledWith( + { type: "organization", data: mockOrganization }, + { token: "new_token", type: "card" }, + { country: "US", postalCode: "12345" }, + ); + + expect(mockOrganizationApiService.upgrade).toHaveBeenCalled(); + }); + + it("should validate billing form for paid plans during creation", async () => { + component.formGroup.patchValue({ + name: "New Org", + billingEmail: "test@example.com", + productTier: ProductTierType.Teams, + plan: PlanType.TeamsAnnually, + additionalSeats: 5, + }); + + // Invalid billing form - explicitly mark as invalid since we removed validators from mock forms + component.billingFormGroup.controls.billingAddress.patchValue({ + country: "", + postalCode: "", + }); + + await component.submit(); + + expect(mockOrganizationApiService.create).not.toHaveBeenCalled(); + expect(component.billingFormGroup.invalid).toBe(true); + }); + + it("should not require billing validation for Free plan", async () => { + component.formGroup.patchValue({ + name: "Free Org", + billingEmail: "test@example.com", + productTier: ProductTierType.Free, + plan: PlanType.Free, + }); + + // Leave billing form empty + component.billingFormGroup.reset(); + + mockKeyService.makeOrgKey.mockResolvedValue([ + { encryptedString: "mock-key" }, + {} as any, + ] as any); + + mockEncryptService.encryptString.mockResolvedValue({ + encryptedString: "mock-collection", + } as any); + + mockKeyService.makeKeyPair.mockResolvedValue([ + "public-key", + { encryptedString: "private-key" }, + ] as any); + + mockOrganizationApiService.create.mockResolvedValue({ + id: "free-org-id", + } as any); + + await component.submit(); + + expect(mockOrganizationApiService.create).toHaveBeenCalled(); + }); + }); +}); 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 3364ce2cbea..73fea30fa83 100644 --- a/apps/web/src/app/billing/organizations/organization-plans.component.ts +++ b/apps/web/src/app/billing/organizations/organization-plans.component.ts @@ -113,8 +113,6 @@ export class OrganizationPlansComponent implements OnInit, OnDestroy { // eslint-disable-next-line @angular-eslint/prefer-signals @Input() currentPlan: PlanResponse; - selectedFile: File; - // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals // eslint-disable-next-line @angular-eslint/prefer-signals @Input() @@ -675,9 +673,7 @@ export class OrganizationPlansComponent implements OnInit, OnDestroy { const collectionCt = collection.encryptedString; const orgKeys = await this.keyService.makeKeyPair(orgKey[1]); - orgId = this.selfHosted - ? await this.createSelfHosted(key, collectionCt, orgKeys) - : await this.createCloudHosted(key, collectionCt, orgKeys, orgKey[1], activeUserId); + orgId = await this.createCloudHosted(key, collectionCt, orgKeys, orgKey[1], activeUserId); this.toastService.showToast({ variant: "success", @@ -953,27 +949,6 @@ export class OrganizationPlansComponent implements OnInit, OnDestroy { } } - private async createSelfHosted(key: string, collectionCt: string, orgKeys: [string, EncString]) { - if (!this.selectedFile) { - throw new Error(this.i18nService.t("selectFile")); - } - - const fd = new FormData(); - fd.append("license", this.selectedFile); - fd.append("key", key); - fd.append("collectionName", collectionCt); - const response = await this.organizationApiService.createLicense(fd); - const orgId = response.id; - - await this.apiService.refreshIdentityToken(); - - // Org Keys live outside of the OrganizationLicense - add the keys to the org here - const request = new OrganizationKeysRequest(orgKeys[0], orgKeys[1].encryptedString); - await this.organizationApiService.updateKeys(orgId, request); - - return orgId; - } - private billingSubLabelText(): string { const selectedPlan = this.selectedPlan; const price = diff --git a/apps/web/src/app/core/core.module.ts b/apps/web/src/app/core/core.module.ts index d21b5039d2a..b3afb8ca984 100644 --- a/apps/web/src/app/core/core.module.ts +++ b/apps/web/src/app/core/core.module.ts @@ -44,16 +44,9 @@ import { InternalUserDecryptionOptionsServiceAbstraction, LoginEmailService, } from "@bitwarden/auth/common"; -import { - AutomaticUserConfirmationService, - DefaultAutomaticUserConfirmationService, -} from "@bitwarden/auto-confirm"; import { ApiService } from "@bitwarden/common/abstractions/api.service"; import { OrganizationApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/organization/organization-api.service.abstraction"; -import { - InternalOrganizationServiceAbstraction, - OrganizationService, -} from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; +import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; import { PolicyApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/policy/policy-api.service.abstraction"; import { InternalPolicyService, @@ -373,19 +366,6 @@ const safeProviders: SafeProvider[] = [ I18nServiceAbstraction, ], }), - safeProvider({ - provide: AutomaticUserConfirmationService, - useClass: DefaultAutomaticUserConfirmationService, - deps: [ - ConfigService, - ApiService, - OrganizationUserService, - StateProvider, - InternalOrganizationServiceAbstraction, - OrganizationUserApiService, - PolicyService, - ], - }), safeProvider({ provide: SdkLoadService, useClass: flagEnabled("sdk") ? WebSdkLoadService : NoopSdkLoadService, diff --git a/apps/web/src/app/dirt/reports/pages/exposed-passwords-report.component.html b/apps/web/src/app/dirt/reports/pages/exposed-passwords-report.component.html index 144396d6772..56316fcddee 100644 --- a/apps/web/src/app/dirt/reports/pages/exposed-passwords-report.component.html +++ b/apps/web/src/app/dirt/reports/pages/exposed-passwords-report.component.html @@ -43,16 +43,16 @@ > } } - + - + {{ "name" | i18n }} @if (!isAdminConsoleActive) { - + {{ "owner" | i18n }} } - + {{ "timesExposed" | i18n }} @@ -60,7 +60,7 @@ - + @if (!organization || canManageCipher(row)) { } @else { - {{ row.name }} + {{ row.name }} } @if (!organization && row.organizationId) { { expect(component).toBeTruthy(); }); - it('should get only ciphers with exposed passwords that the user has "Can Edit" access to', async () => { - const expectedIdOne: any = "cbea34a8-bde4-46ad-9d19-b05001228ab2"; - const expectedIdTwo = "cbea34a8-bde4-46ad-9d19-b05001228cd3"; - + it("should get ciphers with exposed passwords regardless of edit access", async () => { jest.spyOn(auditService, "passwordLeaked").mockReturnValue(Promise.resolve(1234)); jest.spyOn(component as any, "getAllCiphers").mockReturnValue(Promise.resolve(cipherData)); await component.setCiphers(); - expect(component.ciphers.length).toEqual(2); - expect(component.ciphers[0].id).toEqual(expectedIdOne); - expect(component.ciphers[0].edit).toEqual(true); - expect(component.ciphers[1].id).toEqual(expectedIdTwo); - expect(component.ciphers[1].edit).toEqual(true); + const cipherIds = component.ciphers.map((c) => c.id); + expect(cipherIds).toContain("cbea34a8-bde4-46ad-9d19-b05001228ab1"); + expect(cipherIds).toContain("cbea34a8-bde4-46ad-9d19-b05001228ab2"); + expect(cipherIds).toContain("cbea34a8-bde4-46ad-9d19-b05001228cd3"); + expect(component.ciphers.length).toEqual(3); }); it("should call fullSync method of syncService", () => { diff --git a/apps/web/src/app/dirt/reports/pages/exposed-passwords-report.component.ts b/apps/web/src/app/dirt/reports/pages/exposed-passwords-report.component.ts index 51bdde3eda8..e39ef811d66 100644 --- a/apps/web/src/app/dirt/reports/pages/exposed-passwords-report.component.ts +++ b/apps/web/src/app/dirt/reports/pages/exposed-passwords-report.component.ts @@ -64,14 +64,12 @@ export class ExposedPasswordsReportComponent extends CipherReportComponent imple this.filterStatus = [0]; allCiphers.forEach((ciph) => { - const { type, login, isDeleted, edit, viewPassword } = ciph; + const { type, login, isDeleted } = ciph; if ( type !== CipherType.Login || login.password == null || login.password === "" || - isDeleted || - (!this.organization && !edit) || - !viewPassword + isDeleted ) { return; } diff --git a/apps/web/src/app/dirt/reports/pages/inactive-two-factor-report.component.html b/apps/web/src/app/dirt/reports/pages/inactive-two-factor-report.component.html index 83c7e566619..b9512df8e3c 100644 --- a/apps/web/src/app/dirt/reports/pages/inactive-two-factor-report.component.html +++ b/apps/web/src/app/dirt/reports/pages/inactive-two-factor-report.component.html @@ -45,20 +45,20 @@ > } } - - @if (!isAdminConsoleActive) { - - - {{ "name" | i18n }} - {{ "owner" | i18n }} - - - } + + + + {{ "name" | i18n }} + @if (!isAdminConsoleActive) { + {{ "owner" | i18n }} + } + + - + @if (!organization || canManageCipher(row)) { {{ row.name }} } @else { - {{ row.name }} + {{ row.name }} } @if (!organization && row.organizationId) { {{ row.subTitle }} - - @if (!organization) { - - } - + @if (!isAdminConsoleActive) { + + @if (!organization) { + + } + + } @if (cipherDocs.has(row.id)) { diff --git a/apps/web/src/app/dirt/reports/pages/inactive-two-factor-report.component.spec.ts b/apps/web/src/app/dirt/reports/pages/inactive-two-factor-report.component.spec.ts index 12453ea3b88..07a772755f5 100644 --- a/apps/web/src/app/dirt/reports/pages/inactive-two-factor-report.component.spec.ts +++ b/apps/web/src/app/dirt/reports/pages/inactive-two-factor-report.component.spec.ts @@ -95,9 +95,7 @@ describe("InactiveTwoFactorReportComponent", () => { expect(component).toBeTruthy(); }); - it('should get only ciphers with domains in the 2fa directory that they have "Can Edit" access to', async () => { - const expectedIdOne: any = "cbea34a8-bde4-46ad-9d19-b05001228xy4"; - const expectedIdTwo: any = "cbea34a8-bde4-46ad-9d19-b05001227nm5"; + it("should get ciphers with domains in the 2fa directory regardless of edit access", async () => { component.services.set( "101domain.com", "https://help.101domain.com/account-management/account-security/enabling-disabling-two-factor-verification", @@ -110,11 +108,10 @@ describe("InactiveTwoFactorReportComponent", () => { jest.spyOn(component as any, "getAllCiphers").mockReturnValue(Promise.resolve(cipherData)); await component.setCiphers(); + const cipherIds = component.ciphers.map((c) => c.id); + expect(cipherIds).toContain("cbea34a8-bde4-46ad-9d19-b05001228xy4"); + expect(cipherIds).toContain("cbea34a8-bde4-46ad-9d19-b05001227nm5"); expect(component.ciphers.length).toEqual(2); - expect(component.ciphers[0].id).toEqual(expectedIdOne); - expect(component.ciphers[0].edit).toEqual(true); - expect(component.ciphers[1].id).toEqual(expectedIdTwo); - expect(component.ciphers[1].edit).toEqual(true); }); it("should call fullSync method of syncService", () => { @@ -197,7 +194,7 @@ describe("InactiveTwoFactorReportComponent", () => { expect(doc).toBe(""); }); - it("should return false if cipher does not have edit access and no organization", () => { + it("should return true for cipher without edit access", () => { component.organization = null; const cipher = createCipherView({ edit: false, @@ -206,11 +203,11 @@ describe("InactiveTwoFactorReportComponent", () => { }, }); const [doc, isInactive] = (component as any).isInactive2faCipher(cipher); - expect(isInactive).toBe(false); - expect(doc).toBe(""); + expect(isInactive).toBe(true); + expect(doc).toBe("https://example.com/2fa-doc"); }); - it("should return false if cipher does not have viewPassword", () => { + it("should return true for cipher without viewPassword", () => { const cipher = createCipherView({ viewPassword: false, login: { @@ -218,8 +215,8 @@ describe("InactiveTwoFactorReportComponent", () => { }, }); const [doc, isInactive] = (component as any).isInactive2faCipher(cipher); - expect(isInactive).toBe(false); - expect(doc).toBe(""); + expect(isInactive).toBe(true); + expect(doc).toBe("https://example.com/2fa-doc"); }); it("should check all uris and return true if any matches domain or host", () => { diff --git a/apps/web/src/app/dirt/reports/pages/inactive-two-factor-report.component.ts b/apps/web/src/app/dirt/reports/pages/inactive-two-factor-report.component.ts index 9d7de688f3e..cd892130518 100644 --- a/apps/web/src/app/dirt/reports/pages/inactive-two-factor-report.component.ts +++ b/apps/web/src/app/dirt/reports/pages/inactive-two-factor-report.component.ts @@ -92,14 +92,12 @@ export class InactiveTwoFactorReportComponent extends CipherReportComponent impl let docFor2fa: string = ""; let isInactive2faCipher: boolean = false; - const { type, login, isDeleted, edit, viewPassword } = cipher; + const { type, login, isDeleted } = cipher; if ( type !== CipherType.Login || (login.totp != null && login.totp !== "") || !login.hasUris || - isDeleted || - (!this.organization && !edit) || - !viewPassword + isDeleted ) { return [docFor2fa, isInactive2faCipher]; } diff --git a/apps/web/src/app/dirt/reports/pages/reused-passwords-report.component.html b/apps/web/src/app/dirt/reports/pages/reused-passwords-report.component.html index f08af8bda01..66bd11e7bc3 100644 --- a/apps/web/src/app/dirt/reports/pages/reused-passwords-report.component.html +++ b/apps/web/src/app/dirt/reports/pages/reused-passwords-report.component.html @@ -45,20 +45,20 @@ > } } - - @if (!isAdminConsoleActive) { - - - {{ "name" | i18n }} - {{ "owner" | i18n }} - {{ "timesReused" | i18n }} - - } + + + + {{ "name" | i18n }} + @if (!isAdminConsoleActive) { + {{ "owner" | i18n }} + } + {{ "timesReused" | i18n }} + - + @if (!organization || canManageCipher(row)) { {{ row.name }} } @else { - {{ row.name }} + {{ row.name }} } @if (!organization && row.organizationId) { {{ row.subTitle }} - - @if (!organization) { - - - } - + @if (!isAdminConsoleActive) { + + @if (!organization) { + + + } + + } {{ "reusedXTimes" | i18n: passwordUseMap.get(row.login.password) }} diff --git a/apps/web/src/app/dirt/reports/pages/reused-passwords-report.component.spec.ts b/apps/web/src/app/dirt/reports/pages/reused-passwords-report.component.spec.ts index 1b7006d0c68..8f08d06e27b 100644 --- a/apps/web/src/app/dirt/reports/pages/reused-passwords-report.component.spec.ts +++ b/apps/web/src/app/dirt/reports/pages/reused-passwords-report.component.spec.ts @@ -109,17 +109,15 @@ describe("ReusedPasswordsReportComponent", () => { expect(component).toBeTruthy(); }); - it('should get ciphers with reused passwords that the user has "Can Edit" access to', async () => { - const expectedIdOne: any = "cbea34a8-bde4-46ad-9d19-b05001228ab2"; - const expectedIdTwo = "cbea34a8-bde4-46ad-9d19-b05001228cd3"; + it("should get ciphers with reused passwords regardless of edit access", async () => { jest.spyOn(component as any, "getAllCiphers").mockReturnValue(Promise.resolve(cipherData)); await component.setCiphers(); - expect(component.ciphers.length).toEqual(2); - expect(component.ciphers[0].id).toEqual(expectedIdOne); - expect(component.ciphers[0].edit).toEqual(true); - expect(component.ciphers[1].id).toEqual(expectedIdTwo); - expect(component.ciphers[1].edit).toEqual(true); + const cipherIds = component.ciphers.map((c) => c.id); + expect(cipherIds).toContain("cbea34a8-bde4-46ad-9d19-b05001228ab1"); + expect(cipherIds).toContain("cbea34a8-bde4-46ad-9d19-b05001228ab2"); + expect(cipherIds).toContain("cbea34a8-bde4-46ad-9d19-b05001228cd3"); + expect(component.ciphers.length).toEqual(3); }); it("should call fullSync method of syncService", () => { diff --git a/apps/web/src/app/dirt/reports/pages/reused-passwords-report.component.ts b/apps/web/src/app/dirt/reports/pages/reused-passwords-report.component.ts index 0a81b19d4ff..7d24e61f276 100644 --- a/apps/web/src/app/dirt/reports/pages/reused-passwords-report.component.ts +++ b/apps/web/src/app/dirt/reports/pages/reused-passwords-report.component.ts @@ -71,14 +71,12 @@ export class ReusedPasswordsReportComponent extends CipherReportComponent implem this.filterStatus = [0]; ciphers.forEach((ciph) => { - const { type, login, isDeleted, edit, viewPassword } = ciph; + const { type, login, isDeleted } = ciph; if ( type !== CipherType.Login || login.password == null || login.password === "" || - isDeleted || - (!this.organization && !edit) || - !viewPassword + isDeleted ) { return; } diff --git a/apps/web/src/app/dirt/reports/pages/unsecured-websites-report.component.html b/apps/web/src/app/dirt/reports/pages/unsecured-websites-report.component.html index 810c1e384b0..553c3f2f04e 100644 --- a/apps/web/src/app/dirt/reports/pages/unsecured-websites-report.component.html +++ b/apps/web/src/app/dirt/reports/pages/unsecured-websites-report.component.html @@ -45,19 +45,19 @@ > } } - - @if (!isAdminConsoleActive) { - - - {{ "name" | i18n }} - {{ "owner" | i18n }} - - } + + + + {{ "name" | i18n }} + @if (!isAdminConsoleActive) { + {{ "owner" | i18n }} + } + - + @if (!organization || canManageCipher(row)) { {{ row.name }} } @else { - {{ row.name }} + {{ row.name }} } @if (!organization && row.organizationId) { {{ row.subTitle }} - - @if (!organization) { - - - } - + @if (!isAdminConsoleActive) { + + @if (!organization) { + + + } + + } } diff --git a/apps/web/src/app/dirt/reports/pages/unsecured-websites-report.component.spec.ts b/apps/web/src/app/dirt/reports/pages/unsecured-websites-report.component.spec.ts index 2107e0c8df7..f116faf114f 100644 --- a/apps/web/src/app/dirt/reports/pages/unsecured-websites-report.component.spec.ts +++ b/apps/web/src/app/dirt/reports/pages/unsecured-websites-report.component.spec.ts @@ -118,17 +118,14 @@ describe("UnsecuredWebsitesReportComponent", () => { expect(component).toBeTruthy(); }); - it('should get only unsecured ciphers that the user has "Can Edit" access to', async () => { - const expectedIdOne: any = "cbea34a8-bde4-46ad-9d19-b05001228ab2"; - const expectedIdTwo = "cbea34a8-bde4-46ad-9d19-b05001228cd3"; + it("should get unsecured ciphers regardless of edit access", async () => { jest.spyOn(component as any, "getAllCiphers").mockReturnValue(Promise.resolve(cipherData)); await component.setCiphers(); + const cipherIds = component.ciphers.map((c) => c.id); + expect(cipherIds).toContain("cbea34a8-bde4-46ad-9d19-b05001228ab2"); + expect(cipherIds).toContain("cbea34a8-bde4-46ad-9d19-b05001228cd3"); expect(component.ciphers.length).toEqual(2); - expect(component.ciphers[0].id).toEqual(expectedIdOne); - expect(component.ciphers[0].edit).toEqual(true); - expect(component.ciphers[1].id).toEqual(expectedIdTwo); - expect(component.ciphers[1].edit).toEqual(true); }); it("should call fullSync method of syncService", () => { diff --git a/apps/web/src/app/dirt/reports/pages/unsecured-websites-report.component.ts b/apps/web/src/app/dirt/reports/pages/unsecured-websites-report.component.ts index 4a2c0677574..8399395d273 100644 --- a/apps/web/src/app/dirt/reports/pages/unsecured-websites-report.component.ts +++ b/apps/web/src/app/dirt/reports/pages/unsecured-websites-report.component.ts @@ -71,12 +71,7 @@ export class UnsecuredWebsitesReportComponent extends CipherReportComponent impl * @param cipher Current cipher with unsecured uri */ private cipherContainsUnsecured(cipher: CipherView): boolean { - if ( - cipher.type !== CipherType.Login || - !cipher.login.hasUris || - cipher.isDeleted || - (!this.organization && !cipher.edit) - ) { + if (cipher.type !== CipherType.Login || !cipher.login.hasUris || cipher.isDeleted) { return false; } diff --git a/apps/web/src/app/dirt/reports/pages/weak-passwords-report.component.html b/apps/web/src/app/dirt/reports/pages/weak-passwords-report.component.html index 5a187427b5e..fd5b916e661 100644 --- a/apps/web/src/app/dirt/reports/pages/weak-passwords-report.component.html +++ b/apps/web/src/app/dirt/reports/pages/weak-passwords-report.component.html @@ -45,12 +45,12 @@ > } } - + - + {{ "name" | i18n }} @if (!isAdminConsoleActive) { - + {{ "owner" | i18n }} } @@ -62,7 +62,7 @@ - + @if (!organization || canManageCipher(row)) { {{ row.name }} } @else { - {{ row.name }} + {{ row.name }} } @if (!organization && row.organizationId) { { expect(component).toBeTruthy(); }); - it('should get only ciphers with weak passwords that the user has "Can Edit" access to', async () => { - const expectedIdOne: any = "cbea34a8-bde4-46ad-9d19-b05001228ab2"; - const expectedIdTwo = "cbea34a8-bde4-46ad-9d19-b05001228cd3"; - + it("should get ciphers with weak passwords regardless of edit access", async () => { jest.spyOn(passwordStrengthService, "getPasswordStrength").mockReturnValue({ password: "123", score: 0, @@ -125,11 +122,11 @@ describe("WeakPasswordsReportComponent", () => { jest.spyOn(component as any, "getAllCiphers").mockReturnValue(Promise.resolve(cipherData)); await component.setCiphers(); - expect(component.ciphers.length).toEqual(2); - expect(component.ciphers[0].id).toEqual(expectedIdOne); - expect(component.ciphers[0].edit).toEqual(true); - expect(component.ciphers[1].id).toEqual(expectedIdTwo); - expect(component.ciphers[1].edit).toEqual(true); + const cipherIds = component.ciphers.map((c) => c.id); + expect(cipherIds).toContain("cbea34a8-bde4-46ad-9d19-b05001228ab1"); + expect(cipherIds).toContain("cbea34a8-bde4-46ad-9d19-b05001228ab2"); + expect(cipherIds).toContain("cbea34a8-bde4-46ad-9d19-b05001228cd3"); + expect(component.ciphers.length).toEqual(3); }); it("should call fullSync method of syncService", () => { diff --git a/apps/web/src/app/dirt/reports/pages/weak-passwords-report.component.ts b/apps/web/src/app/dirt/reports/pages/weak-passwords-report.component.ts index bb5400346fd..6cde01f2d92 100644 --- a/apps/web/src/app/dirt/reports/pages/weak-passwords-report.component.ts +++ b/apps/web/src/app/dirt/reports/pages/weak-passwords-report.component.ts @@ -103,15 +103,8 @@ export class WeakPasswordsReportComponent extends CipherReportComponent implemen } protected determineWeakPasswordScore(ciph: CipherView): ReportResult | null { - const { type, login, isDeleted, edit, viewPassword } = ciph; - if ( - type !== CipherType.Login || - login.password == null || - login.password === "" || - isDeleted || - (!this.organization && !edit) || - !viewPassword - ) { + const { type, login, isDeleted } = ciph; + if (type !== CipherType.Login || login.password == null || login.password === "" || isDeleted) { return; } diff --git a/apps/web/src/app/shared/components/account-fingerprint/account-fingerprint.component.html b/apps/web/src/app/key-management/account-fingerprint/account-fingerprint.component.html similarity index 100% rename from apps/web/src/app/shared/components/account-fingerprint/account-fingerprint.component.html rename to apps/web/src/app/key-management/account-fingerprint/account-fingerprint.component.html diff --git a/apps/web/src/app/shared/components/account-fingerprint/account-fingerprint.component.ts b/apps/web/src/app/key-management/account-fingerprint/account-fingerprint.component.ts similarity index 96% rename from apps/web/src/app/shared/components/account-fingerprint/account-fingerprint.component.ts rename to apps/web/src/app/key-management/account-fingerprint/account-fingerprint.component.ts index eb84868dca1..ca9042e802e 100644 --- a/apps/web/src/app/shared/components/account-fingerprint/account-fingerprint.component.ts +++ b/apps/web/src/app/key-management/account-fingerprint/account-fingerprint.component.ts @@ -4,7 +4,7 @@ import { Component, Input, OnInit } from "@angular/core"; import { KeyService } from "@bitwarden/key-management"; -import { SharedModule } from "../../shared.module"; +import { SharedModule } from "../../shared/shared.module"; // FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush // eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection diff --git a/apps/web/src/app/tools/send/send-access/send-access-password.component.html b/apps/web/src/app/tools/send/send-access/send-access-password.component.html index deca7ad3d24..53526154773 100644 --- a/apps/web/src/app/tools/send/send-access/send-access-password.component.html +++ b/apps/web/src/app/tools/send/send-access/send-access-password.component.html @@ -1,8 +1,7 @@ -

{{ "sendProtectedPassword" | i18n }}

-

{{ "sendProtectedPasswordDontKnow" | i18n }}

{{ "password" | i18n }} + {{ "sendProtectedPasswordDontKnow" | i18n }}
diff --git a/apps/web/src/app/tools/send/send-access/send-auth.component.ts b/apps/web/src/app/tools/send/send-access/send-auth.component.ts index 8c630ce5315..994bd7f3ee3 100644 --- a/apps/web/src/app/tools/send/send-access/send-auth.component.ts +++ b/apps/web/src/app/tools/send/send-access/send-auth.component.ts @@ -52,6 +52,7 @@ export class SendAuthComponent implements OnInit { authType = AuthType; private expiredAuthAttempts = 0; + private otpSubmitted = false; readonly loading = signal(false); readonly error = signal(false); @@ -104,7 +105,27 @@ export class SendAuthComponent implements OnInit { } catch (e) { if (e instanceof ErrorResponse) { if (e.statusCode === 401) { + if (this.sendAuthType() === AuthType.Password) { + // Password was already required, so this is an invalid password error + const passwordControl = this.sendAccessForm.get("password"); + if (passwordControl) { + passwordControl.setErrors({ + invalidPassword: { message: this.i18nService.t("sendPasswordInvalidAskOwner") }, + }); + passwordControl.markAsTouched(); + } + } + // Set auth type to Password (either first time or refresh) this.sendAuthType.set(AuthType.Password); + } else if (e.statusCode === 400 && this.sendAuthType() === AuthType.Password) { + // Server returns 400 for SendAccessResult.PasswordInvalid + const passwordControl = this.sendAccessForm.get("password"); + if (passwordControl) { + passwordControl.setErrors({ + invalidPassword: { message: this.i18nService.t("sendPasswordInvalidAskOwner") }, + }); + passwordControl.markAsTouched(); + } } else if (e.statusCode === 404) { this.unavailable.set(true); } else { @@ -164,22 +185,29 @@ export class SendAuthComponent implements OnInit { this.updatePageTitle(); } else if (emailAndOtpRequired(response.error)) { this.enterOtp.set(true); + if (this.otpSubmitted) { + this.toastService.showToast({ + variant: "error", + title: this.i18nService.t("errorOccurred"), + message: this.i18nService.t("invalidEmailOrVerificationCode"), + }); + } + this.otpSubmitted = true; this.updatePageTitle(); } else if (otpInvalid(response.error)) { this.toastService.showToast({ variant: "error", title: this.i18nService.t("errorOccurred"), - message: this.i18nService.t("invalidVerificationCode"), + message: this.i18nService.t("invalidEmailOrVerificationCode"), }); } else if (passwordHashB64Required(response.error)) { this.sendAuthType.set(AuthType.Password); this.updatePageTitle(); } else if (passwordHashB64Invalid(response.error)) { - this.toastService.showToast({ - variant: "error", - title: this.i18nService.t("errorOccurred"), - message: this.i18nService.t("invalidSendPassword"), + this.sendAccessForm.controls.password?.setErrors({ + invalidPassword: { message: this.i18nService.t("sendPasswordInvalidAskOwner") }, }); + this.sendAccessForm.controls.password?.markAsTouched(); } else if (sendIdInvalid(response.error)) { this.unavailable.set(true); } else { diff --git a/apps/web/src/app/tools/send/send-access/send-view.component.html b/apps/web/src/app/tools/send/send-access/send-view.component.html index 3536499ddad..ca75f123c7e 100644 --- a/apps/web/src/app/tools/send/send-access/send-view.component.html +++ b/apps/web/src/app/tools/send/send-access/send-view.component.html @@ -9,12 +9,7 @@ @if (loading()) {
- - {{ "loading" | i18n }} +
} @else { @if (unavailable()) { @@ -47,7 +42,11 @@ } } @if (expirationDate()) { -

Expires: {{ expirationDate() | date: "medium" }}

+ @let formattedExpirationTime = expirationDate() | date: "shortTime"; + @let formattedExpirationDate = expirationDate() | date: "mediumDate"; +

+ {{ "sendExpiresOn" | i18n: formattedExpirationTime : formattedExpirationDate }} +

}
} diff --git a/apps/web/src/app/tools/send/send-access/send-view.component.ts b/apps/web/src/app/tools/send/send-access/send-view.component.ts index 923a749db92..2d9766ded6c 100644 --- a/apps/web/src/app/tools/send/send-access/send-view.component.ts +++ b/apps/web/src/app/tools/send/send-access/send-view.component.ts @@ -21,7 +21,11 @@ import { SendAccessResponse } from "@bitwarden/common/tools/send/models/response import { SendAccessView } from "@bitwarden/common/tools/send/models/view/send-access.view"; import { SendApiService } from "@bitwarden/common/tools/send/services/send-api.service.abstraction"; import { SendType } from "@bitwarden/common/tools/send/types/send-type"; -import { AnonLayoutWrapperDataService, ToastService } from "@bitwarden/components"; +import { + AnonLayoutWrapperDataService, + SpinnerComponent, + ToastService, +} from "@bitwarden/components"; import { KeyService } from "@bitwarden/key-management"; import { SharedModule } from "../../../shared"; @@ -32,7 +36,7 @@ import { SendAccessTextComponent } from "./send-access-text.component"; @Component({ selector: "app-send-view", templateUrl: "send-view.component.html", - imports: [SendAccessFileComponent, SendAccessTextComponent, SharedModule], + imports: [SendAccessFileComponent, SendAccessTextComponent, SharedModule, SpinnerComponent], changeDetection: ChangeDetectionStrategy.OnPush, }) export class SendViewComponent implements OnInit { diff --git a/apps/web/src/locales/en/messages.json b/apps/web/src/locales/en/messages.json index e43b266de4b..b257a68052d 100644 --- a/apps/web/src/locales/en/messages.json +++ b/apps/web/src/locales/en/messages.json @@ -4596,29 +4596,26 @@ "generatingYourAccessIntelligence": { "message": "Generating your Access Intelligence..." }, - "fetchingMemberData": { - "message": "Fetching member data..." - }, - "analyzingPasswordHealth": { - "message": "Analyzing password health..." - }, - "calculatingRiskScores": { - "message": "Calculating risk scores..." - }, - "generatingReportData": { - "message": "Generating report data..." - }, - "savingReport": { - "message": "Saving report..." - }, - "compilingInsights": { - "message": "Compiling insights..." - }, "loadingProgress": { "message": "Loading progress" }, - "thisMightTakeFewMinutes": { - "message": "This might take a few minutes." + "reviewingMemberData": { + "message": "Reviewing member data..." + }, + "analyzingPasswords": { + "message": "Analyzing passwords..." + }, + "calculatingRisks": { + "message": "Calculating risks..." + }, + "generatingReports": { + "message": "Generating reports..." + }, + "compilingInsightsProgress": { + "message": "Compiling insights..." + }, + "reportGenerationDone": { + "message": "Done!" }, "riskInsightsRunReport": { "message": "Run report" @@ -7400,6 +7397,9 @@ "invalidVerificationCode": { "message": "Invalid verification code" }, + "invalidEmailOrVerificationCode": { + "message": "Invalid email or verification code" + }, "keyConnectorDomain": { "message": "Key Connector domain" }, @@ -12869,6 +12869,9 @@ "enterMultipleEmailsSeparatedByComma": { "message": "Enter multiple emails by separating with a comma." }, + "emailsRequiredChangeAccessType": { + "message": "Email verification requires at least one email address. To remove all emails, change the access type above." + }, "emailPlaceholder": { "message": "user@bitwarden.com , user@acme.com" }, @@ -12948,5 +12951,23 @@ }, "paymentMethodUpdateError": { "message": "There was an error updating your payment method." + }, + "sendPasswordInvalidAskOwner": { + "message": "Invalid password. Ask the sender for the password needed to access this Send.", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, + "sendExpiresOn": { + "message": "This Send expires at $TIME$ on $DATE$", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated.", + "placeholders": { + "time": { + "content": "$1", + "example": "10:00 AM" + }, + "date": { + "content": "$2", + "example": "Jan 1, 1970" + } + } } } diff --git a/bitwarden_license/bit-web/src/app/admin-console/providers/providers.module.ts b/bitwarden_license/bit-web/src/app/admin-console/providers/providers.module.ts index abdd35c5e61..0a3b78bb014 100644 --- a/bitwarden_license/bit-web/src/app/admin-console/providers/providers.module.ts +++ b/bitwarden_license/bit-web/src/app/admin-console/providers/providers.module.ts @@ -6,6 +6,7 @@ import { FormsModule } from "@angular/forms"; import { JslibModule } from "@bitwarden/angular/jslib.module"; import { CardComponent, ScrollLayoutDirective, SearchModule } from "@bitwarden/components"; import { MemberActionsService } from "@bitwarden/web-vault/app/admin-console/organizations/members/services/member-actions/member-actions.service"; +import { MemberDialogManagerService } from "@bitwarden/web-vault/app/admin-console/organizations/members/services/member-dialog-manager/member-dialog-manager.service"; import { DangerZoneComponent } from "@bitwarden/web-vault/app/auth/settings/account/danger-zone.component"; import { OrganizationPlansComponent } from "@bitwarden/web-vault/app/billing"; import { @@ -83,6 +84,11 @@ import { VerifyRecoverDeleteProviderComponent } from "./verify-recover-delete-pr VerifyRecoverDeleteProviderComponent, SetupBusinessUnitComponent, ], - providers: [WebProviderService, ProviderActionsService, MemberActionsService], + providers: [ + WebProviderService, + ProviderActionsService, + MemberActionsService, + MemberDialogManagerService, + ], }) export class ProvidersModule {} diff --git a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/shared/report-loading.component.html b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/shared/report-loading.component.html index 0b5a63c8f03..c816861b623 100644 --- a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/shared/report-loading.component.html +++ b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/shared/report-loading.component.html @@ -10,14 +10,9 @@ > - -
- - {{ stepConfig[progressStep()].message | i18n }} - - - {{ "thisMightTakeFewMinutes" | i18n }} - -
+ + + {{ stepConfig[progressStep()].message | i18n }} + diff --git a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/shared/report-loading.component.ts b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/shared/report-loading.component.ts index 45b28dae470..9df729b9645 100644 --- a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/shared/report-loading.component.ts +++ b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/shared/report-loading.component.ts @@ -6,12 +6,12 @@ import { ProgressModule } from "@bitwarden/components"; // Map of progress step to display config const ProgressStepConfig = Object.freeze({ - [ReportProgress.FetchingMembers]: { message: "fetchingMemberData", progress: 20 }, - [ReportProgress.AnalyzingPasswords]: { message: "analyzingPasswordHealth", progress: 40 }, - [ReportProgress.CalculatingRisks]: { message: "calculatingRiskScores", progress: 60 }, - [ReportProgress.GeneratingReport]: { message: "generatingReportData", progress: 80 }, - [ReportProgress.Saving]: { message: "savingReport", progress: 95 }, - [ReportProgress.Complete]: { message: "compilingInsights", progress: 100 }, + [ReportProgress.FetchingMembers]: { message: "reviewingMemberData", progress: 20 }, + [ReportProgress.AnalyzingPasswords]: { message: "analyzingPasswords", progress: 40 }, + [ReportProgress.CalculatingRisks]: { message: "calculatingRisks", progress: 60 }, + [ReportProgress.GeneratingReport]: { message: "generatingReports", progress: 80 }, + [ReportProgress.Saving]: { message: "compilingInsightsProgress", progress: 95 }, + [ReportProgress.Complete]: { message: "reportGenerationDone", progress: 100 }, } as const); // FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush diff --git a/libs/angular/src/auth/password-management/set-initial-password/default-set-initial-password.service.implementation.ts b/libs/angular/src/auth/password-management/set-initial-password/default-set-initial-password.service.implementation.ts index d83e40d1d44..317030c25aa 100644 --- a/libs/angular/src/auth/password-management/set-initial-password/default-set-initial-password.service.implementation.ts +++ b/libs/angular/src/auth/password-management/set-initial-password/default-set-initial-password.service.implementation.ts @@ -15,11 +15,13 @@ import { MasterPasswordApiService } from "@bitwarden/common/auth/abstractions/ma import { ForceSetPasswordReason } from "@bitwarden/common/auth/models/domain/force-set-password-reason"; import { SetPasswordRequest } from "@bitwarden/common/auth/models/request/set-password.request"; import { UpdateTdeOffboardingPasswordRequest } from "@bitwarden/common/auth/models/request/update-tde-offboarding-password.request"; +import { assertNonNullish, assertTruthy } from "@bitwarden/common/auth/utils"; import { AccountCryptographicStateService } from "@bitwarden/common/key-management/account-cryptography/account-cryptographic-state.service"; import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service"; import { EncString } from "@bitwarden/common/key-management/crypto/models/enc-string"; import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/key-management/master-password/abstractions/master-password.service.abstraction"; import { + MasterPasswordAuthenticationData, MasterPasswordSalt, MasterPasswordUnlockData, } from "@bitwarden/common/key-management/master-password/types/master-password.types"; @@ -45,6 +47,7 @@ import { SetInitialPasswordService, SetInitialPasswordTdeOffboardingCredentials, SetInitialPasswordUserType, + SetInitialPasswordTdeUserWithPermissionCredentials, } from "./set-initial-password.service.abstraction"; export class DefaultSetInitialPasswordService implements SetInitialPasswordService { @@ -212,7 +215,7 @@ export class DefaultSetInitialPasswordService implements SetInitialPasswordServi await this.masterPasswordService.setMasterKeyHash(newLocalMasterKeyHash, userId); if (resetPasswordAutoEnroll) { - await this.handleResetPasswordAutoEnroll(newServerMasterKeyHash, orgId, userId); + await this.handleResetPasswordAutoEnrollOld(newServerMasterKeyHash, orgId, userId); } } @@ -336,6 +339,86 @@ export class DefaultSetInitialPasswordService implements SetInitialPasswordServi ); } + async setInitialPasswordTdeUserWithPermission( + credentials: SetInitialPasswordTdeUserWithPermissionCredentials, + userId: UserId, + ): Promise { + const ctx = + "Could not set initial password for TDE user with Manage Account Recovery permission."; + + assertTruthy(credentials.newPassword, "newPassword", ctx); + assertTruthy(credentials.salt, "salt", ctx); + assertNonNullish(credentials.kdfConfig, "kdfConfig", ctx); + assertNonNullish(credentials.newPasswordHint, "newPasswordHint", ctx); // can have an empty string as a valid value, so check non-nullish + assertTruthy(credentials.orgSsoIdentifier, "orgSsoIdentifier", ctx); + assertTruthy(credentials.orgId, "orgId", ctx); + assertNonNullish(credentials.resetPasswordAutoEnroll, "resetPasswordAutoEnroll", ctx); // can have `false` as a valid value, so check non-nullish + assertTruthy(userId, "userId", ctx); + + const { + newPassword, + salt, + kdfConfig, + newPasswordHint, + orgSsoIdentifier, + orgId, + resetPasswordAutoEnroll, + } = credentials; + + const userKey = await firstValueFrom(this.keyService.userKey$(userId)); + + if (!userKey) { + throw new Error("userKey not found."); + } + + const authenticationData: MasterPasswordAuthenticationData = + await this.masterPasswordService.makeMasterPasswordAuthenticationData( + newPassword, + kdfConfig, + salt, + ); + + const unlockData: MasterPasswordUnlockData = + await this.masterPasswordService.makeMasterPasswordUnlockData( + newPassword, + kdfConfig, + salt, + userKey, + ); + + const request = SetPasswordRequest.newConstructor( + authenticationData, + unlockData, + newPasswordHint, + orgSsoIdentifier, + null, // no KeysRequest for TDE user because they already have a key pair + ); + + await this.masterPasswordApiService.setPassword(request); + + // Clear force set password reason to allow navigation back to vault. + await this.masterPasswordService.setForceSetPasswordReason(ForceSetPasswordReason.None, userId); + + // User now has a password so update decryption state + await this.masterPasswordService.setMasterPasswordUnlockData(unlockData, userId); + await this.updateLegacyState( + newPassword, + unlockData.kdf, + new EncString(unlockData.masterKeyWrappedUserKey), + userId, + unlockData, + ); + + if (resetPasswordAutoEnroll) { + await this.handleResetPasswordAutoEnroll( + authenticationData.masterPasswordAuthenticationHash, + orgId, + userId, + userKey, + ); + } + } + /** * @deprecated To be removed in PM-28143 */ @@ -441,7 +524,19 @@ export class DefaultSetInitialPasswordService implements SetInitialPasswordServi await this.masterPasswordService.setMasterPasswordUnlockData(masterPasswordUnlockData, userId); } - private async handleResetPasswordAutoEnroll( + /** + * @deprecated To be removed in PM-28143 + * + * This method is now deprecated because it is used with the deprecated `setInitialPassword()` method, + * which handles both JIT MP and TDE + Permission user flows. + * + * Since these methods can handle the JIT MP flow - which creates a new user key and sets it to state - we + * must retreive that user key here in this method. + * + * But the new handleResetPasswordAutoEnroll() method is only used in the TDE + Permission user case, in which + * case we already have the user key and can simply pass it through via method parameter ( @see handleResetPasswordAutoEnroll ) + */ + private async handleResetPasswordAutoEnrollOld( masterKeyHash: string, orgId: string, userId: UserId, @@ -483,4 +578,43 @@ export class DefaultSetInitialPasswordService implements SetInitialPasswordServi enrollmentRequest, ); } + + private async handleResetPasswordAutoEnroll( + masterKeyHash: string, + orgId: string, + userId: UserId, + userKey: UserKey, + ) { + const organizationKeys = await this.organizationApiService.getKeys(orgId); + + if (organizationKeys == null) { + throw new Error( + "Organization keys response is null. Could not handle reset password auto enroll.", + ); + } + + const orgPublicKey = Utils.fromB64ToArray(organizationKeys.publicKey); + + // RSA encrypt user key with organization public key + const orgPublicKeyEncryptedUserKey = await this.encryptService.encapsulateKeyUnsigned( + userKey, + orgPublicKey, + ); + + if (orgPublicKeyEncryptedUserKey == null || !orgPublicKeyEncryptedUserKey.encryptedString) { + throw new Error( + "orgPublicKeyEncryptedUserKey not found. Could not handle reset password auto enroll.", + ); + } + + const enrollmentRequest = new OrganizationUserResetPasswordEnrollmentRequest(); + enrollmentRequest.masterPasswordHash = masterKeyHash; + enrollmentRequest.resetPasswordKey = orgPublicKeyEncryptedUserKey.encryptedString; + + await this.organizationUserApiService.putOrganizationUserResetPasswordEnrollment( + orgId, + userId, + enrollmentRequest, + ); + } } diff --git a/libs/angular/src/auth/password-management/set-initial-password/default-set-initial-password.service.spec.ts b/libs/angular/src/auth/password-management/set-initial-password/default-set-initial-password.service.spec.ts index 8b64e20ce7b..d68bf2c7d01 100644 --- a/libs/angular/src/auth/password-management/set-initial-password/default-set-initial-password.service.spec.ts +++ b/libs/angular/src/auth/password-management/set-initial-password/default-set-initial-password.service.spec.ts @@ -31,6 +31,9 @@ import { } from "@bitwarden/common/key-management/crypto/models/enc-string"; import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/key-management/master-password/abstractions/master-password.service.abstraction"; import { + MasterKeyWrappedUserKey, + MasterPasswordAuthenticationData, + MasterPasswordAuthenticationHash, MasterPasswordSalt, MasterPasswordUnlockData, } from "@bitwarden/common/key-management/master-password/types/master-password.types"; @@ -62,6 +65,7 @@ import { SetInitialPasswordCredentials, SetInitialPasswordService, SetInitialPasswordTdeOffboardingCredentials, + SetInitialPasswordTdeUserWithPermissionCredentials, SetInitialPasswordUserType, } from "./set-initial-password.service.abstraction"; @@ -237,7 +241,7 @@ describe("DefaultSetInitialPasswordService", () => { } } - // Mock handleResetPasswordAutoEnroll() values + // Mock handleResetPasswordAutoEnrollOld() values if (config.resetPasswordAutoEnroll) { organizationApiService.getKeys.mockResolvedValue(organizationKeys); encryptService.encapsulateKeyUnsigned.mockResolvedValue(orgPublicKeyEncryptedUserKey); @@ -1104,4 +1108,285 @@ describe("DefaultSetInitialPasswordService", () => { await expect(promise).rejects.toThrow("Unexpected V2 account cryptographic state"); }); }); + + describe("setInitialPasswordTdeUserWithPermission()", () => { + // Mock method parameters + let credentials: SetInitialPasswordTdeUserWithPermissionCredentials; + + // Mock method data + let authenticationData: MasterPasswordAuthenticationData; + let unlockData: MasterPasswordUnlockData; + let setPasswordRequest: SetPasswordRequest; + let userDecryptionOptions: UserDecryptionOptions; + + beforeEach(() => { + // Mock method parameters + credentials = { + newPassword: "newPassword123!", + salt: "user@example.com" as MasterPasswordSalt, + kdfConfig: DEFAULT_KDF_CONFIG, + newPasswordHint: "newPasswordHint", + orgSsoIdentifier: "orgSsoIdentifier", + orgId: "orgId" as OrganizationId, + resetPasswordAutoEnroll: false, + }; + + // Mock method data + userKey = makeSymmetricCryptoKey(64) as UserKey; + keyService.userKey$.mockReturnValue(of(userKey)); + + authenticationData = { + salt: credentials.salt, + kdf: credentials.kdfConfig, + masterPasswordAuthenticationHash: + "masterPasswordAuthenticationHash" as MasterPasswordAuthenticationHash, + }; + masterPasswordService.makeMasterPasswordAuthenticationData.mockResolvedValue( + authenticationData, + ); + + unlockData = { + salt: credentials.salt, + kdf: credentials.kdfConfig, + masterKeyWrappedUserKey: "masterKeyWrappedUserKey" as MasterKeyWrappedUserKey, + } as MasterPasswordUnlockData; + masterPasswordService.makeMasterPasswordUnlockData.mockResolvedValue(unlockData); + + setPasswordRequest = SetPasswordRequest.newConstructor( + authenticationData, + unlockData, + credentials.newPasswordHint, + credentials.orgSsoIdentifier, + null, // no KeysRequest for TDE user because they already have a key pair + ); + + userDecryptionOptions = new UserDecryptionOptions({ hasMasterPassword: false }); + userDecryptionOptionsService.userDecryptionOptionsById$.mockReturnValue( + of(userDecryptionOptions), + ); + }); + + describe("general error handling", () => { + ["newPassword", "salt", "orgSsoIdentifier", "orgId"].forEach((key) => { + it(`should throw if ${key} is an empty string (falsy) on the SetInitialPasswordTdeUserWithPermissionCredentials object`, async () => { + // Arrange + const invalidCredentials: SetInitialPasswordTdeUserWithPermissionCredentials = { + ...credentials, + [key]: "", + }; + + // Act + const promise = sut.setInitialPasswordTdeUserWithPermission(invalidCredentials, userId); + + // Assert + await expect(promise).rejects.toThrow( + `${key} is falsy. Could not set initial password for TDE user with Manage Account Recovery permission.`, + ); + }); + }); + + ["kdfConfig", "newPasswordHint", "resetPasswordAutoEnroll"].forEach((key) => { + it(`should throw if ${key} is null on the SetInitialPasswordTdeUserWithPermissionCredentials object`, async () => { + // Arrange + const invalidCredentials: SetInitialPasswordTdeUserWithPermissionCredentials = { + ...credentials, + [key]: null, + }; + + // Act + const promise = sut.setInitialPasswordTdeUserWithPermission(invalidCredentials, userId); + + // Assert + await expect(promise).rejects.toThrow( + `${key} is null or undefined. Could not set initial password for TDE user with Manage Account Recovery permission.`, + ); + }); + }); + + it("should throw if userId is not given", async () => { + // Arrange + userId = null; + + // Act + const promise = sut.setInitialPasswordTdeUserWithPermission(credentials, userId); + + // Assert + await expect(promise).rejects.toThrow( + "userId is falsy. Could not set initial password for TDE user with Manage Account Recovery permission.", + ); + }); + }); + + it("should throw if the userKey is not found", async () => { + // Arrange + keyService.userKey$.mockReturnValue(of(null)); + + // Act + const promise = sut.setInitialPasswordTdeUserWithPermission(credentials, userId); + + // Assert + await expect(promise).rejects.toThrow("userKey not found."); + }); + + it("should call makeMasterPasswordAuthenticationData and makeMasterPasswordUnlockData with the correct parameters", async () => { + // Act + await sut.setInitialPasswordTdeUserWithPermission(credentials, userId); + + // Assert + expect(masterPasswordService.makeMasterPasswordAuthenticationData).toHaveBeenCalledWith( + credentials.newPassword, + credentials.kdfConfig, + credentials.salt, + ); + + expect(masterPasswordService.makeMasterPasswordUnlockData).toHaveBeenCalledWith( + credentials.newPassword, + credentials.kdfConfig, + credentials.salt, + userKey, + ); + }); + + it("should call the API method to set a master password", async () => { + // Act + await sut.setInitialPasswordTdeUserWithPermission(credentials, userId); + + // Assert + expect(masterPasswordApiService.setPassword).toHaveBeenCalledTimes(1); + expect(masterPasswordApiService.setPassword).toHaveBeenCalledWith(setPasswordRequest); + }); + + describe("given the initial password has been successfully set", () => { + it("should clear the ForceSetPasswordReason by setting it to None", async () => { + // Act + await sut.setInitialPasswordTdeUserWithPermission(credentials, userId); + + // Assert + expect(masterPasswordService.setForceSetPasswordReason).toHaveBeenCalledWith( + ForceSetPasswordReason.None, + userId, + ); + }); + + it("should set MasterPasswordUnlockData to state", async () => { + // Act + await sut.setInitialPasswordTdeUserWithPermission(credentials, userId); + + // Assert + expect(masterPasswordService.setMasterPasswordUnlockData).toHaveBeenCalledWith( + unlockData, + userId, + ); + }); + + it("should update legacy state", async () => { + // Act + await sut.setInitialPasswordTdeUserWithPermission(credentials, userId); + + // Assert + expect(userDecryptionOptionsService.setUserDecryptionOptionsById).toHaveBeenCalledWith( + userId, + expect.objectContaining({ hasMasterPassword: true }), + ); + expect(kdfConfigService.setKdfConfig).toHaveBeenCalledWith(userId, credentials.kdfConfig); + expect(masterPasswordService.setMasterKeyEncryptedUserKey).toHaveBeenCalledWith( + new EncString(unlockData.masterKeyWrappedUserKey), + userId, + ); + expect(masterPasswordService.setLegacyMasterKeyFromUnlockData).toHaveBeenCalledWith( + credentials.newPassword, + unlockData, + userId, + ); + }); + + describe("given resetPasswordAutoEnroll is false", () => { + it("should NOT handle reset password (account recovery) auto enroll", async () => { + // Act + await sut.setInitialPasswordTdeUserWithPermission(credentials, userId); + + // Assert + expect( + organizationUserApiService.putOrganizationUserResetPasswordEnrollment, + ).not.toHaveBeenCalled(); + }); + }); + + describe("given resetPasswordAutoEnroll is true", () => { + let organizationKeys: OrganizationKeysResponse; + let orgPublicKeyEncryptedUserKey: EncString; + let enrollmentRequest: OrganizationUserResetPasswordEnrollmentRequest; + + beforeEach(() => { + credentials.resetPasswordAutoEnroll = true; + + organizationKeys = { + privateKey: "orgPrivateKey", + publicKey: "orgPublicKey", + } as OrganizationKeysResponse; + organizationApiService.getKeys.mockResolvedValue(organizationKeys); + + orgPublicKeyEncryptedUserKey = new EncString("orgPublicKeyEncryptedUserKey"); + encryptService.encapsulateKeyUnsigned.mockResolvedValue(orgPublicKeyEncryptedUserKey); + + enrollmentRequest = new OrganizationUserResetPasswordEnrollmentRequest(); + enrollmentRequest.masterPasswordHash = + authenticationData.masterPasswordAuthenticationHash; + enrollmentRequest.resetPasswordKey = orgPublicKeyEncryptedUserKey.encryptedString; + }); + + it("should throw if organization keys are not found", async () => { + // Arrange + organizationApiService.getKeys.mockResolvedValue(null); + + // Act + const promise = sut.setInitialPasswordTdeUserWithPermission(credentials, userId); + + // Assert + await expect(promise).rejects.toThrow( + "Organization keys response is null. Could not handle reset password auto enroll.", + ); + }); + + it("should throw if orgPublicKeyEncryptedUserKey is not found", async () => { + // Arrange + encryptService.encapsulateKeyUnsigned.mockResolvedValue(null); + + // Act + const promise = sut.setInitialPasswordTdeUserWithPermission(credentials, userId); + + // Assert + await expect(promise).rejects.toThrow( + "orgPublicKeyEncryptedUserKey not found. Could not handle reset password auto enroll.", + ); + }); + + it("should throw if orgPublicKeyEncryptedUserKey.encryptedString is not found", async () => { + // Arrange + orgPublicKeyEncryptedUserKey.encryptedString = null; + + // Act + const promise = sut.setInitialPasswordTdeUserWithPermission(credentials, userId); + + // Assert + await expect(promise).rejects.toThrow( + "orgPublicKeyEncryptedUserKey not found. Could not handle reset password auto enroll.", + ); + }); + + it("should call the API method to handle reset password (account recovery) auto enroll", async () => { + // Act + await sut.setInitialPasswordTdeUserWithPermission(credentials, userId); + + // Assert + expect( + organizationUserApiService.putOrganizationUserResetPasswordEnrollment, + ).toHaveBeenCalledTimes(1); + expect( + organizationUserApiService.putOrganizationUserResetPasswordEnrollment, + ).toHaveBeenCalledWith(credentials.orgId, userId, enrollmentRequest); + }); + }); + }); + }); }); diff --git a/libs/angular/src/auth/password-management/set-initial-password/set-initial-password.component.ts b/libs/angular/src/auth/password-management/set-initial-password/set-initial-password.component.ts index 7850a980eef..3cafbdb8ff8 100644 --- a/libs/angular/src/auth/password-management/set-initial-password/set-initial-password.component.ts +++ b/libs/angular/src/auth/password-management/set-initial-password/set-initial-password.component.ts @@ -47,6 +47,7 @@ import { SetInitialPasswordCredentials, SetInitialPasswordService, SetInitialPasswordTdeOffboardingCredentials, + SetInitialPasswordTdeUserWithPermissionCredentials, SetInitialPasswordUserType, } from "./set-initial-password.service.abstraction"; @@ -183,7 +184,13 @@ export class SetInitialPasswordComponent implements OnInit { break; } case SetInitialPasswordUserType.TDE_ORG_USER_RESET_PASSWORD_PERMISSION_REQUIRES_MP: + if (passwordInputResult.newApisWithInputPasswordFlagEnabled) { + await this.setInitialPasswordTdeUserWithPermission(passwordInputResult); + return; // EARLY RETURN for flagged logic + } + await this.setInitialPassword(passwordInputResult); + break; case SetInitialPasswordUserType.OFFBOARDED_TDE_ORG_USER: await this.setInitialPasswordTdeOffboarding(passwordInputResult); @@ -382,6 +389,46 @@ export class SetInitialPasswordComponent implements OnInit { } } + private async setInitialPasswordTdeUserWithPermission(passwordInputResult: PasswordInputResult) { + const ctx = + "Could not set initial password for TDE user with Manage Account Recovery permission."; + + assertTruthy(passwordInputResult.newPassword, "newPassword", ctx); + assertTruthy(passwordInputResult.salt, "salt", ctx); + assertNonNullish(passwordInputResult.kdfConfig, "kdfConfig", ctx); + assertNonNullish(passwordInputResult.newPasswordHint, "newPasswordHint", ctx); // can have an empty string as a valid value, so check non-nullish + assertTruthy(this.orgSsoIdentifier, "orgSsoIdentifier", ctx); + assertTruthy(this.orgId, "orgId", ctx); + assertNonNullish(this.resetPasswordAutoEnroll, "resetPasswordAutoEnroll", ctx); // can have `false` as a valid value, so check non-nullish + assertTruthy(this.userId, "userId", ctx); + + try { + const credentials: SetInitialPasswordTdeUserWithPermissionCredentials = { + newPassword: passwordInputResult.newPassword, + salt: passwordInputResult.salt, + kdfConfig: passwordInputResult.kdfConfig, + newPasswordHint: passwordInputResult.newPasswordHint, + orgSsoIdentifier: this.orgSsoIdentifier, + orgId: this.orgId as OrganizationId, + resetPasswordAutoEnroll: this.resetPasswordAutoEnroll, + }; + + await this.setInitialPasswordService.setInitialPasswordTdeUserWithPermission( + credentials, + this.userId, + ); + + this.showSuccessToastByUserType(); + + this.submitting = false; + await this.router.navigate(["vault"]); + } catch (e) { + this.logService.error("Error setting initial password", e); + this.validationService.showError(e); + this.submitting = false; + } + } + private async setInitialPasswordTdeOffboarding(passwordInputResult: PasswordInputResult) { const ctx = "Could not set initial password."; assertTruthy(passwordInputResult.newMasterKey, "newMasterKey", ctx); diff --git a/libs/angular/src/auth/password-management/set-initial-password/set-initial-password.service.abstraction.ts b/libs/angular/src/auth/password-management/set-initial-password/set-initial-password.service.abstraction.ts index 70318be3393..5a68b787e28 100644 --- a/libs/angular/src/auth/password-management/set-initial-password/set-initial-password.service.abstraction.ts +++ b/libs/angular/src/auth/password-management/set-initial-password/set-initial-password.service.abstraction.ts @@ -55,6 +55,16 @@ export interface SetInitialPasswordCredentials { salt: MasterPasswordSalt; } +export interface SetInitialPasswordTdeUserWithPermissionCredentials { + newPassword: string; + salt: MasterPasswordSalt; + kdfConfig: KdfConfig; + newPasswordHint: string; + orgSsoIdentifier: string; + orgId: OrganizationId; + resetPasswordAutoEnroll: boolean; +} + export interface SetInitialPasswordTdeOffboardingCredentials { newMasterKey: MasterKey; newServerMasterKeyHash: string; @@ -103,6 +113,19 @@ export abstract class SetInitialPasswordService { userId: UserId, ) => Promise; + /** + * Sets an initial password for an existing authed TDE user who has been given the + * Manage Account Recovery permission: + * - {@link SetInitialPasswordUserType.TDE_ORG_USER_RESET_PASSWORD_PERMISSION_REQUIRES_MP} + * + * @param credentials An object of the credentials needed to set the initial password + * @throws If any property on the `credentials` object not found, or if userKey is not found + */ + abstract setInitialPasswordTdeUserWithPermission: ( + credentials: SetInitialPasswordTdeUserWithPermissionCredentials, + userId: UserId, + ) => Promise; + /** * Sets an initial password for a user who logs in after their org offboarded from * trusted device encryption and is now a master-password-encryption org: diff --git a/libs/angular/src/services/jslib-services.module.ts b/libs/angular/src/services/jslib-services.module.ts index 2fbf55bf6c5..02ec9833d6f 100644 --- a/libs/angular/src/services/jslib-services.module.ts +++ b/libs/angular/src/services/jslib-services.module.ts @@ -56,7 +56,10 @@ import { UserDecryptionOptionsService, UserDecryptionOptionsServiceAbstraction, } from "@bitwarden/auth/common"; -import { AutomaticUserConfirmationService } from "@bitwarden/auto-confirm"; +import { + AutomaticUserConfirmationService, + DefaultAutomaticUserConfirmationService, +} from "@bitwarden/auto-confirm"; import { ApiService as ApiServiceAbstraction } from "@bitwarden/common/abstractions/api.service"; import { AuditService as AuditServiceAbstraction } from "@bitwarden/common/abstractions/audit.service"; import { EventCollectionService as EventCollectionServiceAbstraction } from "@bitwarden/common/abstractions/event/event-collection.service"; @@ -1061,6 +1064,19 @@ const safeProviders: SafeProvider[] = [ PendingAuthRequestsStateService, ], }), + safeProvider({ + provide: AutomaticUserConfirmationService, + useClass: DefaultAutomaticUserConfirmationService, + deps: [ + ConfigService, + ApiServiceAbstraction, + OrganizationUserService, + StateProvider, + InternalOrganizationServiceAbstraction, + OrganizationUserApiService, + InternalPolicyService, + ], + }), safeProvider({ provide: ServerNotificationsService, useClass: devFlagEnabled("noopNotifications") @@ -1512,7 +1528,7 @@ const safeProviders: SafeProvider[] = [ safeProvider({ provide: OrganizationMetadataServiceAbstraction, useClass: DefaultOrganizationMetadataService, - deps: [BillingApiServiceAbstraction, ConfigService, PlatformUtilsServiceAbstraction], + deps: [BillingApiServiceAbstraction, PlatformUtilsServiceAbstraction], }), safeProvider({ provide: BillingAccountProfileStateService, 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 040d4d3c121..fc91f220138 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 @@ -676,7 +676,7 @@ export class LoginViaAuthRequestComponent implements OnInit, OnDestroy { private async decryptViaApprovedAuthRequest( authRequestResponse: AuthRequestResponse, - privateKey: ArrayBuffer, + privateKey: Uint8Array, userId: UserId, ): Promise { /** diff --git a/libs/auth/src/common/abstractions/auth-request.service.abstraction.ts b/libs/auth/src/common/abstractions/auth-request.service.abstraction.ts index 1bfbfd8d004..1077bc024e9 100644 --- a/libs/auth/src/common/abstractions/auth-request.service.abstraction.ts +++ b/libs/auth/src/common/abstractions/auth-request.service.abstraction.ts @@ -72,7 +72,7 @@ export abstract class AuthRequestServiceAbstraction { */ abstract setUserKeyAfterDecryptingSharedUserKey( authReqResponse: AuthRequestResponse, - authReqPrivateKey: ArrayBuffer, + authReqPrivateKey: Uint8Array, userId: UserId, ): Promise; /** @@ -83,7 +83,7 @@ export abstract class AuthRequestServiceAbstraction { */ abstract setKeysAfterDecryptingSharedMasterKeyAndHash( authReqResponse: AuthRequestResponse, - authReqPrivateKey: ArrayBuffer, + authReqPrivateKey: Uint8Array, userId: UserId, ): Promise; /** @@ -94,7 +94,7 @@ export abstract class AuthRequestServiceAbstraction { */ abstract decryptPubKeyEncryptedUserKey( pubKeyEncryptedUserKey: string, - privateKey: ArrayBuffer, + privateKey: Uint8Array, ): Promise; /** * Decrypts a `MasterKey` and `MasterKeyHash` from a public key encrypted `MasterKey` and `MasterKeyHash`. @@ -106,7 +106,7 @@ export abstract class AuthRequestServiceAbstraction { abstract decryptPubKeyEncryptedMasterKeyAndHash( pubKeyEncryptedMasterKey: string, pubKeyEncryptedMasterKeyHash: string, - privateKey: ArrayBuffer, + privateKey: Uint8Array, ): Promise<{ masterKey: MasterKey; masterKeyHash: string }>; /** diff --git a/libs/common/src/abstractions/api.service.ts b/libs/common/src/abstractions/api.service.ts index afca5b63703..8a87d33a589 100644 --- a/libs/common/src/abstractions/api.service.ts +++ b/libs/common/src/abstractions/api.service.ts @@ -92,6 +92,7 @@ import { CipherRequest } from "../vault/models/request/cipher.request"; import { AttachmentUploadDataResponse } from "../vault/models/response/attachment-upload-data.response"; import { AttachmentResponse } from "../vault/models/response/attachment.response"; import { CipherMiniResponse, CipherResponse } from "../vault/models/response/cipher.response"; +import { DeleteAttachmentResponse } from "../vault/models/response/delete-attachment.response"; import { OptionalCipherResponse } from "../vault/models/response/optional-cipher.response"; /** @@ -243,8 +244,14 @@ export abstract class ApiService { id: string, request: AttachmentRequest, ): Promise; - abstract deleteCipherAttachment(id: string, attachmentId: string): Promise; - abstract deleteCipherAttachmentAdmin(id: string, attachmentId: string): Promise; + abstract deleteCipherAttachment( + id: string, + attachmentId: string, + ): Promise; + abstract deleteCipherAttachmentAdmin( + id: string, + attachmentId: string, + ): Promise; abstract postShareCipherAttachment( id: string, attachmentId: string, diff --git a/libs/common/src/billing/abstractions/billing-api.service.abstraction.ts b/libs/common/src/billing/abstractions/billing-api.service.abstraction.ts index dcb395ef85c..9868a57bd78 100644 --- a/libs/common/src/billing/abstractions/billing-api.service.abstraction.ts +++ b/libs/common/src/billing/abstractions/billing-api.service.abstraction.ts @@ -21,11 +21,7 @@ export abstract class BillingApiServiceAbstraction { organizationId: OrganizationId, ): Promise; - abstract getOrganizationBillingMetadataVNext( - organizationId: OrganizationId, - ): Promise; - - abstract getOrganizationBillingMetadataVNextSelfHost( + abstract getOrganizationBillingMetadataSelfHost( organizationId: OrganizationId, ): Promise; diff --git a/libs/common/src/billing/services/billing-api.service.ts b/libs/common/src/billing/services/billing-api.service.ts index ae6913e545c..834606426db 100644 --- a/libs/common/src/billing/services/billing-api.service.ts +++ b/libs/common/src/billing/services/billing-api.service.ts @@ -36,20 +36,6 @@ export class BillingApiService implements BillingApiServiceAbstraction { async getOrganizationBillingMetadata( organizationId: OrganizationId, - ): Promise { - const r = await this.apiService.send( - "GET", - "/organizations/" + organizationId + "/billing/metadata", - null, - true, - true, - ); - - return new OrganizationBillingMetadataResponse(r); - } - - async getOrganizationBillingMetadataVNext( - organizationId: OrganizationId, ): Promise { const r = await this.apiService.send( "GET", @@ -62,7 +48,7 @@ export class BillingApiService implements BillingApiServiceAbstraction { return new OrganizationBillingMetadataResponse(r); } - async getOrganizationBillingMetadataVNextSelfHost( + async getOrganizationBillingMetadataSelfHost( organizationId: OrganizationId, ): Promise { const r = await this.apiService.send( diff --git a/libs/common/src/billing/services/organization/organization-metadata.service.spec.ts b/libs/common/src/billing/services/organization/organization-metadata.service.spec.ts index a2b012eb161..998356cbc14 100644 --- a/libs/common/src/billing/services/organization/organization-metadata.service.spec.ts +++ b/libs/common/src/billing/services/organization/organization-metadata.service.spec.ts @@ -1,13 +1,11 @@ import { mock } from "jest-mock-extended"; -import { BehaviorSubject, firstValueFrom } from "rxjs"; +import { firstValueFrom } from "rxjs"; import { BillingApiServiceAbstraction } from "@bitwarden/common/billing/abstractions"; import { OrganizationBillingMetadataResponse } from "@bitwarden/common/billing/models/response/organization-billing-metadata.response"; -import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { newGuid } from "@bitwarden/guid"; -import { FeatureFlag } from "../../../enums/feature-flag.enum"; import { OrganizationId } from "../../../types/guid"; import { DefaultOrganizationMetadataService } from "./organization-metadata.service"; @@ -15,9 +13,7 @@ import { DefaultOrganizationMetadataService } from "./organization-metadata.serv describe("DefaultOrganizationMetadataService", () => { let service: DefaultOrganizationMetadataService; let billingApiService: jest.Mocked; - let configService: jest.Mocked; let platformUtilsService: jest.Mocked; - let featureFlagSubject: BehaviorSubject; const mockOrganizationId = newGuid() as OrganizationId; const mockOrganizationId2 = newGuid() as OrganizationId; @@ -34,182 +30,114 @@ describe("DefaultOrganizationMetadataService", () => { beforeEach(() => { billingApiService = mock(); - configService = mock(); platformUtilsService = mock(); - featureFlagSubject = new BehaviorSubject(false); - configService.getFeatureFlag$.mockReturnValue(featureFlagSubject.asObservable()); platformUtilsService.isSelfHost.mockReturnValue(false); - service = new DefaultOrganizationMetadataService( - billingApiService, - configService, - platformUtilsService, - ); + service = new DefaultOrganizationMetadataService(billingApiService, platformUtilsService); }); afterEach(() => { jest.resetAllMocks(); - featureFlagSubject.complete(); }); describe("getOrganizationMetadata$", () => { - describe("feature flag OFF", () => { - beforeEach(() => { - featureFlagSubject.next(false); - }); + it("calls getOrganizationBillingMetadata for cloud-hosted", async () => { + const mockResponse = createMockMetadataResponse(false, 10); + billingApiService.getOrganizationBillingMetadata.mockResolvedValue(mockResponse); - it("calls getOrganizationBillingMetadata when feature flag is off", async () => { - const mockResponse = createMockMetadataResponse(false, 10); - billingApiService.getOrganizationBillingMetadata.mockResolvedValue(mockResponse); + const result = await firstValueFrom(service.getOrganizationMetadata$(mockOrganizationId)); - const result = await firstValueFrom(service.getOrganizationMetadata$(mockOrganizationId)); - - expect(configService.getFeatureFlag$).toHaveBeenCalledWith( - FeatureFlag.PM25379_UseNewOrganizationMetadataStructure, - ); - expect(billingApiService.getOrganizationBillingMetadata).toHaveBeenCalledWith( - mockOrganizationId, - ); - expect(billingApiService.getOrganizationBillingMetadataVNext).not.toHaveBeenCalled(); - expect(result).toEqual(mockResponse); - }); - - it("does not cache metadata when feature flag is off", async () => { - const mockResponse1 = createMockMetadataResponse(false, 10); - const mockResponse2 = createMockMetadataResponse(false, 15); - billingApiService.getOrganizationBillingMetadata - .mockResolvedValueOnce(mockResponse1) - .mockResolvedValueOnce(mockResponse2); - - const result1 = await firstValueFrom(service.getOrganizationMetadata$(mockOrganizationId)); - const result2 = await firstValueFrom(service.getOrganizationMetadata$(mockOrganizationId)); - - expect(billingApiService.getOrganizationBillingMetadata).toHaveBeenCalledTimes(2); - expect(result1).toEqual(mockResponse1); - expect(result2).toEqual(mockResponse2); - }); + expect(billingApiService.getOrganizationBillingMetadata).toHaveBeenCalledWith( + mockOrganizationId, + ); + expect(result).toEqual(mockResponse); }); - describe("feature flag ON", () => { - beforeEach(() => { - featureFlagSubject.next(true); - }); + it("calls getOrganizationBillingMetadataSelfHost when isSelfHost is true", async () => { + platformUtilsService.isSelfHost.mockReturnValue(true); + const mockResponse = createMockMetadataResponse(true, 25); + billingApiService.getOrganizationBillingMetadataSelfHost.mockResolvedValue(mockResponse); - it("calls getOrganizationBillingMetadataVNext when feature flag is on", async () => { - const mockResponse = createMockMetadataResponse(true, 15); - billingApiService.getOrganizationBillingMetadataVNext.mockResolvedValue(mockResponse); + const result = await firstValueFrom(service.getOrganizationMetadata$(mockOrganizationId)); - const result = await firstValueFrom(service.getOrganizationMetadata$(mockOrganizationId)); - - expect(configService.getFeatureFlag$).toHaveBeenCalledWith( - FeatureFlag.PM25379_UseNewOrganizationMetadataStructure, - ); - expect(billingApiService.getOrganizationBillingMetadataVNext).toHaveBeenCalledWith( - mockOrganizationId, - ); - expect(billingApiService.getOrganizationBillingMetadata).not.toHaveBeenCalled(); - expect(result).toEqual(mockResponse); - }); - - it("caches metadata by organization ID when feature flag is on", async () => { - const mockResponse = createMockMetadataResponse(true, 10); - billingApiService.getOrganizationBillingMetadataVNext.mockResolvedValue(mockResponse); - - const result1 = await firstValueFrom(service.getOrganizationMetadata$(mockOrganizationId)); - const result2 = await firstValueFrom(service.getOrganizationMetadata$(mockOrganizationId)); - - expect(billingApiService.getOrganizationBillingMetadataVNext).toHaveBeenCalledTimes(1); - expect(result1).toEqual(mockResponse); - expect(result2).toEqual(mockResponse); - }); - - it("maintains separate cache entries for different organization IDs", async () => { - const mockResponse1 = createMockMetadataResponse(true, 10); - const mockResponse2 = createMockMetadataResponse(false, 20); - billingApiService.getOrganizationBillingMetadataVNext - .mockResolvedValueOnce(mockResponse1) - .mockResolvedValueOnce(mockResponse2); - - const result1 = await firstValueFrom(service.getOrganizationMetadata$(mockOrganizationId)); - const result2 = await firstValueFrom(service.getOrganizationMetadata$(mockOrganizationId2)); - const result3 = await firstValueFrom(service.getOrganizationMetadata$(mockOrganizationId)); - const result4 = await firstValueFrom(service.getOrganizationMetadata$(mockOrganizationId2)); - - expect(billingApiService.getOrganizationBillingMetadataVNext).toHaveBeenCalledTimes(2); - expect(billingApiService.getOrganizationBillingMetadataVNext).toHaveBeenNthCalledWith( - 1, - mockOrganizationId, - ); - expect(billingApiService.getOrganizationBillingMetadataVNext).toHaveBeenNthCalledWith( - 2, - mockOrganizationId2, - ); - expect(result1).toEqual(mockResponse1); - expect(result2).toEqual(mockResponse2); - expect(result3).toEqual(mockResponse1); - expect(result4).toEqual(mockResponse2); - }); - - it("calls getOrganizationBillingMetadataVNextSelfHost when feature flag is on and isSelfHost is true", async () => { - platformUtilsService.isSelfHost.mockReturnValue(true); - const mockResponse = createMockMetadataResponse(true, 25); - billingApiService.getOrganizationBillingMetadataVNextSelfHost.mockResolvedValue( - mockResponse, - ); - - const result = await firstValueFrom(service.getOrganizationMetadata$(mockOrganizationId)); - - expect(platformUtilsService.isSelfHost).toHaveBeenCalled(); - expect(billingApiService.getOrganizationBillingMetadataVNextSelfHost).toHaveBeenCalledWith( - mockOrganizationId, - ); - expect(billingApiService.getOrganizationBillingMetadataVNext).not.toHaveBeenCalled(); - expect(billingApiService.getOrganizationBillingMetadata).not.toHaveBeenCalled(); - expect(result).toEqual(mockResponse); - }); + expect(platformUtilsService.isSelfHost).toHaveBeenCalled(); + expect(billingApiService.getOrganizationBillingMetadataSelfHost).toHaveBeenCalledWith( + mockOrganizationId, + ); + expect(billingApiService.getOrganizationBillingMetadata).not.toHaveBeenCalled(); + expect(result).toEqual(mockResponse); }); - describe("shareReplay behavior", () => { - beforeEach(() => { - featureFlagSubject.next(true); - }); + it("caches metadata by organization ID", async () => { + const mockResponse = createMockMetadataResponse(true, 10); + billingApiService.getOrganizationBillingMetadata.mockResolvedValue(mockResponse); - it("does not call API multiple times when the same cached observable is subscribed to multiple times", async () => { - const mockResponse = createMockMetadataResponse(true, 10); - billingApiService.getOrganizationBillingMetadataVNext.mockResolvedValue(mockResponse); + const result1 = await firstValueFrom(service.getOrganizationMetadata$(mockOrganizationId)); + const result2 = await firstValueFrom(service.getOrganizationMetadata$(mockOrganizationId)); - const metadata$ = service.getOrganizationMetadata$(mockOrganizationId); + expect(billingApiService.getOrganizationBillingMetadata).toHaveBeenCalledTimes(1); + expect(result1).toEqual(mockResponse); + expect(result2).toEqual(mockResponse); + }); - const subscription1Promise = firstValueFrom(metadata$); - const subscription2Promise = firstValueFrom(metadata$); - const subscription3Promise = firstValueFrom(metadata$); + it("maintains separate cache entries for different organization IDs", async () => { + const mockResponse1 = createMockMetadataResponse(true, 10); + const mockResponse2 = createMockMetadataResponse(false, 20); + billingApiService.getOrganizationBillingMetadata + .mockResolvedValueOnce(mockResponse1) + .mockResolvedValueOnce(mockResponse2); - const [result1, result2, result3] = await Promise.all([ - subscription1Promise, - subscription2Promise, - subscription3Promise, - ]); + const result1 = await firstValueFrom(service.getOrganizationMetadata$(mockOrganizationId)); + const result2 = await firstValueFrom(service.getOrganizationMetadata$(mockOrganizationId2)); + const result3 = await firstValueFrom(service.getOrganizationMetadata$(mockOrganizationId)); + const result4 = await firstValueFrom(service.getOrganizationMetadata$(mockOrganizationId2)); - expect(billingApiService.getOrganizationBillingMetadataVNext).toHaveBeenCalledTimes(1); - expect(result1).toEqual(mockResponse); - expect(result2).toEqual(mockResponse); - expect(result3).toEqual(mockResponse); - }); + expect(billingApiService.getOrganizationBillingMetadata).toHaveBeenCalledTimes(2); + expect(billingApiService.getOrganizationBillingMetadata).toHaveBeenNthCalledWith( + 1, + mockOrganizationId, + ); + expect(billingApiService.getOrganizationBillingMetadata).toHaveBeenNthCalledWith( + 2, + mockOrganizationId2, + ); + expect(result1).toEqual(mockResponse1); + expect(result2).toEqual(mockResponse2); + expect(result3).toEqual(mockResponse1); + expect(result4).toEqual(mockResponse2); + }); + + it("does not call API multiple times when the same cached observable is subscribed to multiple times", async () => { + const mockResponse = createMockMetadataResponse(true, 10); + billingApiService.getOrganizationBillingMetadata.mockResolvedValue(mockResponse); + + const metadata$ = service.getOrganizationMetadata$(mockOrganizationId); + + const subscription1Promise = firstValueFrom(metadata$); + const subscription2Promise = firstValueFrom(metadata$); + const subscription3Promise = firstValueFrom(metadata$); + + const [result1, result2, result3] = await Promise.all([ + subscription1Promise, + subscription2Promise, + subscription3Promise, + ]); + + expect(billingApiService.getOrganizationBillingMetadata).toHaveBeenCalledTimes(1); + expect(result1).toEqual(mockResponse); + expect(result2).toEqual(mockResponse); + expect(result3).toEqual(mockResponse); }); }); describe("refreshMetadataCache", () => { - beforeEach(() => { - featureFlagSubject.next(true); - }); - - it("refreshes cached metadata when called with feature flag on", (done) => { + it("refreshes cached metadata when called", (done) => { const mockResponse1 = createMockMetadataResponse(true, 10); const mockResponse2 = createMockMetadataResponse(true, 20); let invocationCount = 0; - billingApiService.getOrganizationBillingMetadataVNext + billingApiService.getOrganizationBillingMetadata .mockResolvedValueOnce(mockResponse1) .mockResolvedValueOnce(mockResponse2); @@ -221,7 +149,7 @@ describe("DefaultOrganizationMetadataService", () => { expect(result).toEqual(mockResponse1); } else if (invocationCount === 2) { expect(result).toEqual(mockResponse2); - expect(billingApiService.getOrganizationBillingMetadataVNext).toHaveBeenCalledTimes(2); + expect(billingApiService.getOrganizationBillingMetadata).toHaveBeenCalledTimes(2); subscription.unsubscribe(); done(); } @@ -234,45 +162,13 @@ describe("DefaultOrganizationMetadataService", () => { }, 10); }); - it("does trigger refresh when feature flag is disabled", async () => { - featureFlagSubject.next(false); - - const mockResponse1 = createMockMetadataResponse(false, 10); - const mockResponse2 = createMockMetadataResponse(false, 20); - let invocationCount = 0; - - billingApiService.getOrganizationBillingMetadata - .mockResolvedValueOnce(mockResponse1) - .mockResolvedValueOnce(mockResponse2); - - const subscription = service.getOrganizationMetadata$(mockOrganizationId).subscribe({ - next: () => { - invocationCount++; - }, - }); - - // wait for initial invocation - await new Promise((resolve) => setTimeout(resolve, 10)); - - expect(invocationCount).toBe(1); - - service.refreshMetadataCache(); - - await new Promise((resolve) => setTimeout(resolve, 10)); - - expect(invocationCount).toBe(2); - expect(billingApiService.getOrganizationBillingMetadata).toHaveBeenCalledTimes(2); - - subscription.unsubscribe(); - }); - it("bypasses cache when refreshing metadata", (done) => { const mockResponse1 = createMockMetadataResponse(true, 10); const mockResponse2 = createMockMetadataResponse(true, 20); const mockResponse3 = createMockMetadataResponse(true, 30); let invocationCount = 0; - billingApiService.getOrganizationBillingMetadataVNext + billingApiService.getOrganizationBillingMetadata .mockResolvedValueOnce(mockResponse1) .mockResolvedValueOnce(mockResponse2) .mockResolvedValueOnce(mockResponse3); @@ -289,7 +185,7 @@ describe("DefaultOrganizationMetadataService", () => { service.refreshMetadataCache(); } else if (invocationCount === 3) { expect(result).toEqual(mockResponse3); - expect(billingApiService.getOrganizationBillingMetadataVNext).toHaveBeenCalledTimes(3); + expect(billingApiService.getOrganizationBillingMetadata).toHaveBeenCalledTimes(3); subscription.unsubscribe(); done(); } diff --git a/libs/common/src/billing/services/organization/organization-metadata.service.ts b/libs/common/src/billing/services/organization/organization-metadata.service.ts index 5ce87262c4b..149c4536df4 100644 --- a/libs/common/src/billing/services/organization/organization-metadata.service.ts +++ b/libs/common/src/billing/services/organization/organization-metadata.service.ts @@ -1,10 +1,8 @@ -import { BehaviorSubject, combineLatest, from, Observable, shareReplay, switchMap } from "rxjs"; +import { BehaviorSubject, from, Observable, shareReplay, switchMap } from "rxjs"; import { BillingApiServiceAbstraction } from "@bitwarden/common/billing/abstractions"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; -import { FeatureFlag } from "../../../enums/feature-flag.enum"; -import { ConfigService } from "../../../platform/abstractions/config/config.service"; import { OrganizationId } from "../../../types/guid"; import { OrganizationMetadataServiceAbstraction } from "../../abstractions/organization-metadata.service.abstraction"; import { OrganizationBillingMetadataResponse } from "../../models/response/organization-billing-metadata.response"; @@ -17,7 +15,6 @@ export class DefaultOrganizationMetadataService implements OrganizationMetadataS constructor( private billingApiService: BillingApiServiceAbstraction, - private configService: ConfigService, private platformUtilsService: PlatformUtilsService, ) {} private refreshMetadataTrigger = new BehaviorSubject(undefined); @@ -28,50 +25,26 @@ export class DefaultOrganizationMetadataService implements OrganizationMetadataS }; getOrganizationMetadata$(orgId: OrganizationId): Observable { - return combineLatest([ - this.refreshMetadataTrigger, - this.configService.getFeatureFlag$(FeatureFlag.PM25379_UseNewOrganizationMetadataStructure), - ]).pipe( - switchMap(([_, featureFlagEnabled]) => - featureFlagEnabled - ? this.vNextGetOrganizationMetadataInternal$(orgId) - : this.getOrganizationMetadataInternal$(orgId), - ), - ); - } - - private vNextGetOrganizationMetadataInternal$( - orgId: OrganizationId, - ): Observable { - const cacheHit = this.metadataCache.get(orgId); - if (cacheHit) { - return cacheHit; - } - - const result = from(this.fetchMetadata(orgId, true)).pipe( - shareReplay({ bufferSize: 1, refCount: false }), - ); - - this.metadataCache.set(orgId, result); - return result; - } - - private getOrganizationMetadataInternal$( - organizationId: OrganizationId, - ): Observable { - return from(this.fetchMetadata(organizationId, false)).pipe( - shareReplay({ bufferSize: 1, refCount: false }), + return this.refreshMetadataTrigger.pipe( + switchMap(() => { + const cacheHit = this.metadataCache.get(orgId); + if (cacheHit) { + return cacheHit; + } + const result = from(this.fetchMetadata(orgId)).pipe( + shareReplay({ bufferSize: 1, refCount: false }), + ); + this.metadataCache.set(orgId, result); + return result; + }), ); } private async fetchMetadata( organizationId: OrganizationId, - featureFlagEnabled: boolean, ): Promise { - return featureFlagEnabled - ? this.platformUtilsService.isSelfHost() - ? await this.billingApiService.getOrganizationBillingMetadataVNextSelfHost(organizationId) - : await this.billingApiService.getOrganizationBillingMetadataVNext(organizationId) + return this.platformUtilsService.isSelfHost() + ? await this.billingApiService.getOrganizationBillingMetadataSelfHost(organizationId) : await this.billingApiService.getOrganizationBillingMetadata(organizationId); } } diff --git a/libs/common/src/enums/feature-flag.enum.ts b/libs/common/src/enums/feature-flag.enum.ts index 96196fbc8e1..4d21cbff602 100644 --- a/libs/common/src/enums/feature-flag.enum.ts +++ b/libs/common/src/enums/feature-flag.enum.ts @@ -19,6 +19,7 @@ export enum FeatureFlag { PM23801_PrefetchPasswordPrelogin = "pm-23801-prefetch-password-prelogin", PM27086_UpdateAuthenticationApisForInputPassword = "pm-27086-update-authentication-apis-for-input-password", SafariAccountSwitching = "pm-5594-safari-account-switching", + PM31088_MasterPasswordServiceEmitSalt = "pm-31088-master-password-service-emit-salt", /* Autofill */ UseUndeterminedCipherScenarioTriggeringLogic = "undetermined-cipher-scenario-logic", @@ -30,7 +31,6 @@ export enum FeatureFlag { /* Billing */ TrialPaymentOptional = "PM-8163-trial-payment", PM24032_NewNavigationPremiumUpgradeButton = "pm-24032-new-navigation-premium-upgrade-button", - PM25379_UseNewOrganizationMetadataStructure = "pm-25379-use-new-organization-metadata-structure", PM26793_FetchPremiumPriceFromPricingService = "pm-26793-fetch-premium-price-from-pricing-service", PM23713_PremiumBadgeOpensNewPremiumUpgradeDialog = "pm-23713-premium-badge-opens-new-premium-upgrade-dialog", PM26462_Milestone_3 = "pm-26462-milestone-3", @@ -54,7 +54,6 @@ export enum FeatureFlag { /* Tools */ UseSdkPasswordGenerators = "pm-19976-use-sdk-password-generators", - ChromiumImporterWithABE = "pm-25855-chromium-importer-abe", SendUIRefresh = "pm-28175-send-ui-refresh", SendEmailOTP = "pm-19051-send-email-verification", @@ -121,7 +120,6 @@ export const DefaultFeatureFlagValue = { /* Tools */ [FeatureFlag.UseSdkPasswordGenerators]: FALSE, - [FeatureFlag.ChromiumImporterWithABE]: FALSE, [FeatureFlag.SendUIRefresh]: FALSE, [FeatureFlag.SendEmailOTP]: FALSE, @@ -144,11 +142,11 @@ export const DefaultFeatureFlagValue = { [FeatureFlag.PM23801_PrefetchPasswordPrelogin]: FALSE, [FeatureFlag.PM27086_UpdateAuthenticationApisForInputPassword]: FALSE, [FeatureFlag.SafariAccountSwitching]: FALSE, + [FeatureFlag.PM31088_MasterPasswordServiceEmitSalt]: FALSE, /* Billing */ [FeatureFlag.TrialPaymentOptional]: FALSE, [FeatureFlag.PM24032_NewNavigationPremiumUpgradeButton]: FALSE, - [FeatureFlag.PM25379_UseNewOrganizationMetadataStructure]: FALSE, [FeatureFlag.PM26793_FetchPremiumPriceFromPricingService]: FALSE, [FeatureFlag.PM23713_PremiumBadgeOpensNewPremiumUpgradeDialog]: FALSE, [FeatureFlag.PM26462_Milestone_3]: FALSE, diff --git a/libs/common/src/key-management/master-password/services/master-password.service.spec.ts b/libs/common/src/key-management/master-password/services/master-password.service.spec.ts index f72ae0e7c5e..4a96dedf024 100644 --- a/libs/common/src/key-management/master-password/services/master-password.service.spec.ts +++ b/libs/common/src/key-management/master-password/services/master-password.service.spec.ts @@ -17,8 +17,11 @@ import { mockAccountServiceWith, } from "../../../../spec"; import { ForceSetPasswordReason } from "../../../auth/models/domain/force-set-password-reason"; +import { FeatureFlag } from "../../../enums/feature-flag.enum"; +import { ServerConfig } from "../../../platform/abstractions/config/server-config"; import { LogService } from "../../../platform/abstractions/log.service"; import { SymmetricCryptoKey } from "../../../platform/models/domain/symmetric-crypto-key"; +import { USER_SERVER_CONFIG } from "../../../platform/services/config/default-config.service"; import { UserId } from "../../../types/guid"; import { MasterKey, UserKey } from "../../../types/key"; import { KeyGenerationService } from "../../crypto"; @@ -92,14 +95,52 @@ describe("MasterPasswordService", () => { sut.saltForUser$(null as unknown as UserId); }).toThrow("userId is null or undefined."); }); + // Removable with unwinding of PM31088_MasterPasswordServiceEmitSalt it("throws when userid present but not in account service", async () => { await expect( firstValueFrom(sut.saltForUser$("00000000-0000-0000-0000-000000000001" as UserId)), ).rejects.toThrow("Cannot read properties of undefined (reading 'email')"); }); - it("returns salt", async () => { - const salt = await firstValueFrom(sut.saltForUser$(userId)); - expect(salt).toBeDefined(); + // Removable with unwinding of PM31088_MasterPasswordServiceEmitSalt + it("returns email-derived salt for legacy path", async () => { + const result = await firstValueFrom(sut.saltForUser$(userId)); + // mockAccountServiceWith defaults email to "email" + expect(result).toBe("email" as MasterPasswordSalt); + }); + + describe("saltForUser$ master password unlock data migration path", () => { + // Flagged with PM31088_MasterPasswordServiceEmitSalt PM-31088 + beforeEach(() => { + stateProvider.singleUser.getFake(userId, USER_SERVER_CONFIG).nextState({ + featureStates: { + [FeatureFlag.PM31088_MasterPasswordServiceEmitSalt]: true, + }, + } as unknown as ServerConfig); + }); + + // Unwinding should promote these tests as part of saltForUser suite. + it("returns salt from master password unlock data", async () => { + const expectedSalt = "custom-salt" as MasterPasswordSalt; + const unlockData = new MasterPasswordUnlockData( + expectedSalt, + new PBKDF2KdfConfig(600_000), + makeEncString().toSdk() as MasterKeyWrappedUserKey, + ); + stateProvider.singleUser + .getFake(userId, MASTER_PASSWORD_UNLOCK_KEY) + .nextState(unlockData.toJSON()); + + const result = await firstValueFrom(sut.saltForUser$(userId)); + expect(result).toBe(expectedSalt); + }); + + it("throws when master password unlock data is null", async () => { + stateProvider.singleUser.getFake(userId, MASTER_PASSWORD_UNLOCK_KEY).nextState(null); + + await expect(firstValueFrom(sut.saltForUser$(userId))).rejects.toThrow( + "Master password unlock data not found for user.", + ); + }); }); }); 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 28d4f58d7dc..f1a074ff14c 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 @@ -1,6 +1,6 @@ // FIXME: Update this file to be type safe and remove this and next line // @ts-strict-ignore -import { firstValueFrom, map, Observable } from "rxjs"; +import { firstValueFrom, iif, map, Observable, switchMap } from "rxjs"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { assertNonNullish } from "@bitwarden/common/auth/utils"; @@ -12,8 +12,10 @@ import { KdfConfig } from "@bitwarden/key-management"; import { PureCrypto } from "@bitwarden/sdk-internal"; import { ForceSetPasswordReason } from "../../../auth/models/domain/force-set-password-reason"; +import { FeatureFlag, getFeatureFlagValue } from "../../../enums/feature-flag.enum"; import { LogService } from "../../../platform/abstractions/log.service"; import { SymmetricCryptoKey } from "../../../platform/models/domain/symmetric-crypto-key"; +import { USER_SERVER_CONFIG } from "../../../platform/services/config/default-config.service"; import { MASTER_PASSWORD_DISK, MASTER_PASSWORD_MEMORY, @@ -102,9 +104,29 @@ export class MasterPasswordService implements InternalMasterPasswordServiceAbstr saltForUser$(userId: UserId): Observable { assertNonNullish(userId, "userId"); - return this.accountService.accounts$.pipe( - map((accounts) => accounts[userId].email), - map((email) => this.emailToSalt(email)), + + // Note: We can't use the config service as an abstraction here because it creates a circular dependency: ConfigService -> ConfigApiService -> ApiService -> VaultTimeoutSettingsService -> KeyService -> MP service. + return this.stateProvider.getUser(userId, USER_SERVER_CONFIG).state$.pipe( + map((serverConfig) => + getFeatureFlagValue(serverConfig, FeatureFlag.PM31088_MasterPasswordServiceEmitSalt), + ), + switchMap((enabled) => + iif( + () => enabled, + this.masterPasswordUnlockData$(userId).pipe( + map((unlockData) => { + if (unlockData == null) { + throw new Error("Master password unlock data not found for user."); + } + return unlockData.salt; + }), + ), + this.accountService.accounts$.pipe( + map((accounts) => accounts[userId].email), + map((email) => this.emailToSalt(email)), + ), + ), + ), ); } diff --git a/libs/common/src/platform/misc/utils.spec.ts b/libs/common/src/platform/misc/utils.spec.ts index 664c6e22b3a..032b03fc3e2 100644 --- a/libs/common/src/platform/misc/utils.spec.ts +++ b/libs/common/src/platform/misc/utils.spec.ts @@ -417,6 +417,142 @@ describe("Utils Service", () => { // }); }); + describe("fromArrayToHex(...)", () => { + const originalIsNode = Utils.isNode; + + afterEach(() => { + Utils.isNode = originalIsNode; + }); + + runInBothEnvironments("should convert a Uint8Array to a hex string", () => { + const arr = new Uint8Array([0x00, 0x01, 0x02, 0x0a, 0xff]); + const hexString = Utils.fromArrayToHex(arr); + expect(hexString).toBe("0001020aff"); + }); + + runInBothEnvironments("should return null for null input", () => { + const hexString = Utils.fromArrayToHex(null); + expect(hexString).toBeNull(); + }); + + runInBothEnvironments("should return empty string for an empty Uint8Array", () => { + const arr = new Uint8Array([]); + const hexString = Utils.fromArrayToHex(arr); + expect(hexString).toBe(""); + }); + }); + + describe("fromArrayToB64(...)", () => { + const originalIsNode = Utils.isNode; + + afterEach(() => { + Utils.isNode = originalIsNode; + }); + + runInBothEnvironments("should convert a Uint8Array to a b64 string", () => { + const arr = new Uint8Array(asciiHelloWorldArray); + const b64String = Utils.fromArrayToB64(arr); + expect(b64String).toBe(b64HelloWorldString); + }); + + runInBothEnvironments("should return null for null input", () => { + const b64String = Utils.fromArrayToB64(null); + expect(b64String).toBeNull(); + }); + + runInBothEnvironments("should return empty string for an empty Uint8Array", () => { + const arr = new Uint8Array([]); + const b64String = Utils.fromArrayToB64(arr); + expect(b64String).toBe(""); + }); + }); + + describe("fromArrayToUrlB64(...)", () => { + const originalIsNode = Utils.isNode; + + afterEach(() => { + Utils.isNode = originalIsNode; + }); + + runInBothEnvironments("should convert a Uint8Array to a URL-safe b64 string", () => { + // Input that produces +, /, and = in standard base64 + const arr = new Uint8Array([251, 255, 254]); + const urlB64String = Utils.fromArrayToUrlB64(arr); + // Standard b64 would be "+//+" with padding, URL-safe removes padding and replaces chars + expect(urlB64String).not.toContain("+"); + expect(urlB64String).not.toContain("/"); + expect(urlB64String).not.toContain("="); + }); + + runInBothEnvironments("should return null for null input", () => { + const urlB64String = Utils.fromArrayToUrlB64(null); + expect(urlB64String).toBeNull(); + }); + + runInBothEnvironments("should return empty string for an empty Uint8Array", () => { + const arr = new Uint8Array([]); + const urlB64String = Utils.fromArrayToUrlB64(arr); + expect(urlB64String).toBe(""); + }); + }); + + describe("fromArrayToByteString(...)", () => { + const originalIsNode = Utils.isNode; + + afterEach(() => { + Utils.isNode = originalIsNode; + }); + + runInBothEnvironments("should convert a Uint8Array to a byte string", () => { + const arr = new Uint8Array(asciiHelloWorldArray); + const byteString = Utils.fromArrayToByteString(arr); + expect(byteString).toBe(asciiHelloWorld); + }); + + runInBothEnvironments("should return null for null input", () => { + const byteString = Utils.fromArrayToByteString(null); + expect(byteString).toBeNull(); + }); + + runInBothEnvironments("should return empty string for an empty Uint8Array", () => { + const arr = new Uint8Array([]); + const byteString = Utils.fromArrayToByteString(arr); + expect(byteString).toBe(""); + }); + }); + + describe("fromArrayToUtf8(...)", () => { + const originalIsNode = Utils.isNode; + + afterEach(() => { + Utils.isNode = originalIsNode; + }); + + runInBothEnvironments("should convert a Uint8Array to a UTF-8 string", () => { + const arr = new Uint8Array(asciiHelloWorldArray); + const utf8String = Utils.fromArrayToUtf8(arr); + expect(utf8String).toBe(asciiHelloWorld); + }); + + runInBothEnvironments("should return null for null input", () => { + const utf8String = Utils.fromArrayToUtf8(null); + expect(utf8String).toBeNull(); + }); + + runInBothEnvironments("should return empty string for an empty Uint8Array", () => { + const arr = new Uint8Array([]); + const utf8String = Utils.fromArrayToUtf8(arr); + expect(utf8String).toBe(""); + }); + + runInBothEnvironments("should handle multi-byte UTF-8 characters", () => { + // "日本" in UTF-8 bytes + const arr = new Uint8Array([0xe6, 0x97, 0xa5, 0xe6, 0x9c, 0xac]); + const utf8String = Utils.fromArrayToUtf8(arr); + expect(utf8String).toBe("日本"); + }); + }); + describe("Base64 and ArrayBuffer round trip conversions", () => { const originalIsNode = Utils.isNode; @@ -447,10 +583,10 @@ describe("Utils Service", () => { "should correctly round trip convert from base64 to ArrayBuffer and back", () => { // Convert known base64 string to ArrayBuffer - const bufferFromB64 = Utils.fromB64ToArray(b64HelloWorldString).buffer; + const bufferFromB64 = Utils.fromB64ToArray(b64HelloWorldString); // Convert the ArrayBuffer back to a base64 string - const roundTrippedB64String = Utils.fromBufferToB64(bufferFromB64); + const roundTrippedB64String = Utils.fromArrayToB64(bufferFromB64); // Compare the original base64 string with the round-tripped base64 string expect(roundTrippedB64String).toBe(b64HelloWorldString); diff --git a/libs/common/src/platform/misc/utils.ts b/libs/common/src/platform/misc/utils.ts index bdbfc4ea17b..c2d8871c2c9 100644 --- a/libs/common/src/platform/misc/utils.ts +++ b/libs/common/src/platform/misc/utils.ts @@ -8,6 +8,8 @@ import { Observable, of, switchMap } from "rxjs"; import { getHostname, parse } from "tldts"; import { Merge } from "type-fest"; +import "core-js/proposals/array-buffer-base64"; + // This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop. // eslint-disable-next-line no-restricted-imports import { KeyService } from "@bitwarden/key-management"; @@ -129,6 +131,78 @@ export class Utils { return arr; } + /** + * Converts a Uint8Array to a hexadecimal string. + * @param arr - The Uint8Array to convert. + * @returns The hexadecimal string representation, or null if the input is null. + */ + static fromArrayToHex(arr: Uint8Array | null): string | null { + if (arr == null) { + return null; + } + + // @ts-expect-error - polyfilled by core-js + return arr.toHex(); + } + + /** + * Converts a Uint8Array to a Base64 encoded string. + * @param arr - The Uint8Array to convert. + * @returns The Base64 encoded string, or null if the input is null. + */ + static fromArrayToB64(arr: Uint8Array | null): string | null { + if (arr == null) { + return null; + } + + // @ts-expect-error - polyfilled by core-js + return arr.toBase64({ alphabet: "base64" }); + } + + /** + * Converts a Uint8Array to a URL-safe Base64 encoded string. + * @param arr - The Uint8Array to convert. + * @returns The URL-safe Base64 encoded string, or null if the input is null. + */ + static fromArrayToUrlB64(arr: Uint8Array | null): string | null { + if (arr == null) { + return null; + } + + // @ts-expect-error - polyfilled by core-js + return arr.toBase64({ alphabet: "base64url" }); + } + + /** + * Converts a Uint8Array to a byte string (each byte as a character). + * @param arr - The Uint8Array to convert. + * @returns The byte string representation, or null if the input is null. + */ + static fromArrayToByteString(arr: Uint8Array | null): string | null { + if (arr == null) { + return null; + } + + let byteString = ""; + for (let i = 0; i < arr.length; i++) { + byteString += String.fromCharCode(arr[i]); + } + return byteString; + } + + /** + * Converts a Uint8Array to a UTF-8 decoded string. + * @param arr - The Uint8Array containing UTF-8 encoded bytes. + * @returns The decoded UTF-8 string, or null if the input is null. + */ + static fromArrayToUtf8(arr: Uint8Array | null): string | null { + if (arr == null) { + return null; + } + + return BufferLib.from(arr).toString("utf8"); + } + /** * Convert binary data into a Base64 string. * @@ -302,7 +376,7 @@ export class Utils { } static fromUtf8ToUrlB64(utfStr: string): string { - return Utils.fromBufferToUrlB64(Utils.fromUtf8ToArray(utfStr)); + return Utils.fromArrayToUrlB64(Utils.fromUtf8ToArray(utfStr)); } static fromB64ToUtf8(b64Str: string): string { diff --git a/libs/common/src/services/api.service.ts b/libs/common/src/services/api.service.ts index 33e251f6411..8b50f14004d 100644 --- a/libs/common/src/services/api.service.ts +++ b/libs/common/src/services/api.service.ts @@ -115,6 +115,7 @@ import { CipherRequest } from "../vault/models/request/cipher.request"; import { AttachmentUploadDataResponse } from "../vault/models/response/attachment-upload-data.response"; import { AttachmentResponse } from "../vault/models/response/attachment.response"; import { CipherResponse } from "../vault/models/response/cipher.response"; +import { DeleteAttachmentResponse } from "../vault/models/response/delete-attachment.response"; import { OptionalCipherResponse } from "../vault/models/response/optional-cipher.response"; import { InsecureUrlNotAllowedError } from "./api-errors"; @@ -590,18 +591,32 @@ export class ApiService implements ApiServiceAbstraction { return new AttachmentUploadDataResponse(r); } - deleteCipherAttachment(id: string, attachmentId: string): Promise { - return this.send("DELETE", "/ciphers/" + id + "/attachment/" + attachmentId, null, true, true); + async deleteCipherAttachment( + id: string, + attachmentId: string, + ): Promise { + const r = await this.send( + "DELETE", + "/ciphers/" + id + "/attachment/" + attachmentId, + null, + true, + true, + ); + return new DeleteAttachmentResponse(r); } - deleteCipherAttachmentAdmin(id: string, attachmentId: string): Promise { - return this.send( + async deleteCipherAttachmentAdmin( + id: string, + attachmentId: string, + ): Promise { + const r = await this.send( "DELETE", "/ciphers/" + id + "/attachment/" + attachmentId + "/admin", null, true, true, ); + return new DeleteAttachmentResponse(r); } postShareCipherAttachment( diff --git a/libs/common/src/vault/models/response/delete-attachment.response.ts b/libs/common/src/vault/models/response/delete-attachment.response.ts new file mode 100644 index 00000000000..ae645fdf315 --- /dev/null +++ b/libs/common/src/vault/models/response/delete-attachment.response.ts @@ -0,0 +1,12 @@ +import { BaseResponse } from "../../../models/response/base.response"; + +import { CipherResponse } from "./cipher.response"; + +export class DeleteAttachmentResponse extends BaseResponse { + cipher: CipherResponse; + + constructor(response: any) { + super(response); + this.cipher = new CipherResponse(this.getResponseProperty("Cipher")); + } +} diff --git a/libs/common/src/vault/services/cipher.service.ts b/libs/common/src/vault/services/cipher.service.ts index e4c4f892b4a..70d2458cff2 100644 --- a/libs/common/src/vault/services/cipher.service.ts +++ b/libs/common/src/vault/services/cipher.service.ts @@ -77,6 +77,7 @@ import { CipherShareRequest } from "../models/request/cipher-share.request"; import { CipherWithIdRequest } from "../models/request/cipher-with-id.request"; import { CipherRequest } from "../models/request/cipher.request"; import { CipherResponse } from "../models/response/cipher.response"; +import { DeleteAttachmentResponse } from "../models/response/delete-attachment.response"; import { AttachmentView } from "../models/view/attachment.view"; import { CipherView } from "../models/view/cipher.view"; import { FieldView } from "../models/view/field.view"; @@ -1482,16 +1483,16 @@ export class CipherService implements CipherServiceAbstraction { userId: UserId, admin: boolean = false, ): Promise { - let cipherResponse = null; + let response: DeleteAttachmentResponse; try { - cipherResponse = admin + response = admin ? await this.apiService.deleteCipherAttachmentAdmin(id, attachmentId) : await this.apiService.deleteCipherAttachment(id, attachmentId); } catch (e) { return Promise.reject((e as ErrorResponse).getSingleMessage()); } - const cipherData = CipherData.fromJSON(cipherResponse?.cipher); + const cipherData = new CipherData(response.cipher); return await this.deleteAttachment(id, cipherData.revisionDate, attachmentId, userId); } diff --git a/libs/common/src/vault/services/file-upload/cipher-file-upload.service.ts b/libs/common/src/vault/services/file-upload/cipher-file-upload.service.ts index 8d97a921748..48b51f50178 100644 --- a/libs/common/src/vault/services/file-upload/cipher-file-upload.service.ts +++ b/libs/common/src/vault/services/file-upload/cipher-file-upload.service.ts @@ -93,12 +93,12 @@ export class CipherFileUploadService implements CipherFileUploadServiceAbstracti response: CipherResponse, uploadData: AttachmentUploadDataResponse, isAdmin: boolean, - ) { - return () => { + ): () => Promise { + return async () => { if (isAdmin) { - return this.apiService.deleteCipherAttachmentAdmin(response.id, uploadData.attachmentId); + await this.apiService.deleteCipherAttachmentAdmin(response.id, uploadData.attachmentId); } else { - return this.apiService.deleteCipherAttachment(response.id, uploadData.attachmentId); + await this.apiService.deleteCipherAttachment(response.id, uploadData.attachmentId); } }; } diff --git a/libs/components/src/berry/berry.component.ts b/libs/components/src/berry/berry.component.ts index 8e58b888f39..a6544b75f6e 100644 --- a/libs/components/src/berry/berry.component.ts +++ b/libs/components/src/berry/berry.component.ts @@ -38,7 +38,7 @@ export class BerryComponent { }); protected readonly textColor = computed(() => { - return this.variant() === "contrast" ? "tw-text-fg-dark" : "tw-text-fg-white"; + return this.variant() === "contrast" ? "tw-text-fg-heading" : "tw-text-fg-contrast"; }); protected readonly padding = computed(() => { @@ -67,7 +67,7 @@ export class BerryComponent { warning: "tw-bg-bg-warning", danger: "tw-bg-bg-danger", accentPrimary: "tw-bg-fg-accent-primary-strong", - contrast: "tw-bg-bg-white", + contrast: "tw-bg-bg-primary", }; return [ diff --git a/libs/components/src/berry/berry.stories.ts b/libs/components/src/berry/berry.stories.ts index 0b71e7259d8..56ee87d9ce3 100644 --- a/libs/components/src/berry/berry.stories.ts +++ b/libs/components/src/berry/berry.stories.ts @@ -75,7 +75,9 @@ export const statusType: Story = { - +
+ +
`, }), @@ -153,8 +155,8 @@ export const AllVariants: Story = { -
- Contrast: +
+ Contrast: diff --git a/libs/importer/src/services/default-import-metadata.service.ts b/libs/importer/src/services/default-import-metadata.service.ts index 393c498e118..a9e767178aa 100644 --- a/libs/importer/src/services/default-import-metadata.service.ts +++ b/libs/importer/src/services/default-import-metadata.service.ts @@ -1,11 +1,9 @@ -import { combineLatest, map, Observable } from "rxjs"; +import { map, Observable } from "rxjs"; -import { ClientType, DeviceType } from "@bitwarden/common/enums"; -import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; import { SemanticLogger } from "@bitwarden/common/tools/log"; import { SystemServiceProvider } from "@bitwarden/common/tools/providers"; -import { DataLoader, ImporterMetadata, Importers, ImportersMetadata, Loader } from "../metadata"; +import { ImporterMetadata, Importers, ImportersMetadata } from "../metadata"; import { ImportType } from "../models/import-options"; import { availableLoaders } from "../util"; @@ -15,13 +13,8 @@ export class DefaultImportMetadataService implements ImportMetadataServiceAbstra protected importers: ImportersMetadata = Importers; private logger: SemanticLogger; - private chromiumWithABE$: Observable; - constructor(protected system: SystemServiceProvider) { this.logger = system.log({ type: "ImportMetadataService" }); - this.chromiumWithABE$ = this.system.configService.getFeatureFlag$( - FeatureFlag.ChromiumImporterWithABE, - ); } async init(): Promise { @@ -30,13 +23,13 @@ export class DefaultImportMetadataService implements ImportMetadataServiceAbstra metadata$(type$: Observable): Observable { const client = this.system.environment.getClientType(); - const capabilities$ = combineLatest([type$, this.chromiumWithABE$]).pipe( - map(([type, enabled]) => { + const capabilities$ = type$.pipe( + map((type) => { if (!this.importers) { return { type, loaders: [] }; } - const loaders = this.availableLoaders(this.importers, type, client, enabled); + const loaders = availableLoaders(this.importers, type, client); if (!loaders || loaders.length === 0) { return { type, loaders: [] }; @@ -55,34 +48,4 @@ export class DefaultImportMetadataService implements ImportMetadataServiceAbstra return capabilities$; } - - /** Determine the available loaders for the given import type and client, considering feature flags and environments */ - private availableLoaders( - importers: ImportersMetadata, - type: ImportType, - client: ClientType, - withABESupport: boolean, - ): DataLoader[] | undefined { - let loaders = availableLoaders(importers, type, client); - - if (withABESupport) { - return loaders; - } - - // Special handling for Brave and Chrome CSV imports on Windows Desktop - if (type === "bravecsv" || type === "chromecsv") { - try { - const device = this.system.environment.getDevice(); - const isWindowsDesktop = device === DeviceType.WindowsDesktop; - if (isWindowsDesktop) { - // Exclude the Chromium loader if on Windows Desktop without ABE support - loaders = loaders?.filter((loader) => loader !== Loader.chromium); - } - } catch { - loaders = loaders?.filter((loader) => loader !== Loader.chromium); - } - } - - return loaders; - } } diff --git a/libs/importer/src/services/import-metadata.service.spec.ts b/libs/importer/src/services/import-metadata.service.spec.ts index e16965a69f8..d6c0ff64d87 100644 --- a/libs/importer/src/services/import-metadata.service.spec.ts +++ b/libs/importer/src/services/import-metadata.service.spec.ts @@ -1,9 +1,7 @@ import { mock, MockProxy } from "jest-mock-extended"; -import { BehaviorSubject, Subject, firstValueFrom } from "rxjs"; +import { Subject, firstValueFrom } from "rxjs"; import { ClientType } from "@bitwarden/client-type"; -import { DeviceType } from "@bitwarden/common/enums"; -import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { SystemServiceProvider } from "@bitwarden/common/tools/providers"; @@ -17,13 +15,10 @@ describe("ImportMetadataService", () => { let systemServiceProvider: MockProxy; beforeEach(() => { - const configService = mock(); - const environment = mock(); environment.getClientType.mockReturnValue(ClientType.Desktop); systemServiceProvider = mock({ - configService, environment, log: jest.fn().mockReturnValue({ debug: jest.fn() }), }); @@ -34,7 +29,6 @@ describe("ImportMetadataService", () => { describe("metadata$", () => { let typeSubject: Subject; let mockLogger: { debug: jest.Mock }; - let featureFlagSubject: BehaviorSubject; const environment = mock(); environment.getClientType.mockReturnValue(ClientType.Desktop); @@ -42,13 +36,8 @@ describe("ImportMetadataService", () => { beforeEach(() => { typeSubject = new Subject(); mockLogger = { debug: jest.fn() }; - featureFlagSubject = new BehaviorSubject(false); - - const configService = mock(); - configService.getFeatureFlag$.mockReturnValue(featureFlagSubject); systemServiceProvider = mock({ - configService, environment, log: jest.fn().mockReturnValue(mockLogger), }); @@ -78,7 +67,6 @@ describe("ImportMetadataService", () => { afterEach(() => { typeSubject.complete(); - featureFlagSubject.complete(); }); it("should emit metadata when type$ emits", async () => { @@ -129,86 +117,5 @@ describe("ImportMetadataService", () => { "capabilities updated", ); }); - - it("should update when feature flag changes", async () => { - environment.getDevice.mockReturnValue(DeviceType.WindowsDesktop); - const testType: ImportType = "bravecsv"; // Use bravecsv which supports chromium loader - const emissions: ImporterMetadata[] = []; - - const subscription = sut.metadata$(typeSubject).subscribe((metadata) => { - emissions.push(metadata); - }); - - typeSubject.next(testType); - featureFlagSubject.next(true); - - // Wait for emissions - await new Promise((resolve) => setTimeout(resolve, 0)); - - expect(emissions).toHaveLength(2); - // Disable ABE - chromium loader should be excluded - expect(emissions[0].loaders).not.toContain(Loader.chromium); - // Enabled ABE - chromium loader should be included - expect(emissions[1].loaders).toContain(Loader.chromium); - - subscription.unsubscribe(); - }); - - it("should exclude chromium loader when ABE is disabled and on Windows Desktop", async () => { - environment.getDevice.mockReturnValue(DeviceType.WindowsDesktop); - const testType: ImportType = "bravecsv"; // bravecsv supports both file and chromium loaders - featureFlagSubject.next(false); - - const metadataPromise = firstValueFrom(sut.metadata$(typeSubject)); - typeSubject.next(testType); - - const result = await metadataPromise; - - expect(result.loaders).not.toContain(Loader.chromium); - expect(result.loaders).toContain(Loader.file); - }); - - it("should exclude chromium loader when ABE is disabled and getDevice throws error", async () => { - environment.getDevice.mockImplementation(() => { - throw new Error("Device detection failed"); - }); - const testType: ImportType = "bravecsv"; - featureFlagSubject.next(false); - - const metadataPromise = firstValueFrom(sut.metadata$(typeSubject)); - typeSubject.next(testType); - - const result = await metadataPromise; - - expect(result.loaders).not.toContain(Loader.chromium); - expect(result.loaders).toContain(Loader.file); - }); - - it("should include chromium loader when ABE is disabled and not on Windows Desktop", async () => { - environment.getDevice.mockReturnValue(DeviceType.MacOsDesktop); - const testType: ImportType = "bravecsv"; // bravecsv supports both file and chromium loaders - featureFlagSubject.next(false); - - const metadataPromise = firstValueFrom(sut.metadata$(typeSubject)); - typeSubject.next(testType); - - const result = await metadataPromise; - - expect(result.loaders).toContain(Loader.chromium); - expect(result.loaders).toContain(Loader.file); - }); - - it("should include chromium loader when ABE is enabled regardless of device", async () => { - environment.getDevice.mockReturnValue(DeviceType.MacOsDesktop); - const testType: ImportType = "bravecsv"; // bravecsv supports both file and chromium loaders - featureFlagSubject.next(true); - - const metadataPromise = firstValueFrom(sut.metadata$(typeSubject)); - typeSubject.next(testType); - - const result = await metadataPromise; - - expect(result.loaders).toContain(Loader.chromium); - }); }); }); 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 47c4d14fc98..915f8a2d30e 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 @@ -1,4 +1,4 @@ -import { ComponentFixture, TestBed } from "@angular/core/testing"; +import { ComponentFixture, TestBed, fakeAsync, tick } from "@angular/core/testing"; import { FormBuilder, ReactiveFormsModule } from "@angular/forms"; import { ActivatedRoute, Router } from "@angular/router"; import { mock } from "jest-mock-extended"; @@ -605,4 +605,150 @@ describe("LockComponent", () => { expect(component.activeUnlockOption).toBe(UnlockOption.Biometrics); }); }); + + describe("listenForUnlockOptionsChanges", () => { + const mockActiveAccount: Account = { + id: userId, + email: "test@example.com", + name: "Test User", + } as Account; + + const mockUnlockOptions: UnlockOptions = { + masterPassword: { enabled: true }, + pin: { enabled: false }, + biometrics: { enabled: false, biometricsStatus: BiometricsStatus.Available }, + prf: { enabled: false }, + }; + + beforeEach(() => { + (component as any).loading = false; + component.activeAccount = mockActiveAccount; + component.activeUnlockOption = null; + component.unlockOptions = null; + mockLockComponentService.getAvailableUnlockOptions$.mockReturnValue(of(mockUnlockOptions)); + }); + + it("skips polling when loading is true", fakeAsync(() => { + (component as any).loading = true; + + component["listenForUnlockOptionsChanges"](); + tick(0); + + expect(mockLockComponentService.getAvailableUnlockOptions$).not.toHaveBeenCalled(); + })); + + it("skips polling when activeAccount is null", fakeAsync(() => { + component.activeAccount = null; + + component["listenForUnlockOptionsChanges"](); + tick(0); + + expect(mockLockComponentService.getAvailableUnlockOptions$).not.toHaveBeenCalled(); + })); + + it("fetches unlock options when loading is false and activeAccount exists", fakeAsync(() => { + component["listenForUnlockOptionsChanges"](); + tick(0); + + expect(mockLockComponentService.getAvailableUnlockOptions$).toHaveBeenCalledWith(userId); + expect(component.unlockOptions).toEqual(mockUnlockOptions); + })); + + it("calls getAvailableUnlockOptions$ at 1000ms intervals", fakeAsync(() => { + component["listenForUnlockOptionsChanges"](); + + // Initial timer fire at 0ms + tick(0); + expect(mockLockComponentService.getAvailableUnlockOptions$).toHaveBeenCalledTimes(1); + + // First poll at 1000ms + tick(1000); + expect(mockLockComponentService.getAvailableUnlockOptions$).toHaveBeenCalledTimes(2); + + // Second poll at 2000ms + tick(1000); + expect(mockLockComponentService.getAvailableUnlockOptions$).toHaveBeenCalledTimes(3); + })); + + it("calls setDefaultActiveUnlockOption when activeUnlockOption is null", fakeAsync(() => { + component.activeUnlockOption = null; + const setDefaultSpy = jest.spyOn(component as any, "setDefaultActiveUnlockOption"); + + component["listenForUnlockOptionsChanges"](); + tick(0); + + expect(setDefaultSpy).toHaveBeenCalledWith(mockUnlockOptions); + })); + + it("does NOT call setDefaultActiveUnlockOption when activeUnlockOption is already set", fakeAsync(() => { + component.activeUnlockOption = UnlockOption.MasterPassword; + component.unlockOptions = mockUnlockOptions; + + const setDefaultSpy = jest.spyOn(component as any, "setDefaultActiveUnlockOption"); + + component["listenForUnlockOptionsChanges"](); + tick(0); + + expect(setDefaultSpy).not.toHaveBeenCalled(); + })); + + it("calls setDefaultActiveUnlockOption when biometrics becomes enabled", fakeAsync(() => { + component.activeUnlockOption = UnlockOption.MasterPassword; + + // Start with biometrics disabled + component.unlockOptions = { + masterPassword: { enabled: true }, + pin: { enabled: false }, + biometrics: { enabled: false, biometricsStatus: BiometricsStatus.Available }, + prf: { enabled: false }, + }; + + // Mock response with biometrics enabled + const newUnlockOptions: UnlockOptions = { + masterPassword: { enabled: true }, + pin: { enabled: false }, + biometrics: { enabled: true, biometricsStatus: BiometricsStatus.Available }, + prf: { enabled: false }, + }; + + mockLockComponentService.getAvailableUnlockOptions$.mockReturnValue(of(newUnlockOptions)); + + const setDefaultSpy = jest.spyOn(component as any, "setDefaultActiveUnlockOption"); + const handleBioSpy = jest.spyOn(component as any, "handleBiometricsUnlockEnabled"); + + component["listenForUnlockOptionsChanges"](); + tick(0); + + expect(setDefaultSpy).toHaveBeenCalledWith(newUnlockOptions); + expect(handleBioSpy).toHaveBeenCalled(); + })); + + it("does NOT call setDefaultActiveUnlockOption when biometrics was already enabled", fakeAsync(() => { + component.activeUnlockOption = UnlockOption.MasterPassword; + + // Start with biometrics already enabled + component.unlockOptions = { + masterPassword: { enabled: true }, + pin: { enabled: false }, + biometrics: { enabled: true, biometricsStatus: BiometricsStatus.Available }, + prf: { enabled: false }, + }; + + // Mock response with biometrics still enabled + const newUnlockOptions: UnlockOptions = { + masterPassword: { enabled: true }, + pin: { enabled: false }, + biometrics: { enabled: true, biometricsStatus: BiometricsStatus.Available }, + prf: { enabled: false }, + }; + mockLockComponentService.getAvailableUnlockOptions$.mockReturnValue(of(newUnlockOptions)); + + const setDefaultSpy = jest.spyOn(component as any, "setDefaultActiveUnlockOption"); + + component["listenForUnlockOptionsChanges"](); + tick(0); + + expect(setDefaultSpy).not.toHaveBeenCalled(); + })); + }); }); 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 9900aa6e827..5686e4b334a 100644 --- a/libs/key-management-ui/src/lock/components/lock.component.ts +++ b/libs/key-management-ui/src/lock/components/lock.component.ts @@ -202,7 +202,8 @@ export class LockComponent implements OnInit, OnDestroy { timer(0, 1000) .pipe( mergeMap(async () => { - if (this.activeAccount?.id != null) { + // Only perform polling after the component has loaded. This prevents multiple sources setting the default active unlock option on initialization. + if (this.loading === false && this.activeAccount?.id != null) { const prevBiometricsEnabled = this.unlockOptions?.biometrics.enabled; this.unlockOptions = await firstValueFrom( @@ -210,7 +211,6 @@ export class LockComponent implements OnInit, OnDestroy { ); if (this.activeUnlockOption == null) { - this.loading = false; await this.setDefaultActiveUnlockOption(this.unlockOptions); } else if (!prevBiometricsEnabled && this.unlockOptions?.biometrics.enabled) { await this.setDefaultActiveUnlockOption(this.unlockOptions); @@ -275,19 +275,18 @@ export class LockComponent implements OnInit, OnDestroy { this.lockComponentService.getAvailableUnlockOptions$(activeAccount.id), ); - const canUseBiometrics = [ - BiometricsStatus.Available, - ...BIOMETRIC_UNLOCK_TEMPORARY_UNAVAILABLE_STATUSES, - ].includes(await this.biometricService.getBiometricsStatusForUser(activeAccount.id)); - if ( - !this.unlockOptions?.masterPassword.enabled && - !this.unlockOptions?.pin.enabled && - !canUseBiometrics - ) { - // User has no available unlock options, force logout. This happens for TDE users without a masterpassword, that don't have a persistent unlock method set. - this.logService.warning("[LockComponent] User cannot unlock again. Logging out!"); - await this.logoutService.logout(activeAccount.id); - return; + // The canUseBiometrics query is an expensive operation. Only call if both PIN and master password unlock are unavailable. + if (!this.unlockOptions?.masterPassword.enabled && !this.unlockOptions?.pin.enabled) { + const canUseBiometrics = [ + BiometricsStatus.Available, + ...BIOMETRIC_UNLOCK_TEMPORARY_UNAVAILABLE_STATUSES, + ].includes(await this.biometricService.getBiometricsStatusForUser(activeAccount.id)); + if (!canUseBiometrics) { + // User has no available unlock options, force logout. This happens for TDE users without a masterpassword, that don't have a persistent unlock method set. + this.logService.warning("[LockComponent] User cannot unlock again. Logging out!"); + await this.logoutService.logout(activeAccount.id); + return; + } } await this.setDefaultActiveUnlockOption(this.unlockOptions); diff --git a/libs/tools/send/send-ui/src/send-form/components/send-details/send-details.component.spec.ts b/libs/tools/send/send-ui/src/send-form/components/send-details/send-details.component.spec.ts index f816c9d5ce4..43b2bc7bcd5 100644 --- a/libs/tools/send/send-ui/src/send-form/components/send-details/send-details.component.spec.ts +++ b/libs/tools/send/send-ui/src/send-form/components/send-details/send-details.component.spec.ts @@ -127,4 +127,57 @@ describe("SendDetailsComponent", () => { expect(emailsControl?.validator).toBeNull(); expect(passwordControl?.validator).toBeNull(); }); + + it("should show validation error when emails are cleared while authType is Email", () => { + // Set authType to Email with valid emails + component.sendDetailsForm.patchValue({ + authType: AuthType.Email, + emails: "test@example.com", + }); + expect(component.sendDetailsForm.get("emails")?.valid).toBe(true); + + // Clear emails - should trigger validation error + component.sendDetailsForm.patchValue({ emails: "" }); + expect(component.sendDetailsForm.get("emails")?.valid).toBe(false); + expect(component.sendDetailsForm.get("emails")?.hasError("emailsRequiredForEmailAuth")).toBe( + true, + ); + }); + + it("should clear validation error when authType is changed from Email after clearing emails", () => { + // Set authType to Email and then clear emails + component.sendDetailsForm.patchValue({ + authType: AuthType.Email, + emails: "test@example.com", + }); + component.sendDetailsForm.patchValue({ emails: "" }); + expect(component.sendDetailsForm.get("emails")?.valid).toBe(false); + + // Change authType to None - emails field should become valid (no longer required) + component.sendDetailsForm.patchValue({ authType: AuthType.None }); + expect(component.sendDetailsForm.get("emails")?.valid).toBe(true); + }); + + it("should force user to change authType by blocking form submission when emails are cleared", () => { + // Set up a send with email verification + component.sendDetailsForm.patchValue({ + name: "Test Send", + authType: AuthType.Email, + emails: "user@example.com", + }); + expect(component.sendDetailsForm.valid).toBe(true); + + // User clears emails field + component.sendDetailsForm.patchValue({ emails: "" }); + + // Form should now be invalid, preventing save + expect(component.sendDetailsForm.valid).toBe(false); + expect(component.sendDetailsForm.get("emails")?.hasError("emailsRequiredForEmailAuth")).toBe( + true, + ); + + // User must change authType to continue + component.sendDetailsForm.patchValue({ authType: AuthType.None }); + expect(component.sendDetailsForm.valid).toBe(true); + }); }); diff --git a/libs/tools/send/send-ui/src/send-form/components/send-details/send-details.component.ts b/libs/tools/send/send-ui/src/send-form/components/send-details/send-details.component.ts index ac1453a925c..78681a70a00 100644 --- a/libs/tools/send/send-ui/src/send-form/components/send-details/send-details.component.ts +++ b/libs/tools/send/send-ui/src/send-form/components/send-details/send-details.component.ts @@ -224,7 +224,10 @@ export class SendDetailsComponent implements OnInit { } else if (type === AuthType.Email) { passwordControl.setValue(null); passwordControl.clearValidators(); - emailsControl.setValidators([Validators.required, this.emailListValidator()]); + emailsControl.setValidators([ + this.emailsRequiredForEmailAuthValidator(), + this.emailListValidator(), + ]); } else { emailsControl.setValue(null); emailsControl.clearValidators(); @@ -317,6 +320,23 @@ export class SendDetailsComponent implements OnInit { }; } + emailsRequiredForEmailAuthValidator(): ValidatorFn { + return (control: FormControl): ValidationErrors | null => { + const authType = this.sendDetailsForm?.get("authType")?.value; + const emails = control.value; + + if (authType === AuthType.Email && (!emails || emails.trim() === "")) { + return { + emailsRequiredForEmailAuth: { + message: this.i18nService.t("emailsRequiredChangeAccessType"), + }, + }; + } + + return null; + }; + } + generatePassword = async () => { const on$ = new BehaviorSubject({ source: "send", type: Type.password }); const account$ = this.accountService.activeAccount$.pipe( diff --git a/package-lock.json b/package-lock.json index cac0b978a63..f5ac6ccbc0c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -23,8 +23,8 @@ "@angular/platform-browser": "20.3.16", "@angular/platform-browser-dynamic": "20.3.16", "@angular/router": "20.3.16", - "@bitwarden/commercial-sdk-internal": "0.2.0-main.527", - "@bitwarden/sdk-internal": "0.2.0-main.527", + "@bitwarden/commercial-sdk-internal": "0.2.0-main.550", + "@bitwarden/sdk-internal": "0.2.0-main.550", "@electron/fuses": "1.8.0", "@emotion/css": "11.13.5", "@koa/multer": "4.0.0", @@ -191,7 +191,7 @@ }, "apps/browser": { "name": "@bitwarden/browser", - "version": "2026.1.0" + "version": "2026.1.1" }, "apps/cli": { "name": "@bitwarden/cli", @@ -4941,9 +4941,9 @@ "link": true }, "node_modules/@bitwarden/commercial-sdk-internal": { - "version": "0.2.0-main.527", - "resolved": "https://registry.npmjs.org/@bitwarden/commercial-sdk-internal/-/commercial-sdk-internal-0.2.0-main.527.tgz", - "integrity": "sha512-4C4lwOgA2v184G2axUR5Jdb4UMXMhF52a/3c0lAZYbD/8Nid6jziE89nCa9hdfdazuPgWXhVFa3gPrhLZ4uTUQ==", + "version": "0.2.0-main.550", + "resolved": "https://registry.npmjs.org/@bitwarden/commercial-sdk-internal/-/commercial-sdk-internal-0.2.0-main.550.tgz", + "integrity": "sha512-hYdGV3qs+kKrAMTIvMfolWz23XXZ8bJGzMGi+gh5EBpjTE4OsAsLKp0JDgpjlpE+cdheSFXyhTU9D1Ujdqzzrg==", "license": "BITWARDEN SOFTWARE DEVELOPMENT KIT LICENSE AGREEMENT", "dependencies": { "type-fest": "^4.41.0" @@ -5046,9 +5046,9 @@ "link": true }, "node_modules/@bitwarden/sdk-internal": { - "version": "0.2.0-main.527", - "resolved": "https://registry.npmjs.org/@bitwarden/sdk-internal/-/sdk-internal-0.2.0-main.527.tgz", - "integrity": "sha512-dxPh4XjEGFDBASRBEd/JwUdoMAz10W/0QGygYkPwhKKGzJncfDEAgQ/KrT9wc36ycrDrOOspff7xs/vmmzI0+A==", + "version": "0.2.0-main.550", + "resolved": "https://registry.npmjs.org/@bitwarden/sdk-internal/-/sdk-internal-0.2.0-main.550.tgz", + "integrity": "sha512-uAGgP+Y2FkxOZ74+9C4JHaM+YbJTI3806akeDg7w2yvfNNryIsLncwvb8FoFgiN6dEY1o9YSzuuv0YYUnbAMww==", "license": "GPL-3.0", "dependencies": { "type-fest": "^4.41.0" diff --git a/package.json b/package.json index 7499a69f99c..1795e93cf83 100644 --- a/package.json +++ b/package.json @@ -161,8 +161,8 @@ "@angular/platform-browser": "20.3.16", "@angular/platform-browser-dynamic": "20.3.16", "@angular/router": "20.3.16", - "@bitwarden/commercial-sdk-internal": "0.2.0-main.527", - "@bitwarden/sdk-internal": "0.2.0-main.527", + "@bitwarden/commercial-sdk-internal": "0.2.0-main.550", + "@bitwarden/sdk-internal": "0.2.0-main.550", "@electron/fuses": "1.8.0", "@emotion/css": "11.13.5", "@koa/multer": "4.0.0", diff --git a/tsconfig.base.json b/tsconfig.base.json index ccfd1f720f4..fb76ea752a7 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -24,8 +24,8 @@ "@bitwarden/assets/svg": ["./libs/assets/src/svg/index.ts"], "@bitwarden/auth/angular": ["./libs/auth/src/angular"], "@bitwarden/auth/common": ["./libs/auth/src/common"], - "@bitwarden/auto-confirm": ["libs/auto-confirm/src/index.ts"], - "@bitwarden/auto-confirm/angular": ["libs/auto-confirm/src/angular"], + "@bitwarden/auto-confirm": ["./libs/auto-confirm/src/index.ts"], + "@bitwarden/auto-confirm/angular": ["./libs/auto-confirm/src/angular"], "@bitwarden/billing": ["./libs/billing/src"], "@bitwarden/bit-common/*": ["./bitwarden_license/bit-common/src/*"], "@bitwarden/browser/*": ["./apps/browser/src/*"],