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:
@@ -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>;
|
||||
/**
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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)
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
# Tools Vendor Integration
|
||||
|
||||
This module defines interfaces and helpers for creating vendor integration sites.
|
||||
|
||||
## RPC
|
||||
|
||||
@@ -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]="['/']"
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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 {}
|
||||
|
||||
|
||||
@@ -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>
|
||||
`,
|
||||
}),
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
`,
|
||||
}),
|
||||
|
||||
@@ -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@bitwarden.com
|
||||
<ng-container slot="secondary">
|
||||
|
||||
@@ -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()"
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
## DIRT Card
|
||||
# DIRT Card
|
||||
|
||||
Package name: `@bitwarden/dirt-card`
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
5
libs/subscription/README.md
Normal file
5
libs/subscription/README.md
Normal file
@@ -0,0 +1,5 @@
|
||||
# Subscription
|
||||
|
||||
Owned by: billing
|
||||
|
||||
Components and services for managing Bitwarden subscriptions.
|
||||
3
libs/subscription/eslint.config.mjs
Normal file
3
libs/subscription/eslint.config.mjs
Normal file
@@ -0,0 +1,3 @@
|
||||
import baseConfig from "../../eslint.config.mjs";
|
||||
|
||||
export default [...baseConfig];
|
||||
10
libs/subscription/jest.config.js
Normal file
10
libs/subscription/jest.config.js
Normal 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",
|
||||
};
|
||||
11
libs/subscription/package.json
Normal file
11
libs/subscription/package.json
Normal 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"
|
||||
}
|
||||
34
libs/subscription/project.json
Normal file
34
libs/subscription/project.json
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
1
libs/subscription/src/index.ts
Normal file
1
libs/subscription/src/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export type Placeholder = unknown;
|
||||
8
libs/subscription/src/subscription.spec.ts
Normal file
8
libs/subscription/src/subscription.spec.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
6
libs/subscription/tsconfig.eslint.json
Normal file
6
libs/subscription/tsconfig.eslint.json
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"files": [],
|
||||
"include": ["src/**/*.ts", "src/**/*.js"],
|
||||
"exclude": ["**/build", "**/dist"]
|
||||
}
|
||||
13
libs/subscription/tsconfig.json
Normal file
13
libs/subscription/tsconfig.json
Normal file
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"files": [],
|
||||
"include": [],
|
||||
"references": [
|
||||
{
|
||||
"path": "./tsconfig.lib.json"
|
||||
},
|
||||
{
|
||||
"path": "./tsconfig.spec.json"
|
||||
}
|
||||
]
|
||||
}
|
||||
10
libs/subscription/tsconfig.lib.json
Normal file
10
libs/subscription/tsconfig.lib.json
Normal 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"]
|
||||
}
|
||||
10
libs/subscription/tsconfig.spec.json
Normal file
10
libs/subscription/tsconfig.spec.json
Normal 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"]
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user