mirror of
https://github.com/bitwarden/browser
synced 2025-12-14 15:23:33 +00:00
[PM-6654] new app id service for angular (#8229)
* Improve state documentation * Add namespace for application id * Spec out behavior of app id service * Use state providers for app ids * Migrate app Id * Add reactive interface
This commit is contained in:
@@ -1,4 +1,8 @@
|
||||
import { Observable } from "rxjs";
|
||||
|
||||
export abstract class AppIdService {
|
||||
appId$: Observable<string>;
|
||||
anonymousAppId$: Observable<string>;
|
||||
getAppId: () => Promise<string>;
|
||||
getAnonymousAppId: () => Promise<string>;
|
||||
}
|
||||
|
||||
@@ -253,11 +253,10 @@ export class Utils {
|
||||
});
|
||||
}
|
||||
|
||||
static guidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;
|
||||
|
||||
static isGuid(id: string) {
|
||||
return RegExp(
|
||||
/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/,
|
||||
"i",
|
||||
).test(id);
|
||||
return RegExp(Utils.guidRegex, "i").test(id);
|
||||
}
|
||||
|
||||
static getHostname(uriString: string): string {
|
||||
|
||||
101
libs/common/src/platform/services/app-id.service.spec.ts
Normal file
101
libs/common/src/platform/services/app-id.service.spec.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
import { FakeGlobalStateProvider } from "../../../spec";
|
||||
import { Utils } from "../misc/utils";
|
||||
|
||||
import { ANONYMOUS_APP_ID_KEY, APP_ID_KEY, AppIdService } from "./app-id.service";
|
||||
|
||||
describe("AppIdService", () => {
|
||||
const globalStateProvider = new FakeGlobalStateProvider();
|
||||
const appIdState = globalStateProvider.getFake(APP_ID_KEY);
|
||||
const anonymousAppIdState = globalStateProvider.getFake(ANONYMOUS_APP_ID_KEY);
|
||||
let sut: AppIdService;
|
||||
|
||||
beforeEach(() => {
|
||||
sut = new AppIdService(globalStateProvider);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.resetAllMocks();
|
||||
});
|
||||
|
||||
describe("getAppId", () => {
|
||||
it("returns the existing appId when it exists", async () => {
|
||||
appIdState.stateSubject.next("existingAppId");
|
||||
|
||||
const appId = await sut.getAppId();
|
||||
|
||||
expect(appId).toBe("existingAppId");
|
||||
});
|
||||
|
||||
it.each([null, undefined])(
|
||||
"uses the util function to create a new id when it AppId does not exist",
|
||||
async (value) => {
|
||||
appIdState.stateSubject.next(value);
|
||||
const spy = jest.spyOn(Utils, "newGuid");
|
||||
|
||||
await sut.getAppId();
|
||||
|
||||
expect(spy).toHaveBeenCalledTimes(1);
|
||||
},
|
||||
);
|
||||
|
||||
it.each([null, undefined])("returns a new appId when it does not exist", async (value) => {
|
||||
appIdState.stateSubject.next(value);
|
||||
|
||||
const appId = await sut.getAppId();
|
||||
|
||||
expect(appId).toMatch(Utils.guidRegex);
|
||||
});
|
||||
|
||||
it.each([null, undefined])(
|
||||
"stores the new guid when it an existing one is not found",
|
||||
async (value) => {
|
||||
appIdState.stateSubject.next(value);
|
||||
|
||||
const appId = await sut.getAppId();
|
||||
|
||||
expect(appIdState.nextMock).toHaveBeenCalledWith(appId);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe("getAnonymousAppId", () => {
|
||||
it("returns the existing appId when it exists", async () => {
|
||||
anonymousAppIdState.stateSubject.next("existingAppId");
|
||||
|
||||
const appId = await sut.getAnonymousAppId();
|
||||
|
||||
expect(appId).toBe("existingAppId");
|
||||
});
|
||||
|
||||
it.each([null, undefined])(
|
||||
"uses the util function to create a new id when it AppId does not exist",
|
||||
async (value) => {
|
||||
anonymousAppIdState.stateSubject.next(value);
|
||||
const spy = jest.spyOn(Utils, "newGuid");
|
||||
|
||||
await sut.getAnonymousAppId();
|
||||
|
||||
expect(spy).toHaveBeenCalledTimes(1);
|
||||
},
|
||||
);
|
||||
|
||||
it.each([null, undefined])("returns a new appId when it does not exist", async (value) => {
|
||||
anonymousAppIdState.stateSubject.next(value);
|
||||
|
||||
const appId = await sut.getAnonymousAppId();
|
||||
|
||||
expect(appId).toMatch(Utils.guidRegex);
|
||||
});
|
||||
|
||||
it.each([null, undefined])(
|
||||
"stores the new guid when it an existing one is not found",
|
||||
async (value) => {
|
||||
anonymousAppIdState.stateSubject.next(value);
|
||||
|
||||
const appId = await sut.getAnonymousAppId();
|
||||
|
||||
expect(anonymousAppIdState.nextMock).toHaveBeenCalledWith(appId);
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,31 +1,46 @@
|
||||
import { Observable, filter, firstValueFrom, tap } from "rxjs";
|
||||
|
||||
import { AppIdService as AppIdServiceAbstraction } from "../abstractions/app-id.service";
|
||||
import { AbstractStorageService } from "../abstractions/storage.service";
|
||||
import { HtmlStorageLocation } from "../enums";
|
||||
import { Utils } from "../misc/utils";
|
||||
import { APPLICATION_ID_DISK, GlobalStateProvider, KeyDefinition } from "../state";
|
||||
|
||||
export const APP_ID_KEY = new KeyDefinition(APPLICATION_ID_DISK, "appId", {
|
||||
deserializer: (value: string) => value,
|
||||
});
|
||||
export const ANONYMOUS_APP_ID_KEY = new KeyDefinition(APPLICATION_ID_DISK, "anonymousAppId", {
|
||||
deserializer: (value: string) => value,
|
||||
});
|
||||
|
||||
export class AppIdService implements AppIdServiceAbstraction {
|
||||
constructor(private storageService: AbstractStorageService) {}
|
||||
appId$: Observable<string>;
|
||||
anonymousAppId$: Observable<string>;
|
||||
|
||||
getAppId(): Promise<string> {
|
||||
return this.makeAndGetAppId("appId");
|
||||
constructor(globalStateProvider: GlobalStateProvider) {
|
||||
const appIdState = globalStateProvider.get(APP_ID_KEY);
|
||||
const anonymousAppIdState = globalStateProvider.get(ANONYMOUS_APP_ID_KEY);
|
||||
this.appId$ = appIdState.state$.pipe(
|
||||
tap(async (appId) => {
|
||||
if (!appId) {
|
||||
await appIdState.update(() => Utils.newGuid());
|
||||
}
|
||||
}),
|
||||
filter((appId) => !!appId),
|
||||
);
|
||||
this.anonymousAppId$ = anonymousAppIdState.state$.pipe(
|
||||
tap(async (appId) => {
|
||||
if (!appId) {
|
||||
await anonymousAppIdState.update(() => Utils.newGuid());
|
||||
}
|
||||
}),
|
||||
filter((appId) => !!appId),
|
||||
);
|
||||
}
|
||||
|
||||
getAnonymousAppId(): Promise<string> {
|
||||
return this.makeAndGetAppId("anonymousAppId");
|
||||
async getAppId(): Promise<string> {
|
||||
return await firstValueFrom(this.appId$);
|
||||
}
|
||||
|
||||
private async makeAndGetAppId(key: string) {
|
||||
const existingId = await this.storageService.get<string>(key, {
|
||||
htmlStorageLocation: HtmlStorageLocation.Local,
|
||||
});
|
||||
if (existingId != null) {
|
||||
return existingId;
|
||||
}
|
||||
|
||||
const guid = Utils.newGuid();
|
||||
await this.storageService.save(key, guid, {
|
||||
htmlStorageLocation: HtmlStorageLocation.Local,
|
||||
});
|
||||
return guid;
|
||||
async getAnonymousAppId(): Promise<string> {
|
||||
return await firstValueFrom(this.anonymousAppId$);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ export interface GlobalState<T> {
|
||||
* @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.
|
||||
* Resolves to the new state. If `shouldUpdate` returns false, the promise will resolve to the current state.
|
||||
*/
|
||||
update: <TCombine>(
|
||||
configureState: (state: T, dependency: TCombine) => T,
|
||||
|
||||
@@ -52,6 +52,9 @@ export const NEW_WEB_LAYOUT_BANNER_DISK = new StateDefinition("newWebLayoutBanne
|
||||
|
||||
// Platform
|
||||
|
||||
export const APPLICATION_ID_DISK = new StateDefinition("applicationId", "disk", {
|
||||
web: "disk-local",
|
||||
});
|
||||
export const BIOMETRIC_SETTINGS_DISK = new StateDefinition("biometricSettings", "disk");
|
||||
export const CLEAR_EVENT_DISK = new StateDefinition("clearEvent", "disk");
|
||||
export const CRYPTO_DISK = new StateDefinition("crypto", "disk");
|
||||
|
||||
@@ -32,7 +32,8 @@ export interface ActiveUserState<T> extends UserState<T> {
|
||||
* @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
|
||||
* @returns A promise that must be awaited before your next action to ensure the update has been written to state.
|
||||
* Resolves to the new state. If `shouldUpdate` returns false, the promise will resolve to the current state.
|
||||
*/
|
||||
readonly update: <TCombine>(
|
||||
configureState: (state: T, dependencies: TCombine) => T,
|
||||
@@ -50,7 +51,8 @@ export interface SingleUserState<T> extends UserState<T> {
|
||||
* @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
|
||||
* @returns A promise that must be awaited before your next action to ensure the update has been written to state.
|
||||
* Resolves to the new state. If `shouldUpdate` returns false, the promise will resolve to the current state.
|
||||
*/
|
||||
readonly update: <TCombine>(
|
||||
configureState: (state: T, dependencies: TCombine) => T,
|
||||
|
||||
Reference in New Issue
Block a user