1
0
mirror of https://github.com/bitwarden/browser synced 2026-02-20 11:24:07 +00:00

merege main and resolve conflicts

This commit is contained in:
Bryan Cunningham
2025-12-22 10:00:10 -05:00
973 changed files with 78930 additions and 23841 deletions

View File

@@ -3,9 +3,7 @@ import { DeviceManagementComponentServiceAbstraction } from "./device-management
/**
* Default implementation of the device management component service
*/
export class DefaultDeviceManagementComponentService
implements DeviceManagementComponentServiceAbstraction
{
export class DefaultDeviceManagementComponentService implements DeviceManagementComponentServiceAbstraction {
/**
* Show header information in web client
*/

View File

@@ -5,11 +5,7 @@ import { MockProxy, mock } from "jest-mock-extended";
import { BehaviorSubject, of } from "rxjs";
import { EmptyComponent } from "@bitwarden/angular/platform/guard/feature-flag.guard.spec";
import {
Account,
AccountInfo,
AccountService,
} from "@bitwarden/common/auth/abstractions/account.service";
import { Account, AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service";
import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status";
import { ForceSetPasswordReason } from "@bitwarden/common/auth/models/domain/force-set-password-reason";
@@ -18,6 +14,7 @@ import { KeyConnectorService } from "@bitwarden/common/key-management/key-connec
import { MasterPasswordServiceAbstraction } from "@bitwarden/common/key-management/master-password/abstractions/master-password.service.abstraction";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service";
import { mockAccountInfoWith } from "@bitwarden/common/spec";
import { UserId } from "@bitwarden/common/types/guid";
import { authGuard } from "./auth.guard";
@@ -38,16 +35,13 @@ describe("AuthGuard", () => {
const accountService: MockProxy<AccountService> = mock<AccountService>();
const activeAccountSubject = new BehaviorSubject<Account | null>(null);
accountService.activeAccount$ = activeAccountSubject;
activeAccountSubject.next(
Object.assign(
{
name: "Test User 1",
email: "test@email.com",
emailVerified: true,
} as AccountInfo,
{ id: "test-id" as UserId },
),
);
activeAccountSubject.next({
id: "test-id" as UserId,
...mockAccountInfoWith({
name: "Test User 1",
email: "test@email.com",
}),
});
if (featureFlag) {
configService.getFeatureFlag.mockResolvedValue(true);

View File

@@ -5,11 +5,7 @@ import { MockProxy, mock } from "jest-mock-extended";
import { BehaviorSubject, of } from "rxjs";
import { EmptyComponent } from "@bitwarden/angular/platform/guard/feature-flag.guard.spec";
import {
Account,
AccountInfo,
AccountService,
} from "@bitwarden/common/auth/abstractions/account.service";
import { Account, AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service";
import { UserVerificationService } from "@bitwarden/common/auth/abstractions/user-verification/user-verification.service.abstraction";
import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status";
@@ -20,6 +16,7 @@ import { KeyConnectorDomainConfirmation } from "@bitwarden/common/key-management
import { VaultTimeoutSettingsService } from "@bitwarden/common/key-management/vault-timeout";
import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { mockAccountInfoWith } from "@bitwarden/common/spec";
import { UserId } from "@bitwarden/common/types/guid";
import { KeyService } from "@bitwarden/key-management";
@@ -68,16 +65,13 @@ describe("lockGuard", () => {
const accountService: MockProxy<AccountService> = mock<AccountService>();
const activeAccountSubject = new BehaviorSubject<Account | null>(null);
accountService.activeAccount$ = activeAccountSubject;
activeAccountSubject.next(
Object.assign(
{
name: "Test User 1",
email: "test@email.com",
emailVerified: true,
} as AccountInfo,
{ id: "test-id" as UserId },
),
);
activeAccountSubject.next({
id: "test-id" as UserId,
...mockAccountInfoWith({
name: "Test User 1",
email: "test@email.com",
}),
});
const testBed = TestBed.configureTestingModule({
imports: [

View File

@@ -7,6 +7,7 @@ import { EmptyComponent } from "@bitwarden/angular/platform/guard/feature-flag.g
import { Account, AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service";
import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status";
import { mockAccountInfoWith } from "@bitwarden/common/spec";
import { UserId } from "@bitwarden/common/types/guid";
import { redirectToVaultIfUnlockedGuard } from "./redirect-to-vault-if-unlocked.guard";
@@ -14,9 +15,10 @@ import { redirectToVaultIfUnlockedGuard } from "./redirect-to-vault-if-unlocked.
describe("redirectToVaultIfUnlockedGuard", () => {
const activeUser: Account = {
id: "userId" as UserId,
email: "test@email.com",
emailVerified: true,
name: "Test User",
...mockAccountInfoWith({
email: "test@email.com",
name: "Test User",
}),
};
const setup = (activeUser: Account | null, authStatus: AuthenticationStatus | null) => {

View File

@@ -9,6 +9,7 @@ import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service";
import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status";
import { DeviceTrustServiceAbstraction } from "@bitwarden/common/key-management/device-trust/abstractions/device-trust.service.abstraction";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { mockAccountInfoWith } from "@bitwarden/common/spec";
import { UserId } from "@bitwarden/common/types/guid";
import { KeyService } from "@bitwarden/key-management";
@@ -17,9 +18,10 @@ import { tdeDecryptionRequiredGuard } from "./tde-decryption-required.guard";
describe("tdeDecryptionRequiredGuard", () => {
const activeUser: Account = {
id: "fake_user_id" as UserId,
email: "test@email.com",
emailVerified: true,
name: "Test User",
...mockAccountInfoWith({
email: "test@email.com",
name: "Test User",
}),
};
const setup = (

View File

@@ -10,6 +10,7 @@ import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service";
import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status";
import { DeviceTrustServiceAbstraction } from "@bitwarden/common/key-management/device-trust/abstractions/device-trust.service.abstraction";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { mockAccountInfoWith } from "@bitwarden/common/spec";
import { UserId } from "@bitwarden/common/types/guid";
import { KeyService } from "@bitwarden/key-management";
@@ -18,9 +19,10 @@ import { unauthGuardFn } from "./unauth.guard";
describe("UnauthGuard", () => {
const activeUser: Account = {
id: "fake_user_id" as UserId,
email: "test@email.com",
emailVerified: true,
name: "Test User",
...mockAccountInfoWith({
email: "test@email.com",
name: "Test User",
}),
};
const setup = (

View File

@@ -3,9 +3,7 @@ import { LoginApprovalDialogComponentServiceAbstraction } from "./login-approval
/**
* Default implementation of the LoginApprovalDialogComponentServiceAbstraction.
*/
export class DefaultLoginApprovalDialogComponentService
implements LoginApprovalDialogComponentServiceAbstraction
{
export class DefaultLoginApprovalDialogComponentService implements LoginApprovalDialogComponentServiceAbstraction {
/**
* No-op implementation of the showLoginRequestedAlertIfWindowNotVisible method.
* @returns

View File

@@ -11,6 +11,7 @@ import { DevicesServiceAbstraction } from "@bitwarden/common/auth/abstractions/d
import { AuthRequestResponse } from "@bitwarden/common/auth/models/response/auth-request.response";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { ValidationService } from "@bitwarden/common/platform/abstractions/validation.service";
import { mockAccountInfoWith } from "@bitwarden/common/spec";
import { UserId } from "@bitwarden/common/types/guid";
import { DialogRef, DIALOG_DATA, ToastService } from "@bitwarden/components";
import { LogService } from "@bitwarden/logging";
@@ -48,10 +49,11 @@ describe("LoginApprovalDialogComponent", () => {
validationService = mock<ValidationService>();
accountService.activeAccount$ = of({
email: testEmail,
id: "test-user-id" as UserId,
emailVerified: true,
name: null,
...mockAccountInfoWith({
email: testEmail,
name: null,
}),
});
await TestBed.configureTestingModule({

View File

@@ -2,7 +2,7 @@
// @ts-strict-ignore
import { CommonModule } from "@angular/common";
import { Component, OnInit } from "@angular/core";
import { Router, RouterModule } from "@angular/router";
import { ActivatedRoute, Router, RouterModule } from "@angular/router";
import { firstValueFrom } from "rxjs";
import { JslibModule } from "@bitwarden/angular/jslib.module";
@@ -19,6 +19,7 @@ import { ClientType } from "@bitwarden/common/enums";
import { ErrorResponse } from "@bitwarden/common/models/response/error.response";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { ValidationService } from "@bitwarden/common/platform/abstractions/validation.service";
import {
@@ -49,6 +50,7 @@ export type State = "assert" | "assertFailed";
})
export class LoginViaWebAuthnComponent implements OnInit {
protected currentState: State = "assert";
private shouldAutoClosePopout = false;
protected readonly Icons = {
TwoFactorAuthSecurityKeyIcon,
@@ -70,6 +72,7 @@ export class LoginViaWebAuthnComponent implements OnInit {
constructor(
private webAuthnLoginService: WebAuthnLoginServiceAbstraction,
private router: Router,
private route: ActivatedRoute,
private logService: LogService,
private validationService: ValidationService,
private i18nService: I18nService,
@@ -77,9 +80,14 @@ export class LoginViaWebAuthnComponent implements OnInit {
private keyService: KeyService,
private platformUtilsService: PlatformUtilsService,
private anonLayoutWrapperDataService: AnonLayoutWrapperDataService,
private messagingService: MessagingService,
) {}
ngOnInit(): void {
// Check if we should auto-close the popout after successful authentication
this.shouldAutoClosePopout =
this.route.snapshot.queryParamMap.get("autoClosePopout") === "true";
// FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling.
// eslint-disable-next-line @typescript-eslint/no-floating-promises
this.authenticate();
@@ -120,7 +128,18 @@ export class LoginViaWebAuthnComponent implements OnInit {
// Only run loginSuccessHandlerService if webAuthn is used for vault decryption.
const userKey = await firstValueFrom(this.keyService.userKey$(authResult.userId));
if (userKey) {
await this.loginSuccessHandlerService.run(authResult.userId);
await this.loginSuccessHandlerService.run(authResult.userId, null);
}
// If autoClosePopout is enabled and we're in a browser extension,
// re-open the regular popup and close this popout window
if (
this.shouldAutoClosePopout &&
this.platformUtilsService.getClientType() === ClientType.Browser
) {
this.messagingService.send("openPopup");
window.close();
return;
}
await this.router.navigate([this.successRoute]);

View File

@@ -8,6 +8,7 @@ import { MasterPasswordApiService } from "@bitwarden/common/auth/abstractions/ma
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 { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key";
import { mockAccountInfoWith } from "@bitwarden/common/spec";
import { UserId } from "@bitwarden/common/types/guid";
import { MasterKey, UserKey } from "@bitwarden/common/types/key";
import { KeyService, PBKDF2KdfConfig } from "@bitwarden/key-management";
@@ -26,9 +27,11 @@ describe("DefaultChangePasswordService", () => {
const user: Account = {
id: userId,
email: "email",
emailVerified: false,
name: "name",
...mockAccountInfoWith({
email: "email",
name: "name",
emailVerified: false,
}),
};
const passwordInputResult: PasswordInputResult = {

View File

@@ -15,9 +15,11 @@ 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 { 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 { MasterPasswordSalt } from "@bitwarden/common/key-management/master-password/types/master-password.types";
import { KeysRequest } from "@bitwarden/common/models/request/keys.request";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { Utils } from "@bitwarden/common/platform/misc/utils";
@@ -44,6 +46,7 @@ export class DefaultSetInitialPasswordService implements SetInitialPasswordServi
protected organizationApiService: OrganizationApiServiceAbstraction,
protected organizationUserApiService: OrganizationUserApiService,
protected userDecryptionOptionsService: InternalUserDecryptionOptionsServiceAbstraction,
protected accountCryptographicStateService: AccountCryptographicStateService,
) {}
async setInitialPassword(
@@ -60,6 +63,8 @@ export class DefaultSetInitialPasswordService implements SetInitialPasswordServi
orgSsoIdentifier,
orgId,
resetPasswordAutoEnroll,
newPassword,
salt,
} = credentials;
for (const [key, value] of Object.entries(credentials)) {
@@ -153,6 +158,20 @@ export class DefaultSetInitialPasswordService implements SetInitialPasswordServi
userId,
);
// Set master password unlock data for unlock path pointed to with
// MasterPasswordUnlockData feature development
// (requires: password, salt, kdf, userKey).
// As migration to this strategy continues, both unlock paths need supported.
// Several invocations in this file become redundant and can be removed once
// the feature is enshrined/unwound. These are marked with [PM-23246] below.
await this.setMasterPasswordUnlockData(
newPassword,
salt,
kdfConfig,
masterKeyEncryptedUserKey[0],
userId,
);
/**
* Set the private key only for new JIT provisioned users in MP encryption orgs.
* (Existing TDE users will have their private key set on sync or on login.)
@@ -162,8 +181,17 @@ export class DefaultSetInitialPasswordService implements SetInitialPasswordServi
throw new Error("encrypted private key not found. Could not set private key in state.");
}
await this.keyService.setPrivateKey(keyPair[1].encryptedString, userId);
await this.accountCryptographicStateService.setAccountCryptographicState(
{
V1: {
private_key: keyPair[1].encryptedString,
},
},
userId,
);
}
// [PM-23246] "Legacy" master key setting path - to be removed once unlock path migration is complete
await this.masterPasswordService.setMasterKeyHash(newLocalMasterKeyHash, userId);
if (resetPasswordAutoEnroll) {
@@ -206,10 +234,40 @@ export class DefaultSetInitialPasswordService implements SetInitialPasswordServi
userDecryptionOpts,
);
await this.kdfConfigService.setKdfConfig(userId, kdfConfig);
// [PM-23246] "Legacy" master key setting path - to be removed once unlock path migration is complete
await this.masterPasswordService.setMasterKey(masterKey, userId);
// [PM-23246] "Legacy" master key setting path - to be removed once unlock path migration is complete
await this.masterPasswordService.setMasterKeyEncryptedUserKey(
masterKeyEncryptedUserKey[1],
userId,
);
await this.keyService.setUserKey(masterKeyEncryptedUserKey[0], userId);
}
/**
* As part of [PM-28494], adding this setting path to accommodate the changes that are
* emerging with pm-23246-unlock-with-master-password-unlock-data.
* Without this, immediately locking/unlocking the vault with the new password _may_ still fail
* if sync has not completed. Sync will eventually set this data, but we want to ensure it's
* set right away here to prevent a race condition UX issue that prevents immediate unlock.
*/
private async setMasterPasswordUnlockData(
password: string,
salt: MasterPasswordSalt,
kdfConfig: KdfConfig,
userKey: UserKey,
userId: UserId,
): Promise<void> {
const masterPasswordUnlockData = await this.masterPasswordService.makeMasterPasswordUnlockData(
password,
kdfConfig,
salt,
userKey,
);
await this.masterPasswordService.setMasterPasswordUnlockData(masterPasswordUnlockData, userId);
}
private async handleResetPasswordAutoEnroll(
masterKeyHash: string,
orgId: string,

View File

@@ -20,6 +20,7 @@ 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 { AccountCryptographicStateService } from "@bitwarden/common/key-management/account-cryptography/account-cryptographic-state.service";
import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service";
import {
EncryptedString,
@@ -56,6 +57,7 @@ describe("DefaultSetInitialPasswordService", () => {
let organizationApiService: MockProxy<OrganizationApiServiceAbstraction>;
let organizationUserApiService: MockProxy<OrganizationUserApiService>;
let userDecryptionOptionsService: MockProxy<InternalUserDecryptionOptionsServiceAbstraction>;
let accountCryptographicStateService: MockProxy<AccountCryptographicStateService>;
let userId: UserId;
let userKey: UserKey;
@@ -73,6 +75,7 @@ describe("DefaultSetInitialPasswordService", () => {
organizationApiService = mock<OrganizationApiServiceAbstraction>();
organizationUserApiService = mock<OrganizationUserApiService>();
userDecryptionOptionsService = mock<InternalUserDecryptionOptionsServiceAbstraction>();
accountCryptographicStateService = mock<AccountCryptographicStateService>();
userId = "userId" as UserId;
userKey = new SymmetricCryptoKey(new Uint8Array(64).buffer as CsprngArray) as UserKey;
@@ -90,6 +93,7 @@ describe("DefaultSetInitialPasswordService", () => {
organizationApiService,
organizationUserApiService,
userDecryptionOptionsService,
accountCryptographicStateService,
);
});
@@ -130,6 +134,8 @@ describe("DefaultSetInitialPasswordService", () => {
orgSsoIdentifier: "orgSsoIdentifier",
orgId: "orgId",
resetPasswordAutoEnroll: false,
newPassword: "Test@Password123!",
salt: "user@example.com" as any,
};
userType = SetInitialPasswordUserType.JIT_PROVISIONED_MP_ORG_USER;
@@ -222,6 +228,8 @@ describe("DefaultSetInitialPasswordService", () => {
"orgSsoIdentifier",
"orgId",
"resetPasswordAutoEnroll",
"newPassword",
"salt",
].forEach((key) => {
it(`should throw if ${key} is not provided on the SetInitialPasswordCredentials object`, async () => {
// Arrange
@@ -353,6 +361,10 @@ describe("DefaultSetInitialPasswordService", () => {
ForceSetPasswordReason.None,
userId,
);
expect(masterPasswordService.setMasterKeyEncryptedUserKey).toHaveBeenCalledWith(
masterKeyEncryptedUserKey[1],
userId,
);
});
it("should update account decryption properties", async () => {
@@ -386,6 +398,16 @@ describe("DefaultSetInitialPasswordService", () => {
// Assert
expect(masterPasswordApiService.setPassword).toHaveBeenCalledWith(setPasswordRequest);
expect(keyService.setPrivateKey).toHaveBeenCalledWith(keyPair[1].encryptedString, userId);
expect(
accountCryptographicStateService.setAccountCryptographicState,
).toHaveBeenCalledWith(
{
V1: {
private_key: keyPair[1].encryptedString as EncryptedString,
},
},
userId,
);
});
it("should set the local master key hash to state", async () => {
@@ -403,6 +425,36 @@ describe("DefaultSetInitialPasswordService", () => {
);
});
it("should create and set master password unlock data to prevent race condition with sync", async () => {
// Arrange
setupMocks();
const mockUnlockData = {
salt: credentials.salt,
kdf: credentials.kdfConfig,
masterKeyWrappedUserKey: "wrapped_key_string",
};
masterPasswordService.makeMasterPasswordUnlockData.mockResolvedValue(
mockUnlockData as any,
);
// Act
await sut.setInitialPassword(credentials, userType, userId);
// Assert
expect(masterPasswordService.makeMasterPasswordUnlockData).toHaveBeenCalledWith(
credentials.newPassword,
credentials.kdfConfig,
credentials.salt,
masterKeyEncryptedUserKey[0],
);
expect(masterPasswordService.setMasterPasswordUnlockData).toHaveBeenCalledWith(
mockUnlockData,
userId,
);
});
describe("given resetPasswordAutoEnroll is true", () => {
it(`should handle reset password (account recovery) auto enroll`, async () => {
// Arrange
@@ -572,6 +624,10 @@ describe("DefaultSetInitialPasswordService", () => {
credentials.newMasterKey,
userId,
);
expect(masterPasswordService.setMasterKeyEncryptedUserKey).toHaveBeenCalledWith(
masterKeyEncryptedUserKey[1],
userId,
);
expect(keyService.setUserKey).toHaveBeenCalledWith(masterKeyEncryptedUserKey[0], userId);
});
@@ -602,6 +658,36 @@ describe("DefaultSetInitialPasswordService", () => {
);
});
it("should create and set master password unlock data to prevent race condition with sync", async () => {
// Arrange
setupMocks({ ...defaultMockConfig, userType });
const mockUnlockData = {
salt: credentials.salt,
kdf: credentials.kdfConfig,
masterKeyWrappedUserKey: "wrapped_key_string",
};
masterPasswordService.makeMasterPasswordUnlockData.mockResolvedValue(
mockUnlockData as any,
);
// Act
await sut.setInitialPassword(credentials, userType, userId);
// Assert
expect(masterPasswordService.makeMasterPasswordUnlockData).toHaveBeenCalledWith(
credentials.newPassword,
credentials.kdfConfig,
credentials.salt,
masterKeyEncryptedUserKey[0],
);
expect(masterPasswordService.setMasterPasswordUnlockData).toHaveBeenCalledWith(
mockUnlockData,
userId,
);
});
describe("given resetPasswordAutoEnroll is true", () => {
it(`should handle reset password (account recovery) auto enroll`, async () => {
// Arrange

View File

@@ -214,6 +214,8 @@ export class SetInitialPasswordComponent implements OnInit {
assertTruthy(passwordInputResult.newServerMasterKeyHash, "newServerMasterKeyHash", ctx);
assertTruthy(passwordInputResult.newLocalMasterKeyHash, "newLocalMasterKeyHash", ctx);
assertTruthy(passwordInputResult.kdfConfig, "kdfConfig", ctx);
assertTruthy(passwordInputResult.newPassword, "newPassword", ctx);
assertTruthy(passwordInputResult.salt, "salt", ctx);
assertTruthy(this.orgSsoIdentifier, "orgSsoIdentifier", ctx);
assertTruthy(this.orgId, "orgId", ctx);
assertTruthy(this.userType, "userType", ctx);
@@ -231,6 +233,8 @@ export class SetInitialPasswordComponent implements OnInit {
orgSsoIdentifier: this.orgSsoIdentifier,
orgId: this.orgId,
resetPasswordAutoEnroll: this.resetPasswordAutoEnroll,
newPassword: passwordInputResult.newPassword,
salt: passwordInputResult.salt,
};
await this.setInitialPasswordService.setInitialPassword(

View File

@@ -1,3 +1,4 @@
import { MasterPasswordSalt } from "@bitwarden/common/key-management/master-password/types/master-password.types";
import { UserId } from "@bitwarden/common/types/guid";
import { MasterKey } from "@bitwarden/common/types/key";
import { KdfConfig } from "@bitwarden/key-management";
@@ -50,6 +51,8 @@ export interface SetInitialPasswordCredentials {
orgSsoIdentifier: string;
orgId: string;
resetPasswordAutoEnroll: boolean;
newPassword: string;
salt: MasterPasswordSalt;
}
export interface SetInitialPasswordTdeOffboardingCredentials {

View File

@@ -14,10 +14,11 @@ import { BadgeModule } from "@bitwarden/components";
type="button"
*appNotPremium
bitBadge
variant="success"
[variant]="'primary'"
class="!tw-text-primary-600 !tw-border-primary-600"
(click)="promptForPremium($event)"
>
{{ "premium" | i18n }}
<i class="bwi bwi-premium tw-pe-1"></i>{{ "upgrade" | i18n }}
</button>
`,
imports: [BadgeModule, JslibModule],

View File

@@ -29,7 +29,7 @@ export default {
provide: I18nService,
useFactory: () => {
return new I18nMockService({
premium: "Premium",
upgrade: "Upgrade",
});
},
},

View File

@@ -20,33 +20,35 @@
<div
class="tw-box-border tw-bg-background tw-text-main tw-size-full tw-flex tw-flex-col tw-px-8 tw-pb-2 tw-w-full tw-max-w-md"
>
<div class="tw-flex tw-items-center tw-justify-between tw-mb-2">
<div class="tw-flex tw-items-center tw-justify-between">
<h3 slot="title" class="tw-m-0" bitTypography="h3">
{{ "upgradeToPremium" | i18n }}
</h3>
</div>
<!-- Tagline with consistent height (exactly 2 lines) -->
<div class="tw-mb-6 tw-h-6">
<div class="tw-h-6">
<p bitTypography="helper" class="tw-text-muted tw-m-0 tw-leading-relaxed tw-line-clamp-2">
{{ cardDetails.tagline }}
</p>
</div>
<!-- Price Section -->
<div class="tw-mb-6">
<div class="tw-flex tw-items-baseline tw-gap-1 tw-flex-wrap">
<span class="tw-text-3xl tw-font-medium tw-leading-none tw-m-0">{{
cardDetails.price.amount | currency: "$"
}}</span>
<span bitTypography="helper" class="tw-text-muted">
/ {{ cardDetails.price.cadence | i18n }}
</span>
@if (cardDetails.price) {
<div class="tw-mt-5">
<div class="tw-flex tw-items-baseline tw-gap-1 tw-flex-wrap">
<span class="tw-text-3xl tw-font-medium tw-leading-none tw-m-0">{{
cardDetails.price.amount | currency: "$"
}}</span>
<span bitTypography="helper" class="tw-text-muted">
/ {{ cardDetails.price.cadence | i18n }}
</span>
</div>
</div>
</div>
}
<!-- Button space (always reserved) -->
<div class="tw-mb-6 tw-h-12">
<div class="tw-my-5 tw-h-12">
<button
bitButton
[buttonType]="cardDetails.button.type"

View File

@@ -206,4 +206,39 @@ describe("PremiumUpgradeDialogComponent", () => {
});
});
});
describe("self-hosted environment", () => {
it("should handle null price data for self-hosted environment", async () => {
const selfHostedPremiumTier: PersonalSubscriptionPricingTier = {
id: PersonalSubscriptionPricingTierIds.Premium,
name: "Premium",
description: "Advanced features for power users",
availableCadences: [SubscriptionCadenceIds.Annually],
passwordManager: {
type: "standalone",
annualPrice: undefined as any, // self-host will have these prices empty
annualPricePerAdditionalStorageGB: undefined as any,
providedStorageGB: undefined as any,
features: [
{ key: "feature1", value: "Feature 1" },
{ key: "feature2", value: "Feature 2" },
],
},
};
mockSubscriptionPricingService.getPersonalSubscriptionPricingTiers$.mockReturnValue(
of([selfHostedPremiumTier]),
);
const selfHostedFixture = TestBed.createComponent(PremiumUpgradeDialogComponent);
const selfHostedComponent = selfHostedFixture.componentInstance;
selfHostedFixture.detectChanges();
const cardDetails = await firstValueFrom(selfHostedComponent["cardDetails$"]);
expect(cardDetails?.title).toBe("Premium");
expect(cardDetails?.price).toBeUndefined();
expect(cardDetails?.features).toEqual(["Feature 1", "Feature 2"]);
});
});
});

View File

@@ -42,6 +42,23 @@ const mockPremiumTier: PersonalSubscriptionPricingTier = {
},
};
const mockPremiumTierNoPricingData: PersonalSubscriptionPricingTier = {
id: PersonalSubscriptionPricingTierIds.Premium,
name: "Premium",
description: "Complete online security",
availableCadences: [SubscriptionCadenceIds.Annually],
passwordManager: {
type: "standalone",
features: [
{ key: "builtInAuthenticator", value: "Built-in authenticator" },
{ key: "secureFileStorage", value: "Secure file storage" },
{ key: "emergencyAccess", value: "Emergency access" },
{ key: "breachMonitoring", value: "Breach monitoring" },
{ key: "andMoreFeatures", value: "And more!" },
],
},
};
export default {
title: "Billing/Premium Upgrade Dialog",
component: PremiumUpgradeDialogComponent,
@@ -86,11 +103,11 @@ export default {
t: (key: string) => {
switch (key) {
case "upgradeNow":
return "Upgrade Now";
return "Upgrade now";
case "month":
return "month";
case "upgradeToPremium":
return "Upgrade To Premium";
return "Upgrade to Premium";
default:
return key;
}
@@ -116,3 +133,18 @@ export default {
type Story = StoryObj<PremiumUpgradeDialogComponent>;
export const Default: Story = {};
export const NoPricingData: Story = {
decorators: [
moduleMetadata({
providers: [
{
provide: SubscriptionPricingServiceAbstraction,
useValue: {
getPersonalSubscriptionPricingTiers$: () => of([mockPremiumTierNoPricingData]),
},
},
],
}),
],
};

View File

@@ -3,12 +3,12 @@ import { CommonModule } from "@angular/common";
import { ChangeDetectionStrategy, Component } from "@angular/core";
import { catchError, EMPTY, firstValueFrom, map, Observable } from "rxjs";
import { SubscriptionPricingCardDetails } from "@bitwarden/angular/billing/types/subscription-pricing-card-details";
import { JslibModule } from "@bitwarden/angular/jslib.module";
import { SubscriptionPricingServiceAbstraction } from "@bitwarden/common/billing/abstractions/subscription-pricing.service.abstraction";
import {
PersonalSubscriptionPricingTier,
PersonalSubscriptionPricingTierIds,
SubscriptionCadence,
SubscriptionCadenceIds,
} from "@bitwarden/common/billing/types/subscription-pricing-tier";
import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service";
@@ -16,7 +16,6 @@ import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.servic
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import {
ButtonModule,
ButtonType,
CenterPositionStrategy,
DialogModule,
DialogRef,
@@ -27,14 +26,6 @@ import {
} from "@bitwarden/components";
import { LogService } from "@bitwarden/logging";
type CardDetails = {
title: string;
tagline: string;
price: { amount: number; cadence: SubscriptionCadence };
button: { text: string; type: ButtonType; icon?: { type: string; position: "before" | "after" } };
features: string[];
};
@Component({
selector: "billing-premium-upgrade-dialog",
standalone: true,
@@ -51,9 +42,8 @@ type CardDetails = {
templateUrl: "./premium-upgrade-dialog.component.html",
})
export class PremiumUpgradeDialogComponent {
protected cardDetails$: Observable<CardDetails | null> = this.subscriptionPricingService
.getPersonalSubscriptionPricingTiers$()
.pipe(
protected cardDetails$: Observable<SubscriptionPricingCardDetails | null> =
this.subscriptionPricingService.getPersonalSubscriptionPricingTiers$().pipe(
map((tiers) => tiers.find((tier) => tier.id === PersonalSubscriptionPricingTierIds.Premium)),
map((tier) => this.mapPremiumTierToCardDetails(tier!)),
catchError((error: unknown) => {
@@ -91,14 +81,18 @@ export class PremiumUpgradeDialogComponent {
this.dialogRef.close();
}
private mapPremiumTierToCardDetails(tier: PersonalSubscriptionPricingTier): CardDetails {
private mapPremiumTierToCardDetails(
tier: PersonalSubscriptionPricingTier,
): SubscriptionPricingCardDetails {
return {
title: tier.name,
tagline: tier.description,
price: {
amount: tier.passwordManager.annualPrice / 12,
cadence: SubscriptionCadenceIds.Monthly,
},
price: tier.passwordManager.annualPrice
? {
amount: tier.passwordManager.annualPrice / 12,
cadence: SubscriptionCadenceIds.Monthly,
}
: undefined,
button: {
text: this.i18nService.t("upgradeNow"),
type: "primary",

View File

@@ -0,0 +1,10 @@
import { SubscriptionCadence } from "@bitwarden/common/billing/types/subscription-pricing-tier";
import { ButtonType } from "@bitwarden/components";
export type SubscriptionPricingCardDetails = {
title: string;
tagline: string;
price?: { amount: number; cadence: SubscriptionCadence };
button: { text: string; type: ButtonType; icon?: { type: string; position: "before" | "after" } };
features: string[];
};

View File

@@ -1,26 +0,0 @@
<bit-callout [icon]="icon" [title]="title" [type]="$any(type)" [useAlertRole]="useAlertRole">
<div class="tw-pl-7 tw-m-0" *ngIf="enforcedPolicyOptions">
{{ enforcedPolicyMessage }}
<ul>
<li *ngIf="enforcedPolicyOptions?.minComplexity > 0">
{{ "policyInEffectMinComplexity" | i18n: getPasswordScoreAlertDisplay() }}
</li>
<li *ngIf="enforcedPolicyOptions?.minLength > 0">
{{ "policyInEffectMinLength" | i18n: enforcedPolicyOptions?.minLength.toString() }}
</li>
<li *ngIf="enforcedPolicyOptions?.requireUpper">
{{ "policyInEffectUppercase" | i18n }}
</li>
<li *ngIf="enforcedPolicyOptions?.requireLower">
{{ "policyInEffectLowercase" | i18n }}
</li>
<li *ngIf="enforcedPolicyOptions?.requireNumbers">
{{ "policyInEffectNumbers" | i18n }}
</li>
<li *ngIf="enforcedPolicyOptions?.requireSpecial">
{{ "policyInEffectSpecial" | i18n: "!@#$%^&*" }}
</li>
</ul>
</div>
<ng-content></ng-content>
</bit-callout>

View File

@@ -1,70 +0,0 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { Component, Input, OnInit } from "@angular/core";
import { MasterPasswordPolicyOptions } from "@bitwarden/common/admin-console/models/domain/master-password-policy-options";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { CalloutTypes } from "@bitwarden/components";
/**
* @deprecated use the CL's `CalloutComponent` instead
*/
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
@Component({
selector: "app-callout",
templateUrl: "callout.component.html",
standalone: false,
})
export class DeprecatedCalloutComponent implements OnInit {
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
// eslint-disable-next-line @angular-eslint/prefer-signals
@Input() type: CalloutTypes = "info";
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
// eslint-disable-next-line @angular-eslint/prefer-signals
@Input() icon: string;
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
// eslint-disable-next-line @angular-eslint/prefer-signals
@Input() title: string;
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
// eslint-disable-next-line @angular-eslint/prefer-signals
@Input() enforcedPolicyOptions: MasterPasswordPolicyOptions;
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
// eslint-disable-next-line @angular-eslint/prefer-signals
@Input() enforcedPolicyMessage: string;
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
// eslint-disable-next-line @angular-eslint/prefer-signals
@Input() useAlertRole = false;
calloutStyle: string;
constructor(private i18nService: I18nService) {}
ngOnInit() {
this.calloutStyle = this.type;
if (this.enforcedPolicyMessage === undefined) {
this.enforcedPolicyMessage = this.i18nService.t("masterPasswordPolicyInEffect");
}
}
getPasswordScoreAlertDisplay() {
if (this.enforcedPolicyOptions == null) {
return "";
}
let str: string;
switch (this.enforcedPolicyOptions.minComplexity) {
case 4:
str = this.i18nService.t("strong");
break;
case 3:
str = this.i18nService.t("good");
break;
default:
str = this.i18nService.t("weak");
break;
}
return str + " (" + this.enforcedPolicyOptions.minComplexity + ")";
}
}

View File

@@ -26,7 +26,6 @@ import {
import { TwoFactorIconComponent } from "./auth/components/two-factor-icon.component";
import { NotPremiumDirective } from "./billing/directives/not-premium.directive";
import { DeprecatedCalloutComponent } from "./components/callout.component";
import { A11yInvalidDirective } from "./directives/a11y-invalid.directive";
import { ApiActionDirective } from "./directives/api-action.directive";
import { BoxRowDirective } from "./directives/box-row.directive";
@@ -86,7 +85,6 @@ import { IconComponent } from "./vault/components/icon.component";
A11yInvalidDirective,
ApiActionDirective,
BoxRowDirective,
DeprecatedCalloutComponent,
CopyTextDirective,
CreditCardNumberPipe,
EllipsisPipe,
@@ -115,7 +113,6 @@ import { IconComponent } from "./vault/components/icon.component";
AutofocusDirective,
ToastModule,
BoxRowDirective,
DeprecatedCalloutComponent,
CopyTextDirective,
CreditCardNumberPipe,
EllipsisPipe,

View File

@@ -0,0 +1,9 @@
import { UserId } from "@bitwarden/common/types/guid";
export abstract class EncryptedMigrationsSchedulerService {
/**
* Runs migrations for a user if needed, handling both interactive and non-interactive cases
* @param userId The user ID to run migrations for
*/
abstract runMigrationsIfNeeded(userId: UserId): Promise<void>;
}

View File

@@ -0,0 +1,267 @@
import { Router } from "@angular/router";
import { mock } from "jest-mock-extended";
import { of } from "rxjs";
import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service";
import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status";
import { EncryptedMigrator } from "@bitwarden/common/key-management/encrypted-migrator/encrypted-migrator.abstraction";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { SingleUserState, StateProvider } from "@bitwarden/common/platform/state";
import { SyncService } from "@bitwarden/common/platform/sync";
import { mockAccountInfoWith, FakeAccountService } from "@bitwarden/common/spec";
import { UserId } from "@bitwarden/common/types/guid";
import { DialogService, ToastService } from "@bitwarden/components";
import { LogService } from "@bitwarden/logging";
import {
DefaultEncryptedMigrationsSchedulerService,
ENCRYPTED_MIGRATION_DISMISSED,
} from "./encrypted-migrations-scheduler.service";
import { PromptMigrationPasswordComponent } from "./prompt-migration-password.component";
const SomeUser = "SomeUser" as UserId;
const AnotherUser = "SomeOtherUser" as UserId;
const accounts = {
[SomeUser]: mockAccountInfoWith({
name: "some user",
email: "some.user@example.com",
}),
[AnotherUser]: mockAccountInfoWith({
name: "some other user",
email: "some.other.user@example.com",
}),
};
describe("DefaultEncryptedMigrationsSchedulerService", () => {
let service: DefaultEncryptedMigrationsSchedulerService;
const mockAccountService = new FakeAccountService(accounts);
const mockAuthService = mock<AuthService>();
const mockEncryptedMigrator = mock<EncryptedMigrator>();
const mockStateProvider = mock<StateProvider>();
const mockSyncService = mock<SyncService>();
const mockDialogService = mock<DialogService>();
const mockToastService = mock<ToastService>();
const mockI18nService = mock<I18nService>();
const mockLogService = mock<LogService>();
const mockRouter = mock<Router>();
const mockUserId = "test-user-id" as UserId;
const mockMasterPassword = "test-master-password";
const createMockUserState = <T>(value: T): jest.Mocked<SingleUserState<T>> =>
({
state$: of(value),
userId: mockUserId,
update: jest.fn(),
combinedState$: of([mockUserId, value]),
}) as any;
beforeEach(() => {
const mockDialogRef = {
closed: of(mockMasterPassword),
};
jest.spyOn(PromptMigrationPasswordComponent, "open").mockReturnValue(mockDialogRef as any);
mockI18nService.t.mockReturnValue("translated_migrationsFailed");
(mockRouter as any)["events"] = of({ url: "/vault" }) as any;
service = new DefaultEncryptedMigrationsSchedulerService(
mockSyncService,
mockAccountService,
mockStateProvider,
mockEncryptedMigrator,
mockAuthService,
mockLogService,
mockDialogService,
mockToastService,
mockI18nService,
mockRouter,
);
});
afterEach(() => {
jest.clearAllMocks();
});
describe("runMigrationsIfNeeded", () => {
it("should return early if user is not unlocked", async () => {
mockAuthService.authStatusFor$.mockReturnValue(of(AuthenticationStatus.Locked));
await service.runMigrationsIfNeeded(mockUserId);
expect(mockEncryptedMigrator.needsMigrations).not.toHaveBeenCalled();
expect(mockLogService.info).not.toHaveBeenCalled();
});
it("should log and return when no migration is needed", async () => {
mockAuthService.authStatusFor$.mockReturnValue(of(AuthenticationStatus.Unlocked));
mockEncryptedMigrator.needsMigrations.mockResolvedValue("noMigrationNeeded");
await service.runMigrationsIfNeeded(mockUserId);
expect(mockEncryptedMigrator.needsMigrations).toHaveBeenCalledWith(mockUserId);
expect(mockLogService.info).toHaveBeenCalledWith(
`[EncryptedMigrationsScheduler] No migrations needed for user ${mockUserId}`,
);
expect(mockEncryptedMigrator.runMigrations).not.toHaveBeenCalled();
});
it("should run migrations without interaction when master password is not required", async () => {
mockAuthService.authStatusFor$.mockReturnValue(of(AuthenticationStatus.Unlocked));
mockEncryptedMigrator.needsMigrations.mockResolvedValue("needsMigration");
await service.runMigrationsIfNeeded(mockUserId);
expect(mockEncryptedMigrator.needsMigrations).toHaveBeenCalledWith(mockUserId);
expect(mockLogService.info).toHaveBeenCalledWith(
`[EncryptedMigrationsScheduler] User ${mockUserId} needs migrations with master password`,
);
expect(mockEncryptedMigrator.runMigrations).toHaveBeenCalledWith(mockUserId, null);
});
it("should run migrations with interaction when migration is needed", async () => {
mockAuthService.authStatusFor$.mockReturnValue(of(AuthenticationStatus.Unlocked));
mockEncryptedMigrator.needsMigrations.mockResolvedValue("needsMigrationWithMasterPassword");
const mockUserState = createMockUserState(null);
mockStateProvider.getUser.mockReturnValue(mockUserState);
await service.runMigrationsIfNeeded(mockUserId);
expect(mockEncryptedMigrator.needsMigrations).toHaveBeenCalledWith(mockUserId);
expect(mockLogService.info).toHaveBeenCalledWith(
`[EncryptedMigrationsScheduler] User ${mockUserId} needs migrations with master password`,
);
expect(PromptMigrationPasswordComponent.open).toHaveBeenCalledWith(mockDialogService);
expect(mockEncryptedMigrator.runMigrations).toHaveBeenCalledWith(
mockUserId,
mockMasterPassword,
);
});
});
describe("runMigrationsWithoutInteraction", () => {
it("should run migrations without master password", async () => {
mockAuthService.authStatusFor$.mockReturnValue(of(AuthenticationStatus.Unlocked));
mockEncryptedMigrator.needsMigrations.mockResolvedValue("needsMigration");
await service.runMigrationsIfNeeded(mockUserId);
expect(mockEncryptedMigrator.runMigrations).toHaveBeenCalledWith(mockUserId, null);
expect(mockLogService.error).not.toHaveBeenCalled();
});
it("should handle errors during migration without interaction", async () => {
const mockError = new Error("Migration failed");
mockAuthService.authStatusFor$.mockReturnValue(of(AuthenticationStatus.Unlocked));
mockEncryptedMigrator.needsMigrations.mockResolvedValue("needsMigration");
mockEncryptedMigrator.runMigrations.mockRejectedValue(mockError);
await service.runMigrationsIfNeeded(mockUserId);
expect(mockEncryptedMigrator.runMigrations).toHaveBeenCalledWith(mockUserId, null);
expect(mockLogService.error).toHaveBeenCalledWith(
"[EncryptedMigrationsScheduler] Error during migration without interaction",
mockError,
);
});
});
describe("runMigrationsWithInteraction", () => {
beforeEach(() => {
mockAuthService.authStatusFor$.mockReturnValue(of(AuthenticationStatus.Unlocked));
mockEncryptedMigrator.needsMigrations.mockResolvedValue("needsMigrationWithMasterPassword");
});
it("should skip if migration was dismissed recently", async () => {
const recentDismissDate = new Date(Date.now() - 12 * 60 * 60 * 1000); // 12 hours ago
const mockUserState = createMockUserState(recentDismissDate);
mockStateProvider.getUser.mockReturnValue(mockUserState);
await service.runMigrationsIfNeeded(mockUserId);
expect(mockStateProvider.getUser).toHaveBeenCalledWith(
mockUserId,
ENCRYPTED_MIGRATION_DISMISSED,
);
expect(mockLogService.info).toHaveBeenCalledWith(
"[EncryptedMigrationsScheduler] Migration prompt dismissed recently, skipping for now.",
);
expect(PromptMigrationPasswordComponent.open).not.toHaveBeenCalled();
});
it("should prompt for migration if dismissed date is older than 24 hours", async () => {
const oldDismissDate = new Date(Date.now() - 25 * 60 * 60 * 1000); // 25 hours ago
const mockUserState = createMockUserState(oldDismissDate);
mockStateProvider.getUser.mockReturnValue(mockUserState);
await service.runMigrationsIfNeeded(mockUserId);
expect(mockStateProvider.getUser).toHaveBeenCalledWith(
mockUserId,
ENCRYPTED_MIGRATION_DISMISSED,
);
expect(PromptMigrationPasswordComponent.open).toHaveBeenCalledWith(mockDialogService);
expect(mockEncryptedMigrator.runMigrations).toHaveBeenCalledWith(
mockUserId,
mockMasterPassword,
);
});
it("should prompt for migration if no dismiss date exists", async () => {
const mockUserState = createMockUserState(null);
mockStateProvider.getUser.mockReturnValue(mockUserState);
await service.runMigrationsIfNeeded(mockUserId);
expect(PromptMigrationPasswordComponent.open).toHaveBeenCalledWith(mockDialogService);
expect(mockEncryptedMigrator.runMigrations).toHaveBeenCalledWith(
mockUserId,
mockMasterPassword,
);
});
it("should set dismiss date when empty password is provided", async () => {
const mockUserState = createMockUserState(null);
mockStateProvider.getUser.mockReturnValue(mockUserState);
const mockDialogRef = {
closed: of(""), // Empty password
};
jest.spyOn(PromptMigrationPasswordComponent, "open").mockReturnValue(mockDialogRef as any);
await service.runMigrationsIfNeeded(mockUserId);
expect(PromptMigrationPasswordComponent.open).toHaveBeenCalledWith(mockDialogService);
expect(mockEncryptedMigrator.runMigrations).not.toHaveBeenCalled();
expect(mockStateProvider.setUserState).toHaveBeenCalledWith(
ENCRYPTED_MIGRATION_DISMISSED,
expect.any(Date),
mockUserId,
);
});
it("should handle errors during migration prompt and show toast", async () => {
const mockUserState = createMockUserState(null);
mockStateProvider.getUser.mockReturnValue(mockUserState);
const mockError = new Error("Migration failed");
mockEncryptedMigrator.runMigrations.mockRejectedValue(mockError);
await service.runMigrationsIfNeeded(mockUserId);
expect(PromptMigrationPasswordComponent.open).toHaveBeenCalledWith(mockDialogService);
expect(mockEncryptedMigrator.runMigrations).toHaveBeenCalledWith(
mockUserId,
mockMasterPassword,
);
expect(mockLogService.error).toHaveBeenCalledWith(
"[EncryptedMigrationsScheduler] Error during migration prompt",
mockError,
);
expect(mockToastService.showToast).toHaveBeenCalledWith({
variant: "error",
message: "translated_migrationsFailed",
});
});
});
});

View File

@@ -0,0 +1,186 @@
import { NavigationEnd, Router } from "@angular/router";
import {
combineLatest,
switchMap,
of,
firstValueFrom,
filter,
concatMap,
Observable,
map,
} from "rxjs";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service";
import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status";
import { EncryptedMigrator } from "@bitwarden/common/key-management/encrypted-migrator/encrypted-migrator.abstraction";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { Utils } from "@bitwarden/common/platform/misc/utils";
import {
UserKeyDefinition,
ENCRYPTED_MIGRATION_DISK,
StateProvider,
} from "@bitwarden/common/platform/state";
import { SyncService } from "@bitwarden/common/platform/sync";
import { UserId } from "@bitwarden/common/types/guid";
import { DialogService, ToastService } from "@bitwarden/components";
import { LogService } from "@bitwarden/logging";
import { EncryptedMigrationsSchedulerService } from "./encrypted-migrations-scheduler.service.abstraction";
import { PromptMigrationPasswordComponent } from "./prompt-migration-password.component";
export const ENCRYPTED_MIGRATION_DISMISSED = new UserKeyDefinition<Date>(
ENCRYPTED_MIGRATION_DISK,
"encryptedMigrationDismissed",
{
deserializer: (obj: string) => (obj != null ? new Date(obj) : null),
clearOn: [],
},
);
const DISMISS_TIME_HOURS = 24;
const VAULT_ROUTES = ["/vault", "/tabs/vault", "/tabs/current"];
/**
* This services schedules encrypted migrations for users on clients that are interactive (non-cli), and handles manual interaction,
* if it is required by showing a UI prompt. It is only one means of triggering migrations, in case the user stays unlocked for a while,
* or regularly logs in without a master-password, when the migrations do require a master-password to run.
*/
export class DefaultEncryptedMigrationsSchedulerService implements EncryptedMigrationsSchedulerService {
isMigrating = false;
url$: Observable<string>;
constructor(
private syncService: SyncService,
private accountService: AccountService,
private stateProvider: StateProvider,
private encryptedMigrator: EncryptedMigrator,
private authService: AuthService,
private logService: LogService,
private dialogService: DialogService,
private toastService: ToastService,
private i18nService: I18nService,
private router: Router,
) {
this.url$ = this.router.events.pipe(
filter((event: any) => event instanceof NavigationEnd),
map((event: NavigationEnd) => event.url),
);
// For all accounts, if the auth status changes to unlocked or a sync happens, prompt for migration
this.accountService.accounts$
.pipe(
switchMap((accounts) => {
const userIds = Object.keys(accounts) as UserId[];
if (userIds.length === 0) {
return of([]);
}
return combineLatest(
userIds.map((userId) =>
combineLatest([
this.authService.authStatusFor$(userId),
this.syncService.lastSync$(userId).pipe(filter((lastSync) => lastSync != null)),
this.url$,
]).pipe(
filter(
([authStatus, _date, url]) =>
authStatus === AuthenticationStatus.Unlocked && VAULT_ROUTES.includes(url),
),
concatMap(() => this.runMigrationsIfNeeded(userId)),
),
),
);
}),
)
.subscribe();
}
async runMigrationsIfNeeded(userId: UserId): Promise<void> {
const authStatus = await firstValueFrom(this.authService.authStatusFor$(userId));
if (authStatus !== AuthenticationStatus.Unlocked) {
return;
}
if (this.isMigrating || this.encryptedMigrator.isRunningMigrations()) {
this.logService.info(
`[EncryptedMigrationsScheduler] Skipping migration check for user ${userId} because migrations are already in progress`,
);
return;
}
this.isMigrating = true;
switch (await this.encryptedMigrator.needsMigrations(userId)) {
case "noMigrationNeeded":
this.logService.info(
`[EncryptedMigrationsScheduler] No migrations needed for user ${userId}`,
);
break;
case "needsMigrationWithMasterPassword":
this.logService.info(
`[EncryptedMigrationsScheduler] User ${userId} needs migrations with master password`,
);
// If the user is unlocked, we can run migrations with the master password
await this.runMigrationsWithInteraction(userId);
break;
case "needsMigration":
this.logService.info(
`[EncryptedMigrationsScheduler] User ${userId} needs migrations with master password`,
);
// If the user is unlocked, we can prompt for the master password
await this.runMigrationsWithoutInteraction(userId);
break;
}
this.isMigrating = false;
}
private async runMigrationsWithoutInteraction(userId: UserId): Promise<void> {
try {
await this.encryptedMigrator.runMigrations(userId, null);
} catch (error) {
this.logService.error(
"[EncryptedMigrationsScheduler] Error during migration without interaction",
error,
);
}
}
private async runMigrationsWithInteraction(userId: UserId): Promise<void> {
// A dialog can be dismissed for a certain amount of time
const dismissedDate = await firstValueFrom(
this.stateProvider.getUser(userId, ENCRYPTED_MIGRATION_DISMISSED).state$,
);
if (dismissedDate != null) {
const now = new Date();
const timeDiff = now.getTime() - (dismissedDate as Date).getTime();
const hoursDiff = timeDiff / (1000 * 60 * 60);
if (hoursDiff < DISMISS_TIME_HOURS) {
this.logService.info(
"[EncryptedMigrationsScheduler] Migration prompt dismissed recently, skipping for now.",
);
return;
}
}
try {
const dialog = PromptMigrationPasswordComponent.open(this.dialogService);
const masterPassword = await firstValueFrom(dialog.closed);
if (Utils.isNullOrWhitespace(masterPassword)) {
await this.stateProvider.setUserState(ENCRYPTED_MIGRATION_DISMISSED, new Date(), userId);
} else {
await this.encryptedMigrator.runMigrations(
userId,
masterPassword === undefined ? null : masterPassword,
);
}
} catch (error) {
this.logService.error("[EncryptedMigrationsScheduler] Error during migration prompt", error);
// If migrations failed when the user actively was prompted, show a toast
this.toastService.showToast({
variant: "error",
message: this.i18nService.t("migrationsFailed"),
});
}
}
}

View File

@@ -0,0 +1,55 @@
<form [bitSubmit]="submit" [formGroup]="migrationPasswordForm">
<bit-dialog>
<div class="tw-font-semibold" bitDialogTitle>
{{ "updateEncryptionSettingsTitle" | i18n }}
</div>
<div bitDialogContent>
<p>
{{ "updateEncryptionSettingsDesc" | i18n }}
<a
bitLink
href="https://bitwarden.com/help/kdf-algorithms/"
target="_blank"
rel="noreferrer"
aria-label="external link"
>
{{ "learnMore" | i18n }}
<i class="bwi bwi-external-link" aria-hidden="true"></i>
</a>
</p>
<bit-form-field>
<bit-label>{{ "masterPass" | i18n }}</bit-label>
<bit-hint>{{ "confirmIdentityToContinue" | i18n }}</bit-hint>
<input
class="tw-font-mono"
bitInput
type="password"
formControlName="masterPassword"
[attr.title]="'masterPass' | i18n"
/>
<button
type="button"
bitIconButton
bitSuffix
bitPasswordInputToggle
[attr.title]="'toggleVisibility' | i18n"
[attr.aria-label]="'toggleVisibility' | i18n"
></button>
</bit-form-field>
</div>
<ng-container bitDialogFooter>
<button
type="submit"
bitButton
bitFormButton
buttonType="primary"
[disabled]="migrationPasswordForm.invalid"
>
<span>{{ "updateSettings" | i18n }}</span>
</button>
<button type="button" bitButton bitFormButton buttonType="secondary" bitDialogClose>
{{ "later" | i18n }}
</button>
</ng-container>
</bit-dialog>
</form>

View File

@@ -0,0 +1,82 @@
import { CommonModule } from "@angular/common";
import { Component, inject, ChangeDetectionStrategy } from "@angular/core";
import { FormBuilder, ReactiveFormsModule, Validators } from "@angular/forms";
import { filter, firstValueFrom, map } from "rxjs";
import { JslibModule } from "@bitwarden/angular/jslib.module";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { MasterPasswordUnlockService } from "@bitwarden/common/key-management/master-password/abstractions/master-password-unlock.service";
import {
LinkModule,
AsyncActionsModule,
ButtonModule,
DialogModule,
DialogRef,
DialogService,
FormFieldModule,
IconButtonModule,
} from "@bitwarden/components";
/**
* This is a generic prompt to run encryption migrations that require the master password.
*/
@Component({
changeDetection: ChangeDetectionStrategy.OnPush,
templateUrl: "prompt-migration-password.component.html",
imports: [
DialogModule,
LinkModule,
CommonModule,
JslibModule,
ButtonModule,
IconButtonModule,
ReactiveFormsModule,
AsyncActionsModule,
FormFieldModule,
],
})
export class PromptMigrationPasswordComponent {
private dialogRef = inject(DialogRef<string>);
private formBuilder = inject(FormBuilder);
private masterPasswordUnlockService = inject(MasterPasswordUnlockService);
private accountService = inject(AccountService);
migrationPasswordForm = this.formBuilder.group({
masterPassword: ["", [Validators.required]],
});
static open(dialogService: DialogService) {
return dialogService.open<string>(PromptMigrationPasswordComponent);
}
submit = async () => {
const masterPasswordControl = this.migrationPasswordForm.controls.masterPassword;
if (!masterPasswordControl.value || masterPasswordControl.invalid) {
return;
}
const { userId } = await firstValueFrom(
this.accountService.activeAccount$.pipe(
filter((account) => account != null),
map((account) => {
return {
userId: account!.id,
};
}),
),
);
if (
!(await this.masterPasswordUnlockService.proofOfDecryption(
masterPasswordControl.value,
userId,
))
) {
return;
}
// Return the master password to the caller
this.dialogRef.close(masterPasswordControl.value);
};
}

View File

@@ -1,6 +1,7 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { ErrorHandler, LOCALE_ID, NgModule } from "@angular/core";
import { APP_INITIALIZER, ErrorHandler, LOCALE_ID, NgModule } from "@angular/core";
import { Router } from "@angular/router";
import { Subject } from "rxjs";
// This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop.
@@ -167,6 +168,8 @@ import { OrganizationBillingService } from "@bitwarden/common/billing/services/o
import { DefaultSubscriptionPricingService } from "@bitwarden/common/billing/services/subscription-pricing.service";
import { HibpApiService } from "@bitwarden/common/dirt/services/hibp-api.service";
import { ProcessReloadServiceAbstraction } from "@bitwarden/common/key-management/abstractions/process-reload.service";
import { AccountCryptographicStateService } from "@bitwarden/common/key-management/account-cryptography/account-cryptographic-state.service";
import { DefaultAccountCryptographicStateService } from "@bitwarden/common/key-management/account-cryptography/default-account-cryptographic-state.service";
import {
DefaultKeyGenerationService,
KeyGenerationService,
@@ -177,11 +180,15 @@ import { EncryptServiceImplementation } from "@bitwarden/common/key-management/c
import { WebCryptoFunctionService } from "@bitwarden/common/key-management/crypto/services/web-crypto-function.service";
import { DeviceTrustServiceAbstraction } from "@bitwarden/common/key-management/device-trust/abstractions/device-trust.service.abstraction";
import { DeviceTrustService } from "@bitwarden/common/key-management/device-trust/services/device-trust.service.implementation";
import { DefaultEncryptedMigrator } from "@bitwarden/common/key-management/encrypted-migrator/default-encrypted-migrator";
import { EncryptedMigrator } from "@bitwarden/common/key-management/encrypted-migrator/encrypted-migrator.abstraction";
import { DefaultChangeKdfApiService } from "@bitwarden/common/key-management/kdf/change-kdf-api.service";
import { ChangeKdfApiService } from "@bitwarden/common/key-management/kdf/change-kdf-api.service.abstraction";
import { DefaultChangeKdfService } from "@bitwarden/common/key-management/kdf/change-kdf-service";
import { ChangeKdfService } from "@bitwarden/common/key-management/kdf/change-kdf-service.abstraction";
import { DefaultChangeKdfService } from "@bitwarden/common/key-management/kdf/change-kdf.service";
import { ChangeKdfService } from "@bitwarden/common/key-management/kdf/change-kdf.service.abstraction";
import { KeyConnectorApiService } from "@bitwarden/common/key-management/key-connector/abstractions/key-connector-api.service";
import { KeyConnectorService as KeyConnectorServiceAbstraction } from "@bitwarden/common/key-management/key-connector/abstractions/key-connector.service";
import { DefaultKeyConnectorApiService } from "@bitwarden/common/key-management/key-connector/services/default-key-connector-api.service";
import { KeyConnectorService } from "@bitwarden/common/key-management/key-connector/services/key-connector.service";
import { KeyApiService } from "@bitwarden/common/key-management/keys/services/abstractions/key-api-service.abstraction";
import { RotateableKeySetService } from "@bitwarden/common/key-management/keys/services/abstractions/rotateable-key-set.service";
@@ -204,6 +211,7 @@ import {
SendPasswordService,
DefaultSendPasswordService,
} from "@bitwarden/common/key-management/sends";
import { SessionTimeoutTypeService } from "@bitwarden/common/key-management/session-timeout";
import {
DefaultVaultTimeoutService,
DefaultVaultTimeoutSettingsService,
@@ -328,6 +336,7 @@ import { DefaultTaskService, TaskService } from "@bitwarden/common/vault/tasks";
import {
AnonLayoutWrapperDataService,
DefaultAnonLayoutWrapperDataService,
DialogService,
ToastService,
} from "@bitwarden/components";
import {
@@ -396,6 +405,8 @@ import { DeviceTrustToastService as DeviceTrustToastServiceAbstraction } from ".
import { DeviceTrustToastService } from "../auth/services/device-trust-toast.service.implementation";
import { NoopPremiumInterestStateService } from "../billing/services/premium-interest/noop-premium-interest-state.service";
import { PremiumInterestStateService } from "../billing/services/premium-interest/premium-interest-state.service.abstraction";
import { DefaultEncryptedMigrationsSchedulerService } from "../key-management/encrypted-migration/encrypted-migrations-scheduler.service";
import { EncryptedMigrationsSchedulerService } from "../key-management/encrypted-migration/encrypted-migrations-scheduler.service.abstraction";
import { FormValidationErrorsService as FormValidationErrorsServiceAbstraction } from "../platform/abstractions/form-validation-errors.service";
import { DocumentLangSetter } from "../platform/i18n";
import { FormValidationErrorsService } from "../platform/services/form-validation-errors.service";
@@ -516,6 +527,23 @@ const safeProviders: SafeProvider[] = [
TokenServiceAbstraction,
],
}),
safeProvider({
provide: ChangeKdfService,
useClass: DefaultChangeKdfService,
deps: [ChangeKdfApiService, SdkService, KeyService, InternalMasterPasswordServiceAbstraction],
}),
safeProvider({
provide: EncryptedMigrator,
useClass: DefaultEncryptedMigrator,
deps: [
KdfConfigService,
ChangeKdfService,
LogService,
ConfigService,
MasterPasswordServiceAbstraction,
SyncService,
],
}),
safeProvider({
provide: LoginStrategyServiceAbstraction,
useClass: LoginStrategyService,
@@ -546,6 +574,7 @@ const safeProviders: SafeProvider[] = [
KdfConfigService,
TaskSchedulerService,
ConfigService,
AccountCryptographicStateService,
],
}),
safeProvider({
@@ -868,8 +897,14 @@ const safeProviders: SafeProvider[] = [
StateProvider,
SecurityStateService,
KdfConfigService,
AccountCryptographicStateService,
],
}),
safeProvider({
provide: AccountCryptographicStateService,
useClass: DefaultAccountCryptographicStateService,
deps: [StateProvider],
}),
safeProvider({
provide: BroadcasterService,
useClass: DefaultBroadcasterService,
@@ -889,6 +924,7 @@ const safeProviders: SafeProvider[] = [
StateProvider,
LogService,
DEFAULT_VAULT_TIMEOUT,
SessionTimeoutTypeService,
],
}),
safeProvider({
@@ -925,7 +961,7 @@ const safeProviders: SafeProvider[] = [
deps: [
FolderServiceAbstraction,
CipherServiceAbstraction,
PinServiceAbstraction,
KeyGenerationService,
KeyService,
EncryptService,
CryptoFunctionServiceAbstraction,
@@ -945,7 +981,7 @@ const safeProviders: SafeProvider[] = [
deps: [
CipherServiceAbstraction,
VaultExportApiService,
PinServiceAbstraction,
KeyGenerationService,
KeyService,
EncryptService,
CryptoFunctionServiceAbstraction,
@@ -1306,7 +1342,7 @@ const safeProviders: SafeProvider[] = [
safeProvider({
provide: ChangeKdfService,
useClass: DefaultChangeKdfService,
deps: [ChangeKdfApiService, SdkService],
deps: [ChangeKdfApiService, SdkService, KeyService, InternalMasterPasswordServiceAbstraction],
}),
safeProvider({
provide: AuthRequestServiceAbstraction,
@@ -1330,16 +1366,7 @@ const safeProviders: SafeProvider[] = [
safeProvider({
provide: PinServiceAbstraction,
useClass: PinService,
deps: [
AccountServiceAbstraction,
EncryptService,
KdfConfigService,
KeyGenerationService,
LogService,
KeyService,
SdkService,
PinStateServiceAbstraction,
],
deps: [EncryptService, LogService, KeyService, SdkService, PinStateServiceAbstraction],
}),
safeProvider({
provide: WebAuthnLoginPrfKeyServiceAbstraction,
@@ -1473,7 +1500,13 @@ const safeProviders: SafeProvider[] = [
safeProvider({
provide: SubscriptionPricingServiceAbstraction,
useClass: DefaultSubscriptionPricingService,
deps: [BillingApiServiceAbstraction, ConfigService, I18nServiceAbstraction, LogService],
deps: [
BillingApiServiceAbstraction,
ConfigService,
I18nServiceAbstraction,
LogService,
EnvironmentService,
],
}),
safeProvider({
provide: OrganizationManagementPreferencesService,
@@ -1541,6 +1574,7 @@ const safeProviders: SafeProvider[] = [
OrganizationApiServiceAbstraction,
OrganizationUserApiService,
InternalUserDecryptionOptionsServiceAbstraction,
AccountCryptographicStateService,
],
}),
safeProvider({
@@ -1665,6 +1699,7 @@ const safeProviders: SafeProvider[] = [
SsoLoginServiceAbstraction,
SyncService,
UserAsymmetricKeysRegenerationService,
EncryptedMigrator,
LogService,
],
}),
@@ -1735,6 +1770,28 @@ const safeProviders: SafeProvider[] = [
InternalMasterPasswordServiceAbstraction,
],
}),
safeProvider({
provide: EncryptedMigrationsSchedulerService,
useClass: DefaultEncryptedMigrationsSchedulerService,
deps: [
SyncService,
AccountService,
StateProvider,
EncryptedMigrator,
AuthServiceAbstraction,
LogService,
DialogService,
ToastService,
I18nServiceAbstraction,
Router,
],
}),
safeProvider({
provide: APP_INITIALIZER as SafeInjectionToken<() => Promise<void>>,
useFactory: (encryptedMigrationsScheduler: EncryptedMigrationsSchedulerService) => () => {},
deps: [EncryptedMigrationsSchedulerService],
multi: true,
}),
safeProvider({
provide: LockService,
useClass: DefaultLockService,
@@ -1781,6 +1838,11 @@ const safeProviders: SafeProvider[] = [
useClass: IpcSessionRepository,
deps: [StateProvider],
}),
safeProvider({
provide: KeyConnectorApiService,
useClass: DefaultKeyConnectorApiService,
deps: [ApiServiceAbstraction],
}),
safeProvider({
provide: PremiumInterestStateService,
useClass: NoopPremiumInterestStateService,

View File

@@ -1,9 +1,5 @@
<!-- Applying width and height styles directly to synchronize icon sizing between web/browser/desktop -->
<div
class="tw-flex tw-justify-center tw-items-center"
[ngStyle]="coloredIcon() ? { width: '36px', height: '36px' } : {}"
aria-hidden="true"
>
<div class="tw-flex tw-justify-center tw-items-center" [ngStyle]="iconStyle()" aria-hidden="true">
<ng-container *ngIf="data$ | async as data">
@if (data.imageEnabled && data.image) {
<img
@@ -16,7 +12,7 @@
'tw-invisible tw-absolute': !imageLoaded(),
'tw-size-6': !coloredIcon(),
}"
[ngStyle]="coloredIcon() ? { width: '36px', height: '36px' } : {}"
[ngStyle]="iconStyle()"
(load)="imageLoaded.set(true)"
(error)="imageLoaded.set(false)"
/>
@@ -28,7 +24,7 @@
'tw-bg-illustration-bg-primary tw-rounded-full':
data.icon?.startsWith('bwi-') && coloredIcon(),
}"
[ngStyle]="coloredIcon() ? { width: '36px', height: '36px' } : {}"
[ngStyle]="iconStyle()"
>
<i
class="tw-text-muted bwi bwi-lg {{ data.icon }}"
@@ -36,6 +32,7 @@
color: coloredIcon() ? 'rgb(var(--color-illustration-outline))' : null,
width: data.icon?.startsWith('credit-card') && coloredIcon() ? '36px' : null,
height: data.icon?.startsWith('credit-card') && coloredIcon() ? '30px' : null,
fontSize: size() ? size() + 'px' : null,
}"
></i>
</div>

View File

@@ -1,4 +1,4 @@
import { ChangeDetectionStrategy, Component, input, signal } from "@angular/core";
import { ChangeDetectionStrategy, Component, computed, input, signal } from "@angular/core";
import { toObservable } from "@angular/core/rxjs-interop";
import {
combineLatest,
@@ -32,8 +32,32 @@ export class IconComponent {
*/
readonly coloredIcon = input<boolean>(false);
/**
* Optional custom size for the icon in pixels.
* When provided, forces explicit dimensions on the icon wrapper to prevent layout collapse at different zoom levels.
* If not provided, the wrapper has no explicit dimensions and relies on CSS classes (tw-size-6/24px for images).
* This can cause the wrapper to collapse when images are loading/hidden, especially at high browser zoom levels.
* Reference: default image size is tw-size-6 (24px), coloredIcon uses 36px.
*/
readonly size = input<number>();
readonly imageLoaded = signal(false);
/**
* Computed style object for icon dimensions.
* Centralizes the sizing logic to avoid repetition in the template.
*/
protected readonly iconStyle = computed(() => {
if (this.coloredIcon()) {
return { width: "36px", height: "36px" };
}
const size = this.size();
if (size) {
return { width: size + "px", height: size + "px" };
}
return {};
});
protected data$: Observable<CipherIconDetails>;
constructor(

View File

@@ -89,7 +89,7 @@ export class VaultFilterComponent implements OnInit {
this.collections = await this.initCollections();
this.showArchiveVaultFilter = await firstValueFrom(
this.cipherArchiveService.hasArchiveFlagEnabled$(),
this.cipherArchiveService.hasArchiveFlagEnabled$,
);
this.isLoaded = true;