1
0
mirror of https://github.com/bitwarden/browser synced 2025-12-19 01:33:33 +00:00

Merge branch 'master' into ac/ac-1139/deprecate-custom-collection-perm

This commit is contained in:
Rui Tome
2023-11-20 11:52:38 +00:00
148 changed files with 3795 additions and 1329 deletions

View File

@@ -1,3 +0,0 @@
export async function awaitAsync(ms = 0) {
await new Promise((resolve) => setTimeout(resolve, ms));
}

View File

@@ -10,6 +10,7 @@ import { StorageOptions } from "../src/platform/models/domain/storage-options";
export class FakeStorageService implements AbstractStorageService {
private store: Record<string, unknown>;
private updatesSubject = new Subject<StorageUpdate>();
private _valuesRequireDeserialization = false;
/**
* Returns a mock of a {@see AbstractStorageService} for asserting the expected
@@ -32,6 +33,18 @@ export class FakeStorageService implements AbstractStorageService {
this.store = store;
}
get internalStore() {
return this.store;
}
internalUpdateValuesRequireDeserialization(value: boolean) {
this._valuesRequireDeserialization = value;
}
get valuesRequireDeserialization(): boolean {
return this._valuesRequireDeserialization;
}
get updates$() {
return this.updatesSubject.asObservable();
}
@@ -48,13 +61,13 @@ export class FakeStorageService implements AbstractStorageService {
save<T>(key: string, obj: T, options?: StorageOptions): Promise<void> {
this.mock.save(key, options);
this.store[key] = obj;
this.updatesSubject.next({ key: key, value: obj, updateType: "save" });
this.updatesSubject.next({ key: key, updateType: "save" });
return Promise.resolve();
}
remove(key: string, options?: StorageOptions): Promise<void> {
this.mock.remove(key, options);
delete this.store[key];
this.updatesSubject.next({ key: key, value: undefined, updateType: "remove" });
this.updatesSubject.next({ key: key, updateType: "remove" });
return Promise.resolve();
}
}

View File

@@ -84,3 +84,11 @@ function clone(value: any): any {
return JSON.parse(JSON.stringify(value));
}
}
export async function awaitAsync(ms = 0) {
if (ms < 1) {
await Promise.resolve();
} else {
await new Promise((resolve) => setTimeout(resolve, ms));
}
}

View File

@@ -420,8 +420,8 @@ export abstract class StateService<T extends Account = Account> {
setMainWindowSize: (value: number, options?: StorageOptions) => Promise<void>;
getMinimizeOnCopyToClipboard: (options?: StorageOptions) => Promise<boolean>;
setMinimizeOnCopyToClipboard: (value: boolean, options?: StorageOptions) => Promise<void>;
getNeverDomains: (options?: StorageOptions) => Promise<{ [id: string]: any }>;
setNeverDomains: (value: { [id: string]: any }, options?: StorageOptions) => Promise<void>;
getNeverDomains: (options?: StorageOptions) => Promise<{ [id: string]: unknown }>;
setNeverDomains: (value: { [id: string]: unknown }, options?: StorageOptions) => Promise<void>;
getNoAutoPromptBiometricsText: (options?: StorageOptions) => Promise<string>;
setNoAutoPromptBiometricsText: (value: string, options?: StorageOptions) => Promise<void>;
getOpenAtLogin: (options?: StorageOptions) => Promise<boolean>;

View File

@@ -5,11 +5,11 @@ import { MemoryStorageOptions, StorageOptions } from "../models/domain/storage-o
export type StorageUpdateType = "save" | "remove";
export type StorageUpdate = {
key: string;
value?: unknown;
updateType: StorageUpdateType;
};
export abstract class AbstractStorageService {
abstract get valuesRequireDeserialization(): boolean;
/**
* Provides an {@link Observable} that represents a stream of updates that
* have happened in this storage service or in the storage this service provides

View File

@@ -222,12 +222,9 @@ export class AccountSettings {
clearClipboard?: number;
collapsedGroupings?: string[];
defaultUriMatch?: UriMatchType;
disableAddLoginNotification?: boolean;
disableAutoBiometricsPrompt?: boolean;
disableAutoTotpCopy?: boolean;
disableBadgeCounter?: boolean;
disableChangedPasswordNotification?: boolean;
disableContextMenuItem?: boolean;
disableGa?: boolean;
dismissedAutoFillOnPageLoadCallout?: boolean;
dontShowCardsCurrentTab?: boolean;
@@ -239,7 +236,6 @@ export class AccountSettings {
environmentUrls: EnvironmentUrls = new EnvironmentUrls();
equivalentDomains?: any;
minimizeOnCopyToClipboard?: boolean;
neverDomains?: { [id: string]: any };
passwordGenerationOptions?: PasswordGeneratorOptions;
usernameGenerationOptions?: UsernameGeneratorOptions;
generatorOptions?: GeneratorOptions;

View File

@@ -36,4 +36,8 @@ export class GlobalState {
enableBrowserIntegrationFingerprint?: boolean;
enableDuckDuckGoBrowserIntegration?: boolean;
region?: string;
neverDomains?: { [id: string]: unknown };
disableAddLoginNotification?: boolean;
disableChangedPasswordNotification?: boolean;
disableContextMenuItem?: boolean;
}

View File

@@ -6,6 +6,9 @@ export class MemoryStorageService extends AbstractMemoryStorageService {
private store = new Map<string, unknown>();
private updatesSubject = new Subject<StorageUpdate>();
get valuesRequireDeserialization(): boolean {
return false;
}
get updates$() {
return this.updatesSubject.asObservable();
}
@@ -27,13 +30,13 @@ export class MemoryStorageService extends AbstractMemoryStorageService {
return this.remove(key);
}
this.store.set(key, obj);
this.updatesSubject.next({ key, value: obj, updateType: "save" });
this.updatesSubject.next({ key, updateType: "save" });
return Promise.resolve();
}
remove(key: string): Promise<void> {
this.store.delete(key);
this.updatesSubject.next({ key, value: null, updateType: "remove" });
this.updatesSubject.next({ key, updateType: "remove" });
return Promise.resolve();
}

View File

@@ -1121,18 +1121,18 @@ export class StateService<
async getDisableAddLoginNotification(options?: StorageOptions): Promise<boolean> {
return (
(await this.getAccount(this.reconcileOptions(options, await this.defaultOnDiskOptions())))
?.settings?.disableAddLoginNotification ?? false
(await this.getGlobals(this.reconcileOptions(options, await this.defaultOnDiskOptions())))
?.disableAddLoginNotification ?? false
);
}
async setDisableAddLoginNotification(value: boolean, options?: StorageOptions): Promise<void> {
const account = await this.getAccount(
const globals = await this.getGlobals(
this.reconcileOptions(options, await this.defaultOnDiskOptions())
);
account.settings.disableAddLoginNotification = value;
await this.saveAccount(
account,
globals.disableAddLoginNotification = value;
await this.saveGlobals(
globals,
this.reconcileOptions(options, await this.defaultOnDiskOptions())
);
}
@@ -1193,8 +1193,8 @@ export class StateService<
async getDisableChangedPasswordNotification(options?: StorageOptions): Promise<boolean> {
return (
(await this.getAccount(this.reconcileOptions(options, await this.defaultOnDiskOptions())))
?.settings?.disableChangedPasswordNotification ?? false
(await this.getGlobals(this.reconcileOptions(options, await this.defaultOnDiskOptions())))
?.disableChangedPasswordNotification ?? false
);
}
@@ -1202,30 +1202,30 @@ export class StateService<
value: boolean,
options?: StorageOptions
): Promise<void> {
const account = await this.getAccount(
const globals = await this.getGlobals(
this.reconcileOptions(options, await this.defaultOnDiskOptions())
);
account.settings.disableChangedPasswordNotification = value;
await this.saveAccount(
account,
globals.disableChangedPasswordNotification = value;
await this.saveGlobals(
globals,
this.reconcileOptions(options, await this.defaultOnDiskOptions())
);
}
async getDisableContextMenuItem(options?: StorageOptions): Promise<boolean> {
return (
(await this.getAccount(this.reconcileOptions(options, await this.defaultOnDiskOptions())))
?.settings?.disableContextMenuItem ?? false
(await this.getGlobals(this.reconcileOptions(options, await this.defaultOnDiskOptions())))
?.disableContextMenuItem ?? false
);
}
async setDisableContextMenuItem(value: boolean, options?: StorageOptions): Promise<void> {
const account = await this.getAccount(
const globals = await this.getGlobals(
this.reconcileOptions(options, await this.defaultOnDiskOptions())
);
account.settings.disableContextMenuItem = value;
await this.saveAccount(
account,
globals.disableContextMenuItem = value;
await this.saveGlobals(
globals,
this.reconcileOptions(options, await this.defaultOnDiskOptions())
);
}
@@ -2295,19 +2295,19 @@ export class StateService<
);
}
async getNeverDomains(options?: StorageOptions): Promise<{ [id: string]: any }> {
async getNeverDomains(options?: StorageOptions): Promise<{ [id: string]: unknown }> {
return (
await this.getAccount(this.reconcileOptions(options, await this.defaultOnDiskOptions()))
)?.settings?.neverDomains;
await this.getGlobals(this.reconcileOptions(options, await this.defaultOnDiskOptions()))
)?.neverDomains;
}
async setNeverDomains(value: { [id: string]: any }, options?: StorageOptions): Promise<void> {
const account = await this.getAccount(
async setNeverDomains(value: { [id: string]: unknown }, options?: StorageOptions): Promise<void> {
const globals = await this.getGlobals(
this.reconcileOptions(options, await this.defaultOnDiskOptions())
);
account.settings.neverDomains = value;
await this.saveAccount(
account,
globals.neverDomains = value;
await this.saveGlobals(
globals,
this.reconcileOptions(options, await this.defaultOnDiskOptions())
);
}

View File

@@ -1,5 +1,7 @@
import { Observable } from "rxjs";
import { StateUpdateOptions } from "./state-update-options";
/**
* A helper object for interacting with state that is scoped to a specific domain
* but is not scoped to a user. This is application wide storage.
@@ -8,9 +10,16 @@ export interface GlobalState<T> {
/**
* Method for allowing you to manipulate state in an additive way.
* @param configureState callback for how you want manipulate this section of state
* @param options Defaults given by @see {module:state-update-options#DEFAULT_OPTIONS}
* @param options.shouldUpdate A callback for determining if you want to update state. Defaults to () => true
* @param options.combineLatestWith An observable that you want to combine with the current state for callbacks. Defaults to null
* @param options.msTimeout A timeout for how long you are willing to wait for a `combineLatestWith` option to complete. Defaults to 1000ms. Only applies if `combineLatestWith` is set.
* @returns A promise that must be awaited before your next action to ensure the update has been written to state.
*/
update: (configureState: (state: T) => T) => Promise<T>;
update: <TCombine>(
configureState: (state: T, dependency: TCombine) => T,
options?: StateUpdateOptions<T, TCombine>
) => Promise<T>;
/**
* An observable stream of this state, the first emission of this will be the current state on disk

View File

@@ -3,9 +3,10 @@
* @jest-environment ../shared/test.environment.ts
*/
import { firstValueFrom, of } from "rxjs";
import { Jsonify } from "type-fest";
import { trackEmissions } from "../../../../spec";
import { trackEmissions, awaitAsync } from "../../../../spec";
import { FakeStorageService } from "../../../../spec/fake-storage.service";
import { KeyDefinition, globalKeyBuilder } from "../key-definition";
import { StateDefinition } from "../state-definition";
@@ -28,16 +29,15 @@ class TestState {
const testStateDefinition = new StateDefinition("fake", "disk");
const testKeyDefinition = new KeyDefinition<TestState>(
testStateDefinition,
"fake",
TestState.fromJSON
);
const testKeyDefinition = new KeyDefinition<TestState>(testStateDefinition, "fake", {
deserializer: TestState.fromJSON,
});
const globalKey = globalKeyBuilder(testKeyDefinition);
describe("DefaultGlobalState", () => {
let diskStorageService: FakeStorageService;
let globalState: DefaultGlobalState<TestState>;
const newData = { date: new Date() };
beforeEach(() => {
diskStorageService = new FakeStorageService();
@@ -48,51 +48,154 @@ describe("DefaultGlobalState", () => {
jest.resetAllMocks();
});
it("should emit when storage updates", async () => {
const emissions = trackEmissions(globalState.state$);
const newData = { date: new Date() };
await diskStorageService.save(globalKey, newData);
describe("state$", () => {
it("should emit when storage updates", async () => {
const emissions = trackEmissions(globalState.state$);
await diskStorageService.save(globalKey, newData);
await awaitAsync();
expect(emissions).toEqual([
null, // Initial value
newData,
// JSON.parse(JSON.stringify(newData)), // This is due to the way `trackEmissions` clones
]);
});
it("should not emit when update key does not match", async () => {
const emissions = trackEmissions(globalState.state$);
const newData = { date: new Date() };
await diskStorageService.save("wrong_key", newData);
expect(emissions).toEqual(
expect.arrayContaining([
expect(emissions).toEqual([
null, // Initial value
])
);
});
it("should save on update", async () => {
const newData = { date: new Date() };
const result = await globalState.update((state) => {
return newData;
newData,
]);
});
expect(diskStorageService.mock.save).toHaveBeenCalledTimes(1);
expect(result).toEqual(newData);
});
it("should not emit when update key does not match", async () => {
const emissions = trackEmissions(globalState.state$);
await diskStorageService.save("wrong_key", newData);
it("should emit once per update", async () => {
const emissions = trackEmissions(globalState.state$);
const newData = { date: new Date() };
await globalState.update((state) => {
return newData;
expect(emissions).toHaveLength(0);
});
expect(emissions).toEqual([
null, // Initial value
newData,
]);
it("should emit initial storage value on first subscribe", async () => {
const initialStorage: Record<string, TestState> = {};
initialStorage[globalKey] = TestState.fromJSON({
date: "2022-09-21T13:14:17.648Z",
});
diskStorageService.internalUpdateStore(initialStorage);
const state = await firstValueFrom(globalState.state$);
expect(diskStorageService.mock.get).toHaveBeenCalledTimes(1);
expect(diskStorageService.mock.get).toHaveBeenCalledWith("global_fake_fake", undefined);
expect(state).toBeTruthy();
});
});
describe("update", () => {
it("should save on update", async () => {
const result = await globalState.update((state) => {
return newData;
});
expect(diskStorageService.mock.save).toHaveBeenCalledTimes(1);
expect(result).toEqual(newData);
});
it("should emit once per update", async () => {
const emissions = trackEmissions(globalState.state$);
await awaitAsync(); // storage updates are behind a promise
await globalState.update((state) => {
return newData;
});
await awaitAsync();
expect(emissions).toEqual([
null, // Initial value
newData,
]);
});
it("should provided combined dependencies", async () => {
const emissions = trackEmissions(globalState.state$);
await awaitAsync(); // storage updates are behind a promise
const combinedDependencies = { date: new Date() };
await globalState.update(
(state, dependencies) => {
expect(dependencies).toEqual(combinedDependencies);
return newData;
},
{
combineLatestWith: of(combinedDependencies),
}
);
await awaitAsync();
expect(emissions).toEqual([
null, // Initial value
newData,
]);
});
it("should not update if shouldUpdate returns false", async () => {
const emissions = trackEmissions(globalState.state$);
const result = await globalState.update(
(state) => {
return newData;
},
{
shouldUpdate: () => false,
}
);
expect(diskStorageService.mock.save).not.toHaveBeenCalled();
expect(emissions).toEqual([null]); // Initial value
expect(result).toBeUndefined();
});
it("should provide the update callback with the current State", async () => {
const emissions = trackEmissions(globalState.state$);
await awaitAsync(); // storage updates are behind a promise
// Seed with interesting data
const initialData = { date: new Date(2020, 1, 1) };
await globalState.update((state, dependencies) => {
return initialData;
});
await awaitAsync();
await globalState.update((state) => {
expect(state).toEqual(initialData);
return newData;
});
await awaitAsync();
expect(emissions).toEqual([
null, // Initial value
initialData,
newData,
]);
});
it("should give initial state for update call", async () => {
const initialStorage: Record<string, TestState> = {};
const initialState = TestState.fromJSON({
date: "2022-09-21T13:14:17.648Z",
});
initialStorage[globalKey] = initialState;
diskStorageService.internalUpdateStore(initialStorage);
const emissions = trackEmissions(globalState.state$);
await awaitAsync(); // storage updates are behind a promise
const newState = {
...initialState,
date: new Date(initialState.date.getFullYear(), initialState.date.getMonth() + 1),
};
const actual = await globalState.update((existingState) => newState);
await awaitAsync();
expect(actual).toEqual(newState);
expect(emissions).toHaveLength(2);
expect(emissions).toEqual(expect.arrayContaining([initialState, newState]));
});
});
});

View File

@@ -1,15 +1,29 @@
import { BehaviorSubject, Observable, defer, filter, map, shareReplay, tap } from "rxjs";
import { Jsonify } from "type-fest";
import {
BehaviorSubject,
Observable,
defer,
filter,
firstValueFrom,
shareReplay,
switchMap,
tap,
timeout,
} from "rxjs";
import { AbstractStorageService } from "../../abstractions/storage.service";
import { GlobalState } from "../global-state";
import { KeyDefinition, globalKeyBuilder } from "../key-definition";
import { StateUpdateOptions, populateOptionsWithDefault } from "../state-update-options";
import { getStoredValue } from "./util";
const FAKE_DEFAULT = Symbol("fakeDefault");
export class DefaultGlobalState<T> implements GlobalState<T> {
private storageKey: string;
private seededPromise: Promise<void>;
protected stateSubject: BehaviorSubject<T | null> = new BehaviorSubject<T | null>(null);
protected stateSubject: BehaviorSubject<T | typeof FAKE_DEFAULT> = new BehaviorSubject<
T | typeof FAKE_DEFAULT
>(FAKE_DEFAULT);
state$: Observable<T>;
@@ -19,15 +33,17 @@ export class DefaultGlobalState<T> implements GlobalState<T> {
) {
this.storageKey = globalKeyBuilder(this.keyDefinition);
this.seededPromise = this.chosenLocation.get<Jsonify<T>>(this.storageKey).then((data) => {
const serializedData = this.keyDefinition.deserializer(data);
this.stateSubject.next(serializedData);
});
const storageUpdates$ = this.chosenLocation.updates$.pipe(
filter((update) => update.key === this.storageKey),
map((update) => {
return this.keyDefinition.deserializer(update.value as Jsonify<T>);
switchMap(async (update) => {
if (update.updateType === "remove") {
return null;
}
return await getStoredValue(
this.storageKey,
this.chosenLocation,
this.keyDefinition.deserializer
);
}),
shareReplay({ bufferSize: 1, refCount: false })
);
@@ -37,24 +53,53 @@ export class DefaultGlobalState<T> implements GlobalState<T> {
this.stateSubject.next(value);
});
this.getFromState().then((s) => {
this.stateSubject.next(s);
});
return this.stateSubject.pipe(
tap({
complete: () => storageUpdateSubscription.unsubscribe(),
complete: () => {
storageUpdateSubscription.unsubscribe();
},
})
);
});
}).pipe(
shareReplay({ refCount: false, bufferSize: 1 }),
filter<T>((i) => i != FAKE_DEFAULT)
);
}
async update(configureState: (state: T) => T): Promise<T> {
await this.seededPromise;
const currentState = this.stateSubject.getValue();
const newState = configureState(currentState);
async update<TCombine>(
configureState: (state: T, dependency: TCombine) => T,
options: StateUpdateOptions<T, TCombine> = {}
): Promise<T> {
options = populateOptionsWithDefault(options);
const currentState = await this.getGuaranteedState();
const combinedDependencies =
options.combineLatestWith != null
? await firstValueFrom(options.combineLatestWith.pipe(timeout(options.msTimeout)))
: null;
if (!options.shouldUpdate(currentState, combinedDependencies)) {
return;
}
const newState = configureState(currentState, combinedDependencies);
await this.chosenLocation.save(this.storageKey, newState);
return newState;
}
private async getGuaranteedState() {
const currentValue = this.stateSubject.getValue();
return currentValue === FAKE_DEFAULT ? await this.getFromState() : currentValue;
}
async getFromState(): Promise<T> {
const data = await this.chosenLocation.get<Jsonify<T>>(this.storageKey);
return this.keyDefinition.deserializer(data);
return await getStoredValue(
this.storageKey,
this.chosenLocation,
this.keyDefinition.deserializer
);
}
}

View File

@@ -1,8 +1,12 @@
/**
* need to update test environment so trackEmissions works appropriately
* @jest-environment ../shared/test.environment.ts
*/
import { any, mock } from "jest-mock-extended";
import { BehaviorSubject, firstValueFrom, timeout } from "rxjs";
import { BehaviorSubject, firstValueFrom, of, timeout } from "rxjs";
import { Jsonify } from "type-fest";
import { trackEmissions } from "../../../../spec";
import { awaitAsync, trackEmissions } from "../../../../spec";
import { FakeStorageService } from "../../../../spec/fake-storage.service";
import { AccountInfo, AccountService } from "../../../auth/abstractions/account.service";
import { AuthenticationStatus } from "../../../auth/enums/authentication-status";
@@ -29,11 +33,9 @@ class TestState {
const testStateDefinition = new StateDefinition("fake", "disk");
const testKeyDefinition = new KeyDefinition<TestState>(
testStateDefinition,
"fake",
TestState.fromJSON
);
const testKeyDefinition = new KeyDefinition<TestState>(testStateDefinition, "fake", {
deserializer: TestState.fromJSON,
});
describe("DefaultUserState", () => {
const accountService = mock<AccountService>();
@@ -62,7 +64,7 @@ describe("DefaultUserState", () => {
name: `Test User ${id}`,
status: AuthenticationStatus.Unlocked,
});
await new Promise((resolve) => setTimeout(resolve, 1));
await awaitAsync();
};
afterEach(() => {
@@ -70,51 +72,42 @@ describe("DefaultUserState", () => {
});
it("emits updates for each user switch and update", async () => {
diskStorageService.internalUpdateStore({
"user_00000000-0000-1000-a000-000000000001_fake_fake": {
date: "2022-09-21T13:14:17.648Z",
array: ["value1", "value2"],
} as Jsonify<TestState>,
"user_00000000-0000-1000-a000-000000000002_fake_fake": {
date: "2021-09-21T13:14:17.648Z",
array: ["user2_value"],
},
});
const user1 = "user_00000000-0000-1000-a000-000000000001_fake_fake";
const user2 = "user_00000000-0000-1000-a000-000000000002_fake_fake";
const state1 = {
date: new Date(2021, 0),
array: ["value1"],
};
const state2 = {
date: new Date(2022, 0),
array: ["value2"],
};
const initialState: Record<string, TestState> = {};
initialState[user1] = state1;
initialState[user2] = state2;
diskStorageService.internalUpdateStore(initialState);
const emissions = trackEmissions(userState.state$);
// User signs in
changeActiveUser("1");
await new Promise<void>((resolve) => setTimeout(resolve, 1));
await awaitAsync();
// Service does an update
await userState.update((state) => {
state.array.push("value3");
state.date = new Date(2023, 0);
return state;
});
await new Promise<void>((resolve) => setTimeout(resolve, 1));
const updatedState = {
date: new Date(2023, 0),
array: ["value3"],
};
await userState.update(() => updatedState);
await awaitAsync();
// Emulate an account switch
await changeActiveUser("2");
expect(emissions).toHaveLength(3);
// Gotten starter user data
expect(emissions[0]).toBeTruthy();
expect(emissions[0].array).toHaveLength(2);
expect(emissions).toEqual([state1, updatedState, state2]);
// Gotten emission for the update call
expect(emissions[1]).toBeTruthy();
expect(emissions[1].array).toHaveLength(3);
expect(new Date(emissions[1].date).getUTCFullYear()).toBe(2023);
// The second users data
expect(emissions[2]).toBeTruthy();
expect(emissions[2].array).toHaveLength(1);
expect(new Date(emissions[2].date).getUTCFullYear()).toBe(2021);
// Should only be called twice to get state, once for each user
expect(diskStorageService.mock.get).toHaveBeenCalledTimes(2);
// Should be called three time to get state, once for each user and once for the update
expect(diskStorageService.mock.get).toHaveBeenCalledTimes(3);
expect(diskStorageService.mock.get).toHaveBeenNthCalledWith(
1,
"user_00000000-0000-1000-a000-000000000001_fake_fake",
@@ -122,6 +115,11 @@ describe("DefaultUserState", () => {
);
expect(diskStorageService.mock.get).toHaveBeenNthCalledWith(
2,
"user_00000000-0000-1000-a000-000000000001_fake_fake",
any()
);
expect(diskStorageService.mock.get).toHaveBeenNthCalledWith(
3,
"user_00000000-0000-1000-a000-000000000002_fake_fake",
any()
);
@@ -161,9 +159,9 @@ describe("DefaultUserState", () => {
diskStorageService.internalUpdateStore({
"user_00000000-0000-1000-a000-000000000001_fake_fake": {
date: "2020-09-21T13:14:17.648Z",
date: new Date(2020, 0),
array: ["testValue"],
} as Jsonify<TestState>,
} as TestState,
});
const promise = firstValueFrom(userState.state$.pipe(timeout(20)))
@@ -233,4 +231,102 @@ describe("DefaultUserState", () => {
// this value is correct.
expect(emissions).toHaveLength(2);
});
describe("update", () => {
const newData = { date: new Date(), array: ["test"] };
beforeEach(async () => {
changeActiveUser("1");
});
it("should save on update", async () => {
const result = await userState.update((state, dependencies) => {
return newData;
});
expect(diskStorageService.mock.save).toHaveBeenCalledTimes(1);
expect(result).toEqual(newData);
});
it("should emit once per update", async () => {
const emissions = trackEmissions(userState.state$);
await awaitAsync(); // Need to await for the initial value to be emitted
await userState.update((state, dependencies) => {
return newData;
});
await awaitAsync();
expect(emissions).toEqual([
null, // initial value
newData,
]);
});
it("should provide combined dependencies", async () => {
const emissions = trackEmissions(userState.state$);
await awaitAsync(); // Need to await for the initial value to be emitted
const combinedDependencies = { date: new Date() };
await userState.update(
(state, dependencies) => {
expect(dependencies).toEqual(combinedDependencies);
return newData;
},
{
combineLatestWith: of(combinedDependencies),
}
);
await awaitAsync();
expect(emissions).toEqual([
null, // initial value
newData,
]);
});
it("should not update if shouldUpdate returns false", async () => {
const emissions = trackEmissions(userState.state$);
await awaitAsync(); // Need to await for the initial value to be emitted
const result = await userState.update(
(state, dependencies) => {
return newData;
},
{
shouldUpdate: () => false,
}
);
await awaitAsync();
expect(diskStorageService.mock.save).not.toHaveBeenCalled();
expect(result).toBe(undefined);
expect(emissions).toEqual([null]);
});
it("should provide the current state to the update callback", async () => {
const emissions = trackEmissions(userState.state$);
await awaitAsync(); // Need to await for the initial value to be emitted
// Seed with interesting data
const initialData = { date: new Date(2020, 0), array: ["value1", "value2"] };
await userState.update((state, dependencies) => {
return initialData;
});
await userState.update((state, dependencies) => {
expect(state).toEqual(initialData);
return newData;
});
await awaitAsync();
expect(emissions).toEqual([
null, // Initial value
initialData,
newData,
]);
});
});
});

View File

@@ -9,8 +9,8 @@ import {
firstValueFrom,
combineLatestWith,
filter,
timeout,
} from "rxjs";
import { Jsonify } from "type-fest";
import { AccountService } from "../../../auth/abstractions/account.service";
import { UserId } from "../../../types/guid";
@@ -18,9 +18,11 @@ import { EncryptService } from "../../abstractions/encrypt.service";
import { AbstractStorageService } from "../../abstractions/storage.service";
import { DerivedUserState } from "../derived-user-state";
import { KeyDefinition, userKeyBuilder } from "../key-definition";
import { StateUpdateOptions, populateOptionsWithDefault } from "../state-update-options";
import { Converter, UserState } from "../user-state";
import { DefaultDerivedUserState } from "./default-derived-state";
import { getStoredValue } from "./util";
const FAKE_DEFAULT = Symbol("fakeDefault");
@@ -54,9 +56,11 @@ export class DefaultUserState<T> implements UserState<T> {
if (key == null) {
return FAKE_DEFAULT;
}
const jsonData = await this.chosenStorageLocation.get<Jsonify<T>>(key);
const data = keyDefinition.deserializer(jsonData);
return data;
return await getStoredValue(
key,
this.chosenStorageLocation,
this.keyDefinition.deserializer
);
}),
// Share the execution
shareReplay({ refCount: false, bufferSize: 1 })
@@ -65,8 +69,16 @@ export class DefaultUserState<T> implements UserState<T> {
const storageUpdates$ = this.chosenStorageLocation.updates$.pipe(
combineLatestWith(this.formattedKey$),
filter(([update, key]) => key !== null && update.key === key),
map(([update]) => {
return keyDefinition.deserializer(update.value as Jsonify<T>);
switchMap(async ([update, key]) => {
if (update.updateType === "remove") {
return null;
}
const data = await getStoredValue(
key,
this.chosenStorageLocation,
this.keyDefinition.deserializer
);
return data;
})
);
@@ -94,23 +106,53 @@ export class DefaultUserState<T> implements UserState<T> {
.pipe(filter<T>((value) => value != FAKE_DEFAULT));
}
async update(configureState: (state: T) => T): Promise<T> {
async update<TCombine>(
configureState: (state: T, dependency: TCombine) => T,
options: StateUpdateOptions<T, TCombine> = {}
): Promise<T> {
options = populateOptionsWithDefault(options);
const key = await this.createKey();
const currentState = await this.getGuaranteedState(key);
const newState = configureState(currentState);
const combinedDependencies =
options.combineLatestWith != null
? await firstValueFrom(options.combineLatestWith.pipe(timeout(options.msTimeout)))
: null;
if (!options.shouldUpdate(currentState, combinedDependencies)) {
return;
}
const newState = configureState(currentState, combinedDependencies);
await this.saveToStorage(key, newState);
return newState;
}
async updateFor(userId: UserId, configureState: (state: T) => T): Promise<T> {
async updateFor<TCombine>(
userId: UserId,
configureState: (state: T, dependencies: TCombine) => T,
options: StateUpdateOptions<T, TCombine> = {}
): Promise<T> {
if (userId == null) {
throw new Error("Attempting to update user state, but no userId has been supplied.");
}
options = populateOptionsWithDefault(options);
const key = userKeyBuilder(userId, this.keyDefinition);
const currentStore = await this.chosenStorageLocation.get<Jsonify<T>>(key);
const currentState = this.keyDefinition.deserializer(currentStore);
const newState = configureState(currentState);
const currentState = await getStoredValue(
key,
this.chosenStorageLocation,
this.keyDefinition.deserializer
);
const combinedDependencies =
options.combineLatestWith != null
? await firstValueFrom(options.combineLatestWith.pipe(timeout(options.msTimeout)))
: null;
if (!options.shouldUpdate(currentState, combinedDependencies)) {
return;
}
const newState = configureState(currentState, combinedDependencies);
await this.saveToStorage(key, newState);
return newState;
@@ -118,8 +160,7 @@ export class DefaultUserState<T> implements UserState<T> {
async getFromState(): Promise<T> {
const key = await this.createKey();
const data = await this.chosenStorageLocation.get<Jsonify<T>>(key);
return this.keyDefinition.deserializer(data);
return await getStoredValue(key, this.chosenStorageLocation, this.keyDefinition.deserializer);
}
createDerived<TTo>(converter: Converter<T, TTo>): DerivedUserState<TTo> {
@@ -140,10 +181,13 @@ export class DefaultUserState<T> implements UserState<T> {
}
private async seedInitial(key: string): Promise<T> {
const data = await this.chosenStorageLocation.get<Jsonify<T>>(key);
const serializedData = this.keyDefinition.deserializer(data);
this.stateSubject.next(serializedData);
return serializedData;
const value = await getStoredValue(
key,
this.chosenStorageLocation,
this.keyDefinition.deserializer
);
this.stateSubject.next(value);
return value;
}
protected saveToStorage(key: string, data: T): Promise<void> {

View File

@@ -0,0 +1,50 @@
import { FakeStorageService } from "../../../../spec/fake-storage.service";
import { getStoredValue } from "./util";
describe("getStoredValue", () => {
const key = "key";
const deserializedValue = { value: 1 };
const value = JSON.stringify(deserializedValue);
const deserializer = (v: string) => JSON.parse(v);
let storageService: FakeStorageService;
beforeEach(() => {
storageService = new FakeStorageService();
});
describe("when the storage service requires deserialization", () => {
beforeEach(() => {
storageService.internalUpdateValuesRequireDeserialization(true);
});
it("should deserialize", async () => {
storageService.save(key, value);
const result = await getStoredValue(key, storageService, deserializer);
expect(result).toEqual(deserializedValue);
});
});
describe("when the storage service does not require deserialization", () => {
beforeEach(() => {
storageService.internalUpdateValuesRequireDeserialization(false);
});
it("should not deserialize", async () => {
storageService.save(key, value);
const result = await getStoredValue(key, storageService, deserializer);
expect(result).toEqual(value);
});
it("should convert undefined to null", async () => {
storageService.save(key, undefined);
const result = await getStoredValue(key, storageService, deserializer);
expect(result).toEqual(null);
});
});
});

View File

@@ -0,0 +1,18 @@
import { Jsonify } from "type-fest";
import { AbstractStorageService } from "../../abstractions/storage.service";
export async function getStoredValue<T>(
key: string,
storage: AbstractStorageService,
deserializer: (jsonValue: Jsonify<T>) => T
) {
if (storage.valuesRequireDeserialization) {
const jsonValue = await storage.get<Jsonify<T>>(key);
const value = deserializer(jsonValue);
return value;
} else {
const value = await storage.get<T>(key);
return value ?? null;
}
}

View File

@@ -0,0 +1,81 @@
import { Opaque } from "type-fest";
import { KeyDefinition } from "./key-definition";
import { StateDefinition } from "./state-definition";
const fakeStateDefinition = new StateDefinition("fake", "disk");
type FancyString = Opaque<string, "FancyString">;
describe("KeyDefinition", () => {
describe("constructor", () => {
it("throws on undefined deserializer", () => {
expect(() => {
new KeyDefinition<boolean>(fakeStateDefinition, "fake", {
deserializer: undefined,
});
});
});
});
describe("record", () => {
it("runs custom deserializer for each record value", () => {
const recordDefinition = KeyDefinition.record<boolean>(fakeStateDefinition, "fake", {
// Intentionally negate the value for testing
deserializer: (value) => !value,
});
expect(recordDefinition).toBeTruthy();
expect(recordDefinition.deserializer).toBeTruthy();
const deserializedValue = recordDefinition.deserializer({
test1: false,
test2: true,
});
expect(Object.keys(deserializedValue)).toHaveLength(2);
// Values should have swapped from their initial value
expect(deserializedValue["test1"]).toBeTruthy();
expect(deserializedValue["test2"]).toBeFalsy();
});
it("can handle fancy string type", () => {
// This test is more of a test that I got the typescript typing correctly than actually testing any business logic
const recordDefinition = KeyDefinition.record<boolean, FancyString>(
fakeStateDefinition,
"fake",
{
deserializer: (value) => !value,
}
);
const fancyRecord = recordDefinition.deserializer(
JSON.parse(`{ "myKey": false, "mySecondKey": true }`)
);
expect(fancyRecord).toBeTruthy();
expect(Object.keys(fancyRecord)).toHaveLength(2);
expect(fancyRecord["myKey" as FancyString]).toBeTruthy();
expect(fancyRecord["mySecondKey" as FancyString]).toBeFalsy();
});
});
describe("array", () => {
it("run custom deserializer for each array element", () => {
const arrayDefinition = KeyDefinition.array<boolean>(fakeStateDefinition, "fake", {
deserializer: (value) => !value,
});
expect(arrayDefinition).toBeTruthy();
expect(arrayDefinition.deserializer).toBeTruthy();
const deserializedValue = arrayDefinition.deserializer([false, true]);
expect(deserializedValue).toBeTruthy();
expect(deserializedValue).toHaveLength(2);
expect(deserializedValue[0]).toBeTruthy();
expect(deserializedValue[1]).toBeFalsy();
});
});
});

View File

@@ -5,6 +5,22 @@ import { Utils } from "../misc/utils";
import { StateDefinition } from "./state-definition";
/**
* A set of options for customizing the behavior of a {@link KeyDefinition}
*/
type KeyDefinitionOptions<T> = {
/**
* A function to use to safely convert your type from json to your expected type.
*
* **Important:** Your data may be serialized/deserialized at any time and this
* callback needs to be able to faithfully re-initialize from the JSON object representation of your type.
*
* @param jsonValue The JSON object representation of your state.
* @returns The fully typed version of your state.
*/
readonly deserializer: (jsonValue: Jsonify<T>) => T;
};
/**
* KeyDefinitions describe the precise location to store data for a given piece of state.
* The StateDefinition is used to describe the domain of the state, and the KeyDefinition
@@ -14,30 +30,61 @@ export class KeyDefinition<T> {
/**
* Creates a new instance of a KeyDefinition
* @param stateDefinition The state definition for which this key belongs to.
* @param key The name of the key, this should be unique per domain
* @param deserializer A function to use to safely convert your type from json to your expected type.
* @param key The name of the key, this should be unique per domain.
* @param options A set of options to customize the behavior of {@link KeyDefinition}. All options are required.
* @param options.deserializer A function to use to safely convert your type from json to your expected type.
* Your data may be serialized/deserialized at any time and this needs callback needs to be able to faithfully re-initialize
* from the JSON object representation of your type.
*/
constructor(
readonly stateDefinition: StateDefinition,
readonly key: string,
readonly deserializer: (jsonValue: Jsonify<T>) => T
) {}
private readonly options: KeyDefinitionOptions<T>
) {
if (options.deserializer == null) {
throw new Error(
`'deserializer' is a required property on key ${stateDefinition.name} > ${key}`
);
}
}
/**
* Gets the deserializer configured for this {@link KeyDefinition}
*/
get deserializer() {
return this.options.deserializer;
}
/**
* Creates a {@link KeyDefinition} for state that is an array.
* @param stateDefinition The state definition to be added to the KeyDefinition
* @param key The key to be added to the KeyDefinition
* @param deserializer The deserializer for the element of the array in your state.
* @returns A {@link KeyDefinition} that contains a serializer that will run the provided deserializer for each
* element of an array **unless that array is null in which case it will return an empty list.**
* @param options The options to customize the final {@link KeyDefinition}.
* @returns A {@link KeyDefinition} initialized for arrays, the options run
* the deserializer on the provided options for each element of an array
* **unless that array is null, in which case it will return an empty list.**
*
* @example
* ```typescript
* const MY_KEY = KeyDefinition.array<MyArrayElement>(MY_STATE, "key", {
* deserializer: (myJsonElement) => convertToElement(myJsonElement),
* });
* ```
*/
static array<T>(
stateDefinition: StateDefinition,
key: string,
deserializer: (jsonValue: Jsonify<T>) => T
// We have them provide options for the element of the array, depending on future options we add, this could get a little weird.
options: KeyDefinitionOptions<T> // The array helper forces an initialValue of an empty array
) {
return new KeyDefinition<T[]>(stateDefinition, key, (jsonValue) => {
return jsonValue?.map((v) => deserializer(v)) ?? [];
return new KeyDefinition<T[]>(stateDefinition, key, {
...options,
deserializer: (jsonValue) => {
if (jsonValue == null) {
return null;
}
return jsonValue.map((v) => options.deserializer(v));
},
});
}
@@ -45,32 +92,42 @@ export class KeyDefinition<T> {
* Creates a {@link KeyDefinition} for state that is a record.
* @param stateDefinition The state definition to be added to the KeyDefinition
* @param key The key to be added to the KeyDefinition
* @param deserializer The deserializer for the value part of a record.
* @param options The options to customize the final {@link KeyDefinition}.
* @returns A {@link KeyDefinition} that contains a serializer that will run the provided deserializer for each
* value in a record and returns every key as a string **unless that record is null in which case it will return an record.**
* value in a record and returns every key as a string **unless that record is null, in which case it will return an record.**
*
* @example
* ```typescript
* const MY_KEY = KeyDefinition.record<MyRecordValue>(MY_STATE, "key", {
* deserializer: (myJsonValue) => convertToValue(myJsonValue),
* });
* ```
*/
static record<T>(
static record<T, TKey extends string = string>(
stateDefinition: StateDefinition,
key: string,
deserializer: (jsonValue: Jsonify<T>) => T
// We have them provide options for the value of the record, depending on future options we add, this could get a little weird.
options: KeyDefinitionOptions<T> // The array helper forces an initialValue of an empty record
) {
return new KeyDefinition<Record<string, T>>(stateDefinition, key, (jsonValue) => {
const output: Record<string, T> = {};
return new KeyDefinition<Record<TKey, T>>(stateDefinition, key, {
...options,
deserializer: (jsonValue) => {
if (jsonValue == null) {
return null;
}
if (jsonValue == null) {
const output: Record<string, T> = {};
for (const key in jsonValue) {
output[key] = options.deserializer((jsonValue as Record<string, Jsonify<T>>)[key]);
}
return output;
}
for (const key in jsonValue) {
output[key] = deserializer((jsonValue as Record<string, Jsonify<T>>)[key]);
}
return output;
},
});
}
/**
*
* @returns
* Create a string that should be unique across the entire application.
* @returns A string that can be used to cache instances created via this key.
*/
buildCacheKey(): string {
return `${this.stateDefinition.storageLocation}_${this.stateDefinition.name}_${this.key}`;

View File

@@ -0,0 +1,26 @@
import { Observable } from "rxjs";
export const DEFAULT_OPTIONS = {
shouldUpdate: () => true,
combineLatestWith: null as Observable<unknown>,
msTimeout: 1000,
};
type DefinitelyTypedDefault<T, TCombine> = Omit<
typeof DEFAULT_OPTIONS,
"shouldUpdate" | "combineLatestWith"
> & {
shouldUpdate: (state: T, dependency: TCombine) => boolean;
combineLatestWith?: Observable<TCombine>;
};
export type StateUpdateOptions<T, TCombine> = Partial<DefinitelyTypedDefault<T, TCombine>>;
export function populateOptionsWithDefault<T, TCombine>(
options: StateUpdateOptions<T, TCombine>
): StateUpdateOptions<T, TCombine> {
return {
...(DEFAULT_OPTIONS as StateUpdateOptions<T, TCombine>),
...options,
};
}

View File

@@ -4,6 +4,8 @@ import { UserId } from "../../types/guid";
import { EncryptService } from "../abstractions/encrypt.service";
import { UserKey } from "../models/domain/symmetric-crypto-key";
import { StateUpdateOptions } from "./state-update-options";
import { DerivedUserState } from ".";
export class DeriveContext {
@@ -21,16 +23,33 @@ export interface UserState<T> {
/**
* Updates backing stores for the active user.
* @param configureState function that takes the current state and returns the new state
* @param options Defaults to @see {module:state-update-options#DEFAULT_OPTIONS}
* @param options.shouldUpdate A callback for determining if you want to update state. Defaults to () => true
* @param options.combineLatestWith An observable that you want to combine with the current state for callbacks. Defaults to null
* @param options.msTimeout A timeout for how long you are willing to wait for a `combineLatestWith` option to complete. Defaults to 1000ms. Only applies if `combineLatestWith` is set.
* @returns The new state
*/
readonly update: (configureState: (state: T) => T) => Promise<T>;
readonly update: <TCombine>(
configureState: (state: T, dependencies: TCombine) => T,
options?: StateUpdateOptions<T, TCombine>
) => Promise<T>;
/**
* Updates backing stores for the given userId, which may or may not be active.
* @param userId the UserId to target the update for
* @param configureState function that takes the current state for the targeted user and returns the new state
* @param options Defaults given by @see {module:state-update-options#DEFAULT_OPTIONS}
* @param options.shouldUpdate A callback for determining if you want to update state. Defaults to () => true
* @param options.combineLatestWith An observable that you want to combine with the current state for callbacks. Defaults to null
* @param options.msTimeout A timeout for how long you are willing to wait for a `combineLatestWith` option to complete. Defaults to 1000ms. Only applies if `combineLatestWith` is set.
* @returns The new state
*/
readonly updateFor: (userId: UserId, configureState: (state: T) => T) => Promise<T>;
readonly updateFor: <TCombine>(
userId: UserId,
configureState: (state: T, dependencies: TCombine) => T,
options?: StateUpdateOptions<T, TCombine>
) => Promise<T>;
/**
* Creates a derives state from the current state. Derived states are always tied to the active user.

View File

@@ -11,10 +11,11 @@ import { AddKeyTypeToOrgKeysMigrator } from "./migrations/5-add-key-type-to-org-
import { RemoveLegacyEtmKeyMigrator } from "./migrations/6-remove-legacy-etm-key";
import { MoveBiometricAutoPromptToAccount } from "./migrations/7-move-biometric-auto-prompt-to-account";
import { MoveStateVersionMigrator } from "./migrations/8-move-state-version";
import { MoveBrowserSettingsToGlobal } from "./migrations/9-move-browser-settings-to-global";
import { MinVersionMigrator } from "./migrations/min-version";
export const MIN_VERSION = 2;
export const CURRENT_VERSION = 8;
export const CURRENT_VERSION = 9;
export type MinVersion = typeof MIN_VERSION;
export async function migrate(
@@ -38,7 +39,8 @@ export async function migrate(
.with(AddKeyTypeToOrgKeysMigrator, 4, 5)
.with(RemoveLegacyEtmKeyMigrator, 5, 6)
.with(MoveBiometricAutoPromptToAccount, 6, 7)
.with(MoveStateVersionMigrator, 7, CURRENT_VERSION)
.with(MoveStateVersionMigrator, 7, 8)
.with(MoveBrowserSettingsToGlobal, 8, CURRENT_VERSION)
.migrate(migrationHelper);
}

View File

@@ -17,6 +17,20 @@ describe("MigrationBuilder", () => {
}
}
class TestMigratorWithInstanceMethod extends Migrator<0, 1> {
private async instanceMethod(helper: MigrationHelper, value: string) {
await helper.set("test", value);
}
async migrate(helper: MigrationHelper): Promise<void> {
await this.instanceMethod(helper, "migrate");
}
async rollback(helper: MigrationHelper): Promise<void> {
await this.instanceMethod(helper, "rollback");
}
}
let sut: MigrationBuilder<number>;
beforeEach(() => {
@@ -114,4 +128,9 @@ describe("MigrationBuilder", () => {
expect(rollback).not.toBeCalled();
});
});
it("should be able to call instance methods", async () => {
const helper = new MigrationHelper(0, mock(), mock());
await sut.with(TestMigratorWithInstanceMethod, 0, 1).migrate(helper);
});
});

View File

@@ -93,7 +93,7 @@ export class MigrationBuilder<TCurrent extends number = 0> {
);
if (shouldMigrate) {
const method = direction === "up" ? migrator.migrate : migrator.rollback;
await method(helper);
await method.bind(migrator)(helper);
helper.info(
`Migrator ${migrator.constructor.name} (to version ${migrator.toVersion}) migrated - ${direction}`
);

View File

@@ -0,0 +1,355 @@
import { mock } from "jest-mock-extended";
import { FakeStorageService } from "../../../spec/fake-storage.service";
import { MigrationHelper } from "../migration-helper";
import { Migrator } from "../migrator";
import { MoveBrowserSettingsToGlobal } from "./9-move-browser-settings-to-global";
type TestState = { authenticatedAccounts: string[] } & { [key: string]: unknown };
// This could become a helper available to anyone
const runMigrator = async <TMigrator extends Migrator<number, number>>(
migrator: TMigrator,
initalData?: Record<string, unknown>
): Promise<Record<string, unknown>> => {
const fakeStorageService = new FakeStorageService(initalData);
const helper = new MigrationHelper(migrator.fromVersion, fakeStorageService, mock());
await migrator.migrate(helper);
return fakeStorageService.internalStore;
};
describe("MoveBrowserSettingsToGlobal", () => {
const myMigrator = new MoveBrowserSettingsToGlobal(8, 9);
// This could be the state for a browser client who has never touched the settings or this could
// be a different client who doesn't make it possible to toggle these settings
it("doesn't set any value to global if there is no equivalent settings on the account", async () => {
const testInput: TestState = {
authenticatedAccounts: ["user1"],
global: {
theme: "system", // A real global setting that should persist after migration
},
user1: {
settings: {
region: "Self-hosted",
},
},
};
const output = await runMigrator(myMigrator, testInput);
// No additions to the global state
expect(output["global"]).toEqual({
theme: "system",
});
// No additions to user state
expect(output["user1"]).toEqual({
settings: {
region: "Self-hosted",
},
});
});
// This could be a user who opened up the settings page and toggled the checkbox, since this setting infers undefined
// as false this is essentially the default value.
it("sets the setting from the users settings if they have toggled the setting but placed it back to it's inferred", async () => {
const testInput: TestState = {
authenticatedAccounts: ["user1"],
global: {
theme: "system", // A real global setting that should persist after migration
},
user1: {
settings: {
disableAddLoginNotification: false,
disableChangedPasswordNotification: false,
disableContextMenuItem: false,
neverDomains: {
"example.com": null,
},
region: "Self-hosted",
},
},
};
const output = await runMigrator(myMigrator, testInput);
// User settings should have moved to global
expect(output["global"]).toEqual({
theme: "system",
disableAddLoginNotification: false,
disableChangedPasswordNotification: false,
disableContextMenuItem: false,
neverDomains: {
"example.com": null,
},
});
// Migrated settings should be deleted
expect(output["user1"]).toEqual({
settings: { region: "Self-hosted" },
});
});
// The user has set a value and it's not the default, we should respect that choice globally
it("should take the only users settings", async () => {
const testInput: TestState = {
authenticatedAccounts: ["user1"],
global: {
theme: "system", // A real global setting that should persist after migration
},
user1: {
settings: {
disableAddLoginNotification: true,
disableChangedPasswordNotification: true,
disableContextMenuItem: true,
neverDomains: {
"example.com": null,
},
region: "Self-hosted",
},
},
};
const output = await runMigrator(myMigrator, testInput);
// The value for the single user value should be set to global
expect(output["global"]).toEqual({
theme: "system",
disableAddLoginNotification: true,
disableChangedPasswordNotification: true,
disableContextMenuItem: true,
neverDomains: {
"example.com": null,
},
});
expect(output["user1"]).toEqual({
settings: { region: "Self-hosted" },
});
});
// No browser client at the time of this writing should ever have multiple authenticatedAccounts
// but in the bizzare case, we should interpret any user having the feature turned on as the value for
// all the accounts.
it("should take the false value if there are conflicting choices", async () => {
const testInput: TestState = {
authenticatedAccounts: ["user1", "user2"],
global: {
theme: "system", // A real global setting that should persist after migration
},
user1: {
settings: {
disableAddLoginNotification: true,
disableChangedPasswordNotification: true,
disableContextMenuItem: true,
neverDomains: {
"example.com": null,
},
region: "Self-hosted",
},
},
user2: {
settings: {
disableAddLoginNotification: false,
disableChangedPasswordNotification: false,
disableContextMenuItem: false,
neverDomains: {
"example2.com": null,
},
region: "Self-hosted",
},
},
};
const output = await runMigrator(myMigrator, testInput);
// The false settings should be respected over the true values
// neverDomains should be combined into a single object
expect(output["global"]).toEqual({
theme: "system",
disableAddLoginNotification: false,
disableChangedPasswordNotification: false,
disableContextMenuItem: false,
neverDomains: {
"example.com": null,
"example2.com": null,
},
});
expect(output["user1"]).toEqual({
settings: { region: "Self-hosted" },
});
expect(output["user2"]).toEqual({
settings: { region: "Self-hosted" },
});
});
// Once again, no normal browser should have conflicting values at the time of this comment but:
// if one user has toggled the setting back to on and one user has never touched the setting,
// persist the false value into the global state.
it("should persist the false value if one user has that in their settings", async () => {
const testInput: TestState = {
authenticatedAccounts: ["user1", "user2"],
global: {
theme: "system", // A real global setting that should persist after migration
},
user1: {
settings: {
region: "Self-hosted",
},
},
user2: {
settings: {
disableAddLoginNotification: false,
disableChangedPasswordNotification: false,
disableContextMenuItem: false,
neverDomains: {
"example.com": null,
},
region: "Self-hosted",
},
},
};
const output = await runMigrator(myMigrator, testInput);
// The false settings should be respected over the true values
// neverDomains should be combined into a single object
expect(output["global"]).toEqual({
theme: "system",
disableAddLoginNotification: false,
disableChangedPasswordNotification: false,
disableContextMenuItem: false,
neverDomains: {
"example.com": null,
},
});
expect(output["user1"]).toEqual({
settings: { region: "Self-hosted" },
});
expect(output["user2"]).toEqual({
settings: { region: "Self-hosted" },
});
});
// Once again, no normal browser should have conflicting values at the time of this comment but:
// if one user has toggled the setting off and one user has never touched the setting,
// persist the false value into the global state.
it("should persist the false value from a user with no settings since undefined is inferred as false", async () => {
const testInput: TestState = {
authenticatedAccounts: ["user1", "user2"],
global: {
theme: "system", // A real global setting that should persist after migration
},
user1: {
settings: {
region: "Self-hosted",
},
},
user2: {
settings: {
disableAddLoginNotification: true,
disableChangedPasswordNotification: true,
disableContextMenuItem: true,
neverDomains: {
"example.com": null,
},
region: "Self-hosted",
},
},
};
const output = await runMigrator(myMigrator, testInput);
// The false settings should be respected over the true values
// neverDomains should be combined into a single object
expect(output["global"]).toEqual({
theme: "system",
disableAddLoginNotification: false,
disableChangedPasswordNotification: false,
disableContextMenuItem: false,
neverDomains: {
"example.com": null,
},
});
expect(output["user1"]).toEqual({
settings: { region: "Self-hosted" },
});
expect(output["user2"]).toEqual({
settings: { region: "Self-hosted" },
});
});
// This is more realistic, a browser user could have signed into the application and logged out, then signed
// into a different account. Pre browser account switching, the state for the user _is_ kept on disk but the account
// id of the non-current account isn't saved to the authenticatedAccounts array so we don't have a great way to
// get the state and include it in our calculations for what the global state should be.
it("only cares about users defined in authenticatedAccounts", async () => {
const testInput: TestState = {
authenticatedAccounts: ["user1"],
global: {
theme: "system", // A real global setting that should persist after migration
},
user1: {
settings: {
disableAddLoginNotification: true,
disableChangedPasswordNotification: true,
disableContextMenuItem: true,
neverDomains: {
"example.com": null,
},
region: "Self-hosted",
},
},
user2: {
settings: {
disableAddLoginNotification: false,
disableChangedPasswordNotification: false,
disableContextMenuItem: false,
neverDomains: {
"example2.com": null,
},
region: "Self-hosted",
},
},
};
const output = await runMigrator(myMigrator, testInput);
// The true settings should be respected over the false values because that whole users values
// shouldn't be respected.
// neverDomains should be combined into a single object
expect(output["global"]).toEqual({
theme: "system",
disableAddLoginNotification: true,
disableChangedPasswordNotification: true,
disableContextMenuItem: true,
neverDomains: {
"example.com": null,
},
});
expect(output["user1"]).toEqual({
settings: { region: "Self-hosted" },
});
expect(output["user2"]).toEqual({
settings: {
disableAddLoginNotification: false,
disableChangedPasswordNotification: false,
disableContextMenuItem: false,
neverDomains: {
"example2.com": null,
},
region: "Self-hosted",
},
});
});
});

View File

@@ -0,0 +1,102 @@
import { MigrationHelper } from "../migration-helper";
import { Migrator } from "../migrator";
type NeverDomains = { [id: string]: unknown };
type ExpectedAccountType = {
settings?: {
neverDomains?: NeverDomains;
disableAddLoginNotification?: boolean;
disableChangedPasswordNotification?: boolean;
disableContextMenuItem?: boolean;
};
};
type TargetGlobalState = {
neverDomains?: NeverDomains;
disableAddLoginNotification?: boolean;
disableChangedPasswordNotification?: boolean;
disableContextMenuItem?: boolean;
};
export class MoveBrowserSettingsToGlobal extends Migrator<8, 9> {
// Will first check if any of the accounts have a value from the given accountSelector
// if they do have a value it will set that value into global state but if multiple
// users have differing values it will prefer the false setting,
// if all users have true then it will take true.
tryAddSetting(
accounts: { userId: string; account: ExpectedAccountType }[],
accountSelector: (account: ExpectedAccountType) => boolean | undefined,
globalSetter: (value: boolean | undefined) => void
): void {
const hasValue = accounts.some(({ account }) => {
return accountSelector(account) !== undefined;
});
if (hasValue) {
const value = !accounts.some(({ account }) => {
return (accountSelector(account) ?? false) === false;
});
globalSetter(value);
}
}
async migrate(helper: MigrationHelper): Promise<void> {
const global = await helper.get<object>("global");
const accounts = await helper.getAccounts<ExpectedAccountType>();
const globalNeverDomainsValue = accounts.reduce((accumulator, { account }) => {
const normalizedNeverDomains = account.settings?.neverDomains ?? {};
for (const [id, value] of Object.entries(normalizedNeverDomains)) {
accumulator ??= {};
accumulator[id] = value;
}
return accumulator;
}, undefined as NeverDomains);
const targetGlobalState: TargetGlobalState = {};
if (globalNeverDomainsValue != null) {
targetGlobalState.neverDomains = globalNeverDomainsValue;
}
this.tryAddSetting(
accounts,
(a) => a.settings?.disableAddLoginNotification,
(v) => (targetGlobalState.disableAddLoginNotification = v)
);
this.tryAddSetting(
accounts,
(a) => a.settings?.disableChangedPasswordNotification,
(v) => (targetGlobalState.disableChangedPasswordNotification = v)
);
this.tryAddSetting(
accounts,
(a) => a.settings?.disableContextMenuItem,
(v) => (targetGlobalState.disableContextMenuItem = v)
);
await helper.set<TargetGlobalState>("global", {
...global,
...targetGlobalState,
});
await Promise.all(
accounts.map(async ({ userId, account }) => {
delete account.settings?.disableAddLoginNotification;
delete account.settings?.disableChangedPasswordNotification;
delete account.settings?.disableContextMenuItem;
delete account.settings?.neverDomains;
await helper.set(userId, account);
})
);
}
rollback(helper: MigrationHelper): Promise<void> {
throw new Error("Method not implemented.");
}
}

View File

@@ -0,0 +1,214 @@
import { Meta } from "@storybook/addon-docs";
<Meta title="Documentation/Migration" />
# Migrating to the Component Library
You have been tasked with migrating a component to use the CL. What does that entail?
## Getting Started
Before progressing here, please ensure that...
- You have fully setup your dev environment as described in the
[contributing docs](https://contributing.bitwarden.com/).
- You are familiar with [Angular reactive forms](https://angular.io/guide/reactive-forms).
- You are familiar with [Tailwind](https://tailwindcss.com/docs/utility-first).
## Background
The design of Bitwarden is in flux. At the time of writing, the frontend codebase uses a mix of
multiple UI frameworks: Bootstrap, custom "box" styles, and this component library, which is built
on top of Tailwind. In short, the "CL migration" is a move to only use the CL and remove everything
else.
This is very important work. Centralizing around a shared design system will:
- improve user experience by utilizing consistent patterns
- improve developer experience by reducing custom complex UI code
- improve dev & design velocity by having a central location to make UI/UX changes that impact the
entire project
## Success Criteria
Follow these steps to fully migrate a component.
### Use Storybook
Don't recreate the wheel.
After reviewing a design, consult this Storybook to determine if there is a component built for your
usecase. Don't waste effort styling a button or building a popover menu from scratch--we already
have those. If a component isn't flexible enough or doesn't exist for your usecase, contact Will
Martin.
### Use Tailwind
Only use Tailwind for styling. No Bootstrap or other custom CSS is allowed.
This is easy to verify. Bitwarden prefixes all Tailwind classes with `tw-`. If you see a class
without this prefix, it probably shouldn't be there.
<div class="tw-bg-danger-500/10 tw-p-4">
<span class="tw-font-bold tw-text-danger">Bad (Bootstrap)</span>
```html
<div class="mb-2"></div>
```
</div>
<div class="tw-bg-success-500/10 tw-p-4">
<span class="tw-font-bold tw-text-success">Good (Tailwind)</span>
```html
<div class="tw-mb-2"></div>
```
</div>
**Exception:** Icon font classes, prefixed with `bwi`, are allowed.
<div class="tw-bg-success-500/10 tw-p-4">
<span class="tw-font-bold tw-text-success">Good (Icons)</span>
```html
<i class="bwi bwi-spinner bwi-lg bwi-spin" aria-hidden="true"></i>
```
</div>
### Use Reactive Forms
The CL has form components that integrate with Angular's reactive forms: `bit-form-field`,
`bitSubmit`, `bit-form-control`, etc. All forms should be migrated from template-drive forms to
reactive forms to make use of these components. Review the
[form component docs](?path=/docs/component-library-form--docs).
<div class="tw-bg-danger-500/10 tw-p-4">
<span class="tw-text-danger tw-font-bold">Bad</span>
```html
<form #form (ngSubmit)="submit()">
...
</form>
```
</div>
<div class="tw-bg-success-500/10 tw-p-4">
<span class="tw-text-success tw-font-bold">Good</span>
```html
<form [formGroup]="formGroup" [bitSubmit]="submit">
...
</form>
```
</div>
### Dialogs
Legacy Bootstrap modals use the `ModalService`. These should be converted to use the `DialogService`
and it's [related CL components](?path=/docs/component-library-dialogs--docs). Components that are
fully migrated should have no reference to the `ModalService`.
1. Update the template to use CL components:
<div class="tw-bg-danger-500/10 tw-p-4">
```html
<!-- FooDialogComponent -->
<div class="modal fade" role="dialog" aria-modal="true">...</div>
```
</div>
<div class="tw-bg-success-500/10 tw-p-4">
```html
<!-- FooDialogComponent -->
<bit-dialog>...</bit-dialog>
```
</div>
2. Create a static `open` method on the component, that calls `DialogService.open`:
<div class="tw-bg-success-500/10 tw-p-4">
```ts
export class FooDialogComponent {
//...
static open(dialogService: DialogService) {
return dialogService.open(DeleteAccountComponent);
}
}
```
</div>
3. If you need to pass data into the dialog, pass it to `open` as a parameter and inject
`DIALOG_DATA` into the component's constructor.
<div class="tw-bg-success-500/10 tw-p-4">
```ts
export type FooDialogParams = {
bar: string;
}
export class FooDialogComponent {
constructor(@Inject(DIALOG_DATA) protected params: FooDialogParams) {}
static open(dialogService: DialogService, data: FooDialogParams) {
return dialogService.open(DeleteAccountComponent, { data });
}
}
```
</div>
4. Replace calls to `ModalService.open` or `ModalService.openViewRef` with the newly created static
`open` method:
<div class="tw-bg-danger-500/10 tw-p-4">`this.modalService.open(FooDialogComponent);`</div>
<div class="tw-bg-success-500/10 tw-p-4">`FooDialogComponent.open(this.dialogService);`</div>
## Examples
The following examples come from accross the Bitwarden codebase.
### 1.) AboutComponent
Codeowner: Platform
https://github.com/bitwarden/clients/pull/6301/files
This migration updates a `ModalService` component to the `DialogService`.
**Note:** Most of the internal markup of this component was unchanged, aside from the removal of
defunct Bootstrap classes.
### 2.) Auth
Codeowner: Auth
https://github.com/bitwarden/clients/pull/5377
This PR also does some general refactoring, the main relevant change can be seen here:
[Old template](https://github.com/bitwarden/clients/pull/5377/files#diff-4fcab9ffa4ed26904c53da3bd130e346986576f2372e90b0f66188c809f9284d)
-->
[New template](https://github.com/bitwarden/clients/pull/5377/files#diff-cb93c74c828b9b49dc7869cc0324f5f7d6609da6f72e38ac6baba6d5b6384327)
Updates a dialog, similar to example 1, but also adds CL form components and Angular Reactive Forms.
### 3.) AC
Codeowner: Admin Console
https://github.com/bitwarden/clients/pull/5417
Migrates dialog, form, buttons, and a table.
### 4.) Vault
Codeowner: Vault
https://github.com/bitwarden/clients/pull/5648
Some of our components are shared between multiple clients (web, desktop, and the browser extension)
through the use of inheritance. This PR updates the _web_ template of a cross-client component to
use Tailwind and the CL, and updates the base component implementation to use reactive forms,
without updating the desktop or browser templates.
## Questions
Please direct any development questions to Will Martin. Thank you!

View File

@@ -142,9 +142,11 @@ export class Vault {
);
if (response.status === HttpStatusCode.Ok) {
const json = await response.json();
const k1 = json?.extensions?.LastPassK1 as string;
if (k1 != null) {
return Utils.fromB64ToArray(k1);
if (json?.extensions != null && json.extensions.length > 0) {
const k1 = json.extensions[0].LastPassK1 as string;
if (k1 != null) {
return Utils.fromB64ToArray(k1);
}
}
}
return null;