1
0
mirror of https://github.com/bitwarden/browser synced 2026-02-17 09:59:41 +00:00

Merge branch 'main' into vault/pm-27632/sdk-cipher-ops

This commit is contained in:
Nik Gilmore
2026-01-22 11:59:21 -08:00
committed by GitHub
584 changed files with 18380 additions and 4326 deletions

View File

@@ -1,10 +1,12 @@
import { Observable } from "rxjs";
import { CollectionDetailsResponse } from "@bitwarden/admin-console/common";
import {
CollectionAdminView,
CollectionAccessSelectionView,
CollectionDetailsResponse,
} from "@bitwarden/common/admin-console/models/collections";
import { UserId } from "@bitwarden/common/types/guid";
import { CollectionAccessSelectionView, CollectionAdminView } from "../models";
export abstract class CollectionAdminService {
abstract collectionAdminViews$(
organizationId: string,

View File

@@ -1,11 +1,14 @@
import { Observable } from "rxjs";
import {
CollectionView,
Collection,
CollectionData,
} from "@bitwarden/common/admin-console/models/collections";
import { CollectionId, OrganizationId, UserId } from "@bitwarden/common/types/guid";
import { OrgKey } from "@bitwarden/common/types/key";
import { TreeNode } from "@bitwarden/common/vault/models/domain/tree-node";
import { CollectionData, Collection, CollectionView } from "../models";
export abstract class CollectionService {
abstract encryptedCollections$(userId: UserId): Observable<Collection[] | null>;
abstract decryptedCollections$(userId: UserId): Observable<CollectionView[]>;

View File

@@ -1,4 +1,5 @@
import { Collection } from "./collection";
import { Collection } from "@bitwarden/common/admin-console/models/collections";
import { BaseCollectionRequest } from "./collection.request";
export class CollectionWithIdRequest extends BaseCollectionRequest {

View File

@@ -1,15 +1,17 @@
import { MockProxy, mock } from "jest-mock-extended";
import {
CollectionDetailsResponse,
Collection,
CollectionTypes,
CollectionData,
} from "@bitwarden/common/admin-console/models/collections";
import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service";
import { EncString } from "@bitwarden/common/key-management/crypto/models/enc-string";
import { makeSymmetricCryptoKey } from "@bitwarden/common/spec";
import { CollectionId, OrganizationId } from "@bitwarden/common/types/guid";
import { OrgKey } from "@bitwarden/common/types/key";
import { Collection, CollectionTypes } from "./collection";
import { CollectionData } from "./collection.data";
import { CollectionDetailsResponse } from "./collection.response";
describe("Collection", () => {
let data: CollectionData;
let encService: MockProxy<EncryptService>;

View File

@@ -1,9 +1,3 @@
export * from "./bulk-collection-access.request";
export * from "./collection-access-selection.view";
export * from "./collection-admin.view";
export * from "./collection";
export * from "./collection.data";
export * from "./collection.view";
export * from "./collection.request";
export * from "./collection.response";
export * from "./collection-with-id.request";

View File

@@ -1,5 +1,6 @@
import { Jsonify } from "type-fest";
import { CollectionView, CollectionData } from "@bitwarden/common/admin-console/models/collections";
import {
COLLECTION_DISK,
COLLECTION_MEMORY,
@@ -7,8 +8,6 @@ import {
} from "@bitwarden/common/platform/state";
import { CollectionId } from "@bitwarden/common/types/guid";
import { CollectionData, CollectionView } from "../models";
export const ENCRYPTED_COLLECTION_DATA_KEY = UserKeyDefinition.record<CollectionData, CollectionId>(
COLLECTION_DISK,
"collections",

View File

@@ -5,6 +5,14 @@ import {
getOrganizationById,
OrganizationService,
} from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
import {
CollectionAccessSelectionView,
CollectionAdminView,
CollectionAccessDetailsResponse,
CollectionDetailsResponse,
CollectionResponse,
CollectionData,
} from "@bitwarden/common/admin-console/models/collections";
import { SelectionReadOnlyRequest } from "@bitwarden/common/admin-console/models/request/selection-read-only.request";
import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service";
import { CollectionId, OrganizationId, UserId } from "@bitwarden/common/types/guid";
@@ -13,13 +21,7 @@ import { KeyService } from "@bitwarden/key-management";
import { CollectionAdminService, CollectionService } from "../abstractions";
import {
CollectionData,
CollectionAccessDetailsResponse,
CollectionDetailsResponse,
CollectionResponse,
BulkCollectionAccessRequest,
CollectionAccessSelectionView,
CollectionAdminView,
BaseCollectionRequest,
UpdateCollectionRequest,
CreateCollectionRequest,

View File

@@ -1,6 +1,11 @@
import { mock, MockProxy } from "jest-mock-extended";
import { combineLatest, first, firstValueFrom, of, ReplaySubject, takeWhile } from "rxjs";
import {
CollectionView,
CollectionTypes,
CollectionData,
} from "@bitwarden/common/admin-console/models/collections";
import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service";
import { EncString } from "@bitwarden/common/key-management/crypto/models/enc-string";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
@@ -18,8 +23,6 @@ import { OrgKey } from "@bitwarden/common/types/key";
import { newGuid } from "@bitwarden/guid";
import { KeyService } from "@bitwarden/key-management";
import { CollectionData, CollectionTypes, CollectionView } from "../models";
import { DECRYPTED_COLLECTION_DATA_KEY, ENCRYPTED_COLLECTION_DATA_KEY } from "./collection.state";
import { DefaultCollectionService } from "./default-collection.service";

View File

@@ -12,6 +12,11 @@ import {
switchMap,
} from "rxjs";
import {
CollectionView,
Collection,
CollectionData,
} from "@bitwarden/common/admin-console/models/collections";
import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { Utils } from "@bitwarden/common/platform/misc/utils";
@@ -23,7 +28,6 @@ import { ServiceUtils } from "@bitwarden/common/vault/service-utils";
import { KeyService } from "@bitwarden/key-management";
import { CollectionService } from "../abstractions/collection.service";
import { Collection, CollectionData, CollectionView } from "../models";
import { DECRYPTED_COLLECTION_DATA_KEY, ENCRYPTED_COLLECTION_DATA_KEY } from "./collection.state";

View File

@@ -264,6 +264,13 @@ export abstract class OrganizationUserApiService {
ids: string[],
): Promise<ListResponse<OrganizationUserBulkResponse>>;
/**
* Revoke the current user's access to the organization
* if they decline an item transfer under the Organization Data Ownership policy.
* @param organizationId - Identifier for the organization the user belongs to
*/
abstract revokeSelf(organizationId: string): Promise<void>;
/**
* Restore an organization user's access to the organization
* @param organizationId - Identifier for the organization the user belongs to

View File

@@ -339,6 +339,16 @@ export class DefaultOrganizationUserApiService implements OrganizationUserApiSer
return new ListResponse(r, OrganizationUserBulkResponse);
}
revokeSelf(organizationId: string): Promise<void> {
return this.apiService.send(
"PUT",
"/organizations/" + organizationId + "/users/revoke-self",
null,
true,
false,
);
}
restoreOrganizationUser(organizationId: string, id: string): Promise<void> {
return this.apiService.send(
"PUT",

View File

@@ -1,4 +1,4 @@
import { firstValueFrom } from "rxjs";
import { concatMap, firstValueFrom } from "rxjs";
// 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
@@ -19,19 +19,32 @@ import { AccountCryptographicStateService } from "@bitwarden/common/key-manageme
import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service";
import { EncString } from "@bitwarden/common/key-management/crypto/models/enc-string";
import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/key-management/master-password/abstractions/master-password.service.abstraction";
import { MasterPasswordSalt } from "@bitwarden/common/key-management/master-password/types/master-password.types";
import {
MasterPasswordSalt,
MasterPasswordUnlockData,
} from "@bitwarden/common/key-management/master-password/types/master-password.types";
import { KeysRequest } from "@bitwarden/common/models/request/keys.request";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { RegisterSdkService } from "@bitwarden/common/platform/abstractions/sdk/register-sdk.service";
import { asUuid } from "@bitwarden/common/platform/abstractions/sdk/sdk.service";
import { Utils } from "@bitwarden/common/platform/misc/utils";
import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key";
import { UserId } from "@bitwarden/common/types/guid";
import { MasterKey, UserKey } from "@bitwarden/common/types/key";
import { KdfConfigService, KeyService, KdfConfig } from "@bitwarden/key-management";
import {
fromSdkKdfConfig,
KdfConfig,
KdfConfigService,
KeyService,
} from "@bitwarden/key-management";
import { OrganizationId as SdkOrganizationId, UserId as SdkUserId } from "@bitwarden/sdk-internal";
import {
SetInitialPasswordService,
InitializeJitPasswordCredentials,
SetInitialPasswordCredentials,
SetInitialPasswordUserType,
SetInitialPasswordService,
SetInitialPasswordTdeOffboardingCredentials,
SetInitialPasswordUserType,
} from "./set-initial-password.service.abstraction";
export class DefaultSetInitialPasswordService implements SetInitialPasswordService {
@@ -47,6 +60,7 @@ export class DefaultSetInitialPasswordService implements SetInitialPasswordServi
protected organizationUserApiService: OrganizationUserApiService,
protected userDecryptionOptionsService: InternalUserDecryptionOptionsServiceAbstraction,
protected accountCryptographicStateService: AccountCryptographicStateService,
protected registerSdkService: RegisterSdkService,
) {}
async setInitialPassword(
@@ -199,6 +213,126 @@ export class DefaultSetInitialPasswordService implements SetInitialPasswordServi
}
}
async setInitialPasswordTdeOffboarding(
credentials: SetInitialPasswordTdeOffboardingCredentials,
userId: UserId,
) {
const { newMasterKey, newServerMasterKeyHash, newPasswordHint } = credentials;
for (const [key, value] of Object.entries(credentials)) {
if (value == null) {
throw new Error(`${key} not found. Could not set password.`);
}
}
if (userId == null) {
throw new Error("userId not found. Could not set password.");
}
const userKey = await firstValueFrom(this.keyService.userKey$(userId));
if (userKey == null) {
throw new Error("userKey not found. Could not set password.");
}
const newMasterKeyEncryptedUserKey = await this.keyService.encryptUserKeyWithMasterKey(
newMasterKey,
userKey,
);
if (!newMasterKeyEncryptedUserKey[1].encryptedString) {
throw new Error("newMasterKeyEncryptedUserKey not found. Could not set password.");
}
const request = new UpdateTdeOffboardingPasswordRequest();
request.key = newMasterKeyEncryptedUserKey[1].encryptedString;
request.newMasterPasswordHash = newServerMasterKeyHash;
request.masterPasswordHint = newPasswordHint;
await this.masterPasswordApiService.putUpdateTdeOffboardingPassword(request);
// Clear force set password reason to allow navigation back to vault.
await this.masterPasswordService.setForceSetPasswordReason(ForceSetPasswordReason.None, userId);
}
async initializePasswordJitPasswordUserV2Encryption(
credentials: InitializeJitPasswordCredentials,
userId: UserId,
): Promise<void> {
if (userId == null) {
throw new Error("User ID is required.");
}
for (const [key, value] of Object.entries(credentials)) {
if (value == null) {
throw new Error(`${key} is required.`);
}
}
const { newPasswordHint, orgSsoIdentifier, orgId, resetPasswordAutoEnroll, newPassword, salt } =
credentials;
const organizationKeys = await this.organizationApiService.getKeys(orgId);
if (organizationKeys == null) {
throw new Error("Organization keys response is null.");
}
const registerResult = await firstValueFrom(
this.registerSdkService.registerClient$(userId).pipe(
concatMap(async (sdk) => {
if (!sdk) {
throw new Error("SDK not available");
}
using ref = sdk.take();
return await ref.value
.auth()
.registration()
.post_keys_for_jit_password_registration({
org_id: asUuid<SdkOrganizationId>(orgId),
org_public_key: organizationKeys.publicKey,
master_password: newPassword,
master_password_hint: newPasswordHint,
salt: salt,
organization_sso_identifier: orgSsoIdentifier,
user_id: asUuid<SdkUserId>(userId),
reset_password_enroll: resetPasswordAutoEnroll,
});
}),
),
);
if (!("V2" in registerResult.account_cryptographic_state)) {
throw new Error("Unexpected V2 account cryptographic state");
}
// Note: When SDK state management matures, these should be moved into post_keys_for_tde_registration
// Set account cryptography state
await this.accountCryptographicStateService.setAccountCryptographicState(
registerResult.account_cryptographic_state,
userId,
);
// Clear force set password reason to allow navigation back to vault.
await this.masterPasswordService.setForceSetPasswordReason(ForceSetPasswordReason.None, userId);
const masterPasswordUnlockData = MasterPasswordUnlockData.fromSdk(
registerResult.master_password_unlock,
);
await this.masterPasswordService.setMasterPasswordUnlockData(masterPasswordUnlockData, userId);
await this.keyService.setUserKey(
SymmetricCryptoKey.fromString(registerResult.user_key) as UserKey,
userId,
);
await this.updateLegacyState(
newPassword,
fromSdkKdfConfig(registerResult.master_password_unlock.kdf),
new EncString(registerResult.master_password_unlock.masterKeyWrappedUserKey),
userId,
masterPasswordUnlockData,
);
}
private async makeMasterKeyEncryptedUserKey(
masterKey: MasterKey,
userId: UserId,
@@ -244,6 +378,37 @@ export class DefaultSetInitialPasswordService implements SetInitialPasswordServi
await this.keyService.setUserKey(masterKeyEncryptedUserKey[0], userId);
}
// Deprecated legacy support - to be removed in future
private async updateLegacyState(
newPassword: string,
kdfConfig: KdfConfig,
masterKeyWrappedUserKey: EncString,
userId: UserId,
masterPasswordUnlockData: MasterPasswordUnlockData,
) {
// TODO Remove HasMasterPassword from UserDecryptionOptions https://bitwarden.atlassian.net/browse/PM-23475
const userDecryptionOpts = await firstValueFrom(
this.userDecryptionOptionsService.userDecryptionOptionsById$(userId),
);
userDecryptionOpts.hasMasterPassword = true;
await this.userDecryptionOptionsService.setUserDecryptionOptionsById(
userId,
userDecryptionOpts,
);
// TODO Remove KDF state https://bitwarden.atlassian.net/browse/PM-30661
await this.kdfConfigService.setKdfConfig(userId, kdfConfig);
// TODO Remove master key memory state https://bitwarden.atlassian.net/browse/PM-23477
await this.masterPasswordService.setMasterKeyEncryptedUserKey(masterKeyWrappedUserKey, userId);
// TODO Removed with https://bitwarden.atlassian.net/browse/PM-30676
await this.masterPasswordService.setLegacyMasterKeyFromUnlockData(
newPassword,
masterPasswordUnlockData,
userId,
);
}
/**
* As part of [PM-28494], adding this setting path to accommodate the changes that are
* emerging with pm-23246-unlock-with-master-password-unlock-data.
@@ -310,44 +475,4 @@ export class DefaultSetInitialPasswordService implements SetInitialPasswordServi
enrollmentRequest,
);
}
async setInitialPasswordTdeOffboarding(
credentials: SetInitialPasswordTdeOffboardingCredentials,
userId: UserId,
) {
const { newMasterKey, newServerMasterKeyHash, newPasswordHint } = credentials;
for (const [key, value] of Object.entries(credentials)) {
if (value == null) {
throw new Error(`${key} not found. Could not set password.`);
}
}
if (userId == null) {
throw new Error("userId not found. Could not set password.");
}
const userKey = await firstValueFrom(this.keyService.userKey$(userId));
if (userKey == null) {
throw new Error("userKey not found. Could not set password.");
}
const newMasterKeyEncryptedUserKey = await this.keyService.encryptUserKeyWithMasterKey(
newMasterKey,
userKey,
);
if (!newMasterKeyEncryptedUserKey[1].encryptedString) {
throw new Error("newMasterKeyEncryptedUserKey not found. Could not set password.");
}
const request = new UpdateTdeOffboardingPasswordRequest();
request.key = newMasterKeyEncryptedUserKey[1].encryptedString;
request.newMasterPasswordHash = newServerMasterKeyHash;
request.masterPasswordHint = newPasswordHint;
await this.masterPasswordApiService.putUpdateTdeOffboardingPassword(request);
// Clear force set password reason to allow navigation back to vault.
await this.masterPasswordService.setForceSetPasswordReason(ForceSetPasswordReason.None, userId);
}
}

View File

@@ -1,5 +1,8 @@
import { MockProxy, mock } from "jest-mock-extended";
import { BehaviorSubject, of } from "rxjs";
// Polyfill for Symbol.dispose required by the service's use of `using` keyword
import "core-js/proposals/explicit-resource-management";
import { mock, MockProxy } from "jest-mock-extended";
import { BehaviorSubject, Observable, of } from "rxjs";
// 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
@@ -27,17 +30,35 @@ import {
EncString,
} from "@bitwarden/common/key-management/crypto/models/enc-string";
import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/key-management/master-password/abstractions/master-password.service.abstraction";
import {
MasterPasswordSalt,
MasterPasswordUnlockData,
} from "@bitwarden/common/key-management/master-password/types/master-password.types";
import { KeysRequest } from "@bitwarden/common/models/request/keys.request";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { RegisterSdkService } from "@bitwarden/common/platform/abstractions/sdk/register-sdk.service";
import { Rc } from "@bitwarden/common/platform/misc/reference-counting/rc";
import { Utils } from "@bitwarden/common/platform/misc/utils";
import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key";
import { makeEncString, makeSymmetricCryptoKey } from "@bitwarden/common/spec";
import { CsprngArray } from "@bitwarden/common/types/csprng";
import { UserId } from "@bitwarden/common/types/guid";
import { OrganizationId, UserId } from "@bitwarden/common/types/guid";
import { MasterKey, UserKey, UserPrivateKey, UserPublicKey } from "@bitwarden/common/types/key";
import { DEFAULT_KDF_CONFIG, KdfConfigService, KeyService } from "@bitwarden/key-management";
import {
DEFAULT_KDF_CONFIG,
fromSdkKdfConfig,
KdfConfigService,
KeyService,
} from "@bitwarden/key-management";
import {
AuthClient,
BitwardenClient,
WrappedAccountCryptographicState,
} from "@bitwarden/sdk-internal";
import { DefaultSetInitialPasswordService } from "./default-set-initial-password.service.implementation";
import {
InitializeJitPasswordCredentials,
SetInitialPasswordCredentials,
SetInitialPasswordService,
SetInitialPasswordTdeOffboardingCredentials,
@@ -58,6 +79,7 @@ describe("DefaultSetInitialPasswordService", () => {
let organizationUserApiService: MockProxy<OrganizationUserApiService>;
let userDecryptionOptionsService: MockProxy<InternalUserDecryptionOptionsServiceAbstraction>;
let accountCryptographicStateService: MockProxy<AccountCryptographicStateService>;
const registerSdkService = mock<RegisterSdkService>();
let userId: UserId;
let userKey: UserKey;
@@ -94,6 +116,7 @@ describe("DefaultSetInitialPasswordService", () => {
organizationUserApiService,
userDecryptionOptionsService,
accountCryptographicStateService,
registerSdkService,
);
});
@@ -834,4 +857,246 @@ describe("DefaultSetInitialPasswordService", () => {
});
});
});
describe("initializePasswordJitPasswordUserV2Encryption()", () => {
let mockSdkRef: {
value: MockProxy<BitwardenClient>;
[Symbol.dispose]: jest.Mock;
};
let mockSdk: {
take: jest.Mock;
};
let mockRegistration: jest.Mock;
const userId = "d4e2e3a1-1b5e-4c3b-8d7a-9f8e7d6c5b4a" as UserId;
const orgId = "a1b2c3d4-e5f6-4a5b-8c9d-0e1f2a3b4c5d" as OrganizationId;
const credentials: InitializeJitPasswordCredentials = {
newPasswordHint: "test-hint",
orgSsoIdentifier: "org-sso-id",
orgId: orgId,
resetPasswordAutoEnroll: false,
newPassword: "Test@Password123!",
salt: "user@example.com" as unknown as MasterPasswordSalt,
};
const orgKeys: OrganizationKeysResponse = {
publicKey: "org-public-key-base64",
privateKey: "org-private-key-encrypted",
} as OrganizationKeysResponse;
const sdkRegistrationResult = {
account_cryptographic_state: {
V2: {
private_key: makeEncString().encryptedString!,
signed_public_key: "test-signed-public-key",
signing_key: makeEncString().encryptedString!,
security_state: "test-security-state",
},
},
master_password_unlock: {
kdf: {
pBKDF2: {
iterations: 600000,
},
},
masterKeyWrappedUserKey: makeEncString().encryptedString!,
salt: "user@example.com" as unknown as MasterPasswordSalt,
},
user_key: makeSymmetricCryptoKey(64).keyB64,
};
beforeEach(() => {
jest.clearAllMocks();
mockSdkRef = {
value: mock<BitwardenClient>(),
[Symbol.dispose]: jest.fn(),
};
mockSdkRef.value.auth.mockReturnValue({
registration: jest.fn().mockReturnValue({
post_keys_for_jit_password_registration: jest.fn(),
}),
} as unknown as AuthClient);
mockSdk = {
take: jest.fn().mockReturnValue(mockSdkRef),
};
registerSdkService.registerClient$.mockReturnValue(
of(mockSdk) as unknown as Observable<Rc<BitwardenClient>>,
);
organizationApiService.getKeys.mockResolvedValue(orgKeys);
mockRegistration = mockSdkRef.value.auth().registration()
.post_keys_for_jit_password_registration as unknown as jest.Mock;
mockRegistration.mockResolvedValue(sdkRegistrationResult);
const mockUserDecryptionOpts = new UserDecryptionOptions({ hasMasterPassword: false });
userDecryptionOptionsService.userDecryptionOptionsById$.mockReturnValue(
of(mockUserDecryptionOpts),
);
});
it("should successfully initialize JIT password user", async () => {
await sut.initializePasswordJitPasswordUserV2Encryption(credentials, userId);
expect(organizationApiService.getKeys).toHaveBeenCalledWith(credentials.orgId);
expect(registerSdkService.registerClient$).toHaveBeenCalledWith(userId);
expect(mockRegistration).toHaveBeenCalledWith(
expect.objectContaining({
org_id: credentials.orgId,
org_public_key: orgKeys.publicKey,
master_password: credentials.newPassword,
master_password_hint: credentials.newPasswordHint,
salt: credentials.salt,
organization_sso_identifier: credentials.orgSsoIdentifier,
user_id: userId,
reset_password_enroll: credentials.resetPasswordAutoEnroll,
}),
);
expect(accountCryptographicStateService.setAccountCryptographicState).toHaveBeenCalledWith(
sdkRegistrationResult.account_cryptographic_state,
userId,
);
expect(masterPasswordService.setForceSetPasswordReason).toHaveBeenCalledWith(
ForceSetPasswordReason.None,
userId,
);
expect(masterPasswordService.setMasterPasswordUnlockData).toHaveBeenCalledWith(
MasterPasswordUnlockData.fromSdk(sdkRegistrationResult.master_password_unlock),
userId,
);
expect(keyService.setUserKey).toHaveBeenCalledWith(
SymmetricCryptoKey.fromString(sdkRegistrationResult.user_key) as UserKey,
userId,
);
// Verify legacy state updates below
expect(userDecryptionOptionsService.userDecryptionOptionsById$).toHaveBeenCalledWith(userId);
expect(userDecryptionOptionsService.setUserDecryptionOptionsById).toHaveBeenCalledWith(
userId,
expect.objectContaining({ hasMasterPassword: true }),
);
expect(kdfConfigService.setKdfConfig).toHaveBeenCalledWith(
userId,
fromSdkKdfConfig(sdkRegistrationResult.master_password_unlock.kdf),
);
expect(masterPasswordService.setMasterKeyEncryptedUserKey).toHaveBeenCalledWith(
new EncString(sdkRegistrationResult.master_password_unlock.masterKeyWrappedUserKey),
userId,
);
expect(masterPasswordService.setLegacyMasterKeyFromUnlockData).toHaveBeenCalledWith(
credentials.newPassword,
MasterPasswordUnlockData.fromSdk(sdkRegistrationResult.master_password_unlock),
userId,
);
});
describe("input validation", () => {
it.each([
"newPasswordHint",
"orgSsoIdentifier",
"orgId",
"resetPasswordAutoEnroll",
"newPassword",
"salt",
])("should throw error when %s is null", async (field) => {
const invalidCredentials = {
...credentials,
[field]: null,
} as unknown as InitializeJitPasswordCredentials;
const promise = sut.initializePasswordJitPasswordUserV2Encryption(
invalidCredentials,
userId,
);
await expect(promise).rejects.toThrow(`${field} is required.`);
expect(organizationApiService.getKeys).not.toHaveBeenCalled();
expect(registerSdkService.registerClient$).not.toHaveBeenCalled();
});
it("should throw error when userId is null", async () => {
const nullUserId = null as unknown as UserId;
const promise = sut.initializePasswordJitPasswordUserV2Encryption(credentials, nullUserId);
await expect(promise).rejects.toThrow("User ID is required.");
expect(organizationApiService.getKeys).not.toHaveBeenCalled();
});
});
describe("organization API error handling", () => {
it("should throw when organizationApiService.getKeys returns null", async () => {
organizationApiService.getKeys.mockResolvedValue(
null as unknown as OrganizationKeysResponse,
);
const promise = sut.initializePasswordJitPasswordUserV2Encryption(credentials, userId);
await expect(promise).rejects.toThrow("Organization keys response is null.");
expect(organizationApiService.getKeys).toHaveBeenCalledWith(credentials.orgId);
expect(registerSdkService.registerClient$).not.toHaveBeenCalled();
});
it("should throw when organizationApiService.getKeys rejects", async () => {
const apiError = new Error("API network error");
organizationApiService.getKeys.mockRejectedValue(apiError);
const promise = sut.initializePasswordJitPasswordUserV2Encryption(credentials, userId);
await expect(promise).rejects.toThrow("API network error");
expect(registerSdkService.registerClient$).not.toHaveBeenCalled();
});
});
describe("SDK error handling", () => {
it("should throw when SDK is not available", async () => {
organizationApiService.getKeys.mockResolvedValue(orgKeys);
registerSdkService.registerClient$.mockReturnValue(
of(null) as unknown as Observable<Rc<BitwardenClient>>,
);
const promise = sut.initializePasswordJitPasswordUserV2Encryption(credentials, userId);
await expect(promise).rejects.toThrow("SDK not available");
});
it("should throw when SDK registration fails", async () => {
const sdkError = new Error("SDK crypto operation failed");
organizationApiService.getKeys.mockResolvedValue(orgKeys);
mockRegistration.mockRejectedValue(sdkError);
const promise = sut.initializePasswordJitPasswordUserV2Encryption(credentials, userId);
await expect(promise).rejects.toThrow("SDK crypto operation failed");
});
});
it("should throw when account_cryptographic_state is not V2", async () => {
const invalidResult = {
...sdkRegistrationResult,
account_cryptographic_state: { V1: {} } as unknown as WrappedAccountCryptographicState,
};
mockRegistration.mockResolvedValue(invalidResult);
const promise = sut.initializePasswordJitPasswordUserV2Encryption(credentials, userId);
await expect(promise).rejects.toThrow("Unexpected V2 account cryptographic state");
});
});
});

View File

@@ -21,14 +21,16 @@ import { MasterPasswordPolicyOptions } from "@bitwarden/common/admin-console/mod
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { SsoLoginServiceAbstraction } from "@bitwarden/common/auth/abstractions/sso-login.service.abstraction";
import { ForceSetPasswordReason } from "@bitwarden/common/auth/models/domain/force-set-password-reason";
import { assertTruthy, assertNonNullish } from "@bitwarden/common/auth/utils";
import { assertNonNullish, assertTruthy } from "@bitwarden/common/auth/utils";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/key-management/master-password/abstractions/master-password.service.abstraction";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service";
import { ValidationService } from "@bitwarden/common/platform/abstractions/validation.service";
import { SyncService } from "@bitwarden/common/platform/sync";
import { UserId } from "@bitwarden/common/types/guid";
import { OrganizationId, UserId } from "@bitwarden/common/types/guid";
import {
AnonLayoutWrapperDataService,
ButtonModule,
@@ -39,6 +41,7 @@ import {
import { I18nPipe } from "@bitwarden/ui-common";
import {
InitializeJitPasswordCredentials,
SetInitialPasswordCredentials,
SetInitialPasswordService,
SetInitialPasswordTdeOffboardingCredentials,
@@ -86,6 +89,7 @@ export class SetInitialPasswordComponent implements OnInit {
private syncService: SyncService,
private toastService: ToastService,
private validationService: ValidationService,
private configService: ConfigService,
) {}
async ngOnInit() {
@@ -101,6 +105,51 @@ export class SetInitialPasswordComponent implements OnInit {
this.initializing = false;
}
protected async handlePasswordFormSubmit(passwordInputResult: PasswordInputResult) {
this.submitting = true;
switch (this.userType) {
case SetInitialPasswordUserType.JIT_PROVISIONED_MP_ORG_USER: {
const accountEncryptionV2 = await this.configService.getFeatureFlag(
FeatureFlag.EnableAccountEncryptionV2JitPasswordRegistration,
);
if (accountEncryptionV2) {
await this.setInitialPasswordJitMPUserV2Encryption(passwordInputResult);
return;
}
await this.setInitialPassword(passwordInputResult);
break;
}
case SetInitialPasswordUserType.TDE_ORG_USER_RESET_PASSWORD_PERMISSION_REQUIRES_MP:
await this.setInitialPassword(passwordInputResult);
break;
case SetInitialPasswordUserType.OFFBOARDED_TDE_ORG_USER:
await this.setInitialPasswordTdeOffboarding(passwordInputResult);
break;
default:
this.logService.error(
`Unexpected user type: ${this.userType}. Could not set initial password.`,
);
this.validationService.showError("Unexpected user type. Could not set initial password.");
}
}
protected async logout() {
const confirmed = await this.dialogService.openSimpleDialog({
title: { key: "logOut" },
content: { key: "logOutConfirmation" },
acceptButtonText: { key: "logOut" },
type: "warning",
});
if (confirmed) {
this.messagingService.send("logout");
}
}
private async establishUserType() {
if (!this.userId) {
throw new Error("userId not found. Could not determine user type.");
@@ -189,22 +238,39 @@ export class SetInitialPasswordComponent implements OnInit {
}
}
protected async handlePasswordFormSubmit(passwordInputResult: PasswordInputResult) {
this.submitting = true;
private async setInitialPasswordJitMPUserV2Encryption(passwordInputResult: PasswordInputResult) {
const ctx = "Could not set initial password for SSO JIT master password encryption user.";
assertTruthy(passwordInputResult.newPassword, "newPassword", ctx);
assertTruthy(passwordInputResult.salt, "salt", ctx);
assertTruthy(this.orgSsoIdentifier, "orgSsoIdentifier", ctx);
assertTruthy(this.orgId, "orgId", ctx);
assertTruthy(this.userId, "userId", ctx);
assertNonNullish(passwordInputResult.newPasswordHint, "newPasswordHint", ctx); // can have an empty string as a valid value, so check non-nullish
assertNonNullish(this.resetPasswordAutoEnroll, "resetPasswordAutoEnroll", ctx); // can have `false` as a valid value, so check non-nullish
switch (this.userType) {
case SetInitialPasswordUserType.JIT_PROVISIONED_MP_ORG_USER:
case SetInitialPasswordUserType.TDE_ORG_USER_RESET_PASSWORD_PERMISSION_REQUIRES_MP:
await this.setInitialPassword(passwordInputResult);
break;
case SetInitialPasswordUserType.OFFBOARDED_TDE_ORG_USER:
await this.setInitialPasswordTdeOffboarding(passwordInputResult);
break;
default:
this.logService.error(
`Unexpected user type: ${this.userType}. Could not set initial password.`,
);
this.validationService.showError("Unexpected user type. Could not set initial password.");
try {
const credentials: InitializeJitPasswordCredentials = {
newPasswordHint: passwordInputResult.newPasswordHint,
orgSsoIdentifier: this.orgSsoIdentifier,
orgId: this.orgId as OrganizationId,
resetPasswordAutoEnroll: this.resetPasswordAutoEnroll,
newPassword: passwordInputResult.newPassword,
salt: passwordInputResult.salt,
};
await this.setInitialPasswordService.initializePasswordJitPasswordUserV2Encryption(
credentials,
this.userId,
);
this.showSuccessToastByUserType();
this.submitting = false;
await this.router.navigate(["vault"]);
} catch (e) {
this.logService.error("Error setting initial password", e);
this.validationService.showError(e);
this.submitting = false;
}
}
@@ -307,17 +373,4 @@ export class SetInitialPasswordComponent implements OnInit {
});
}
}
protected async logout() {
const confirmed = await this.dialogService.openSimpleDialog({
title: { key: "logOut" },
content: { key: "logOutConfirmation" },
acceptButtonText: { key: "logOut" },
type: "warning",
});
if (confirmed) {
this.messagingService.send("logout");
}
}
}

View File

@@ -1,5 +1,5 @@
import { MasterPasswordSalt } from "@bitwarden/common/key-management/master-password/types/master-password.types";
import { UserId } from "@bitwarden/common/types/guid";
import { OrganizationId, UserId } from "@bitwarden/common/types/guid";
import { MasterKey } from "@bitwarden/common/types/key";
import { KdfConfig } from "@bitwarden/key-management";
@@ -61,6 +61,24 @@ export interface SetInitialPasswordTdeOffboardingCredentials {
newPasswordHint: string;
}
/**
* Credentials required to initialize a just-in-time (JIT) provisioned user with a master password.
*/
export interface InitializeJitPasswordCredentials {
/** Hint for the new master password */
newPasswordHint: string;
/** SSO identifier for the organization */
orgSsoIdentifier: string;
/** Organization ID */
orgId: OrganizationId;
/** Whether to auto-enroll the user in account recovery (reset password) */
resetPasswordAutoEnroll: boolean;
/** The new master password */
newPassword: string;
/** Master password salt (typically the user's email) */
salt: MasterPasswordSalt;
}
/**
* Handles setting an initial password for an existing authed user.
*
@@ -95,4 +113,14 @@ export abstract class SetInitialPasswordService {
credentials: SetInitialPasswordTdeOffboardingCredentials,
userId: UserId,
) => Promise<void>;
/**
* Initializes a JIT-provisioned user's cryptographic state and enrolls them in master password unlock.
* @param credentials The credentials needed to initialize the JIT password user
* @param userId The account userId
*/
abstract initializePasswordJitPasswordUserV2Encryption(
credentials: InitializeJitPasswordCredentials,
userId: UserId,
): Promise<void>;
}

View File

@@ -108,7 +108,7 @@ import { UserVerificationService as UserVerificationServiceAbstraction } from "@
import { WebAuthnLoginApiServiceAbstraction } from "@bitwarden/common/auth/abstractions/webauthn/webauthn-login-api.service.abstraction";
import { WebAuthnLoginPrfKeyServiceAbstraction } from "@bitwarden/common/auth/abstractions/webauthn/webauthn-login-prf-key.service.abstraction";
import { WebAuthnLoginServiceAbstraction } from "@bitwarden/common/auth/abstractions/webauthn/webauthn-login.service.abstraction";
import { SendTokenService, DefaultSendTokenService } from "@bitwarden/common/auth/send-access";
import { DefaultSendTokenService, SendTokenService } from "@bitwarden/common/auth/send-access";
import { AccountApiServiceImplementation } from "@bitwarden/common/auth/services/account-api.service";
import { AccountServiceImplementation } from "@bitwarden/common/auth/services/account.service";
import { AnonymousHubService } from "@bitwarden/common/auth/services/anonymous-hub.service";
@@ -131,10 +131,10 @@ import { WebAuthnLoginApiService } from "@bitwarden/common/auth/services/webauth
import { WebAuthnLoginPrfKeyService } from "@bitwarden/common/auth/services/webauthn-login/webauthn-login-prf-key.service";
import { WebAuthnLoginService } from "@bitwarden/common/auth/services/webauthn-login/webauthn-login.service";
import {
TwoFactorApiService,
DefaultTwoFactorApiService,
TwoFactorService,
DefaultTwoFactorService,
TwoFactorApiService,
TwoFactorService,
} from "@bitwarden/common/auth/two-factor";
import {
AutofillSettingsService,
@@ -208,8 +208,8 @@ import { PinService } from "@bitwarden/common/key-management/pin/pin.service.imp
import { SecurityStateService } from "@bitwarden/common/key-management/security-state/abstractions/security-state.service";
import { DefaultSecurityStateService } from "@bitwarden/common/key-management/security-state/services/security-state.service";
import {
SendPasswordService,
DefaultSendPasswordService,
SendPasswordService,
} from "@bitwarden/common/key-management/sends";
import { SessionTimeoutTypeService } from "@bitwarden/common/key-management/session-timeout";
import {
@@ -389,12 +389,12 @@ import { SafeInjectionToken } from "@bitwarden/ui-common";
// eslint-disable-next-line no-restricted-imports
import { PasswordRepromptService } from "@bitwarden/vault";
import {
DefaultVaultExportApiService,
IndividualVaultExportService,
IndividualVaultExportServiceAbstraction,
DefaultVaultExportApiService,
VaultExportApiService,
OrganizationVaultExportService,
OrganizationVaultExportServiceAbstraction,
VaultExportApiService,
VaultExportService,
VaultExportServiceAbstraction,
} from "@bitwarden/vault-export-core";
@@ -1593,6 +1593,7 @@ const safeProviders: SafeProvider[] = [
OrganizationUserApiService,
InternalUserDecryptionOptionsServiceAbstraction,
AccountCryptographicStateService,
RegisterSdkService,
],
}),
safeProvider({

View File

@@ -1,8 +1,6 @@
import { Observable } from "rxjs";
// 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 { CollectionView } from "@bitwarden/admin-console/common";
import { CollectionView } from "@bitwarden/common/admin-console/models/collections";
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
import { UserId } from "@bitwarden/common/types/guid";
import { FolderView } from "@bitwarden/common/vault/models/view/folder.view";

View File

@@ -1,4 +1,8 @@
// Note: Nudge related code is exported from `libs/angular` because it is consumed by multiple
// `libs/*` packages. Exporting from the `libs/vault` package creates circular dependencies.
export { NudgesService, NudgeStatus, NudgeType } from "./services/nudges.service";
export { AUTOFILL_NUDGE_SERVICE } from "./services/nudge-injection-tokens";
export {
AUTOFILL_NUDGE_SERVICE,
AUTO_CONFIRM_NUDGE_SERVICE,
} from "./services/nudge-injection-tokens";
export { AutoConfirmNudgeService } from "./services/custom-nudges-services";

View File

@@ -0,0 +1,204 @@
# Custom Nudge Services
This folder contains custom implementations of `SingleNudgeService` that provide specialized logic for determining when nudges should be shown or dismissed.
## Architecture Overview
### Core Components
- **`NudgesService`** (`../nudges.service.ts`) - The main service that components use to check nudge status and dismiss nudges
- **`SingleNudgeService`** - Interface that all nudge services implement
- **`DefaultSingleNudgeService`** - Base implementation that stores dismissed state in user state
- **Custom nudge services** - Specialized implementations with additional logic
### How It Works
1. Components call `NudgesService.showNudgeSpotlight$()` or `showNudgeBadge$()` with a `NudgeType`
2. `NudgesService` routes to the appropriate custom nudge service (or falls back to `DefaultSingleNudgeService`)
3. The custom service returns a `NudgeStatus` indicating if the badge/spotlight should be shown
4. Custom services can combine the persisted dismissed state with dynamic conditions (e.g., account age, vault contents)
### NudgeStatus
```typescript
type NudgeStatus = {
hasBadgeDismissed: boolean; // True if the badge indicator should be hidden
hasSpotlightDismissed: boolean; // True if the spotlight/callout should be hidden
};
```
## Service Categories
### Universal Services
These services work on **all clients** (browser, web, desktop) and use `@Injectable({ providedIn: "root" })`.
| Service | Purpose |
| --------------------------------- | ---------------------------------------------------------------------- |
| `NewAccountNudgeService` | Auto-dismisses after account is 30 days old |
| `NewItemNudgeService` | Checks cipher counts for "add first item" nudges |
| `HasItemsNudgeService` | Checks if vault has items |
| `EmptyVaultNudgeService` | Checks empty vault state |
| `AccountSecurityNudgeService` | Checks security settings (PIN, biometrics) |
| `VaultSettingsImportNudgeService` | Checks import status |
| `NoOpNudgeService` | Always returns dismissed (used as fallback for client specific nudges) |
### Client-Specific Services
These services require **platform-specific features** and must be explicitly registered in each client that supports them.
| Service | Clients | Requires |
| ----------------------------- | ------------ | -------------------------------------- |
| `AutoConfirmNudgeService` | Browser only | `AutomaticUserConfirmationService` |
| `BrowserAutofillNudgeService` | Browser only | `BrowserApi` (lives in `apps/browser`) |
## Adding a New Nudge Service
### Step 1: Determine if Universal or Client-Specific
**Universal** - If your service only depends on:
- `StateProvider`
- Services available in all clients (e.g., `CipherService`, `OrganizationService`)
**Client-Specific** - If your service depends on:
- Browser APIs (`BrowserApi`, autofill services)
- Services only available in certain clients
- Platform-specific features
### Step 2: Create the Service
#### For Universal Services
```typescript
// my-nudge.service.ts
import { Injectable } from "@angular/core";
import { combineLatest, map, Observable } from "rxjs";
import { StateProvider } from "@bitwarden/common/platform/state";
import { UserId } from "@bitwarden/common/types/guid";
import { DefaultSingleNudgeService } from "../default-single-nudge.service";
import { NudgeStatus, NudgeType } from "../nudges.service";
@Injectable({ providedIn: "root" })
export class MyNudgeService extends DefaultSingleNudgeService {
constructor(
stateProvider: StateProvider,
private myDependency: MyDependency, // Must be available in all clients
) {
super(stateProvider);
}
nudgeStatus$(nudgeType: NudgeType, userId: UserId): Observable<NudgeStatus> {
return combineLatest([
this.getNudgeStatus$(nudgeType, userId), // Gets persisted dismissed state
this.myDependency.someData$,
]).pipe(
map(([persistedStatus, data]) => {
// Return dismissed if user already dismissed OR your condition is met
const autoDismiss = /* your logic */;
return {
hasBadgeDismissed: persistedStatus.hasBadgeDismissed || autoDismiss,
hasSpotlightDismissed: persistedStatus.hasSpotlightDismissed || autoDismiss,
};
}),
);
}
}
```
#### For Client-Specific Services
```typescript
// my-client-specific-nudge.service.ts
import { Injectable } from "@angular/core";
import { combineLatest, map, Observable } from "rxjs";
import { StateProvider } from "@bitwarden/common/platform/state";
import { UserId } from "@bitwarden/common/types/guid";
import { DefaultSingleNudgeService } from "../default-single-nudge.service";
import { NudgeStatus, NudgeType } from "../nudges.service";
@Injectable() // NO providedIn: "root"
export class MyClientSpecificNudgeService extends DefaultSingleNudgeService {
constructor(
stateProvider: StateProvider,
private clientSpecificService: ClientSpecificService,
) {
super(stateProvider);
}
nudgeStatus$(nudgeType: NudgeType, userId: UserId): Observable<NudgeStatus> {
return combineLatest([
this.getNudgeStatus$(nudgeType, userId),
this.clientSpecificService.someData$,
]).pipe(
map(([persistedStatus, data]) => {
const autoDismiss = /* your logic */;
return {
hasBadgeDismissed: persistedStatus.hasBadgeDismissed || autoDismiss,
hasSpotlightDismissed: persistedStatus.hasSpotlightDismissed || autoDismiss,
};
}),
);
}
}
```
### Step 3: Add NudgeType
Add your nudge type to `NudgeType` in `../nudges.service.ts`:
```typescript
export const NudgeType = {
// ... existing types
MyNewNudge: "my-new-nudge",
} as const;
```
### Step 4: Register in NudgesService
#### For Universal Services
Add to `customNudgeServices` map in `../nudges.service.ts`:
```typescript
private customNudgeServices: Partial<Record<NudgeType, SingleNudgeService>> = {
// ... existing
[NudgeType.MyNewNudge]: inject(MyNudgeService),
};
```
#### For Client-Specific Services
1. **Add injection token** in `../nudge-injection-tokens.ts`:
```typescript
export const MY_NUDGE_SERVICE = new InjectionToken<SingleNudgeService>("MyNudgeService");
```
2. **Inject with optional** in `../nudges.service.ts`:
```typescript
private myNudgeService = inject(MY_NUDGE_SERVICE, { optional: true });
private customNudgeServices = {
// ... existing
[NudgeType.MyNewNudge]: this.myNudgeService ?? this.noOpNudgeService,
};
```
3. **Register in each supporting client** (e.g., `apps/browser/src/popup/services/services.module.ts`):
```typescript
import { MY_NUDGE_SERVICE } from "@bitwarden/angular/vault";
safeProvider({
provide: MY_NUDGE_SERVICE as SafeInjectionToken<SingleNudgeService>,
useClass: MyClientSpecificNudgeService,
deps: [StateProvider, ClientSpecificService],
}),
```

View File

@@ -1,15 +1,24 @@
import { inject, Injectable } from "@angular/core";
import { Injectable } from "@angular/core";
import { combineLatest, map, Observable } from "rxjs";
import { AutomaticUserConfirmationService } from "@bitwarden/auto-confirm";
import { StateProvider } from "@bitwarden/common/platform/state";
import { UserId } from "@bitwarden/user-core";
import { DefaultSingleNudgeService } from "../default-single-nudge.service";
import { NudgeType, NudgeStatus } from "../nudges.service";
@Injectable({ providedIn: "root" })
/**
* Browser specific nudge service for auto-confirm nudge.
*/
@Injectable()
export class AutoConfirmNudgeService extends DefaultSingleNudgeService {
autoConfirmService = inject(AutomaticUserConfirmationService);
constructor(
stateProvider: StateProvider,
private autoConfirmService: AutomaticUserConfirmationService,
) {
super(stateProvider);
}
nudgeStatus$(nudgeType: NudgeType, userId: UserId): Observable<NudgeStatus> {
return combineLatest([

View File

@@ -1,10 +1,11 @@
import { Injectable, inject } from "@angular/core";
import { Injectable } from "@angular/core";
import { Observable, combineLatest, from, map, of } from "rxjs";
import { catchError } from "rxjs/operators";
import { VaultProfileService } from "@bitwarden/angular/vault/services/vault-profile.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { UserId } from "@bitwarden/common/types/guid";
import { StateProvider } from "@bitwarden/state";
import { DefaultSingleNudgeService } from "../default-single-nudge.service";
import { NudgeStatus, NudgeType } from "../nudges.service";
@@ -18,8 +19,13 @@ const THIRTY_DAYS_MS = 30 * 24 * 60 * 60 * 1000;
providedIn: "root",
})
export class NewAccountNudgeService extends DefaultSingleNudgeService {
vaultProfileService = inject(VaultProfileService);
logService = inject(LogService);
constructor(
stateProvider: StateProvider,
private vaultProfileService: VaultProfileService,
private logService: LogService,
) {
super(stateProvider);
}
nudgeStatus$(nudgeType: NudgeType, userId: UserId): Observable<NudgeStatus> {
const profileDate$ = from(this.vaultProfileService.getProfileCreationDate(userId)).pipe(

View File

@@ -1,4 +1,4 @@
import { inject, Injectable } from "@angular/core";
import { Injectable } from "@angular/core";
import { map, Observable } from "rxjs";
import { StateProvider } from "@bitwarden/common/platform/state";
@@ -22,7 +22,11 @@ export interface SingleNudgeService {
providedIn: "root",
})
export class DefaultSingleNudgeService implements SingleNudgeService {
stateProvider = inject(StateProvider);
protected stateProvider: StateProvider;
constructor(stateProvider: StateProvider) {
this.stateProvider = stateProvider;
}
protected getNudgeStatus$(nudgeType: NudgeType, userId: UserId): Observable<NudgeStatus> {
return this.stateProvider

View File

@@ -2,6 +2,25 @@ import { InjectionToken } from "@angular/core";
import { SingleNudgeService } from "./default-single-nudge.service";
/**
* Injection tokens for client specific nudge services.
*
* These services require platform-specific features and must be explicitly
* provided by each client that supports them. If not provided, NudgesService
* falls back to NoOpNudgeService.
*
* Client specific services should use constructor injection (not inject())
* to maintain safeProvider type safety.
*
* Universal services use @Injectable({ providedIn: "root" }) and can use inject().
*/
/** Browser: Requires BrowserApi */
export const AUTOFILL_NUDGE_SERVICE = new InjectionToken<SingleNudgeService>(
"AutofillNudgeService",
);
/** Browser: Requires AutomaticUserConfirmationService */
export const AUTO_CONFIRM_NUDGE_SERVICE = new InjectionToken<SingleNudgeService>(
"AutoConfirmNudgeService",
);

View File

@@ -12,11 +12,10 @@ import {
NewItemNudgeService,
AccountSecurityNudgeService,
VaultSettingsImportNudgeService,
AutoConfirmNudgeService,
NoOpNudgeService,
} from "./custom-nudges-services";
import { DefaultSingleNudgeService, SingleNudgeService } from "./default-single-nudge.service";
import { AUTOFILL_NUDGE_SERVICE } from "./nudge-injection-tokens";
import { AUTOFILL_NUDGE_SERVICE, AUTO_CONFIRM_NUDGE_SERVICE } from "./nudge-injection-tokens";
export type NudgeStatus = {
hasBadgeDismissed: boolean;
@@ -63,12 +62,21 @@ export class NudgesService {
// NoOp service that always returns dismissed
private noOpNudgeService = inject(NoOpNudgeService);
// Optional Browser-specific service provided via injection token (not all clients have autofill)
// Client specific services (optional, via injection tokens)
// These services require platform-specific features and fallback to NoOpNudgeService if not provided
private autofillNudgeService = inject(AUTOFILL_NUDGE_SERVICE, { optional: true });
private autoConfirmNudgeService = inject(AUTO_CONFIRM_NUDGE_SERVICE, { optional: true });
/**
* Custom nudge services to use for specific nudge types
* Each nudge type can have its own service to determine when to show the nudge
*
* NOTE: If a custom nudge service requires client specific services/features:
* 1. The custom nudge service must be provided via injection token and marked as optional.
* 2. The custom nudge service must be manually registered with that token in the client(s).
*
* See the README.md in the custom-nudge-services folder for more details on adding custom nudges.
* @private
*/
private customNudgeServices: Partial<Record<NudgeType, SingleNudgeService>> = {
@@ -84,7 +92,7 @@ export class NudgesService {
[NudgeType.NewIdentityItemStatus]: this.newItemNudgeService,
[NudgeType.NewNoteItemStatus]: this.newItemNudgeService,
[NudgeType.NewSshItemStatus]: this.newItemNudgeService,
[NudgeType.AutoConfirmNudge]: inject(AutoConfirmNudgeService),
[NudgeType.AutoConfirmNudge]: this.autoConfirmNudgeService ?? this.noOpNudgeService,
};
/**

View File

@@ -2,9 +2,10 @@
// @ts-strict-ignore
import { Directive, EventEmitter, Input, Output } from "@angular/core";
// 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 { CollectionTypes, CollectionView } from "@bitwarden/admin-console/common";
import {
CollectionView,
CollectionTypes,
} from "@bitwarden/common/admin-console/models/collections";
import { ITreeNodeObject } from "@bitwarden/common/vault/models/domain/tree-node";
import { DynamicTreeNode } from "../models/dynamic-tree-node.model";

View File

@@ -3,9 +3,7 @@
import { Directive, EventEmitter, Input, OnInit, Output } from "@angular/core";
import { firstValueFrom, Observable } from "rxjs";
// 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 { CollectionView } from "@bitwarden/admin-console/common";
import { CollectionView } from "@bitwarden/common/admin-console/models/collections";
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { getUserId } from "@bitwarden/common/auth/services/account.service";

View File

@@ -3,14 +3,14 @@ import { firstValueFrom, from, map, mergeMap, Observable, switchMap, take } from
// 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 {
CollectionService,
CollectionTypes,
CollectionView,
} from "@bitwarden/admin-console/common";
import { CollectionService } from "@bitwarden/admin-console/common";
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 {
CollectionView,
CollectionTypes,
} from "@bitwarden/common/admin-console/models/collections";
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { getUserId } from "@bitwarden/common/auth/services/account.service";

View File

@@ -1,12 +1,11 @@
// 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 { CreateCollectionRequest, UpdateCollectionRequest } from "@bitwarden/admin-console/common";
import {
CollectionAccessDetailsResponse,
CollectionDetailsResponse,
CollectionResponse,
CreateCollectionRequest,
UpdateCollectionRequest,
} from "@bitwarden/admin-console/common";
} from "@bitwarden/common/admin-console/models/collections";
import { OrganizationConnectionType } from "../admin-console/enums";
import { OrganizationSponsorshipCreateRequest } from "../admin-console/models/request/organization/organization-sponsorship-create.request";

View File

@@ -1,9 +1,9 @@
import { CollectionAccessSelectionView } from "@bitwarden/common/admin-console/models/collections";
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service";
import { EncString } from "@bitwarden/common/key-management/crypto/models/enc-string";
import { OrgKey } from "@bitwarden/common/types/key";
import { CollectionAccessSelectionView } from "./collection-access-selection.view";
import { CollectionAccessDetailsResponse, CollectionResponse } from "./collection.response";
import { CollectionView } from "./collection.view";
@@ -121,13 +121,13 @@ export class CollectionAdminView extends CollectionView {
try {
view.name = await encryptService.decryptString(new EncString(view.name), orgKey);
} catch (e) {
view.name = "[error: cannot decrypt]";
// Note: This should be replaced by the owning team with appropriate, domain-specific behavior.
// eslint-disable-next-line no-console
console.error(
"[CollectionAdminView/fromCollectionAccessDetails] Error decrypting collection name",
e,
);
throw e;
}
view.assigned = collection.assigned;
view.readOnly = collection.readOnly;

View File

@@ -1,10 +1,12 @@
import { Jsonify } from "type-fest";
import {
CollectionDetailsResponse,
CollectionType,
CollectionTypes,
} from "@bitwarden/common/admin-console/models/collections";
import { CollectionId, OrganizationId } from "@bitwarden/common/types/guid";
import { CollectionType, CollectionTypes } from "./collection";
import { CollectionDetailsResponse } from "./collection.response";
export class CollectionData {
id: CollectionId;
organizationId: OrganizationId;

View File

@@ -1,9 +1,11 @@
import {
CollectionType,
CollectionTypes,
} from "@bitwarden/common/admin-console/models/collections";
import { SelectionReadOnlyResponse } from "@bitwarden/common/admin-console/models/response/selection-read-only.response";
import { BaseResponse } from "@bitwarden/common/models/response/base.response";
import { CollectionId, OrganizationId } from "@bitwarden/common/types/guid";
import { CollectionType, CollectionTypes } from "./collection";
export class CollectionResponse extends BaseResponse {
id: CollectionId;
organizationId: OrganizationId;

View File

@@ -1,3 +1,4 @@
import { CollectionView } from "@bitwarden/common/admin-console/models/collections";
import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service";
import { EncString } from "@bitwarden/common/key-management/crypto/models/enc-string";
import Domain from "@bitwarden/common/platform/models/domain/domain-base";
@@ -5,7 +6,6 @@ import { CollectionId, OrganizationId } from "@bitwarden/common/types/guid";
import { OrgKey } from "@bitwarden/common/types/key";
import { CollectionData } from "./collection.data";
import { CollectionView } from "./collection.view";
export const CollectionTypes = {
SharedCollection: 0,

View File

@@ -126,7 +126,14 @@ export class CollectionView implements View, ITreeNodeObject {
): Promise<CollectionView> {
const view = new CollectionView({ ...collection, name: "" });
view.name = await encryptService.decryptString(collection.name, key);
try {
view.name = await encryptService.decryptString(collection.name, key);
} catch (e) {
view.name = "[error: cannot decrypt]";
// eslint-disable-next-line no-console
console.error("[CollectionView] Error decrypting collection name", e);
}
view.assigned = true;
view.externalId = collection.externalId;
view.readOnly = collection.readOnly;

View File

@@ -0,0 +1,6 @@
export * from "./collection-access-selection.view";
export * from "./collection-admin.view";
export * from "./collection.view";
export * from "./collection.response";
export * from "./collection";
export * from "./collection.data";

View File

@@ -1,5 +1,7 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
export class ProviderUserConfirmRequest {
key: string;
protected key: string;
constructor(key: string) {
this.key = key;
}
}

View File

@@ -1,8 +1,6 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
// 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 { CollectionResponse } from "@bitwarden/admin-console/common";
import { CollectionResponse } from "@bitwarden/common/admin-console/models/collections";
import { BaseResponse } from "../../../models/response/base.response";
import { CipherResponse } from "../../../vault/models/response/cipher.response";

View File

@@ -0,0 +1,120 @@
import { CollectionView } from "@bitwarden/common/admin-console/models/collections";
import { CollectionId, OrganizationId } from "@bitwarden/common/types/guid";
import { TreeNode } from "@bitwarden/common/vault/models/domain/tree-node";
import { newGuid } from "@bitwarden/guid";
import { getNestedCollectionTree, getFlatCollectionTree } from "./collection-utils";
describe("CollectionUtils Service", () => {
describe("getNestedCollectionTree", () => {
it("should return collections properly sorted if provided out of order", () => {
// Arrange
const collections: CollectionView[] = [];
const parentCollection = new CollectionView({
name: "Parent",
organizationId: "orgId" as OrganizationId,
id: newGuid() as CollectionId,
});
const childCollection = new CollectionView({
name: "Parent/Child",
organizationId: "orgId" as OrganizationId,
id: newGuid() as CollectionId,
});
collections.push(childCollection);
collections.push(parentCollection);
// Act
const result = getNestedCollectionTree(collections);
// Assert
expect(result[0].node.name).toBe("Parent");
expect(result[0].children[0].node.name).toBe("Child");
});
it("should return an empty array if no collections are provided", () => {
// Arrange
const collections: CollectionView[] = [];
// Act
const result = getNestedCollectionTree(collections);
// Assert
expect(result).toEqual([]);
});
});
describe("getFlatCollectionTree", () => {
it("should flatten a tree node with no children", () => {
// Arrange
const collection = new CollectionView({
name: "Test Collection",
id: "test-id" as CollectionId,
organizationId: "orgId" as OrganizationId,
});
const treeNodes: TreeNode<CollectionView>[] = [
new TreeNode<CollectionView>(collection, {} as TreeNode<CollectionView>),
];
// Act
const result = getFlatCollectionTree(treeNodes);
// Assert
expect(result.length).toBe(1);
expect(result[0]).toBe(collection);
});
it("should flatten a tree node with children", () => {
// Arrange
const parentCollection = new CollectionView({
name: "Parent",
id: "parent-id" as CollectionId,
organizationId: "orgId" as OrganizationId,
});
const child1Collection = new CollectionView({
name: "Child 1",
id: "child1-id" as CollectionId,
organizationId: "orgId" as OrganizationId,
});
const child2Collection = new CollectionView({
name: "Child 2",
id: "child2-id" as CollectionId,
organizationId: "orgId" as OrganizationId,
});
const grandchildCollection = new CollectionView({
name: "Grandchild",
id: "grandchild-id" as CollectionId,
organizationId: "orgId" as OrganizationId,
});
const parentNode = new TreeNode<CollectionView>(
parentCollection,
{} as TreeNode<CollectionView>,
);
const child1Node = new TreeNode<CollectionView>(child1Collection, parentNode);
const child2Node = new TreeNode<CollectionView>(child2Collection, parentNode);
const grandchildNode = new TreeNode<CollectionView>(grandchildCollection, child1Node);
parentNode.children = [child1Node, child2Node];
child1Node.children = [grandchildNode];
const treeNodes: TreeNode<CollectionView>[] = [parentNode];
// Act
const result = getFlatCollectionTree(treeNodes);
// Assert
expect(result.length).toBe(4);
expect(result[0]).toBe(parentCollection);
expect(result).toContain(child1Collection);
expect(result).toContain(child2Collection);
expect(result).toContain(grandchildCollection);
});
});
});

View File

@@ -0,0 +1,87 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import {
CollectionView,
NestingDelimiter,
CollectionAdminView,
} from "@bitwarden/common/admin-console/models/collections";
import { OrganizationId } from "@bitwarden/common/types/guid";
import { TreeNode } from "@bitwarden/common/vault/models/domain/tree-node";
import { ServiceUtils } from "@bitwarden/common/vault/service-utils";
export function getNestedCollectionTree(
collections: CollectionAdminView[],
): TreeNode<CollectionAdminView>[];
export function getNestedCollectionTree(collections: CollectionView[]): TreeNode<CollectionView>[];
export function getNestedCollectionTree(
collections: (CollectionView | CollectionAdminView)[],
): TreeNode<CollectionView | CollectionAdminView>[] {
if (!collections) {
return [];
}
// Collections need to be cloned because ServiceUtils.nestedTraverse actively
// modifies the names of collections.
// These changes risk affecting collections store in StateService.
const clonedCollections: CollectionView[] | CollectionAdminView[] = collections
.sort((a, b) => a.name.localeCompare(b.name))
.map(cloneCollection);
const all: TreeNode<CollectionView | CollectionAdminView>[] = [];
const groupedByOrg = new Map<OrganizationId, (CollectionView | CollectionAdminView)[]>();
clonedCollections.map((c) => {
const key = c.organizationId;
(groupedByOrg.get(key) ?? groupedByOrg.set(key, []).get(key)!).push(c);
});
for (const group of groupedByOrg.values()) {
const nodes: TreeNode<CollectionView | CollectionAdminView>[] = [];
for (const c of group) {
const parts = c.name ? c.name.replace(/^\/+|\/+$/g, "").split(NestingDelimiter) : [];
ServiceUtils.nestedTraverse(nodes, 0, parts, c, undefined, NestingDelimiter);
}
all.push(...nodes);
}
return all;
}
export function cloneCollection(collection: CollectionView): CollectionView;
export function cloneCollection(collection: CollectionAdminView): CollectionAdminView;
export function cloneCollection(
collection: CollectionView | CollectionAdminView,
): CollectionView | CollectionAdminView {
let cloned;
if (collection instanceof CollectionAdminView) {
cloned = Object.assign(
new CollectionAdminView({ ...collection, name: collection.name }),
collection,
);
} else {
cloned = Object.assign(
new CollectionView({ ...collection, name: collection.name }),
collection,
);
}
return cloned;
}
export function getFlatCollectionTree(
nodes: TreeNode<CollectionAdminView>[],
): CollectionAdminView[];
export function getFlatCollectionTree(nodes: TreeNode<CollectionView>[]): CollectionView[];
export function getFlatCollectionTree(
nodes: TreeNode<CollectionView | CollectionAdminView>[],
): (CollectionView | CollectionAdminView)[] {
if (!nodes || nodes.length === 0) {
return [];
}
return nodes.flatMap((node) => {
if (!node.children || node.children.length === 0) {
return [node.node];
}
const children = getFlatCollectionTree(node.children);
return [node.node, ...children];
});
}

View File

@@ -0,0 +1 @@
export * from "./collection-utils";

View File

@@ -8,6 +8,7 @@ import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abs
import { ProductTierType } from "@bitwarden/common/billing/enums";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { LogService } from "@bitwarden/logging";
import { FakeAccountService, FakeStateProvider, mockAccountServiceWith } from "../../../../spec";
import { UserId } from "../../../types/guid";
@@ -54,6 +55,8 @@ describe("PhishingDetectionSettingsService", () => {
usePhishingBlocker: true,
});
const mockLogService = mock<LogService>();
const mockUserId = "mock-user-id" as UserId;
const account = mock<Account>({ id: mockUserId });
const accountService: FakeAccountService = mockAccountServiceWith(mockUserId);
@@ -85,6 +88,7 @@ describe("PhishingDetectionSettingsService", () => {
mockAccountService,
mockBillingService,
mockConfigService,
mockLogService,
mockOrganizationService,
mockPlatformService,
stateProvider,

View File

@@ -1,5 +1,5 @@
import { combineLatest, Observable, of, switchMap } from "rxjs";
import { catchError, distinctUntilChanged, map, shareReplay } from "rxjs/operators";
import { catchError, distinctUntilChanged, map, shareReplay, tap } 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";
@@ -9,6 +9,7 @@ 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 { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { LogService } from "@bitwarden/logging";
import { UserId } from "@bitwarden/user-core";
import { PHISHING_DETECTION_DISK, StateProvider, UserKeyDefinition } from "../../../platform/state";
@@ -32,27 +33,47 @@ export class PhishingDetectionSettingsService implements PhishingDetectionSettin
private accountService: AccountService,
private billingService: BillingAccountProfileStateService,
private configService: ConfigService,
private logService: LogService,
private organizationService: OrganizationService,
private platformService: PlatformUtilsService,
private stateProvider: StateProvider,
) {
this.logService.debug(`[PhishingDetectionSettingsService] Initializing service...`);
this.available$ = this.buildAvailablePipeline$().pipe(
distinctUntilChanged(),
tap((available) =>
this.logService.debug(
`[PhishingDetectionSettingsService] Phishing detection available: ${available}`,
),
),
shareReplay({ bufferSize: 1, refCount: true }),
);
this.enabled$ = this.buildEnabledPipeline$().pipe(
distinctUntilChanged(),
tap((enabled) =>
this.logService.debug(
`[PhishingDetectionSettingsService] Phishing detection enabled: ${{ enabled }}`,
),
),
shareReplay({ bufferSize: 1, refCount: true }),
);
this.on$ = combineLatest([this.available$, this.enabled$]).pipe(
map(([available, enabled]) => available && enabled),
distinctUntilChanged(),
shareReplay({ bufferSize: 1, refCount: true }),
tap((on) =>
this.logService.debug(
`[PhishingDetectionSettingsService] Phishing detection is on: ${{ on }}`,
),
),
shareReplay({ bufferSize: 1, refCount: false }),
);
}
async setEnabled(userId: UserId, enabled: boolean): Promise<void> {
this.logService.debug(
`[PhishingDetectionSettingsService] Setting phishing detection enabled: ${{ enabled, userId }}`,
);
await this.stateProvider.getUser(userId, ENABLE_PHISHING_DETECTION).update(() => enabled);
}
@@ -64,6 +85,9 @@ export class PhishingDetectionSettingsService implements PhishingDetectionSettin
private buildAvailablePipeline$(): Observable<boolean> {
// Phishing detection is unavailable on Safari due to platform limitations.
if (this.platformService.isSafari()) {
this.logService.warning(
`[PhishingDetectionSettingsService] Phishing detection is unavailable on Safari due to platform limitations`,
);
return of(false);
}
@@ -97,6 +121,9 @@ export class PhishingDetectionSettingsService implements PhishingDetectionSettin
if (!account) {
return of(false);
}
this.logService.debug(
`[PhishingDetectionSettingsService] Refreshing phishing detection enabled state`,
);
return this.stateProvider.getUserState$(ENABLE_PHISHING_DETECTION, account.id);
}),
map((enabled) => enabled ?? true),

View File

@@ -14,6 +14,7 @@ export enum FeatureFlag {
AutoConfirm = "pm-19934-auto-confirm-organization-users",
BlockClaimedDomainAccountCreation = "pm-28297-block-uninvited-claimed-domain-registration",
IncreaseBulkReinviteLimitForCloud = "pm-28251-increase-bulk-reinvite-limit-for-cloud",
MembersComponentRefactor = "pm-29503-refactor-members-inheritance",
/* Auth */
PM23801_PrefetchPasswordPrelogin = "pm-23801-prefetch-password-prelogin",
@@ -22,6 +23,7 @@ export enum FeatureFlag {
MacOsNativeCredentialSync = "macos-native-credential-sync",
WindowsDesktopAutotype = "windows-desktop-autotype",
WindowsDesktopAutotypeGA = "windows-desktop-autotype-ga",
SSHAgentV2 = "ssh-agent-v2",
/* Billing */
TrialPaymentOptional = "PM-8163-trial-payment",
@@ -45,9 +47,9 @@ export enum FeatureFlag {
ConsolidatedSessionTimeoutComponent = "pm-26056-consolidated-session-timeout-component",
PM27279_V2RegistrationTdeJit = "pm-27279-v2-registration-tde-jit",
EnableAccountEncryptionV2KeyConnectorRegistration = "enable-account-encryption-v2-key-connector-registration",
EnableAccountEncryptionV2JitPasswordRegistration = "enable-account-encryption-v2-jit-password-registration",
/* Tools */
DesktopSendUIRefresh = "desktop-send-ui-refresh",
UseSdkPasswordGenerators = "pm-19976-use-sdk-password-generators",
ChromiumImporterWithABE = "pm-25855-chromium-importer-abe",
SendUIRefresh = "pm-28175-send-ui-refresh",
@@ -76,6 +78,7 @@ export enum FeatureFlag {
/* Desktop */
DesktopUiMigrationMilestone1 = "desktop-ui-migration-milestone-1",
DesktopUiMigrationMilestone2 = "desktop-ui-migration-milestone-2",
/* UIF */
RouterFocusManagement = "router-focus-management",
@@ -102,14 +105,15 @@ export const DefaultFeatureFlagValue = {
[FeatureFlag.AutoConfirm]: FALSE,
[FeatureFlag.BlockClaimedDomainAccountCreation]: FALSE,
[FeatureFlag.IncreaseBulkReinviteLimitForCloud]: FALSE,
[FeatureFlag.MembersComponentRefactor]: FALSE,
/* Autofill */
[FeatureFlag.MacOsNativeCredentialSync]: FALSE,
[FeatureFlag.WindowsDesktopAutotype]: FALSE,
[FeatureFlag.WindowsDesktopAutotypeGA]: FALSE,
[FeatureFlag.SSHAgentV2]: FALSE,
/* Tools */
[FeatureFlag.DesktopSendUIRefresh]: FALSE,
[FeatureFlag.UseSdkPasswordGenerators]: FALSE,
[FeatureFlag.ChromiumImporterWithABE]: FALSE,
[FeatureFlag.SendUIRefresh]: FALSE,
@@ -155,6 +159,7 @@ export const DefaultFeatureFlagValue = {
[FeatureFlag.ConsolidatedSessionTimeoutComponent]: FALSE,
[FeatureFlag.PM27279_V2RegistrationTdeJit]: FALSE,
[FeatureFlag.EnableAccountEncryptionV2KeyConnectorRegistration]: FALSE,
[FeatureFlag.EnableAccountEncryptionV2JitPasswordRegistration]: FALSE,
/* Platform */
[FeatureFlag.IpcChannelFramework]: FALSE,
@@ -164,6 +169,7 @@ export const DefaultFeatureFlagValue = {
/* Desktop */
[FeatureFlag.DesktopUiMigrationMilestone1]: FALSE,
[FeatureFlag.DesktopUiMigrationMilestone2]: FALSE,
/* UIF */
[FeatureFlag.RouterFocusManagement]: FALSE,

View File

@@ -113,6 +113,23 @@ export abstract class MasterPasswordServiceAbstraction {
* @throws If the user ID is missing.
*/
abstract userHasMasterPassword(userId: UserId): Promise<boolean>;
/**
* Derives a master key from the provided password and master password unlock data,
* then sets it to state for the specified user. This is a temporary backwards compatibility function
* to support existing code that relies on direct master key access.
* Note: This will be removed in https://bitwarden.atlassian.net/browse/PM-30676
*
* @param password The master password.
* @param masterPasswordUnlockData The master password unlock data containing the KDF settings and salt.
* @param userId The user ID.
* @throws If the password, master password unlock data, or user ID is missing.
*/
abstract setLegacyMasterKeyFromUnlockData(
password: string,
masterPasswordUnlockData: MasterPasswordUnlockData,
userId: UserId,
): Promise<void>;
}
export abstract class InternalMasterPasswordServiceAbstraction extends MasterPasswordServiceAbstraction {

View File

@@ -127,4 +127,12 @@ export class FakeMasterPasswordService implements InternalMasterPasswordServiceA
masterPasswordUnlockData$(userId: UserId): Observable<MasterPasswordUnlockData | null> {
return this.mock.masterPasswordUnlockData$(userId);
}
setLegacyMasterKeyFromUnlockData(
password: string,
masterPasswordUnlockData: MasterPasswordUnlockData,
userId: UserId,
): Promise<void> {
return this.mock.setLegacyMasterKeyFromUnlockData(password, masterPasswordUnlockData, userId);
}
}

View File

@@ -3,6 +3,7 @@ import { firstValueFrom } from "rxjs";
import { Jsonify } from "type-fest";
import { SdkLoadService } from "@bitwarden/common/platform/abstractions/sdk/sdk-load.service";
import { HashPurpose } from "@bitwarden/common/platform/enums";
import { Utils } from "@bitwarden/common/platform/misc/utils";
// eslint-disable-next-line no-restricted-imports
import { Argon2KdfConfig, KdfConfig, KdfType, PBKDF2KdfConfig } from "@bitwarden/key-management";
@@ -415,6 +416,125 @@ describe("MasterPasswordService", () => {
);
});
describe("setLegacyMasterKeyFromUnlockData", () => {
const password = "test-password";
it("derives master key from password and sets it in state", async () => {
const masterKey = makeSymmetricCryptoKey(32, 5) as MasterKey;
keyGenerationService.deriveKeyFromPassword.mockResolvedValue(masterKey);
cryptoFunctionService.pbkdf2.mockResolvedValue(new Uint8Array(32));
const masterPasswordUnlockData = new MasterPasswordUnlockData(
salt,
kdfPBKDF2,
makeEncString().toSdk() as MasterKeyWrappedUserKey,
);
await sut.setLegacyMasterKeyFromUnlockData(password, masterPasswordUnlockData, userId);
expect(keyGenerationService.deriveKeyFromPassword).toHaveBeenCalledWith(
password,
masterPasswordUnlockData.salt,
masterPasswordUnlockData.kdf,
);
const state = await firstValueFrom(stateProvider.getUser(userId, MASTER_KEY).state$);
expect(state).toEqual(masterKey);
});
it("works with argon2 kdf config", async () => {
const masterKey = makeSymmetricCryptoKey(32, 6) as MasterKey;
keyGenerationService.deriveKeyFromPassword.mockResolvedValue(masterKey);
cryptoFunctionService.pbkdf2.mockResolvedValue(new Uint8Array(32));
const masterPasswordUnlockData = new MasterPasswordUnlockData(
salt,
kdfArgon2,
makeEncString().toSdk() as MasterKeyWrappedUserKey,
);
await sut.setLegacyMasterKeyFromUnlockData(password, masterPasswordUnlockData, userId);
expect(keyGenerationService.deriveKeyFromPassword).toHaveBeenCalledWith(
password,
masterPasswordUnlockData.salt,
masterPasswordUnlockData.kdf,
);
const state = await firstValueFrom(stateProvider.getUser(userId, MASTER_KEY).state$);
expect(state).toEqual(masterKey);
});
it("computes and sets master key hash in state", async () => {
const masterKey = makeSymmetricCryptoKey(32, 7) as MasterKey;
const expectedHashBytes = new Uint8Array([1, 2, 3, 4, 5, 6, 7, 8]);
const expectedHashB64 = "AQIDBAUGBwg=";
keyGenerationService.deriveKeyFromPassword.mockResolvedValue(masterKey);
cryptoFunctionService.pbkdf2.mockResolvedValue(expectedHashBytes);
jest.spyOn(Utils, "fromBufferToB64").mockReturnValue(expectedHashB64);
const masterPasswordUnlockData = new MasterPasswordUnlockData(
salt,
kdfPBKDF2,
makeEncString().toSdk() as MasterKeyWrappedUserKey,
);
await sut.setLegacyMasterKeyFromUnlockData(password, masterPasswordUnlockData, userId);
expect(cryptoFunctionService.pbkdf2).toHaveBeenCalledWith(
masterKey.inner().encryptionKey,
password,
"sha256",
HashPurpose.LocalAuthorization,
);
const hashState = await firstValueFrom(sut.masterKeyHash$(userId));
expect(hashState).toEqual(expectedHashB64);
});
it("throws if password is null", async () => {
const masterPasswordUnlockData = new MasterPasswordUnlockData(
salt,
kdfPBKDF2,
makeEncString().toSdk() as MasterKeyWrappedUserKey,
);
await expect(
sut.setLegacyMasterKeyFromUnlockData(
null as unknown as string,
masterPasswordUnlockData,
userId,
),
).rejects.toThrow("password is null or undefined.");
});
it("throws if masterPasswordUnlockData is null", async () => {
await expect(
sut.setLegacyMasterKeyFromUnlockData(
password,
null as unknown as MasterPasswordUnlockData,
userId,
),
).rejects.toThrow("masterPasswordUnlockData is null or undefined.");
});
it("throws if userId is null", async () => {
const masterPasswordUnlockData = new MasterPasswordUnlockData(
salt,
kdfPBKDF2,
makeEncString().toSdk() as MasterKeyWrappedUserKey,
);
await expect(
sut.setLegacyMasterKeyFromUnlockData(
password,
masterPasswordUnlockData,
null as unknown as UserId,
),
).rejects.toThrow("userId is null or undefined.");
});
});
describe("MASTER_PASSWORD_UNLOCK_KEY", () => {
it("has the correct configuration", () => {
expect(MASTER_PASSWORD_UNLOCK_KEY.stateDefinition).toBeDefined();

View File

@@ -5,6 +5,7 @@ import { firstValueFrom, map, Observable } from "rxjs";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { assertNonNullish } from "@bitwarden/common/auth/utils";
import { SdkLoadService } from "@bitwarden/common/platform/abstractions/sdk/sdk-load.service";
import { HashPurpose } from "@bitwarden/common/platform/enums";
import { Utils } from "@bitwarden/common/platform/misc/utils";
// eslint-disable-next-line no-restricted-imports
import { KdfConfig } from "@bitwarden/key-management";
@@ -342,4 +343,51 @@ export class MasterPasswordService implements InternalMasterPasswordServiceAbstr
return this.stateProvider.getUser(userId, MASTER_PASSWORD_UNLOCK_KEY).state$;
}
async setLegacyMasterKeyFromUnlockData(
password: string,
masterPasswordUnlockData: MasterPasswordUnlockData,
userId: UserId,
): Promise<void> {
assertNonNullish(password, "password");
assertNonNullish(masterPasswordUnlockData, "masterPasswordUnlockData");
assertNonNullish(userId, "userId");
const masterKey = (await this.keyGenerationService.deriveKeyFromPassword(
password,
masterPasswordUnlockData.salt,
masterPasswordUnlockData.kdf,
)) as MasterKey;
const localKeyHash = await this.hashMasterKey(
password,
masterKey,
HashPurpose.LocalAuthorization,
);
await this.setMasterKey(masterKey, userId);
await this.setMasterKeyHash(localKeyHash, userId);
}
// Copied from KeyService to avoid circular dependency. This will be dropped together with `setLegacyMatserKeyFromUnlockData`.
private async hashMasterKey(
password: string,
key: MasterKey,
hashPurpose: HashPurpose,
): Promise<string> {
if (password == null) {
throw new Error("password is required.");
}
if (key == null) {
throw new Error("key is required.");
}
const iterations = hashPurpose === HashPurpose.LocalAuthorization ? 2 : 1;
const hash = await this.cryptoFunctionService.pbkdf2(
key.inner().encryptionKey,
password,
"sha256",
iterations,
);
return Utils.fromBufferToB64(hash);
}
}

View File

@@ -1,8 +1,9 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
// 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 { Collection as CollectionDomain, CollectionView } from "@bitwarden/admin-console/common";
import {
CollectionView,
Collection as CollectionDomain,
} from "@bitwarden/common/admin-console/models/collections";
import { CollectionId } from "@bitwarden/common/types/guid";
import { CollectionExport } from "./collection.export";

View File

@@ -1,8 +1,9 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
// 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 { Collection as CollectionDomain, CollectionView } from "@bitwarden/admin-console/common";
import {
CollectionView,
Collection as CollectionDomain,
} from "@bitwarden/common/admin-console/models/collections";
import { EncString } from "../../key-management/crypto/models/enc-string";
import { CollectionId, emptyGuid, OrganizationId } from "../../types/guid";

View File

@@ -136,11 +136,11 @@ export interface CreateCredentialResult {
*/
export interface AssertCredentialParams {
allowedCredentialIds: string[];
rpId: string;
rpId?: string;
origin: string;
challenge: string;
userVerification?: UserVerification;
timeout: number;
timeout?: number;
sameOriginWithAncestors: boolean;
mediation?: "silent" | "optional" | "required" | "conditional";
fallbackSupported: boolean;

View File

@@ -30,7 +30,6 @@ import {
Fido2ClientService as Fido2ClientServiceAbstraction,
PublicKeyCredentialParam,
UserRequestedFallbackAbortReason,
UserVerification,
} from "../../abstractions/fido2/fido2-client.service.abstraction";
import { LogService } from "../../abstractions/log.service";
import { Utils } from "../../misc/utils";
@@ -195,7 +194,7 @@ export class Fido2ClientService<
}
const timeoutSubscription = this.setAbortTimeout(
abortController,
params.authenticatorSelection?.userVerification,
makeCredentialParams.requireUserVerification,
params.timeout,
);
@@ -318,7 +317,7 @@ export class Fido2ClientService<
const timeoutSubscription = this.setAbortTimeout(
abortController,
params.userVerification,
getAssertionParams.requireUserVerification,
params.timeout,
);
@@ -441,13 +440,13 @@ export class Fido2ClientService<
private setAbortTimeout = (
abortController: AbortController,
userVerification?: UserVerification,
requireUserVerification: boolean,
timeout?: number,
): Subscription => {
let clampedTimeout: number;
const { WITH_VERIFICATION, NO_VERIFICATION } = this.TIMEOUTS;
if (userVerification === "required") {
if (requireUserVerification) {
timeout = timeout ?? WITH_VERIFICATION.DEFAULT;
clampedTimeout = Math.max(WITH_VERIFICATION.MIN, Math.min(timeout, WITH_VERIFICATION.MAX));
} else {

View File

@@ -4,11 +4,11 @@ import { firstValueFrom, map } from "rxjs";
// 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 { CollectionService } from "@bitwarden/admin-console/common";
import {
CollectionData,
CollectionDetailsResponse,
CollectionService,
} from "@bitwarden/admin-console/common";
CollectionData,
} from "@bitwarden/common/admin-console/models/collections";
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.

View File

@@ -1,6 +1,4 @@
// 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 { CollectionDetailsResponse } from "@bitwarden/admin-console/common";
import { CollectionDetailsResponse } from "@bitwarden/common/admin-console/models/collections";
import { PolicyResponse } from "../../admin-console/models/response/policy.response";
import { UserDecryptionResponse } from "../../key-management/models/response/user-decryption.response";

View File

@@ -4,16 +4,15 @@ import { firstValueFrom, map } from "rxjs";
// 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 { CreateCollectionRequest, UpdateCollectionRequest } from "@bitwarden/admin-console/common";
// 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 { LogoutReason } from "@bitwarden/auth/common";
import {
CollectionAccessDetailsResponse,
CollectionDetailsResponse,
CollectionResponse,
CreateCollectionRequest,
UpdateCollectionRequest,
} from "@bitwarden/admin-console/common";
// 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 { LogoutReason } from "@bitwarden/auth/common";
} from "@bitwarden/common/admin-console/models/collections";
import { ApiService as ApiServiceAbstraction } from "../abstractions/api.service";
import { OrganizationConnectionType } from "../admin-console/enums";

View File

@@ -1,6 +1,6 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import * as zxcvbn from "zxcvbn";
import zxcvbn from "zxcvbn";
import { PasswordStrengthServiceAbstraction } from "./password-strength.service.abstraction";

View File

@@ -1,5 +1,6 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { AuthType } from "../../types/auth-type";
import { SendType } from "../../types/send-type";
import { SendResponse } from "../response/send.response";
@@ -10,6 +11,7 @@ export class SendData {
id: string;
accessId: string;
type: SendType;
authType: AuthType;
name: string;
notes: string;
file: SendFileData;
@@ -33,6 +35,7 @@ export class SendData {
this.id = response.id;
this.accessId = response.accessId;
this.type = response.type;
this.authType = response.authType;
this.name = response.name;
this.notes = response.notes;
this.key = response.key;

View File

@@ -11,6 +11,7 @@ import { EncryptService } from "../../../../key-management/crypto/abstractions/e
import { SymmetricCryptoKey } from "../../../../platform/models/domain/symmetric-crypto-key";
import { ContainerService } from "../../../../platform/services/container.service";
import { UserKey } from "../../../../types/key";
import { AuthType } from "../../types/auth-type";
import { SendType } from "../../types/send-type";
import { SendData } from "../data/send.data";
@@ -25,6 +26,7 @@ describe("Send", () => {
id: "id",
accessId: "accessId",
type: SendType.Text,
authType: AuthType.None,
name: "encName",
notes: "encNotes",
text: {
@@ -55,6 +57,7 @@ describe("Send", () => {
id: null,
accessId: null,
type: undefined,
authType: undefined,
name: null,
notes: null,
text: undefined,
@@ -78,6 +81,7 @@ describe("Send", () => {
id: "id",
accessId: "accessId",
type: SendType.Text,
authType: AuthType.None,
name: { encryptedString: "encName", encryptionType: 0 },
notes: { encryptedString: "encNotes", encryptionType: 0 },
text: {
@@ -107,6 +111,7 @@ describe("Send", () => {
send.id = "id";
send.accessId = "accessId";
send.type = SendType.Text;
send.authType = AuthType.None;
send.name = mockEnc("name");
send.notes = mockEnc("notes");
send.text = text;
@@ -145,6 +150,7 @@ describe("Send", () => {
name: "name",
notes: "notes",
type: 0,
authType: 2,
key: expect.anything(),
cryptoKey: "cryptoKey",
file: expect.anything(),

View File

@@ -8,6 +8,7 @@ import { UserId } from "@bitwarden/common/types/guid";
import { EncString } from "../../../../key-management/crypto/models/enc-string";
import { Utils } from "../../../../platform/misc/utils";
import Domain from "../../../../platform/models/domain/domain-base";
import { AuthType } from "../../types/auth-type";
import { SendType } from "../../types/send-type";
import { SendData } from "../data/send.data";
import { SendView } from "../view/send.view";
@@ -19,6 +20,7 @@ export class Send extends Domain {
id: string;
accessId: string;
type: SendType;
authType: AuthType;
name: EncString;
notes: EncString;
file: SendFile;
@@ -54,6 +56,7 @@ export class Send extends Domain {
);
this.type = obj.type;
this.authType = obj.authType;
this.maxAccessCount = obj.maxAccessCount;
this.accessCount = obj.accessCount;
this.password = obj.password;

View File

@@ -1,6 +1,7 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { BaseResponse } from "../../../../models/response/base.response";
import { AuthType } from "../../types/auth-type";
import { SendType } from "../../types/send-type";
import { SendFileApi } from "../api/send-file.api";
import { SendTextApi } from "../api/send-text.api";
@@ -9,6 +10,7 @@ export class SendResponse extends BaseResponse {
id: string;
accessId: string;
type: SendType;
authType: AuthType;
name: string;
notes: string;
file: SendFileApi;
@@ -29,6 +31,7 @@ export class SendResponse extends BaseResponse {
this.id = this.getResponseProperty("Id");
this.accessId = this.getResponseProperty("AccessId");
this.type = this.getResponseProperty("Type");
this.authType = this.getResponseProperty("AuthType");
this.name = this.getResponseProperty("Name");
this.notes = this.getResponseProperty("Notes");
this.key = this.getResponseProperty("Key");

View File

@@ -4,6 +4,7 @@ import { View } from "../../../../models/view/view";
import { Utils } from "../../../../platform/misc/utils";
import { SymmetricCryptoKey } from "../../../../platform/models/domain/symmetric-crypto-key";
import { DeepJsonify } from "../../../../types/deep-jsonify";
import { AuthType } from "../../types/auth-type";
import { SendType } from "../../types/send-type";
import { Send } from "../domain/send";
@@ -18,6 +19,7 @@ export class SendView implements View {
key: Uint8Array;
cryptoKey: SymmetricCryptoKey;
type: SendType = null;
authType: AuthType = null;
text = new SendTextView();
file = new SendFileView();
maxAccessCount?: number = null;
@@ -38,6 +40,7 @@ export class SendView implements View {
this.id = s.id;
this.accessId = s.accessId;
this.type = s.type;
this.authType = s.authType;
this.maxAccessCount = s.maxAccessCount;
this.accessCount = s.accessCount;
this.revisionDate = s.revisionDate;

View File

@@ -0,0 +1,12 @@
/** An type of auth necessary to access a Send */
export const AuthType = Object.freeze({
/** Send requires email OTP verification */
Email: 0,
/** Send requires a password */
Password: 1,
/** Send requires no auth */
None: 2,
} as const);
/** An type of auth necessary to access a Send */
export type AuthType = (typeof AuthType)[keyof typeof AuthType];

View File

@@ -3,8 +3,9 @@ import { Observable, firstValueFrom, of } from "rxjs";
// 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 { CollectionService, CollectionView } from "@bitwarden/admin-console/common";
import { CollectionService } from "@bitwarden/admin-console/common";
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
import { CollectionView } from "@bitwarden/common/admin-console/models/collections";
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
import { Utils } from "@bitwarden/common/platform/misc/utils";
import { UserId } from "@bitwarden/common/types/guid";

View File

@@ -2,7 +2,9 @@
"extends": "./tsconfig.json",
"compilerOptions": {
"isolatedModules": true,
"emitDecoratorMetadata": false
"emitDecoratorMetadata": false,
"module": "nodenext",
"moduleResolution": "nodenext"
},
"files": ["./test.setup.ts"]
}

View File

@@ -2,7 +2,6 @@
@if (breadcrumb.route(); as route) {
<a
bitLink
linkType="primary"
class="tw-my-2 tw-inline-block"
[routerLink]="route"
[queryParams]="breadcrumb.queryParams()"
@@ -14,7 +13,6 @@
<button
type="button"
bitLink
linkType="primary"
class="tw-my-2 tw-inline-block"
(click)="breadcrumb.onClick($event)"
>
@@ -42,7 +40,6 @@
@if (breadcrumb.route(); as route) {
<a
bitMenuItem
linkType="primary"
[routerLink]="route"
[queryParams]="breadcrumb.queryParams()"
[queryParamsHandling]="breadcrumb.queryParamsHandling()"
@@ -50,7 +47,7 @@
<ng-container [ngTemplateOutlet]="breadcrumb.content()"></ng-container>
</a>
} @else {
<button type="button" bitMenuItem linkType="primary" (click)="breadcrumb.onClick($event)">
<button type="button" bitMenuItem (click)="breadcrumb.onClick($event)">
<ng-container [ngTemplateOutlet]="breadcrumb.content()"></ng-container>
</button>
}
@@ -61,7 +58,6 @@
@if (breadcrumb.route(); as route) {
<a
bitLink
linkType="primary"
class="tw-my-2 tw-inline-block"
[routerLink]="route"
[queryParams]="breadcrumb.queryParams()"
@@ -73,7 +69,6 @@
<button
type="button"
bitLink
linkType="primary"
class="tw-my-2 tw-inline-block"
(click)="breadcrumb.onClick($event)"
>

View File

@@ -1,5 +1,14 @@
import { Component, computed, HostBinding, input } from "@angular/core";
import {
Component,
computed,
ElementRef,
HostBinding,
HostListener,
inject,
input,
} from "@angular/core";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { Utils } from "@bitwarden/common/platform/misc/utils";
type CharacterType = "letter" | "emoji" | "special" | "number";
@@ -14,7 +23,7 @@ type CharacterType = "letter" | "emoji" | "special" | "number";
@Component({
selector: "bit-color-password",
template: `@for (character of passwordCharArray(); track $index; let i = $index) {
<span [class]="getCharacterClass(character)" class="tw-font-mono">
<span [class]="getCharacterClass(character)" class="tw-font-mono" data-password-character>
<span>{{ character }}</span>
@if (showCount()) {
<span class="tw-whitespace-nowrap tw-text-xs tw-leading-5 tw-text-main">{{ i + 1 }}</span>
@@ -31,6 +40,9 @@ export class ColorPasswordComponent {
return Array.from(this.password() ?? "");
});
private platformUtilsService = inject(PlatformUtilsService);
private elementRef = inject(ElementRef);
characterStyles: Record<CharacterType, string[]> = {
emoji: [],
letter: ["tw-text-main"],
@@ -78,4 +90,28 @@ export class ColorPasswordComponent {
return "letter";
}
@HostListener("copy", ["$event"])
onCopy(event: ClipboardEvent) {
event.preventDefault();
const selection = window.getSelection();
if (!selection || selection.rangeCount === 0) {
return;
}
const spanElements = this.elementRef.nativeElement.querySelectorAll(
"span[data-password-character]",
);
let copiedText = "";
spanElements.forEach((span: HTMLElement, index: number) => {
if (selection.containsNode(span, true)) {
copiedText += this.passwordCharArray()[index];
}
});
if (copiedText) {
this.platformUtilsService.copyToClipboard(copiedText);
}
}
}

View File

@@ -1,4 +1,6 @@
import { Meta, StoryObj } from "@storybook/angular";
import { applicationConfig, Meta, StoryObj } from "@storybook/angular";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { formatArgsForCodeSnippet } from "../../../../.storybook/format-args-for-code-snippet";
@@ -9,6 +11,19 @@ const examplePassword = "Wq$Jk😀7jlI DX#rS5Sdi!z0O ";
export default {
title: "Component Library/Color Password",
component: ColorPasswordComponent,
decorators: [
applicationConfig({
providers: [
{
provide: PlatformUtilsService,
useValue: {
// eslint-disable-next-line
copyToClipboard: (text: string) => console.log(`${text} copied to clipboard`),
},
},
],
}),
],
args: {
password: examplePassword,
showCount: false,

View File

@@ -10,7 +10,7 @@ import { ComponentPortal, Portal } from "@angular/cdk/portal";
import { Injectable, Injector, TemplateRef, inject } from "@angular/core";
import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
import { NavigationEnd, Router } from "@angular/router";
import { filter, firstValueFrom, map, Observable, Subject, switchMap } from "rxjs";
import { filter, firstValueFrom, map, Observable, Subject, switchMap, take } from "rxjs";
import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service";
import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status";
@@ -62,7 +62,7 @@ export abstract class DialogRef<R = unknown, C = unknown> implements Pick<
export type DialogConfig<D = unknown, R = unknown> = Pick<
CdkDialogConfig<D, R>,
"data" | "disableClose" | "ariaModal" | "positionStrategy" | "height" | "width"
"data" | "disableClose" | "ariaModal" | "positionStrategy" | "height" | "width" | "restoreFocus"
>;
/**
@@ -242,6 +242,11 @@ export class DialogService {
};
ref.cdkDialogRefBase = this.dialog.open<R, D, C>(componentOrTemplateRef, _config);
if (config?.restoreFocus === undefined) {
this.setRestoreFocusEl<R, C>(ref);
}
return ref;
}
@@ -305,6 +310,48 @@ export class DialogService {
return this.activeDrawer?.close();
}
/**
* Configure the dialog to return focus to the previous active element upon closing.
* @param ref CdkDialogRef
*
* The cdk dialog already has the optional directive `cdkTrapFocusAutoCapture` to capture the
* current active element and return focus to it upon close. However, it does not have a way to
* delay the capture of the element. We need this delay in some situations, where the active
* element may be changing as the dialog is opening, and we want to wait for that to settle.
*
* For example -- the menu component often contains menu items that open dialogs. When the dialog
* opens, the menu is closing and is setting focus back to the menu trigger since the menu item no
* longer exists. We want to capture the menu trigger as the active element, not the about-to-be-
* nonexistent menu item. If we wait a tick, we can let the menu finish that focus move.
*/
private setRestoreFocusEl<R = unknown, C = unknown>(ref: CdkDialogRef<R, C>) {
/**
* First, capture the current active el with no delay so that we can support normal use cases
* where we are not doing manual focus management
*/
const activeEl = document.activeElement;
const restoreFocusTimeout = setTimeout(() => {
let restoreFocusEl = activeEl;
/**
* If the original active element is no longer connected, it's because we purposely removed it
* from the DOM and have moved focus. Select the new active element instead.
*/
if (!restoreFocusEl?.isConnected) {
restoreFocusEl = document.activeElement;
}
if (restoreFocusEl instanceof HTMLElement) {
ref.cdkDialogRefBase.config.restoreFocus = restoreFocusEl;
}
}, 0);
ref.closed.pipe(take(1)).subscribe(() => {
clearTimeout(restoreFocusTimeout);
});
}
/** The injector that is passed to the opened dialog */
private createInjector(opts: { data: unknown; dialogRef: DialogRef }): Injector {
return Injector.create({

View File

@@ -6,7 +6,6 @@
isDrawer ? 'tw-h-full tw-border-t-0' : 'tw-rounded-t-xl md:tw-rounded-xl tw-shadow-lg',
]"
cdkTrapFocus
cdkTrapFocusAutoCapture
>
@let showHeaderBorder = bodyHasScrolledFrom().top;
<header
@@ -23,8 +22,8 @@
bitTypography="h3"
noMargin
class="tw-text-main tw-mb-0 tw-line-clamp-2 tw-text-ellipsis tw-break-words focus-visible:tw-outline-none"
cdkFocusInitial
tabindex="-1"
#dialogHeader
>
{{ title() }}
@if (subtitle(); as subtitleText) {

View File

@@ -11,6 +11,7 @@ import {
DestroyRef,
computed,
signal,
AfterViewInit,
} from "@angular/core";
import { toObservable } from "@angular/core/rxjs-interop";
import { combineLatest, switchMap } from "rxjs";
@@ -62,8 +63,10 @@ const drawerSizeToWidth = {
SpinnerComponent,
],
})
export class DialogComponent {
export class DialogComponent implements AfterViewInit {
private readonly destroyRef = inject(DestroyRef);
private readonly dialogHeader =
viewChild.required<ElementRef<HTMLHeadingElement>>("dialogHeader");
private readonly scrollableBody = viewChild.required(CdkScrollable);
private readonly scrollBottom = viewChild.required<ElementRef<HTMLDivElement>>("scrollBottom");
@@ -141,6 +144,22 @@ export class DialogComponent {
return [...baseClasses, this.width(), ...sizeClasses, ...animationClasses];
});
ngAfterViewInit() {
/**
* Wait a tick for any focus management to occur on the trigger element before moving focus to
* the dialog header. We choose the dialog header because it is always present, unlike possible
* interactive elements.
*
* We are doing this manually instead of using `cdkTrapFocusAutoCapture` and `cdkFocusInitial`
* because we need this delay behavior.
*/
const headerFocusTimeout = setTimeout(() => {
this.dialogHeader().nativeElement.focus();
}, 0);
this.destroyRef.onDestroy(() => clearTimeout(headerFocusTimeout));
}
handleEsc(event: Event) {
if (!this.dialogRef?.disableClose) {
this.dialogRef?.close();

View File

@@ -1,5 +1,5 @@
@let mainContentId = "main-content";
<div class="tw-flex tw-size-full">
<div class="tw-flex tw-size-full" [class.tw-bg-background-alt3]="rounded()">
<div class="tw-flex tw-size-full" cdkTrapFocus>
<div
class="tw-fixed tw-z-50 tw-w-full tw-flex tw-justify-center tw-opacity-0 focus-within:tw-opacity-100 tw-pointer-events-none focus-within:tw-pointer-events-auto"
@@ -24,6 +24,7 @@
tabindex="-1"
bitScrollLayoutHost
class="tw-overflow-auto tw-max-h-full tw-min-w-0 tw-flex-1 tw-bg-background tw-p-8 tw-pt-6 tw-@container"
[class.tw-rounded-tl-2xl]="rounded()"
>
<!-- ^ If updating this padding, also update the padding correction in bit-banner! ^ -->
<ng-content></ng-content>

View File

@@ -1,7 +1,7 @@
import { A11yModule, CdkTrapFocus } from "@angular/cdk/a11y";
import { PortalModule } from "@angular/cdk/portal";
import { CommonModule } from "@angular/common";
import { Component, ElementRef, inject, viewChild } from "@angular/core";
import { booleanAttribute, Component, ElementRef, inject, input, viewChild } from "@angular/core";
import { RouterModule } from "@angular/router";
import { DrawerHostDirective } from "../drawer/drawer-host.directive";
@@ -38,6 +38,12 @@ export class LayoutComponent {
protected drawerPortal = inject(DrawerService).portal;
private readonly mainContent = viewChild.required<ElementRef<HTMLElement>>("main");
/**
* Rounded top left corner for the main content area
*/
readonly rounded = input(false, { transform: booleanAttribute });
protected focusMainContent() {
this.mainContent().nativeElement.focus();
}

View File

@@ -14,6 +14,8 @@ import { StorybookGlobalStateProvider } from "../utils/state-mock";
import { LayoutComponent } from "./layout.component";
import { mockLayoutI18n } from "./mocks";
import { formatArgsForCodeSnippet } from ".storybook/format-args-for-code-snippet";
export default {
title: "Component Library/Layout",
component: LayoutComponent,
@@ -63,7 +65,7 @@ export const WithContent: Story = {
render: (args) => ({
props: args,
template: /* HTML */ `
<bit-layout>
<bit-layout ${formatArgsForCodeSnippet<LayoutComponent>(args)}>
<bit-side-nav>
<bit-nav-group text="Hello World (Anchor)" [route]="['a']" icon="bwi-filter">
<bit-nav-item text="Child A" route="a" icon="bwi-filter"></bit-nav-item>
@@ -111,3 +113,10 @@ export const Secondary: Story = {
`,
}),
};
export const Rounded: Story = {
...WithContent,
args: {
rounded: true,
},
};

View File

@@ -3,21 +3,34 @@ import { input, HostBinding, Directive, inject, ElementRef, booleanAttribute } f
import { AriaDisableDirective } from "../a11y";
import { ariaDisableElement } from "../utils";
export type LinkType = "primary" | "secondary" | "contrast" | "light";
export const LinkTypes = [
"primary",
"secondary",
"contrast",
"light",
"default",
"subtle",
"success",
"warning",
"danger",
] as const;
export type LinkType = (typeof LinkTypes)[number];
const linkStyles: Record<LinkType, string[]> = {
primary: [
"!tw-text-primary-600",
"hover:!tw-text-primary-700",
"focus-visible:before:tw-ring-primary-600",
],
secondary: ["!tw-text-main", "hover:!tw-text-main", "focus-visible:before:tw-ring-primary-600"],
primary: ["tw-text-fg-brand", "hover:tw-text-fg-brand-strong"],
default: ["tw-text-fg-brand", "hover:tw-text-fg-brand-strong"],
secondary: ["tw-text-fg-heading", "hover:tw-text-fg-heading"],
light: ["tw-text-fg-white", "hover:tw-text-fg-white", "focus-visible:before:tw-ring-fg-contrast"],
subtle: ["!tw-text-fg-heading", "hover:tw-text-fg-heading"],
success: ["tw-text-fg-success", "hover:tw-text-fg-success-strong"],
warning: ["tw-text-fg-warning", "hover:tw-text-fg-warning-strong"],
danger: ["tw-text-fg-danger", "hover:tw-text-fg-danger-strong"],
contrast: [
"!tw-text-contrast",
"hover:!tw-text-contrast",
"focus-visible:before:tw-ring-text-contrast",
"tw-text-fg-contrast",
"hover:tw-text-fg-contrast",
"focus-visible:before:tw-ring-fg-contrast",
],
light: ["!tw-text-alt2", "hover:!tw-text-alt2", "focus-visible:before:tw-ring-text-alt2"],
};
const commonStyles = [
@@ -32,16 +45,18 @@ const commonStyles = [
"tw-rounded",
"tw-transition",
"tw-no-underline",
"tw-cursor-pointer",
"hover:tw-underline",
"hover:tw-decoration-1",
"disabled:tw-no-underline",
"disabled:tw-cursor-not-allowed",
"disabled:!tw-text-secondary-300",
"disabled:hover:!tw-text-secondary-300",
"disabled:!tw-text-fg-disabled",
"disabled:hover:!tw-text-fg-disabled",
"disabled:hover:tw-no-underline",
"focus-visible:tw-outline-none",
"focus-visible:tw-underline",
"focus-visible:tw-decoration-1",
"focus-visible:before:tw-ring-border-focus",
// Workaround for html button tag not being able to be set to `display: inline`
// and at the same time not being able to use `tw-ring-offset` because of box-shadow issue.
@@ -63,14 +78,14 @@ const commonStyles = [
"focus-visible:tw-z-10",
"aria-disabled:tw-no-underline",
"aria-disabled:tw-pointer-events-none",
"aria-disabled:!tw-text-secondary-300",
"aria-disabled:hover:!tw-text-secondary-300",
"aria-disabled:!tw-text-fg-disabled",
"aria-disabled:hover:!tw-text-fg-disabled",
"aria-disabled:hover:tw-no-underline",
];
@Directive()
abstract class LinkDirective {
readonly linkType = input<LinkType>("primary");
readonly linkType = input<LinkType>("default");
}
/**

View File

@@ -18,10 +18,15 @@ import { LinkModule } from "@bitwarden/components";
You can use one of the following variants by providing it as the `linkType` input:
- `primary` - most common, uses brand color
- `secondary` - matches the main text color
- @deprecated `primary` => use `default` instead
- @deprecated `secondary` => use `subtle` instead
- `default` - most common, uses brand color
- `subtle` - matches the main text color
- `contrast` - for high contrast against a dark background (or a light background in dark mode)
- `light` - always a light color, even in dark mode
- `warning` - used in association with warning callouts/banners
- `success` - used in association with success callouts/banners
- `danger` - used in association with danger callouts/banners
## Sizes

View File

@@ -2,7 +2,7 @@ import { Meta, StoryObj, moduleMetadata } from "@storybook/angular";
import { formatArgsForCodeSnippet } from "../../../../.storybook/format-args-for-code-snippet";
import { AnchorLinkDirective, ButtonLinkDirective } from "./link.directive";
import { AnchorLinkDirective, ButtonLinkDirective, LinkTypes } from "./link.directive";
import { LinkModule } from "./link.module";
export default {
@@ -14,7 +14,7 @@ export default {
],
argTypes: {
linkType: {
options: ["primary", "secondary", "contrast"],
options: LinkTypes.map((type) => type),
control: { type: "radio" },
},
},
@@ -30,48 +30,153 @@ type Story = StoryObj<ButtonLinkDirective>;
export const Default: Story = {
render: (args) => ({
props: {
linkType: args.linkType,
backgroundClass:
args.linkType === "contrast"
? "tw-bg-bg-contrast"
: args.linkType === "light"
? "tw-bg-bg-brand"
: "tw-bg-transparent",
},
template: /*html*/ `
<a bitLink ${formatArgsForCodeSnippet<ButtonLinkDirective>(args)}>Your text here</a>
<div class="tw-p-2" [class]="backgroundClass">
<a bitLink href="" ${formatArgsForCodeSnippet<ButtonLinkDirective>(args)}>Your text here</a>
</div>
`,
}),
args: {
linkType: "primary",
},
parameters: {
chromatic: { disableSnapshot: true },
},
};
export const AllVariations: Story = {
render: () => ({
template: /*html*/ `
<div class="tw-flex tw-flex-col tw-gap-6">
<div class="tw-flex tw-gap-4 tw-p-2">
<a bitLink linkType="primary" href="#">Primary</a>
</div>
<div class="tw-flex tw-gap-4 tw-p-2">
<a bitLink linkType="secondary" href="#">Secondary</a>
</div>
<div class="tw-flex tw-gap-4 tw-p-2 tw-bg-bg-contrast">
<a bitLink linkType="contrast" href="#">Contrast</a>
</div>
<div class="tw-flex tw-gap-4 tw-p-2 tw-bg-bg-brand">
<a bitLink linkType="light" href="#">Light</a>
</div>
<div class="tw-flex tw-gap-4 tw-p-2">
<a bitLink linkType="default" href="#">Default</a>
</div>
<div class="tw-flex tw-gap-4 tw-p-2">
<a bitLink linkType="subtle" href="#">Subtle</a>
</div>
<div class="tw-flex tw-gap-4 tw-p-2">
<a bitLink linkType="success" href="#">Success</a>
</div>
<div class="tw-flex tw-gap-4 tw-p-2">
<a bitLink linkType="warning" href="#">Warning</a>
</div>
<div class="tw-flex tw-gap-4 tw-p-2">
<a bitLink linkType="danger" href="#">Danger</a>
</div>
</div>
`,
}),
parameters: {
controls: {
exclude: ["linkType"],
hideNoControlsWarning: true,
},
},
};
export const InteractionStates: Story = {
render: () => ({
template: /*html*/ `
<div class="tw-flex tw-gap-4 tw-p-2 tw-mb-6">
<div class="tw-flex tw-flex-col tw-gap-6">
<div class="tw-flex tw-gap-4 tw-p-2">
<a bitLink linkType="primary" href="#">Primary</a>
<a bitLink linkType="primary" href="#" class="tw-test-hover">Primary</a>
<a bitLink linkType="primary" href="#" class="tw-test-focus-visible">Primary</a>
<a bitLink linkType="primary" href="#" class="tw-test-hover tw-test-focus-visible">Primary</a>
</div>
<div class="tw-flex tw-gap-4 tw-p-2 tw-mb-6">
<div class="tw-flex tw-gap-4 tw-p-2">
<a bitLink linkType="secondary" href="#">Secondary</a>
<a bitLink linkType="secondary" href="#" class="tw-test-hover">Secondary</a>
<a bitLink linkType="secondary" href="#" class="tw-test-focus-visible">Secondary</a>
<a bitLink linkType="secondary" href="#" class="tw-test-hover tw-test-focus-visible">Secondary</a>
</div>
<div class="tw-flex tw-gap-4 tw-p-2 tw-mb-6 tw-bg-primary-600">
<div class="tw-flex tw-gap-4 tw-p-2 tw-bg-bg-contrast">
<a bitLink linkType="contrast" href="#">Contrast</a>
<a bitLink linkType="contrast" href="#" class="tw-test-hover">Contrast</a>
<a bitLink linkType="contrast" href="#" class="tw-test-focus-visible">Contrast</a>
<a bitLink linkType="contrast" href="#" class="tw-test-hover tw-test-focus-visible">Contrast</a>
</div>
<div class="tw-flex tw-gap-4 tw-p-2 tw-mb-6 tw-bg-primary-600">
<div class="tw-flex tw-gap-4 tw-p-2 tw-bg-bg-brand">
<a bitLink linkType="light" href="#">Light</a>
<a bitLink linkType="light" href="#" class="tw-test-hover">Light</a>
<a bitLink linkType="light" href="#" class="tw-test-focus-visible">Light</a>
<a bitLink linkType="light" href="#" class="tw-test-hover tw-test-focus-visible">Light</a>
</div>
<div class="tw-flex tw-gap-4 tw-p-2">
<a bitLink linkType="default" href="#">Default</a>
<a bitLink linkType="default" href="#" class="tw-test-hover">Default</a>
<a bitLink linkType="default" href="#" class="tw-test-focus-visible">Default</a>
<a bitLink linkType="default" href="#" class="tw-test-hover tw-test-focus-visible">Default</a>
</div>
<div class="tw-flex tw-gap-4 tw-p-2">
<a bitLink linkType="subtle" href="#">Subtle</a>
<a bitLink linkType="subtle" href="#" class="tw-test-hover">Subtle</a>
<a bitLink linkType="subtle" href="#" class="tw-test-focus-visible">Subtle</a>
<a bitLink linkType="subtle" href="#" class="tw-test-hover tw-test-focus-visible">Subtle</a>
</div>
<div class="tw-flex tw-gap-4 tw-p-2">
<a bitLink linkType="success" href="#">Success</a>
<a bitLink linkType="success" href="#" class="tw-test-hover">Success</a>
<a bitLink linkType="success" href="#" class="tw-test-focus-visible">Success</a>
<a bitLink linkType="success" href="#" class="tw-test-hover tw-test-focus-visible">Success</a>
</div>
<div class="tw-flex tw-gap-4 tw-p-2">
<a bitLink linkType="warning" href="#">Warning</a>
<a bitLink linkType="warning" href="#" class="tw-test-hover">Warning</a>
<a bitLink linkType="warning" href="#" class="tw-test-focus-visible">Warning</a>
<a bitLink linkType="warning" href="#" class="tw-test-hover tw-test-focus-visible">Warning</a>
</div>
<div class="tw-flex tw-gap-4 tw-p-2">
<a bitLink linkType="danger" href="#">Danger</a>
<a bitLink linkType="danger" href="#" class="tw-test-hover">Danger</a>
<a bitLink linkType="danger" href="#" class="tw-test-focus-visible">Danger</a>
<a bitLink linkType="danger" href="#" class="tw-test-hover tw-test-focus-visible">Danger</a>
</div>
</div>
`,
}),
parameters: {
controls: {
exclude: ["linkType"],
hideNoControlsWarning: true,
},
},
};
export const Buttons: Story = {
render: (args) => ({
props: args,
props: {
linkType: args.linkType,
backgroundClass:
args.linkType === "contrast"
? "tw-bg-bg-contrast"
: args.linkType === "light"
? "tw-bg-bg-brand"
: "tw-bg-transparent",
},
template: /*html*/ `
<div class="tw-p-2" [ngClass]="{ 'tw-bg-transparent': linkType != 'contrast', 'tw-bg-primary-600': linkType === 'contrast' }">
<div class="tw-p-2" [class]="backgroundClass">
<div class="tw-block tw-p-2">
<button type="button" bitLink [linkType]="linkType">Button</button>
</div>
@@ -100,9 +205,17 @@ export const Buttons: Story = {
export const Anchors: StoryObj<AnchorLinkDirective> = {
render: (args) => ({
props: args,
props: {
linkType: args.linkType,
backgroundClass:
args.linkType === "contrast"
? "tw-bg-bg-contrast"
: args.linkType === "light"
? "tw-bg-bg-brand"
: "tw-bg-transparent",
},
template: /*html*/ `
<div class="tw-p-2" [ngClass]="{ 'tw-bg-transparent': linkType != 'contrast', 'tw-bg-primary-600': linkType === 'contrast' }">
<div class="tw-p-2" [class]="backgroundClass">
<div class="tw-block tw-p-2">
<a bitLink [linkType]="linkType" href="#">Anchor</a>
</div>
@@ -138,18 +251,15 @@ export const Inline: Story = {
</span>
`,
}),
args: {
linkType: "primary",
},
};
export const Disabled: Story = {
export const Inactive: Story = {
render: (args) => ({
props: args,
template: /*html*/ `
<button type="button" bitLink disabled linkType="primary" class="tw-me-2">Primary</button>
<button type="button" bitLink disabled linkType="secondary" class="tw-me-2">Secondary</button>
<div class="tw-bg-primary-600 tw-p-2 tw-inline-block">
<div class="tw-bg-bg-contrast tw-p-2 tw-inline-block">
<button type="button" bitLink disabled linkType="contrast">Contrast</button>
</div>
`,

View File

@@ -192,7 +192,7 @@ export class MenuTriggerForDirective implements OnDestroy {
return;
}
const escKey = this.overlayRef.keydownEvents().pipe(
const keyEvents = this.overlayRef.keydownEvents().pipe(
filter((event: KeyboardEvent) => {
const keys = this.menu().ariaRole() === "menu" ? ["Escape", "Tab"] : ["Escape"];
return keys.includes(event.key);
@@ -202,8 +202,8 @@ export class MenuTriggerForDirective implements OnDestroy {
const detachments = this.overlayRef.detachments();
const closeEvents = isContextMenu
? merge(detachments, escKey, menuClosed)
: merge(detachments, escKey, this.overlayRef.backdropClick(), menuClosed);
? merge(detachments, keyEvents, menuClosed)
: merge(detachments, keyEvents, this.overlayRef.backdropClick(), menuClosed);
this.closedEventsSub = closeEvents
.pipe(takeUntil(this.overlayRef.detachments()))
@@ -215,9 +215,9 @@ export class MenuTriggerForDirective implements OnDestroy {
event.preventDefault();
}
if (event instanceof KeyboardEvent && (event.key === "Tab" || event.key === "Escape")) {
this.elementRef.nativeElement.focus();
}
// Move focus to the menu trigger, since any active menu items are about to be destroyed
this.elementRef.nativeElement.focus();
this.destroyMenu();
});
}

View File

@@ -0,0 +1,446 @@
import { Overlay, OverlayRef } from "@angular/cdk/overlay";
import { ChangeDetectionStrategy, Component, NgZone, TemplateRef, viewChild } from "@angular/core";
import { ComponentFixture, TestBed, fakeAsync, flush, tick } from "@angular/core/testing";
import { Subject } from "rxjs";
import { PopoverTriggerForDirective } from "./popover-trigger-for.directive";
import { PopoverComponent } from "./popover.component";
/**
* Test component to host the directive.
*
* Note: When testing RAF (requestAnimationFrame) behavior in fakeAsync tests:
* - tick() without arguments advances virtual time but does NOT execute RAF callbacks
* - tick(16) advances time by 16ms (typical animation frame duration) and DOES execute RAF callbacks
* - tick(0) flushes microtasks, useful for Angular effects that run synchronously
*/
@Component({
standalone: true,
template: `
<button
type="button"
[bitPopoverTriggerFor]="popoverComponent"
[(popoverOpen)]="isOpen"
#trigger="popoverTrigger"
>
Trigger
</button>
<bit-popover #popoverComponent></bit-popover>
`,
imports: [PopoverTriggerForDirective, PopoverComponent],
changeDetection: ChangeDetectionStrategy.OnPush,
})
class TestPopoverTriggerComponent {
isOpen = false;
readonly directive = viewChild("trigger", { read: PopoverTriggerForDirective });
readonly popoverComponent = viewChild("popoverComponent", { read: PopoverComponent });
readonly templateRef = viewChild("trigger", { read: TemplateRef });
}
describe("PopoverTriggerForDirective", () => {
let fixture: ComponentFixture<TestPopoverTriggerComponent>;
let component: TestPopoverTriggerComponent;
let directive: PopoverTriggerForDirective;
let overlayRef: Partial<OverlayRef>;
let overlay: Partial<Overlay>;
let ngZone: NgZone;
beforeEach(async () => {
// Create mock overlay ref
overlayRef = {
backdropElement: document.createElement("div"),
attach: jest.fn(),
detach: jest.fn(),
dispose: jest.fn(),
detachments: jest.fn().mockReturnValue(new Subject()),
keydownEvents: jest.fn().mockReturnValue(new Subject()),
backdropClick: jest.fn().mockReturnValue(new Subject()),
};
// Create mock overlay
const mockPositionStrategy = {
flexibleConnectedTo: jest.fn().mockReturnThis(),
withPositions: jest.fn().mockReturnThis(),
withLockedPosition: jest.fn().mockReturnThis(),
withFlexibleDimensions: jest.fn().mockReturnThis(),
withPush: jest.fn().mockReturnThis(),
};
overlay = {
create: jest.fn().mockReturnValue(overlayRef),
position: jest.fn().mockReturnValue(mockPositionStrategy),
scrollStrategies: {
reposition: jest.fn().mockReturnValue({}),
} as any,
};
await TestBed.configureTestingModule({
imports: [TestPopoverTriggerComponent],
providers: [{ provide: Overlay, useValue: overlay }],
}).compileComponents();
fixture = TestBed.createComponent(TestPopoverTriggerComponent);
component = fixture.componentInstance;
ngZone = TestBed.inject(NgZone);
fixture.detectChanges();
directive = component.directive()!;
});
afterEach(() => {
fixture.destroy();
});
describe("Initial popover open with RAF delay", () => {
it("should use double RAF delay on first open", fakeAsync(() => {
// Spy on requestAnimationFrame to verify it's being called
const rafSpy = jest.spyOn(window, "requestAnimationFrame");
// Set popoverOpen signal directly on the directive inside NgZone
ngZone.run(() => {
directive.popoverOpen.set(true);
fixture.detectChanges();
});
// After effect execution, RAF should be scheduled but not executed yet
expect(overlay.create).not.toHaveBeenCalled();
// Execute first RAF - tick(16) advances time by one animation frame (16ms)
// This executes the first requestAnimationFrame callback
tick(16);
expect(overlay.create).not.toHaveBeenCalled();
// Execute second RAF - the nested requestAnimationFrame callback
tick(16);
expect(overlay.create).toHaveBeenCalled();
expect(overlayRef.attach).toHaveBeenCalled();
rafSpy.mockRestore();
flush();
}));
it("should skip RAF delay on subsequent opens", fakeAsync(() => {
// First open with double RAF delay
ngZone.run(() => {
directive.popoverOpen.set(true);
fixture.detectChanges();
});
// Execute both RAF callbacks (16ms each = 32ms total for first open)
tick(16); // First RAF
tick(16); // Second RAF
expect(overlay.create).toHaveBeenCalledTimes(1);
jest.mocked(overlay.create).mockClear();
// Close by clicking
const button = fixture.nativeElement.querySelector("button");
button.click();
fixture.detectChanges();
// Second open should skip RAF delay (hasInitialized is now true)
ngZone.run(() => {
directive.popoverOpen.set(true);
fixture.detectChanges();
});
// Only need tick(0) to flush microtasks - NO RAF delay on subsequent opens
tick(0);
expect(overlay.create).toHaveBeenCalledTimes(1);
flush();
}));
});
describe("Race condition prevention", () => {
it("should prevent multiple RAF scheduling when toggled rapidly", fakeAsync(() => {
ngZone.run(() => {
directive.popoverOpen.set(true);
});
fixture.detectChanges();
// Try to toggle back to false before RAF completes
ngZone.run(() => {
directive.popoverOpen.set(false);
});
fixture.detectChanges();
// Try to toggle back to true
ngZone.run(() => {
directive.popoverOpen.set(true);
});
fixture.detectChanges();
// Execute RAFs
tick(16);
tick(16);
// Should only create overlay once
expect(overlay.create).toHaveBeenCalledTimes(1);
flush();
}));
it("should not schedule new RAF if one is already pending", fakeAsync(() => {
ngZone.run(() => {
directive.popoverOpen.set(true);
});
fixture.detectChanges();
// Try to open again while RAF is pending (shouldn't schedule another)
ngZone.run(() => {
directive.popoverOpen.set(false);
});
fixture.detectChanges();
ngZone.run(() => {
directive.popoverOpen.set(true);
});
fixture.detectChanges();
tick(16);
tick(16);
// Should only have created one overlay
expect(overlay.create).toHaveBeenCalledTimes(1);
flush();
}));
it("should prevent duplicate overlays from click handler during RAF", fakeAsync(() => {
ngZone.run(() => {
directive.popoverOpen.set(true);
});
fixture.detectChanges();
// Click to close before RAF completes - this should cancel the RAF and prevent overlay creation
const button = fixture.nativeElement.querySelector("button");
button.click();
fixture.detectChanges();
// Verify popoverOpen was set to false
expect(directive.popoverOpen()).toBe(false);
tick(16);
tick(16);
// Should NOT have created any overlay because RAF was canceled
expect(overlay.create).not.toHaveBeenCalled();
flush();
}));
});
describe("Component destruction during RAF", () => {
it("should cancel RAF callbacks when component is destroyed", fakeAsync(() => {
const cancelAnimationFrameSpy = jest.spyOn(window, "cancelAnimationFrame");
ngZone.run(() => {
directive.popoverOpen.set(true);
});
fixture.detectChanges();
// Destroy component before RAF completes
fixture.destroy();
// Should have cancelled animation frames
expect(cancelAnimationFrameSpy).toHaveBeenCalled();
cancelAnimationFrameSpy.mockRestore();
flush();
}));
it("should not create overlay if destroyed during RAF delay", fakeAsync(() => {
ngZone.run(() => {
directive.popoverOpen.set(true);
});
fixture.detectChanges();
// Execute first RAF
tick(16);
// Destroy before second RAF
fixture.destroy();
// Execute second RAF (should be no-op)
tick(16);
expect(overlay.create).not.toHaveBeenCalled();
flush();
}));
it("should set isDestroyed flag and prevent further operations", fakeAsync(() => {
ngZone.run(() => {
directive.popoverOpen.set(true);
});
fixture.detectChanges();
tick(16);
tick(16);
// Destroy the component
fixture.destroy();
// Try to toggle (should be blocked by isDestroyed check)
const button = fixture.nativeElement.querySelector("button");
button.click();
expect(overlay.create).toHaveBeenCalledTimes(1); // Only from initial open
flush();
}));
});
describe("Click handling", () => {
it("should open popover on click when closed", fakeAsync(() => {
const button = fixture.nativeElement.querySelector("button");
button.click();
fixture.detectChanges();
expect(component.isOpen).toBe(true);
expect(overlay.create).toHaveBeenCalled();
flush();
}));
it("should close popover on click when open", fakeAsync(() => {
// Open first
ngZone.run(() => {
directive.popoverOpen.set(true);
});
fixture.detectChanges();
tick(16);
tick(16);
// Click to close
const button = fixture.nativeElement.querySelector("button");
button.click();
fixture.detectChanges();
expect(component.isOpen).toBe(false);
expect(overlayRef.dispose).toHaveBeenCalled();
flush();
}));
it("should not process clicks after component is destroyed", fakeAsync(() => {
ngZone.run(() => {
directive.popoverOpen.set(true);
});
fixture.detectChanges();
tick(16);
tick(16);
const initialCreateCount = jest.mocked(overlay.create).mock.calls.length;
fixture.destroy();
const button = fixture.nativeElement.querySelector("button");
button.click();
// Should not have created additional overlay
expect(overlay.create).toHaveBeenCalledTimes(initialCreateCount);
flush();
}));
});
describe("Resource cleanup", () => {
it("should cancel both RAF IDs in disposeAll", fakeAsync(() => {
const cancelAnimationFrameSpy = jest.spyOn(window, "cancelAnimationFrame");
ngZone.run(() => {
directive.popoverOpen.set(true);
});
fixture.detectChanges();
// Trigger disposal while RAF is pending
directive.ngOnDestroy();
// Should cancel animation frames
expect(cancelAnimationFrameSpy).toHaveBeenCalled();
cancelAnimationFrameSpy.mockRestore();
flush();
}));
it("should dispose overlay on destroy", fakeAsync(() => {
ngZone.run(() => {
directive.popoverOpen.set(true);
});
fixture.detectChanges();
tick(16);
tick(16);
expect(overlayRef.attach).toHaveBeenCalled();
fixture.destroy();
expect(overlayRef.dispose).toHaveBeenCalled();
flush();
}));
it("should unsubscribe from closed events on destroy", fakeAsync(() => {
ngZone.run(() => {
directive.popoverOpen.set(true);
});
fixture.detectChanges();
tick(16);
tick(16);
// Get the subscription (it's private, so we'll verify via disposal)
fixture.destroy();
// Should have disposed overlay which triggers cleanup
expect(overlayRef.dispose).toHaveBeenCalled();
flush();
}));
});
describe("Overlay guard in openPopover", () => {
it("should not create duplicate overlay if overlayRef already exists", fakeAsync(() => {
ngZone.run(() => {
directive.popoverOpen.set(true);
});
fixture.detectChanges();
tick(16);
tick(16);
expect(overlay.create).toHaveBeenCalledTimes(1);
// Try to open again
ngZone.run(() => {
directive.popoverOpen.set(false);
});
fixture.detectChanges();
ngZone.run(() => {
directive.popoverOpen.set(true);
});
fixture.detectChanges();
expect(overlay.create).toHaveBeenCalledTimes(1);
flush();
}));
});
describe("aria-expanded attribute", () => {
it("should set aria-expanded to false when closed", () => {
const button = fixture.nativeElement.querySelector("button");
expect(button.getAttribute("aria-expanded")).toBe("false");
});
it("should set aria-expanded to true when open", fakeAsync(() => {
ngZone.run(() => {
directive.popoverOpen.set(true);
});
fixture.detectChanges();
tick(16);
tick(16);
const button = fixture.nativeElement.querySelector("button");
expect(button.getAttribute("aria-expanded")).toBe("true");
flush();
}));
});
});

View File

@@ -1,12 +1,12 @@
import { Overlay, OverlayConfig, OverlayRef } from "@angular/cdk/overlay";
import { TemplatePortal } from "@angular/cdk/portal";
import {
AfterViewInit,
Directive,
ElementRef,
HostListener,
OnDestroy,
ViewContainerRef,
effect,
input,
model,
} from "@angular/core";
@@ -22,7 +22,7 @@ import { PopoverComponent } from "./popover.component";
"[attr.aria-expanded]": "this.popoverOpen()",
},
})
export class PopoverTriggerForDirective implements OnDestroy, AfterViewInit {
export class PopoverTriggerForDirective implements OnDestroy {
readonly popoverOpen = model(false);
readonly popover = input.required<PopoverComponent>({ alias: "bitPopoverTriggerFor" });
@@ -31,6 +31,10 @@ export class PopoverTriggerForDirective implements OnDestroy, AfterViewInit {
private overlayRef: OverlayRef | null = null;
private closedEventsSub: Subscription | null = null;
private hasInitialized = false;
private rafId1: number | null = null;
private rafId2: number | null = null;
private isDestroyed = false;
get positions() {
if (!this.position()) {
@@ -65,10 +69,44 @@ export class PopoverTriggerForDirective implements OnDestroy, AfterViewInit {
private elementRef: ElementRef<HTMLElement>,
private viewContainerRef: ViewContainerRef,
private overlay: Overlay,
) {}
) {
effect(() => {
if (this.isDestroyed || !this.popoverOpen() || this.overlayRef) {
return;
}
if (this.hasInitialized) {
this.openPopover();
return;
}
if (this.rafId1 !== null || this.rafId2 !== null) {
return;
}
// Initial open - wait for layout to stabilize
// First RAF: Waits for Angular's change detection to complete and queues the next paint
this.rafId1 = requestAnimationFrame(() => {
// Second RAF: Ensures the browser has actually painted that frame and all layout/position calculations are final
this.rafId2 = requestAnimationFrame(() => {
if (this.isDestroyed || !this.popoverOpen() || this.overlayRef) {
return;
}
this.openPopover();
this.hasInitialized = true;
this.rafId2 = null;
});
this.rafId1 = null;
});
});
}
@HostListener("click")
togglePopover() {
if (this.isDestroyed) {
return;
}
if (this.popoverOpen()) {
this.closePopover();
} else {
@@ -77,6 +115,10 @@ export class PopoverTriggerForDirective implements OnDestroy, AfterViewInit {
}
private openPopover() {
if (this.overlayRef) {
return;
}
this.popoverOpen.set(true);
this.overlayRef = this.overlay.create(this.defaultPopoverConfig);
@@ -104,7 +146,7 @@ export class PopoverTriggerForDirective implements OnDestroy, AfterViewInit {
}
private destroyPopover() {
if (!this.overlayRef || !this.popoverOpen()) {
if (!this.popoverOpen()) {
return;
}
@@ -117,15 +159,19 @@ export class PopoverTriggerForDirective implements OnDestroy, AfterViewInit {
this.closedEventsSub = null;
this.overlayRef?.dispose();
this.overlayRef = null;
}
ngAfterViewInit() {
if (this.popoverOpen()) {
this.openPopover();
if (this.rafId1 !== null) {
cancelAnimationFrame(this.rafId1);
this.rafId1 = null;
}
if (this.rafId2 !== null) {
cancelAnimationFrame(this.rafId2);
this.rafId2 = null;
}
}
ngOnDestroy() {
this.isDestroyed = true;
this.disposeAll();
}

View File

@@ -73,7 +73,6 @@ import { KitchenSinkSharedModule } from "../kitchen-sink-shared.module";
A random password
<button
bitLink
linkType="primary"
[bitPopoverTriggerFor]="myPopover"
#triggerRef="popoverTrigger"
type="button"

View File

@@ -112,13 +112,12 @@ class KitchenSinkDialogComponent {
<div class="tw-my-6">
<h1 bitTypography="h1">Bitwarden Kitchen Sink<bit-avatar text="Bit Warden"></bit-avatar></h1>
<a bitLink linkType="primary" href="#">This is a link</a>
<a bitLink href="#">This is a link</a>
<p bitTypography="body1" class="tw-inline">
&nbsp;and this is a link button popover trigger:&nbsp;
</p>
<button
bitLink
linkType="primary"
[bitPopoverTriggerFor]="myPopover"
#triggerRef="popoverTrigger"
type="button"

View File

@@ -13,6 +13,7 @@ import {
import { PasswordManagerLogo } from "@bitwarden/assets/svg";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { GlobalStateProvider } from "@bitwarden/state";
import { LayoutComponent } from "../../layout";
@@ -71,6 +72,13 @@ export default {
});
},
},
{
provide: PlatformUtilsService,
useValue: {
// eslint-disable-next-line
copyToClipboard: (text: string) => console.log(`${text} copied to clipboard`),
},
},
{
provide: GlobalStateProvider,
useClass: StorybookGlobalStateProvider,

View File

@@ -11,6 +11,7 @@ import {
signal,
model,
computed,
OnDestroy,
} from "@angular/core";
import { TooltipPositionIdentifier, tooltipPositions } from "./tooltip-positions";
@@ -32,7 +33,7 @@ export const TOOLTIP_DELAY_MS = 800;
"[attr.aria-describedby]": "resolvedDescribedByIds()",
},
})
export class TooltipDirective implements OnInit {
export class TooltipDirective implements OnInit, OnDestroy {
private static nextId = 0;
/**
* The value of this input is forwarded to the tooltip.component to render
@@ -51,6 +52,7 @@ export class TooltipDirective implements OnInit {
private readonly isVisible = signal(false);
private overlayRef: OverlayRef | undefined;
private showTimeoutId: ReturnType<typeof setTimeout> | undefined;
private elementRef = inject<ElementRef<HTMLElement>>(ElementRef);
private overlay = inject(Overlay);
private viewContainerRef = inject(ViewContainerRef);
@@ -81,13 +83,29 @@ export class TooltipDirective implements OnInit {
}),
);
/**
* Clear any pending show timeout
*
* Use cases: prevent tooltip from appearing after hide; clear existing timeout before showing a
* new tooltip
*/
private clearTimeout() {
if (this.showTimeoutId !== undefined) {
clearTimeout(this.showTimeoutId);
this.showTimeoutId = undefined;
}
}
private destroyTooltip = () => {
this.clearTimeout();
this.overlayRef?.dispose();
this.overlayRef = undefined;
this.isVisible.set(false);
};
protected showTooltip = () => {
this.clearTimeout();
if (!this.overlayRef) {
this.overlayRef = this.overlay.create({
...this.defaultPopoverConfig,
@@ -97,8 +115,9 @@ export class TooltipDirective implements OnInit {
this.overlayRef.attach(this.tooltipPortal);
}
setTimeout(() => {
this.showTimeoutId = setTimeout(() => {
this.isVisible.set(true);
this.showTimeoutId = undefined;
}, TOOLTIP_DELAY_MS);
};
@@ -134,4 +153,8 @@ export class TooltipDirective implements OnInit {
ngOnInit() {
this.positionStrategy.withPositions(this.computePositions(this.tooltipPosition()));
}
ngOnDestroy(): void {
this.destroyTooltip();
}
}

View File

@@ -41,6 +41,7 @@ interface OverlayLike {
interface OverlayRefStub {
attach: (portal: ComponentPortal<unknown>) => unknown;
updatePosition: () => void;
dispose: () => void;
}
describe("TooltipDirective (visibility only)", () => {
@@ -68,6 +69,7 @@ describe("TooltipDirective (visibility only)", () => {
},
})),
updatePosition: jest.fn(),
dispose: jest.fn(),
};
const overlayMock: OverlayLike = {

View File

@@ -29,15 +29,15 @@ import { combineLatestWith, filter, map, switchMap, takeUntil } from "rxjs/opera
// 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 {
CollectionService,
CollectionTypes,
CollectionView,
} from "@bitwarden/admin-console/common";
import { CollectionService } from "@bitwarden/admin-console/common";
import { JslibModule } from "@bitwarden/angular/jslib.module";
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 {
CollectionView,
CollectionTypes,
} from "@bitwarden/common/admin-console/models/collections";
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { getUserId } from "@bitwarden/common/auth/services/account.service";

View File

@@ -2,9 +2,7 @@
// @ts-strict-ignore
import * as papa from "papaparse";
// 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 { Collection, CollectionView } from "@bitwarden/admin-console/common";
import { CollectionView, Collection } from "@bitwarden/common/admin-console/models/collections";
import { normalizeExpiryYearFormat } from "@bitwarden/common/autofill/utils";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { Utils } from "@bitwarden/common/platform/misc/utils";

View File

@@ -2,9 +2,7 @@
// @ts-strict-ignore
import { filter, firstValueFrom } from "rxjs";
// 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 { Collection } from "@bitwarden/admin-console/common";
import { Collection } from "@bitwarden/common/admin-console/models/collections";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service";
import { EncString } from "@bitwarden/common/key-management/crypto/models/enc-string";

View File

@@ -1,8 +1,6 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
// 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 { CollectionView } from "@bitwarden/admin-console/common";
import { CollectionView } from "@bitwarden/common/admin-console/models/collections";
import { ImportResult } from "../models/import-result";

View File

@@ -1,6 +1,4 @@
// 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 { CollectionView } from "@bitwarden/admin-console/common";
import { CollectionView } from "@bitwarden/common/admin-console/models/collections";
import { ImportResult } from "../models/import-result";

View File

@@ -1,6 +1,4 @@
// 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 { CollectionView } from "@bitwarden/admin-console/common";
import { CollectionView } from "@bitwarden/common/admin-console/models/collections";
import { OrganizationId } from "@bitwarden/common/types/guid";
import { FieldType, SecureNoteType } from "@bitwarden/common/vault/enums";
import { FolderView } from "@bitwarden/common/vault/models/view/folder.view";

View File

@@ -1,8 +1,6 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
// 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 { CollectionView } from "@bitwarden/admin-console/common";
import { CollectionView } from "@bitwarden/common/admin-console/models/collections";
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
import { FolderView } from "@bitwarden/common/vault/models/view/folder.view";

View File

@@ -1,8 +1,6 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
// 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 { CollectionView } from "@bitwarden/admin-console/common";
import { CollectionView } from "@bitwarden/common/admin-console/models/collections";
import { UserId } from "@bitwarden/user-core";
export abstract class ImportCollectionServiceAbstraction {

View File

@@ -1,9 +1,6 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
// 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 { CollectionView } from "@bitwarden/admin-console/common";
import { CollectionView } from "@bitwarden/common/admin-console/models/collections";
import { FolderView } from "@bitwarden/common/vault/models/view/folder.view";
import { Importer } from "../importers/importer";

View File

@@ -2,11 +2,11 @@ import { mock, MockProxy } from "jest-mock-extended";
// 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 { CollectionService } from "@bitwarden/admin-console/common";
import {
CollectionService,
CollectionTypes,
CollectionView,
} from "@bitwarden/admin-console/common";
CollectionTypes,
} from "@bitwarden/common/admin-console/models/collections";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { KeyGenerationService } from "@bitwarden/common/key-management/crypto";
import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service";

View File

@@ -4,12 +4,11 @@ import { firstValueFrom, map } from "rxjs";
// 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 { CollectionService, CollectionWithIdRequest } from "@bitwarden/admin-console/common";
import {
CollectionService,
CollectionWithIdRequest,
CollectionView,
CollectionTypes,
} from "@bitwarden/admin-console/common";
} from "@bitwarden/common/admin-console/models/collections";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { getUserId } from "@bitwarden/common/auth/services/account.service";
import { KeyGenerationService } from "@bitwarden/common/key-management/crypto";

View File

@@ -280,8 +280,7 @@ export abstract class KeyService {
* encrypted private key at all.
*
* @param userId The user id of the user to get the data for.
* @returns An observable stream of the decrypted private key or null.
* @throws Error when decryption of the encrypted private key fails.
* @returns An observable stream of the decrypted private key or null if the private key is not present or fails to decrypt
*/
abstract userPrivateKey$(userId: UserId): Observable<UserPrivateKey | null>;

View File

@@ -437,14 +437,13 @@ describe("keyService", () => {
);
});
it("throws an error if unwrapping encrypted private key fails", async () => {
it("emits null if unwrapping encrypted private key fails", async () => {
encryptService.unwrapDecapsulationKey.mockImplementationOnce(() => {
throw new Error("Unwrapping failed");
});
await expect(firstValueFrom(keyService.userPrivateKey$(mockUserId))).rejects.toThrow(
"Unwrapping failed",
);
const result = await firstValueFrom(keyService.userPrivateKey$(mockUserId));
expect(result).toBeNull();
});
it("returns null if user key is not set", async () => {

View File

@@ -791,7 +791,10 @@ export class DefaultKeyService implements KeyServiceAbstraction {
return this.stateProvider.getUser(userId, USER_ENCRYPTED_PRIVATE_KEY).state$;
}
private userPrivateKeyHelper$(userId: UserId) {
private userPrivateKeyHelper$(userId: UserId): Observable<{
userKey: UserKey;
userPrivateKey: UserPrivateKey | null;
} | null> {
const userKey$ = this.userKey$(userId);
return userKey$.pipe(
switchMap((userKey) => {
@@ -801,18 +804,20 @@ export class DefaultKeyService implements KeyServiceAbstraction {
return this.stateProvider.getUser(userId, USER_ENCRYPTED_PRIVATE_KEY).state$.pipe(
switchMap(async (encryptedPrivateKey) => {
try {
return await this.decryptPrivateKey(encryptedPrivateKey, userKey);
} catch (e) {
this.logService.error("Failed to decrypt private key for user ", userId, e);
throw e;
}
return await this.decryptPrivateKey(encryptedPrivateKey, userKey);
}),
// Combine outerscope info with user private key
map((userPrivateKey) => ({
userKey,
userPrivateKey,
})),
catchError((err: unknown) => {
this.logService.error(`Failed to decrypt private key for user ${userId}`);
return of({
userKey,
userPrivateKey: null,
});
}),
);
}),
);

View File

@@ -12,6 +12,8 @@ export abstract class UserAsymmetricKeysRegenerationService {
* Performs the regeneration of the user's public/private key pair without checking any preconditions.
* This should only be used for V1 encryption accounts
* @param userId The user id.
* @returns True if regeneration was performed, false otherwise.
* @throws An error if the regeneration could not be attempted due to missing state
*/
abstract regenerateUserPublicKeyEncryptionKeyPair(userId: UserId): Promise<void>;
abstract regenerateUserPublicKeyEncryptionKeyPair(userId: UserId): Promise<boolean>;
}

View File

@@ -123,7 +123,7 @@ export class DefaultUserAsymmetricKeysRegenerationService implements UserAsymmet
return false;
}
async regenerateUserPublicKeyEncryptionKeyPair(userId: UserId): Promise<void> {
async regenerateUserPublicKeyEncryptionKeyPair(userId: UserId): Promise<boolean> {
const userKey = await firstValueFrom(this.keyService.userKey$(userId));
if (userKey == null) {
throw new Error("User key not found");
@@ -152,19 +152,21 @@ export class DefaultUserAsymmetricKeysRegenerationService implements UserAsymmet
this.logService.info(
"[UserAsymmetricKeyRegeneration] Regeneration not supported for this user at this time.",
);
return false;
} else {
this.logService.error(
"[UserAsymmetricKeyRegeneration] Regeneration error when submitting the request to the server: " +
error,
);
return false;
}
return;
}
await this.keyService.setPrivateKey(makeKeyPairResponse.userKeyEncryptedPrivateKey, userId);
this.logService.info(
"[UserAsymmetricKeyRegeneration] User's asymmetric keys successfully regenerated.",
);
return true;
}
private async userKeyCanDecrypt(userKey: UserKey, userId: UserId): Promise<boolean> {

View File

@@ -3,13 +3,13 @@
import * as papa from "papaparse";
import { filter, firstValueFrom, map } from "rxjs";
import { CollectionService } from "@bitwarden/admin-console/common";
import {
CollectionService,
CollectionData,
Collection,
CollectionDetailsResponse,
CollectionView,
} from "@bitwarden/admin-console/common";
CollectionDetailsResponse,
Collection,
CollectionData,
} from "@bitwarden/common/admin-console/models/collections";
import { KeyGenerationService } from "@bitwarden/common/key-management/crypto";
import { CryptoFunctionService } from "@bitwarden/common/key-management/crypto/abstractions/crypto-function.service";
import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service";

Some files were not shown because too many files have changed in this diff Show More