1
0
mirror of https://github.com/bitwarden/browser synced 2025-12-06 00:13:28 +00:00

feat(user-decryption-options) [PM-26413]: Remove ActiveUserState from UserDecryptionOptionsService (#16894)

* feat(user-decryption-options) [PM-26413]: Update UserDecryptionOptionsService and tests to use UserId-only APIs.

* feat(user-decryption-options) [PM-26413]: Update InternalUserDecryptionOptionsService call sites to use UserId-only API.

* feat(user-decryption-options) [PM-26413] Update userDecryptionOptions$ call sites to use the UserId-only API.

* feat(user-decryption-options) [PM-26413]: Update additional call sites.

* feat(user-decryption-options) [PM-26413]: Update dependencies and an additional call site.

* feat(user-verification-service) [PM-26413]: Replace where allowed by unrestricted imports invocation of UserVerificationService.hasMasterPassword (deprecated) with UserDecryptionOptions.hasMasterPasswordById$. Additional work to complete as tech debt tracked in PM-27009.

* feat(user-decryption-options) [PM-26413]: Update for non-null strict adherence.

* feat(user-decryption-options) [PM-26413]: Update type safety and defensive returns.

* chore(user-decryption-options) [PM-26413]: Comment cleanup.

* feat(user-decryption-options) [PM-26413]: Update tests.

* feat(user-decryption-options) [PM-26413]: Standardize null-checking on active account id for new API consumption.

* feat(vault-timeout-settings-service) [PM-26413]: Add test cases to illustrate null active account from AccountService.

* fix(fido2-user-verification-service-spec) [PM-26413]: Update test harness to use FakeAccountService.

* fix(downstream-components) [PM-26413]: Prefer use of the getUserId operator in all authenticated contexts for user id provided to UserDecryptionOptionsService.

---------

Co-authored-by: bnagawiecki <107435978+bnagawiecki@users.noreply.github.com>
This commit is contained in:
Dave
2025-11-25 11:23:22 -05:00
committed by GitHub
parent c04c1757ea
commit cf6569bfea
33 changed files with 280 additions and 172 deletions

View File

@@ -198,10 +198,13 @@ export class DefaultSetInitialPasswordService implements SetInitialPasswordServi
userId: UserId,
) {
const userDecryptionOpts = await firstValueFrom(
this.userDecryptionOptionsService.userDecryptionOptions$,
this.userDecryptionOptionsService.userDecryptionOptionsById$(userId),
);
userDecryptionOpts.hasMasterPassword = true;
await this.userDecryptionOptionsService.setUserDecryptionOptions(userDecryptionOpts);
await this.userDecryptionOptionsService.setUserDecryptionOptionsById(
userId,
userDecryptionOpts,
);
await this.kdfConfigService.setKdfConfig(userId, kdfConfig);
await this.masterPasswordService.setMasterKey(masterKey, userId);
await this.keyService.setUserKey(masterKeyEncryptedUserKey[0], userId);

View File

@@ -149,7 +149,9 @@ describe("DefaultSetInitialPasswordService", () => {
userDecryptionOptions = new UserDecryptionOptions({ hasMasterPassword: true });
userDecryptionOptionsSubject = new BehaviorSubject(userDecryptionOptions);
userDecryptionOptionsService.userDecryptionOptions$ = userDecryptionOptionsSubject;
userDecryptionOptionsService.userDecryptionOptionsById$.mockReturnValue(
userDecryptionOptionsSubject,
);
setPasswordRequest = new SetPasswordRequest(
credentials.newServerMasterKeyHash,
@@ -362,7 +364,8 @@ describe("DefaultSetInitialPasswordService", () => {
// Assert
expect(masterPasswordApiService.setPassword).toHaveBeenCalledWith(setPasswordRequest);
expect(userDecryptionOptionsService.setUserDecryptionOptions).toHaveBeenCalledWith(
expect(userDecryptionOptionsService.setUserDecryptionOptionsById).toHaveBeenCalledWith(
userId,
userDecryptionOptions,
);
expect(kdfConfigService.setKdfConfig).toHaveBeenCalledWith(userId, credentials.kdfConfig);
@@ -560,7 +563,8 @@ describe("DefaultSetInitialPasswordService", () => {
// Assert
expect(masterPasswordApiService.setPassword).toHaveBeenCalledWith(setPasswordRequest);
expect(userDecryptionOptionsService.setUserDecryptionOptions).toHaveBeenCalledWith(
expect(userDecryptionOptionsService.setUserDecryptionOptionsById).toHaveBeenCalledWith(
userId,
userDecryptionOptions,
);
expect(kdfConfigService.setKdfConfig).toHaveBeenCalledWith(userId, credentials.kdfConfig);

View File

@@ -684,7 +684,7 @@ const safeProviders: SafeProvider[] = [
safeProvider({
provide: InternalUserDecryptionOptionsServiceAbstraction,
useClass: UserDecryptionOptionsService,
deps: [StateProvider],
deps: [SingleUserStateProvider],
}),
safeProvider({
provide: UserDecryptionOptionsServiceAbstraction,
@@ -1292,6 +1292,7 @@ const safeProviders: SafeProvider[] = [
UserDecryptionOptionsServiceAbstraction,
LogService,
ConfigService,
AccountServiceAbstraction,
],
}),
safeProvider({

View File

@@ -135,7 +135,7 @@ export class LoginDecryptionOptionsComponent implements OnInit {
try {
const userDecryptionOptions = await firstValueFrom(
this.userDecryptionOptionsService.userDecryptionOptions$,
this.userDecryptionOptionsService.userDecryptionOptionsById$(this.activeAccountId),
);
if (

View File

@@ -460,7 +460,7 @@ export class SsoComponent implements OnInit {
// must come after 2fa check since user decryption options aren't available if 2fa is required
const userDecryptionOpts = await firstValueFrom(
this.userDecryptionOptionsService.userDecryptionOptions$,
this.userDecryptionOptionsService.userDecryptionOptionsById$(authResult.userId),
);
const tdeEnabled = userDecryptionOpts.trustedDeviceOption

View File

@@ -176,7 +176,9 @@ describe("TwoFactorAuthComponent", () => {
selectedUserDecryptionOptions = new BehaviorSubject<UserDecryptionOptions>(
mockUserDecryptionOpts.withMasterPassword,
);
mockUserDecryptionOptionsService.userDecryptionOptions$ = selectedUserDecryptionOptions;
mockUserDecryptionOptionsService.userDecryptionOptionsById$.mockReturnValue(
selectedUserDecryptionOptions,
);
TestBed.configureTestingModule({
declarations: [TestTwoFactorComponent],

View File

@@ -473,7 +473,7 @@ export class TwoFactorAuthComponent implements OnInit, OnDestroy {
}
const userDecryptionOpts = await firstValueFrom(
this.userDecryptionOptionsService.userDecryptionOptions$,
this.userDecryptionOptionsService.userDecryptionOptionsById$(authResult.userId),
);
const tdeEnabled = await this.isTrustedDeviceEncEnabled(userDecryptionOpts.trustedDeviceOption);

View File

@@ -1,34 +1,45 @@
import { Observable } from "rxjs";
import { UserId } from "@bitwarden/common/types/guid";
import { UserDecryptionOptions } from "../models";
/**
* Public service for reading user decryption options.
* For use in components and services that need to evaluate user decryption settings.
*/
export abstract class UserDecryptionOptionsServiceAbstraction {
/**
* Returns what decryption options are available for the current user.
* @remark This is sent from the server on authentication.
* Returns the user decryption options for the given user id.
* Will only emit when options are set (does not emit null/undefined
* for an unpopulated state), and should not be called in an unauthenticated context.
* @param userId The user id to check.
*/
abstract userDecryptionOptions$: Observable<UserDecryptionOptions>;
abstract userDecryptionOptionsById$(userId: UserId): Observable<UserDecryptionOptions>;
/**
* Uses user decryption options to determine if current user has a master password.
* @remark This is sent from the server, and does not indicate if the master password
* was used to login and/or if a master key is saved locally.
*/
abstract hasMasterPassword$: Observable<boolean>;
/**
* Returns the user decryption options for the given user id.
* @param userId The user id to check.
*/
abstract userDecryptionOptionsById$(userId: string): Observable<UserDecryptionOptions>;
abstract hasMasterPasswordById$(userId: UserId): Observable<boolean>;
}
/**
* Internal service for managing user decryption options.
* For use only in authentication flows that need to update decryption options
* (e.g., login strategies). Extends consumer methods from {@link UserDecryptionOptionsServiceAbstraction}.
* @remarks Most consumers should use UserDecryptionOptionsServiceAbstraction instead.
*/
export abstract class InternalUserDecryptionOptionsServiceAbstraction extends UserDecryptionOptionsServiceAbstraction {
/**
* Sets the current decryption options for the user, contains the current configuration
* Sets the current decryption options for the user. Contains the current configuration
* of the users account related to how they can decrypt their vault.
* @remark Intended to be used when user decryption options are received from server, does
* not update the server. Consider syncing instead of updating locally.
* @param userDecryptionOptions Current user decryption options received from server.
*/
abstract setUserDecryptionOptions(userDecryptionOptions: UserDecryptionOptions): Promise<void>;
abstract setUserDecryptionOptionsById(
userId: UserId,
userDecryptionOptions: UserDecryptionOptions,
): Promise<void>;
}

View File

@@ -257,7 +257,8 @@ describe("LoginStrategy", () => {
expect(environmentService.seedUserEnvironment).toHaveBeenCalled();
expect(userDecryptionOptionsService.setUserDecryptionOptions).toHaveBeenCalledWith(
expect(userDecryptionOptionsService.setUserDecryptionOptionsById).toHaveBeenCalledWith(
userId,
UserDecryptionOptions.fromResponse(idTokenResponse),
);
expect(masterPasswordService.mock.setMasterPasswordUnlockData).toHaveBeenCalledWith(

View File

@@ -195,7 +195,8 @@ export abstract class LoginStrategy {
// We must set user decryption options before retrieving vault timeout settings
// as the user decryption options help determine the available timeout actions.
await this.userDecryptionOptionsService.setUserDecryptionOptions(
await this.userDecryptionOptionsService.setUserDecryptionOptionsById(
userId,
UserDecryptionOptions.fromResponse(tokenResponse),
);

View File

@@ -134,7 +134,9 @@ describe("SsoLoginStrategy", () => {
);
const userDecryptionOptions = new UserDecryptionOptions();
userDecryptionOptionsService.userDecryptionOptions$ = of(userDecryptionOptions);
userDecryptionOptionsService.userDecryptionOptionsById$.mockReturnValue(
of(userDecryptionOptions),
);
ssoLoginStrategy = new SsoLoginStrategy(
{} as SsoLoginStrategyData,

View File

@@ -393,7 +393,7 @@ export class SsoLoginStrategy extends LoginStrategy {
// Check for TDE-related conditions
const userDecryptionOptions = await firstValueFrom(
this.userDecryptionOptionsService.userDecryptionOptions$,
this.userDecryptionOptionsService.userDecryptionOptionsById$(userId),
);
if (!userDecryptionOptions) {

View File

@@ -1,12 +1,8 @@
import { firstValueFrom } from "rxjs";
import { Utils } from "@bitwarden/common/platform/misc/utils";
import {
FakeAccountService,
FakeStateProvider,
mockAccountServiceWith,
} from "@bitwarden/common/spec";
import { FakeSingleUserStateProvider } from "@bitwarden/common/spec";
import { UserId } from "@bitwarden/common/types/guid";
import { newGuid } from "@bitwarden/guid";
import { UserDecryptionOptions } from "../../models/domain/user-decryption-options";
@@ -17,15 +13,10 @@ import {
describe("UserDecryptionOptionsService", () => {
let sut: UserDecryptionOptionsService;
const fakeUserId = Utils.newGuid() as UserId;
let fakeAccountService: FakeAccountService;
let fakeStateProvider: FakeStateProvider;
let fakeStateProvider: FakeSingleUserStateProvider;
beforeEach(() => {
fakeAccountService = mockAccountServiceWith(fakeUserId);
fakeStateProvider = new FakeStateProvider(fakeAccountService);
fakeStateProvider = new FakeSingleUserStateProvider();
sut = new UserDecryptionOptionsService(fakeStateProvider);
});
@@ -42,55 +33,71 @@ describe("UserDecryptionOptionsService", () => {
},
};
describe("userDecryptionOptions$", () => {
it("should return the active user's decryption options", async () => {
await fakeStateProvider.setUserState(USER_DECRYPTION_OPTIONS, userDecryptionOptions);
describe("userDecryptionOptionsById$", () => {
it("should return user decryption options for a specific user", async () => {
const userId = newGuid() as UserId;
const result = await firstValueFrom(sut.userDecryptionOptions$);
fakeStateProvider.getFake(userId, USER_DECRYPTION_OPTIONS).nextState(userDecryptionOptions);
const result = await firstValueFrom(sut.userDecryptionOptionsById$(userId));
expect(result).toEqual(userDecryptionOptions);
});
});
describe("hasMasterPassword$", () => {
it("should return the hasMasterPassword property of the active user's decryption options", async () => {
await fakeStateProvider.setUserState(USER_DECRYPTION_OPTIONS, userDecryptionOptions);
describe("hasMasterPasswordById$", () => {
it("should return true when user has a master password", async () => {
const userId = newGuid() as UserId;
const result = await firstValueFrom(sut.hasMasterPassword$);
fakeStateProvider.getFake(userId, USER_DECRYPTION_OPTIONS).nextState(userDecryptionOptions);
const result = await firstValueFrom(sut.hasMasterPasswordById$(userId));
expect(result).toBe(true);
});
});
describe("userDecryptionOptionsById$", () => {
it("should return the user decryption options for the given user", async () => {
const givenUser = Utils.newGuid() as UserId;
await fakeAccountService.addAccount(givenUser, {
name: "Test User 1",
email: "test1@email.com",
emailVerified: false,
});
await fakeStateProvider.setUserState(
USER_DECRYPTION_OPTIONS,
userDecryptionOptions,
givenUser,
);
it("should return false when user does not have a master password", async () => {
const userId = newGuid() as UserId;
const optionsWithoutMasterPassword = {
...userDecryptionOptions,
hasMasterPassword: false,
};
const result = await firstValueFrom(sut.userDecryptionOptionsById$(givenUser));
fakeStateProvider
.getFake(userId, USER_DECRYPTION_OPTIONS)
.nextState(optionsWithoutMasterPassword);
expect(result).toEqual(userDecryptionOptions);
const result = await firstValueFrom(sut.hasMasterPasswordById$(userId));
expect(result).toBe(false);
});
});
describe("setUserDecryptionOptions", () => {
it("should set the active user's decryption options", async () => {
await sut.setUserDecryptionOptions(userDecryptionOptions);
describe("setUserDecryptionOptionsById", () => {
it("should set user decryption options for a specific user", async () => {
const userId = newGuid() as UserId;
const result = await firstValueFrom(
fakeStateProvider.getActive(USER_DECRYPTION_OPTIONS).state$,
);
await sut.setUserDecryptionOptionsById(userId, userDecryptionOptions);
const fakeState = fakeStateProvider.getFake(userId, USER_DECRYPTION_OPTIONS);
const result = await firstValueFrom(fakeState.state$);
expect(result).toEqual(userDecryptionOptions);
});
it("should overwrite existing user decryption options", async () => {
const userId = newGuid() as UserId;
const initialOptions = { ...userDecryptionOptions, hasMasterPassword: false };
const updatedOptions = { ...userDecryptionOptions, hasMasterPassword: true };
const fakeState = fakeStateProvider.getFake(userId, USER_DECRYPTION_OPTIONS);
fakeState.nextState(initialOptions);
await sut.setUserDecryptionOptionsById(userId, updatedOptions);
const result = await firstValueFrom(fakeState.state$);
expect(result).toEqual(updatedOptions);
});
});
});

View File

@@ -1,16 +1,11 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { Observable, map } from "rxjs";
import { Observable, filter, map } from "rxjs";
import {
ActiveUserState,
StateProvider,
SingleUserStateProvider,
USER_DECRYPTION_OPTIONS_DISK,
UserKeyDefinition,
} from "@bitwarden/common/platform/state";
// FIXME: remove `src` and fix import
// eslint-disable-next-line no-restricted-imports
import { UserId } from "@bitwarden/common/src/types/guid";
import { UserId } from "@bitwarden/common/types/guid";
import { InternalUserDecryptionOptionsServiceAbstraction } from "../../abstractions/user-decryption-options.service.abstraction";
import { UserDecryptionOptions } from "../../models";
@@ -27,25 +22,26 @@ export const USER_DECRYPTION_OPTIONS = new UserKeyDefinition<UserDecryptionOptio
export class UserDecryptionOptionsService
implements InternalUserDecryptionOptionsServiceAbstraction
{
private userDecryptionOptionsState: ActiveUserState<UserDecryptionOptions>;
constructor(private singleUserStateProvider: SingleUserStateProvider) {}
userDecryptionOptions$: Observable<UserDecryptionOptions>;
hasMasterPassword$: Observable<boolean>;
userDecryptionOptionsById$(userId: UserId): Observable<UserDecryptionOptions> {
return this.singleUserStateProvider
.get(userId, USER_DECRYPTION_OPTIONS)
.state$.pipe(filter((options): options is UserDecryptionOptions => options != null));
}
constructor(private stateProvider: StateProvider) {
this.userDecryptionOptionsState = this.stateProvider.getActive(USER_DECRYPTION_OPTIONS);
this.userDecryptionOptions$ = this.userDecryptionOptionsState.state$;
this.hasMasterPassword$ = this.userDecryptionOptions$.pipe(
map((options) => options?.hasMasterPassword ?? false),
hasMasterPasswordById$(userId: UserId): Observable<boolean> {
return this.userDecryptionOptionsById$(userId).pipe(
map((options) => options.hasMasterPassword ?? false),
);
}
userDecryptionOptionsById$(userId: UserId): Observable<UserDecryptionOptions> {
return this.stateProvider.getUser(userId, USER_DECRYPTION_OPTIONS).state$;
}
async setUserDecryptionOptions(userDecryptionOptions: UserDecryptionOptions): Promise<void> {
await this.userDecryptionOptionsState.update((_) => userDecryptionOptions);
async setUserDecryptionOptionsById(
userId: UserId,
userDecryptionOptions: UserDecryptionOptions,
): Promise<void> {
await this.singleUserStateProvider
.get(userId, USER_DECRYPTION_OPTIONS)
.update((_) => userDecryptionOptions);
}
}

View File

@@ -48,6 +48,9 @@ export abstract class UserVerificationService {
* @param userId The user id to check. If not provided, the current user is used
* @returns True if the user has a master password
* @deprecated Use UserDecryptionOptionsService.hasMasterPassword$ instead
* @remark To facilitate deprecation, many call sites were removed as part of PM-26413.
* Those remaining are blocked by currently-disallowed imports of auth/common.
* PM-27009 has been filed to track completion of this deprecation.
*/
abstract hasMasterPassword(userId?: string): Promise<boolean>;
/**

View File

@@ -3,10 +3,7 @@ import { 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 {
UserDecryptionOptions,
UserDecryptionOptionsServiceAbstraction,
} from "@bitwarden/auth/common";
import { UserDecryptionOptionsServiceAbstraction } from "@bitwarden/auth/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 {
@@ -146,11 +143,7 @@ describe("UserVerificationService", () => {
describe("server verification type", () => {
it("correctly returns master password availability", async () => {
userDecryptionOptionsService.userDecryptionOptionsById$.mockReturnValue(
of({
hasMasterPassword: true,
} as UserDecryptionOptions),
);
userDecryptionOptionsService.hasMasterPasswordById$.mockReturnValue(of(true));
const result = await sut.getAvailableVerificationOptions("server");
@@ -168,11 +161,7 @@ describe("UserVerificationService", () => {
});
it("correctly returns OTP availability", async () => {
userDecryptionOptionsService.userDecryptionOptionsById$.mockReturnValue(
of({
hasMasterPassword: false,
} as UserDecryptionOptions),
);
userDecryptionOptionsService.hasMasterPasswordById$.mockReturnValue(of(false));
const result = await sut.getAvailableVerificationOptions("server");
@@ -526,11 +515,7 @@ describe("UserVerificationService", () => {
// Helpers
function setMasterPasswordAvailability(hasMasterPassword: boolean) {
userDecryptionOptionsService.userDecryptionOptionsById$.mockReturnValue(
of({
hasMasterPassword: hasMasterPassword,
} as UserDecryptionOptions),
);
userDecryptionOptionsService.hasMasterPasswordById$.mockReturnValue(of(hasMasterPassword));
masterPasswordService.masterKeyHash$.mockReturnValue(
of(hasMasterPassword ? "masterKeyHash" : null),
);

View File

@@ -258,16 +258,19 @@ export class UserVerificationService implements UserVerificationServiceAbstracti
}
async hasMasterPassword(userId?: string): Promise<boolean> {
if (userId) {
const decryptionOptions = await firstValueFrom(
this.userDecryptionOptionsService.userDecryptionOptionsById$(userId),
);
const resolvedUserId = userId ?? (await firstValueFrom(this.accountService.activeAccount$))?.id;
if (decryptionOptions?.hasMasterPassword != undefined) {
return decryptionOptions.hasMasterPassword;
}
if (!resolvedUserId) {
return false;
}
return await firstValueFrom(this.userDecryptionOptionsService.hasMasterPassword$);
// Ideally, this method would accept a UserId over string. To avoid scope creep in PM-26413, we are
// doing the cast here. Future work should be done to make this type-safe, and should be considered
// as part of PM-27009.
return await firstValueFrom(
this.userDecryptionOptionsService.hasMasterPasswordById$(resolvedUserId as UserId),
);
}
async hasMasterPasswordAndMasterKeyHash(userId?: string): Promise<boolean> {

View File

@@ -1,6 +1,6 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { firstValueFrom, map, Observable, Subject } from "rxjs";
import { firstValueFrom, map, Observable, Subject, switchMap } 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
@@ -9,6 +9,7 @@ import { UserDecryptionOptionsServiceAbstraction } from "@bitwarden/auth/common"
// eslint-disable-next-line no-restricted-imports
import { KeyService } from "@bitwarden/key-management";
import { AccountService } from "../../../auth/abstractions/account.service";
import { DeviceResponse } from "../../../auth/abstractions/devices/responses/device.response";
import { DevicesApiServiceAbstraction } from "../../../auth/abstractions/devices-api.service.abstraction";
import { SecretVerificationRequest } from "../../../auth/models/request/secret-verification.request";
@@ -87,10 +88,18 @@ export class DeviceTrustService implements DeviceTrustServiceAbstraction {
private userDecryptionOptionsService: UserDecryptionOptionsServiceAbstraction,
private logService: LogService,
private configService: ConfigService,
private accountService: AccountService,
) {
this.supportsDeviceTrust$ = this.userDecryptionOptionsService.userDecryptionOptions$.pipe(
map((options) => {
return options?.trustedDeviceOption != null;
this.supportsDeviceTrust$ = this.accountService.activeAccount$.pipe(
switchMap((account) => {
if (account == null) {
return [false];
}
return this.userDecryptionOptionsService.userDecryptionOptionsById$(account.id).pipe(
map((options) => {
return options?.trustedDeviceOption != null;
}),
);
}),
);
}

View File

@@ -914,7 +914,7 @@ describe("deviceTrustService", () => {
platformUtilsService.supportsSecureStorage.mockReturnValue(supportsSecureStorage);
decryptionOptions.next({} as any);
userDecryptionOptionsService.userDecryptionOptions$ = decryptionOptions;
userDecryptionOptionsService.userDecryptionOptionsById$.mockReturnValue(decryptionOptions);
return new DeviceTrustService(
keyGenerationService,
@@ -930,6 +930,7 @@ describe("deviceTrustService", () => {
userDecryptionOptionsService,
logService,
configService,
accountService,
);
}
});

View File

@@ -53,9 +53,11 @@ describe("VaultTimeoutSettingsService", () => {
policyService = mock<PolicyService>();
userDecryptionOptionsSubject = new BehaviorSubject(null);
userDecryptionOptionsService.userDecryptionOptions$ = userDecryptionOptionsSubject;
userDecryptionOptionsService.hasMasterPassword$ = userDecryptionOptionsSubject.pipe(
map((options) => options?.hasMasterPassword ?? false),
userDecryptionOptionsService.userDecryptionOptionsById$.mockReturnValue(
userDecryptionOptionsSubject,
);
userDecryptionOptionsService.hasMasterPasswordById$.mockReturnValue(
userDecryptionOptionsSubject.pipe(map((options) => options?.hasMasterPassword ?? false)),
);
userDecryptionOptionsService.userDecryptionOptionsById$.mockReturnValue(
userDecryptionOptionsSubject,
@@ -127,6 +129,23 @@ describe("VaultTimeoutSettingsService", () => {
expect(result).not.toContain(VaultTimeoutAction.Lock);
});
it("should return only LogOut when userId is not provided and there is no active account", async () => {
// Set up accountService to return null for activeAccount
accountService.activeAccount$ = of(null);
pinStateService.isPinSet.mockResolvedValue(false);
biometricStateService.biometricUnlockEnabled$ = of(false);
// Call availableVaultTimeoutActions$ which internally calls userHasMasterPassword without a userId
const result = await firstValueFrom(
vaultTimeoutSettingsService.availableVaultTimeoutActions$(),
);
// Since there's no active account, userHasMasterPassword returns false,
// meaning no master password is available, so Lock should not be available
expect(result).toEqual([VaultTimeoutAction.LogOut]);
expect(result).not.toContain(VaultTimeoutAction.Lock);
});
});
describe("canLock", () => {

View File

@@ -290,14 +290,19 @@ export class VaultTimeoutSettingsService implements VaultTimeoutSettingsServiceA
}
private async userHasMasterPassword(userId: string): Promise<boolean> {
let resolvedUserId: UserId;
if (userId) {
const decryptionOptions = await firstValueFrom(
this.userDecryptionOptionsService.userDecryptionOptionsById$(userId),
);
return !!decryptionOptions?.hasMasterPassword;
resolvedUserId = userId as UserId;
} else {
return await firstValueFrom(this.userDecryptionOptionsService.hasMasterPassword$);
const activeAccount = await firstValueFrom(this.accountService.activeAccount$);
if (!activeAccount) {
return false; // No account, can't have master password
}
resolvedUserId = activeAccount.id;
}
return await firstValueFrom(
this.userDecryptionOptionsService.hasMasterPasswordById$(resolvedUserId),
);
}
}