mirror of
https://github.com/bitwarden/browser
synced 2026-02-02 17:53:41 +00:00
Merge branch 'main' into vault/pm-27632/sdk-cipher-ops
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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";
|
||||
|
||||
204
libs/angular/src/vault/services/custom-nudges-services/README.md
Normal file
204
libs/angular/src/vault/services/custom-nudges-services/README.md
Normal 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],
|
||||
}),
|
||||
```
|
||||
@@ -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([
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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";
|
||||
|
||||
Reference in New Issue
Block a user