1
0
mirror of https://github.com/bitwarden/browser synced 2025-12-31 07:33:23 +00:00

Merge branch 'main' into PM-26250-Explore-options-to-enable-direct-importer-for-mac-app-store-build

This commit is contained in:
John Harrington
2025-12-19 09:06:33 -07:00
committed by GitHub
441 changed files with 52881 additions and 9119 deletions

View File

@@ -9,6 +9,14 @@ import { CollectionData, Collection, CollectionView } from "../models";
export abstract class CollectionService {
abstract encryptedCollections$(userId: UserId): Observable<Collection[] | null>;
abstract decryptedCollections$(userId: UserId): Observable<CollectionView[]>;
/**
* Gets the default collection for a user in a given organization, if it exists.
*/
abstract defaultUserCollection$(
userId: UserId,
orgId: OrganizationId,
): Observable<CollectionView | undefined>;
abstract upsert(collection: CollectionData, userId: UserId): Promise<any>;
abstract replace(collections: { [id: string]: CollectionData }, userId: UserId): Promise<any>;
/**

View File

@@ -15,9 +15,10 @@ import {
} from "@bitwarden/common/spec";
import { CollectionId, OrganizationId, UserId } from "@bitwarden/common/types/guid";
import { OrgKey } from "@bitwarden/common/types/key";
import { newGuid } from "@bitwarden/guid";
import { KeyService } from "@bitwarden/key-management";
import { CollectionData, CollectionView } from "../models";
import { CollectionData, CollectionTypes, CollectionView } from "../models";
import { DECRYPTED_COLLECTION_DATA_KEY, ENCRYPTED_COLLECTION_DATA_KEY } from "./collection.state";
import { DefaultCollectionService } from "./default-collection.service";
@@ -389,6 +390,83 @@ describe("DefaultCollectionService", () => {
});
});
describe("defaultUserCollection$", () => {
it("returns the default collection when one exists matching the org", async () => {
const orgId = newGuid() as OrganizationId;
const defaultCollection = collectionViewDataFactory(orgId);
defaultCollection.type = CollectionTypes.DefaultUserCollection;
const regularCollection = collectionViewDataFactory(orgId);
regularCollection.type = CollectionTypes.SharedCollection;
await setDecryptedState([defaultCollection, regularCollection]);
const result = await firstValueFrom(collectionService.defaultUserCollection$(userId, orgId));
expect(result).toBeDefined();
expect(result?.id).toBe(defaultCollection.id);
expect(result?.isDefaultCollection).toBe(true);
});
it("returns undefined when no default collection exists", async () => {
const orgId = newGuid() as OrganizationId;
const collection1 = collectionViewDataFactory(orgId);
collection1.type = CollectionTypes.SharedCollection;
const collection2 = collectionViewDataFactory(orgId);
collection2.type = CollectionTypes.SharedCollection;
await setDecryptedState([collection1, collection2]);
const result = await firstValueFrom(collectionService.defaultUserCollection$(userId, orgId));
expect(result).toBeUndefined();
});
it("returns undefined when default collection exists but for different org", async () => {
const orgA = newGuid() as OrganizationId;
const orgB = newGuid() as OrganizationId;
const defaultCollectionForOrgA = collectionViewDataFactory(orgA);
defaultCollectionForOrgA.type = CollectionTypes.DefaultUserCollection;
await setDecryptedState([defaultCollectionForOrgA]);
const result = await firstValueFrom(collectionService.defaultUserCollection$(userId, orgB));
expect(result).toBeUndefined();
});
it("returns undefined when collections array is empty", async () => {
const orgId = newGuid() as OrganizationId;
await setDecryptedState([]);
const result = await firstValueFrom(collectionService.defaultUserCollection$(userId, orgId));
expect(result).toBeUndefined();
});
it("returns correct collection when multiple orgs have default collections", async () => {
const orgA = newGuid() as OrganizationId;
const orgB = newGuid() as OrganizationId;
const defaultCollectionForOrgA = collectionViewDataFactory(orgA);
defaultCollectionForOrgA.type = CollectionTypes.DefaultUserCollection;
const defaultCollectionForOrgB = collectionViewDataFactory(orgB);
defaultCollectionForOrgB.type = CollectionTypes.DefaultUserCollection;
await setDecryptedState([defaultCollectionForOrgA, defaultCollectionForOrgB]);
const result = await firstValueFrom(collectionService.defaultUserCollection$(userId, orgB));
expect(result).toBeDefined();
expect(result?.id).toBe(defaultCollectionForOrgB.id);
expect(result?.organizationId).toBe(orgB);
});
});
const setEncryptedState = (collectionData: CollectionData[] | null) =>
stateProvider.setUserState(
ENCRYPTED_COLLECTION_DATA_KEY,

View File

@@ -87,6 +87,17 @@ export class DefaultCollectionService implements CollectionService {
return result$;
}
defaultUserCollection$(
userId: UserId,
orgId: OrganizationId,
): Observable<CollectionView | undefined> {
return this.decryptedCollections$(userId).pipe(
map((collections) => {
return collections.find((c) => c.isDefaultCollection && c.organizationId === orgId);
}),
);
}
private initializeDecryptedState(userId: UserId): Observable<CollectionView[]> {
return combineLatest([
this.encryptedCollections$(userId),

View File

@@ -15,6 +15,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 { EncString } from "@bitwarden/common/key-management/crypto/models/enc-string";
import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/key-management/master-password/abstractions/master-password.service.abstraction";
@@ -44,6 +45,7 @@ export class DefaultSetInitialPasswordService implements SetInitialPasswordServi
protected organizationApiService: OrganizationApiServiceAbstraction,
protected organizationUserApiService: OrganizationUserApiService,
protected userDecryptionOptionsService: InternalUserDecryptionOptionsServiceAbstraction,
protected accountCryptographicStateService: AccountCryptographicStateService,
) {}
async setInitialPassword(
@@ -162,6 +164,14 @@ 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,
);
}
await this.masterPasswordService.setMasterKeyHash(newLocalMasterKeyHash, userId);

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,
);
});
@@ -386,6 +390,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 () => {

View File

@@ -5,8 +5,7 @@ import { filter, firstValueFrom, map } from "rxjs";
import { JslibModule } from "@bitwarden/angular/jslib.module";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { UserVerificationService } from "@bitwarden/common/auth/abstractions/user-verification/user-verification.service.abstraction";
import { VerificationType } from "@bitwarden/common/auth/enums/verification-type";
import { MasterPasswordUnlockService } from "@bitwarden/common/key-management/master-password/abstractions/master-password-unlock.service";
import {
LinkModule,
AsyncActionsModule,
@@ -39,7 +38,7 @@ import {
export class PromptMigrationPasswordComponent {
private dialogRef = inject(DialogRef<string>);
private formBuilder = inject(FormBuilder);
private uvService = inject(UserVerificationService);
private masterPasswordUnlockService = inject(MasterPasswordUnlockService);
private accountService = inject(AccountService);
migrationPasswordForm = this.formBuilder.group({
@@ -57,23 +56,21 @@ export class PromptMigrationPasswordComponent {
return;
}
const { userId, email } = await firstValueFrom(
const { userId } = await firstValueFrom(
this.accountService.activeAccount$.pipe(
filter((account) => account != null),
map((account) => {
return {
userId: account!.id,
email: account!.email,
};
}),
),
);
if (
!(await this.uvService.verifyUserByMasterPassword(
{ type: VerificationType.MasterPassword, secret: masterPasswordControl.value },
!(await this.masterPasswordUnlockService.proofOfDecryption(
masterPasswordControl.value,
userId,
email,
))
) {
return;

View File

@@ -168,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,
@@ -528,7 +530,7 @@ const safeProviders: SafeProvider[] = [
safeProvider({
provide: ChangeKdfService,
useClass: DefaultChangeKdfService,
deps: [ChangeKdfApiService, SdkService],
deps: [ChangeKdfApiService, SdkService, KeyService, InternalMasterPasswordServiceAbstraction],
}),
safeProvider({
provide: EncryptedMigrator,
@@ -572,6 +574,7 @@ const safeProviders: SafeProvider[] = [
KdfConfigService,
TaskSchedulerService,
ConfigService,
AccountCryptographicStateService,
],
}),
safeProvider({
@@ -894,8 +897,14 @@ const safeProviders: SafeProvider[] = [
StateProvider,
SecurityStateService,
KdfConfigService,
AccountCryptographicStateService,
],
}),
safeProvider({
provide: AccountCryptographicStateService,
useClass: DefaultAccountCryptographicStateService,
deps: [StateProvider],
}),
safeProvider({
provide: BroadcasterService,
useClass: DefaultBroadcasterService,
@@ -1333,7 +1342,7 @@ const safeProviders: SafeProvider[] = [
safeProvider({
provide: ChangeKdfService,
useClass: DefaultChangeKdfService,
deps: [ChangeKdfApiService, SdkService],
deps: [ChangeKdfApiService, SdkService, KeyService, InternalMasterPasswordServiceAbstraction],
}),
safeProvider({
provide: AuthRequestServiceAbstraction,
@@ -1565,6 +1574,7 @@ const safeProviders: SafeProvider[] = [
OrganizationApiServiceAbstraction,
OrganizationUserApiService,
InternalUserDecryptionOptionsServiceAbstraction,
AccountCryptographicStateService,
],
}),
safeProvider({

View File

@@ -478,7 +478,7 @@ export class SsoComponent implements OnInit {
!userDecryptionOpts.hasMasterPassword &&
userDecryptionOpts.keyConnectorOption === undefined;
if (requireSetPassword || authResult.resetMasterPassword) {
if (requireSetPassword) {
// Change implies going no password -> password in this case
return await this.handleChangePasswordRequired(orgSsoIdentifier);
}

View File

@@ -487,7 +487,7 @@ export class TwoFactorAuthComponent implements OnInit, OnDestroy {
!userDecryptionOpts.hasMasterPassword && userDecryptionOpts.keyConnectorOption === undefined;
// New users without a master password must set a master password before advancing.
if (requireSetPassword || authResult.resetMasterPassword) {
if (requireSetPassword) {
// Change implies going no password -> password in this case
return await this.handleChangePasswordRequired(this.orgSsoIdentifier);
}

View File

@@ -6,6 +6,7 @@ import { TokenService } from "@bitwarden/common/auth/abstractions/token.service"
import { IdentityTokenResponse } from "@bitwarden/common/auth/models/response/identity-token.response";
import { TwoFactorService } from "@bitwarden/common/auth/two-factor";
import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service";
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 { DeviceTrustServiceAbstraction } from "@bitwarden/common/key-management/device-trust/abstractions/device-trust.service.abstraction";
import { FakeMasterPasswordService } from "@bitwarden/common/key-management/master-password/services/fake-master-password.service";
@@ -22,7 +23,7 @@ import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/pl
import { StateService } from "@bitwarden/common/platform/abstractions/state.service";
import { Utils } from "@bitwarden/common/platform/misc/utils";
import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key";
import { FakeAccountService, mockAccountServiceWith } from "@bitwarden/common/spec";
import { makeEncString, FakeAccountService, mockAccountServiceWith } from "@bitwarden/common/spec";
import { CsprngArray } from "@bitwarden/common/types/csprng";
import { UserId } from "@bitwarden/common/types/guid";
import { MasterKey, UserKey } from "@bitwarden/common/types/key";
@@ -57,6 +58,7 @@ describe("AuthRequestLoginStrategy", () => {
let kdfConfigService: MockProxy<KdfConfigService>;
let environmentService: MockProxy<EnvironmentService>;
let configService: MockProxy<ConfigService>;
let accountCryptographicStateService: MockProxy<AccountCryptographicStateService>;
const mockUserId = Utils.newGuid() as UserId;
let accountService: FakeAccountService;
@@ -94,6 +96,7 @@ describe("AuthRequestLoginStrategy", () => {
kdfConfigService = mock<KdfConfigService>();
environmentService = mock<EnvironmentService>();
configService = mock<ConfigService>();
accountCryptographicStateService = mock<AccountCryptographicStateService>();
accountService = mockAccountServiceWith(mockUserId);
masterPasswordService = new FakeMasterPasswordService();
@@ -125,6 +128,7 @@ describe("AuthRequestLoginStrategy", () => {
kdfConfigService,
environmentService,
configService,
accountCryptographicStateService,
);
tokenResponse = identityTokenResponseFactory();
@@ -208,4 +212,41 @@ describe("AuthRequestLoginStrategy", () => {
// trustDeviceIfRequired should be called
expect(deviceTrustService.trustDeviceIfRequired).not.toHaveBeenCalled();
});
it("sets account cryptographic state when accountKeysResponseModel is present", async () => {
const accountKeysData = {
publicKeyEncryptionKeyPair: {
publicKey: "testPublicKey",
wrappedPrivateKey: "testPrivateKey",
},
};
tokenResponse = identityTokenResponseFactory();
tokenResponse.key = makeEncString("mockEncryptedUserKey");
// Add accountKeysResponseModel to the response
(tokenResponse as any).accountKeysResponseModel = {
publicKeyEncryptionKeyPair: accountKeysData.publicKeyEncryptionKeyPair,
toWrappedAccountCryptographicState: jest.fn().mockReturnValue({
V1: {
private_key: "testPrivateKey",
},
}),
};
apiService.postIdentityToken.mockResolvedValue(tokenResponse);
masterPasswordService.masterKeySubject.next(decMasterKey);
masterPasswordService.mock.decryptUserKeyWithMasterKey.mockResolvedValue(decUserKey);
await authRequestLoginStrategy.logIn(credentials);
expect(accountCryptographicStateService.setAccountCryptographicState).toHaveBeenCalledTimes(1);
expect(accountCryptographicStateService.setAccountCryptographicState).toHaveBeenCalledWith(
{
V1: {
private_key: "testPrivateKey",
},
},
mockUserId,
);
});
});

View File

@@ -128,6 +128,12 @@ export class AuthRequestLoginStrategy extends LoginStrategy {
response.privateKey ?? (await this.createKeyPairForOldAccount(userId)),
userId,
);
if (response.accountKeysResponseModel) {
await this.accountCryptographicStateService.setAccountCryptographicState(
response.accountKeysResponseModel.toWrappedAccountCryptographicState(),
userId,
);
}
}
exportCache(): CacheData {

View File

@@ -17,6 +17,7 @@ import { MasterPasswordPolicyResponse } from "@bitwarden/common/auth/models/resp
import { IUserDecryptionOptionsServerResponse } from "@bitwarden/common/auth/models/response/user-decryption-options/user-decryption-options.response";
import { TwoFactorService } from "@bitwarden/common/auth/two-factor";
import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service";
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 { FakeMasterPasswordService } from "@bitwarden/common/key-management/master-password/services/fake-master-password.service";
@@ -101,7 +102,6 @@ export function identityTokenResponseFactory(
KdfIterations: kdfIterations,
Key: encryptedUserKey,
PrivateKey: privateKey,
ResetMasterPassword: false,
access_token: accessToken,
expires_in: 3600,
refresh_token: refreshToken,
@@ -137,6 +137,7 @@ describe("LoginStrategy", () => {
let kdfConfigService: MockProxy<KdfConfigService>;
let environmentService: MockProxy<EnvironmentService>;
let configService: MockProxy<ConfigService>;
let accountCryptographicStateService: MockProxy<AccountCryptographicStateService>;
let passwordLoginStrategy: PasswordLoginStrategy;
let credentials: PasswordLoginCredentials;
@@ -163,6 +164,7 @@ describe("LoginStrategy", () => {
billingAccountProfileStateService = mock<BillingAccountProfileStateService>();
environmentService = mock<EnvironmentService>();
configService = mock<ConfigService>();
accountCryptographicStateService = mock<AccountCryptographicStateService>();
vaultTimeoutSettingsService = mock<VaultTimeoutSettingsService>();
@@ -193,6 +195,7 @@ describe("LoginStrategy", () => {
kdfConfigService,
environmentService,
configService,
accountCryptographicStateService,
);
credentials = new PasswordLoginCredentials(email, masterPassword);
});
@@ -301,7 +304,6 @@ describe("LoginStrategy", () => {
it("builds AuthResult", async () => {
const tokenResponse = identityTokenResponseFactory();
tokenResponse.forcePasswordReset = true;
tokenResponse.resetMasterPassword = true;
apiService.postIdentityToken.mockResolvedValue(tokenResponse);
@@ -310,7 +312,6 @@ describe("LoginStrategy", () => {
const expected = new AuthResult();
expected.masterPassword = "password";
expected.userId = userId;
expected.resetMasterPassword = true;
expected.twoFactorProviders = null;
expect(result).toEqual(expected);
});
@@ -326,7 +327,6 @@ describe("LoginStrategy", () => {
const expected = new AuthResult();
expected.masterPassword = "password";
expected.userId = userId;
expected.resetMasterPassword = false;
expected.twoFactorProviders = null;
expect(result).toEqual(expected);
@@ -522,6 +522,7 @@ describe("LoginStrategy", () => {
kdfConfigService,
environmentService,
configService,
accountCryptographicStateService,
);
apiService.postIdentityToken.mockResolvedValue(identityTokenResponseFactory());
@@ -583,6 +584,7 @@ describe("LoginStrategy", () => {
kdfConfigService,
environmentService,
configService,
accountCryptographicStateService,
);
const result = await passwordLoginStrategy.logIn(credentials);

View File

@@ -18,6 +18,7 @@ import { IdentityTokenResponse } from "@bitwarden/common/auth/models/response/id
import { IdentityTwoFactorResponse } from "@bitwarden/common/auth/models/response/identity-two-factor.response";
import { TwoFactorService } from "@bitwarden/common/auth/two-factor";
import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service";
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 { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/key-management/master-password/abstractions/master-password.service.abstraction";
import {
@@ -89,6 +90,7 @@ export abstract class LoginStrategy {
protected KdfConfigService: KdfConfigService,
protected environmentService: EnvironmentService,
protected configService: ConfigService,
protected accountCryptographicStateService: AccountCryptographicStateService,
) {}
abstract exportCache(): CacheData;
@@ -254,8 +256,6 @@ export abstract class LoginStrategy {
const userId = await this.saveAccountInformation(response);
result.userId = userId;
result.resetMasterPassword = response.resetMasterPassword;
if (response.twoFactorToken != null) {
// note: we can read email from access token b/c it was saved in saveAccountInformation
const userEmail = await this.tokenService.getEmail();

View File

@@ -12,6 +12,7 @@ import { IdentityTwoFactorResponse } from "@bitwarden/common/auth/models/respons
import { MasterPasswordPolicyResponse } from "@bitwarden/common/auth/models/response/master-password-policy.response";
import { TwoFactorService } from "@bitwarden/common/auth/two-factor";
import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service";
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 { FakeMasterPasswordService } from "@bitwarden/common/key-management/master-password/services/fake-master-password.service";
import {
@@ -28,7 +29,7 @@ import { StateService } from "@bitwarden/common/platform/abstractions/state.serv
import { HashPurpose } from "@bitwarden/common/platform/enums";
import { Utils } from "@bitwarden/common/platform/misc/utils";
import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key";
import { FakeAccountService, mockAccountServiceWith } from "@bitwarden/common/spec";
import { FakeAccountService, makeEncString, mockAccountServiceWith } from "@bitwarden/common/spec";
import {
PasswordStrengthServiceAbstraction,
PasswordStrengthService,
@@ -85,6 +86,7 @@ describe("PasswordLoginStrategy", () => {
let kdfConfigService: MockProxy<KdfConfigService>;
let environmentService: MockProxy<EnvironmentService>;
let configService: MockProxy<ConfigService>;
let accountCryptographicStateService: MockProxy<AccountCryptographicStateService>;
let passwordLoginStrategy: PasswordLoginStrategy;
let credentials: PasswordLoginCredentials;
@@ -113,6 +115,7 @@ describe("PasswordLoginStrategy", () => {
kdfConfigService = mock<KdfConfigService>();
environmentService = mock<EnvironmentService>();
configService = mock<ConfigService>();
accountCryptographicStateService = mock<AccountCryptographicStateService>();
appIdService.getAppId.mockResolvedValue(deviceId);
tokenService.decodeAccessToken.mockResolvedValue({
@@ -153,6 +156,7 @@ describe("PasswordLoginStrategy", () => {
kdfConfigService,
environmentService,
configService,
accountCryptographicStateService,
);
credentials = new PasswordLoginCredentials(email, masterPassword);
tokenResponse = identityTokenResponseFactory(masterPasswordPolicyResponse);
@@ -390,7 +394,45 @@ describe("PasswordLoginStrategy", () => {
newDeviceOtp: deviceVerificationOtp,
}),
);
expect(result.resetMasterPassword).toBe(false);
expect(result.userId).toBe(userId);
});
it("sets account cryptographic state when accountKeysResponseModel is present", async () => {
const accountKeysData = {
publicKeyEncryptionKeyPair: {
publicKey: "testPublicKey",
wrappedPrivateKey: "testPrivateKey",
},
};
tokenResponse = identityTokenResponseFactory();
tokenResponse.key = makeEncString("mockEncryptedUserKey");
// Add accountKeysResponseModel to the response
(tokenResponse as any).accountKeysResponseModel = {
publicKeyEncryptionKeyPair: accountKeysData.publicKeyEncryptionKeyPair,
toWrappedAccountCryptographicState: jest.fn().mockReturnValue({
V1: {
private_key: "testPrivateKey",
},
}),
};
apiService.postIdentityToken.mockResolvedValue(tokenResponse);
masterPasswordService.masterKeySubject.next(masterKey);
masterPasswordService.mock.decryptUserKeyWithMasterKey.mockResolvedValue(
new SymmetricCryptoKey(new Uint8Array(64).buffer as CsprngArray) as UserKey,
);
await passwordLoginStrategy.logIn(credentials);
expect(accountCryptographicStateService.setAccountCryptographicState).toHaveBeenCalledTimes(1);
expect(accountCryptographicStateService.setAccountCryptographicState).toHaveBeenCalledWith(
{
V1: {
private_key: "testPrivateKey",
},
},
userId,
);
});
});

View File

@@ -156,6 +156,12 @@ export class PasswordLoginStrategy extends LoginStrategy {
response.privateKey ?? (await this.createKeyPairForOldAccount(userId)),
userId,
);
if (response.accountKeysResponseModel) {
await this.accountCryptographicStateService.setAccountCryptographicState(
response.accountKeysResponseModel.toWrappedAccountCryptographicState(),
userId,
);
}
}
protected override encryptionKeyMigrationRequired(response: IdentityTokenResponse): boolean {

View File

@@ -10,6 +10,7 @@ import { IdentityTokenResponse } from "@bitwarden/common/auth/models/response/id
import { IUserDecryptionOptionsServerResponse } from "@bitwarden/common/auth/models/response/user-decryption-options/user-decryption-options.response";
import { TwoFactorService } from "@bitwarden/common/auth/two-factor";
import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service";
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 } from "@bitwarden/common/key-management/crypto/models/enc-string";
import { DeviceTrustServiceAbstraction } from "@bitwarden/common/key-management/device-trust/abstractions/device-trust.service.abstraction";
@@ -30,7 +31,7 @@ import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/pl
import { StateService } from "@bitwarden/common/platform/abstractions/state.service";
import { Utils } from "@bitwarden/common/platform/misc/utils";
import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key";
import { FakeAccountService, mockAccountServiceWith } from "@bitwarden/common/spec";
import { FakeAccountService, makeEncString, mockAccountServiceWith } from "@bitwarden/common/spec";
import { CsprngArray } from "@bitwarden/common/types/csprng";
import { UserId } from "@bitwarden/common/types/guid";
import { DeviceKey, MasterKey, UserKey } from "@bitwarden/common/types/key";
@@ -70,6 +71,7 @@ describe("SsoLoginStrategy", () => {
let kdfConfigService: MockProxy<KdfConfigService>;
let environmentService: MockProxy<EnvironmentService>;
let configService: MockProxy<ConfigService>;
let accountCryptographicStateService: MockProxy<AccountCryptographicStateService>;
let ssoLoginStrategy: SsoLoginStrategy;
let credentials: SsoLoginCredentials;
@@ -108,6 +110,7 @@ describe("SsoLoginStrategy", () => {
kdfConfigService = mock<KdfConfigService>();
environmentService = mock<EnvironmentService>();
configService = mock<ConfigService>();
accountCryptographicStateService = mock<AccountCryptographicStateService>();
tokenService.getTwoFactorToken.mockResolvedValue(null);
appIdService.getAppId.mockResolvedValue(deviceId);
@@ -162,6 +165,7 @@ describe("SsoLoginStrategy", () => {
kdfConfigService,
environmentService,
configService,
accountCryptographicStateService,
);
credentials = new SsoLoginCredentials(ssoCode, ssoCodeVerifier, ssoRedirectUrl, ssoOrgId);
});
@@ -556,4 +560,39 @@ describe("SsoLoginStrategy", () => {
expect(keyService.setUserKey).toHaveBeenCalledWith(userKey, userId);
});
});
it("sets account cryptographic state when accountKeysResponseModel is present", async () => {
const accountKeysData = {
publicKeyEncryptionKeyPair: {
publicKey: "testPublicKey",
wrappedPrivateKey: "testPrivateKey",
},
};
const tokenResponse = identityTokenResponseFactory();
tokenResponse.key = makeEncString("mockEncryptedUserKey");
// Add accountKeysResponseModel to the response
(tokenResponse as any).accountKeysResponseModel = {
publicKeyEncryptionKeyPair: accountKeysData.publicKeyEncryptionKeyPair,
toWrappedAccountCryptographicState: jest.fn().mockReturnValue({
V1: {
private_key: "testPrivateKey",
},
}),
};
apiService.postIdentityToken.mockResolvedValue(tokenResponse);
await ssoLoginStrategy.logIn(credentials);
expect(accountCryptographicStateService.setAccountCryptographicState).toHaveBeenCalledTimes(1);
expect(accountCryptographicStateService.setAccountCryptographicState).toHaveBeenCalledWith(
{
V1: {
private_key: "testPrivateKey",
},
},
userId,
);
});
});

View File

@@ -339,6 +339,13 @@ export class SsoLoginStrategy extends LoginStrategy {
tokenResponse: IdentityTokenResponse,
userId: UserId,
): Promise<void> {
if (tokenResponse.accountKeysResponseModel) {
await this.accountCryptographicStateService.setAccountCryptographicState(
tokenResponse.accountKeysResponseModel.toWrappedAccountCryptographicState(),
userId,
);
}
if (tokenResponse.hasMasterKeyEncryptedUserKey()) {
// User has masterKeyEncryptedUserKey, so set the userKeyEncryptedPrivateKey
// Note: new JIT provisioned SSO users will not yet have a user asymmetric key pair

View File

@@ -5,6 +5,7 @@ import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { TokenService } from "@bitwarden/common/auth/abstractions/token.service";
import { TwoFactorService } from "@bitwarden/common/auth/two-factor";
import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service";
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 { KeyConnectorService } from "@bitwarden/common/key-management/key-connector/abstractions/key-connector.service";
import { FakeMasterPasswordService } from "@bitwarden/common/key-management/master-password/services/fake-master-password.service";
@@ -58,6 +59,7 @@ describe("UserApiLoginStrategy", () => {
let vaultTimeoutSettingsService: MockProxy<VaultTimeoutSettingsService>;
let kdfConfigService: MockProxy<KdfConfigService>;
let configService: MockProxy<ConfigService>;
let accountCryptographicStateService: MockProxy<AccountCryptographicStateService>;
let apiLogInStrategy: UserApiLoginStrategy;
let credentials: UserApiLoginCredentials;
@@ -91,6 +93,7 @@ describe("UserApiLoginStrategy", () => {
vaultTimeoutSettingsService = mock<VaultTimeoutSettingsService>();
kdfConfigService = mock<KdfConfigService>();
configService = mock<ConfigService>();
accountCryptographicStateService = mock<AccountCryptographicStateService>();
appIdService.getAppId.mockResolvedValue(deviceId);
tokenService.getTwoFactorToken.mockResolvedValue(null);
@@ -119,6 +122,7 @@ describe("UserApiLoginStrategy", () => {
kdfConfigService,
environmentService,
configService,
accountCryptographicStateService,
);
credentials = new UserApiLoginCredentials(apiClientId, apiClientSecret);
@@ -226,4 +230,38 @@ describe("UserApiLoginStrategy", () => {
);
expect(keyService.setUserKey).toHaveBeenCalledWith(userKey, userId);
});
it("sets account cryptographic state when accountKeysResponseModel is present", async () => {
const accountKeysData = {
publicKeyEncryptionKeyPair: {
publicKey: "testPublicKey",
wrappedPrivateKey: "testPrivateKey",
},
};
const tokenResponse = identityTokenResponseFactory();
// Add accountKeysResponseModel to the response
(tokenResponse as any).accountKeysResponseModel = {
publicKeyEncryptionKeyPair: accountKeysData.publicKeyEncryptionKeyPair,
toWrappedAccountCryptographicState: jest.fn().mockReturnValue({
V1: {
private_key: "testPrivateKey",
},
}),
};
apiService.postIdentityToken.mockResolvedValue(tokenResponse);
await apiLogInStrategy.logIn(credentials);
expect(accountCryptographicStateService.setAccountCryptographicState).toHaveBeenCalledTimes(1);
expect(accountCryptographicStateService.setAccountCryptographicState).toHaveBeenCalledWith(
{
V1: {
private_key: "testPrivateKey",
},
},
userId,
);
});
});

View File

@@ -87,6 +87,12 @@ export class UserApiLoginStrategy extends LoginStrategy {
response.privateKey ?? (await this.createKeyPairForOldAccount(userId)),
userId,
);
if (response.accountKeysResponseModel) {
await this.accountCryptographicStateService.setAccountCryptographicState(
response.accountKeysResponseModel.toWrappedAccountCryptographicState(),
userId,
);
}
}
// Overridden to save client ID and secret to token service

View File

@@ -9,6 +9,7 @@ import { IUserDecryptionOptionsServerResponse } from "@bitwarden/common/auth/mod
import { WebAuthnLoginAssertionResponseRequest } from "@bitwarden/common/auth/services/webauthn-login/request/webauthn-login-assertion-response.request";
import { TwoFactorService } from "@bitwarden/common/auth/two-factor";
import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service";
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 { FakeMasterPasswordService } from "@bitwarden/common/key-management/master-password/services/fake-master-password.service";
import {
@@ -56,6 +57,7 @@ describe("WebAuthnLoginStrategy", () => {
let kdfConfigService: MockProxy<KdfConfigService>;
let environmentService: MockProxy<EnvironmentService>;
let configService: MockProxy<ConfigService>;
let accountCryptographicStateService: MockProxy<AccountCryptographicStateService>;
let webAuthnLoginStrategy!: WebAuthnLoginStrategy;
@@ -101,6 +103,7 @@ describe("WebAuthnLoginStrategy", () => {
kdfConfigService = mock<KdfConfigService>();
environmentService = mock<EnvironmentService>();
configService = mock<ConfigService>();
accountCryptographicStateService = mock<AccountCryptographicStateService>();
tokenService.getTwoFactorToken.mockResolvedValue(null);
appIdService.getAppId.mockResolvedValue(deviceId);
@@ -128,6 +131,7 @@ describe("WebAuthnLoginStrategy", () => {
kdfConfigService,
environmentService,
configService,
accountCryptographicStateService,
);
// Create credentials
@@ -212,7 +216,6 @@ describe("WebAuthnLoginStrategy", () => {
expect(authResult).toBeInstanceOf(AuthResult);
expect(authResult).toMatchObject({
resetMasterPassword: false,
twoFactorProviders: null,
requiresTwoFactor: false,
});
@@ -341,6 +344,53 @@ describe("WebAuthnLoginStrategy", () => {
// Assert
expect(keyService.setUserKey).not.toHaveBeenCalled();
});
it("sets account cryptographic state when accountKeysResponseModel is present", async () => {
// Arrange
const accountKeysData = {
publicKeyEncryptionKeyPair: {
publicKey: "testPublicKey",
wrappedPrivateKey: "testPrivateKey",
},
};
const idTokenResponse: IdentityTokenResponse = identityTokenResponseFactory(
null,
userDecryptionOptsServerResponseWithWebAuthnPrfOption,
);
// Add accountKeysResponseModel to the response
(idTokenResponse as any).accountKeysResponseModel = {
publicKeyEncryptionKeyPair: accountKeysData.publicKeyEncryptionKeyPair,
toWrappedAccountCryptographicState: jest.fn().mockReturnValue({
V1: {
private_key: "testPrivateKey",
},
}),
};
apiService.postIdentityToken.mockResolvedValue(idTokenResponse);
const mockPrfPrivateKey: Uint8Array = randomBytes(32);
const mockUserKeyArray: Uint8Array = randomBytes(32);
encryptService.unwrapDecapsulationKey.mockResolvedValue(mockPrfPrivateKey);
encryptService.decapsulateKeyUnsigned.mockResolvedValue(
new SymmetricCryptoKey(mockUserKeyArray),
);
// Act
await webAuthnLoginStrategy.logIn(webAuthnCredentials);
// Assert
expect(accountCryptographicStateService.setAccountCryptographicState).toHaveBeenCalledTimes(1);
expect(accountCryptographicStateService.setAccountCryptographicState).toHaveBeenCalledWith(
{
V1: {
private_key: "testPrivateKey",
},
},
userId,
);
});
});
// Helpers and mocks

View File

@@ -107,6 +107,12 @@ export class WebAuthnLoginStrategy extends LoginStrategy {
response.privateKey ?? (await this.createKeyPairForOldAccount(userId)),
userId,
);
if (response.accountKeysResponseModel) {
await this.accountCryptographicStateService.setAccountCryptographicState(
response.accountKeysResponseModel.toWrappedAccountCryptographicState(),
userId,
);
}
}
exportCache(): CacheData {

View File

@@ -13,6 +13,7 @@ import { PreloginResponse } from "@bitwarden/common/auth/models/response/prelogi
import { UserDecryptionOptionsResponse } from "@bitwarden/common/auth/models/response/user-decryption-options/user-decryption-options.response";
import { TwoFactorService } from "@bitwarden/common/auth/two-factor";
import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service";
import { DefaultAccountCryptographicStateService } from "@bitwarden/common/key-management/account-cryptography/default-account-cryptographic-state.service";
import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service";
import { DeviceTrustServiceAbstraction } from "@bitwarden/common/key-management/device-trust/abstractions/device-trust.service.abstraction";
import { KeyConnectorService } from "@bitwarden/common/key-management/key-connector/abstractions/key-connector.service";
@@ -84,6 +85,7 @@ describe("LoginStrategyService", () => {
let kdfConfigService: MockProxy<KdfConfigService>;
let taskSchedulerService: MockProxy<TaskSchedulerService>;
let configService: MockProxy<ConfigService>;
let accountCryptographicStateService: MockProxy<DefaultAccountCryptographicStateService>;
let stateProvider: FakeGlobalStateProvider;
let loginStrategyCacheExpirationState: FakeGlobalState<Date | null>;
@@ -117,6 +119,7 @@ describe("LoginStrategyService", () => {
kdfConfigService = mock<KdfConfigService>();
taskSchedulerService = mock<TaskSchedulerService>();
configService = mock<ConfigService>();
accountCryptographicStateService = mock<DefaultAccountCryptographicStateService>();
sut = new LoginStrategyService(
accountService,
@@ -145,6 +148,7 @@ describe("LoginStrategyService", () => {
kdfConfigService,
taskSchedulerService,
configService,
accountCryptographicStateService,
);
loginStrategyCacheExpirationState = stateProvider.getFake(CACHE_EXPIRATION_KEY);
@@ -491,7 +495,6 @@ describe("LoginStrategyService", () => {
KdfParallelism: 1,
Key: "KEY",
PrivateKey: "PRIVATE_KEY",
ResetMasterPassword: false,
access_token: "ACCESS_TOKEN",
expires_in: 3600,
refresh_token: "REFRESH_TOKEN",
@@ -559,7 +562,6 @@ describe("LoginStrategyService", () => {
KdfParallelism: 1,
Key: "KEY",
PrivateKey: "PRIVATE_KEY",
ResetMasterPassword: false,
access_token: "ACCESS_TOKEN",
expires_in: 3600,
refresh_token: "REFRESH_TOKEN",
@@ -625,7 +627,6 @@ describe("LoginStrategyService", () => {
KdfIterations: PBKDF2KdfConfig.PRELOGIN_ITERATIONS_MIN - 1,
Key: "KEY",
PrivateKey: "PRIVATE_KEY",
ResetMasterPassword: false,
access_token: "ACCESS_TOKEN",
expires_in: 3600,
refresh_token: "REFRESH_TOKEN",
@@ -689,7 +690,6 @@ describe("LoginStrategyService", () => {
KdfParallelism: 1,
Key: "KEY",
PrivateKey: "PRIVATE_KEY",
ResetMasterPassword: false,
access_token: "ACCESS_TOKEN",
expires_in: 3600,
refresh_token: "REFRESH_TOKEN",

View File

@@ -19,6 +19,7 @@ import { TokenTwoFactorRequest } from "@bitwarden/common/auth/models/request/ide
import { TwoFactorService } from "@bitwarden/common/auth/two-factor";
import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
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 { DeviceTrustServiceAbstraction } from "@bitwarden/common/key-management/device-trust/abstractions/device-trust.service.abstraction";
import { KeyConnectorService } from "@bitwarden/common/key-management/key-connector/abstractions/key-connector.service";
@@ -160,6 +161,7 @@ export class LoginStrategyService implements LoginStrategyServiceAbstraction {
protected kdfConfigService: KdfConfigService,
protected taskSchedulerService: TaskSchedulerService,
protected configService: ConfigService,
protected accountCryptographicStateService: AccountCryptographicStateService,
) {
this.currentAuthnTypeState = this.stateProvider.get(CURRENT_LOGIN_STRATEGY_KEY);
this.loginStrategyCacheState = this.stateProvider.get(CACHE_KEY);
@@ -509,6 +511,7 @@ export class LoginStrategyService implements LoginStrategyServiceAbstraction {
this.kdfConfigService,
this.environmentService,
this.configService,
this.accountCryptographicStateService,
];
return source.pipe(

View File

@@ -7,14 +7,6 @@ import { TwoFactorProviderType } from "../../enums/two-factor-provider-type";
export class AuthResult {
userId: UserId;
// TODO: PM-3287 - Remove this after 3 releases of backwards compatibility. - Target release 2023.12 for removal
/**
* @deprecated
* Replace with using UserDecryptionOptions to determine if the user does
* not have a master password and is not using Key Connector.
* */
resetMasterPassword = false;
twoFactorProviders: Partial<Record<TwoFactorProviderType, Record<string, string>>> = null;
ssoEmail2FaSessionToken?: string;
email: string;

View File

@@ -116,4 +116,36 @@ describe("IdentityTokenResponse", () => {
const identityTokenResponse = new IdentityTokenResponse(response);
expect(identityTokenResponse.userDecryptionOptions).toBeDefined();
});
it("should create response with accountKeys not present", () => {
const response = {
access_token: accessToken,
token_type: tokenType,
AccountKeys: null as unknown,
};
const identityTokenResponse = new IdentityTokenResponse(response);
expect(identityTokenResponse.accountKeysResponseModel).toBeNull();
});
it("should create response with accountKeys present", () => {
const accountKeysData = {
publicKeyEncryptionKeyPair: {
publicKey: "testPublicKey",
wrappedPrivateKey: "testPrivateKey",
},
};
const response = {
access_token: accessToken,
token_type: tokenType,
AccountKeys: accountKeysData,
};
const identityTokenResponse = new IdentityTokenResponse(response);
expect(identityTokenResponse.accountKeysResponseModel).toBeDefined();
expect(
identityTokenResponse.accountKeysResponseModel?.publicKeyEncryptionKeyPair,
).toBeDefined();
});
});

