1
0
mirror of https://github.com/bitwarden/browser synced 2026-02-21 03:43:58 +00:00

Merge branch 'main' into ps/extension-refresh

This commit is contained in:
Vicki League
2024-08-27 10:55:30 -04:00
154 changed files with 2288 additions and 1265 deletions

View File

@@ -30,6 +30,7 @@ import { MessagingService } from "@bitwarden/common/platform/abstractions/messag
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { StateService } from "@bitwarden/common/platform/abstractions/state.service";
import { BiometricStateService } from "@bitwarden/common/platform/biometrics/biometric-state.service";
import { BiometricsService } from "@bitwarden/common/platform/biometrics/biometric.service";
import { KeySuffixOptions } from "@bitwarden/common/platform/enums";
import { PasswordStrengthServiceAbstraction } from "@bitwarden/common/tools/password-strength";
import { UserId } from "@bitwarden/common/types/guid";
@@ -84,6 +85,7 @@ export class LockComponent implements OnInit, OnDestroy {
protected userVerificationService: UserVerificationService,
protected pinService: PinServiceAbstraction,
protected biometricStateService: BiometricStateService,
protected biometricsService: BiometricsService,
protected accountService: AccountService,
protected authService: AuthService,
protected kdfConfigService: KdfConfigService,
@@ -146,6 +148,13 @@ export class LockComponent implements OnInit, OnDestroy {
return !!userKey;
}
async isBiometricUnlockAvailable(): Promise<boolean> {
if (!(await this.biometricsService.supportsBiometric())) {
return false;
}
return this.biometricsService.isBiometricUnlockAvailable();
}
togglePassword() {
this.showPassword = !this.showPassword;
const input = document.getElementById(this.pinEnabled ? "pin" : "masterPassword");
@@ -327,7 +336,7 @@ export class LockComponent implements OnInit, OnDestroy {
this.masterPasswordEnabled = await this.userVerificationService.hasMasterPassword();
this.supportsBiometric = await this.platformUtilsService.supportsBiometric();
this.supportsBiometric = await this.biometricsService.supportsBiometric();
this.biometricLock =
(await this.vaultTimeoutSettingsService.isBiometricLockSet()) &&
((await this.cryptoService.hasUserKeyStored(KeySuffixOptions.Biometric)) ||

View File

@@ -23,6 +23,7 @@ import { LogService } from "@bitwarden/common/platform/abstractions/log.service"
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { StateService } from "@bitwarden/common/platform/abstractions/state.service";
import { Utils } from "@bitwarden/common/platform/misc/utils";
import { UserId } from "@bitwarden/common/types/guid";
import { PasswordGenerationServiceAbstraction } from "@bitwarden/generator-legacy";
import {
@@ -39,7 +40,7 @@ export class LoginComponent extends CaptchaProtectedComponent implements OnInit,
showPassword = false;
formPromise: Promise<AuthResult>;
onSuccessfulLogin: () => Promise<any>;
onSuccessfulLoginNavigate: () => Promise<any>;
onSuccessfulLoginNavigate: (userId: UserId) => Promise<any>;
onSuccessfulLoginTwoFactorNavigate: () => Promise<any>;
onSuccessfulLoginForceResetNavigate: () => Promise<any>;
showLoginWithDevice: boolean;
@@ -185,7 +186,7 @@ export class LoginComponent extends CaptchaProtectedComponent implements OnInit,
if (this.onSuccessfulLoginNavigate != null) {
// FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling.
// eslint-disable-next-line @typescript-eslint/no-floating-promises
this.onSuccessfulLoginNavigate();
this.onSuccessfulLoginNavigate(response.userId);
} else {
this.loginEmailService.clearValues();
// FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling.

View File

@@ -141,7 +141,7 @@
</ng-container>
<!-- Account Credit -->
<ng-container *ngIf="showAccountCredit && usingAccountCredit">
<app-callout type="note">
<app-callout type="info">
{{ "makeSureEnoughCredit" | i18n }}
</app-callout>
</ng-container>

View File

@@ -1,14 +1,5 @@
<div
#callout
class="callout callout-{{ calloutStyle }}"
[ngClass]="{ clickable: clickable }"
[attr.role]="useAlertRole ? 'alert' : null"
>
<h3 class="callout-heading" *ngIf="title">
<i class="bwi {{ icon }}" *ngIf="icon" aria-hidden="true"></i>
{{ title }}
</h3>
<div class="enforced-policy-options" *ngIf="enforcedPolicyOptions">
<bit-callout [icon]="icon" [title]="title" [type]="$any(type)" [useAlertRole]="useAlertRole">
<div class="tw-pl-7 tw-m-0" *ngIf="enforcedPolicyOptions">
{{ enforcedPolicyMessage }}
<ul>
<li *ngIf="enforcedPolicyOptions?.minComplexity > 0">
@@ -32,4 +23,4 @@
</ul>
</div>
<ng-content></ng-content>
</div>
</bit-callout>

View File

@@ -2,16 +2,19 @@ import { Component, Input, OnInit } from "@angular/core";
import { MasterPasswordPolicyOptions } from "@bitwarden/common/admin-console/models/domain/master-password-policy-options";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { CalloutTypes } from "@bitwarden/components";
/**
* @deprecated use the CL's `CalloutComponent` instead
*/
@Component({
selector: "app-callout",
templateUrl: "callout.component.html",
})
export class CalloutComponent implements OnInit {
@Input() type = "info";
export class DeprecatedCalloutComponent implements OnInit {
@Input() type: CalloutTypes = "info";
@Input() icon: string;
@Input() title: string;
@Input() clickable: boolean;
@Input() enforcedPolicyOptions: MasterPasswordPolicyOptions;
@Input() enforcedPolicyMessage: string;
@Input() useAlertRole = false;
@@ -26,34 +29,6 @@ export class CalloutComponent implements OnInit {
if (this.enforcedPolicyMessage === undefined) {
this.enforcedPolicyMessage = this.i18nService.t("masterPasswordPolicyInEffect");
}
if (this.type === "warning" || this.type === "danger") {
if (this.type === "danger") {
this.calloutStyle = "danger";
}
if (this.title === undefined) {
this.title = this.i18nService.t("warning");
}
if (this.icon === undefined) {
this.icon = "bwi-exclamation-triangle";
}
} else if (this.type === "error") {
this.calloutStyle = "danger";
if (this.title === undefined) {
this.title = this.i18nService.t("error");
}
if (this.icon === undefined) {
this.icon = "bwi-error";
}
} else if (this.type === "tip") {
this.calloutStyle = "success";
if (this.title === undefined) {
this.title = this.i18nService.t("tip");
}
if (this.icon === undefined) {
this.icon = "bwi-lightbulb";
}
}
}
getPasswordScoreAlertDisplay() {

View File

@@ -14,6 +14,7 @@ import {
AsyncActionsModule,
AutofocusDirective,
ButtonModule,
CalloutModule,
CheckboxModule,
DialogModule,
FormFieldModule,
@@ -29,7 +30,7 @@ import {
} from "@bitwarden/components";
import { TwoFactorIconComponent } from "./auth/components/two-factor-icon.component";
import { CalloutComponent } from "./components/callout.component";
import { DeprecatedCalloutComponent } from "./components/callout.component";
import { A11yInvalidDirective } from "./directives/a11y-invalid.directive";
import { A11yTitleDirective } from "./directives/a11y-title.directive";
import { ApiActionDirective } from "./directives/api-action.directive";
@@ -72,6 +73,7 @@ import { IconComponent } from "./vault/components/icon.component";
FormFieldModule,
SelectModule,
ButtonModule,
CalloutModule,
CheckboxModule,
DialogModule,
TypographyModule,
@@ -88,7 +90,7 @@ import { IconComponent } from "./vault/components/icon.component";
ApiActionDirective,
AutofocusDirective,
BoxRowDirective,
CalloutComponent,
DeprecatedCalloutComponent,
CopyTextDirective,
CreditCardNumberPipe,
EllipsisPipe,
@@ -125,7 +127,7 @@ import { IconComponent } from "./vault/components/icon.component";
AutofocusDirective,
ToastModule,
BoxRowDirective,
CalloutComponent,
DeprecatedCalloutComponent,
CopyTextDirective,
CreditCardNumberPipe,
EllipsisPipe,

View File

@@ -0,0 +1,83 @@
import { Injector, WritableSignal } from "@angular/core";
import type { FormGroup } from "@angular/forms";
import type { Jsonify, JsonValue } from "type-fest";
type Deserializer<T> = {
/**
* A function to use to safely convert your type from json to your expected type.
*
* @param jsonValue The JSON object representation of your state.
* @returns The fully typed version of your state.
*/
readonly deserializer?: (jsonValue: Jsonify<T>) => T;
};
type BaseCacheOptions<T> = {
/** A unique key for saving the cached value to state */
key: string;
/** An optional injector. Required if the method is called outside of an injection context. */
injector?: Injector;
} & (T extends JsonValue ? Deserializer<T> : Required<Deserializer<T>>);
export type SignalCacheOptions<T> = BaseCacheOptions<T> & {
/** The initial value for the signal. */
initialValue: T;
};
/** Extract the value type from a FormGroup */
type FormValue<TFormGroup extends FormGroup> = TFormGroup["value"];
export type FormCacheOptions<TFormGroup extends FormGroup> = BaseCacheOptions<
FormValue<TFormGroup>
> & {
control: TFormGroup;
};
/**
* Cache for temporary component state
*
* #### Implementations
* - browser extension popup: used to persist UI between popup open and close
* - all other clients: noop
*/
export abstract class ViewCacheService {
/**
* Create a signal from a previously cached value. Whenever the signal is updated, the new value is saved to the cache.
*
* Non browser extension implementations are noop and return a normal signal.
*
* @returns the created signal
*
* @example
* ```ts
* const mySignal = this.viewCacheService.signal({
* key: "popup-search-text"
* initialValue: ""
* });
* ```
*/
abstract signal<T>(options: SignalCacheOptions<T>): WritableSignal<T>;
/**
* - Initialize a form from a cached value
* - Save form value to cache when it changes
* - The form is marked dirty if the restored value is not `undefined`.
*
* Non browser extension implementations are noop and return the original form group.
*
* @example
* ```ts
* this.loginDetailsForm = this.viewCacheService.formGroup({
* key: "vault-login-details-form",
* control: this.formBuilder.group({
* username: [""],
* email: [""],
* })
* });
* ```
**/
abstract formGroup<TFormGroup extends FormGroup>(
options: FormCacheOptions<TFormGroup>,
): TFormGroup;
}

View File

@@ -0,0 +1,33 @@
import { Injectable, signal, WritableSignal } from "@angular/core";
import type { FormGroup } from "@angular/forms";
import {
FormCacheOptions,
SignalCacheOptions,
ViewCacheService,
} from "../abstractions/view-cache.service";
/**
* The functionality of the {@link ViewCacheService} is only needed in the browser extension popup,
* yet is provided to all clients to make sharing components easier.
*
* Non-extension clients use this noop implementation.
* */
@Injectable({
providedIn: "root",
})
export class NoopViewCacheService implements ViewCacheService {
/**
* Return a normal signal.
*/
signal<T>(options: SignalCacheOptions<T>): WritableSignal<T> {
return signal(options.initialValue);
}
/**
* Return the original form group.
**/
formGroup<TFormGroup extends FormGroup>(options: FormCacheOptions<TFormGroup>): TFormGroup {
return options.control;
}
}

View File

@@ -268,8 +268,10 @@ import {
} from "@bitwarden/vault-export-core";
import { FormValidationErrorsService as FormValidationErrorsServiceAbstraction } from "../platform/abstractions/form-validation-errors.service";
import { ViewCacheService } from "../platform/abstractions/view-cache.service";
import { FormValidationErrorsService } from "../platform/services/form-validation-errors.service";
import { LoggingErrorHandler } from "../platform/services/logging-error-handler";
import { NoopViewCacheService } from "../platform/services/noop-view-cache.service";
import { AngularThemingService } from "../platform/services/theming/angular-theming.service";
import { AbstractThemingService } from "../platform/services/theming/theming.service.abstraction";
import { safeProvider, SafeProvider } from "../platform/utils/safe-provider";
@@ -1290,6 +1292,11 @@ const safeProviders: SafeProvider[] = [
useClass: DefaultRegistrationFinishService,
deps: [CryptoServiceAbstraction, AccountApiServiceAbstraction],
}),
safeProvider({
provide: ViewCacheService,
useExisting: NoopViewCacheService,
deps: [],
}),
];
@NgModule({

View File

@@ -2,7 +2,7 @@
class="tw-flex tw-min-h-screen tw-w-full tw-mx-auto tw-flex-col tw-gap-7 tw-bg-background-alt tw-px-8 tw-pb-4 tw-text-main"
[ngClass]="{ 'tw-pt-0': decreaseTopPadding, 'tw-pt-8': !decreaseTopPadding }"
>
<bit-icon *ngIf="!hideLogo" [icon]="logo" class="tw-max-w-36"></bit-icon>
<bit-icon *ngIf="!hideLogo" [icon]="logo" class="tw-w-[128px] [&>*]:tw-align-top"></bit-icon>
<div class="tw-text-center">
<div class="tw-mx-auto tw-max-w-28 sm:tw-max-w-32">

View File

@@ -158,7 +158,10 @@ describe("AuthRequestLoginStrategy", () => {
decMasterKeyHash,
mockUserId,
);
expect(cryptoService.setMasterKeyEncryptedUserKey).toHaveBeenCalledWith(tokenResponse.key);
expect(cryptoService.setMasterKeyEncryptedUserKey).toHaveBeenCalledWith(
tokenResponse.key,
mockUserId,
);
expect(cryptoService.setUserKey).toHaveBeenCalledWith(userKey, mockUserId);
expect(deviceTrustService.trustDeviceIfRequired).toHaveBeenCalled();
expect(cryptoService.setPrivateKey).toHaveBeenCalledWith(tokenResponse.privateKey, mockUserId);
@@ -183,7 +186,10 @@ describe("AuthRequestLoginStrategy", () => {
expect(masterPasswordService.mock.setMasterKeyHash).not.toHaveBeenCalled();
// setMasterKeyEncryptedUserKey, setUserKey, and setPrivateKey should still be called
expect(cryptoService.setMasterKeyEncryptedUserKey).toHaveBeenCalledWith(tokenResponse.key);
expect(cryptoService.setMasterKeyEncryptedUserKey).toHaveBeenCalledWith(
tokenResponse.key,
mockUserId,
);
expect(cryptoService.setUserKey).toHaveBeenCalledWith(decUserKey, mockUserId);
expect(cryptoService.setPrivateKey).toHaveBeenCalledWith(tokenResponse.privateKey, mockUserId);

View File

@@ -99,7 +99,7 @@ export class AuthRequestLoginStrategy extends LoginStrategy {
const authRequestCredentials = this.cache.value.authRequestCredentials;
// User now may or may not have a master password
// but set the master key encrypted user key if it exists regardless
await this.cryptoService.setMasterKeyEncryptedUserKey(response.key);
await this.cryptoService.setMasterKeyEncryptedUserKey(response.key, userId);
if (authRequestCredentials.decryptedUserKey) {
await this.cryptoService.setUserKey(authRequestCredentials.decryptedUserKey, userId);

View File

@@ -222,7 +222,11 @@ export abstract class LoginStrategy {
),
);
await this.billingAccountProfileStateService.setHasPremium(accountInformation.premium, false);
await this.billingAccountProfileStateService.setHasPremium(
accountInformation.premium,
false,
userId,
);
return userId;
}

View File

@@ -172,7 +172,10 @@ describe("UserApiLoginStrategy", () => {
await apiLogInStrategy.logIn(credentials);
expect(cryptoService.setMasterKeyEncryptedUserKey).toHaveBeenCalledWith(tokenResponse.key);
expect(cryptoService.setMasterKeyEncryptedUserKey).toHaveBeenCalledWith(
tokenResponse.key,
userId,
);
expect(cryptoService.setPrivateKey).toHaveBeenCalledWith(tokenResponse.privateKey, userId);
});

View File

@@ -64,7 +64,7 @@ export class UserApiLoginStrategy extends LoginStrategy {
response: IdentityTokenResponse,
userId: UserId,
): Promise<void> {
await this.cryptoService.setMasterKeyEncryptedUserKey(response.key);
await this.cryptoService.setMasterKeyEncryptedUserKey(response.key, userId);
if (response.apiUseKeyConnector) {
const masterKey = await firstValueFrom(this.masterPasswordService.masterKey$(userId));

View File

@@ -32,7 +32,7 @@ export class FakeGlobalStateProvider implements GlobalStateProvider {
states: Map<string, GlobalState<unknown>> = new Map();
get<T>(keyDefinition: KeyDefinition<T>): GlobalState<T> {
this.mock.get(keyDefinition);
const cacheKey = `${keyDefinition.fullName}_${keyDefinition.stateDefinition.defaultStorageLocation}`;
const cacheKey = this.cacheKey(keyDefinition);
let result = this.states.get(cacheKey);
if (result == null) {
@@ -53,94 +53,143 @@ export class FakeGlobalStateProvider implements GlobalStateProvider {
return result as GlobalState<T>;
}
private cacheKey(keyDefinition: KeyDefinition<unknown>) {
return `${keyDefinition.fullName}_${keyDefinition.stateDefinition.defaultStorageLocation}`;
}
getFake<T>(keyDefinition: KeyDefinition<T>): FakeGlobalState<T> {
return this.get(keyDefinition) as FakeGlobalState<T>;
}
mockFor<T>(keyDefinitionKey: string, initialValue?: T): FakeGlobalState<T> {
if (!this.establishedMocks.has(keyDefinitionKey)) {
this.establishedMocks.set(keyDefinitionKey, new FakeGlobalState<T>(initialValue));
mockFor<T>(keyDefinition: KeyDefinition<T>, initialValue?: T): FakeGlobalState<T> {
const cacheKey = this.cacheKey(keyDefinition);
if (!this.states.has(cacheKey)) {
this.states.set(cacheKey, new FakeGlobalState<T>(initialValue));
}
return this.establishedMocks.get(keyDefinitionKey) as FakeGlobalState<T>;
return this.states.get(cacheKey) as FakeGlobalState<T>;
}
}
export class FakeSingleUserStateProvider implements SingleUserStateProvider {
mock = mock<SingleUserStateProvider>();
establishedMocks: Map<string, FakeSingleUserState<unknown>> = new Map();
states: Map<string, SingleUserState<unknown>> = new Map();
constructor(
readonly updateSyncCallback?: (
key: UserKeyDefinition<unknown>,
userId: UserId,
newValue: unknown,
) => Promise<void>,
) {}
get<T>(userId: UserId, userKeyDefinition: UserKeyDefinition<T>): SingleUserState<T> {
this.mock.get(userId, userKeyDefinition);
const cacheKey = `${userKeyDefinition.fullName}_${userKeyDefinition.stateDefinition.defaultStorageLocation}_${userId}`;
const cacheKey = this.cacheKey(userId, userKeyDefinition);
let result = this.states.get(cacheKey);
if (result == null) {
let fake: FakeSingleUserState<T>;
// Look for established mock
if (this.establishedMocks.has(userKeyDefinition.key)) {
fake = this.establishedMocks.get(userKeyDefinition.key) as FakeSingleUserState<T>;
} else {
fake = new FakeSingleUserState<T>(userId);
}
fake.keyDefinition = userKeyDefinition;
result = fake;
result = this.buildFakeState(userId, userKeyDefinition);
this.states.set(cacheKey, result);
}
return result as SingleUserState<T>;
}
getFake<T>(userId: UserId, userKeyDefinition: UserKeyDefinition<T>): FakeSingleUserState<T> {
getFake<T>(
userId: UserId,
userKeyDefinition: UserKeyDefinition<T>,
{ allowInit }: { allowInit: boolean } = { allowInit: true },
): FakeSingleUserState<T> {
if (!allowInit && this.states.get(this.cacheKey(userId, userKeyDefinition)) == null) {
return null;
}
return this.get(userId, userKeyDefinition) as FakeSingleUserState<T>;
}
mockFor<T>(userId: UserId, keyDefinitionKey: string, initialValue?: T): FakeSingleUserState<T> {
if (!this.establishedMocks.has(keyDefinitionKey)) {
this.establishedMocks.set(keyDefinitionKey, new FakeSingleUserState<T>(userId, initialValue));
mockFor<T>(
userId: UserId,
userKeyDefinition: UserKeyDefinition<T>,
initialValue?: T,
): FakeSingleUserState<T> {
const cacheKey = this.cacheKey(userId, userKeyDefinition);
if (!this.states.has(cacheKey)) {
this.states.set(cacheKey, this.buildFakeState(userId, userKeyDefinition, initialValue));
}
return this.establishedMocks.get(keyDefinitionKey) as FakeSingleUserState<T>;
return this.states.get(cacheKey) as FakeSingleUserState<T>;
}
private buildFakeState<T>(
userId: UserId,
userKeyDefinition: UserKeyDefinition<T>,
initialValue?: T,
) {
const state = new FakeSingleUserState(userId, initialValue, async (...args) => {
await this.updateSyncCallback?.(userKeyDefinition, ...args);
});
state.keyDefinition = userKeyDefinition;
return state;
}
private cacheKey(userId: UserId, userKeyDefinition: UserKeyDefinition<unknown>) {
return `${userKeyDefinitionCacheKey(userKeyDefinition)}_${userId}`;
}
}
export class FakeActiveUserStateProvider implements ActiveUserStateProvider {
activeUserId$: Observable<UserId>;
establishedMocks: Map<string, FakeActiveUserState<unknown>> = new Map();
states: Map<string, FakeActiveUserState<unknown>> = new Map();
constructor(public accountService: FakeAccountService) {
constructor(
public accountService: FakeAccountService,
readonly updateSyncCallback?: (
key: UserKeyDefinition<unknown>,
userId: UserId,
newValue: unknown,
) => Promise<void>,
) {
this.activeUserId$ = accountService.activeAccountSubject.asObservable().pipe(map((a) => a?.id));
}
get<T>(userKeyDefinition: UserKeyDefinition<T>): ActiveUserState<T> {
const cacheKey = `${userKeyDefinition.fullName}_${userKeyDefinition.stateDefinition.defaultStorageLocation}`;
const cacheKey = userKeyDefinitionCacheKey(userKeyDefinition);
let result = this.states.get(cacheKey);
if (result == null) {
// Look for established mock
if (this.establishedMocks.has(userKeyDefinition.key)) {
result = this.establishedMocks.get(userKeyDefinition.key);
} else {
result = new FakeActiveUserState<T>(this.accountService);
}
result.keyDefinition = userKeyDefinition;
result = this.buildFakeState(userKeyDefinition);
this.states.set(cacheKey, result);
}
return result as ActiveUserState<T>;
}
getFake<T>(userKeyDefinition: UserKeyDefinition<T>): FakeActiveUserState<T> {
getFake<T>(
userKeyDefinition: UserKeyDefinition<T>,
{ allowInit }: { allowInit: boolean } = { allowInit: true },
): FakeActiveUserState<T> {
if (!allowInit && this.states.get(userKeyDefinitionCacheKey(userKeyDefinition)) == null) {
return null;
}
return this.get(userKeyDefinition) as FakeActiveUserState<T>;
}
mockFor<T>(keyDefinitionKey: string, initialValue?: T): FakeActiveUserState<T> {
if (!this.establishedMocks.has(keyDefinitionKey)) {
this.establishedMocks.set(
keyDefinitionKey,
new FakeActiveUserState<T>(this.accountService, initialValue),
);
mockFor<T>(userKeyDefinition: UserKeyDefinition<T>, initialValue?: T): FakeActiveUserState<T> {
const cacheKey = userKeyDefinitionCacheKey(userKeyDefinition);
if (!this.states.has(cacheKey)) {
this.states.set(cacheKey, this.buildFakeState(userKeyDefinition, initialValue));
}
return this.establishedMocks.get(keyDefinitionKey) as FakeActiveUserState<T>;
return this.states.get(cacheKey) as FakeActiveUserState<T>;
}
private buildFakeState<T>(userKeyDefinition: UserKeyDefinition<T>, initialValue?: T) {
const state = new FakeActiveUserState<T>(this.accountService, initialValue, async (...args) => {
await this.updateSyncCallback?.(userKeyDefinition, ...args);
});
state.keyDefinition = userKeyDefinition;
return state;
}
}
function userKeyDefinitionCacheKey(userKeyDefinition: UserKeyDefinition<unknown>) {
return `${userKeyDefinition.fullName}_${userKeyDefinition.stateDefinition.defaultStorageLocation}`;
}
export class FakeStateProvider implements StateProvider {
@@ -207,9 +256,35 @@ export class FakeStateProvider implements StateProvider {
constructor(public accountService: FakeAccountService) {}
private distributeSingleUserUpdate(
key: UserKeyDefinition<unknown>,
userId: UserId,
newState: unknown,
) {
if (this.activeUser.accountService.activeUserId === userId) {
const state = this.activeUser.getFake(key, { allowInit: false });
state?.nextState(newState, { syncValue: false });
}
}
private distributeActiveUserUpdate(
key: UserKeyDefinition<unknown>,
userId: UserId,
newState: unknown,
) {
this.singleUser
.getFake(userId, key, { allowInit: false })
?.nextState(newState, { syncValue: false });
}
global: FakeGlobalStateProvider = new FakeGlobalStateProvider();
singleUser: FakeSingleUserStateProvider = new FakeSingleUserStateProvider();
activeUser: FakeActiveUserStateProvider = new FakeActiveUserStateProvider(this.accountService);
singleUser: FakeSingleUserStateProvider = new FakeSingleUserStateProvider(
this.distributeSingleUserUpdate.bind(this),
);
activeUser: FakeActiveUserStateProvider = new FakeActiveUserStateProvider(
this.accountService,
this.distributeActiveUserUpdate.bind(this),
);
derived: FakeDerivedStateProvider = new FakeDerivedStateProvider();
activeUserId$: Observable<UserId> = this.activeUser.activeUserId$;
}

View File

@@ -1,4 +1,4 @@
import { Observable, ReplaySubject, concatMap, firstValueFrom, map, timeout } from "rxjs";
import { Observable, ReplaySubject, concatMap, filter, firstValueFrom, map, timeout } from "rxjs";
import {
DerivedState,
@@ -41,6 +41,10 @@ export class FakeGlobalState<T> implements GlobalState<T> {
this.stateSubject.next(initialValue ?? null);
}
nextState(state: T) {
this.stateSubject.next(state);
}
async update<TCombine>(
configureState: (state: T, dependency: TCombine) => T,
options?: StateUpdateOptions<T, TCombine>,
@@ -89,7 +93,10 @@ export class FakeGlobalState<T> implements GlobalState<T> {
export class FakeSingleUserState<T> implements SingleUserState<T> {
// eslint-disable-next-line rxjs/no-exposed-subjects -- exposed for testing setup
stateSubject = new ReplaySubject<CombinedState<T>>(1);
stateSubject = new ReplaySubject<{
syncValue: boolean;
combinedState: CombinedState<T>;
}>(1);
state$: Observable<T>;
combinedState$: Observable<CombinedState<T>>;
@@ -97,15 +104,28 @@ export class FakeSingleUserState<T> implements SingleUserState<T> {
constructor(
readonly userId: UserId,
initialValue?: T,
updateSyncCallback?: (userId: UserId, newValue: T) => Promise<void>,
) {
this.stateSubject.next([userId, initialValue ?? null]);
// Inform the state provider of updates to keep active user states in sync
this.stateSubject
.pipe(
filter((next) => next.syncValue),
concatMap(async ({ combinedState }) => {
await updateSyncCallback?.(...combinedState);
}),
)
.subscribe();
this.nextState(initialValue ?? null, { syncValue: initialValue != null });
this.combinedState$ = this.stateSubject.asObservable();
this.combinedState$ = this.stateSubject.pipe(map((v) => v.combinedState));
this.state$ = this.combinedState$.pipe(map(([_userId, state]) => state));
}
nextState(state: T) {
this.stateSubject.next([this.userId, state]);
nextState(state: T, { syncValue }: { syncValue: boolean } = { syncValue: true }) {
this.stateSubject.next({
syncValue,
combinedState: [this.userId, state],
});
}
async update<TCombine>(
@@ -122,7 +142,7 @@ export class FakeSingleUserState<T> implements SingleUserState<T> {
return current;
}
const newState = configureState(current, combinedDependencies);
this.stateSubject.next([this.userId, newState]);
this.nextState(newState);
this.nextMock(newState);
return newState;
}
@@ -146,7 +166,10 @@ export class FakeActiveUserState<T> implements ActiveUserState<T> {
[activeMarker]: true;
// eslint-disable-next-line rxjs/no-exposed-subjects -- exposed for testing setup
stateSubject = new ReplaySubject<CombinedState<T>>(1);
stateSubject = new ReplaySubject<{
syncValue: boolean;
combinedState: CombinedState<T>;
}>(1);
state$: Observable<T>;
combinedState$: Observable<CombinedState<T>>;
@@ -154,10 +177,18 @@ export class FakeActiveUserState<T> implements ActiveUserState<T> {
constructor(
private accountService: FakeAccountService,
initialValue?: T,
updateSyncCallback?: (userId: UserId, newValue: T) => Promise<void>,
) {
this.stateSubject.next([accountService.activeUserId, initialValue ?? null]);
// Inform the state provider of updates to keep single user states in sync
this.stateSubject.pipe(
filter((next) => next.syncValue),
concatMap(async ({ combinedState }) => {
await updateSyncCallback?.(...combinedState);
}),
);
this.nextState(initialValue ?? null, { syncValue: initialValue != null });
this.combinedState$ = this.stateSubject.asObservable();
this.combinedState$ = this.stateSubject.pipe(map((v) => v.combinedState));
this.state$ = this.combinedState$.pipe(map(([_userId, state]) => state));
}
@@ -165,8 +196,11 @@ export class FakeActiveUserState<T> implements ActiveUserState<T> {
return this.accountService.activeUserId;
}
nextState(state: T) {
this.stateSubject.next([this.userId, state]);
nextState(state: T, { syncValue }: { syncValue: boolean } = { syncValue: true }) {
this.stateSubject.next({
syncValue,
combinedState: [this.userId, state],
});
}
async update<TCombine>(
@@ -183,7 +217,7 @@ export class FakeActiveUserState<T> implements ActiveUserState<T> {
return [this.userId, current];
}
const newState = configureState(current, combinedDependencies);
this.stateSubject.next([this.userId, newState]);
this.nextState(newState);
this.nextMock([this.userId, newState]);
return [this.userId, newState];
}

View File

@@ -77,5 +77,5 @@ export abstract class PolicyService {
export abstract class InternalPolicyService extends PolicyService {
upsert: (policy: PolicyData) => Promise<void>;
replace: (policies: { [id: string]: PolicyData }) => Promise<void>;
replace: (policies: { [id: string]: PolicyData }, userId: UserId) => Promise<void>;
}

View File

@@ -20,6 +20,7 @@ import { POLICIES, PolicyService } from "../../../admin-console/services/policy/
import { PolicyId, UserId } from "../../../types/guid";
describe("PolicyService", () => {
const userId = "userId" as UserId;
let stateProvider: FakeStateProvider;
let organizationService: MockProxy<OrganizationService>;
let activeUserState: FakeActiveUserState<Record<PolicyId, PolicyData>>;
@@ -27,7 +28,7 @@ describe("PolicyService", () => {
let policyService: PolicyService;
beforeEach(() => {
const accountService = mockAccountServiceWith("userId" as UserId);
const accountService = mockAccountServiceWith(userId);
stateProvider = new FakeStateProvider(accountService);
organizationService = mock<OrganizationService>();
@@ -95,9 +96,12 @@ describe("PolicyService", () => {
]),
);
await policyService.replace({
"2": policyData("2", "test-organization", PolicyType.DisableSend, true),
});
await policyService.replace(
{
"2": policyData("2", "test-organization", PolicyType.DisableSend, true),
},
userId,
);
expect(await firstValueFrom(policyService.policies$)).toEqual([
{

View File

@@ -219,8 +219,8 @@ export class PolicyService implements InternalPolicyServiceAbstraction {
});
}
async replace(policies: { [id: string]: PolicyData }): Promise<void> {
await this.activeUserPolicyState.update(() => policies);
async replace(policies: { [id: string]: PolicyData }, userId: UserId): Promise<void> {
await this.stateProvider.setUserState(POLICIES, policies, userId);
}
/**

View File

@@ -4,17 +4,17 @@ import { IdentityTokenResponse } from "../models/response/identity-token.respons
export abstract class KeyConnectorService {
setMasterKeyFromUrl: (url: string, userId: UserId) => Promise<void>;
getManagingOrganization: () => Promise<Organization>;
getUsesKeyConnector: () => Promise<boolean>;
migrateUser: () => Promise<void>;
userNeedsMigration: () => Promise<boolean>;
getManagingOrganization: (userId?: UserId) => Promise<Organization>;
getUsesKeyConnector: (userId: UserId) => Promise<boolean>;
migrateUser: (userId?: UserId) => Promise<void>;
userNeedsMigration: (userId: UserId) => Promise<boolean>;
convertNewSsoUserToKeyConnector: (
tokenResponse: IdentityTokenResponse,
orgId: string,
userId: UserId,
) => Promise<void>;
setUsesKeyConnector: (enabled: boolean) => Promise<void>;
setConvertAccountRequired: (status: boolean) => Promise<void>;
setUsesKeyConnector: (enabled: boolean, userId: UserId) => Promise<void>;
setConvertAccountRequired: (status: boolean, userId?: UserId) => Promise<void>;
getConvertAccountRequired: () => Promise<boolean>;
removeConvertAccountRequired: () => Promise<void>;
removeConvertAccountRequired: (userId?: UserId) => Promise<void>;
}

View File

@@ -148,10 +148,11 @@ export abstract class TokenService {
/**
* Decodes the access token.
* @param token The access token to decode.
* @param tokenOrUserId The access token to decode or the user id to retrieve the access token for, and then decode.
* If null, the currently active user's token is used.
* @returns A promise that resolves with the decoded access token.
*/
decodeAccessToken: (token?: string) => Promise<DecodedAccessToken>;
decodeAccessToken: (tokenOrUserId?: string | UserId) => Promise<DecodedAccessToken>;
/**
* Gets the expiration date for the access token. Returns if token can't be decoded or has no expiration
@@ -212,9 +213,10 @@ export abstract class TokenService {
/**
* Gets whether or not the user authenticated via an external mechanism.
* @param userId The optional user id to check for external authN status; if not provided, the active user is used.
* @returns A promise that resolves with a boolean representing the user's external authN status.
*/
getIsExternal: () => Promise<boolean>;
getIsExternal: (userId: UserId) => Promise<boolean>;
/** Gets the active or passed in user's security stamp */
getSecurityStamp: (userId?: UserId) => Promise<string | null>;

View File

@@ -78,9 +78,9 @@ describe("KeyConnectorService", () => {
const newValue = true;
await keyConnectorService.setUsesKeyConnector(newValue);
await keyConnectorService.setUsesKeyConnector(newValue, mockUserId);
expect(await keyConnectorService.getUsesKeyConnector()).toBe(newValue);
expect(await keyConnectorService.getUsesKeyConnector(mockUserId)).toBe(newValue);
});
});
@@ -185,7 +185,7 @@ describe("KeyConnectorService", () => {
const state = stateProvider.activeUser.getFake(USES_KEY_CONNECTOR);
state.nextState(false);
const result = await keyConnectorService.userNeedsMigration();
const result = await keyConnectorService.userNeedsMigration(mockUserId);
expect(result).toBe(true);
});
@@ -197,7 +197,7 @@ describe("KeyConnectorService", () => {
const state = stateProvider.activeUser.getFake(USES_KEY_CONNECTOR);
state.nextState(true);
const result = await keyConnectorService.userNeedsMigration();
const result = await keyConnectorService.userNeedsMigration(mockUserId);
expect(result).toBe(false);
});

View File

@@ -69,25 +69,25 @@ export class KeyConnectorService implements KeyConnectorServiceAbstraction {
);
}
async setUsesKeyConnector(usesKeyConnector: boolean) {
await this.usesKeyConnectorState.update(() => usesKeyConnector);
async setUsesKeyConnector(usesKeyConnector: boolean, userId: UserId) {
await this.stateProvider.getUser(userId, USES_KEY_CONNECTOR).update(() => usesKeyConnector);
}
getUsesKeyConnector(): Promise<boolean> {
return firstValueFrom(this.usesKeyConnectorState.state$);
getUsesKeyConnector(userId: UserId): Promise<boolean> {
return firstValueFrom(this.stateProvider.getUserState$(USES_KEY_CONNECTOR, userId));
}
async userNeedsMigration() {
const loggedInUsingSso = await this.tokenService.getIsExternal();
const requiredByOrganization = (await this.getManagingOrganization()) != null;
const userIsNotUsingKeyConnector = !(await this.getUsesKeyConnector());
async userNeedsMigration(userId: UserId) {
const loggedInUsingSso = await this.tokenService.getIsExternal(userId);
const requiredByOrganization = (await this.getManagingOrganization(userId)) != null;
const userIsNotUsingKeyConnector = !(await this.getUsesKeyConnector(userId));
return loggedInUsingSso && requiredByOrganization && userIsNotUsingKeyConnector;
}
async migrateUser() {
const organization = await this.getManagingOrganization();
const userId = (await firstValueFrom(this.accountService.activeAccount$))?.id;
async migrateUser(userId?: UserId) {
userId ??= (await firstValueFrom(this.accountService.activeAccount$))?.id;
const organization = await this.getManagingOrganization(userId);
const masterKey = await firstValueFrom(this.masterPasswordService.masterKey$(userId));
const keyConnectorRequest = new KeyConnectorUserKeyRequest(masterKey.encKeyB64);
@@ -115,8 +115,8 @@ export class KeyConnectorService implements KeyConnectorServiceAbstraction {
}
}
async getManagingOrganization(): Promise<Organization> {
const orgs = await this.organizationService.getAll();
async getManagingOrganization(userId?: UserId): Promise<Organization> {
const orgs = await this.organizationService.getAll(userId);
return orgs.find(
(o) =>
o.keyConnectorEnabled &&
@@ -178,16 +178,16 @@ export class KeyConnectorService implements KeyConnectorServiceAbstraction {
await this.apiService.postSetKeyConnectorKey(setPasswordRequest);
}
async setConvertAccountRequired(status: boolean) {
await this.convertAccountToKeyConnectorState.update(() => status);
async setConvertAccountRequired(status: boolean, userId?: UserId) {
await this.stateProvider.setUserState(CONVERT_ACCOUNT_TO_KEY_CONNECTOR, status, userId);
}
getConvertAccountRequired(): Promise<boolean> {
return firstValueFrom(this.convertAccountToKeyConnectorState.state$);
}
async removeConvertAccountRequired() {
await this.setConvertAccountRequired(null);
async removeConvertAccountRequired(userId?: UserId) {
await this.setConvertAccountRequired(null, userId);
}
private handleKeyConnectorError(e: any) {

View File

@@ -126,7 +126,7 @@ describe("TokenService", () => {
// Arrange
singleUserStateProvider
.getFake(userIdFromAccessToken, ACCESS_TOKEN_MEMORY)
.stateSubject.next([userIdFromAccessToken, accessTokenJwt]);
.nextState(accessTokenJwt);
// Act
const result = await firstValueFrom(tokenService.hasAccessToken$(userIdFromAccessToken));
@@ -139,11 +139,11 @@ describe("TokenService", () => {
// Arrange
singleUserStateProvider
.getFake(userIdFromAccessToken, ACCESS_TOKEN_MEMORY)
.stateSubject.next([userIdFromAccessToken, undefined]);
.nextState(undefined);
singleUserStateProvider
.getFake(userIdFromAccessToken, ACCESS_TOKEN_DISK)
.stateSubject.next([userIdFromAccessToken, accessTokenJwt]);
.nextState(accessTokenJwt);
// Act
const result = await firstValueFrom(tokenService.hasAccessToken$(userIdFromAccessToken));
@@ -156,7 +156,7 @@ describe("TokenService", () => {
// Arrange
singleUserStateProvider
.getFake(userIdFromAccessToken, ACCESS_TOKEN_DISK)
.stateSubject.next([userIdFromAccessToken, "encryptedAccessToken"]);
.nextState("encryptedAccessToken");
secureStorageService.get.mockResolvedValue(accessTokenKeyB64);
@@ -282,7 +282,7 @@ describe("TokenService", () => {
// For testing purposes, let's assume that the access token is already in memory
singleUserStateProvider
.getFake(userIdFromAccessToken, ACCESS_TOKEN_MEMORY)
.stateSubject.next([userIdFromAccessToken, accessTokenJwt]);
.nextState(accessTokenJwt);
keyGenerationService.createKey.mockResolvedValue(accessTokenKey);
@@ -411,9 +411,7 @@ describe("TokenService", () => {
it("returns null when no access token is found in memory, disk, or secure storage", async () => {
// Arrange
globalStateProvider
.getFake(ACCOUNT_ACTIVE_ACCOUNT_ID)
.stateSubject.next(userIdFromAccessToken);
globalStateProvider.getFake(ACCOUNT_ACTIVE_ACCOUNT_ID).nextState(userIdFromAccessToken);
// Act
const result = await tokenService.getAccessToken();
@@ -429,18 +427,16 @@ describe("TokenService", () => {
// Arrange
singleUserStateProvider
.getFake(userIdFromAccessToken, ACCESS_TOKEN_MEMORY)
.stateSubject.next([userIdFromAccessToken, accessTokenJwt]);
.nextState(accessTokenJwt);
// set disk to undefined
singleUserStateProvider
.getFake(userIdFromAccessToken, ACCESS_TOKEN_DISK)
.stateSubject.next([userIdFromAccessToken, undefined]);
.nextState(undefined);
// Need to have global active id set to the user id
if (!userId) {
globalStateProvider
.getFake(ACCOUNT_ACTIVE_ACCOUNT_ID)
.stateSubject.next(userIdFromAccessToken);
globalStateProvider.getFake(ACCOUNT_ACTIVE_ACCOUNT_ID).nextState(userIdFromAccessToken);
}
// Act
@@ -459,17 +455,15 @@ describe("TokenService", () => {
// Arrange
singleUserStateProvider
.getFake(userIdFromAccessToken, ACCESS_TOKEN_MEMORY)
.stateSubject.next([userIdFromAccessToken, undefined]);
.nextState(undefined);
singleUserStateProvider
.getFake(userIdFromAccessToken, ACCESS_TOKEN_DISK)
.stateSubject.next([userIdFromAccessToken, accessTokenJwt]);
.nextState(accessTokenJwt);
// Need to have global active id set to the user id
if (!userId) {
globalStateProvider
.getFake(ACCOUNT_ACTIVE_ACCOUNT_ID)
.stateSubject.next(userIdFromAccessToken);
globalStateProvider.getFake(ACCOUNT_ACTIVE_ACCOUNT_ID).nextState(userIdFromAccessToken);
}
// Act
@@ -498,20 +492,18 @@ describe("TokenService", () => {
// Arrange
singleUserStateProvider
.getFake(userIdFromAccessToken, ACCESS_TOKEN_MEMORY)
.stateSubject.next([userIdFromAccessToken, undefined]);
.nextState(undefined);
singleUserStateProvider
.getFake(userIdFromAccessToken, ACCESS_TOKEN_DISK)
.stateSubject.next([userIdFromAccessToken, "encryptedAccessToken"]);
.nextState("encryptedAccessToken");
secureStorageService.get.mockResolvedValue(accessTokenKeyB64);
encryptService.decryptToUtf8.mockResolvedValue("decryptedAccessToken");
// Need to have global active id set to the user id
if (!userId) {
globalStateProvider
.getFake(ACCOUNT_ACTIVE_ACCOUNT_ID)
.stateSubject.next(userIdFromAccessToken);
globalStateProvider.getFake(ACCOUNT_ACTIVE_ACCOUNT_ID).nextState(userIdFromAccessToken);
}
// Act
@@ -534,17 +526,15 @@ describe("TokenService", () => {
// Arrange
singleUserStateProvider
.getFake(userIdFromAccessToken, ACCESS_TOKEN_MEMORY)
.stateSubject.next([userIdFromAccessToken, undefined]);
.nextState(undefined);
singleUserStateProvider
.getFake(userIdFromAccessToken, ACCESS_TOKEN_DISK)
.stateSubject.next([userIdFromAccessToken, accessTokenJwt]);
.nextState(accessTokenJwt);
// Need to have global active id set to the user id
if (!userId) {
globalStateProvider
.getFake(ACCOUNT_ACTIVE_ACCOUNT_ID)
.stateSubject.next(userIdFromAccessToken);
globalStateProvider.getFake(ACCOUNT_ACTIVE_ACCOUNT_ID).nextState(userIdFromAccessToken);
}
// No access token key set
@@ -564,11 +554,11 @@ describe("TokenService", () => {
// Arrange
singleUserStateProvider
.getFake(userIdFromAccessToken, ACCESS_TOKEN_MEMORY)
.stateSubject.next([userIdFromAccessToken, undefined]);
.nextState(undefined);
singleUserStateProvider
.getFake(userIdFromAccessToken, ACCESS_TOKEN_DISK)
.stateSubject.next([userIdFromAccessToken, encryptedAccessToken]);
.nextState(encryptedAccessToken);
// No access token key set
@@ -596,11 +586,11 @@ describe("TokenService", () => {
// Arrange
singleUserStateProvider
.getFake(userIdFromAccessToken, ACCESS_TOKEN_MEMORY)
.stateSubject.next([userIdFromAccessToken, undefined]);
.nextState(undefined);
singleUserStateProvider
.getFake(userIdFromAccessToken, ACCESS_TOKEN_DISK)
.stateSubject.next([userIdFromAccessToken, encryptedAccessToken]);
.nextState(encryptedAccessToken);
// Mock linux secure storage error
const secureStorageError = "Secure storage error";
@@ -655,17 +645,15 @@ describe("TokenService", () => {
// Arrange
singleUserStateProvider
.getFake(userIdFromAccessToken, ACCESS_TOKEN_MEMORY)
.stateSubject.next([userIdFromAccessToken, accessTokenJwt]);
.nextState(accessTokenJwt);
singleUserStateProvider
.getFake(userIdFromAccessToken, ACCESS_TOKEN_DISK)
.stateSubject.next([userIdFromAccessToken, accessTokenJwt]);
.nextState(accessTokenJwt);
// Need to have global active id set to the user id
if (!userId) {
globalStateProvider
.getFake(ACCOUNT_ACTIVE_ACCOUNT_ID)
.stateSubject.next(userIdFromAccessToken);
globalStateProvider.getFake(ACCOUNT_ACTIVE_ACCOUNT_ID).nextState(userIdFromAccessToken);
}
// Act
@@ -688,8 +676,32 @@ describe("TokenService", () => {
});
describe("decodeAccessToken", () => {
it("retrieves the requested user's token when the passed in parameter is a Guid", async () => {
// Arrange
tokenService.getAccessToken = jest.fn().mockResolvedValue(accessTokenJwt);
// Act
const result = await tokenService.decodeAccessToken(userIdFromAccessToken);
// Assert
expect(result).toEqual(accessTokenDecoded);
expect(tokenService.getAccessToken).toHaveBeenCalledWith(userIdFromAccessToken);
});
it("decodes the given token when a string is passed in that is not a Guid", async () => {
// Arrange
tokenService.getAccessToken = jest.fn();
// Act
const result = await tokenService.decodeAccessToken(accessTokenJwt);
// Assert
expect(result).toEqual(accessTokenDecoded);
expect(tokenService.getAccessToken).not.toHaveBeenCalled();
});
it("throws an error when no access token is provided or retrievable from state", async () => {
// Access
// Arrange
tokenService.getAccessToken = jest.fn().mockResolvedValue(null);
// Act
@@ -1194,7 +1206,7 @@ describe("TokenService", () => {
// Act
// note: don't await here because we want to test the error
const result = tokenService.getIsExternal();
const result = tokenService.getIsExternal(null);
// Assert
await expect(result).rejects.toThrow("Failed to decode access token: Mock error");
});
@@ -1210,7 +1222,7 @@ describe("TokenService", () => {
.mockResolvedValue(accessTokenDecodedWithoutExternalAmr);
// Act
const result = await tokenService.getIsExternal();
const result = await tokenService.getIsExternal(null);
// Assert
expect(result).toEqual(false);
@@ -1227,11 +1239,22 @@ describe("TokenService", () => {
.mockResolvedValue(accessTokenDecodedWithExternalAmr);
// Act
const result = await tokenService.getIsExternal();
const result = await tokenService.getIsExternal(null);
// Assert
expect(result).toEqual(true);
});
it("passes the requested userId to decode", async () => {
// Arrange
tokenService.decodeAccessToken = jest.fn().mockResolvedValue(accessTokenDecoded);
// Act
await tokenService.getIsExternal(userIdFromAccessToken);
// Assert
expect(tokenService.decodeAccessToken).toHaveBeenCalledWith(userIdFromAccessToken);
});
});
});
});
@@ -1326,11 +1349,11 @@ describe("TokenService", () => {
// For testing purposes, let's assume that the token is already in disk and memory
singleUserStateProvider
.getFake(userIdFromAccessToken, REFRESH_TOKEN_DISK)
.stateSubject.next([userIdFromAccessToken, refreshToken]);
.nextState(refreshToken);
singleUserStateProvider
.getFake(userIdFromAccessToken, REFRESH_TOKEN_MEMORY)
.stateSubject.next([userIdFromAccessToken, refreshToken]);
.nextState(refreshToken);
// We immediately call to get the refresh token from secure storage after setting it to ensure it was set.
secureStorageService.get.mockResolvedValue(refreshToken);
@@ -1423,11 +1446,11 @@ describe("TokenService", () => {
// Arrange
singleUserStateProvider
.getFake(userIdFromAccessToken, ACCESS_TOKEN_MEMORY)
.stateSubject.next([userIdFromAccessToken, undefined]);
.nextState(undefined);
singleUserStateProvider
.getFake(userIdFromAccessToken, ACCESS_TOKEN_DISK)
.stateSubject.next([userIdFromAccessToken, accessTokenJwt]);
.nextState(accessTokenJwt);
// Mock linux secure storage error
const secureStorageError = "Secure storage error";
@@ -1480,11 +1503,11 @@ describe("TokenService", () => {
// Arrange
singleUserStateProvider
.getFake(userIdFromAccessToken, ACCESS_TOKEN_MEMORY)
.stateSubject.next([userIdFromAccessToken, undefined]);
.nextState(undefined);
singleUserStateProvider
.getFake(userIdFromAccessToken, ACCESS_TOKEN_DISK)
.stateSubject.next([userIdFromAccessToken, encryptedAccessToken]);
.nextState(encryptedAccessToken);
secureStorageService.get.mockResolvedValue(accessTokenKeyB64);
encryptService.decryptToUtf8.mockRejectedValue(new Error("Decryption error"));
@@ -1520,9 +1543,7 @@ describe("TokenService", () => {
it("returns null when no refresh token is found in memory, disk, or secure storage", async () => {
// Arrange
globalStateProvider
.getFake(ACCOUNT_ACTIVE_ACCOUNT_ID)
.stateSubject.next(userIdFromAccessToken);
globalStateProvider.getFake(ACCOUNT_ACTIVE_ACCOUNT_ID).nextState(userIdFromAccessToken);
// Act
const result = await (tokenService as any).getRefreshToken();
@@ -1535,16 +1556,14 @@ describe("TokenService", () => {
// Arrange
singleUserStateProvider
.getFake(userIdFromAccessToken, REFRESH_TOKEN_MEMORY)
.stateSubject.next([userIdFromAccessToken, refreshToken]);
.nextState(refreshToken);
singleUserStateProvider
.getFake(userIdFromAccessToken, REFRESH_TOKEN_DISK)
.stateSubject.next([userIdFromAccessToken, undefined]);
.nextState(undefined);
// Need to have global active id set to the user id
globalStateProvider
.getFake(ACCOUNT_ACTIVE_ACCOUNT_ID)
.stateSubject.next(userIdFromAccessToken);
globalStateProvider.getFake(ACCOUNT_ACTIVE_ACCOUNT_ID).nextState(userIdFromAccessToken);
// Act
const result = await tokenService.getRefreshToken();
@@ -1557,11 +1576,11 @@ describe("TokenService", () => {
// Arrange
singleUserStateProvider
.getFake(userIdFromAccessToken, REFRESH_TOKEN_MEMORY)
.stateSubject.next([userIdFromAccessToken, refreshToken]);
.nextState(refreshToken);
singleUserStateProvider
.getFake(userIdFromAccessToken, REFRESH_TOKEN_DISK)
.stateSubject.next([userIdFromAccessToken, undefined]);
.nextState(undefined);
// Act
const result = await tokenService.getRefreshToken(userIdFromAccessToken);
@@ -1575,16 +1594,14 @@ describe("TokenService", () => {
// Arrange
singleUserStateProvider
.getFake(userIdFromAccessToken, REFRESH_TOKEN_MEMORY)
.stateSubject.next([userIdFromAccessToken, undefined]);
.nextState(undefined);
singleUserStateProvider
.getFake(userIdFromAccessToken, REFRESH_TOKEN_DISK)
.stateSubject.next([userIdFromAccessToken, refreshToken]);
.nextState(refreshToken);
// Need to have global active id set to the user id
globalStateProvider
.getFake(ACCOUNT_ACTIVE_ACCOUNT_ID)
.stateSubject.next(userIdFromAccessToken);
globalStateProvider.getFake(ACCOUNT_ACTIVE_ACCOUNT_ID).nextState(userIdFromAccessToken);
// Act
const result = await tokenService.getRefreshToken();
@@ -1596,11 +1613,11 @@ describe("TokenService", () => {
// Arrange
singleUserStateProvider
.getFake(userIdFromAccessToken, REFRESH_TOKEN_MEMORY)
.stateSubject.next([userIdFromAccessToken, undefined]);
.nextState(undefined);
singleUserStateProvider
.getFake(userIdFromAccessToken, REFRESH_TOKEN_DISK)
.stateSubject.next([userIdFromAccessToken, refreshToken]);
.nextState(refreshToken);
// Act
const result = await tokenService.getRefreshToken(userIdFromAccessToken);
@@ -1619,18 +1636,16 @@ describe("TokenService", () => {
// Arrange
singleUserStateProvider
.getFake(userIdFromAccessToken, REFRESH_TOKEN_MEMORY)
.stateSubject.next([userIdFromAccessToken, undefined]);
.nextState(undefined);
singleUserStateProvider
.getFake(userIdFromAccessToken, REFRESH_TOKEN_DISK)
.stateSubject.next([userIdFromAccessToken, undefined]);
.nextState(undefined);
secureStorageService.get.mockResolvedValue(refreshToken);
// Need to have global active id set to the user id
globalStateProvider
.getFake(ACCOUNT_ACTIVE_ACCOUNT_ID)
.stateSubject.next(userIdFromAccessToken);
globalStateProvider.getFake(ACCOUNT_ACTIVE_ACCOUNT_ID).nextState(userIdFromAccessToken);
// Act
const result = await tokenService.getRefreshToken();
@@ -1643,11 +1658,11 @@ describe("TokenService", () => {
singleUserStateProvider
.getFake(userIdFromAccessToken, REFRESH_TOKEN_MEMORY)
.stateSubject.next([userIdFromAccessToken, undefined]);
.nextState(undefined);
singleUserStateProvider
.getFake(userIdFromAccessToken, REFRESH_TOKEN_DISK)
.stateSubject.next([userIdFromAccessToken, undefined]);
.nextState(undefined);
secureStorageService.get.mockResolvedValue(refreshToken);
@@ -1661,11 +1676,11 @@ describe("TokenService", () => {
// Arrange
singleUserStateProvider
.getFake(userIdFromAccessToken, REFRESH_TOKEN_MEMORY)
.stateSubject.next([userIdFromAccessToken, undefined]);
.nextState(undefined);
singleUserStateProvider
.getFake(userIdFromAccessToken, REFRESH_TOKEN_DISK)
.stateSubject.next([userIdFromAccessToken, refreshToken]);
.nextState(refreshToken);
// Act
const result = await tokenService.getRefreshToken(userIdFromAccessToken);
@@ -1681,16 +1696,14 @@ describe("TokenService", () => {
// Arrange
singleUserStateProvider
.getFake(userIdFromAccessToken, REFRESH_TOKEN_MEMORY)
.stateSubject.next([userIdFromAccessToken, undefined]);
.nextState(undefined);
singleUserStateProvider
.getFake(userIdFromAccessToken, REFRESH_TOKEN_DISK)
.stateSubject.next([userIdFromAccessToken, refreshToken]);
.nextState(refreshToken);
// Need to have global active id set to the user id
globalStateProvider
.getFake(ACCOUNT_ACTIVE_ACCOUNT_ID)
.stateSubject.next(userIdFromAccessToken);
globalStateProvider.getFake(ACCOUNT_ACTIVE_ACCOUNT_ID).nextState(userIdFromAccessToken);
// Act
const result = await tokenService.getRefreshToken();
@@ -1719,11 +1732,11 @@ describe("TokenService", () => {
// Arrange
singleUserStateProvider
.getFake(userIdFromAccessToken, REFRESH_TOKEN_MEMORY)
.stateSubject.next([userIdFromAccessToken, undefined]);
.nextState(undefined);
singleUserStateProvider
.getFake(userIdFromAccessToken, REFRESH_TOKEN_DISK)
.stateSubject.next([userIdFromAccessToken, undefined]);
.nextState(undefined);
secureStorageService.get.mockResolvedValue(null);
@@ -1743,11 +1756,11 @@ describe("TokenService", () => {
// Arrange
singleUserStateProvider
.getFake(userIdFromAccessToken, REFRESH_TOKEN_MEMORY)
.stateSubject.next([userIdFromAccessToken, undefined]);
.nextState(undefined);
singleUserStateProvider
.getFake(userIdFromAccessToken, REFRESH_TOKEN_DISK)
.stateSubject.next([userIdFromAccessToken, undefined]);
.nextState(undefined);
const secureStorageSvcMockErrorMsg = "Secure storage retrieval error";
@@ -1792,11 +1805,11 @@ describe("TokenService", () => {
// Arrange
singleUserStateProvider
.getFake(userIdFromAccessToken, REFRESH_TOKEN_MEMORY)
.stateSubject.next([userIdFromAccessToken, refreshToken]);
.nextState(refreshToken);
singleUserStateProvider
.getFake(userIdFromAccessToken, REFRESH_TOKEN_DISK)
.stateSubject.next([userIdFromAccessToken, refreshToken]);
.nextState(refreshToken);
// Act
await (tokenService as any).clearRefreshToken(userIdFromAccessToken);
@@ -1833,9 +1846,7 @@ describe("TokenService", () => {
it("should throw an error if the vault timeout is missing", async () => {
// Arrange
globalStateProvider
.getFake(ACCOUNT_ACTIVE_ACCOUNT_ID)
.stateSubject.next(userIdFromAccessToken);
globalStateProvider.getFake(ACCOUNT_ACTIVE_ACCOUNT_ID).nextState(userIdFromAccessToken);
// Act
const result = tokenService.setClientId(clientId, VaultTimeoutAction.Lock, null);
@@ -1847,9 +1858,7 @@ describe("TokenService", () => {
it("should throw an error if the vault timeout action is missing", async () => {
// Arrange
globalStateProvider
.getFake(ACCOUNT_ACTIVE_ACCOUNT_ID)
.stateSubject.next(userIdFromAccessToken);
globalStateProvider.getFake(ACCOUNT_ACTIVE_ACCOUNT_ID).nextState(userIdFromAccessToken);
// Act
const result = tokenService.setClientId(clientId, null, VaultTimeoutStringType.Never);
@@ -1861,9 +1870,7 @@ describe("TokenService", () => {
describe("Memory storage tests", () => {
it("sets the client id in memory when there is an active user in global state", async () => {
// Arrange
globalStateProvider
.getFake(ACCOUNT_ACTIVE_ACCOUNT_ID)
.stateSubject.next(userIdFromAccessToken);
globalStateProvider.getFake(ACCOUNT_ACTIVE_ACCOUNT_ID).nextState(userIdFromAccessToken);
// Act
await tokenService.setClientId(clientId, memoryVaultTimeoutAction, memoryVaultTimeout);
@@ -1895,9 +1902,7 @@ describe("TokenService", () => {
describe("Disk storage tests", () => {
it("sets the client id in disk when there is an active user in global state", async () => {
// Arrange
globalStateProvider
.getFake(ACCOUNT_ACTIVE_ACCOUNT_ID)
.stateSubject.next(userIdFromAccessToken);
globalStateProvider.getFake(ACCOUNT_ACTIVE_ACCOUNT_ID).nextState(userIdFromAccessToken);
// Act
await tokenService.setClientId(clientId, diskVaultTimeoutAction, diskVaultTimeout);
@@ -1935,9 +1940,7 @@ describe("TokenService", () => {
it("returns null when no client id is found in memory or disk", async () => {
// Arrange
globalStateProvider
.getFake(ACCOUNT_ACTIVE_ACCOUNT_ID)
.stateSubject.next(userIdFromAccessToken);
globalStateProvider.getFake(ACCOUNT_ACTIVE_ACCOUNT_ID).nextState(userIdFromAccessToken);
// Act
const result = await tokenService.getClientId();
@@ -1950,17 +1953,15 @@ describe("TokenService", () => {
// Arrange
singleUserStateProvider
.getFake(userIdFromAccessToken, API_KEY_CLIENT_ID_MEMORY)
.stateSubject.next([userIdFromAccessToken, clientId]);
.nextState(clientId);
// set disk to undefined
singleUserStateProvider
.getFake(userIdFromAccessToken, API_KEY_CLIENT_ID_DISK)
.stateSubject.next([userIdFromAccessToken, undefined]);
.nextState(undefined);
// Need to have global active id set to the user id
globalStateProvider
.getFake(ACCOUNT_ACTIVE_ACCOUNT_ID)
.stateSubject.next(userIdFromAccessToken);
globalStateProvider.getFake(ACCOUNT_ACTIVE_ACCOUNT_ID).nextState(userIdFromAccessToken);
// Act
const result = await tokenService.getClientId();
@@ -1973,12 +1974,12 @@ describe("TokenService", () => {
// Arrange
singleUserStateProvider
.getFake(userIdFromAccessToken, API_KEY_CLIENT_ID_MEMORY)
.stateSubject.next([userIdFromAccessToken, clientId]);
.nextState(clientId);
// set disk to undefined
singleUserStateProvider
.getFake(userIdFromAccessToken, API_KEY_CLIENT_ID_DISK)
.stateSubject.next([userIdFromAccessToken, undefined]);
.nextState(undefined);
// Act
const result = await tokenService.getClientId(userIdFromAccessToken);
@@ -1992,16 +1993,14 @@ describe("TokenService", () => {
// Arrange
singleUserStateProvider
.getFake(userIdFromAccessToken, API_KEY_CLIENT_ID_MEMORY)
.stateSubject.next([userIdFromAccessToken, undefined]);
.nextState(undefined);
singleUserStateProvider
.getFake(userIdFromAccessToken, API_KEY_CLIENT_ID_DISK)
.stateSubject.next([userIdFromAccessToken, clientId]);
.nextState(clientId);
// Need to have global active id set to the user id
globalStateProvider
.getFake(ACCOUNT_ACTIVE_ACCOUNT_ID)
.stateSubject.next(userIdFromAccessToken);
globalStateProvider.getFake(ACCOUNT_ACTIVE_ACCOUNT_ID).nextState(userIdFromAccessToken);
// Act
const result = await tokenService.getClientId();
@@ -2013,11 +2012,11 @@ describe("TokenService", () => {
// Arrange
singleUserStateProvider
.getFake(userIdFromAccessToken, API_KEY_CLIENT_ID_MEMORY)
.stateSubject.next([userIdFromAccessToken, undefined]);
.nextState(undefined);
singleUserStateProvider
.getFake(userIdFromAccessToken, API_KEY_CLIENT_ID_DISK)
.stateSubject.next([userIdFromAccessToken, clientId]);
.nextState(clientId);
// Act
const result = await tokenService.getClientId(userIdFromAccessToken);
@@ -2040,11 +2039,11 @@ describe("TokenService", () => {
// Arrange
singleUserStateProvider
.getFake(userIdFromAccessToken, API_KEY_CLIENT_ID_MEMORY)
.stateSubject.next([userIdFromAccessToken, clientId]);
.nextState(clientId);
singleUserStateProvider
.getFake(userIdFromAccessToken, API_KEY_CLIENT_ID_DISK)
.stateSubject.next([userIdFromAccessToken, clientId]);
.nextState(clientId);
// Act
await (tokenService as any).clearClientId(userIdFromAccessToken);
@@ -2062,16 +2061,14 @@ describe("TokenService", () => {
// Arrange
singleUserStateProvider
.getFake(userIdFromAccessToken, API_KEY_CLIENT_ID_MEMORY)
.stateSubject.next([userIdFromAccessToken, clientId]);
.nextState(clientId);
singleUserStateProvider
.getFake(userIdFromAccessToken, API_KEY_CLIENT_ID_DISK)
.stateSubject.next([userIdFromAccessToken, clientId]);
.nextState(clientId);
// Need to have global active id set to the user id
globalStateProvider
.getFake(ACCOUNT_ACTIVE_ACCOUNT_ID)
.stateSubject.next(userIdFromAccessToken);
globalStateProvider.getFake(ACCOUNT_ACTIVE_ACCOUNT_ID).nextState(userIdFromAccessToken);
// Act
await (tokenService as any).clearClientId();
@@ -2106,9 +2103,7 @@ describe("TokenService", () => {
it("should throw an error if the vault timeout is missing", async () => {
// Arrange
globalStateProvider
.getFake(ACCOUNT_ACTIVE_ACCOUNT_ID)
.stateSubject.next(userIdFromAccessToken);
globalStateProvider.getFake(ACCOUNT_ACTIVE_ACCOUNT_ID).nextState(userIdFromAccessToken);
// Act
const result = tokenService.setClientSecret(clientSecret, VaultTimeoutAction.Lock, null);
@@ -2120,9 +2115,7 @@ describe("TokenService", () => {
it("should throw an error if the vault timeout action is missing", async () => {
// Arrange
globalStateProvider
.getFake(ACCOUNT_ACTIVE_ACCOUNT_ID)
.stateSubject.next(userIdFromAccessToken);
globalStateProvider.getFake(ACCOUNT_ACTIVE_ACCOUNT_ID).nextState(userIdFromAccessToken);
// Act
const result = tokenService.setClientSecret(
@@ -2138,9 +2131,7 @@ describe("TokenService", () => {
describe("Memory storage tests", () => {
it("sets the client secret in memory when there is an active user in global state", async () => {
// Arrange
globalStateProvider
.getFake(ACCOUNT_ACTIVE_ACCOUNT_ID)
.stateSubject.next(userIdFromAccessToken);
globalStateProvider.getFake(ACCOUNT_ACTIVE_ACCOUNT_ID).nextState(userIdFromAccessToken);
// Act
await tokenService.setClientSecret(
@@ -2176,9 +2167,7 @@ describe("TokenService", () => {
describe("Disk storage tests", () => {
it("sets the client secret on disk when there is an active user in global state", async () => {
// Arrange
globalStateProvider
.getFake(ACCOUNT_ACTIVE_ACCOUNT_ID)
.stateSubject.next(userIdFromAccessToken);
globalStateProvider.getFake(ACCOUNT_ACTIVE_ACCOUNT_ID).nextState(userIdFromAccessToken);
// Act
await tokenService.setClientSecret(
@@ -2222,9 +2211,7 @@ describe("TokenService", () => {
it("returns null when no client secret is found in memory or disk", async () => {
// Arrange
globalStateProvider
.getFake(ACCOUNT_ACTIVE_ACCOUNT_ID)
.stateSubject.next(userIdFromAccessToken);
globalStateProvider.getFake(ACCOUNT_ACTIVE_ACCOUNT_ID).nextState(userIdFromAccessToken);
// Act
const result = await tokenService.getClientSecret();
@@ -2237,17 +2224,15 @@ describe("TokenService", () => {
// Arrange
singleUserStateProvider
.getFake(userIdFromAccessToken, API_KEY_CLIENT_SECRET_MEMORY)
.stateSubject.next([userIdFromAccessToken, clientSecret]);
.nextState(clientSecret);
// set disk to undefined
singleUserStateProvider
.getFake(userIdFromAccessToken, API_KEY_CLIENT_SECRET_DISK)
.stateSubject.next([userIdFromAccessToken, undefined]);
.nextState(undefined);
// Need to have global active id set to the user id
globalStateProvider
.getFake(ACCOUNT_ACTIVE_ACCOUNT_ID)
.stateSubject.next(userIdFromAccessToken);
globalStateProvider.getFake(ACCOUNT_ACTIVE_ACCOUNT_ID).nextState(userIdFromAccessToken);
// Act
const result = await tokenService.getClientSecret();
@@ -2260,12 +2245,12 @@ describe("TokenService", () => {
// Arrange
singleUserStateProvider
.getFake(userIdFromAccessToken, API_KEY_CLIENT_SECRET_MEMORY)
.stateSubject.next([userIdFromAccessToken, clientSecret]);
.nextState(clientSecret);
// set disk to undefined
singleUserStateProvider
.getFake(userIdFromAccessToken, API_KEY_CLIENT_SECRET_DISK)
.stateSubject.next([userIdFromAccessToken, undefined]);
.nextState(undefined);
// Act
const result = await tokenService.getClientSecret(userIdFromAccessToken);
@@ -2279,16 +2264,14 @@ describe("TokenService", () => {
// Arrange
singleUserStateProvider
.getFake(userIdFromAccessToken, API_KEY_CLIENT_SECRET_MEMORY)
.stateSubject.next([userIdFromAccessToken, undefined]);
.nextState(undefined);
singleUserStateProvider
.getFake(userIdFromAccessToken, API_KEY_CLIENT_SECRET_DISK)
.stateSubject.next([userIdFromAccessToken, clientSecret]);
.nextState(clientSecret);
// Need to have global active id set to the user id
globalStateProvider
.getFake(ACCOUNT_ACTIVE_ACCOUNT_ID)
.stateSubject.next(userIdFromAccessToken);
globalStateProvider.getFake(ACCOUNT_ACTIVE_ACCOUNT_ID).nextState(userIdFromAccessToken);
// Act
const result = await tokenService.getClientSecret();
@@ -2300,11 +2283,11 @@ describe("TokenService", () => {
// Arrange
singleUserStateProvider
.getFake(userIdFromAccessToken, API_KEY_CLIENT_SECRET_MEMORY)
.stateSubject.next([userIdFromAccessToken, undefined]);
.nextState(undefined);
singleUserStateProvider
.getFake(userIdFromAccessToken, API_KEY_CLIENT_SECRET_DISK)
.stateSubject.next([userIdFromAccessToken, clientSecret]);
.nextState(clientSecret);
// Act
const result = await tokenService.getClientSecret(userIdFromAccessToken);
@@ -2327,11 +2310,11 @@ describe("TokenService", () => {
// Arrange
singleUserStateProvider
.getFake(userIdFromAccessToken, API_KEY_CLIENT_SECRET_MEMORY)
.stateSubject.next([userIdFromAccessToken, clientSecret]);
.nextState(clientSecret);
singleUserStateProvider
.getFake(userIdFromAccessToken, API_KEY_CLIENT_SECRET_DISK)
.stateSubject.next([userIdFromAccessToken, clientSecret]);
.nextState(clientSecret);
// Act
await (tokenService as any).clearClientSecret(userIdFromAccessToken);
@@ -2351,16 +2334,14 @@ describe("TokenService", () => {
// Arrange
singleUserStateProvider
.getFake(userIdFromAccessToken, API_KEY_CLIENT_SECRET_MEMORY)
.stateSubject.next([userIdFromAccessToken, clientSecret]);
.nextState(clientSecret);
singleUserStateProvider
.getFake(userIdFromAccessToken, API_KEY_CLIENT_SECRET_DISK)
.stateSubject.next([userIdFromAccessToken, clientSecret]);
.nextState(clientSecret);
// Need to have global active id set to the user id
globalStateProvider
.getFake(ACCOUNT_ACTIVE_ACCOUNT_ID)
.stateSubject.next(userIdFromAccessToken);
globalStateProvider.getFake(ACCOUNT_ACTIVE_ACCOUNT_ID).nextState(userIdFromAccessToken);
// Act
await (tokenService as any).clearClientSecret();
@@ -2634,7 +2615,7 @@ describe("TokenService", () => {
// Arrange
const userId = "userId" as UserId;
globalStateProvider.getFake(ACCOUNT_ACTIVE_ACCOUNT_ID).stateSubject.next(userId);
globalStateProvider.getFake(ACCOUNT_ACTIVE_ACCOUNT_ID).nextState(userId);
tokenService.clearAccessToken = jest.fn();
(tokenService as any).clearRefreshToken = jest.fn();
@@ -2693,7 +2674,7 @@ describe("TokenService", () => {
globalStateProvider
.getFake(EMAIL_TWO_FACTOR_TOKEN_RECORD_DISK_LOCAL)
.stateSubject.next(initialTwoFactorTokenRecord);
.nextState(initialTwoFactorTokenRecord);
// Act
await tokenService.setTwoFactorToken(email, twoFactorToken);
@@ -2716,7 +2697,7 @@ describe("TokenService", () => {
globalStateProvider
.getFake(EMAIL_TWO_FACTOR_TOKEN_RECORD_DISK_LOCAL)
.stateSubject.next(initialTwoFactorTokenRecord);
.nextState(initialTwoFactorTokenRecord);
// Act
const result = await tokenService.getTwoFactorToken(email);
@@ -2734,7 +2715,7 @@ describe("TokenService", () => {
globalStateProvider
.getFake(EMAIL_TWO_FACTOR_TOKEN_RECORD_DISK_LOCAL)
.stateSubject.next(initialTwoFactorTokenRecord);
.nextState(initialTwoFactorTokenRecord);
// Act
const result = await tokenService.getTwoFactorToken(email);
@@ -2745,9 +2726,7 @@ describe("TokenService", () => {
it("returns null when there is no two factor token record", async () => {
// Arrange
globalStateProvider
.getFake(EMAIL_TWO_FACTOR_TOKEN_RECORD_DISK_LOCAL)
.stateSubject.next(null);
globalStateProvider.getFake(EMAIL_TWO_FACTOR_TOKEN_RECORD_DISK_LOCAL).nextState(null);
// Act
const result = await tokenService.getTwoFactorToken("testUser");
@@ -2768,7 +2747,7 @@ describe("TokenService", () => {
globalStateProvider
.getFake(EMAIL_TWO_FACTOR_TOKEN_RECORD_DISK_LOCAL)
.stateSubject.next(initialTwoFactorTokenRecord);
.nextState(initialTwoFactorTokenRecord);
// Act
await tokenService.clearTwoFactorToken(email);
@@ -2808,9 +2787,7 @@ describe("TokenService", () => {
it("sets the security stamp in memory when there is an active user in global state", async () => {
// Arrange
globalStateProvider
.getFake(ACCOUNT_ACTIVE_ACCOUNT_ID)
.stateSubject.next(userIdFromAccessToken);
globalStateProvider.getFake(ACCOUNT_ACTIVE_ACCOUNT_ID).nextState(userIdFromAccessToken);
// Act
await tokenService.setSecurityStamp(mockSecurityStamp);
@@ -2843,13 +2820,11 @@ describe("TokenService", () => {
it("returns the security stamp from memory when no user id is specified (uses global active user)", async () => {
// Arrange
globalStateProvider
.getFake(ACCOUNT_ACTIVE_ACCOUNT_ID)
.stateSubject.next(userIdFromAccessToken);
globalStateProvider.getFake(ACCOUNT_ACTIVE_ACCOUNT_ID).nextState(userIdFromAccessToken);
singleUserStateProvider
.getFake(userIdFromAccessToken, SECURITY_STAMP_MEMORY)
.stateSubject.next([userIdFromAccessToken, mockSecurityStamp]);
.nextState(mockSecurityStamp);
// Act
const result = await tokenService.getSecurityStamp();
@@ -2862,7 +2837,7 @@ describe("TokenService", () => {
// Arrange
singleUserStateProvider
.getFake(userIdFromAccessToken, SECURITY_STAMP_MEMORY)
.stateSubject.next([userIdFromAccessToken, mockSecurityStamp]);
.nextState(mockSecurityStamp);
// Act
const result = await tokenService.getSecurityStamp(userIdFromAccessToken);

View File

@@ -9,6 +9,7 @@ import { KeyGenerationService } from "../../platform/abstractions/key-generation
import { LogService } from "../../platform/abstractions/log.service";
import { AbstractStorageService } from "../../platform/abstractions/storage.service";
import { StorageLocation } from "../../platform/enums";
import { Utils } from "../../platform/misc/utils";
import { EncString, EncryptedString } from "../../platform/models/domain/enc-string";
import { StorageOptions } from "../../platform/models/domain/storage-options";
import { SymmetricCryptoKey } from "../../platform/models/domain/symmetric-crypto-key";
@@ -875,8 +876,13 @@ export class TokenService implements TokenServiceAbstraction {
// jwthelper methods
// ref https://github.com/auth0/angular-jwt/blob/master/src/angularJwt/services/jwt.js
async decodeAccessToken(token?: string): Promise<DecodedAccessToken> {
token = token ?? (await this.getAccessToken());
async decodeAccessToken(tokenOrUserId?: string | UserId): Promise<DecodedAccessToken> {
let token = tokenOrUserId as string;
if (Utils.isGuid(tokenOrUserId)) {
token = await this.getAccessToken(tokenOrUserId as UserId);
} else {
token ??= await this.getAccessToken();
}
if (token == null) {
throw new Error("Access token not found.");
@@ -1012,10 +1018,10 @@ export class TokenService implements TokenServiceAbstraction {
return decoded.iss;
}
async getIsExternal(): Promise<boolean> {
async getIsExternal(userId: UserId): Promise<boolean> {
let decoded: DecodedAccessToken;
try {
decoded = await this.decodeAccessToken();
decoded = await this.decodeAccessToken(userId);
} catch (error) {
throw new Error("Failed to decode access token: " + error.message);
}

View File

@@ -15,6 +15,7 @@ import {
StateProvider,
UserKeyDefinition,
} from "../../platform/state";
import { UserId } from "../../types/guid";
const SHOW_FAVICONS = new KeyDefinition(DOMAIN_SETTINGS_DISK, "showFavicons", {
deserializer: (value: boolean) => value ?? true,
@@ -44,7 +45,7 @@ export abstract class DomainSettingsService {
neverDomains$: Observable<NeverDomains>;
setNeverDomains: (newValue: NeverDomains) => Promise<void>;
equivalentDomains$: Observable<EquivalentDomains>;
setEquivalentDomains: (newValue: EquivalentDomains) => Promise<void>;
setEquivalentDomains: (newValue: EquivalentDomains, userId: UserId) => Promise<void>;
defaultUriMatchStrategy$: Observable<UriMatchStrategySetting>;
setDefaultUriMatchStrategy: (newValue: UriMatchStrategySetting) => Promise<void>;
getUrlEquivalentDomains: (url: string) => Observable<Set<string>>;
@@ -87,8 +88,8 @@ export class DefaultDomainSettingsService implements DomainSettingsService {
await this.neverDomainsState.update(() => newValue);
}
async setEquivalentDomains(newValue: EquivalentDomains): Promise<void> {
await this.equivalentDomainsState.update(() => newValue);
async setEquivalentDomains(newValue: EquivalentDomains, userId: UserId): Promise<void> {
await this.stateProvider.getUser(userId, EQUIVALENT_DOMAINS).update(() => newValue);
}
async setDefaultUriMatchStrategy(newValue: UriMatchStrategySetting): Promise<void> {

View File

@@ -1,5 +1,7 @@
import { Observable } from "rxjs";
import { UserId } from "../../../types/guid";
export type BillingAccountProfile = {
hasPremiumPersonally: boolean;
hasPremiumFromAnyOrganization: boolean;
@@ -32,5 +34,6 @@ export abstract class BillingAccountProfileStateService {
abstract setHasPremium(
hasPremiumPersonally: boolean,
hasPremiumFromAnyOrganization: boolean,
userId: UserId,
): Promise<void>;
}

View File

@@ -3,7 +3,6 @@ import { firstValueFrom } from "rxjs";
import {
FakeAccountService,
mockAccountServiceWith,
FakeActiveUserState,
FakeStateProvider,
FakeSingleUserState,
} from "../../../../spec";
@@ -18,7 +17,6 @@ import {
describe("BillingAccountProfileStateService", () => {
let stateProvider: FakeStateProvider;
let sut: DefaultBillingAccountProfileStateService;
let billingAccountProfileState: FakeActiveUserState<BillingAccountProfile>;
let userBillingAccountProfileState: FakeSingleUserState<BillingAccountProfile>;
let accountService: FakeAccountService;
@@ -30,10 +28,6 @@ describe("BillingAccountProfileStateService", () => {
sut = new DefaultBillingAccountProfileStateService(stateProvider);
billingAccountProfileState = stateProvider.activeUser.getFake(
BILLING_ACCOUNT_PROFILE_KEY_DEFINITION,
);
userBillingAccountProfileState = stateProvider.singleUser.getFake(
userId,
BILLING_ACCOUNT_PROFILE_KEY_DEFINITION,
@@ -133,12 +127,11 @@ describe("BillingAccountProfileStateService", () => {
describe("setHasPremium", () => {
it("should update the active users state when called", async () => {
await sut.setHasPremium(true, false);
await sut.setHasPremium(true, false, userId);
expect(billingAccountProfileState.nextMock).toHaveBeenCalledWith([
userId,
{ hasPremiumPersonally: true, hasPremiumFromAnyOrganization: false },
]);
expect(await firstValueFrom(sut.hasPremiumFromAnyOrganization$)).toBe(false);
expect(await firstValueFrom(sut.hasPremiumPersonally$)).toBe(true);
expect(await firstValueFrom(sut.hasPremiumFromAnySource$)).toBe(true);
});
});
});

View File

@@ -6,6 +6,7 @@ import {
StateProvider,
UserKeyDefinition,
} from "../../../platform/state";
import { UserId } from "../../../types/guid";
import {
BillingAccountProfile,
BillingAccountProfileStateService,
@@ -27,7 +28,7 @@ export class DefaultBillingAccountProfileStateService implements BillingAccountP
hasPremiumPersonally$: Observable<boolean>;
hasPremiumFromAnySource$: Observable<boolean>;
constructor(stateProvider: StateProvider) {
constructor(private readonly stateProvider: StateProvider) {
this.billingAccountProfileState = stateProvider.getActive(
BILLING_ACCOUNT_PROFILE_KEY_DEFINITION,
);
@@ -62,8 +63,9 @@ export class DefaultBillingAccountProfileStateService implements BillingAccountP
async setHasPremium(
hasPremiumPersonally: boolean,
hasPremiumFromAnyOrganization: boolean,
userId: UserId,
): Promise<void> {
await this.billingAccountProfileState.update((billingAccountProfile) => {
await this.stateProvider.getUser(userId, BILLING_ACCOUNT_PROFILE_KEY_DEFINITION).update((_) => {
return {
hasPremiumPersonally: hasPremiumPersonally,
hasPremiumFromAnyOrganization: hasPremiumFromAnyOrganization,

View File

@@ -143,7 +143,7 @@ export abstract class CryptoService {
* @param userKeyMasterKey The master key encrypted user key to set
* @param userId The desired user
*/
abstract setMasterKeyEncryptedUserKey(UserKeyMasterKey: string, userId?: string): Promise<void>;
abstract setMasterKeyEncryptedUserKey(UserKeyMasterKey: string, userId: string): Promise<void>;
/**
* @param password The user's master password that will be used to derive a master key if one isn't found
* @param userId The desired user

View File

@@ -43,26 +43,6 @@ export abstract class PlatformUtilsService {
abstract isSelfHost(): boolean;
abstract copyToClipboard(text: string, options?: ClipboardOptions): void | boolean;
abstract readFromClipboard(): Promise<string>;
abstract supportsBiometric(): Promise<boolean>;
/**
* Determine whether biometrics support requires going through a setup process.
* This is currently only needed on Linux.
*
* @returns true if biometrics support requires setup, false if it does not (is already setup, or did not require it in the first place)
*/
abstract biometricsNeedsSetup: () => Promise<boolean>;
/**
* Determine whether biometrics support can be automatically setup, or requires user interaction.
* Auto-setup is prevented by sandboxed environments, such as Snap and Flatpak.
*
* @returns true if biometrics support can be automatically setup, false if it requires user interaction.
*/
abstract biometricsSupportsAutoSetup(): Promise<boolean>;
/**
* Start automatic biometric setup, which places the required configuration files / changes the required settings.
*/
abstract biometricsSetup: () => Promise<void>;
abstract authenticateBiometric(): Promise<boolean>;
abstract supportsSecureStorage(): boolean;
abstract getAutofillKeyboardShortcut(): Promise<string>;
}

View File

@@ -119,7 +119,7 @@ describe("BiometricStateService", () => {
describe("getRequirePasswordOnStart", () => {
it("returns the requirePasswordOnStart state value", async () => {
stateProvider.singleUser.mockFor(userId, REQUIRE_PASSWORD_ON_START.key, true);
stateProvider.singleUser.mockFor(userId, REQUIRE_PASSWORD_ON_START, true);
expect(await sut.getRequirePasswordOnStart(userId)).toBe(true);
});

View File

@@ -0,0 +1,37 @@
/**
* The biometrics service is used to provide access to the status of and access to biometric functionality on the platforms.
*/
export abstract class BiometricsService {
/**
* Check if the platform supports biometric authentication.
*/
abstract supportsBiometric(): Promise<boolean>;
/**
* Checks whether biometric unlock is currently available at the moment (e.g. if the laptop lid is shut, biometric unlock may not be available)
*/
abstract isBiometricUnlockAvailable(): Promise<boolean>;
/**
* Performs biometric authentication
*/
abstract authenticateBiometric(): Promise<boolean>;
/**
* Determine whether biometrics support requires going through a setup process.
* This is currently only needed on Linux.
*
* @returns true if biometrics support requires setup, false if it does not (is already setup, or did not require it in the first place)
*/
abstract biometricsNeedsSetup(): Promise<boolean>;
/**
* Determine whether biometrics support can be automatically setup, or requires user interaction.
* Auto-setup is prevented by sandboxed environments, such as Snap and Flatpak.
*
* @returns true if biometrics support can be automatically setup, false if it requires user interaction.
*/
abstract biometricsSupportsAutoSetup(): Promise<boolean>;
/**
* Start automatic biometric setup, which places the required configuration files / changes the required settings.
*/
abstract biometricsSetup(): Promise<void>;
}

View File

@@ -3,8 +3,8 @@
* @jest-environment ../../libs/shared/test.environment.ts
*/
import { mock } from "jest-mock-extended";
import { Subject, firstValueFrom, of } from "rxjs";
import { matches, mock } from "jest-mock-extended";
import { BehaviorSubject, Subject, bufferCount, firstValueFrom, of } from "rxjs";
import {
FakeGlobalState,
@@ -35,6 +35,7 @@ import {
RETRIEVAL_INTERVAL,
GLOBAL_SERVER_CONFIGURATIONS,
USER_SERVER_CONFIG,
SLOW_EMISSION_GUARD,
} from "./default-config.service";
describe("ConfigService", () => {
@@ -65,12 +66,14 @@ describe("ConfigService", () => {
describe.each([null, userId])("active user: %s", (activeUserId) => {
let sut: DefaultConfigService;
const environmentSubject = new BehaviorSubject(environmentFactory(activeApiUrl));
beforeAll(async () => {
await accountService.switchAccount(activeUserId);
});
beforeEach(() => {
environmentService.environment$ = of(environmentFactory(activeApiUrl));
environmentService.environment$ = environmentSubject;
sut = new DefaultConfigService(
configApiService,
environmentService,
@@ -129,7 +132,8 @@ describe("ConfigService", () => {
await firstValueFrom(sut.serverConfig$);
expect(logService.error).toHaveBeenCalledWith(
`Unable to fetch ServerConfig from ${activeApiUrl}: Unable to fetch`,
`Unable to fetch ServerConfig from ${activeApiUrl}`,
matches<Error>((e) => e.message === "Unable to fetch"),
);
});
});
@@ -138,6 +142,10 @@ describe("ConfigService", () => {
const response = serverConfigResponseFactory();
const newConfig = new ServerConfig(new ServerConfigData(response));
beforeEach(() => {
configApiService.get.mockResolvedValue(response);
});
it("should be a new config", async () => {
expect(newConfig).not.toEqual(activeUserId ? userStored : globalStored[activeApiUrl]);
});
@@ -149,8 +157,6 @@ describe("ConfigService", () => {
});
it("returns the updated config", async () => {
configApiService.get.mockResolvedValue(response);
const actual = await firstValueFrom(sut.serverConfig$);
// This is the time the response is converted to a config
@@ -270,6 +276,54 @@ describe("ConfigService", () => {
});
});
});
describe("slow configuration", () => {
const environmentSubject = new BehaviorSubject<Environment>(null);
let sut: DefaultConfigService = null;
beforeEach(async () => {
const config = serverConfigFactory("existing-data", tooOld);
environmentService.environment$ = environmentSubject;
globalState.stateSubject.next({ [apiUrl(0)]: config });
userState.stateSubject.next({
syncValue: true,
combinedState: [userId, config],
});
configApiService.get.mockImplementation(() => {
return new Promise<ServerConfigResponse>((resolve) => {
setTimeout(() => {
resolve(serverConfigResponseFactory("slow-response"));
}, SLOW_EMISSION_GUARD + 20);
});
});
sut = new DefaultConfigService(
configApiService,
environmentService,
logService,
stateProvider,
authService,
);
});
afterEach(() => {
jest.resetAllMocks();
});
it("emits old configuration when the http call takes a long time", async () => {
environmentSubject.next(environmentFactory(apiUrl(0)));
const configs = await firstValueFrom(sut.serverConfig$.pipe(bufferCount(2)));
await jest.runOnlyPendingTimersAsync();
expect(configs[0].gitHash).toBe("existing-data");
expect(configs[1].gitHash).toBe("slow-response");
});
});
});
function apiUrl(count: number) {
@@ -305,8 +359,9 @@ function serverConfigResponseFactory(hash?: string) {
});
}
function environmentFactory(apiUrl: string) {
function environmentFactory(apiUrl: string, isCloud: boolean = true) {
return {
getApiUrl: () => apiUrl,
isCloud: () => isCloud,
} as Environment;
}

View File

@@ -24,7 +24,7 @@ import { UserId } from "../../../types/guid";
import { ConfigApiServiceAbstraction } from "../../abstractions/config/config-api.service.abstraction";
import { ConfigService } from "../../abstractions/config/config.service";
import { ServerConfig } from "../../abstractions/config/server-config";
import { EnvironmentService, Region } from "../../abstractions/environment.service";
import { Environment, EnvironmentService, Region } from "../../abstractions/environment.service";
import { LogService } from "../../abstractions/log.service";
import { devFlagEnabled, devFlagValue } from "../../misc/flags";
import { ServerConfigData } from "../../models/data/server-config.data";
@@ -34,6 +34,8 @@ export const RETRIEVAL_INTERVAL = devFlagEnabled("configRetrievalIntervalMs")
? (devFlagValue("configRetrievalIntervalMs") as number)
: 3_600_000; // 1 hour
export const SLOW_EMISSION_GUARD = 800;
export type ApiUrl = string;
export const USER_SERVER_CONFIG = new UserKeyDefinition<ServerConfig>(CONFIG_DISK, "serverConfig", {
@@ -64,29 +66,32 @@ export class DefaultConfigService implements ConfigService {
private stateProvider: StateProvider,
private authService: AuthService,
) {
const apiUrl$ = this.environmentService.environment$.pipe(
map((environment) => environment.getApiUrl()),
);
const userId$ = this.stateProvider.activeUserId$;
const authStatus$ = userId$.pipe(
switchMap((userId) => (userId == null ? of(null) : this.authService.authStatusFor$(userId))),
);
this.serverConfig$ = combineLatest([userId$, apiUrl$, authStatus$]).pipe(
switchMap(([userId, apiUrl, authStatus]) => {
this.serverConfig$ = combineLatest([
userId$,
this.environmentService.environment$,
authStatus$,
]).pipe(
switchMap(([userId, environment, authStatus]) => {
if (userId == null || authStatus !== AuthenticationStatus.Unlocked) {
return this.globalConfigFor$(apiUrl).pipe(
map((config) => [config, null, apiUrl] as const),
return this.globalConfigFor$(environment.getApiUrl()).pipe(
map((config) => [config, null, environment] as const),
);
}
return this.userConfigFor$(userId).pipe(map((config) => [config, userId, apiUrl] as const));
return this.userConfigFor$(userId).pipe(
map((config) => [config, userId, environment] as const),
);
}),
tap(async (rec) => {
const [existingConfig, userId, apiUrl] = rec;
const [existingConfig, userId, environment] = rec;
// Grab new config if older retrieval interval
if (!existingConfig || this.olderThanRetrievalInterval(existingConfig.utcDate)) {
await this.renewConfig(existingConfig, userId, apiUrl);
await this.renewConfig(existingConfig, userId, environment);
}
}),
switchMap(([existingConfig]) => {
@@ -149,10 +154,20 @@ export class DefaultConfigService implements ConfigService {
private async renewConfig(
existingConfig: ServerConfig,
userId: UserId,
apiUrl: string,
environment: Environment,
): Promise<void> {
try {
// Feature flags often have a big impact on user experience, lets ensure we return some value
// somewhat quickly even though it may not be accurate, we won't cancel the HTTP request
// though so that hopefully it can have finished and hydrated a more accurate value.
const handle = setTimeout(() => {
this.logService.info(
"Self-host environment did not respond in time, emitting previous config.",
);
this.failedFetchFallbackSubject.next(existingConfig);
}, SLOW_EMISSION_GUARD);
const response = await this.configApiService.get(userId);
clearTimeout(handle);
const newConfig = new ServerConfig(new ServerConfigData(response));
// Update the environment region
@@ -167,7 +182,7 @@ export class DefaultConfigService implements ConfigService {
if (userId == null) {
// update global state with new pulled config
await this.stateProvider.getGlobal(GLOBAL_SERVER_CONFIGURATIONS).update((configs) => {
return { ...configs, [apiUrl]: newConfig };
return { ...configs, [environment.getApiUrl()]: newConfig };
});
} else {
// update state with new pulled config
@@ -175,9 +190,7 @@ export class DefaultConfigService implements ConfigService {
}
} catch (e) {
// mutate error to be handled by catchError
this.logService.error(
`Unable to fetch ServerConfig from ${apiUrl}: ${(e as Error)?.message}`,
);
this.logService.error(`Unable to fetch ServerConfig from ${environment.getApiUrl()}`, e);
// Emit the existing config
this.failedFetchFallbackSubject.next(existingConfig);
}

View File

@@ -365,9 +365,9 @@ describe("cryptoService", () => {
const userKeyState = stateProvider.singleUser.getFake(mockUserId, USER_KEY);
const fakeMasterKey = makeMasterKey ? makeSymmetricCryptoKey<MasterKey>(64) : null;
masterPasswordService.masterKeySubject.next(fakeMasterKey);
userKeyState.stateSubject.next([mockUserId, null]);
userKeyState.nextState(null);
const fakeUserKey = makeUserKey ? makeSymmetricCryptoKey<UserKey>(64) : null;
userKeyState.stateSubject.next([mockUserId, fakeUserKey]);
userKeyState.nextState(fakeUserKey);
return [fakeUserKey, fakeMasterKey];
}
@@ -384,10 +384,7 @@ describe("cryptoService", () => {
const fakeEncryptedUserPrivateKey = makeEncString("1");
userEncryptedPrivateKeyState.stateSubject.next([
mockUserId,
fakeEncryptedUserPrivateKey.encryptedString,
]);
userEncryptedPrivateKeyState.nextState(fakeEncryptedUserPrivateKey.encryptedString);
// Decryption of the user private key
const fakeDecryptedUserPrivateKey = makeStaticByteArray(10, 1);
@@ -423,7 +420,7 @@ describe("cryptoService", () => {
mockUserId,
USER_ENCRYPTED_PRIVATE_KEY,
);
encryptedUserPrivateKeyState.stateSubject.next([mockUserId, null]);
encryptedUserPrivateKeyState.nextState(null);
const userPrivateKey = await firstValueFrom(cryptoService.userPrivateKey$(mockUserId));
expect(userPrivateKey).toBeFalsy();
@@ -463,7 +460,7 @@ describe("cryptoService", () => {
function updateKeys(keys: Partial<UpdateKeysParams> = {}) {
if ("userKey" in keys) {
const userKeyState = stateProvider.singleUser.getFake(mockUserId, USER_KEY);
userKeyState.stateSubject.next([mockUserId, keys.userKey]);
userKeyState.nextState(keys.userKey);
}
if ("encryptedPrivateKey" in keys) {
@@ -471,10 +468,7 @@ describe("cryptoService", () => {
mockUserId,
USER_ENCRYPTED_PRIVATE_KEY,
);
userEncryptedPrivateKey.stateSubject.next([
mockUserId,
keys.encryptedPrivateKey.encryptedString,
]);
userEncryptedPrivateKey.nextState(keys.encryptedPrivateKey.encryptedString);
}
if ("orgKeys" in keys) {
@@ -482,7 +476,7 @@ describe("cryptoService", () => {
mockUserId,
USER_ENCRYPTED_ORGANIZATION_KEYS,
);
orgKeysState.stateSubject.next([mockUserId, keys.orgKeys]);
orgKeysState.nextState(keys.orgKeys);
}
if ("providerKeys" in keys) {
@@ -490,7 +484,7 @@ describe("cryptoService", () => {
mockUserId,
USER_ENCRYPTED_PROVIDER_KEYS,
);
providerKeysState.stateSubject.next([mockUserId, keys.providerKeys]);
providerKeysState.nextState(keys.providerKeys);
}
encryptService.decryptToBytes.mockImplementation((encryptedPrivateKey, userKey) => {

View File

@@ -225,7 +225,7 @@ export class CryptoService implements CryptoServiceAbstraction {
}
}
async setMasterKeyEncryptedUserKey(userKeyMasterKey: string, userId?: UserId): Promise<void> {
async setMasterKeyEncryptedUserKey(userKeyMasterKey: string, userId: UserId): Promise<void> {
userId ??= await firstValueFrom(this.stateProvider.activeUserId$);
await this.masterPasswordService.setMasterKeyEncryptedUserKey(
new EncString(userKeyMasterKey),

View File

@@ -143,7 +143,7 @@ describe("DefaultStateProvider", () => {
it("should not emit any values until a truthy user id is supplied", async () => {
accountService.activeAccountSubject.next(null);
const state = singleUserStateProvider.getFake(userId, keyDefinition);
state.stateSubject.next([userId, "value"]);
state.nextState("value");
const emissions = trackEmissions(sut.getUserState$(keyDefinition));

View File

@@ -124,12 +124,12 @@ export class DefaultSyncService extends CoreSyncService {
const response = await this.apiService.getSync();
await this.syncProfile(response.profile);
await this.syncFolders(response.folders);
await this.syncCollections(response.collections);
await this.syncCiphers(response.ciphers);
await this.syncSends(response.sends);
await this.syncSettings(response.domains);
await this.syncPolicies(response.policies);
await this.syncFolders(response.folders, response.profile.id);
await this.syncCollections(response.collections, response.profile.id);
await this.syncCiphers(response.ciphers, response.profile.id);
await this.syncSends(response.sends, response.profile.id);
await this.syncSettings(response.domains, response.profile.id);
await this.syncPolicies(response.policies, response.profile.id);
await this.setLastSync(now, userId);
return this.syncCompleted(true);
@@ -190,8 +190,9 @@ export class DefaultSyncService extends CoreSyncService {
await this.billingAccountProfileStateService.setHasPremium(
response.premiumPersonally,
response.premiumFromOrganization,
response.id,
);
await this.keyConnectorService.setUsesKeyConnector(response.usesKeyConnector);
await this.keyConnectorService.setUsesKeyConnector(response.usesKeyConnector, response.id);
await this.setForceSetPasswordReasonIfNeeded(response);
@@ -200,17 +201,17 @@ export class DefaultSyncService extends CoreSyncService {
providers[p.id] = new ProviderData(p);
});
await this.providerService.save(providers);
await this.providerService.save(providers, response.id);
await this.syncProfileOrganizations(response);
await this.syncProfileOrganizations(response, response.id);
if (await this.keyConnectorService.userNeedsMigration()) {
await this.keyConnectorService.setConvertAccountRequired(true);
if (await this.keyConnectorService.userNeedsMigration(response.id)) {
await this.keyConnectorService.setConvertAccountRequired(true, response.id);
this.messageSender.send("convertAccountToKeyConnector");
} else {
// FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling.
// eslint-disable-next-line @typescript-eslint/no-floating-promises
this.keyConnectorService.removeConvertAccountRequired();
this.keyConnectorService.removeConvertAccountRequired(response.id);
}
}
@@ -261,7 +262,7 @@ export class DefaultSyncService extends CoreSyncService {
}
}
private async syncProfileOrganizations(response: ProfileResponse) {
private async syncProfileOrganizations(response: ProfileResponse, userId: UserId) {
const organizations: { [id: string]: OrganizationData } = {};
response.organizations.forEach((o) => {
organizations[o.id] = new OrganizationData(o, {
@@ -281,42 +282,42 @@ export class DefaultSyncService extends CoreSyncService {
}
});
await this.organizationService.replace(organizations);
await this.organizationService.replace(organizations, userId);
}
private async syncFolders(response: FolderResponse[]) {
private async syncFolders(response: FolderResponse[], userId: UserId) {
const folders: { [id: string]: FolderData } = {};
response.forEach((f) => {
folders[f.id] = new FolderData(f);
});
return await this.folderService.replace(folders);
return await this.folderService.replace(folders, userId);
}
private async syncCollections(response: CollectionDetailsResponse[]) {
private async syncCollections(response: CollectionDetailsResponse[], userId: UserId) {
const collections: { [id: string]: CollectionData } = {};
response.forEach((c) => {
collections[c.id] = new CollectionData(c);
});
return await this.collectionService.replace(collections);
return await this.collectionService.replace(collections, userId);
}
private async syncCiphers(response: CipherResponse[]) {
private async syncCiphers(response: CipherResponse[], userId: UserId) {
const ciphers: { [id: string]: CipherData } = {};
response.forEach((c) => {
ciphers[c.id] = new CipherData(c);
});
return await this.cipherService.replace(ciphers);
return await this.cipherService.replace(ciphers, userId);
}
private async syncSends(response: SendResponse[]) {
private async syncSends(response: SendResponse[], userId: UserId) {
const sends: { [id: string]: SendData } = {};
response.forEach((s) => {
sends[s.id] = new SendData(s);
});
return await this.sendService.replace(sends);
return await this.sendService.replace(sends, userId);
}
private async syncSettings(response: DomainsResponse) {
private async syncSettings(response: DomainsResponse, userId: UserId) {
let eqDomains: string[][] = [];
if (response != null && response.equivalentDomains != null) {
eqDomains = eqDomains.concat(response.equivalentDomains);
@@ -330,16 +331,16 @@ export class DefaultSyncService extends CoreSyncService {
});
}
return this.domainSettingsService.setEquivalentDomains(eqDomains);
return this.domainSettingsService.setEquivalentDomains(eqDomains, userId);
}
private async syncPolicies(response: PolicyResponse[]) {
private async syncPolicies(response: PolicyResponse[], userId: UserId) {
const policies: { [id: string]: PolicyData } = {};
if (response != null) {
response.forEach((p) => {
policies[p.id] = new PolicyData(p);
});
}
return await this.policyService.replace(policies);
return await this.policyService.replace(policies, userId);
}
}

View File

@@ -1,15 +1,19 @@
import { Observable } from "rxjs";
import type { Simplify } from "type-fest";
import { CombinedState } from "../../../platform/state";
import { UserId } from "../../../types/guid";
import { SendData } from "../models/data/send.data";
import { SendView } from "../models/view/send.view";
type EncryptedSendState = Simplify<CombinedState<Record<string, SendData>>>;
export abstract class SendStateProvider {
encryptedState$: Observable<Record<string, SendData>>;
encryptedState$: Observable<EncryptedSendState>;
decryptedState$: Observable<SendView[]>;
getEncryptedSends: () => Promise<{ [id: string]: SendData }>;
getEncryptedSends: () => Promise<EncryptedSendState>;
setEncryptedSends: (value: { [id: string]: SendData }) => Promise<void>;
setEncryptedSends: (value: { [id: string]: SendData }, userId: UserId) => Promise<void>;
getDecryptedSends: () => Promise<SendView[]>;

View File

@@ -27,11 +27,11 @@ describe("Send State Provider", () => {
describe("Encrypted Sends", () => {
it("should return SendData", async () => {
const sendData = { "1": testSendData("1", "Test Send Data") };
await sendStateProvider.setEncryptedSends(sendData);
await sendStateProvider.setEncryptedSends(sendData, mockUserId);
await awaitAsync();
const actual = await sendStateProvider.getEncryptedSends();
expect(actual).toStrictEqual(sendData);
expect(actual).toStrictEqual([mockUserId, sendData]);
});
});

View File

@@ -1,6 +1,7 @@
import { Observable, firstValueFrom } from "rxjs";
import { ActiveUserState, StateProvider } from "../../../platform/state";
import { ActiveUserState, CombinedState, StateProvider } from "../../../platform/state";
import { UserId } from "../../../types/guid";
import { SendData } from "../models/data/send.data";
import { SendView } from "../models/view/send.view";
@@ -10,7 +11,7 @@ import { SendStateProvider as SendStateProviderAbstraction } from "./send-state.
/** State provider for sends */
export class SendStateProvider implements SendStateProviderAbstraction {
/** Observable for the encrypted sends for an active user */
encryptedState$: Observable<Record<string, SendData>>;
encryptedState$: Observable<CombinedState<Record<string, SendData>>>;
/** Observable with the decrypted sends for an active user */
decryptedState$: Observable<SendView[]>;
@@ -19,20 +20,20 @@ export class SendStateProvider implements SendStateProviderAbstraction {
constructor(protected stateProvider: StateProvider) {
this.activeUserEncryptedState = this.stateProvider.getActive(SEND_USER_ENCRYPTED);
this.encryptedState$ = this.activeUserEncryptedState.state$;
this.encryptedState$ = this.activeUserEncryptedState.combinedState$;
this.activeUserDecryptedState = this.stateProvider.getActive(SEND_USER_DECRYPTED);
this.decryptedState$ = this.activeUserDecryptedState.state$;
}
/** Gets the encrypted sends from state for an active user */
async getEncryptedSends(): Promise<{ [id: string]: SendData }> {
async getEncryptedSends(): Promise<CombinedState<{ [id: string]: SendData }>> {
return await firstValueFrom(this.encryptedState$);
}
/** Sets the encrypted send state for an active user */
async setEncryptedSends(value: { [id: string]: SendData }): Promise<void> {
await this.activeUserEncryptedState.update(() => value);
async setEncryptedSends(value: { [id: string]: SendData }, userId: UserId): Promise<void> {
await this.stateProvider.getUser(userId, SEND_USER_ENCRYPTED).update(() => value);
}
/** Gets the decrypted sends from state for the active user */

View File

@@ -55,6 +55,6 @@ export abstract class SendService implements UserKeyRotationDataProvider<SendWit
export abstract class InternalSendService extends SendService {
upsert: (send: SendData | SendData[]) => Promise<any>;
replace: (sends: { [id: string]: SendData }) => Promise<void>;
replace: (sends: { [id: string]: SendData }, userId: UserId) => Promise<void>;
delete: (id: string | string[]) => Promise<any>;
}

View File

@@ -110,9 +110,12 @@ describe("SendService", () => {
const result = await firstValueFrom(singleSendObservable);
expect(result).toEqual(testSend("1", "Test Send"));
await sendService.replace({
"1": testSendData("1", "Test Send Updated"),
});
await sendService.replace(
{
"1": testSendData("1", "Test Send Updated"),
},
mockUserId,
);
const result2 = await firstValueFrom(singleSendObservable);
expect(result2).toEqual(testSend("1", "Test Send Updated"));
@@ -127,10 +130,13 @@ describe("SendService", () => {
//it is immediately called when subscribed, we need to reset the value
changed = false;
await sendService.replace({
"1": sendDataObject,
"2": testSendData("2", "Test Send 2"),
});
await sendService.replace(
{
"1": sendDataObject,
"2": testSendData("2", "Test Send 2"),
},
mockUserId,
);
expect(changed).toEqual(true);
});
@@ -138,10 +144,13 @@ describe("SendService", () => {
it("reports a change when notes changes on a new send", async () => {
const sendDataObject = createSendData() as SendData;
await sendService.replace({
"1": sendDataObject,
"2": testSendData("2", "Test Send 2"),
});
await sendService.replace(
{
"1": sendDataObject,
"2": testSendData("2", "Test Send 2"),
},
mockUserId,
);
let changed = false;
sendService.get$("1").subscribe(() => {
@@ -152,10 +161,13 @@ describe("SendService", () => {
//it is immediately called when subscribed, we need to reset the value
changed = false;
await sendService.replace({
"1": sendDataObject,
"2": testSendData("2", "Test Send 2"),
});
await sendService.replace(
{
"1": sendDataObject,
"2": testSendData("2", "Test Send 2"),
},
mockUserId,
);
expect(changed).toEqual(true);
});
@@ -163,10 +175,13 @@ describe("SendService", () => {
it("reports a change when Text changes on a new send", async () => {
const sendDataObject = createSendData() as SendData;
await sendService.replace({
"1": sendDataObject,
"2": testSendData("2", "Test Send 2"),
});
await sendService.replace(
{
"1": sendDataObject,
"2": testSendData("2", "Test Send 2"),
},
mockUserId,
);
let changed = false;
sendService.get$("1").subscribe(() => {
@@ -177,10 +192,13 @@ describe("SendService", () => {
changed = false;
sendDataObject.text.text = "new text";
await sendService.replace({
"1": sendDataObject,
"2": testSendData("2", "Test Send 2"),
});
await sendService.replace(
{
"1": sendDataObject,
"2": testSendData("2", "Test Send 2"),
},
mockUserId,
);
expect(changed).toEqual(true);
});
@@ -188,10 +206,13 @@ describe("SendService", () => {
it("reports a change when Text is set as null on a new send", async () => {
const sendDataObject = createSendData() as SendData;
await sendService.replace({
"1": sendDataObject,
"2": testSendData("2", "Test Send 2"),
});
await sendService.replace(
{
"1": sendDataObject,
"2": testSendData("2", "Test Send 2"),
},
mockUserId,
);
let changed = false;
sendService.get$("1").subscribe(() => {
@@ -202,10 +223,13 @@ describe("SendService", () => {
changed = false;
sendDataObject.text = null;
await sendService.replace({
"1": sendDataObject,
"2": testSendData("2", "Test Send 2"),
});
await sendService.replace(
{
"1": sendDataObject,
"2": testSendData("2", "Test Send 2"),
},
mockUserId,
);
expect(changed).toEqual(true);
});
@@ -215,10 +239,13 @@ describe("SendService", () => {
type: SendType.File,
file: new SendFileData(new SendFileApi({ FileName: "name of file" })),
}) as SendData;
await sendService.replace({
"1": sendDataObject,
"2": testSendData("2", "Test Send 2"),
});
await sendService.replace(
{
"1": sendDataObject,
"2": testSendData("2", "Test Send 2"),
},
mockUserId,
);
sendDataObject.file = new SendFileData(new SendFileApi({ FileName: "updated name of file" }));
let changed = false;
@@ -229,10 +256,13 @@ describe("SendService", () => {
//it is immediately called when subscribed, we need to reset the value
changed = false;
await sendService.replace({
"1": sendDataObject,
"2": testSendData("2", "Test Send 2"),
});
await sendService.replace(
{
"1": sendDataObject,
"2": testSendData("2", "Test Send 2"),
},
mockUserId,
);
expect(changed).toEqual(false);
});
@@ -240,10 +270,13 @@ describe("SendService", () => {
it("reports a change when key changes on a new send", async () => {
const sendDataObject = createSendData() as SendData;
await sendService.replace({
"1": sendDataObject,
"2": testSendData("2", "Test Send 2"),
});
await sendService.replace(
{
"1": sendDataObject,
"2": testSendData("2", "Test Send 2"),
},
mockUserId,
);
let changed = false;
sendService.get$("1").subscribe(() => {
@@ -254,10 +287,13 @@ describe("SendService", () => {
changed = false;
sendDataObject.key = "newKey";
await sendService.replace({
"1": sendDataObject,
"2": testSendData("2", "Test Send 2"),
});
await sendService.replace(
{
"1": sendDataObject,
"2": testSendData("2", "Test Send 2"),
},
mockUserId,
);
expect(changed).toEqual(true);
});
@@ -265,10 +301,13 @@ describe("SendService", () => {
it("reports a change when revisionDate changes on a new send", async () => {
const sendDataObject = createSendData() as SendData;
await sendService.replace({
"1": sendDataObject,
"2": testSendData("2", "Test Send 2"),
});
await sendService.replace(
{
"1": sendDataObject,
"2": testSendData("2", "Test Send 2"),
},
mockUserId,
);
let changed = false;
sendService.get$("1").subscribe(() => {
@@ -279,10 +318,13 @@ describe("SendService", () => {
changed = false;
sendDataObject.revisionDate = "2025-04-05";
await sendService.replace({
"1": sendDataObject,
"2": testSendData("2", "Test Send 2"),
});
await sendService.replace(
{
"1": sendDataObject,
"2": testSendData("2", "Test Send 2"),
},
mockUserId,
);
expect(changed).toEqual(true);
});
@@ -290,10 +332,13 @@ describe("SendService", () => {
it("reports a change when a property is set as null on a new send", async () => {
const sendDataObject = createSendData() as SendData;
await sendService.replace({
"1": sendDataObject,
"2": testSendData("2", "Test Send 2"),
});
await sendService.replace(
{
"1": sendDataObject,
"2": testSendData("2", "Test Send 2"),
},
mockUserId,
);
let changed = false;
sendService.get$("1").subscribe(() => {
@@ -304,10 +349,13 @@ describe("SendService", () => {
changed = false;
sendDataObject.name = null;
await sendService.replace({
"1": sendDataObject,
"2": testSendData("2", "Test Send 2"),
});
await sendService.replace(
{
"1": sendDataObject,
"2": testSendData("2", "Test Send 2"),
},
mockUserId,
);
expect(changed).toEqual(true);
});
@@ -317,10 +365,13 @@ describe("SendService", () => {
text: new SendTextData(new SendTextApi({ Text: null })),
}) as SendData;
await sendService.replace({
"1": sendDataObject,
"2": testSendData("2", "Test Send 2"),
});
await sendService.replace(
{
"1": sendDataObject,
"2": testSendData("2", "Test Send 2"),
},
mockUserId,
);
let changed = false;
sendService.get$("1").subscribe(() => {
@@ -330,23 +381,29 @@ describe("SendService", () => {
//it is immediately called when subscribed, we need to reset the value
changed = false;
await sendService.replace({
"1": sendDataObject,
"2": testSendData("2", "Test Send 2"),
});
await sendService.replace(
{
"1": sendDataObject,
"2": testSendData("2", "Test Send 2"),
},
mockUserId,
);
expect(changed).toEqual(false);
sendDataObject.text.text = "Asdf";
await sendService.replace({
"1": sendDataObject,
"2": testSendData("2", "Test Send 2"),
});
await sendService.replace(
{
"1": sendDataObject,
"2": testSendData("2", "Test Send 2"),
},
mockUserId,
);
expect(changed).toEqual(true);
});
it("do not reports a change when nothing changes on the observed send", async () => {
it("do not report a change when nothing changes on the observed send", async () => {
let changed = false;
sendService.get$("1").subscribe(() => {
changed = true;
@@ -357,10 +414,13 @@ describe("SendService", () => {
//it is immediately called when subscribed, we need to reset the value
changed = false;
await sendService.replace({
"1": sendDataObject,
"2": testSendData("3", "Test Send 3"),
});
await sendService.replace(
{
"1": sendDataObject,
"2": testSendData("3", "Test Send 3"),
},
mockUserId,
);
expect(changed).toEqual(false);
});
@@ -373,9 +433,12 @@ describe("SendService", () => {
//it is immediately called when subscribed, we need to reset the value
changed = false;
await sendService.replace({
"2": testSendData("2", "Test Send 2"),
});
await sendService.replace(
{
"2": testSendData("2", "Test Send 2"),
},
mockUserId,
);
expect(changed).toEqual(true);
});
@@ -426,7 +489,7 @@ describe("SendService", () => {
});
it("returns empty array if there are no sends", async () => {
await sendService.replace(null);
await sendService.replace(null, mockUserId);
await awaitAsync();
@@ -461,16 +524,11 @@ describe("SendService", () => {
});
it("replace", async () => {
await sendService.replace({ "2": testSendData("2", "test 2") });
await sendService.replace({ "2": testSendData("2", "test 2") }, mockUserId);
expect(await firstValueFrom(sendService.sends$)).toEqual([testSend("2", "test 2")]);
});
it("clear", async () => {
await sendService.clear();
await awaitAsync();
expect(await firstValueFrom(sendService.sends$)).toEqual([]);
});
describe("Delete", () => {
it("Sends count should decrease after delete", async () => {
const sendsBeforeDelete = await firstValueFrom(sendService.sends$);
@@ -488,7 +546,7 @@ describe("SendService", () => {
});
it("Deleting on an empty sends array should not throw", async () => {
sendStateProvider.getEncryptedSends = jest.fn().mockResolvedValue(null);
stateProvider.activeUser.getFake(SEND_USER_ENCRYPTED).nextState(null);
await expect(sendService.delete("2")).resolves.not.toThrow();
});

View File

@@ -28,10 +28,10 @@ export class SendService implements InternalSendServiceAbstraction {
readonly sendKeyPurpose = "send";
sends$ = this.stateProvider.encryptedState$.pipe(
map((record) => Object.values(record || {}).map((data) => new Send(data))),
map(([, record]) => Object.values(record || {}).map((data) => new Send(data))),
);
sendViews$ = this.stateProvider.encryptedState$.pipe(
concatMap((record) =>
concatMap(([, record]) =>
this.decryptSends(Object.values(record || {}).map((data) => new Send(data))),
),
);
@@ -167,7 +167,7 @@ export class SendService implements InternalSendServiceAbstraction {
}
async getFromState(id: string): Promise<Send> {
const sends = await this.stateProvider.getEncryptedSends();
const [, sends] = await this.stateProvider.getEncryptedSends();
// eslint-disable-next-line
if (sends == null || !sends.hasOwnProperty(id)) {
return null;
@@ -177,7 +177,7 @@ export class SendService implements InternalSendServiceAbstraction {
}
async getAll(): Promise<Send[]> {
const sends = await this.stateProvider.getEncryptedSends();
const [, sends] = await this.stateProvider.getEncryptedSends();
const response: Send[] = [];
for (const id in sends) {
// eslint-disable-next-line
@@ -214,7 +214,8 @@ export class SendService implements InternalSendServiceAbstraction {
}
async upsert(send: SendData | SendData[]): Promise<any> {
let sends = await this.stateProvider.getEncryptedSends();
const [userId, currentSends] = await this.stateProvider.getEncryptedSends();
let sends = currentSends;
if (sends == null) {
sends = {};
}
@@ -227,16 +228,11 @@ export class SendService implements InternalSendServiceAbstraction {
});
}
await this.replace(sends);
}
async clear(userId?: string): Promise<any> {
await this.stateProvider.setDecryptedSends(null);
await this.stateProvider.setEncryptedSends(null);
await this.replace(sends, userId);
}
async delete(id: string | string[]): Promise<any> {
const sends = await this.stateProvider.getEncryptedSends();
const [userId, sends] = await this.stateProvider.getEncryptedSends();
if (sends == null) {
return;
}
@@ -252,11 +248,11 @@ export class SendService implements InternalSendServiceAbstraction {
});
}
await this.replace(sends);
await this.replace(sends, userId);
}
async replace(sends: { [id: string]: SendData }): Promise<any> {
await this.stateProvider.setEncryptedSends(sends);
async replace(sends: { [id: string]: SendData }, userId: UserId): Promise<any> {
await this.stateProvider.setEncryptedSends(sends, userId);
}
async getRotatedData(

View File

@@ -133,7 +133,7 @@ export abstract class CipherService implements UserKeyRotationDataProvider<Ciphe
* @returns A promise that resolves to a record of updated cipher store, keyed by their cipher ID. Returns all ciphers, not just those updated
*/
upsert: (cipher: CipherData | CipherData[]) => Promise<Record<CipherId, CipherData>>;
replace: (ciphers: { [id: string]: CipherData }) => Promise<any>;
replace: (ciphers: { [id: string]: CipherData }, userId: UserId) => Promise<any>;
clear: (userId?: string) => Promise<void>;
moveManyWithServer: (ids: string[], folderId: string) => Promise<any>;
delete: (id: string | string[]) => Promise<any>;

View File

@@ -1,6 +1,6 @@
import { Observable } from "rxjs";
import { CollectionId } from "../../types/guid";
import { CollectionId, UserId } from "../../types/guid";
import { CollectionData } from "../models/data/collection.data";
import { Collection } from "../models/domain/collection";
import { TreeNode } from "../models/domain/tree-node";
@@ -22,7 +22,7 @@ export abstract class CollectionService {
getAllNested: (collections?: CollectionView[]) => Promise<TreeNode<CollectionView>[]>;
getNested: (id: string) => Promise<TreeNode<CollectionView>>;
upsert: (collection: CollectionData | CollectionData[]) => Promise<any>;
replace: (collections: { [id: string]: CollectionData }) => Promise<any>;
replace: (collections: { [id: string]: CollectionData }, userId: UserId) => Promise<any>;
clear: (userId?: string) => Promise<void>;
delete: (id: string | string[]) => Promise<any>;
}

View File

@@ -45,7 +45,7 @@ export abstract class FolderService implements UserKeyRotationDataProvider<Folde
export abstract class InternalFolderService extends FolderService {
upsert: (folder: FolderData | FolderData[]) => Promise<void>;
replace: (folders: { [id: string]: FolderData }) => Promise<void>;
replace: (folders: { [id: string]: FolderData }, userId: UserId) => Promise<void>;
clear: (userId?: string) => Promise<void>;
delete: (id: string | string[]) => Promise<any>;
}

View File

@@ -913,8 +913,8 @@ export class CipherService implements CipherServiceAbstraction {
});
}
async replace(ciphers: { [id: string]: CipherData }): Promise<any> {
await this.updateEncryptedCipherState(() => ciphers);
async replace(ciphers: { [id: string]: CipherData }, userId: UserId): Promise<any> {
await this.updateEncryptedCipherState(() => ciphers, userId);
}
/**
@@ -924,15 +924,18 @@ export class CipherService implements CipherServiceAbstraction {
*/
private async updateEncryptedCipherState(
update: (current: Record<CipherId, CipherData>) => Record<CipherId, CipherData>,
userId: UserId = null,
): Promise<Record<CipherId, CipherData>> {
const userId = await firstValueFrom(this.stateProvider.activeUserId$);
userId ||= await firstValueFrom(this.stateProvider.activeUserId$);
// Store that we should wait for an update to return any ciphers
await this.ciphersExpectingUpdate.forceValue(true);
await this.clearDecryptedCiphersState(userId);
const [, updatedCiphers] = await this.encryptedCiphersState.update((current) => {
const result = update(current ?? {});
return result;
});
const updatedCiphers = await this.stateProvider
.getUser(userId, ENCRYPTED_CIPHERS)
.update((current) => {
const result = update(current ?? {});
return result;
});
return updatedCiphers;
}

View File

@@ -184,8 +184,10 @@ export class CollectionService implements CollectionServiceAbstraction {
});
}
async replace(collections: Record<CollectionId, CollectionData>): Promise<void> {
await this.encryptedCollectionDataState.update(() => collections);
async replace(collections: Record<CollectionId, CollectionData>, userId: UserId): Promise<void> {
await this.stateProvider
.getUser(userId, ENCRYPTED_COLLECTION_DATA_KEY)
.update(() => collections);
}
async clear(userId?: UserId): Promise<void> {

View File

@@ -120,7 +120,7 @@ describe("Folder Service", () => {
});
it("replace", async () => {
await folderService.replace({ "2": folderData("2", "test 2") });
await folderService.replace({ "2": folderData("2", "test 2") }, mockUserId);
expect(await firstValueFrom(folderService.folders$)).toEqual([
{

View File

@@ -111,12 +111,12 @@ export class FolderService implements InternalFolderServiceAbstraction {
});
}
async replace(folders: { [id: string]: FolderData }): Promise<void> {
async replace(folders: { [id: string]: FolderData }, userId: UserId): Promise<void> {
if (!folders) {
return;
}
await this.encryptedFoldersState.update(() => {
await this.stateProvider.getUser(userId, FOLDER_ENCRYPTED_FOLDERS).update(() => {
const newFolders: Record<string, FolderData> = { ...folders };
return newFolders;
});

View File

@@ -5,7 +5,7 @@ import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.servic
import { SharedModule } from "../shared";
import { TypographyModule } from "../typography";
type CalloutTypes = "success" | "info" | "warning" | "danger";
export type CalloutTypes = "success" | "info" | "warning" | "danger";
const defaultIcon: Record<CalloutTypes, string> = {
success: "bwi-check",

View File

@@ -23,6 +23,7 @@ export type OptionalInitialValues = {
collectionIds?: CollectionId[];
loginUri?: string;
username?: string;
password?: string;
name?: string;
};
@@ -58,7 +59,8 @@ type BaseCipherFormConfig = {
originalCipher?: Cipher;
/**
* Optional initial values for the form when creating a new cipher. Useful when creating a cipher in a filtered view.
* Optional initial values for the form when opening the cipher form.
* Useful when creating a new cipher in a filtered view or modifying a cipher with values from another source (e.g. the notification bar in Browser)
*/
initialValues?: OptionalInitialValues;

View File

@@ -128,6 +128,47 @@ describe("AutofillOptionsComponent", () => {
expect(component.autofillOptionsForm.value.autofillOnPageLoad).toEqual(null);
});
it("initializes 'autoFillOptionsForm' with initialValues when editing an existing cipher", () => {
cipherFormContainer.config.initialValues = { loginUri: "https://new-website.com" };
const existingLogin = new LoginUriView();
existingLogin.uri = "https://example.com";
existingLogin.match = UriMatchStrategy.Exact;
(cipherFormContainer.originalCipherView as CipherView) = new CipherView();
cipherFormContainer.originalCipherView.login = {
autofillOnPageLoad: true,
uris: [existingLogin],
} as LoginView;
fixture.detectChanges();
expect(component.autofillOptionsForm.value.uris).toEqual([
{ uri: "https://example.com", matchDetection: UriMatchStrategy.Exact },
{ uri: "https://new-website.com", matchDetection: null },
]);
expect(component.autofillOptionsForm.value.autofillOnPageLoad).toEqual(true);
});
it("initializes 'autoFillOptionsForm' with initialValues without duplicating an existing URI", () => {
cipherFormContainer.config.initialValues = { loginUri: "https://example.com" };
const existingLogin = new LoginUriView();
existingLogin.uri = "https://example.com";
existingLogin.match = UriMatchStrategy.Exact;
(cipherFormContainer.originalCipherView as CipherView) = new CipherView();
cipherFormContainer.originalCipherView.login = {
autofillOnPageLoad: true,
uris: [existingLogin],
} as LoginView;
fixture.detectChanges();
expect(component.autofillOptionsForm.value.uris).toEqual([
{ uri: "https://example.com", matchDetection: UriMatchStrategy.Exact },
]);
expect(component.autofillOptionsForm.value.autofillOnPageLoad).toEqual(true);
});
it("initializes 'autoFillOptionsForm' with an empty URI when creating a new cipher", () => {
cipherFormContainer.config.initialValues = null;

View File

@@ -143,6 +143,20 @@ export class AutofillOptionsComponent implements OnInit {
this.autofillOptionsForm.patchValue({
autofillOnPageLoad: existingLogin.autofillOnPageLoad,
});
if (this.cipherFormContainer.config.initialValues?.loginUri) {
// Avoid adding the same uri again if it already exists
if (
existingLogin.uris?.findIndex(
(uri) => uri.uri === this.cipherFormContainer.config.initialValues.loginUri,
) === -1
) {
this.addUri({
uri: this.cipherFormContainer.config.initialValues.loginUri,
matchDetection: null,
});
}
}
}
private initNewCipher() {

View File

@@ -126,15 +126,22 @@ describe("CipherFormGeneratorComponent", () => {
expect(fixture.nativeElement.querySelector("bit-toggle-group")).toBeTruthy();
});
it("should save password options when the password type is updated", async () => {
mockLegacyPasswordGenerationService.generatePassword.mockResolvedValue("generated-password");
it("should update the generated value when the password type is updated", fakeAsync(async () => {
mockLegacyPasswordGenerationService.generatePassword
.mockResolvedValueOnce("first-password")
.mockResolvedValueOnce("second-password");
component.ngOnChanges();
tick();
expect(component["generatedValue"]).toBe("first-password");
await component["updatePasswordType"]("passphrase");
tick();
expect(mockLegacyPasswordGenerationService.saveOptions).toHaveBeenCalledWith({
type: "passphrase",
});
});
expect(component["generatedValue"]).toBe("second-password");
expect(mockLegacyPasswordGenerationService.generatePassword).toHaveBeenCalledTimes(2);
}));
it("should update the password history when a new password is generated", fakeAsync(() => {
mockLegacyPasswordGenerationService.generatePassword.mockResolvedValue("new-password");

View File

@@ -1,7 +1,18 @@
import { CommonModule } from "@angular/common";
import { Component, DestroyRef, EventEmitter, Input, OnChanges, Output } from "@angular/core";
import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
import { firstValueFrom, map, startWith, Subject, Subscription, switchMap, tap } from "rxjs";
import {
combineLatest,
map,
merge,
shareReplay,
startWith,
Subject,
Subscription,
switchMap,
take,
tap,
} from "rxjs";
import { JslibModule } from "@bitwarden/angular/jslib.module";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
@@ -61,18 +72,13 @@ export class CipherFormGeneratorComponent implements OnChanges {
protected regenerateButtonTitle: string;
protected regenerate$ = new Subject<void>();
protected passwordTypeSubject$ = new Subject<GeneratorType>();
/**
* The currently generated value displayed to the user.
* @protected
*/
protected generatedValue: string = "";
/**
* The current password generation options.
* @private
*/
private passwordOptions$ = this.legacyPasswordGenerationService.getOptions$();
/**
* The current username generation options.
* @private
@@ -80,10 +86,30 @@ export class CipherFormGeneratorComponent implements OnChanges {
private usernameOptions$ = this.legacyUsernameGenerationService.getOptions$();
/**
* The current password type specified by the password generation options.
* The current password type selected in the UI. Starts with the saved value from the service.
* @protected
*/
protected passwordType$ = this.passwordOptions$.pipe(map(([options]) => options.type));
protected passwordType$ = merge(
this.legacyPasswordGenerationService.getOptions$().pipe(
take(1),
map(([options]) => options.type),
),
this.passwordTypeSubject$,
).pipe(shareReplay({ bufferSize: 1, refCount: false }));
/**
* The current password generation options.
* @private
*/
private passwordOptions$ = combineLatest([
this.legacyPasswordGenerationService.getOptions$(),
this.passwordType$,
]).pipe(
map(([[options], type]) => {
options.type = type;
return options;
}),
);
/**
* Tracks the regenerate$ subscription
@@ -121,7 +147,7 @@ export class CipherFormGeneratorComponent implements OnChanges {
.pipe(
startWith(null),
switchMap(() => this.passwordOptions$),
switchMap(([options]) => this.legacyPasswordGenerationService.generatePassword(options)),
switchMap((options) => this.legacyPasswordGenerationService.generatePassword(options)),
tap(async (password) => {
await this.legacyPasswordGenerationService.addHistory(password);
}),
@@ -148,12 +174,10 @@ export class CipherFormGeneratorComponent implements OnChanges {
}
/**
* Switch the password generation type and save the options (generating a new password automatically).
* Switch the password generation type.
* @param value The new password generation type.
*/
protected updatePasswordType = async (value: GeneratorType) => {
const [currentOptions] = await firstValueFrom(this.passwordOptions$);
currentOptions.type = value;
await this.legacyPasswordGenerationService.saveOptions(currentOptions);
this.passwordTypeSubject$.next(value);
};
}

View File

@@ -127,7 +127,7 @@ export class IdentitySectionComponent implements OnInit {
firstName: identity.firstName,
middleName: identity.middleName,
lastName: identity.lastName,
username: identity.username,
username: this.cipherFormContainer.config.initialValues?.username ?? identity.username,
company: identity.company,
ssn: identity.ssn,
passportNumber: identity.passportNumber,

View File

@@ -5,6 +5,7 @@ import { mock, MockProxy } from "jest-mock-extended";
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { CollectionId, OrganizationId } from "@bitwarden/common/types/guid";
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
import { CollectionView } from "@bitwarden/common/vault/models/view/collection.view";
@@ -104,6 +105,43 @@ describe("ItemDetailsSectionComponent", () => {
expect(updatedCipher.favorite).toBe(true);
}));
it("should prioritize initialValues when editing an existing cipher ", fakeAsync(async () => {
component.config.allowPersonalOwnership = true;
component.config.organizations = [{ id: "org1" } as Organization];
component.config.collections = [
{ id: "col1", name: "Collection 1", organizationId: "org1" } as CollectionView,
{ id: "col2", name: "Collection 2", organizationId: "org1" } as CollectionView,
];
component.originalCipherView = {
name: "cipher1",
organizationId: "org1",
folderId: "folder1",
collectionIds: ["col1"],
favorite: true,
} as CipherView;
component.config.initialValues = {
name: "new-name",
folderId: "new-folder",
organizationId: "bad-org" as OrganizationId, // Should not be set in edit mode
collectionIds: ["col2" as CollectionId],
};
await component.ngOnInit();
tick();
expect(cipherFormProvider.patchCipher).toHaveBeenCalled();
const patchFn = cipherFormProvider.patchCipher.mock.lastCall[0];
const updatedCipher = patchFn(new CipherView());
expect(updatedCipher.name).toBe("new-name");
expect(updatedCipher.organizationId).toBe("org1");
expect(updatedCipher.folderId).toBe("new-folder");
expect(updatedCipher.collectionIds).toEqual(["col2"]);
expect(updatedCipher.favorite).toBe(true);
}));
it("should disable organizationId control if ownership change is not allowed", async () => {
component.config.allowPersonalOwnership = false;
component.config.organizations = [{ id: "org1" } as Organization];

View File

@@ -190,9 +190,9 @@ export class ItemDetailsSectionComponent implements OnInit {
private async initFromExistingCipher() {
this.itemDetailsForm.setValue({
name: this.originalCipherView.name,
organizationId: this.originalCipherView.organizationId,
folderId: this.originalCipherView.folderId,
name: this.initialValues?.name ?? this.originalCipherView.name,
organizationId: this.originalCipherView.organizationId, // We do not allow changing ownership of an existing cipher.
folderId: this.initialValues?.folderId ?? this.originalCipherView.folderId,
collectionIds: [],
favorite: this.originalCipherView.favorite,
});
@@ -208,7 +208,10 @@ export class ItemDetailsSectionComponent implements OnInit {
}
}
await this.updateCollectionOptions(this.originalCipherView.collectionIds as CollectionId[]);
await this.updateCollectionOptions(
this.initialValues?.collectionIds ??
(this.originalCipherView.collectionIds as CollectionId[]),
);
if (this.partialEdit) {
this.itemDetailsForm.disable();

View File

@@ -125,6 +125,29 @@ describe("LoginDetailsSectionComponent", () => {
});
});
it("initializes 'loginDetailsForm' with initialValues that override any original cipher view values", async () => {
(cipherFormContainer.originalCipherView as CipherView) = {
viewPassword: true,
login: {
password: "original-password",
username: "original-username",
totp: "original-totp",
} as LoginView,
} as CipherView;
cipherFormContainer.config.initialValues = {
username: "new-username",
password: "new-password",
};
await component.ngOnInit();
expect(component.loginDetailsForm.value).toEqual({
username: "new-username",
password: "new-password",
totp: "original-totp",
});
});
describe("viewHiddenFields", () => {
beforeEach(() => {
(cipherFormContainer.originalCipherView as CipherView) = {

View File

@@ -95,6 +95,10 @@ export class LoginDetailsSectionComponent implements OnInit {
return true;
}
get initialValues() {
return this.cipherFormContainer.config.initialValues;
}
constructor(
private cipherFormContainer: CipherFormContainer,
private formBuilder: FormBuilder,
@@ -139,8 +143,8 @@ export class LoginDetailsSectionComponent implements OnInit {
private initFromExistingCipher(existingLogin: LoginView) {
this.loginDetailsForm.patchValue({
username: existingLogin.username,
password: existingLogin.password,
username: this.initialValues?.username ?? existingLogin.username,
password: this.initialValues?.password ?? existingLogin.password,
totp: existingLogin.totp,
});
@@ -154,8 +158,8 @@ export class LoginDetailsSectionComponent implements OnInit {
private async initNewCipher() {
this.loginDetailsForm.patchValue({
username: this.cipherFormContainer.config.initialValues?.username || "",
password: "",
username: this.initialValues?.username || "",
password: this.initialValues?.password || "",
});
}

View File

@@ -37,6 +37,7 @@
data-testid="login-password"
/>
<button
*ngIf="cipher.viewPassword"
bitSuffix
type="button"
bitIconButton
@@ -55,6 +56,7 @@
(click)="togglePasswordCount()"
></button>
<button
*ngIf="cipher.viewPassword"
bitIconButton="bwi-clone"
bitSuffix
type="button"
@@ -92,7 +94,7 @@
*ngIf="!(isPremium$ | async)"
bitBadge
variant="success"
class="tw-ml-2"
class="tw-ml-2 tw-cursor-pointer"
(click)="getPremium()"
slot="end"
>
@@ -115,6 +117,7 @@
bitSuffix
type="button"
(sendCopyCode)="setTotpCopyCode($event)"
class="tw-cursor-default"
></button>
<button
bitIconButton="bwi-clone"
@@ -126,6 +129,7 @@
[appA11yTitle]="'copyValue' | i18n"
data-testid="copy-totp"
[disabled]="!(isPremium$ | async)"
class="disabled:tw-cursor-default"
></button>
</bit-form-field>
</bit-card>