From c2b89608aeb9bf1d32d78d817f6095475ddcffd9 Mon Sep 17 00:00:00 2001 From: Brandon Treston Date: Tue, 17 Feb 2026 10:07:08 -0500 Subject: [PATCH 01/38] fix missing provider (#18974) --- .../src/app/admin-console/providers/providers.module.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) 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 {} From c47881624afb6d00f562da7e59eb476d50d5a630 Mon Sep 17 00:00:00 2001 From: Bernd Schoolmann Date: Tue, 17 Feb 2026 16:43:41 +0100 Subject: [PATCH 02/38] Fix types in auth-request abstraction (#19021) * Fix types in auth-request abstraction * Fix firefox build --- .../login-via-auth-request.component.ts | 2 +- .../abstractions/auth-request.service.abstraction.ts | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) 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 }>; /** From c0e9678c0680122a8d97fa7906788548307019e5 Mon Sep 17 00:00:00 2001 From: Mike Amirault Date: Tue, 17 Feb 2026 10:49:30 -0500 Subject: [PATCH 03/38] [PM-31770] Fix Send expires text formatting (#18926) * [PM-31770] Fix Send expires text formatting * Address PR review comments --- .../send/send-access/send-view.component.html | 13 ++++++------- .../tools/send/send-access/send-view.component.ts | 8 ++++++-- apps/web/src/locales/en/messages.json | 14 ++++++++++++++ 3 files changed, 26 insertions(+), 9 deletions(-) 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..970244119f8 100644 --- a/apps/web/src/locales/en/messages.json +++ b/apps/web/src/locales/en/messages.json @@ -12948,5 +12948,19 @@ }, "paymentMethodUpdateError": { "message": "There was an error updating your payment method." + }, + "sendExpiresOn": { + "message": "This Send expires at $TIME$ on $DATE$", + "placeholders": { + "time": { + "content": "$1", + "example": "10:00 AM" + }, + "date": { + "content": "$2", + "example": "Jan 1, 1970" + } + }, + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." } } From e8df377ba1016b72d6a88209e098ff63945aa121 Mon Sep 17 00:00:00 2001 From: Nik Gilmore Date: Tue, 17 Feb 2026 09:10:39 -0800 Subject: [PATCH 04/38] [PM-30735] Fix bug causing ciphers not to load under certain circumstances. (#18895) --- .../src/vault/popup/services/vault-popup-items.service.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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..016fa330a38 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 @@ -119,7 +119,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$, ]), ), From 3715ed14414905be6c0c1e452d97cbe805df31e2 Mon Sep 17 00:00:00 2001 From: SmithThe4th Date: Tue, 17 Feb 2026 12:14:09 -0500 Subject: [PATCH 05/38] Assign Vault team ownership of shared onboarding components via CODEOWNERS (#19016) --- .github/CODEOWNERS | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 39e5b3f6003..c6c1e42ae52 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 From 4a651fbfb3fbbf3ad73fabfd25fef63e83ada161 Mon Sep 17 00:00:00 2001 From: rr-bw <102181210+rr-bw@users.noreply.github.com> Date: Tue, 17 Feb 2026 10:44:21 -0800 Subject: [PATCH 06/38] refactor(input-password-flows) [Auth/PM-27086] Use new KM Data Types in InputPasswordComponent flows - TDE & Permission User (#18400) Updates the SetInitialPasswordService TDE + Permission user flow to use the new KM data types: - `MasterPasswordAuthenticationData` - `MasterPasswordUnlockData` This allows us to move away from the deprecated `makeMasterKey()` method (which takes email as salt) as we seek to eventually separate the email from the salt. The new `setInitialPasswordTdeUserWithPermission()` method essentially takes the existing deprecated `setInitialPassword()` method and: - Removes logic that is specific to a `JIT_PROVISIONED_MP_ORG_USER` case. This way the method only handles `TDE_ORG_USER_RESET_PASSWORD_PERMISSION_REQUIRES_MP` cases. - Updates the logic to use `MasterPasswordAuthenticationData` and `MasterPasswordUnlockData` Behind feature flag: `pm-27086-update-authentication-apis-for-input-password` --- ...sktop-set-initial-password.service.spec.ts | 66 ++++ .../desktop-set-initial-password.service.ts | 10 + ...initial-password.service.implementation.ts | 138 ++++++++- ...fault-set-initial-password.service.spec.ts | 287 +++++++++++++++++- .../set-initial-password.component.ts | 47 +++ ...et-initial-password.service.abstraction.ts | 23 ++ 6 files changed, 568 insertions(+), 3 deletions(-) diff --git a/apps/desktop/src/app/services/set-initial-password/desktop-set-initial-password.service.spec.ts b/apps/desktop/src/app/services/set-initial-password/desktop-set-initial-password.service.spec.ts index 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/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: From 8a670f17318b7c8534c9c468eaf8075bff295038 Mon Sep 17 00:00:00 2001 From: Brandon Treston Date: Tue, 17 Feb 2026 14:05:14 -0500 Subject: [PATCH 07/38] fix circular dependency (#19023) --- .../src/popup/services/services.module.ts | 29 ++----------------- apps/web/src/app/core/core.module.ts | 22 +------------- .../src/services/jslib-services.module.ts | 18 +++++++++++- 3 files changed, 21 insertions(+), 48 deletions(-) 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/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/libs/angular/src/services/jslib-services.module.ts b/libs/angular/src/services/jslib-services.module.ts index 2fbf55bf6c5..9d407f0f310 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") From 044db949fa7d569117fa91e65c5dbaedd516f394 Mon Sep 17 00:00:00 2001 From: aj-bw <81774843+aj-bw@users.noreply.github.com> Date: Tue, 17 Feb 2026 15:06:48 -0500 Subject: [PATCH 08/38] remove release draft key for DAS test enablement (#19035) --- .github/workflows/release-web.yml | 1 - 1 file changed, 1 deletion(-) 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 From e760b1c9236396d25f2d84d145b6fc6c47bafbb8 Mon Sep 17 00:00:00 2001 From: bitwarden-devops-bot <106330231+bitwarden-devops-bot@users.noreply.github.com> Date: Tue, 17 Feb 2026 20:45:06 +0000 Subject: [PATCH 09/38] Bumped client version(s) --- apps/browser/package.json | 2 +- apps/browser/src/manifest.json | 2 +- apps/browser/src/manifest.v3.json | 2 +- package-lock.json | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) 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/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/package-lock.json b/package-lock.json index 789a63c07b5..8d3c32c027d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -191,7 +191,7 @@ }, "apps/browser": { "name": "@bitwarden/browser", - "version": "2026.1.0" + "version": "2026.1.1" }, "apps/cli": { "name": "@bitwarden/cli", From 61326979b9ff2f22d900013eae09934a5e69ee40 Mon Sep 17 00:00:00 2001 From: SmithThe4th Date: Tue, 17 Feb 2026 16:07:14 -0500 Subject: [PATCH 10/38] Updated delete attachment to conform with the server side changes (#19014) --- libs/common/src/abstractions/api.service.ts | 11 +++++++-- libs/common/src/services/api.service.ts | 23 +++++++++++++++---- .../response/delete-attachment.response.ts | 12 ++++++++++ .../src/vault/services/cipher.service.ts | 7 +++--- .../file-upload/cipher-file-upload.service.ts | 8 +++---- 5 files changed, 48 insertions(+), 13 deletions(-) create mode 100644 libs/common/src/vault/models/response/delete-attachment.response.ts 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/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); } }; } From 9f18a68707d6fb2220b585a9dfe788bfae01a12d Mon Sep 17 00:00:00 2001 From: John Harrington <84741727+harr1424@users.noreply.github.com> Date: Tue, 17 Feb 2026 14:10:55 -0700 Subject: [PATCH 11/38] remove unwanted text and place wanted in bit-hint (#18989) --- .../tools/send/send-access/send-access-password.component.html | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) 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 }}
From e262441999e4e243f903c8a781fcefc7906fa60c Mon Sep 17 00:00:00 2001 From: Dave <3836813+enmande@users.noreply.github.com> Date: Tue, 17 Feb 2026 17:43:00 -0500 Subject: [PATCH 12/38] [PM-31088] saltForUser should emit salt from master password unlock data (#18976) * feat(salt-for-user) [PM-31088]: Add feature flag for saltForUser. * feat(salt-for-user) [PM-31088]: Flag saltForUser logic to return unlockdata.salt or emailToSalt. * test(salt-for-user) [PM-31088]: Update tests to include coverage for new behavior. --- libs/common/src/enums/feature-flag.enum.ts | 2 + .../services/master-password.service.spec.ts | 47 +++++++++++++++++-- .../services/master-password.service.ts | 30 ++++++++++-- 3 files changed, 72 insertions(+), 7 deletions(-) diff --git a/libs/common/src/enums/feature-flag.enum.ts b/libs/common/src/enums/feature-flag.enum.ts index 05fded6bcaf..71b95ec6057 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", @@ -143,6 +144,7 @@ export const DefaultFeatureFlagValue = { [FeatureFlag.PM23801_PrefetchPasswordPrelogin]: FALSE, [FeatureFlag.PM27086_UpdateAuthenticationApisForInputPassword]: FALSE, [FeatureFlag.SafariAccountSwitching]: FALSE, + [FeatureFlag.PM31088_MasterPasswordServiceEmitSalt]: FALSE, /* Billing */ [FeatureFlag.TrialPaymentOptional]: 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)), + ), + ), + ), ); } From 24c3b8fb2bfb51abb3b7674b1e000577f71f289d Mon Sep 17 00:00:00 2001 From: Jordan Aasen <166539328+jaasen-livefront@users.noreply.github.com> Date: Tue, 17 Feb 2026 16:43:23 -0800 Subject: [PATCH 13/38] fix autofill on click behavior (#19046) --- .../autofill-vault-list-items.component.html | 2 +- .../vault-list-items-container.component.ts | 5 +++-- 2 files changed, 4 insertions(+), 3 deletions(-) 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.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(); }); From ff775c7bbc4867a7d42c8bfceaa5acce1c570f9b Mon Sep 17 00:00:00 2001 From: Jordan Aasen <166539328+jaasen-livefront@users.noreply.github.com> Date: Tue, 17 Feb 2026 16:43:37 -0800 Subject: [PATCH 14/38] fix click on "Fill" text (#19047) --- .../vault-list-items-container.component.html | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) 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()) { From ec33ea4f3c661050f458677aa1ecd7773be234df Mon Sep 17 00:00:00 2001 From: Alex <55413326+AlexRubik@users.noreply.github.com> Date: Tue, 17 Feb 2026 21:29:41 -0700 Subject: [PATCH 15/38] [PM-27782] Update Access Intelligence loading state text (#18808) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * [PM-27782] Update Access Intelligence loading state text Simplify the loading progress messages shown during Access Intelligence report generation to be more user-friendly and concise. Changes: - Add new i18n keys with simplified text - Update ProgressStepConfig to use new keys Progress message updates: - "Fetching member data..." → "Reviewing member data..." - "Analyzing password health..." → "Analyzing passwords..." - "Calculating risk scores..." → "Calculating risks..." - "Generating report data..." → "Generating reports..." - "Saving report..." → "Compiling insights..." - "Compiling insights..." → "Done!" * delete old messages * remove all "this might take a few minutes" --- apps/web/src/locales/en/messages.json | 37 +++++++++---------- .../shared/report-loading.component.html | 13 ++----- .../shared/report-loading.component.ts | 12 +++--- 3 files changed, 27 insertions(+), 35 deletions(-) diff --git a/apps/web/src/locales/en/messages.json b/apps/web/src/locales/en/messages.json index 970244119f8..cc73a04b81b 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" 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 From 03340aee7102f4c296b0e83e732bff7d7f14cf1c Mon Sep 17 00:00:00 2001 From: Alex <55413326+AlexRubik@users.noreply.github.com> Date: Tue, 17 Feb 2026 21:31:08 -0700 Subject: [PATCH 16/38] [PM-31163] stabilize table column widths with fixed layout (#18708) * stabilize table column widths with fixed layout (PM-31163) Add layout="fixed" and explicit width classes to report tables to prevent column widths from shifting during virtual scroll. Files changed: - weak-passwords-report.component.html - reused-passwords-report.component.html - exposed-passwords-report.component.html - inactive-two-factor-report.component.html - unsecured-websites-report.component.html * use auto width for name column to fix width calculation (PM-31163) Remove tw-w-1/2 from name column headers. With layout="fixed", the explicit percentages didn't sum to 100%, causing inconsistent column widths. Before: | 48px | 50% | 25% | 25% | = 48px + 100% (overflow) After: | 48px | auto | 25% | 25% | = columns sum correctly Name column now uses auto to fill remaining space. * render headers in Admin Console to fix column widths (PM-31163) Admin Console reports had a very wide icon column because no headers were rendered. Without headers, table-layout: fixed uses data row content to determine column widths, causing inconsistent sizing. Root cause: Three reports had their entire block inside @if (!isAdminConsoleActive), so when isAdminConsoleActive=true (Admin Console), no headers were rendered at all. Before (broken): @if (!isAdminConsoleActive) { Icon Name Owner } After (fixed): Icon Name @if (!isAdminConsoleActive) { Owner } This matches the pattern already used by weak-passwords-report and exposed-passwords-report, which were working correctly. Files changed: - unsecured-websites-report.component.html - reused-passwords-report.component.html - inactive-two-factor-report.component.html Result: - Admin Console now renders headers with correct column widths - Icon column is 48px (tw-w-12) as expected - Owner column properly hidden in Admin Console view * truncate long item names to prevent column overflow - you can hover cursor for tooltip to see full name --- .../exposed-passwords-report.component.html | 12 ++--- .../inactive-two-factor-report.component.html | 46 ++++++++++-------- .../reused-passwords-report.component.html | 48 ++++++++++--------- .../unsecured-websites-report.component.html | 46 ++++++++++-------- .../weak-passwords-report.component.html | 10 ++-- 5 files changed, 87 insertions(+), 75 deletions(-) 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) { } } - - @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/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/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/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) { Date: Wed, 18 Feb 2026 09:32:08 +0100 Subject: [PATCH 17/38] Fix non-relative imports (#19022) --- tsconfig.base.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tsconfig.base.json b/tsconfig.base.json index 17f8f6d44fc..995eac031fd 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/*"], From cf5e19463937c72f48bc3e8275558181de854bd2 Mon Sep 17 00:00:00 2001 From: Isaiah Inuwa Date: Wed, 18 Feb 2026 06:57:29 -0600 Subject: [PATCH 18/38] [BRE-1621] Fix Appx Release (#19043) * Revert to electron-builder appx manifest template * Remove comments * Remove unnecessary namespaces * Re-include Tamil translation files * Reinstate bitwarden protocol handler * Set minimum version to Windows 10 2016 Anniversary Update * Fix spacing --- apps/desktop/custom-appx-manifest.xml | 25 ++++++++++++----------- apps/desktop/electron-builder.beta.json | 1 - apps/desktop/electron-builder.json | 1 - apps/desktop/scripts/appx-cross-build.ps1 | 3 ++- 4 files changed, 15 insertions(+), 15 deletions(-) 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 From 51731c1526470bb20a4eb46a46a15c73b6746599 Mon Sep 17 00:00:00 2001 From: bitwarden-devops-bot <106330231+bitwarden-devops-bot@users.noreply.github.com> Date: Wed, 18 Feb 2026 14:32:21 +0000 Subject: [PATCH 19/38] Bumped client version(s) --- apps/desktop/package.json | 2 +- apps/desktop/src/package-lock.json | 4 ++-- apps/desktop/src/package.json | 2 +- package-lock.json | 2 +- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/apps/desktop/package.json b/apps/desktop/package.json index cd2147d21e4..5718c752a7c 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -1,7 +1,7 @@ { "name": "@bitwarden/desktop", "description": "A secure and free password manager for all of your devices.", - "version": "2026.2.0", + "version": "2026.2.1", "keywords": [ "bitwarden", "password", diff --git a/apps/desktop/src/package-lock.json b/apps/desktop/src/package-lock.json index 0aa188eba2f..01c429ab3d0 100644 --- a/apps/desktop/src/package-lock.json +++ b/apps/desktop/src/package-lock.json @@ -1,12 +1,12 @@ { "name": "@bitwarden/desktop", - "version": "2026.2.0", + "version": "2026.2.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@bitwarden/desktop", - "version": "2026.2.0", + "version": "2026.2.1", "license": "GPL-3.0", "dependencies": { "@bitwarden/desktop-napi": "file:../desktop_native/napi" diff --git a/apps/desktop/src/package.json b/apps/desktop/src/package.json index 0076981ab60..fac797b5344 100644 --- a/apps/desktop/src/package.json +++ b/apps/desktop/src/package.json @@ -2,7 +2,7 @@ "name": "@bitwarden/desktop", "productName": "Bitwarden", "description": "A secure and free password manager for all of your devices.", - "version": "2026.2.0", + "version": "2026.2.1", "author": "Bitwarden Inc. (https://bitwarden.com)", "homepage": "https://bitwarden.com", "license": "GPL-3.0", diff --git a/package-lock.json b/package-lock.json index 8d3c32c027d..bf532fba66a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -232,7 +232,7 @@ }, "apps/desktop": { "name": "@bitwarden/desktop", - "version": "2026.2.0", + "version": "2026.2.1", "hasInstallScript": true, "license": "GPL-3.0" }, From 5161a232f52cd714ad64b5ff6b4b42d4e07a43c6 Mon Sep 17 00:00:00 2001 From: Alex Morask <144709477+amorask-bitwarden@users.noreply.github.com> Date: Wed, 18 Feb 2026 09:06:10 -0600 Subject: [PATCH 20/38] [PM-29055] Remove pm-25379-use-new-organization-metadata-structure feature flag (#18848) Remove the fully-enabled feature flag and simplify the billing metadata API to always use the vNext endpoints. The legacy API path is removed since the server will no longer serve it. - Remove FeatureFlag.PM25379_UseNewOrganizationMetadataStructure enum and default - Delete legacy getOrganizationBillingMetadata() API method (old /billing/metadata path) - Rename vNext methods to remove VNext suffix - Simplify OrganizationMetadataService to always use cached vNext path - Remove ConfigService dependency from OrganizationMetadataService - Update tests to remove feature flag branching --- .../src/services/jslib-services.module.ts | 2 +- .../billing-api.service.abstraction.ts | 6 +- .../billing/services/billing-api.service.ts | 16 +- .../organization-metadata.service.spec.ts | 264 ++++++------------ .../organization-metadata.service.ts | 57 +--- libs/common/src/enums/feature-flag.enum.ts | 2 - 6 files changed, 98 insertions(+), 249 deletions(-) diff --git a/libs/angular/src/services/jslib-services.module.ts b/libs/angular/src/services/jslib-services.module.ts index 9d407f0f310..02ec9833d6f 100644 --- a/libs/angular/src/services/jslib-services.module.ts +++ b/libs/angular/src/services/jslib-services.module.ts @@ -1528,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/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 71b95ec6057..d252f7dcda5 100644 --- a/libs/common/src/enums/feature-flag.enum.ts +++ b/libs/common/src/enums/feature-flag.enum.ts @@ -31,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", @@ -149,7 +148,6 @@ export const DefaultFeatureFlagValue = { /* 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, From dda862a8c6924d7e202be91f032b6c9c5037d085 Mon Sep 17 00:00:00 2001 From: Isaiah Inuwa Date: Wed, 18 Feb 2026 09:39:58 -0600 Subject: [PATCH 21/38] Revert "Bumped client version(s)" (#19062) This reverts commit 51731c1526470bb20a4eb46a46a15c73b6746599. The desktop version was bumped erroneously, skipping 2026.2.0. --- apps/desktop/package.json | 2 +- apps/desktop/src/package-lock.json | 4 ++-- apps/desktop/src/package.json | 2 +- package-lock.json | 2 +- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 5718c752a7c..cd2147d21e4 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -1,7 +1,7 @@ { "name": "@bitwarden/desktop", "description": "A secure and free password manager for all of your devices.", - "version": "2026.2.1", + "version": "2026.2.0", "keywords": [ "bitwarden", "password", diff --git a/apps/desktop/src/package-lock.json b/apps/desktop/src/package-lock.json index 01c429ab3d0..0aa188eba2f 100644 --- a/apps/desktop/src/package-lock.json +++ b/apps/desktop/src/package-lock.json @@ -1,12 +1,12 @@ { "name": "@bitwarden/desktop", - "version": "2026.2.1", + "version": "2026.2.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@bitwarden/desktop", - "version": "2026.2.1", + "version": "2026.2.0", "license": "GPL-3.0", "dependencies": { "@bitwarden/desktop-napi": "file:../desktop_native/napi" diff --git a/apps/desktop/src/package.json b/apps/desktop/src/package.json index fac797b5344..0076981ab60 100644 --- a/apps/desktop/src/package.json +++ b/apps/desktop/src/package.json @@ -2,7 +2,7 @@ "name": "@bitwarden/desktop", "productName": "Bitwarden", "description": "A secure and free password manager for all of your devices.", - "version": "2026.2.1", + "version": "2026.2.0", "author": "Bitwarden Inc. (https://bitwarden.com)", "homepage": "https://bitwarden.com", "license": "GPL-3.0", diff --git a/package-lock.json b/package-lock.json index bf532fba66a..8d3c32c027d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -232,7 +232,7 @@ }, "apps/desktop": { "name": "@bitwarden/desktop", - "version": "2026.2.1", + "version": "2026.2.0", "hasInstallScript": true, "license": "GPL-3.0" }, From 1ef8f257b0120fbef5f22e1828e2facea9a9913e Mon Sep 17 00:00:00 2001 From: Alex <55413326+AlexRubik@users.noreply.github.com> Date: Wed, 18 Feb 2026 09:00:36 -0700 Subject: [PATCH 22/38] [PM-31803] Fix Password Manager reports not displaying items with limited collection access (#18956) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When "Owners and admins can manage all collections and items" is OFF, Password Manager reports incorrectly filter out items from collections where the user has "Can view", "Can view except passwords", or "Can edit except passwords" access. The root cause is that all five PM report components filter ciphers using `(!this.organization && !edit) || !viewPassword`. Since PM reports run without an organization context (this.organization is undefined), this condition excludes any item where edit=false or viewPassword=false. These permission checks are unnecessary for PM reports because: 1. Personal vault items always have edit=true and viewPassword=true, so the checks never applied to them. 2. Organization items should appear in reports regardless of permission level — the user has collection access, and edit restrictions should only affect the item dialog, not report visibility. 3. Admin Console reports (which work correctly) skip this filtering because this.organization is always set, making the condition always false. This also explains why "Can edit except passwords" items only appeared in the Unsecured Websites report — it was the only report that didn't check !viewPassword. Removed the edit/viewPassword filter conditions from all five PM report components: - exposed-passwords-report - weak-passwords-report - reused-passwords-report - inactive-two-factor-report - unsecured-websites-report --- ...exposed-passwords-report.component.spec.ts | 15 +++++------- .../exposed-passwords-report.component.ts | 6 ++--- ...active-two-factor-report.component.spec.ts | 23 ++++++++----------- .../inactive-two-factor-report.component.ts | 6 ++--- .../reused-passwords-report.component.spec.ts | 14 +++++------ .../reused-passwords-report.component.ts | 6 ++--- ...nsecured-websites-report.component.spec.ts | 11 ++++----- .../unsecured-websites-report.component.ts | 7 +----- .../weak-passwords-report.component.spec.ts | 15 +++++------- .../pages/weak-passwords-report.component.ts | 11 ++------- 10 files changed, 41 insertions(+), 73 deletions(-) diff --git a/apps/web/src/app/dirt/reports/pages/exposed-passwords-report.component.spec.ts b/apps/web/src/app/dirt/reports/pages/exposed-passwords-report.component.spec.ts index e056ec44af5..81e4a78b491 100644 --- a/apps/web/src/app/dirt/reports/pages/exposed-passwords-report.component.spec.ts +++ b/apps/web/src/app/dirt/reports/pages/exposed-passwords-report.component.spec.ts @@ -122,19 +122,16 @@ describe("ExposedPasswordsReportComponent", () => { 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.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.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.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.spec.ts b/apps/web/src/app/dirt/reports/pages/weak-passwords-report.component.spec.ts index a63723dc688..f9aca0aa378 100644 --- a/apps/web/src/app/dirt/reports/pages/weak-passwords-report.component.spec.ts +++ b/apps/web/src/app/dirt/reports/pages/weak-passwords-report.component.spec.ts @@ -114,10 +114,7 @@ describe("WeakPasswordsReportComponent", () => { 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; } From bc6b1c3b831778b64e75988e104dbec361113a4c Mon Sep 17 00:00:00 2001 From: John Harrington <84741727+harr1424@users.noreply.github.com> Date: Wed, 18 Feb 2026 09:34:57 -0700 Subject: [PATCH 23/38] [PM-32242] Error message is incorrectly formatted for password protected Send (#18991) * re-work error display to match design specs * fix password auth in attemptV1Access * fix locales file (formatting) --- .../send/send-access/send-auth.component.ts | 27 ++++++++++++++++--- apps/web/src/locales/en/messages.json | 8 ++++-- 2 files changed, 29 insertions(+), 6 deletions(-) diff --git a/apps/web/src/app/tools/send/send-access/send-auth.component.ts b/apps/web/src/app/tools/send/send-access/send-auth.component.ts index 8c630ce5315..92c3d445333 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 @@ -104,7 +104,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 { @@ -175,11 +195,10 @@ export class SendAuthComponent implements OnInit { 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/locales/en/messages.json b/apps/web/src/locales/en/messages.json index cc73a04b81b..4731be36ef5 100644 --- a/apps/web/src/locales/en/messages.json +++ b/apps/web/src/locales/en/messages.json @@ -12946,8 +12946,13 @@ "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", @@ -12957,7 +12962,6 @@ "content": "$2", "example": "Jan 1, 1970" } - }, - "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + } } } From f7f06267ee22e7ba06fc8ef02c8c854810529ada Mon Sep 17 00:00:00 2001 From: Mike Amirault Date: Wed, 18 Feb 2026 11:50:52 -0500 Subject: [PATCH 24/38] [PM-31347] Add missing messages resulting in empty toast on invalid export master password (#19037) --- apps/browser/src/_locales/en/messages.json | 3 +++ apps/desktop/src/locales/en/messages.json | 3 +++ 2 files changed, 6 insertions(+) diff --git a/apps/browser/src/_locales/en/messages.json b/apps/browser/src/_locales/en/messages.json index a221dc4f338..5ed97ce0f07 100644 --- a/apps/browser/src/_locales/en/messages.json +++ b/apps/browser/src/_locales/en/messages.json @@ -6173,5 +6173,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/desktop/src/locales/en/messages.json b/apps/desktop/src/locales/en/messages.json index f444265877d..85ef3d94001 100644 --- a/apps/desktop/src/locales/en/messages.json +++ b/apps/desktop/src/locales/en/messages.json @@ -4617,5 +4617,8 @@ }, "emailPlaceholder": { "message": "user@bitwarden.com , user@acme.com" + }, + "userVerificationFailed": { + "message": "User verification failed." } } From 935bf3655cd8ac0c62eff3956d116df0739044b6 Mon Sep 17 00:00:00 2001 From: Bernd Schoolmann Date: Wed, 18 Feb 2026 18:08:16 +0100 Subject: [PATCH 25/38] Update sdk to 546 (#19056) --- package-lock.json | 16 ++++++++-------- package.json | 4 ++-- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/package-lock.json b/package-lock.json index 8d3c32c027d..fb1111a82b9 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.546", + "@bitwarden/sdk-internal": "0.2.0-main.546", "@electron/fuses": "1.8.0", "@emotion/css": "11.13.5", "@koa/multer": "4.0.0", @@ -4936,9 +4936,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.546", + "resolved": "https://registry.npmjs.org/@bitwarden/commercial-sdk-internal/-/commercial-sdk-internal-0.2.0-main.546.tgz", + "integrity": "sha512-3lIQSb1yYSpDqhgT2uqHjPC88yVL7rWR08i0XD0BQJMFfN0FcB378r2Fq6d5TMXLPEYZ8PR62BCDB+tYKM7FPw==", "license": "BITWARDEN SOFTWARE DEVELOPMENT KIT LICENSE AGREEMENT", "dependencies": { "type-fest": "^4.41.0" @@ -5041,9 +5041,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.546", + "resolved": "https://registry.npmjs.org/@bitwarden/sdk-internal/-/sdk-internal-0.2.0-main.546.tgz", + "integrity": "sha512-KGPyP1pr7aIBaJ9Knibpfjydo/27Rlve77X4ENmDIwrSJ9FB3o2B6D3UXpNNVyXKt2Ii1C+rNT7ezMRO25Qs4A==", "license": "GPL-3.0", "dependencies": { "type-fest": "^4.41.0" diff --git a/package.json b/package.json index 7499a69f99c..c18112989fe 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.546", + "@bitwarden/sdk-internal": "0.2.0-main.546", "@electron/fuses": "1.8.0", "@emotion/css": "11.13.5", "@koa/multer": "4.0.0", From c086df14e7c27433eda798a10abad4a25c1635bb Mon Sep 17 00:00:00 2001 From: Todd Martin <106564991+trmartin4@users.noreply.github.com> Date: Wed, 18 Feb 2026 12:56:53 -0500 Subject: [PATCH 26/38] chore(ownership): Move account-fingerprint to KM ownership --- .../organizations/settings/organization-settings.module.ts | 2 +- apps/web/src/app/auth/settings/account/profile.component.ts | 2 +- .../account-fingerprint/account-fingerprint.component.html | 0 .../account-fingerprint/account-fingerprint.component.ts | 2 +- 4 files changed, 3 insertions(+), 3 deletions(-) rename apps/web/src/app/{shared/components => key-management}/account-fingerprint/account-fingerprint.component.html (100%) rename apps/web/src/app/{shared/components => key-management}/account-fingerprint/account-fingerprint.component.ts (96%) 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/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 From 5444869456317fc77f43d4639155838f0ce890b9 Mon Sep 17 00:00:00 2001 From: Isaac Ivins Date: Wed, 18 Feb 2026 13:20:08 -0500 Subject: [PATCH 27/38] PM-31733: Sends Drawer Persisting On Side Nav Change (#18762) * using activeDrawerRef with onDestroy * improved refs type checking - removed cdr --- .../app/tools/send-v2/send-v2.component.ts | 24 ++++++++++++++----- 1 file changed, 18 insertions(+), 6 deletions(-) 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; From ab595900196800944cdc6fa8f286eaa03e2038a0 Mon Sep 17 00:00:00 2001 From: Stephon Brown Date: Wed, 18 Feb 2026 14:32:08 -0500 Subject: [PATCH 28/38] [PM-29823] Add Tests for Updates (#19040) * refactor: Remove direct self-hosted org creation from OrganizationPlansComponent * tests: Add comprehensive test suite for OrganizationPlansComponent --- .../organization-plans.component.spec.ts | 2199 +++++++++++++++++ .../organization-plans.component.ts | 27 +- 2 files changed, 2200 insertions(+), 26 deletions(-) create mode 100644 apps/web/src/app/billing/organizations/organization-plans.component.spec.ts 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 = From 6dea7504a6105d5d34525e4d85eccf375cd7ef3e Mon Sep 17 00:00:00 2001 From: Mike Amirault Date: Wed, 18 Feb 2026 14:49:51 -0500 Subject: [PATCH 29/38] [PM-26732] Remove Chromium ABE importer feature flag (#19039) --- libs/common/src/enums/feature-flag.enum.ts | 2 - .../default-import-metadata.service.ts | 47 +-------- .../services/import-metadata.service.spec.ts | 95 +------------------ 3 files changed, 6 insertions(+), 138 deletions(-) diff --git a/libs/common/src/enums/feature-flag.enum.ts b/libs/common/src/enums/feature-flag.enum.ts index d252f7dcda5..5160e6aa542 100644 --- a/libs/common/src/enums/feature-flag.enum.ts +++ b/libs/common/src/enums/feature-flag.enum.ts @@ -53,7 +53,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", @@ -120,7 +119,6 @@ export const DefaultFeatureFlagValue = { /* Tools */ [FeatureFlag.UseSdkPasswordGenerators]: FALSE, - [FeatureFlag.ChromiumImporterWithABE]: FALSE, [FeatureFlag.SendUIRefresh]: FALSE, [FeatureFlag.SendEmailOTP]: FALSE, 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); - }); }); }); From bca2ebaca9b53b519e08877e9bf1c25a8d7d3883 Mon Sep 17 00:00:00 2001 From: Jason Ng Date: Wed, 18 Feb 2026 16:22:50 -0500 Subject: [PATCH 30/38] [PM-30122] allow no folders inside browser folder settings (#19041) --- .../src/vault/popup/settings/folders.component.spec.ts | 3 ++- apps/browser/src/vault/popup/settings/folders.component.ts | 7 ------- 2 files changed, 2 insertions(+), 8 deletions(-) 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; - }), ); } From 263ec9412433f1c87360b5810184e4e43fd3d5d2 Mon Sep 17 00:00:00 2001 From: John Harrington <84741727+harr1424@users.noreply.github.com> Date: Wed, 18 Feb 2026 14:59:34 -0700 Subject: [PATCH 31/38] [PM-32161] Remove all emails when email list field is cleared and send is saved (#18959) * add new validation criteria to prevent authType.Email with an empty emails field * simplify validation logic --- apps/browser/src/_locales/en/messages.json | 4 +- apps/desktop/src/locales/en/messages.json | 3 ++ apps/web/src/locales/en/messages.json | 3 ++ .../send-details.component.spec.ts | 53 +++++++++++++++++++ .../send-details/send-details.component.ts | 22 +++++++- 5 files changed, 83 insertions(+), 2 deletions(-) diff --git a/apps/browser/src/_locales/en/messages.json b/apps/browser/src/_locales/en/messages.json index 5ed97ce0f07..cc99e0abe18 100644 --- a/apps/browser/src/_locales/en/messages.json +++ b/apps/browser/src/_locales/en/messages.json @@ -6160,10 +6160,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" }, diff --git a/apps/desktop/src/locales/en/messages.json b/apps/desktop/src/locales/en/messages.json index 85ef3d94001..3f005db0ba8 100644 --- a/apps/desktop/src/locales/en/messages.json +++ b/apps/desktop/src/locales/en/messages.json @@ -4615,6 +4615,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" }, diff --git a/apps/web/src/locales/en/messages.json b/apps/web/src/locales/en/messages.json index 4731be36ef5..ba59184a9f9 100644 --- a/apps/web/src/locales/en/messages.json +++ b/apps/web/src/locales/en/messages.json @@ -12866,6 +12866,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" }, 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( From f8b5e15a44c1f5770f9057e1d7fd9be7feb8d4fc Mon Sep 17 00:00:00 2001 From: John Harrington <84741727+harr1424@users.noreply.github.com> Date: Wed, 18 Feb 2026 15:08:57 -0700 Subject: [PATCH 32/38] [PM-31731] [Defect] No error is returned when entering an invalid email + an invalid verification code (#18913) * share i18n key for both invalid email and invalid otp submission * claude review --- apps/browser/src/_locales/en/messages.json | 3 +++ apps/cli/src/locales/en/messages.json | 3 +++ apps/desktop/src/locales/en/messages.json | 3 +++ .../app/tools/send/send-access/send-auth.component.ts | 11 ++++++++++- apps/web/src/locales/en/messages.json | 3 +++ 5 files changed, 22 insertions(+), 1 deletion(-) diff --git a/apps/browser/src/_locales/en/messages.json b/apps/browser/src/_locales/en/messages.json index cc99e0abe18..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.", 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/src/locales/en/messages.json b/apps/desktop/src/locales/en/messages.json index 3f005db0ba8..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" }, 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 92c3d445333..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); @@ -184,12 +185,20 @@ 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); diff --git a/apps/web/src/locales/en/messages.json b/apps/web/src/locales/en/messages.json index ba59184a9f9..b257a68052d 100644 --- a/apps/web/src/locales/en/messages.json +++ b/apps/web/src/locales/en/messages.json @@ -7397,6 +7397,9 @@ "invalidVerificationCode": { "message": "Invalid verification code" }, + "invalidEmailOrVerificationCode": { + "message": "Invalid email or verification code" + }, "keyConnectorDomain": { "message": "Key Connector domain" }, From c90b4ded33feb26ea69799ead93989cb989d4a82 Mon Sep 17 00:00:00 2001 From: Meteoni-San <141850520+Meteony@users.noreply.github.com> Date: Wed, 18 Feb 2026 23:22:38 +0100 Subject: [PATCH 33/38] Revert "Inform user if Desktop client already running (#17846)" as per user feedback (#18897) This reverts commit a199744e2456fde1863dba0d89320ac659d04e32. Co-authored-by: neuronull <9162534+neuronull@users.noreply.github.com> --- apps/desktop/src/main/window.main.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) 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 { From d1250cf5a4449501bd1c3d0c7b0c8cab5f16d129 Mon Sep 17 00:00:00 2001 From: Jackson Engstrom Date: Wed, 18 Feb 2026 14:34:17 -0800 Subject: [PATCH 34/38] [PM-26704] Vault List Item Ordering for Extension (#18853) * shows all/filtered ciphers in allItems instead of the ones that haven't been bubbled up into autofill or favorites * removes remainingCiphers$ remnants * updates loading$ observable logic * updates loading$ test --- .../components/vault/vault.component.html | 2 +- .../popup/components/vault/vault.component.ts | 1 - .../vault-popup-items.service.spec.ts | 35 ++----------------- .../services/vault-popup-items.service.ts | 22 +----------- 4 files changed, 4 insertions(+), 56 deletions(-) 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 016fa330a38..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, @@ -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. From 1efd74daafd8d4488ef3fb3cb1f512bd5a04b85c Mon Sep 17 00:00:00 2001 From: Leslie Xiong Date: Wed, 18 Feb 2026 17:59:18 -0500 Subject: [PATCH 35/38] fixed berry styles for dark mode (#19068) --- libs/components/src/berry/berry.component.ts | 4 ++-- libs/components/src/berry/berry.stories.ts | 8 +++++--- 2 files changed, 7 insertions(+), 5 deletions(-) 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: From c9b821262c5f1589571645e44dddd02ec8bb51b1 Mon Sep 17 00:00:00 2001 From: Thomas Avery <43214426+Thomas-Avery@users.noreply.github.com> Date: Wed, 18 Feb 2026 17:08:33 -0600 Subject: [PATCH 36/38] [PM-30927] Fix lock component initialization bug (#18822) --- .../lock/components/lock.component.spec.ts | 148 +++++++++++++++++- .../src/lock/components/lock.component.ts | 29 ++-- 2 files changed, 161 insertions(+), 16 deletions(-) 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); From 6498ec42f8e2433369e6686c5b658a4d8d8aa835 Mon Sep 17 00:00:00 2001 From: Bernd Schoolmann Date: Thu, 19 Feb 2026 14:04:43 +0100 Subject: [PATCH 37/38] [BEEEP] Add util functions for uint8 array conversion (#18451) * Add util functions for uint8 array conversion * Use polyfill instead of old functionality * Replace last usage of old functions --- libs/common/src/platform/misc/utils.spec.ts | 140 +++++++++++++++++++- libs/common/src/platform/misc/utils.ts | 76 ++++++++++- 2 files changed, 213 insertions(+), 3 deletions(-) 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 { From c93577d90934d1465797b00ad6045eb4a045a9e4 Mon Sep 17 00:00:00 2001 From: Bernd Schoolmann Date: Thu, 19 Feb 2026 14:06:42 +0100 Subject: [PATCH 38/38] Update sdk to 550 --- package-lock.json | 16 ++++++++-------- package.json | 4 ++-- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/package-lock.json b/package-lock.json index fb1111a82b9..9f6e82d98ef 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.546", - "@bitwarden/sdk-internal": "0.2.0-main.546", + "@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", @@ -4936,9 +4936,9 @@ "link": true }, "node_modules/@bitwarden/commercial-sdk-internal": { - "version": "0.2.0-main.546", - "resolved": "https://registry.npmjs.org/@bitwarden/commercial-sdk-internal/-/commercial-sdk-internal-0.2.0-main.546.tgz", - "integrity": "sha512-3lIQSb1yYSpDqhgT2uqHjPC88yVL7rWR08i0XD0BQJMFfN0FcB378r2Fq6d5TMXLPEYZ8PR62BCDB+tYKM7FPw==", + "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" @@ -5041,9 +5041,9 @@ "link": true }, "node_modules/@bitwarden/sdk-internal": { - "version": "0.2.0-main.546", - "resolved": "https://registry.npmjs.org/@bitwarden/sdk-internal/-/sdk-internal-0.2.0-main.546.tgz", - "integrity": "sha512-KGPyP1pr7aIBaJ9Knibpfjydo/27Rlve77X4ENmDIwrSJ9FB3o2B6D3UXpNNVyXKt2Ii1C+rNT7ezMRO25Qs4A==", + "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 c18112989fe..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.546", - "@bitwarden/sdk-internal": "0.2.0-main.546", + "@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",