View File

@@ -5,6 +5,7 @@
import { Argon2KdfConfig, KdfConfig, KdfType, PBKDF2KdfConfig } from "@bitwarden/key-management";
import { EncString } from "../../../key-management/crypto/models/enc-string";
import { PrivateKeysResponseModel } from "../../../key-management/keys/response/private-keys.response";
import { BaseResponse } from "../../../models/response/base.response";
import { MasterPasswordPolicyResponse } from "./master-password-policy.response";
@@ -18,8 +19,8 @@ export class IdentityTokenResponse extends BaseResponse {
tokenType: string;
// Decryption Information
resetMasterPassword: boolean;
privateKey: string; // userKeyEncryptedPrivateKey
accountKeysResponseModel: PrivateKeysResponseModel | null = null;
key?: EncString; // masterKeyEncryptedUserKey
twoFactorToken: string;
kdfConfig: KdfConfig;
@@ -52,8 +53,12 @@ export class IdentityTokenResponse extends BaseResponse {
this.refreshToken = refreshToken;
}
this.resetMasterPassword = this.getResponseProperty("ResetMasterPassword");
this.privateKey = this.getResponseProperty("PrivateKey");
if (this.getResponseProperty("AccountKeys") != null) {
this.accountKeysResponseModel = new PrivateKeysResponseModel(
this.getResponseProperty("AccountKeys"),
);
}
const key = this.getResponseProperty("Key");
if (key) {
this.key = new EncString(key);

View File

@@ -0,0 +1,37 @@
import { Observable } from "rxjs";
import { UserId } from "@bitwarden/user-core";
/**
* Abstraction for phishing detection settings
*/
export abstract class PhishingDetectionSettingsServiceAbstraction {
/**
* An observable for whether phishing detection is available for the active user account.
*
* Access is granted only when the PhishingDetection feature flag is enabled and
* at least one of the following is true for the active account:
* - the user has a personal premium subscription
* - the user is a member of a Family org (ProductTierType.Families)
* - the user is a member of an Enterprise org with `usePhishingBlocker` enabled
*
* Note: Non-specified organization types (e.g., Team orgs) do not grant access.
*/
abstract readonly available$: Observable<boolean>;
/**
* An observable for whether phishing detection is on for the active user account
*
* This is true when {@link available$} is true and when {@link enabled$} is true
*/
abstract readonly on$: Observable<boolean>;
/**
* An observable for whether phishing detection is enabled
*/
abstract readonly enabled$: Observable<boolean>;
/**
* Sets whether phishing detection is enabled
*
* @param enabled True to enable, false to disable
*/
abstract setEnabled: (userId: UserId, enabled: boolean) => Promise<void>;
}

View File

@@ -0,0 +1,203 @@
import { mock, MockProxy } from "jest-mock-extended";
import { BehaviorSubject, firstValueFrom, Subject } from "rxjs";
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
import { Account, AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions";
import { ProductTierType } from "@bitwarden/common/billing/enums";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { FakeAccountService, FakeStateProvider, mockAccountServiceWith } from "../../../../spec";
import { UserId } from "../../../types/guid";
import { PhishingDetectionSettingsService } from "./phishing-detection-settings.service";
describe("PhishingDetectionSettingsService", () => {
// Mock services
let mockAccountService: MockProxy<AccountService>;
let mockBillingService: MockProxy<BillingAccountProfileStateService>;
let mockConfigService: MockProxy<ConfigService>;
let mockOrganizationService: MockProxy<OrganizationService>;
// RxJS Subjects we control in the tests
let activeAccountSubject: BehaviorSubject<Account | null>;
let featureFlagSubject: BehaviorSubject<boolean>;
let premiumStatusSubject: BehaviorSubject<boolean>;
let organizationsSubject: BehaviorSubject<Organization[]>;
let service: PhishingDetectionSettingsService;
let stateProvider: FakeStateProvider;
// Constant mock data
const familyOrg = mock<Organization>({
canAccess: true,
isMember: true,
usersGetPremium: true,
productTierType: ProductTierType.Families,
usePhishingBlocker: true,
});
const teamOrg = mock<Organization>({
canAccess: true,
isMember: true,
usersGetPremium: true,
productTierType: ProductTierType.Teams,
usePhishingBlocker: true,
});
const enterpriseOrg = mock<Organization>({
canAccess: true,
isMember: true,
usersGetPremium: true,
productTierType: ProductTierType.Enterprise,
usePhishingBlocker: true,
});
const mockUserId = "mock-user-id" as UserId;
const account = mock<Account>({ id: mockUserId });
const accountService: FakeAccountService = mockAccountServiceWith(mockUserId);
beforeEach(() => {
// Initialize subjects
activeAccountSubject = new BehaviorSubject<Account | null>(null);
featureFlagSubject = new BehaviorSubject<boolean>(false);
premiumStatusSubject = new BehaviorSubject<boolean>(false);
organizationsSubject = new BehaviorSubject<Organization[]>([]);
// Default implementations for required functions
mockAccountService = mock<AccountService>();
mockAccountService.activeAccount$ = activeAccountSubject.asObservable();
mockBillingService = mock<BillingAccountProfileStateService>();
mockBillingService.hasPremiumPersonally$.mockReturnValue(premiumStatusSubject.asObservable());
mockConfigService = mock<ConfigService>();
mockConfigService.getFeatureFlag$.mockReturnValue(featureFlagSubject.asObservable());
mockOrganizationService = mock<OrganizationService>();
mockOrganizationService.organizations$.mockReturnValue(organizationsSubject.asObservable());
stateProvider = new FakeStateProvider(accountService);
service = new PhishingDetectionSettingsService(
mockAccountService,
mockBillingService,
mockConfigService,
mockOrganizationService,
stateProvider,
);
});
// Helper to easily get the result of the observable we are testing
const getAccess = () => firstValueFrom(service.available$);
describe("enabled$", () => {
it("should default to true if an account is logged in", async () => {
activeAccountSubject.next(account);
const result = await firstValueFrom(service.enabled$);
expect(result).toBe(true);
});
it("should return the stored value", async () => {
activeAccountSubject.next(account);
await service.setEnabled(mockUserId, false);
const resultDisabled = await firstValueFrom(service.enabled$);
expect(resultDisabled).toBe(false);
await service.setEnabled(mockUserId, true);
const resultEnabled = await firstValueFrom(service.enabled$);
expect(resultEnabled).toBe(true);
});
});
describe("setEnabled", () => {
it("should update the stored value", async () => {
activeAccountSubject.next(account);
await service.setEnabled(mockUserId, false);
let result = await firstValueFrom(service.enabled$);
expect(result).toBe(false);
await service.setEnabled(mockUserId, true);
result = await firstValueFrom(service.enabled$);
expect(result).toBe(true);
});
});
it("returns false immediately when the feature flag is disabled, regardless of other conditions", async () => {
activeAccountSubject.next(account);
premiumStatusSubject.next(true);
organizationsSubject.next([familyOrg]);
featureFlagSubject.next(false);
await expect(getAccess()).resolves.toBe(false);
});
it("returns false if there is no active account present yet", async () => {
activeAccountSubject.next(null); // No active account
featureFlagSubject.next(true); // Flag is on
await expect(getAccess()).resolves.toBe(false);
});
it("returns true when feature flag is enabled and user has premium personally", async () => {
activeAccountSubject.next(account);
featureFlagSubject.next(true);
organizationsSubject.next([]);
premiumStatusSubject.next(true);
await expect(getAccess()).resolves.toBe(true);
});
it("returns true when feature flag is enabled and user is in a Family Organization", async () => {
activeAccountSubject.next(account);
featureFlagSubject.next(true);
premiumStatusSubject.next(false); // User has no personal premium
organizationsSubject.next([familyOrg]);
await expect(getAccess()).resolves.toBe(true);
});
it("returns true when feature flag is enabled and user is in an Enterprise org with phishing blocker enabled", async () => {
activeAccountSubject.next(account);
featureFlagSubject.next(true);
premiumStatusSubject.next(false);
organizationsSubject.next([enterpriseOrg]);
await expect(getAccess()).resolves.toBe(true);
});
it("returns false when user has no access through personal premium or organizations", async () => {
activeAccountSubject.next(account);
featureFlagSubject.next(true);
premiumStatusSubject.next(false);
organizationsSubject.next([teamOrg]); // Team org does not give access
await expect(getAccess()).resolves.toBe(false);
});
it("shares/caches the available$ result between multiple subscribers", async () => {
// Use a plain Subject for this test so we control when the premium observable emits
// and avoid the BehaviorSubject's initial emission which can race with subscriptions.
// Provide the Subject directly as the mock return value for the billing service
const oneTimePremium = new Subject<boolean>();
mockBillingService.hasPremiumPersonally$.mockReturnValueOnce(oneTimePremium.asObservable());
activeAccountSubject.next(account);
featureFlagSubject.next(true);
organizationsSubject.next([]);
const p1 = firstValueFrom(service.available$);
const p2 = firstValueFrom(service.available$);
// Trigger the pipeline
oneTimePremium.next(true);
const [first, second] = await Promise.all([p1, p2]);
expect(first).toBe(true);
expect(second).toBe(true);
// The billing function should have been called at most once due to caching
expect(mockBillingService.hasPremiumPersonally$).toHaveBeenCalledTimes(1);
});
});

View File

@@ -0,0 +1,116 @@
import { combineLatest, Observable, of, switchMap } from "rxjs";
import { catchError, distinctUntilChanged, map, shareReplay } from "rxjs/operators";
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions";
import { ProductTierType } from "@bitwarden/common/billing/enums";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { UserId } from "@bitwarden/user-core";
import { PHISHING_DETECTION_DISK, StateProvider, UserKeyDefinition } from "../../../platform/state";
import { PhishingDetectionSettingsServiceAbstraction } from "../abstractions/phishing-detection-settings.service.abstraction";
const ENABLE_PHISHING_DETECTION = new UserKeyDefinition(
PHISHING_DETECTION_DISK,
"enablePhishingDetection",
{
deserializer: (value: boolean) => value ?? true, // Default: enabled
clearOn: [],
},
);
export class PhishingDetectionSettingsService implements PhishingDetectionSettingsServiceAbstraction {
readonly available$: Observable<boolean>;
readonly enabled$: Observable<boolean>;
readonly on$: Observable<boolean>;
constructor(
private accountService: AccountService,
private billingService: BillingAccountProfileStateService,
private configService: ConfigService,
private organizationService: OrganizationService,
private stateProvider: StateProvider,
) {
this.available$ = this.buildAvailablePipeline$().pipe(
distinctUntilChanged(),
shareReplay({ bufferSize: 1, refCount: true }),
);
this.enabled$ = this.buildEnabledPipeline$().pipe(
distinctUntilChanged(),
shareReplay({ bufferSize: 1, refCount: true }),
);
this.on$ = combineLatest([this.available$, this.enabled$]).pipe(
map(([available, enabled]) => available && enabled),
distinctUntilChanged(),
shareReplay({ bufferSize: 1, refCount: true }),
);
}
async setEnabled(userId: UserId, enabled: boolean): Promise<void> {
await this.stateProvider.getUser(userId, ENABLE_PHISHING_DETECTION).update(() => enabled);
}
/**
* Builds the observable pipeline to determine if phishing detection is available to the user
*
* @returns An observable pipeline that determines if phishing detection is available
*/
private buildAvailablePipeline$(): Observable<boolean> {
return combineLatest([
this.accountService.activeAccount$,
this.configService.getFeatureFlag$(FeatureFlag.PhishingDetection),
]).pipe(
switchMap(([account, featureEnabled]) => {
if (!account || !featureEnabled) {
return of(false);
}
return combineLatest([
this.billingService.hasPremiumPersonally$(account.id).pipe(catchError(() => of(false))),
this.organizationService.organizations$(account.id).pipe(catchError(() => of([]))),
]).pipe(
map(([hasPremium, organizations]) => hasPremium || this.orgGrantsAccess(organizations)),
catchError(() => of(false)),
);
}),
);
}
/**
* Builds the observable pipeline to determine if phishing detection is enabled by the user
*
* @returns True if phishing detection is enabled for the active user
*/
private buildEnabledPipeline$(): Observable<boolean> {
return this.accountService.activeAccount$.pipe(
switchMap((account) => {
if (!account) {
return of(false);
}
return this.stateProvider.getUserState$(ENABLE_PHISHING_DETECTION, account.id);
}),
map((enabled) => enabled ?? true),
);
}
/**
* Determines if any of the user's organizations grant access to phishing detection
*
* @param organizations The organizations the user is a member of
* @returns True if any organization grants access to phishing detection
*/
private orgGrantsAccess(organizations: Organization[]): boolean {
return organizations.some((org) => {
if (!org.canAccess || !org.isMember || !org.usersGetPremium) {
return false;
}
return (
org.productTierType === ProductTierType.Families ||
(org.productTierType === ProductTierType.Enterprise && org.usePhishingBlocker)
);
});
}
}

View File

@@ -79,6 +79,8 @@ export enum EventType {
Organization_CollectionManagement_LimitItemDeletionDisabled = 1615,
Organization_CollectionManagement_AllowAdminAccessToAllCollectionItemsEnabled = 1616,
Organization_CollectionManagement_AllowAdminAccessToAllCollectionItemsDisabled = 1617,
Organization_ItemOrganization_Accepted = 1618,
Organization_ItemOrganization_Declined = 1619,
Policy_Updated = 1700,

View File

@@ -22,6 +22,7 @@ export enum FeatureFlag {
/* Autofill */
MacOsNativeCredentialSync = "macos-native-credential-sync",
WindowsDesktopAutotype = "windows-desktop-autotype",
WindowsDesktopAutotypeGA = "windows-desktop-autotype-ga",
/* Billing */
TrialPaymentOptional = "PM-8163-trial-payment",
@@ -29,7 +30,6 @@ export enum FeatureFlag {
PM24032_NewNavigationPremiumUpgradeButton = "pm-24032-new-navigation-premium-upgrade-button",
PM25379_UseNewOrganizationMetadataStructure = "pm-25379-use-new-organization-metadata-structure",
PM24996_ImplementUpgradeFromFreeDialog = "pm-24996-implement-upgrade-from-free-dialog",
PM24033PremiumUpgradeNewDesign = "pm-24033-updat-premium-subscription-page",
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",
@@ -50,6 +50,8 @@ export enum FeatureFlag {
DesktopSendUIRefresh = "desktop-send-ui-refresh",
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",
/* DIRT */
EventManagementForDataDogAndCrowdStrike = "event-management-for-datadog-and-crowdstrike",
@@ -105,11 +107,14 @@ export const DefaultFeatureFlagValue = {
/* Autofill */
[FeatureFlag.MacOsNativeCredentialSync]: FALSE,
[FeatureFlag.WindowsDesktopAutotype]: FALSE,
[FeatureFlag.WindowsDesktopAutotypeGA]: FALSE,
/* Tools */
[FeatureFlag.DesktopSendUIRefresh]: FALSE,
[FeatureFlag.UseSdkPasswordGenerators]: FALSE,
[FeatureFlag.ChromiumImporterWithABE]: FALSE,
[FeatureFlag.SendUIRefresh]: FALSE,
[FeatureFlag.SendEmailOTP]: FALSE,
/* DIRT */
[FeatureFlag.EventManagementForDataDogAndCrowdStrike]: FALSE,
@@ -136,7 +141,6 @@ export const DefaultFeatureFlagValue = {
[FeatureFlag.PM24032_NewNavigationPremiumUpgradeButton]: FALSE,
[FeatureFlag.PM25379_UseNewOrganizationMetadataStructure]: FALSE,
[FeatureFlag.PM24996_ImplementUpgradeFromFreeDialog]: FALSE,
[FeatureFlag.PM24033PremiumUpgradeNewDesign]: FALSE,
[FeatureFlag.PM26793_FetchPremiumPriceFromPricingService]: FALSE,
[FeatureFlag.PM23713_PremiumBadgeOpensNewPremiumUpgradeDialog]: FALSE,
[FeatureFlag.PM26462_Milestone_3]: FALSE,

View File

@@ -0,0 +1,22 @@
import { Observable } from "rxjs";
import { WrappedAccountCryptographicState } from "@bitwarden/sdk-internal";
import { UserId } from "@bitwarden/user-core";
export abstract class AccountCryptographicStateService {
/**
* Emits the provided user's account cryptographic state or null if there is no account cryptographic state present for the user.
*/
abstract accountCryptographicState$(
userId: UserId,
): Observable<WrappedAccountCryptographicState | null>;
/**
* Sets the account cryptographic state.
* This is not yet validated, and is only validated upon SDK initialization.
*/
abstract setAccountCryptographicState(
accountCryptographicState: WrappedAccountCryptographicState,
userId: UserId,
): Promise<void>;
}

View File

@@ -0,0 +1,133 @@
import { firstValueFrom } from "rxjs";
import { WrappedAccountCryptographicState } from "@bitwarden/sdk-internal";
import { FakeStateProvider } from "@bitwarden/state-test-utils";
import { UserId } from "@bitwarden/user-core";
import { FakeAccountService, mockAccountServiceWith } from "../../../spec";
import {
ACCOUNT_CRYPTOGRAPHIC_STATE,
DefaultAccountCryptographicStateService,
} from "./default-account-cryptographic-state.service";
describe("DefaultAccountCryptographicStateService", () => {
let service: DefaultAccountCryptographicStateService;
let stateProvider: FakeStateProvider;
let accountService: FakeAccountService;
const mockUserId = "user-id" as UserId;
beforeEach(() => {
accountService = mockAccountServiceWith(mockUserId);
stateProvider = new FakeStateProvider(accountService);
service = new DefaultAccountCryptographicStateService(stateProvider);
});
describe("accountCryptographicState$", () => {
it("returns null when no state is set", async () => {
const result = await firstValueFrom(service.accountCryptographicState$(mockUserId));
expect(result).toBeNull();
});
it("returns the account cryptographic state when set (V1)", async () => {
const mockState: WrappedAccountCryptographicState = {
V1: {
private_key: "test-wrapped-state" as any,
},
};
await stateProvider.setUserState(ACCOUNT_CRYPTOGRAPHIC_STATE, mockState, mockUserId);
const result = await firstValueFrom(service.accountCryptographicState$(mockUserId));
expect(result).toEqual(mockState);
});
it("returns the account cryptographic state when set (V2)", async () => {
const mockState: WrappedAccountCryptographicState = {
V2: {
private_key: "test-wrapped-private-key" as any,
signing_key: "test-wrapped-signing-key" as any,
signed_public_key: "test-signed-public-key" as any,
security_state: "test-security-state",
},
};
await stateProvider.setUserState(ACCOUNT_CRYPTOGRAPHIC_STATE, mockState, mockUserId);
const result = await firstValueFrom(service.accountCryptographicState$(mockUserId));
expect(result).toEqual(mockState);
});
it("emits updated state when state changes", async () => {
const mockState1: any = {
V1: {
private_key: "test-state-1" as any,
},
};
const mockState2: any = {
V1: {
private_key: "test-state-2" as any,
},
};
await stateProvider.setUserState(ACCOUNT_CRYPTOGRAPHIC_STATE, mockState1, mockUserId);
const observable = service.accountCryptographicState$(mockUserId);
const results: (WrappedAccountCryptographicState | null)[] = [];
const subscription = observable.subscribe((state) => results.push(state));
await stateProvider.setUserState(ACCOUNT_CRYPTOGRAPHIC_STATE, mockState2, mockUserId);
subscription.unsubscribe();
expect(results).toHaveLength(2);
expect(results[0]).toEqual(mockState1);
expect(results[1]).toEqual(mockState2);
});
});
describe("setAccountCryptographicState", () => {
it("sets the account cryptographic state", async () => {
const mockState: WrappedAccountCryptographicState = {
V1: {
private_key: "test-wrapped-state" as any,
},
};
await service.setAccountCryptographicState(mockState, mockUserId);
const result = await firstValueFrom(service.accountCryptographicState$(mockUserId));
expect(result).toEqual(mockState);
});
it("overwrites existing state", async () => {
const mockState1: WrappedAccountCryptographicState = {
V1: {
private_key: "test-state-1" as any,
},
};
const mockState2: WrappedAccountCryptographicState = {
V1: {
private_key: "test-state-2" as any,
},
};
await service.setAccountCryptographicState(mockState1, mockUserId);
await service.setAccountCryptographicState(mockState2, mockUserId);
const result = await firstValueFrom(service.accountCryptographicState$(mockUserId));
expect(result).toEqual(mockState2);
});
});
describe("ACCOUNT_CRYPTOGRAPHIC_STATE key definition", () => {
it("deserializer returns object as-is", () => {
const mockState: any = {
V1: {
private_key: "test" as any,
},
};
const result = ACCOUNT_CRYPTOGRAPHIC_STATE.deserializer(mockState);
expect(result).toBe(mockState);
});
});
});

View File

@@ -0,0 +1,35 @@
import { Observable } from "rxjs";
import { WrappedAccountCryptographicState } from "@bitwarden/sdk-internal";
import { CRYPTO_DISK, StateProvider, UserKeyDefinition } from "@bitwarden/state";
import { UserId } from "@bitwarden/user-core";
import { AccountCryptographicStateService } from "./account-cryptographic-state.service";
export const ACCOUNT_CRYPTOGRAPHIC_STATE = new UserKeyDefinition<WrappedAccountCryptographicState>(
CRYPTO_DISK,
"accountCryptographicState",
{
deserializer: (obj) => obj as WrappedAccountCryptographicState,
clearOn: ["logout"],
},
);
export class DefaultAccountCryptographicStateService implements AccountCryptographicStateService {
constructor(protected stateProvider: StateProvider) {}
accountCryptographicState$(userId: UserId): Observable<WrappedAccountCryptographicState | null> {
return this.stateProvider.getUserState$(ACCOUNT_CRYPTOGRAPHIC_STATE, userId);
}
async setAccountCryptographicState(
accountCryptographicState: WrappedAccountCryptographicState,
userId: UserId,
): Promise<void> {
await this.stateProvider.setUserState(
ACCOUNT_CRYPTOGRAPHIC_STATE,
accountCryptographicState,
userId,
);
}
}

View File

@@ -2,13 +2,14 @@ import { mock } from "jest-mock-extended";
import { of } from "rxjs";
// eslint-disable-next-line no-restricted-imports
import { PBKDF2KdfConfig } from "@bitwarden/key-management";
import { KeyService, PBKDF2KdfConfig } from "@bitwarden/key-management";
import { makeEncString } from "../../../spec";
import { KdfRequest } from "../../models/request/kdf.request";
import { SdkService } from "../../platform/abstractions/sdk/sdk.service";
import { UserId } from "../../types/guid";
import { EncString } from "../crypto/models/enc-string";
import { InternalMasterPasswordServiceAbstraction } from "../master-password/abstractions/master-password.service.abstraction";
import {
MasterKeyWrappedUserKey,
MasterPasswordAuthenticationHash,
@@ -22,6 +23,8 @@ import { DefaultChangeKdfService } from "./change-kdf.service";
describe("ChangeKdfService", () => {
const changeKdfApiService = mock<ChangeKdfApiService>();
const sdkService = mock<SdkService>();
const keyService = mock<KeyService>();
const masterPasswordService = mock<InternalMasterPasswordServiceAbstraction>();
let sut: DefaultChangeKdfService;
@@ -48,7 +51,12 @@ describe("ChangeKdfService", () => {
beforeEach(() => {
sdkService.userClient$ = jest.fn((userId: UserId) => of(mockSdk)) as any;
sut = new DefaultChangeKdfService(changeKdfApiService, sdkService);
sut = new DefaultChangeKdfService(
changeKdfApiService,
sdkService,
keyService,
masterPasswordService,
);
});
afterEach(() => {
@@ -163,6 +171,20 @@ describe("ChangeKdfService", () => {
expect(changeKdfApiService.updateUserKdfParams).toHaveBeenCalledWith(expectedRequest);
});
it("should set master key and hash after KDF update", async () => {
const masterPassword = "masterPassword";
const mockMasterKey = {} as any;
const mockHash = "localHash";
keyService.makeMasterKey.mockResolvedValue(mockMasterKey);
keyService.hashMasterKey.mockResolvedValue(mockHash);
await sut.updateUserKdfParams(masterPassword, mockNewKdfConfig, mockUserId);
expect(masterPasswordService.setMasterKey).toHaveBeenCalledWith(mockMasterKey, mockUserId);
expect(masterPasswordService.setMasterKeyHash).toHaveBeenCalledWith(mockHash, mockUserId);
});
it("should properly dispose of SDK resources", async () => {
const masterPassword = "masterPassword";
jest.spyOn(mockNewKdfConfig, "toSdkConfig").mockReturnValue({} as any);

View File

@@ -1,12 +1,14 @@
import { firstValueFrom, map } from "rxjs";
import { assertNonNullish } from "@bitwarden/common/auth/utils";
import { HashPurpose } from "@bitwarden/common/platform/enums";
import { UserId } from "@bitwarden/common/types/guid";
// eslint-disable-next-line no-restricted-imports
import { KdfConfig } from "@bitwarden/key-management";
import { KdfConfig, KeyService } from "@bitwarden/key-management";
import { KdfRequest } from "../../models/request/kdf.request";
import { SdkService } from "../../platform/abstractions/sdk/sdk.service";
import { InternalMasterPasswordServiceAbstraction } from "../master-password/abstractions/master-password.service.abstraction";
import {
fromSdkAuthenticationData,
MasterPasswordAuthenticationData,
@@ -20,6 +22,8 @@ export class DefaultChangeKdfService implements ChangeKdfService {
constructor(
private changeKdfApiService: ChangeKdfApiService,
private sdkService: SdkService,
private keyService: KeyService,
private masterPasswordService: InternalMasterPasswordServiceAbstraction,
) {}
async updateUserKdfParams(masterPassword: string, kdf: KdfConfig, userId: UserId): Promise<void> {
@@ -56,5 +60,19 @@ export class DefaultChangeKdfService implements ChangeKdfService {
const request = new KdfRequest(authenticationData, unlockData);
request.authenticateWith(oldAuthenticationData);
await this.changeKdfApiService.updateUserKdfParams(request);
// Update the locally stored master key and hash, so that UV, etc. still works
const masterKey = await this.keyService.makeMasterKey(
masterPassword,
unlockData.salt,
unlockData.kdf,
);
const localMasterKeyHash = await this.keyService.hashMasterKey(
masterPassword,
masterKey,
HashPurpose.LocalAuthorization,
);
await this.masterPasswordService.setMasterKeyHash(localMasterKeyHash, userId);
await this.masterPasswordService.setMasterKey(masterKey, userId);
}
}

View File

@@ -1,3 +1,5 @@
import { SignedPublicKey, WrappedAccountCryptographicState } from "@bitwarden/sdk-internal";
import { SecurityStateResponse } from "../../security-state/response/security-state.response";
import { PublicKeyEncryptionKeyPairResponse } from "./public-key-encryption-key-pair.response";
@@ -52,4 +54,31 @@ export class PrivateKeysResponseModel {
);
}
}
toWrappedAccountCryptographicState(): WrappedAccountCryptographicState {
if (this.signatureKeyPair === null && this.securityState === null) {
// V1 user
return {
V1: {
private_key: this.publicKeyEncryptionKeyPair.wrappedPrivateKey,
},
};
} else if (this.signatureKeyPair !== null && this.securityState !== null) {
// V2 user
return {
V2: {
private_key: this.publicKeyEncryptionKeyPair.wrappedPrivateKey,
signing_key: this.signatureKeyPair.wrappedSigningKey,
signed_public_key: this.publicKeyEncryptionKeyPair.signedPublicKey as SignedPublicKey,
security_state: this.securityState.securityState as string,
},
};
} else {
throw new Error("Both signatureKeyPair and securityState must be present or absent together");
}
}
isV2Encryption(): boolean {
return this.signatureKeyPair !== null && this.securityState !== null;
}
}

View File

@@ -10,3 +10,5 @@ export {
VaultTimeoutNumberType,
VaultTimeoutStringType,
} from "./types/vault-timeout.type";
// Only used by desktop's electron-key.service.spec.ts test
export { VAULT_TIMEOUT } from "./services/vault-timeout-settings.state";

View File

@@ -13,6 +13,7 @@ import {
shareReplay,
switchMap,
tap,
concatMap,
} from "rxjs";
// This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop.
@@ -150,7 +151,7 @@ export class VaultTimeoutSettingsService implements VaultTimeoutSettingsServiceA
return from(
this.determineVaultTimeout(currentVaultTimeout, maxSessionTimeoutPolicyData),
).pipe(
tap((vaultTimeout: VaultTimeout) => {
concatMap(async (vaultTimeout: VaultTimeout) => {
this.logService.debug(
"[VaultTimeoutSettingsService] Determined vault timeout is %o for user id %s",
vaultTimeout,
@@ -159,8 +160,9 @@ export class VaultTimeoutSettingsService implements VaultTimeoutSettingsServiceA
// As a side effect, set the new value determined by determineVaultTimeout into state if it's different from the current
if (vaultTimeout !== currentVaultTimeout) {
return this.stateProvider.setUserState(VAULT_TIMEOUT, vaultTimeout, userId);
await this.stateProvider.setUserState(VAULT_TIMEOUT, vaultTimeout, userId);
}
return vaultTimeout;
}),
catchError((error: unknown) => {
// Protect outer observable from canceling on error by catching and returning EMPTY

View File

@@ -1,4 +1,4 @@
import { EncString } from "../../../key-management/crypto/models/enc-string";
import { EncryptedString, EncString } from "../../../key-management/crypto/models/enc-string";
import { EncryptionType } from "../../enums";
import { Utils } from "../../misc/utils";
@@ -27,7 +27,9 @@ describe("Encrypted private key", () => {
it("should deserialize encrypted private key", () => {
const encryptedPrivateKey = makeEncString().encryptedString;
const result = sut.deserializer(JSON.parse(JSON.stringify(encryptedPrivateKey)));
const result = sut.deserializer(
JSON.parse(JSON.stringify(encryptedPrivateKey as unknown)) as unknown as EncryptedString,
);
expect(result).toEqual(encryptedPrivateKey);
});

View File

@@ -11,8 +11,6 @@ import {
UserDecryptionOptions,
UserDecryptionOptionsServiceAbstraction,
} from "@bitwarden/auth/common";
import { EncString } from "@bitwarden/common/key-management/crypto/models/enc-string";
import { SecurityStateService } from "@bitwarden/common/key-management/security-state/abstractions/security-state.service";
// 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 { KdfConfigService, KeyService, PBKDF2KdfConfig } from "@bitwarden/key-management";
@@ -29,6 +27,8 @@ import { TokenService } from "../../auth/abstractions/token.service";
import { AuthenticationStatus } from "../../auth/enums/authentication-status";
import { DomainSettingsService } from "../../autofill/services/domain-settings.service";
import { BillingAccountProfileStateService } from "../../billing/abstractions";
import { AccountCryptographicStateService } from "../../key-management/account-cryptography/account-cryptographic-state.service";
import { EncString } from "../../key-management/crypto/models/enc-string";
import { KeyConnectorService } from "../../key-management/key-connector/abstractions/key-connector.service";
import { InternalMasterPasswordServiceAbstraction } from "../../key-management/master-password/abstractions/master-password.service.abstraction";
import {
@@ -36,6 +36,7 @@ import {
MasterPasswordSalt,
MasterPasswordUnlockData,
} from "../../key-management/master-password/types/master-password.types";
import { SecurityStateService } from "../../key-management/security-state/abstractions/security-state.service";
import { SendApiService } from "../../tools/send/services/send-api.service.abstraction";
import { InternalSendService } from "../../tools/send/services/send.service.abstraction";
import { UserId } from "../../types/guid";
@@ -76,6 +77,7 @@ describe("DefaultSyncService", () => {
let stateProvider: MockProxy<StateProvider>;
let securityStateService: MockProxy<SecurityStateService>;
let kdfConfigService: MockProxy<KdfConfigService>;
let accountCryptographicStateService: MockProxy<AccountCryptographicStateService>;
let sut: DefaultSyncService;
@@ -107,6 +109,7 @@ describe("DefaultSyncService", () => {
stateProvider = mock();
securityStateService = mock();
kdfConfigService = mock();
accountCryptographicStateService = mock();
sut = new DefaultSyncService(
masterPasswordAbstraction,
@@ -135,6 +138,7 @@ describe("DefaultSyncService", () => {
stateProvider,
securityStateService,
kdfConfigService,
accountCryptographicStateService,
);
});

View File

@@ -9,8 +9,9 @@ import {
CollectionDetailsResponse,
CollectionService,
} from "@bitwarden/admin-console/common";
// This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop.
import { AccountCryptographicStateService } from "@bitwarden/common/key-management/account-cryptography/account-cryptographic-state.service";
import { SecurityStateService } from "@bitwarden/common/key-management/security-state/abstractions/security-state.service";
// 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 { KdfConfigService, KeyService } from "@bitwarden/key-management";
@@ -101,6 +102,7 @@ export class DefaultSyncService extends CoreSyncService {
stateProvider: StateProvider,
private securityStateService: SecurityStateService,
private kdfConfigService: KdfConfigService,
private accountCryptographicStateService: AccountCryptographicStateService,
) {
super(
tokenService,
@@ -239,12 +241,18 @@ export class DefaultSyncService extends CoreSyncService {
// Cleanup: Only the first branch should be kept after the server always returns accountKeys https://bitwarden.atlassian.net/browse/PM-21768
if (response.accountKeys != null) {
await this.accountCryptographicStateService.setAccountCryptographicState(
response.accountKeys.toWrappedAccountCryptographicState(),
response.id,
);
// V1 and V2 users
await this.keyService.setPrivateKey(
response.accountKeys.publicKeyEncryptionKeyPair.wrappedPrivateKey,
response.id,
);
if (response.accountKeys.signatureKeyPair !== null) {
// User is V2 user
// V2 users only
if (response.accountKeys.isV2Encryption()) {
await this.keyService.setUserSigningKey(
response.accountKeys.signatureKeyPair.wrappedSigningKey,
response.id,

View File

@@ -1,3 +1,5 @@
# Tools Vendor Integration
This module defines interfaces and helpers for creating vendor integration sites.
## RPC

View File

@@ -5,7 +5,11 @@
'tw-min-h-full': clientType === 'browser' || clientType === 'desktop',
}"
>
<div class="tw-flex tw-justify-between tw-items-center tw-w-full tw-mb-12">
<div
[class]="
'tw-flex tw-justify-between tw-items-center tw-w-full' + (!hideLogo() ? ' tw-mb-12' : '')
"
>
@if (!hideLogo()) {
<a
[routerLink]="['/']"

View File

@@ -1,5 +1,5 @@
import { NgClass } from "@angular/common";
import { Component, computed, input } from "@angular/core";
import { ChangeDetectionStrategy, Component, computed, input } from "@angular/core";
import { Utils } from "@bitwarden/common/platform/misc/utils";
@@ -14,13 +14,11 @@ const SizeClasses: Record<SizeTypes, string[]> = {
};
/**
* Avatars display a unique color that helps a user visually recognize their logged in account.
* A variance in color across the avatar component is important as it is used in Account Switching as a
* visual indicator to recognize which of a personal or work account a user is logged into.
*/
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
* Avatars display a unique color that helps a user visually recognize their logged in account.
*
* A variance in color across the avatar component is important as it is used in Account Switching as a
* visual indicator to recognize which of a personal or work account a user is logged into.
*/
@Component({
selector: "bit-avatar",
template: `
@@ -49,13 +47,38 @@ const SizeClasses: Record<SizeTypes, string[]> = {
</span>
`,
imports: [NgClass],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class AvatarComponent {
/**
* Whether to display a border around the avatar.
*/
readonly border = input(false);
/**
* Custom background color for the avatar. If not provided, a color will be generated based on the id or text.
*/
readonly color = input<string>();
/**
* Unique identifier used to generate a consistent background color. Takes precedence over text for color generation.
*/
readonly id = input<string>();
/**
* Text to display in the avatar. The first letters of words (up to 2 characters) will be shown.
* Also used to generate background color if id is not provided.
*/
readonly text = input<string>();
/**
* Title attribute for the avatar. If not provided, falls back to the text value.
*/
readonly title = input<string>();
/**
* Size of the avatar.
*/
readonly size = input<SizeTypes>("default");
protected readonly svgCharCount = 2;

View File

@@ -1,6 +1,6 @@
<ng-template>
@if (icon(); as icon) {
<i class="bwi {{ icon }} !tw-me-2" aria-hidden="true"></i>
<i class="bwi !tw-me-2" [class]="icon" aria-hidden="true"></i>
}
<ng-content></ng-content>
</ng-template>

View File

@@ -1,29 +1,55 @@
import { Component, EventEmitter, Output, TemplateRef, input, viewChild } from "@angular/core";
import {
ChangeDetectionStrategy,
Component,
TemplateRef,
input,
output,
viewChild,
} from "@angular/core";
import { QueryParamsHandling } from "@angular/router";
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
/**
* Individual breadcrumb item used within the `bit-breadcrumbs` component.
* Represents a single navigation step in the breadcrumb trail.
*
* This component should be used as a child of `bit-breadcrumbs` and supports both
* router navigation and custom click handlers.
*/
@Component({
selector: "bit-breadcrumb",
templateUrl: "./breadcrumb.component.html",
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class BreadcrumbComponent {
/**
* Optional icon to display before the breadcrumb text.
*/
readonly icon = input<string>();
/**
* Router link for the breadcrumb. Can be a string or an array of route segments.
*/
readonly route = input<string | any[]>();
/**
* Query parameters to include in the router link.
*/
readonly queryParams = input<Record<string, string>>({});
/**
* How to handle query parameters when navigating. Options include 'merge' or 'preserve'.
*/
readonly queryParamsHandling = input<QueryParamsHandling>();
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
// eslint-disable-next-line @angular-eslint/prefer-output-emitter-ref
@Output()
click = new EventEmitter();
/**
* Emitted when the breadcrumb is clicked.
*/
readonly click = output<unknown>();
/** Used by the BreadcrumbsComponent to access the breadcrumb content */
readonly content = viewChild(TemplateRef);
onClick(args: unknown) {
this.click.next(args);
this.click.emit(args);
}
}

View File

@@ -1,4 +1,4 @@
@for (breadcrumb of beforeOverflow; track breadcrumb; let last = $last) {
@for (breadcrumb of beforeOverflow(); track breadcrumb; let last = $last) {
@if (breadcrumb.route(); as route) {
<a
bitLink
@@ -26,8 +26,8 @@
}
}
@if (hasOverflow) {
@if (beforeOverflow.length > 0) {
@if (hasOverflow()) {
@if (beforeOverflow().length > 0) {
<i class="bwi bwi-angle-right tw-mx-1.5 tw-text-main"></i>
}
<button
@@ -38,7 +38,7 @@
[label]="'moreBreadcrumbs' | i18n"
></button>
<bit-menu #overflowMenu>
@for (breadcrumb of overflow; track breadcrumb) {
@for (breadcrumb of overflow(); track breadcrumb) {
@if (breadcrumb.route(); as route) {
<a
bitMenuItem
@@ -57,7 +57,7 @@
}
</bit-menu>
<i class="bwi bwi-angle-right tw-mx-1.5 tw-text-main"></i>
@for (breadcrumb of afterOverflow; track breadcrumb; let last = $last) {
@for (breadcrumb of afterOverflow(); track breadcrumb; let last = $last) {
@if (breadcrumb.route(); as route) {
<a
bitLink

View File

@@ -1,5 +1,11 @@
import { CommonModule } from "@angular/common";
import { Component, ContentChildren, QueryList, input } from "@angular/core";
import {
ChangeDetectionStrategy,
Component,
computed,
contentChildren,
input,
} from "@angular/core";
import { RouterModule } from "@angular/router";
import { I18nPipe } from "@bitwarden/ui-common";
@@ -15,42 +21,39 @@ import { BreadcrumbComponent } from "./breadcrumb.component";
* Bitwarden uses this component to indicate the user's current location in a set of data organized in
* containers (Collections, Folders, or Projects).
*/
// 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: "bit-breadcrumbs",
templateUrl: "./breadcrumbs.component.html",
imports: [I18nPipe, CommonModule, LinkModule, RouterModule, IconButtonModule, MenuModule],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class BreadcrumbsComponent {
/**
* The maximum number of breadcrumbs to show before overflow.
*/
readonly show = input(3);
private breadcrumbs: BreadcrumbComponent[] = [];
protected readonly breadcrumbs = contentChildren(BreadcrumbComponent);
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
// eslint-disable-next-line @angular-eslint/prefer-signals
@ContentChildren(BreadcrumbComponent)
protected set breadcrumbList(value: QueryList<BreadcrumbComponent>) {
this.breadcrumbs = value.toArray();
}
/** Whether the breadcrumbs exceed the show limit and require an overflow menu */
protected readonly hasOverflow = computed(() => this.breadcrumbs().length > this.show());
protected get beforeOverflow() {
if (this.hasOverflow) {
return this.breadcrumbs.slice(0, this.show() - 1);
/** Breadcrumbs shown before the overflow menu */
protected readonly beforeOverflow = computed(() => {
const items = this.breadcrumbs();
const showCount = this.show();
if (items.length > showCount) {
return items.slice(0, showCount - 1);
}
return items;
});
return this.breadcrumbs;
}
/** Breadcrumbs hidden in the overflow menu */
protected readonly overflow = computed(() => {
return this.breadcrumbs().slice(this.show() - 1, -1);
});
protected get overflow() {
return this.breadcrumbs.slice(this.show() - 1, -1);
}
protected get afterOverflow() {
return this.breadcrumbs.slice(-1);
}
protected get hasOverflow() {
return this.breadcrumbs.length > this.show();
}
/** The last breadcrumb, shown after the overflow menu */
protected readonly afterOverflow = computed(() => this.breadcrumbs().slice(-1));
}

View File

@@ -1,4 +1,4 @@
import { Component, importProvidersFrom } from "@angular/core";
import { ChangeDetectionStrategy, Component, importProvidersFrom } from "@angular/core";
import { RouterModule } from "@angular/router";
import { Meta, StoryObj, applicationConfig, moduleMetadata } from "@storybook/angular";
@@ -18,10 +18,9 @@ interface Breadcrumb {
route: string;
}
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
@Component({
template: "",
changeDetection: ChangeDetectionStrategy.OnPush,
})
class EmptyComponent {}

View File

@@ -81,10 +81,10 @@ export const Small: Story = {
props: args,
template: /*html*/ `
<div class="tw-flex tw-gap-4 tw-mb-6 tw-items-center">
<button type="button" bitButton [disabled]="disabled" [loading]="loading" [buttonType]="'primary'" [size]="size" [block]="block">Primary small</button>
<button type="button" bitButton [disabled]="disabled" [loading]="loading" [buttonType]="'secondary'" [size]="size" [block]="block">Secondary small</button>
<button type="button" bitButton [disabled]="disabled" [loading]="loading" [buttonType]="'danger'" [size]="size" [block]="block">Danger small</button>
<button type="button" bitButton [disabled]="disabled" [loading]="loading" [buttonType]="'dangerPrimary'" [size]="size" [block]="block">Danger Primary small</button>
<button type="button" bitButton [disabled]="disabled" [loading]="loading" buttonType="primary" [size]="size" [block]="block">Primary small</button>
<button type="button" bitButton [disabled]="disabled" [loading]="loading" buttonType="secondary" [size]="size" [block]="block">Secondary small</button>
<button type="button" bitButton [disabled]="disabled" [loading]="loading" buttonType="danger" [size]="size" [block]="block">Danger small</button>
<button type="button" bitButton [disabled]="disabled" [loading]="loading" buttonType="dangerPrimary" [size]="size" [block]="block">Danger Primary small</button>
</div>
`,
}),

View File

@@ -64,7 +64,7 @@ export const Default: Story = {
type="button"
bitSuffix
bitIconButton="bwi-clone"
[label]="'Copy'"
label="Copy"
[appCopyClick]="value"
></button>
</bit-form-field>
@@ -86,7 +86,7 @@ export const WithDefaultToast: Story = {
type="button"
bitSuffix
bitIconButton="bwi-clone"
[label]="'Copy'"
label="Copy"
[appCopyClick]="value"
showToast
></button>
@@ -109,7 +109,7 @@ export const WithCustomToastVariant: Story = {
type="button"
bitSuffix
bitIconButton="bwi-clone"
[label]="'Copy'"
label="Copy"
[appCopyClick]="value"
showToast="info"
></button>
@@ -132,7 +132,7 @@ export const WithCustomValueLabel: Story = {
type="button"
bitSuffix
bitIconButton="bwi-clone"
[label]="'Copy'"
label="Copy"
[appCopyClick]="value"
showToast
valueLabel="API Key"

View File

@@ -300,6 +300,11 @@ export class DialogService {
return this.dialog.closeAll();
}
/** Close the open drawer */
closeDrawer(): void {
return this.activeDrawer?.close();
}
/** The injector that is passed to the opened dialog */
private createInjector(opts: { data: unknown; dialogRef: DialogRef }): Injector {
return Injector.create({

View File

@@ -34,16 +34,21 @@
}
<ng-content select="[bitDialogTitle]"></ng-content>
</h2>
@if (!this.dialogRef?.disableClose) {
<button
type="button"
bitIconButton="bwi-close"
buttonType="main"
size="default"
bitDialogClose
[label]="'close' | i18n"
></button>
}
<div class="tw-flex tw-items-center tw-justify-center">
<ng-content select="[bitDialogHeaderEnd]"></ng-content>
@if (!this.dialogRef?.disableClose) {
<button
type="button"
bitIconButton="bwi-close"
buttonType="main"
size="default"
bitDialogClose
[label]="'close' | i18n"
></button>
}
</div>
</header>
<div

View File

@@ -94,6 +94,7 @@ export const Default: Story = {
<ng-container bitDialogTitle>
<span bitBadge variant="success">Foobar</span>
</ng-container>
<ng-container bitDialogContent>Dialog body text goes here.</ng-container>
<ng-container bitDialogFooter>
<button type="button" bitButton buttonType="primary" [disabled]="loading">Save</button>
@@ -292,3 +293,42 @@ export const WithCards: Story = {
disableAnimations: true,
},
};
export const HeaderEnd: Story = {
render: (args) => ({
props: args,
template: /*html*/ `
<bit-dialog
[dialogSize]="dialogSize"
[title]="title"
[subtitle]="subtitle"
[loading]="loading"
[disablePadding]="disablePadding"
[disableAnimations]="disableAnimations">
<ng-container bitDialogHeaderEnd>
<span bitBadge>Archived</span>
</ng-container>
<ng-container bitDialogContent>Dialog body text goes here.</ng-container>
<ng-container bitDialogFooter>
<button type="button" bitButton buttonType="primary" [disabled]="loading">Save</button>
<button type="button" bitButton buttonType="secondary" [disabled]="loading">Cancel</button>
<button
type="button"
[disabled]="loading"
class="tw-ms-auto"
bitIconButton="bwi-trash"
buttonType="danger"
size="default"
label="Delete"></button>
</ng-container>
</bit-dialog>
`,
}),
args: {
dialogSize: "small",
title: "Very Long Title That Should Be Truncated After Two Lines To Test Header End Slot",
subtitle: "Subtitle",
},
};

View File

@@ -241,7 +241,7 @@ export const Readonly: Story = {
<bit-label>Input</bit-label>
<input bitInput type="password" value="Foobar" [readonly]="true" />
<button type="button" label="Toggle password" bitIconButton bitSuffix bitPasswordInputToggle></button>
<button type="button" bitSuffix bitIconButton="bwi-clone" [label]="'Clone Input'"></button>
<button type="button" bitSuffix bitIconButton="bwi-clone" label="Clone Input"></button>
</bit-form-field>
<bit-form-field>
@@ -262,7 +262,7 @@ export const Readonly: Story = {
<bit-label>Input</bit-label>
<input bitInput type="password" value="Foobar" readonly />
<button type="button" bitIconButton bitSuffix bitPasswordInputToggle></button>
<button type="button" bitSuffix bitIconButton="bwi-clone" [label]="'Clone Input'"></button>
<button type="button" bitSuffix bitIconButton="bwi-clone" label="Clone Input"></button>
</bit-form-field>
<bit-form-field>
@@ -310,11 +310,11 @@ export const ButtonInputGroup: Story = {
<i class="bwi bwi-question-circle" aria-hidden="true"></i>
</a>
</bit-label>
<button type="button" bitPrefix bitIconButton="bwi-star" [label]="'Favorite Label'"></button>
<button type="button" bitPrefix bitIconButton="bwi-star" label="Favorite Label"></button>
<input bitInput placeholder="Placeholder" />
<button type="button" bitSuffix bitIconButton="bwi-eye" [label]="'Hide Label'"></button>
<button type="button" bitSuffix bitIconButton="bwi-clone" [label]="'Clone Label'"></button>
<button type="button" bitSuffix bitIconButton="bwi-ellipsis-v" [label]="'Menu Label'"></button>
<button type="button" bitSuffix bitIconButton="bwi-eye" label="Hide Label"></button>
<button type="button" bitSuffix bitIconButton="bwi-clone" label="Clone Label"></button>
<button type="button" bitSuffix bitIconButton="bwi-ellipsis-v" label="Menu Label"></button>
</bit-form-field>
`,
}),
@@ -327,12 +327,11 @@ export const DisabledButtonInputGroup: Story = {
template: /*html*/ `
<bit-form-field>
<bit-label>Label</bit-label>
<button type="button" bitPrefix bitIconButton="bwi-star" disabled [label]="'Favorite Label'"></button>
<button type="button" bitPrefix bitIconButton="bwi-star" disabled label="Favorite Label"></button>
<input bitInput placeholder="Placeholder" disabled />
<button type="button" bitSuffix bitIconButton="bwi-eye" disabled [label]="'Hide Label'"></button>
<button type="button" bitSuffix bitIconButton="bwi-clone" disabled [label]="'Clone Label'"></button>
<button type="button" bitSuffix bitIconButton="bwi-ellipsis-v" disabled [label]="'Menu Label'"></button>
<button type="button" bitSuffix bitIconButton="bwi-eye" disabled label="Hide Label"></button>
<button type="button" bitSuffix bitIconButton="bwi-clone" disabled label="Clone Label"></button>
<button type="button" bitSuffix bitIconButton="bwi-ellipsis-v" disabled label="Menu Label"></button>
</bit-form-field>
`,
}),
@@ -346,9 +345,9 @@ export const PartiallyDisabledButtonInputGroup: Story = {
<bit-form-field>
<bit-label>Label</bit-label>
<input bitInput placeholder="Placeholder" disabled />
<button type="button" bitSuffix bitIconButton="bwi-eye" [label]="'Hide Label'"></button>
<button type="button" bitSuffix bitIconButton="bwi-clone" [label]="'Clone Label'"></button>
<button type="button" bitSuffix bitIconButton="bwi-ellipsis-v" disabled [label]="'Menu Label'"></button>
<button type="button" bitSuffix bitIconButton="bwi-eye" label="Hide Label"></button>
<button type="button" bitSuffix bitIconButton="bwi-clone" label="Clone Label"></button>
<button type="button" bitSuffix bitIconButton="bwi-ellipsis-v" disabled label="Menu Label"></button>
</bit-form-field>
`,
}),

View File

@@ -97,7 +97,7 @@ export const ContentSlots: Story = {
<button bit-item-content type="button">
<bit-avatar
slot="start"
[text]="'Foo'"
text="Foo"
></bit-avatar>
foo&#64;bitwarden.com
<ng-container slot="secondary">

View File

@@ -23,7 +23,7 @@
'tw-transform tw-rotate-[90deg]': variantValue === 'tree' && !open(),
}"
[bitIconButton]="toggleButtonIcon()"
[buttonType]="'nav-contrast'"
buttonType="nav-contrast"
(click)="toggle($event)"
size="small"
[attr.aria-expanded]="open().toString()"

View File

@@ -144,8 +144,8 @@ export const Tree: StoryObj<NavGroupComponent> = {
template: /*html*/ `
<bit-side-nav>
<bit-nav-group text="Tree example" icon="bwi-collection-shared" [open]="true">
<bit-nav-item text="Level 1 - no children" route="t2" icon="bwi-collection-shared" [variant]="'tree'"></bit-nav-item>
<bit-nav-group text="Level 1 - with children" route="t3" icon="bwi-collection-shared" [variant]="'tree'" [open]="true">
<bit-nav-item text="Level 1 - no children" route="t2" icon="bwi-collection-shared" variant="tree"></bit-nav-item>
<bit-nav-group text="Level 1 - with children" route="t3" icon="bwi-collection-shared" variant="tree" [open]="true">
<bit-nav-group text="Level 2 - with children" route="t4" icon="bwi-collection-shared" variant="tree" [open]="true">
<bit-nav-item text="Level 3 - no children, no icon" route="t5" variant="tree"></bit-nav-item>
<bit-nav-group text="Level 3 - with children" route="t6" icon="bwi-collection-shared" variant="tree" [open]="true">

View File

@@ -90,20 +90,20 @@ export const WithChildButtons: Story = {
template: /*html*/ `
<bit-nav-item text="Hello World Very Cool World" [route]="['']" icon="bwi-collection-shared">
<button
type="button"
type="button"
slot="end"
class="tw-ms-auto"
[bitIconButton]="'bwi-pencil-square'"
[buttonType]="'nav-contrast'"
bitIconButton="bwi-pencil-square"
buttonType="nav-contrast"
size="small"
label="Edit"
></button>
<button
type="button"
type="button"
slot="end"
class="tw-ms-auto"
[bitIconButton]="'bwi-check'"
[buttonType]="'nav-contrast'"
bitIconButton="bwi-check"
buttonType="nav-contrast"
size="small"
label="Confirm"
></button>

View File

@@ -17,7 +17,7 @@
[attr.aria-label]="label()"
[title]="label()"
routerLinkActive
[ariaCurrentWhenActive]="'page'"
ariaCurrentWhenActive="page"
>
<bit-icon [icon]="sideNavService.open ? openIcon() : closedIcon()"></bit-icon>
</a>

View File

@@ -81,7 +81,7 @@ You can manually set the initial position of the Popover by binding a `[position
Popover's trigger element, such as:
```html
<button [bitPopoverTriggerFor]="myPopover" [position]="'above-end'">Open Popover</button>
<button [bitPopoverTriggerFor]="myPopover" position="above-end">Open Popover</button>
```
<Canvas of={stories.AboveEnd} />

View File

@@ -99,7 +99,7 @@ import { KitchenSinkSharedModule } from "../kitchen-sink-shared.module";
<bit-color-password
class="tw-text-base"
[password]="'Wq$Jk😀7j DX#rS5Sdi!z'"
password="Wq$Jk😀7j DX#rS5Sdi!z"
[showCount]="true"
></bit-color-password>
</div>
@@ -123,7 +123,7 @@ import { KitchenSinkSharedModule } from "../kitchen-sink-shared.module";
<button bitButton bitFormButton buttonType="primary" type="submit">Submit</button>
<bit-error-summary [formGroup]="formObj"></bit-error-summary>
<bit-popover [title]="'Password help'" #myPopover>
<bit-popover title="Password help" #myPopover>
<div>A strong password has the following:</div>
<ul class="tw-mt-2 tw-mb-0 tw-ps-4">
<li>Letters</li>

View File

@@ -172,7 +172,7 @@ class KitchenSinkDialogComponent {
</bit-tab>
</bit-tab-group>
<bit-popover [title]="'Educational Popover'" #myPopover>
<bit-popover title="Educational Popover" #myPopover>
<div>You can learn more things at:</div>
<ul class="tw-mt-2 tw-mb-0 tw-ps-4">
<li>Help center</li>

View File

@@ -1,4 +1,4 @@
## DIRT Card
# DIRT Card
Package name: `@bitwarden/dirt-card`

View File

@@ -21,7 +21,7 @@
<ng-container bitDialogFooter>
<button bitButton buttonType="primary" type="submit">
<span>{{ "import" | i18n }}</span>
<span>{{ "importVerb" | i18n }}</span>
</button>
<button bitButton bitDialogClose buttonType="secondary" type="button">
<span>{{ "cancel" | i18n }}</span>

View File

@@ -69,11 +69,13 @@ describe("keyService", () => {
let accountService: FakeAccountService;
let masterPasswordService: FakeMasterPasswordService;
beforeEach(() => {
beforeEach(async () => {
accountService = mockAccountServiceWith(mockUserId);
masterPasswordService = new FakeMasterPasswordService();
stateProvider = new FakeStateProvider(accountService);
await stateProvider.setUserState(VAULT_TIMEOUT, VaultTimeoutStringType.Never, mockUserId);
keyService = new DefaultKeyService(
masterPasswordService,
keyGenerationService,

View File

@@ -691,7 +691,9 @@ export class DefaultKeyService implements KeyServiceAbstraction {
// the VaultTimeoutSettingsSvc and this service.
// This should be fixed as part of the PM-7082 - Auto Key Service work.
const vaultTimeout = await firstValueFrom(
this.stateProvider.getUserState$(VAULT_TIMEOUT, userId),
this.stateProvider
.getUserState$(VAULT_TIMEOUT, userId)
.pipe(filter((timeout) => timeout != null)),
);
shouldStoreKey = vaultTimeout == VaultTimeoutStringType.Never;

View File

@@ -78,7 +78,7 @@ After generating your library:
### Issue: TypeScript path mapping not working
**Solution**: Run `nx reset` to clear the Nx cache, then try importing from your library again.
**Solution**: Run `npx nx reset` to clear the Nx cache, then try importing from your library again.
## Extending the Generated Code

View File

@@ -0,0 +1,5 @@
# Subscription
Owned by: billing
Components and services for managing Bitwarden subscriptions.

View File

@@ -0,0 +1,3 @@
import baseConfig from "../../eslint.config.mjs";
export default [...baseConfig];

View File

@@ -0,0 +1,10 @@
module.exports = {
displayName: "subscription",
preset: "../../jest.preset.js",
testEnvironment: "node",
transform: {
"^.+\\.[tj]s$": ["ts-jest", { tsconfig: "<rootDir>/tsconfig.spec.json" }],
},
moduleFileExtensions: ["ts", "js", "html"],
coverageDirectory: "../../coverage/libs/subscription",
};

View File

@@ -0,0 +1,11 @@
{
"name": "@bitwarden/subscription",
"version": "0.0.1",
"description": "Components and services for managing Bitwarden subscriptions.",
"private": true,
"type": "commonjs",
"main": "index.js",
"types": "index.d.ts",
"license": "GPL-3.0",
"author": "billing"
}

View File

@@ -0,0 +1,34 @@
{
"name": "subscription",
"$schema": "../../node_modules/nx/schemas/project-schema.json",
"sourceRoot": "libs/subscription/src",
"projectType": "library",
"tags": [],
"targets": {
"build": {
"executor": "@nx/js:tsc",
"outputs": ["{options.outputPath}"],
"options": {
"outputPath": "dist/libs/subscription",
"main": "libs/subscription/src/index.ts",
"tsConfig": "libs/subscription/tsconfig.lib.json",
"assets": ["libs/subscription/*.md"],
"rootDir": "libs/subscription/src"
}
},
"lint": {
"executor": "@nx/eslint:lint",
"outputs": ["{options.outputFile}"],
"options": {
"lintFilePatterns": ["libs/subscription/**/*.ts"]
}
},
"test": {
"executor": "@nx/jest:jest",
"outputs": ["{workspaceRoot}/coverage/{projectRoot}"],
"options": {
"jestConfig": "libs/subscription/jest.config.js"
}
}
}
}

View File

@@ -0,0 +1 @@
export type Placeholder = unknown;

View File

@@ -0,0 +1,8 @@
import * as lib from "./index";
describe("subscription", () => {
// This test will fail until something is exported from index.ts
it("should work", () => {
expect(lib).toBeDefined();
});
});

View File

@@ -0,0 +1,6 @@
{
"extends": "../../tsconfig.base.json",
"files": [],
"include": ["src/**/*.ts", "src/**/*.js"],
"exclude": ["**/build", "**/dist"]
}

View File

@@ -0,0 +1,13 @@
{
"extends": "../../tsconfig.base.json",
"files": [],
"include": [],
"references": [
{
"path": "./tsconfig.lib.json"
},
{
"path": "./tsconfig.spec.json"
}
]
}

View File

@@ -0,0 +1,10 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "../../dist/out-tsc",
"declaration": true,
"types": ["node"]
},
"include": ["src/**/*.ts"],
"exclude": ["jest.config.js", "src/**/*.spec.ts"]
}

View File

@@ -0,0 +1,10 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "../../dist/out-tsc",
"module": "commonjs",
"moduleResolution": "node10",
"types": ["jest", "node"]
},
"include": ["jest.config.ts", "src/**/*.test.ts", "src/**/*.spec.ts", "src/**/*.d.ts"]
}

View File

@@ -174,4 +174,19 @@ export class SendAddEditDialogComponent {
},
);
}
/**
* Opens the send add/edit dialog in a drawer
* @param dialogService Instance of the DialogService.
* @param params The parameters for the drawer.
* @returns The drawer result.
*/
static openDrawer(dialogService: DialogService, params: SendItemDialogParams) {
return dialogService.openDrawer<SendItemDialogResult, SendItemDialogParams>(
SendAddEditDialogComponent,
{
data: params,
},
);
}
}

View File

@@ -1,102 +1,112 @@
<bit-table [dataSource]="dataSource()">
<ng-container header>
<tr>
<th bitCell bitSortable="name" default>{{ "name" | i18n }}</th>
<th bitCell bitSortable="deletionDate">{{ "deletionDate" | i18n }}</th>
<th bitCell>{{ "options" | i18n }}</th>
</tr>
</ng-container>
<ng-template body let-rows$>
<tr bitRow *ngFor="let s of rows$ | async">
<td bitCell (click)="onEditSend(s)" class="tw-cursor-pointer">
<div class="tw-flex tw-gap-2 tw-items-center">
<span aria-hidden="true">
@if (s.type == sendType.File) {
<i class="bwi bwi-fw bwi-lg bwi-file"></i>
}
@if (s.type == sendType.Text) {
<i class="bwi bwi-fw bwi-lg bwi-file-text"></i>
}
</span>
<button type="button" bitLink>
{{ s.name }}
</button>
@if (s.disabled) {
<i
class="bwi bwi-exclamation-triangle"
appStopProp
title="{{ 'disabled' | i18n }}"
aria-hidden="true"
></i>
<span class="tw-sr-only">{{ "disabled" | i18n }}</span>
}
@if (s.password) {
<i
class="bwi bwi-key"
appStopProp
title="{{ 'password' | i18n }}"
aria-hidden="true"
></i>
<span class="tw-sr-only">{{ "password" | i18n }}</span>
}
@if (s.maxAccessCountReached) {
<i
class="bwi bwi-exclamation-triangle"
appStopProp
title="{{ 'maxAccessCountReached' | i18n }}"
aria-hidden="true"
></i>
<span class="tw-sr-only">{{ "maxAccessCountReached" | i18n }}</span>
}
@if (s.expired) {
<i
class="bwi bwi-clock"
appStopProp
title="{{ 'expired' | i18n }}"
aria-hidden="true"
></i>
<span class="tw-sr-only">{{ "expired" | i18n }}</span>
}
@if (s.pendingDelete) {
<i
class="bwi bwi-trash"
appStopProp
title="{{ 'pendingDeletion' | i18n }}"
aria-hidden="true"
></i>
<span class="tw-sr-only">{{ "pendingDeletion" | i18n }}</span>
}
</div>
</td>
<td bitCell (click)="onEditSend(s)" class="tw-text-muted tw-cursor-pointer">
<small bitTypography="body2" appStopProp>{{ s.deletionDate | date: "medium" }}</small>
</td>
<td bitCell class="tw-w-0 tw-text-right">
<button
type="button"
[bitMenuTriggerFor]="sendOptions"
bitIconButton="bwi-ellipsis-v"
label="{{ 'options' | i18n }}"
></button>
<bit-menu #sendOptions>
<button type="button" bitMenuItem (click)="onCopy(s)">
<i class="bwi bwi-fw bwi-clone" aria-hidden="true"></i>
{{ "copySendLink" | i18n }}
</button>
@if (s.password && !disableSend()) {
<button type="button" bitMenuItem (click)="onRemovePassword(s)">
<i class="bwi bwi-fw bwi-close" aria-hidden="true"></i>
{{ "removePassword" | i18n }}
</button>
}
<button type="button" bitMenuItem (click)="onDelete(s)">
<span class="tw-text-danger">
<i class="bwi bwi-fw bwi-trash" aria-hidden="true"></i>
{{ "delete" | i18n }}
<div class="tw-@container/send-table">
<bit-table [dataSource]="dataSource()">
<ng-container header>
<tr>
<th bitCell bitSortable="name" default>{{ "name" | i18n }}</th>
<th bitCell bitSortable="deletionDate" class="@lg/send-table:tw-table-cell tw-hidden">
{{ "deletionDate" | i18n }}
</th>
<th bitCell>{{ "options" | i18n }}</th>
</tr>
</ng-container>
<ng-template body let-rows$>
<tr bitRow *ngFor="let s of rows$ | async">
<td bitCell (click)="onEditSend(s)" class="tw-cursor-pointer">
<div class="tw-flex tw-gap-2 tw-items-center">
<span aria-hidden="true">
@if (s.type == sendType.File) {
<i class="bwi bwi-fw bwi-lg bwi-file"></i>
}
@if (s.type == sendType.Text) {
<i class="bwi bwi-fw bwi-lg bwi-file-text"></i>
}
</span>
</button>
</bit-menu>
</td>
</tr>
</ng-template>
</bit-table>
<button type="button" bitLink>
{{ s.name }}
</button>
@if (s.disabled) {
<i
class="bwi bwi-exclamation-triangle"
appStopProp
title="{{ 'disabled' | i18n }}"
aria-hidden="true"
></i>
<span class="tw-sr-only">{{ "disabled" | i18n }}</span>
}
@if (s.password) {
<i
class="bwi bwi-key"
appStopProp
title="{{ 'password' | i18n }}"
aria-hidden="true"
></i>
<span class="tw-sr-only">{{ "password" | i18n }}</span>
}
@if (s.maxAccessCountReached) {
<i
class="bwi bwi-exclamation-triangle"
appStopProp
title="{{ 'maxAccessCountReached' | i18n }}"
aria-hidden="true"
></i>
<span class="tw-sr-only">{{ "maxAccessCountReached" | i18n }}</span>
}
@if (s.expired) {
<i
class="bwi bwi-clock"
appStopProp
title="{{ 'expired' | i18n }}"
aria-hidden="true"
></i>
<span class="tw-sr-only">{{ "expired" | i18n }}</span>
}
@if (s.pendingDelete) {
<i
class="bwi bwi-trash"
appStopProp
title="{{ 'pendingDeletion' | i18n }}"
aria-hidden="true"
></i>
<span class="tw-sr-only">{{ "pendingDeletion" | i18n }}</span>
}
</div>
</td>
<td
bitCell
(click)="onEditSend(s)"
class="tw-text-muted tw-cursor-pointer @lg/send-table:tw-table-cell tw-hidden"
>
<small bitTypography="body2" appStopProp>
{{ s.deletionDate | date: "medium" }}
</small>
</td>
<td bitCell class="tw-w-0 tw-text-right">
<button
type="button"
[bitMenuTriggerFor]="sendOptions"
bitIconButton="bwi-ellipsis-v"
label="{{ 'options' | i18n }}"
></button>
<bit-menu #sendOptions>
<button type="button" bitMenuItem (click)="onCopy(s)">
<i class="bwi bwi-fw bwi-clone" aria-hidden="true"></i>
{{ "copySendLink" | i18n }}
</button>
@if (s.password && !disableSend()) {
<button type="button" bitMenuItem (click)="onRemovePassword(s)">
<i class="bwi bwi-fw bwi-close" aria-hidden="true"></i>
{{ "removePassword" | i18n }}
</button>
}
<button type="button" bitMenuItem (click)="onDelete(s)">
<span class="tw-text-danger">
<i class="bwi bwi-fw bwi-trash" aria-hidden="true"></i>
{{ "delete" | i18n }}
</span>
</button>
</bit-menu>
</td>
</tr>
</ng-template>
</bit-table>
</div>

View File

@@ -31,6 +31,11 @@ export type UserMigrationInfo =
};
export abstract class VaultItemsTransferService {
/**
* Indicates whether a vault items transfer is currently in progress.
*/
abstract transferInProgress$: Observable<boolean>;
/**
* Gets information about whether the given user requires migration of their vault items
* from My Vault to a My Items collection, and whether they are capable of performing that migration.

View File

@@ -11,7 +11,7 @@
<p bitTypography="body1">
{{ "leaveConfirmationDialogContentOne" | i18n }}
</p>
<p bitTypography="body1">
<p bitTypography="body1" class="tw-mb-0">
{{ "leaveConfirmationDialogContentTwo" | i18n }}
</p>
</ng-container>
@@ -25,7 +25,7 @@
{{ "goBack" | i18n }}
</button>
<a bitLink href="#" (click)="openLearnMore($event)" class="tw-w-full tw-text-center">
<a bitLink href="#" (click)="openLearnMore($event)" class="tw-w-full tw-text-center tw-text-sm">
{{ "howToManageMyVault" | i18n }}
<i class="bwi bwi-external-link tw-ml-1" aria-hidden="true"></i>
</a>

View File

@@ -12,6 +12,7 @@ import {
DialogModule,
LinkModule,
TypographyModule,
CenterPositionStrategy,
} from "@bitwarden/components";
export interface LeaveConfirmationDialogParams {
@@ -58,6 +59,8 @@ export class LeaveConfirmationDialogComponent {
static open(dialogService: DialogService, config: DialogConfig<LeaveConfirmationDialogParams>) {
return dialogService.open<LeaveConfirmationDialogResultType>(LeaveConfirmationDialogComponent, {
positionStrategy: new CenterPositionStrategy(),
disableClose: true,
...config,
});
}

View File

@@ -14,7 +14,7 @@
{{ "declineAndLeave" | i18n }}
</button>
<a bitLink href="#" (click)="openLearnMore($event)" class="tw-w-full tw-text-center">
<a bitLink href="#" (click)="openLearnMore($event)" class="tw-w-full tw-text-center tw-text-sm">
{{ "whyAmISeeingThis" | i18n }}
<i class="bwi bwi-external-link tw-ml-1" aria-hidden="true"></i>
</a>

View File

@@ -12,6 +12,7 @@ import {
DialogModule,
LinkModule,
TypographyModule,
CenterPositionStrategy,
} from "@bitwarden/components";
export interface TransferItemsDialogParams {
@@ -58,6 +59,8 @@ export class TransferItemsDialogComponent {
static open(dialogService: DialogService, config: DialogConfig<TransferItemsDialogParams>) {
return dialogService.open<TransferItemsDialogResultType>(TransferItemsDialogComponent, {
positionStrategy: new CenterPositionStrategy(),
disableClose: true,
...config,
});
}

View File

@@ -1,27 +1,35 @@
import { mock, MockProxy } from "jest-mock-extended";
import { firstValueFrom, of } from "rxjs";
import { firstValueFrom, of, Subject } from "rxjs";
// eslint-disable-next-line no-restricted-imports
import { CollectionService, CollectionView } from "@bitwarden/admin-console/common";
import { EventCollectionService } from "@bitwarden/common/abstractions/event/event-collection.service";
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
import { PolicyType } from "@bitwarden/common/admin-console/enums";
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
import { Policy } from "@bitwarden/common/admin-console/models/domain/policy";
import { EventType } from "@bitwarden/common/enums";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { OrganizationId, CollectionId } from "@bitwarden/common/types/guid";
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
import { DialogService, ToastService } from "@bitwarden/components";
import { DialogRef, DialogService, ToastService } from "@bitwarden/components";
import { LogService } from "@bitwarden/logging";
import { UserId } from "@bitwarden/user-core";
import {
LeaveConfirmationDialogResult,
TransferItemsDialogResult,
} from "../components/vault-items-transfer";
import { DefaultVaultItemsTransferService } from "./default-vault-items-transfer.service";
describe("DefaultVaultItemsTransferService", () => {
let service: DefaultVaultItemsTransferService;
let transferInProgressValues: boolean[];
let mockCipherService: MockProxy<CipherService>;
let mockPolicyService: MockProxy<PolicyService>;
@@ -31,12 +39,32 @@ describe("DefaultVaultItemsTransferService", () => {
let mockI18nService: MockProxy<I18nService>;
let mockDialogService: MockProxy<DialogService>;
let mockToastService: MockProxy<ToastService>;
let mockEventCollectionService: MockProxy<EventCollectionService>;
let mockConfigService: MockProxy<ConfigService>;
const userId = "user-id" as UserId;
const organizationId = "org-id" as OrganizationId;
const collectionId = "collection-id" as CollectionId;
/**
* Creates a mock DialogRef that emits the provided result when closed
*/
function createMockDialogRef<T>(result: T): DialogRef<T> {
const closed$ = new Subject<T>();
const dialogRef = {
closed: closed$.asObservable(),
close: jest.fn(),
} as unknown as DialogRef<T>;
// Emit the result asynchronously to simulate dialog closing
setTimeout(() => {
closed$.next(result);
closed$.complete();
}, 0);
return dialogRef;
}
beforeEach(() => {
mockCipherService = mock<CipherService>();
mockPolicyService = mock<PolicyService>();
@@ -46,9 +74,11 @@ describe("DefaultVaultItemsTransferService", () => {
mockI18nService = mock<I18nService>();
mockDialogService = mock<DialogService>();
mockToastService = mock<ToastService>();
mockEventCollectionService = mock<EventCollectionService>();
mockConfigService = mock<ConfigService>();
mockI18nService.t.mockImplementation((key) => key);
transferInProgressValues = [];
service = new DefaultVaultItemsTransferService(
mockCipherService,
@@ -59,6 +89,7 @@ describe("DefaultVaultItemsTransferService", () => {
mockI18nService,
mockDialogService,
mockToastService,
mockEventCollectionService,
mockConfigService,
);
});
@@ -69,12 +100,12 @@ describe("DefaultVaultItemsTransferService", () => {
policies?: Policy[];
organizations?: Organization[];
ciphers?: CipherView[];
collections?: CollectionView[];
defaultCollection?: CollectionView;
}): void {
mockPolicyService.policiesByType$.mockReturnValue(of(options.policies ?? []));
mockOrganizationService.organizations$.mockReturnValue(of(options.organizations ?? []));
mockCipherService.cipherViews$.mockReturnValue(of(options.ciphers ?? []));
mockCollectionService.decryptedCollections$.mockReturnValue(of(options.collections ?? []));
mockCollectionService.defaultUserCollection$.mockReturnValue(of(options.defaultCollection));
}
it("calls policiesByType$ with correct PolicyType", async () => {
@@ -151,39 +182,12 @@ describe("DefaultVaultItemsTransferService", () => {
});
it("includes defaultCollectionId when a default collection exists", async () => {
mockCollectionService.decryptedCollections$.mockReturnValue(
of([
{
id: collectionId,
organizationId: organizationId,
isDefaultCollection: true,
} as CollectionView,
]),
);
const result = await firstValueFrom(service.userMigrationInfo$(userId));
expect(result).toEqual({
requiresMigration: true,
enforcingOrganization: organization,
defaultCollectionId: collectionId,
});
});
it("returns default collection only for the enforcing organization", async () => {
mockCollectionService.decryptedCollections$.mockReturnValue(
of([
{
id: "wrong-collection-id" as CollectionId,
organizationId: "wrong-org-id" as OrganizationId,
isDefaultCollection: true,
} as CollectionView,
{
id: collectionId,
organizationId: organizationId,
isDefaultCollection: true,
} as CollectionView,
]),
mockCollectionService.defaultUserCollection$.mockReturnValue(
of({
id: collectionId,
organizationId: organizationId,
isDefaultCollection: true,
} as CollectionView),
);
const result = await firstValueFrom(service.userMigrationInfo$(userId));
@@ -542,13 +546,13 @@ describe("DefaultVaultItemsTransferService", () => {
policies?: Policy[];
organizations?: Organization[];
ciphers?: CipherView[];
collections?: CollectionView[];
defaultCollection?: CollectionView;
}): void {
mockConfigService.getFeatureFlag.mockResolvedValue(options.featureEnabled ?? true);
mockPolicyService.policiesByType$.mockReturnValue(of(options.policies ?? []));
mockOrganizationService.organizations$.mockReturnValue(of(options.organizations ?? []));
mockCipherService.cipherViews$.mockReturnValue(of(options.ciphers ?? []));
mockCollectionService.decryptedCollections$.mockReturnValue(of(options.collections ?? []));
mockCollectionService.defaultUserCollection$.mockReturnValue(of(options.defaultCollection));
}
it("does nothing when feature flag is disabled", async () => {
@@ -557,13 +561,11 @@ describe("DefaultVaultItemsTransferService", () => {
policies: [policy],
organizations: [organization],
ciphers: [{ id: "cipher-1" } as CipherView],
collections: [
{
id: collectionId,
organizationId: organizationId,
isDefaultCollection: true,
} as CollectionView,
],
defaultCollection: {
id: collectionId,
organizationId: organizationId,
isDefaultCollection: true,
} as CollectionView,
});
await service.enforceOrganizationDataOwnership(userId);
@@ -571,7 +573,7 @@ describe("DefaultVaultItemsTransferService", () => {
expect(mockConfigService.getFeatureFlag).toHaveBeenCalledWith(
FeatureFlag.MigrateMyVaultToMyItems,
);
expect(mockDialogService.openSimpleDialog).not.toHaveBeenCalled();
expect(mockDialogService.open).not.toHaveBeenCalled();
expect(mockCipherService.shareManyWithServer).not.toHaveBeenCalled();
});
@@ -580,7 +582,7 @@ describe("DefaultVaultItemsTransferService", () => {
await service.enforceOrganizationDataOwnership(userId);
expect(mockDialogService.openSimpleDialog).not.toHaveBeenCalled();
expect(mockDialogService.open).not.toHaveBeenCalled();
expect(mockCipherService.shareManyWithServer).not.toHaveBeenCalled();
});
@@ -593,7 +595,7 @@ describe("DefaultVaultItemsTransferService", () => {
await service.enforceOrganizationDataOwnership(userId);
expect(mockDialogService.openSimpleDialog).not.toHaveBeenCalled();
expect(mockDialogService.open).not.toHaveBeenCalled();
expect(mockCipherService.shareManyWithServer).not.toHaveBeenCalled();
});
@@ -602,7 +604,6 @@ describe("DefaultVaultItemsTransferService", () => {
policies: [policy],
organizations: [organization],
ciphers: [{ id: "cipher-1" } as CipherView],
collections: [],
});
await service.enforceOrganizationDataOwnership(userId);
@@ -610,69 +611,48 @@ describe("DefaultVaultItemsTransferService", () => {
expect(mockLogService.warning).toHaveBeenCalledWith(
"Default collection is missing for user during organization data ownership enforcement",
);
expect(mockDialogService.openSimpleDialog).not.toHaveBeenCalled();
expect(mockDialogService.open).not.toHaveBeenCalled();
expect(mockCipherService.shareManyWithServer).not.toHaveBeenCalled();
});
it("shows confirmation dialog when migration is required", async () => {
it("does not transfer items when user declines and confirms leaving", async () => {
setupMocksForEnforcementScenario({
policies: [policy],
organizations: [organization],
ciphers: [{ id: "cipher-1" } as CipherView],
collections: [
{
id: collectionId,
organizationId: organizationId,
isDefaultCollection: true,
} as CollectionView,
],
defaultCollection: {
id: collectionId,
organizationId: organizationId,
isDefaultCollection: true,
} as CollectionView,
});
mockDialogService.openSimpleDialog.mockResolvedValue(false);
await service.enforceOrganizationDataOwnership(userId);
expect(mockDialogService.openSimpleDialog).toHaveBeenCalledWith({
title: "Requires migration",
content: "Your vault requires migration of personal items to your organization.",
type: "warning",
});
});
it("does not transfer items when user declines confirmation", async () => {
setupMocksForEnforcementScenario({
policies: [policy],
organizations: [organization],
ciphers: [{ id: "cipher-1" } as CipherView],
collections: [
{
id: collectionId,
organizationId: organizationId,
isDefaultCollection: true,
} as CollectionView,
],
});
mockDialogService.openSimpleDialog.mockResolvedValue(false);
// User declines transfer, then confirms leaving
mockDialogService.open
.mockReturnValueOnce(createMockDialogRef(TransferItemsDialogResult.Declined))
.mockReturnValueOnce(createMockDialogRef(LeaveConfirmationDialogResult.Confirmed));
await service.enforceOrganizationDataOwnership(userId);
expect(mockCipherService.shareManyWithServer).not.toHaveBeenCalled();
});
it("transfers items and shows success toast when user confirms", async () => {
it("transfers items and shows success toast when user accepts transfer", async () => {
const personalCiphers = [{ id: "cipher-1" } as CipherView];
setupMocksForEnforcementScenario({
policies: [policy],
organizations: [organization],
ciphers: personalCiphers,
collections: [
{
id: collectionId,
organizationId: organizationId,
isDefaultCollection: true,
} as CollectionView,
],
defaultCollection: {
id: collectionId,
organizationId: organizationId,
isDefaultCollection: true,
} as CollectionView,
});
mockDialogService.openSimpleDialog.mockResolvedValue(true);
mockDialogService.open.mockReturnValueOnce(
createMockDialogRef(TransferItemsDialogResult.Accepted),
);
mockCipherService.shareManyWithServer.mockResolvedValue(undefined);
await service.enforceOrganizationDataOwnership(userId);
@@ -695,15 +675,16 @@ describe("DefaultVaultItemsTransferService", () => {
policies: [policy],
organizations: [organization],
ciphers: personalCiphers,
collections: [
{
id: collectionId,
organizationId: organizationId,
isDefaultCollection: true,
} as CollectionView,
],
defaultCollection: {
id: collectionId,
organizationId: organizationId,
isDefaultCollection: true,
} as CollectionView,
});
mockDialogService.openSimpleDialog.mockResolvedValue(true);
mockDialogService.open.mockReturnValueOnce(
createMockDialogRef(TransferItemsDialogResult.Accepted),
);
mockCipherService.shareManyWithServer.mockRejectedValue(new Error("Transfer failed"));
await service.enforceOrganizationDataOwnership(userId);
@@ -717,5 +698,228 @@ describe("DefaultVaultItemsTransferService", () => {
message: "errorOccurred",
});
});
it("re-shows transfer dialog when user goes back from leave confirmation", async () => {
const personalCiphers = [{ id: "cipher-1" } as CipherView];
setupMocksForEnforcementScenario({
policies: [policy],
organizations: [organization],
ciphers: personalCiphers,
defaultCollection: {
id: collectionId,
organizationId: organizationId,
isDefaultCollection: true,
} as CollectionView,
});
// User declines, goes back, then accepts
mockDialogService.open
.mockReturnValueOnce(createMockDialogRef(TransferItemsDialogResult.Declined))
.mockReturnValueOnce(createMockDialogRef(LeaveConfirmationDialogResult.Back))
.mockReturnValueOnce(createMockDialogRef(TransferItemsDialogResult.Accepted));
mockCipherService.shareManyWithServer.mockResolvedValue(undefined);
await service.enforceOrganizationDataOwnership(userId);
// Dialog should have been opened 3 times: transfer -> leave -> transfer (after going back)
expect(mockDialogService.open).toHaveBeenCalledTimes(3);
expect(mockCipherService.shareManyWithServer).toHaveBeenCalled();
});
it("allows multiple back navigations before accepting transfer", async () => {
const personalCiphers = [{ id: "cipher-1" } as CipherView];
setupMocksForEnforcementScenario({
policies: [policy],
organizations: [organization],
ciphers: personalCiphers,
defaultCollection: {
id: collectionId,
organizationId: organizationId,
isDefaultCollection: true,
} as CollectionView,
});
// User declines, goes back, declines again, goes back again, then accepts
mockDialogService.open
.mockReturnValueOnce(createMockDialogRef(TransferItemsDialogResult.Declined))
.mockReturnValueOnce(createMockDialogRef(LeaveConfirmationDialogResult.Back))
.mockReturnValueOnce(createMockDialogRef(TransferItemsDialogResult.Declined))
.mockReturnValueOnce(createMockDialogRef(LeaveConfirmationDialogResult.Back))
.mockReturnValueOnce(createMockDialogRef(TransferItemsDialogResult.Accepted));
mockCipherService.shareManyWithServer.mockResolvedValue(undefined);
await service.enforceOrganizationDataOwnership(userId);
// Dialog should have been opened 5 times
expect(mockDialogService.open).toHaveBeenCalledTimes(5);
expect(mockCipherService.shareManyWithServer).toHaveBeenCalled();
});
it("allows user to go back and then confirm leaving", async () => {
setupMocksForEnforcementScenario({
policies: [policy],
organizations: [organization],
ciphers: [{ id: "cipher-1" } as CipherView],
defaultCollection: {
id: collectionId,
organizationId: organizationId,
isDefaultCollection: true,
} as CollectionView,
});
// User declines, goes back, declines again, then confirms leaving
mockDialogService.open
.mockReturnValueOnce(createMockDialogRef(TransferItemsDialogResult.Declined))
.mockReturnValueOnce(createMockDialogRef(LeaveConfirmationDialogResult.Back))
.mockReturnValueOnce(createMockDialogRef(TransferItemsDialogResult.Declined))
.mockReturnValueOnce(createMockDialogRef(LeaveConfirmationDialogResult.Confirmed));
await service.enforceOrganizationDataOwnership(userId);
expect(mockDialogService.open).toHaveBeenCalledTimes(4);
expect(mockCipherService.shareManyWithServer).not.toHaveBeenCalled();
});
describe("event logs", () => {
it("logs accepted event when user accepts transfer", async () => {
const personalCiphers = [{ id: "cipher-1" } as CipherView];
setupMocksForEnforcementScenario({
policies: [policy],
organizations: [organization],
ciphers: personalCiphers,
defaultCollection: {
id: collectionId,
organizationId: organizationId,
isDefaultCollection: true,
} as CollectionView,
});
mockDialogService.open.mockReturnValueOnce(
createMockDialogRef(TransferItemsDialogResult.Accepted),
);
mockCipherService.shareManyWithServer.mockResolvedValue(undefined);
await service.enforceOrganizationDataOwnership(userId);
expect(mockEventCollectionService.collect).toHaveBeenCalledWith(
EventType.Organization_ItemOrganization_Accepted,
undefined,
undefined,
organizationId,
);
});
it("logs declined event when user rejects transfer", async () => {
const personalCiphers = [{ id: "cipher-1" } as CipherView];
setupMocksForEnforcementScenario({
policies: [policy],
organizations: [organization],
ciphers: personalCiphers,
defaultCollection: {
id: collectionId,
organizationId: organizationId,
isDefaultCollection: true,
} as CollectionView,
});
mockDialogService.open
.mockReturnValueOnce(createMockDialogRef(TransferItemsDialogResult.Declined))
.mockReturnValueOnce(createMockDialogRef(LeaveConfirmationDialogResult.Confirmed));
await service.enforceOrganizationDataOwnership(userId);
expect(mockEventCollectionService.collect).toHaveBeenCalledWith(
EventType.Organization_ItemOrganization_Declined,
undefined,
undefined,
organizationId,
);
});
});
});
describe("transferInProgress$", () => {
const policy = {
organizationId: organizationId,
revisionDate: new Date("2024-01-01"),
} as Policy;
const organization = {
id: organizationId,
name: "Test Org",
} as Organization;
function setupMocksForTransferScenario(options: {
featureEnabled?: boolean;
policies?: Policy[];
organizations?: Organization[];
ciphers?: CipherView[];
defaultCollection?: CollectionView;
}): void {
mockConfigService.getFeatureFlag.mockResolvedValue(options.featureEnabled ?? true);
mockPolicyService.policiesByType$.mockReturnValue(of(options.policies ?? []));
mockOrganizationService.organizations$.mockReturnValue(of(options.organizations ?? []));
mockCipherService.cipherViews$.mockReturnValue(of(options.ciphers ?? []));
mockCollectionService.defaultUserCollection$.mockReturnValue(of(options.defaultCollection));
}
it("emits false initially", async () => {
const result = await firstValueFrom(service.transferInProgress$);
expect(result).toBe(false);
});
it("emits true during transfer and false after successful completion", async () => {
const personalCiphers = [{ id: "cipher-1" } as CipherView];
setupMocksForTransferScenario({
policies: [policy],
organizations: [organization],
ciphers: personalCiphers,
defaultCollection: {
id: collectionId,
organizationId: organizationId,
isDefaultCollection: true,
} as CollectionView,
});
mockDialogService.open.mockReturnValueOnce(
createMockDialogRef(TransferItemsDialogResult.Accepted),
);
mockCipherService.shareManyWithServer.mockResolvedValue(undefined);
// Subscribe to track all emitted values
service.transferInProgress$.subscribe((value) => transferInProgressValues.push(value));
await service.enforceOrganizationDataOwnership(userId);
// Should have emitted: false (initial), true (transfer started), false (transfer completed)
expect(transferInProgressValues).toEqual([false, true, false]);
});
it("emits false after transfer fails with error", async () => {
const personalCiphers = [{ id: "cipher-1" } as CipherView];
setupMocksForTransferScenario({
policies: [policy],
organizations: [organization],
ciphers: personalCiphers,
defaultCollection: {
id: collectionId,
organizationId: organizationId,
isDefaultCollection: true,
} as CollectionView,
});
mockDialogService.open.mockReturnValueOnce(
createMockDialogRef(TransferItemsDialogResult.Accepted),
);
mockCipherService.shareManyWithServer.mockRejectedValue(new Error("Transfer failed"));
// Subscribe to track all emitted values
service.transferInProgress$.subscribe((value) => transferInProgressValues.push(value));
await service.enforceOrganizationDataOwnership(userId);
// Should have emitted: false (initial), true (transfer started), false (transfer failed)
expect(transferInProgressValues).toEqual([false, true, false]);
});
});
});

View File

@@ -1,12 +1,22 @@
import { Injectable } from "@angular/core";
import { firstValueFrom, switchMap, map, of, Observable, combineLatest } from "rxjs";
import {
firstValueFrom,
switchMap,
map,
of,
Observable,
combineLatest,
BehaviorSubject,
} from "rxjs";
// eslint-disable-next-line no-restricted-imports
import { CollectionService } from "@bitwarden/admin-console/common";
import { EventCollectionService } from "@bitwarden/common/abstractions/event/event-collection.service";
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
import { PolicyType } from "@bitwarden/common/admin-console/enums";
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
import { EventType } from "@bitwarden/common/enums";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
@@ -23,6 +33,12 @@ import {
VaultItemsTransferService,
UserMigrationInfo,
} from "../abstractions/vault-items-transfer.service";
import {
TransferItemsDialogComponent,
TransferItemsDialogResult,
LeaveConfirmationDialogComponent,
LeaveConfirmationDialogResult,
} from "../components/vault-items-transfer";
@Injectable()
export class DefaultVaultItemsTransferService implements VaultItemsTransferService {
@@ -35,9 +51,14 @@ export class DefaultVaultItemsTransferService implements VaultItemsTransferServi
private i18nService: I18nService,
private dialogService: DialogService,
private toastService: ToastService,
private eventCollectionService: EventCollectionService,
private configService: ConfigService,
) {}
private _transferInProgressSubject = new BehaviorSubject(false);
transferInProgress$ = this._transferInProgressSubject.asObservable();
private enforcingOrganization$(userId: UserId): Observable<Organization | undefined> {
return this.policyService.policiesByType$(PolicyType.OrganizationDataOwnership, userId).pipe(
map(
@@ -60,18 +81,6 @@ export class DefaultVaultItemsTransferService implements VaultItemsTransferServi
);
}
private defaultUserCollection$(
userId: UserId,
organizationId: OrganizationId,
): Observable<CollectionId | undefined> {
return this.collectionService.decryptedCollections$(userId).pipe(
map((collections) => {
return collections.find((c) => c.isDefaultCollection && c.organizationId === organizationId)
?.id;
}),
);
}
userMigrationInfo$(userId: UserId): Observable<UserMigrationInfo> {
return this.enforcingOrganization$(userId).pipe(
switchMap((enforcingOrganization) => {
@@ -82,13 +91,13 @@ export class DefaultVaultItemsTransferService implements VaultItemsTransferServi
}
return combineLatest([
this.personalCiphers$(userId),
this.defaultUserCollection$(userId, enforcingOrganization.id),
this.collectionService.defaultUserCollection$(userId, enforcingOrganization.id),
]).pipe(
map(([personalCiphers, defaultCollectionId]): UserMigrationInfo => {
map(([personalCiphers, defaultCollection]): UserMigrationInfo => {
return {
requiresMigration: personalCiphers.length > 0,
enforcingOrganization,
defaultCollectionId,
defaultCollectionId: defaultCollection?.id,
};
}),
);
@@ -96,6 +105,35 @@ export class DefaultVaultItemsTransferService implements VaultItemsTransferServi
);
}
/**
* Prompts the user to accept or decline the vault items transfer.
* If declined, shows a leave confirmation dialog with option to go back.
* @returns true if user accepts transfer, false if user confirms leaving
*/
private async promptUserForTransfer(organizationName: string): Promise<boolean> {
const confirmDialogRef = TransferItemsDialogComponent.open(this.dialogService, {
data: { organizationName },
});
const confirmResult = await firstValueFrom(confirmDialogRef.closed);
if (confirmResult === TransferItemsDialogResult.Accepted) {
return true;
}
const leaveDialogRef = LeaveConfirmationDialogComponent.open(this.dialogService, {
data: { organizationName },
});
const leaveResult = await firstValueFrom(leaveDialogRef.closed);
if (leaveResult === LeaveConfirmationDialogResult.Back) {
return this.promptUserForTransfer(organizationName);
}
return false;
}
async enforceOrganizationDataOwnership(userId: UserId): Promise<void> {
const featureEnabled = await this.configService.getFeatureFlag(
FeatureFlag.MigrateMyVaultToMyItems,
@@ -119,30 +157,43 @@ export class DefaultVaultItemsTransferService implements VaultItemsTransferServi
return;
}
// Temporary confirmation dialog. Full implementation in PM-27663
const confirmMigration = await this.dialogService.openSimpleDialog({
title: "Requires migration",
content: "Your vault requires migration of personal items to your organization.",
type: "warning",
});
const userAcceptedTransfer = await this.promptUserForTransfer(
migrationInfo.enforcingOrganization.name,
);
if (!confirmMigration) {
// TODO: Show secondary confirmation dialog in PM-27663, for now we just exit
// TODO: Revoke user from organization if they decline migration PM-29465
if (!userAcceptedTransfer) {
// TODO: Revoke user from organization if they decline migration and show toast PM-29465
await this.eventCollectionService.collect(
EventType.Organization_ItemOrganization_Declined,
undefined,
undefined,
migrationInfo.enforcingOrganization.id,
);
return;
}
try {
this._transferInProgressSubject.next(true);
await this.transferPersonalItems(
userId,
migrationInfo.enforcingOrganization.id,
migrationInfo.defaultCollectionId,
);
this._transferInProgressSubject.next(false);
this.toastService.showToast({
variant: "success",
message: this.i18nService.t("itemsTransferred"),
});
await this.eventCollectionService.collect(
EventType.Organization_ItemOrganization_Accepted,
undefined,
undefined,
migrationInfo.enforcingOrganization.id,
);
} catch (error) {
this._transferInProgressSubject.next(false);
this.logService.error("Error transferring personal items to organization", error);
this.toastService.showToast({
variant: "error",