diff --git a/apps/browser/src/autofill/fido2/background/fido2.background.spec.ts b/apps/browser/src/autofill/fido2/background/fido2.background.spec.ts index 43d07bdea1e..2a22a226e38 100644 --- a/apps/browser/src/autofill/fido2/background/fido2.background.spec.ts +++ b/apps/browser/src/autofill/fido2/background/fido2.background.spec.ts @@ -1,6 +1,7 @@ import { mock, MockProxy } from "jest-mock-extended"; import { BehaviorSubject } from "rxjs"; +import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { AssertCredentialParams, CreateCredentialParams, @@ -54,6 +55,7 @@ describe("Fido2Background", () => { let fido2ClientService!: MockProxy; let vaultSettingsService!: MockProxy; let scriptInjectorServiceMock!: MockProxy; + let configServiceMock!: MockProxy; let enablePasskeysMock$!: BehaviorSubject; let fido2Background!: Fido2Background; @@ -71,6 +73,7 @@ describe("Fido2Background", () => { abortController = mock(); registeredContentScripsMock = mock(); scriptInjectorServiceMock = mock(); + configServiceMock = mock(); enablePasskeysMock$ = new BehaviorSubject(true); vaultSettingsService.enablePasskeys$ = enablePasskeysMock$; @@ -80,6 +83,7 @@ describe("Fido2Background", () => { fido2ClientService, vaultSettingsService, scriptInjectorServiceMock, + configServiceMock, ); fido2Background["abortManager"] = abortManagerMock; abortManagerMock.runWithAbortController.mockImplementation((_requestId, runner) => @@ -110,6 +114,7 @@ describe("Fido2Background", () => { tabsQuerySpy.mockResolvedValueOnce([tabMock, secondTabMock, insecureTab, noUrlTab]); await fido2Background.injectFido2ContentScriptsInAllTabs(); + await flushPromises(); expect(scriptInjectorServiceMock.inject).toHaveBeenCalledWith({ tabId: tabMock.id, @@ -133,6 +138,7 @@ describe("Fido2Background", () => { tabsQuerySpy.mockResolvedValueOnce([tabMock]); await fido2Background.injectFido2ContentScriptsInAllTabs(); + await flushPromises(); expect(scriptInjectorServiceMock.inject).toHaveBeenCalledWith({ tabId: tabMock.id, @@ -206,6 +212,22 @@ describe("Fido2Background", () => { }); }); + it("registers the page-script-delay-append-mv2.js content script when the DelayFido2PageScriptInitWithinMv2 feature flag is enabled", async () => { + configServiceMock.getFeatureFlag.mockResolvedValue(true); + isManifestVersionSpy.mockImplementation((manifestVersion) => manifestVersion === 2); + + enablePasskeysMock$.next(true); + await flushPromises(); + + expect(BrowserApi.registerContentScriptsMv2).toHaveBeenCalledWith({ + js: [ + { file: Fido2ContentScript.PageScriptDelayAppend }, + { file: Fido2ContentScript.ContentScript }, + ], + ...sharedRegistrationOptions, + }); + }); + it("unregisters any existing registered content scripts when the enablePasskeys setting is set to `false`", async () => { isManifestVersionSpy.mockImplementation((manifestVersion) => manifestVersion === 2); fido2Background["registeredContentScripts"] = registeredContentScripsMock; diff --git a/apps/browser/src/autofill/fido2/background/fido2.background.ts b/apps/browser/src/autofill/fido2/background/fido2.background.ts index 1694602225b..800c7d4e0de 100644 --- a/apps/browser/src/autofill/fido2/background/fido2.background.ts +++ b/apps/browser/src/autofill/fido2/background/fido2.background.ts @@ -1,6 +1,8 @@ import { firstValueFrom, startWith } from "rxjs"; import { pairwise } from "rxjs/operators"; +import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; +import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { AssertCredentialParams, AssertCredentialResult, @@ -50,6 +52,7 @@ export class Fido2Background implements Fido2BackgroundInterface { private fido2ClientService: Fido2ClientService, private vaultSettingsService: VaultSettingsService, private scriptInjectorService: ScriptInjectorService, + private configService: ConfigService, ) {} /** @@ -132,7 +135,7 @@ export class Fido2Background implements Fido2BackgroundInterface { this.registeredContentScripts = await BrowserApi.registerContentScriptsMv2({ js: [ - { file: Fido2ContentScript.PageScriptAppend }, + { file: await this.getFido2PageScriptAppendFileName() }, { file: Fido2ContentScript.ContentScript }, ], ...this.sharedRegistrationOptions, @@ -176,7 +179,7 @@ export class Fido2Background implements Fido2BackgroundInterface { void this.scriptInjectorService.inject({ tabId: tab.id, injectDetails: { frame: "all_frames", ...this.sharedInjectionDetails }, - mv2Details: { file: Fido2ContentScript.PageScriptAppend }, + mv2Details: { file: await this.getFido2PageScriptAppendFileName() }, mv3Details: { file: Fido2ContentScript.PageScript, world: "MAIN" }, }); @@ -353,4 +356,20 @@ export class Fido2Background implements Fido2BackgroundInterface { this.fido2ContentScriptPortsSet.delete(port); }; + + /** + * Gets the file name of the page-script used within mv2. Will return the + * delayed append script if the associated feature flag is enabled. + */ + private async getFido2PageScriptAppendFileName() { + const shouldDelayInit = await this.configService.getFeatureFlag( + FeatureFlag.DelayFido2PageScriptInitWithinMv2, + ); + + if (shouldDelayInit) { + return Fido2ContentScript.PageScriptDelayAppend; + } + + return Fido2ContentScript.PageScriptAppend; + } } diff --git a/apps/browser/src/autofill/fido2/content/fido2-page-script-delay-append.mv2.ts b/apps/browser/src/autofill/fido2/content/fido2-page-script-delay-append.mv2.ts new file mode 100644 index 00000000000..4afeb76a0d3 --- /dev/null +++ b/apps/browser/src/autofill/fido2/content/fido2-page-script-delay-append.mv2.ts @@ -0,0 +1,27 @@ +/** + * This script handles injection of the FIDO2 override page script into the document. + * This is required for manifest v2, but will be removed when we migrate fully to manifest v3. + */ +import { Fido2ContentScript } from "../enums/fido2-content-script.enum"; + +(function (globalContext) { + if (globalContext.document.contentType !== "text/html") { + return; + } + + if (globalContext.document.readyState === "complete") { + loadScript(); + } else { + globalContext.addEventListener("DOMContentLoaded", loadScript); + } + + function loadScript() { + const script = globalContext.document.createElement("script"); + script.src = chrome.runtime.getURL(Fido2ContentScript.PageScript); + script.addEventListener("load", () => script.remove()); + + const scriptInsertionPoint = + globalContext.document.head || globalContext.document.documentElement; + scriptInsertionPoint.insertBefore(script, scriptInsertionPoint.firstChild); + } +})(globalThis); diff --git a/apps/browser/src/autofill/fido2/enums/fido2-content-script.enum.ts b/apps/browser/src/autofill/fido2/enums/fido2-content-script.enum.ts index 14e629b412e..9d9189c1623 100644 --- a/apps/browser/src/autofill/fido2/enums/fido2-content-script.enum.ts +++ b/apps/browser/src/autofill/fido2/enums/fido2-content-script.enum.ts @@ -1,6 +1,7 @@ export const Fido2ContentScript = { PageScript: "content/fido2-page-script.js", PageScriptAppend: "content/fido2-page-script-append-mv2.js", + PageScriptDelayAppend: "content/fido2-page-script-delay-append-mv2.js", ContentScript: "content/fido2-content-script.js", } as const; diff --git a/apps/browser/src/background/main.background.ts b/apps/browser/src/background/main.background.ts index d514c417efd..db3055b4c68 100644 --- a/apps/browser/src/background/main.background.ts +++ b/apps/browser/src/background/main.background.ts @@ -1025,6 +1025,7 @@ export default class MainBackground { this.fido2ClientService, this.vaultSettingsService, this.scriptInjectorService, + this.configService, ); this.runtimeBackground = new RuntimeBackground( this, diff --git a/apps/browser/webpack.config.js b/apps/browser/webpack.config.js index e6ef80bcd9e..36007f26f0c 100644 --- a/apps/browser/webpack.config.js +++ b/apps/browser/webpack.config.js @@ -301,6 +301,8 @@ if (manifestVersion == 2) { "./src/tools/content/lp-suppress-import-download-script-append.mv2.ts"; mainConfig.entry["content/fido2-page-script-append-mv2"] = "./src/autofill/fido2/content/fido2-page-script-append.mv2.ts"; + mainConfig.entry["content/fido2-page-script-delay-append-mv2"] = + "./src/autofill/fido2/content/fido2-page-script-delay-append.mv2.ts"; configs.push(mainConfig); } else { diff --git a/libs/common/src/enums/feature-flag.enum.ts b/libs/common/src/enums/feature-flag.enum.ts index 4b19251d979..3b2849e0f54 100644 --- a/libs/common/src/enums/feature-flag.enum.ts +++ b/libs/common/src/enums/feature-flag.enum.ts @@ -30,6 +30,7 @@ export enum FeatureFlag { UnauthenticatedExtensionUIRefresh = "unauth-ui-refresh", EnableUpgradePasswordManagerSub = "AC-2708-upgrade-password-manager-sub", GenerateIdentityFillScriptRefactor = "generate-identity-fill-script-refactor", + DelayFido2PageScriptInitWithinMv2 = "delay-fido2-page-script-init-within-mv2", } export type AllowedFeatureFlagTypes = boolean | number | string; @@ -70,6 +71,7 @@ export const DefaultFeatureFlagValue = { [FeatureFlag.UnauthenticatedExtensionUIRefresh]: FALSE, [FeatureFlag.EnableUpgradePasswordManagerSub]: FALSE, [FeatureFlag.GenerateIdentityFillScriptRefactor]: FALSE, + [FeatureFlag.DelayFido2PageScriptInitWithinMv2]: FALSE, } satisfies Record; export type DefaultFeatureFlagValueType = typeof DefaultFeatureFlagValue; diff --git a/libs/common/src/tools/dependencies.ts b/libs/common/src/tools/dependencies.ts new file mode 100644 index 00000000000..0488291b446 --- /dev/null +++ b/libs/common/src/tools/dependencies.ts @@ -0,0 +1,125 @@ +import { Observable } from "rxjs"; + +import { Policy } from "@bitwarden/common/admin-console/models/domain/policy"; +import { UserId } from "@bitwarden/common/types/guid"; + +/** error emitted when the `SingleUserDependency` changes Ids */ +export type UserChangedError = { + /** the userId pinned by the single user dependency */ + expectedUserId: UserId; + /** the userId received in error */ + actualUserId: UserId; +}; + +/** A pattern for types that depend upon a dynamic policy stream and return + * an observable. + * + * Consumers of this dependency should emit when `policy$` + * emits, provided that the latest message materially + * changes the output of the consumer. If `policy$` emits + * an unrecoverable error, the consumer should continue using + * the last-emitted policy. If `policy$` completes, the consumer + * should continue using the last-emitted policy. + */ +export type PolicyDependency = { + /** A stream that emits policies when subscribed and + * when the policy changes. The stream should not + * emit null or undefined. + */ + policy$: Observable; +}; + +/** A pattern for types that depend upon a dynamic userid and return + * an observable. + * + * Consumers of this dependency should emit when `userId$` changes. + * If `userId$` completes, the consumer should also complete. If + * `userId$` emits an unrecoverable error, the consumer should + * also emit the error. + */ +export type UserDependency = { + /** A stream that emits a UserId when subscribed and when + * the userId changes. The stream should not emit null + * or undefined. + */ + userId$: Observable; +}; + +/** A pattern for types that depend upon a fixed userid and return + * an observable. + * + * Consumers of this dependency should emit a `UserChangedError` if + * the value of `singleUserId$` changes. If `singleUserId$` completes, + * the consumer should also complete. If `singleUserId$` errors, the + * consumer should also emit the error. + * + * @remarks Check the consumer's documentation to determine how it + * responds to repeat emissions. + */ +export type SingleUserDependency = { + /** A stream that emits a UserId when subscribed and the user's account + * is unlocked, and completes when the account is locked or logged out. + * The stream should not emit null or undefined. + */ + singleUserId$: Observable; +}; + +/** A pattern for types that emit values exclusively when the dependency + * emits a message. + * + * Consumers of this dependency should emit when `on$` emits. If `on$` + * completes, the consumer should also complete. If `on$` + * errors, the consumer should also emit the error. + * + * @remarks This dependency is useful when you have a nondeterministic + * or stateful algorithm that you would like to run when an event occurs. + */ +export type OnDependency = { + /** The stream that controls emissions + */ + on$: Observable; +}; + +/** A pattern for types that emit when a dependency is `true`. + * + * Consumers of this dependency may emit when `when$` emits a true + * value. If `when$` completes, the consumer should also complete. If + * `when$` errors, the consumer should also emit the error. + * + * @remarks Check the consumer's documentation to determine how it + * responds to emissions. + */ +export type WhenDependency = { + /** The stream to observe for true emissions. */ + when$: Observable; +}; + +/** A pattern for types that allow their managed settings to + * be overridden. + * + * Consumers of this dependency should emit when `settings$` + * change. If `settings$` completes, the consumer should also + * complete. If `settings$` errors, the consumer should also + * emit the error. + */ +export type SettingsDependency = { + /** A stream that emits settings when settings become available + * and when they change. If the settings are not available, the + * stream should wait to emit until they become available. + */ + settings$: Observable; +}; + +/** A pattern for types that accept an arbitrary dependency and + * inject it into behavior-customizing functions. + * + * Unlike most other dependency types, this interface does not + * functionally constrain the behavior of the consumer. + * + * @remarks Consumers of this dependency wholly determine + * their response. Check the consumer's documentation + * to find this information. + */ +export type Dependencies = { + dependencies$: Observable; +}; diff --git a/libs/common/src/tools/state/user-state-subject.spec.ts b/libs/common/src/tools/state/user-state-subject.spec.ts new file mode 100644 index 00000000000..9d019abb0bc --- /dev/null +++ b/libs/common/src/tools/state/user-state-subject.spec.ts @@ -0,0 +1,467 @@ +import { BehaviorSubject, of, Subject } from "rxjs"; + +import { UserId } from "@bitwarden/common/types/guid"; + +import { awaitAsync, FakeSingleUserState } from "../../../spec"; + +import { UserStateSubject } from "./user-state-subject"; + +const SomeUser = "some user" as UserId; +type TestType = { foo: string }; + +describe("UserStateSubject", () => { + describe("dependencies", () => { + it("ignores repeated when$ emissions", async () => { + // this test looks for `nextValue` because a subscription isn't necessary for + // the subject to update + const initialValue: TestType = { foo: "init" }; + const state = new FakeSingleUserState(SomeUser, initialValue); + const singleUserId$ = new BehaviorSubject(SomeUser); + const nextValue = jest.fn((_, next) => next); + const when$ = new BehaviorSubject(true); + const subject = new UserStateSubject(state, { singleUserId$, nextValue, when$ }); + + // the interleaved await asyncs are only necessary b/c `nextValue` is called asynchronously + subject.next({ foo: "next" }); + await awaitAsync(); + when$.next(true); + await awaitAsync(); + when$.next(true); + when$.next(true); + await awaitAsync(); + + expect(nextValue).toHaveBeenCalledTimes(1); + }); + + it("ignores repeated singleUserId$ emissions", async () => { + // this test looks for `nextValue` because a subscription isn't necessary for + // the subject to update + const initialValue: TestType = { foo: "init" }; + const state = new FakeSingleUserState(SomeUser, initialValue); + const singleUserId$ = new BehaviorSubject(SomeUser); + const nextValue = jest.fn((_, next) => next); + const when$ = new BehaviorSubject(true); + const subject = new UserStateSubject(state, { singleUserId$, nextValue, when$ }); + + // the interleaved await asyncs are only necessary b/c `nextValue` is called asynchronously + subject.next({ foo: "next" }); + await awaitAsync(); + singleUserId$.next(SomeUser); + await awaitAsync(); + singleUserId$.next(SomeUser); + singleUserId$.next(SomeUser); + await awaitAsync(); + + expect(nextValue).toHaveBeenCalledTimes(1); + }); + }); + + describe("next", () => { + it("emits the next value", async () => { + const state = new FakeSingleUserState(SomeUser, { foo: "init" }); + const singleUserId$ = new BehaviorSubject(SomeUser); + const subject = new UserStateSubject(state, { singleUserId$ }); + const expected: TestType = { foo: "next" }; + + let actual: TestType = null; + subject.subscribe((value) => { + actual = value; + }); + subject.next(expected); + await awaitAsync(); + + expect(actual).toEqual(expected); + }); + + it("ceases emissions once complete", async () => { + const initialState = { foo: "init" }; + const state = new FakeSingleUserState(SomeUser, initialState); + const singleUserId$ = new BehaviorSubject(SomeUser); + const subject = new UserStateSubject(state, { singleUserId$ }); + + let actual: TestType = null; + subject.subscribe((value) => { + actual = value; + }); + subject.complete(); + subject.next({ foo: "ignored" }); + await awaitAsync(); + + expect(actual).toEqual(initialState); + }); + + it("evaluates shouldUpdate", async () => { + const initialValue: TestType = { foo: "init" }; + const state = new FakeSingleUserState(SomeUser, initialValue); + const singleUserId$ = new BehaviorSubject(SomeUser); + const shouldUpdate = jest.fn(() => true); + const subject = new UserStateSubject(state, { singleUserId$, shouldUpdate }); + + const nextVal: TestType = { foo: "next" }; + subject.next(nextVal); + await awaitAsync(); + + expect(shouldUpdate).toHaveBeenCalledWith(initialValue, nextVal, null); + }); + + it("evaluates shouldUpdate with a dependency", async () => { + const initialValue: TestType = { foo: "init" }; + const state = new FakeSingleUserState(SomeUser, initialValue); + const singleUserId$ = new BehaviorSubject(SomeUser); + const shouldUpdate = jest.fn(() => true); + const dependencyValue = { bar: "dependency" }; + const subject = new UserStateSubject(state, { + singleUserId$, + shouldUpdate, + dependencies$: of(dependencyValue), + }); + + const nextVal: TestType = { foo: "next" }; + subject.next(nextVal); + await awaitAsync(); + + expect(shouldUpdate).toHaveBeenCalledWith(initialValue, nextVal, dependencyValue); + }); + + it("emits a value when shouldUpdate returns `true`", async () => { + const initialValue: TestType = { foo: "init" }; + const state = new FakeSingleUserState(SomeUser, initialValue); + const singleUserId$ = new BehaviorSubject(SomeUser); + const shouldUpdate = jest.fn(() => true); + const subject = new UserStateSubject(state, { singleUserId$, shouldUpdate }); + const expected: TestType = { foo: "next" }; + + let actual: TestType = null; + subject.subscribe((value) => { + actual = value; + }); + subject.next(expected); + await awaitAsync(); + + expect(actual).toEqual(expected); + }); + + it("retains the current value when shouldUpdate returns `false`", async () => { + const initialValue: TestType = { foo: "init" }; + const state = new FakeSingleUserState(SomeUser, initialValue); + const singleUserId$ = new BehaviorSubject(SomeUser); + const shouldUpdate = jest.fn(() => false); + const subject = new UserStateSubject(state, { singleUserId$, shouldUpdate }); + + subject.next({ foo: "next" }); + await awaitAsync(); + let actual: TestType = null; + subject.subscribe((value) => { + actual = value; + }); + + expect(actual).toEqual(initialValue); + }); + + it("evaluates nextValue", async () => { + const initialValue: TestType = { foo: "init" }; + const state = new FakeSingleUserState(SomeUser, initialValue); + const singleUserId$ = new BehaviorSubject(SomeUser); + const nextValue = jest.fn((_, next) => next); + const subject = new UserStateSubject(state, { singleUserId$, nextValue }); + + const nextVal: TestType = { foo: "next" }; + subject.next(nextVal); + await awaitAsync(); + + expect(nextValue).toHaveBeenCalledWith(initialValue, nextVal, null); + }); + + it("evaluates nextValue with a dependency", async () => { + const initialValue: TestType = { foo: "init" }; + const state = new FakeSingleUserState(SomeUser, initialValue); + const singleUserId$ = new BehaviorSubject(SomeUser); + const nextValue = jest.fn((_, next) => next); + const dependencyValue = { bar: "dependency" }; + const subject = new UserStateSubject(state, { + singleUserId$, + nextValue, + dependencies$: of(dependencyValue), + }); + + const nextVal: TestType = { foo: "next" }; + subject.next(nextVal); + await awaitAsync(); + + expect(nextValue).toHaveBeenCalledWith(initialValue, nextVal, dependencyValue); + }); + + it("evaluates nextValue when when$ is true", async () => { + // this test looks for `nextValue` because a subscription isn't necessary for + // the subject to update + const initialValue: TestType = { foo: "init" }; + const state = new FakeSingleUserState(SomeUser, initialValue); + const singleUserId$ = new BehaviorSubject(SomeUser); + const nextValue = jest.fn((_, next) => next); + const when$ = new BehaviorSubject(true); + const subject = new UserStateSubject(state, { singleUserId$, nextValue, when$ }); + + const nextVal: TestType = { foo: "next" }; + subject.next(nextVal); + await awaitAsync(); + + expect(nextValue).toHaveBeenCalled(); + }); + + it("waits to evaluate nextValue until when$ is true", async () => { + // this test looks for `nextValue` because a subscription isn't necessary for + // the subject to update. + const initialValue: TestType = { foo: "init" }; + const state = new FakeSingleUserState(SomeUser, initialValue); + const singleUserId$ = new BehaviorSubject(SomeUser); + const nextValue = jest.fn((_, next) => next); + const when$ = new BehaviorSubject(false); + const subject = new UserStateSubject(state, { singleUserId$, nextValue, when$ }); + + const nextVal: TestType = { foo: "next" }; + subject.next(nextVal); + await awaitAsync(); + expect(nextValue).not.toHaveBeenCalled(); + + when$.next(true); + await awaitAsync(); + expect(nextValue).toHaveBeenCalled(); + }); + + it("waits to evaluate nextValue until singleUserId$ emits", async () => { + // this test looks for `nextValue` because a subscription isn't necessary for + // the subject to update. + const initialValue: TestType = { foo: "init" }; + const state = new FakeSingleUserState(SomeUser, initialValue); + const singleUserId$ = new Subject(); + const nextValue = jest.fn((_, next) => next); + const subject = new UserStateSubject(state, { singleUserId$, nextValue }); + + const nextVal: TestType = { foo: "next" }; + subject.next(nextVal); + await awaitAsync(); + expect(nextValue).not.toHaveBeenCalled(); + singleUserId$.next(SomeUser); + await awaitAsync(); + + expect(nextValue).toHaveBeenCalled(); + }); + }); + + describe("error", () => { + it("emits errors", async () => { + const state = new FakeSingleUserState(SomeUser, { foo: "init" }); + const singleUserId$ = new BehaviorSubject(SomeUser); + const subject = new UserStateSubject(state, { singleUserId$ }); + const expected: TestType = { foo: "error" }; + + let actual: TestType = null; + subject.subscribe({ + error: (value) => { + actual = value; + }, + }); + subject.error(expected); + await awaitAsync(); + + expect(actual).toEqual(expected); + }); + + it("ceases emissions once errored", async () => { + const initialState = { foo: "init" }; + const state = new FakeSingleUserState(SomeUser, initialState); + const singleUserId$ = new BehaviorSubject(SomeUser); + const subject = new UserStateSubject(state, { singleUserId$ }); + + let actual: TestType = null; + subject.subscribe({ + error: (value) => { + actual = value; + }, + }); + subject.error("expectedError"); + subject.error("ignored"); + await awaitAsync(); + + expect(actual).toEqual("expectedError"); + }); + + it("ceases emissions once complete", async () => { + const initialState = { foo: "init" }; + const state = new FakeSingleUserState(SomeUser, initialState); + const singleUserId$ = new BehaviorSubject(SomeUser); + const subject = new UserStateSubject(state, { singleUserId$ }); + + let shouldNotRun = false; + subject.subscribe({ + error: () => { + shouldNotRun = true; + }, + }); + subject.complete(); + subject.error("ignored"); + await awaitAsync(); + + expect(shouldNotRun).toBeFalsy(); + }); + }); + + describe("complete", () => { + it("emits completes", async () => { + const state = new FakeSingleUserState(SomeUser, { foo: "init" }); + const singleUserId$ = new BehaviorSubject(SomeUser); + const subject = new UserStateSubject(state, { singleUserId$ }); + + let actual = false; + subject.subscribe({ + complete: () => { + actual = true; + }, + }); + subject.complete(); + await awaitAsync(); + + expect(actual).toBeTruthy(); + }); + + it("ceases emissions once errored", async () => { + const initialState = { foo: "init" }; + const state = new FakeSingleUserState(SomeUser, initialState); + const singleUserId$ = new BehaviorSubject(SomeUser); + const subject = new UserStateSubject(state, { singleUserId$ }); + + let shouldNotRun = false; + subject.subscribe({ + complete: () => { + shouldNotRun = true; + }, + // prevent throw + error: () => {}, + }); + subject.error("occurred"); + subject.complete(); + await awaitAsync(); + + expect(shouldNotRun).toBeFalsy(); + }); + + it("ceases emissions once complete", async () => { + const initialState = { foo: "init" }; + const state = new FakeSingleUserState(SomeUser, initialState); + const singleUserId$ = new BehaviorSubject(SomeUser); + const subject = new UserStateSubject(state, { singleUserId$ }); + + let timesRun = 0; + subject.subscribe({ + complete: () => { + timesRun++; + }, + }); + subject.complete(); + subject.complete(); + await awaitAsync(); + + expect(timesRun).toEqual(1); + }); + }); + + describe("subscribe", () => { + it("completes when singleUserId$ completes", async () => { + const initialValue: TestType = { foo: "init" }; + const state = new FakeSingleUserState(SomeUser, initialValue); + const singleUserId$ = new BehaviorSubject(SomeUser); + const subject = new UserStateSubject(state, { singleUserId$ }); + + let actual = false; + subject.subscribe({ + complete: () => { + actual = true; + }, + }); + singleUserId$.complete(); + await awaitAsync(); + + expect(actual).toBeTruthy(); + }); + + it("completes when when$ completes", async () => { + const initialValue: TestType = { foo: "init" }; + const state = new FakeSingleUserState(SomeUser, initialValue); + const singleUserId$ = new BehaviorSubject(SomeUser); + const when$ = new BehaviorSubject(true); + const subject = new UserStateSubject(state, { singleUserId$, when$ }); + + let actual = false; + subject.subscribe({ + complete: () => { + actual = true; + }, + }); + when$.complete(); + await awaitAsync(); + + expect(actual).toBeTruthy(); + }); + + // FIXME: add test for `this.state.catch` once `FakeSingleUserState` supports + // simulated errors + + it("errors when singleUserId$ changes", async () => { + const initialValue: TestType = { foo: "init" }; + const state = new FakeSingleUserState(SomeUser, initialValue); + const singleUserId$ = new BehaviorSubject(SomeUser); + const subject = new UserStateSubject(state, { singleUserId$ }); + const errorUserId = "error" as UserId; + + let error = false; + subject.subscribe({ + error: (e) => { + error = e; + }, + }); + singleUserId$.next(errorUserId); + await awaitAsync(); + + expect(error).toEqual({ expectedUserId: SomeUser, actualUserId: errorUserId }); + }); + + it("errors when singleUserId$ errors", async () => { + const initialValue: TestType = { foo: "init" }; + const state = new FakeSingleUserState(SomeUser, initialValue); + const singleUserId$ = new BehaviorSubject(SomeUser); + const subject = new UserStateSubject(state, { singleUserId$ }); + const expected = { error: "description" }; + + let actual = false; + subject.subscribe({ + error: (e) => { + actual = e; + }, + }); + singleUserId$.error(expected); + await awaitAsync(); + + expect(actual).toEqual(expected); + }); + + it("errors when when$ errors", async () => { + const initialValue: TestType = { foo: "init" }; + const state = new FakeSingleUserState(SomeUser, initialValue); + const singleUserId$ = new BehaviorSubject(SomeUser); + const when$ = new BehaviorSubject(true); + const subject = new UserStateSubject(state, { singleUserId$, when$ }); + const expected = { error: "description" }; + + let actual = false; + subject.subscribe({ + error: (e) => { + actual = e; + }, + }); + when$.error(expected); + await awaitAsync(); + + expect(actual).toEqual(expected); + }); + }); +}); diff --git a/libs/common/src/tools/state/user-state-subject.ts b/libs/common/src/tools/state/user-state-subject.ts new file mode 100644 index 00000000000..290103664bf --- /dev/null +++ b/libs/common/src/tools/state/user-state-subject.ts @@ -0,0 +1,199 @@ +import { + Observer, + SubjectLike, + Unsubscribable, + ReplaySubject, + filter, + map, + Subject, + takeUntil, + pairwise, + combineLatest, + distinctUntilChanged, + BehaviorSubject, + race, + ignoreElements, + endWith, + startWith, +} from "rxjs"; +import { Simplify } from "type-fest"; + +import { SingleUserState } from "@bitwarden/common/platform/state"; + +import { Dependencies, SingleUserDependency, WhenDependency } from "../dependencies"; + +/** dependencies accepted by the user state subject */ +export type UserStateSubjectDependencies = Simplify< + SingleUserDependency & + Partial & + Partial> & { + /** Compute the next stored value. If this is not set, values + * provided to `next` unconditionally override state. + * @param current the value stored in state + * @param next the value received by the user state subject's `next` member + * @param dependencies the latest value from `Dependencies` + * @returns the value to store in state + */ + nextValue?: (current: State, next: State, dependencies?: Dependency) => State; + /** + * Compute whether the state should update. If this is not set, values + * provided to `next` always update the state. + * @param current the value stored in state + * @param next the value received by the user state subject's `next` member + * @param dependencies the latest value from `Dependencies` + * @returns `true` if the value should be stored, otherwise `false`. + */ + shouldUpdate?: (value: State, next: State, dependencies?: Dependency) => boolean; + } +>; + +/** + * Adapt a state provider to an rxjs subject. + * + * This subject buffers the last value it received in memory. The buffer is erased + * if the subject receives a complete or error event. It does not persist the buffer. + * + * Warning! The user state subject has a synchronous interface, but subscriptions are + * always asynchronous. + * + * @template State the state stored by the subject + * @template Dependencies use-specific dependencies provided by the user. + */ +export class UserStateSubject implements SubjectLike { + /** + * Instantiates the user state subject + * @param state the backing store of the subject + * @param dependencies tailor the subject's behavior for a particular + * purpose. + * @param dependencies.when$ blocks updates to the state subject until + * this becomes true. When this occurs, only the last-received update + * is applied. The blocked update is kept in memory. It does not persist + * to disk. + * @param dependencies.singleUserId$ writes block until the singleUserId$ + * is available. + */ + constructor( + private state: SingleUserState, + private dependencies: UserStateSubjectDependencies, + ) { + // normalize dependencies + const when$ = (this.dependencies.when$ ?? new BehaviorSubject(true)).pipe( + distinctUntilChanged(), + ); + const userIdAvailable$ = this.dependencies.singleUserId$.pipe( + startWith(state.userId), + pairwise(), + map(([expectedUserId, actualUserId]) => { + if (expectedUserId === actualUserId) { + return true; + } else { + throw { expectedUserId, actualUserId }; + } + }), + distinctUntilChanged(), + ); + + // observe completion + const whenComplete$ = when$.pipe(ignoreElements(), endWith(true)); + const inputComplete$ = this.input.pipe(ignoreElements(), endWith(true)); + const userIdComplete$ = this.dependencies.singleUserId$.pipe(ignoreElements(), endWith(true)); + const completion$ = race(whenComplete$, inputComplete$, userIdComplete$); + + // wire subscriptions + this.outputSubscription = this.state.state$.subscribe(this.output); + this.inputSubscription = combineLatest([this.input, when$, userIdAvailable$]) + .pipe( + filter(([_, when]) => when), + map(([state]) => state), + takeUntil(completion$), + ) + .subscribe({ + next: (r) => this.onNext(r), + error: (e: unknown) => this.onError(e), + complete: () => this.onComplete(), + }); + } + + next(value: State) { + this.input?.next(value); + } + + error(err: any) { + this.input?.error(err); + } + + complete() { + this.input?.complete(); + } + + /** Subscribe to the subject's event stream + * @param observer listening for events + * @returns the subscription + */ + subscribe(observer: Partial> | ((value: State) => void)): Unsubscribable { + return this.output.subscribe(observer); + } + + // using subjects to ensure the right semantics are followed; + // if greater efficiency becomes desirable, consider implementing + // `SubjectLike` directly + private input = new Subject(); + private readonly output = new ReplaySubject(1); + + private inputSubscription: Unsubscribable; + private outputSubscription: Unsubscribable; + + private onNext(value: State) { + const nextValue = this.dependencies.nextValue ?? ((_: State, next: State) => next); + const shouldUpdate = this.dependencies.shouldUpdate ?? ((_: State) => true); + + this.state + .update( + (state, dependencies) => { + const next = nextValue(state, value, dependencies); + return next; + }, + { + shouldUpdate(current, dependencies) { + const update = shouldUpdate(current, value, dependencies); + return update; + }, + combineLatestWith: this.dependencies.dependencies$, + }, + ) + .catch((e: any) => this.onError(e)); + } + + private onError(value: any) { + if (!this.isDisposed) { + this.output.error(value); + } + + this.dispose(); + } + + private onComplete() { + if (!this.isDisposed) { + this.output.complete(); + } + + this.dispose(); + } + + private get isDisposed() { + return this.input === null; + } + + private dispose() { + if (!this.isDisposed) { + // clean up internal subscriptions + this.inputSubscription.unsubscribe(); + this.outputSubscription.unsubscribe(); + this.inputSubscription = null; + this.outputSubscription = null; + + // drop input to ensure its value is removed from memory + this.input = null; + } + } +} diff --git a/libs/components/src/badge/badge.mdx b/libs/components/src/badge/badge.mdx index ed3aff9b64c..fb2dceb0fa8 100644 --- a/libs/components/src/badge/badge.mdx +++ b/libs/components/src/badge/badge.mdx @@ -4,6 +4,10 @@ import * as stories from "./badge.stories"; +```ts +import { BadgeModule } from "@bitwarden/components"; +``` + # Badge Badges are primarily used as labels, counters, and small buttons. diff --git a/libs/components/src/badge/badge.stories.ts b/libs/components/src/badge/badge.stories.ts index e3e8c1d0c31..6c57bc0cbfb 100644 --- a/libs/components/src/badge/badge.stories.ts +++ b/libs/components/src/badge/badge.stories.ts @@ -70,9 +70,9 @@ export const Primary: Story = { props: args, template: /*html*/ ` Span Badge containing lengthy text -

+

Link Badge -

+

Button `, }), diff --git a/libs/components/src/button/button.mdx b/libs/components/src/button/button.mdx index 39431f31bff..33e4aed19f7 100644 --- a/libs/components/src/button/button.mdx +++ b/libs/components/src/button/button.mdx @@ -4,6 +4,10 @@ import * as stories from "./button.stories"; +```ts +import { ButtonModule } from "@bitwarden/components"; +``` + # Button Buttons are interactive elements that can be triggered using a mouse, keyboard, or touch. They are diff --git a/libs/components/src/checkbox/checkbox.mdx b/libs/components/src/checkbox/checkbox.mdx new file mode 100644 index 00000000000..f3ce0d8fd07 --- /dev/null +++ b/libs/components/src/checkbox/checkbox.mdx @@ -0,0 +1,32 @@ +import { Meta, Canvas, Source, Primary, Controls } from "@storybook/addon-docs"; + +import * as stories from "./checkbox.stories"; + + + +```ts +import { CheckboxModule } from "@bitwarden/components"; +``` + +# Checkbox + + + + +## Stories + +### Default + + + +### Hint + + + +### Custom + + + +### Intermediate + + diff --git a/libs/components/src/checkbox/checkbox.stories.ts b/libs/components/src/checkbox/checkbox.stories.ts index f516abf7161..0f6c7ef6ecd 100644 --- a/libs/components/src/checkbox/checkbox.stories.ts +++ b/libs/components/src/checkbox/checkbox.stories.ts @@ -20,7 +20,7 @@ import { CheckboxModule } from "./checkbox.module"; const template = /*html*/ `
- + Click me
@@ -133,7 +133,7 @@ export const Hint: Story = { template: /*html*/ `
- + Really long value that never ends. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Curabitur iaculis consequat enim vitae elementum. @@ -181,15 +181,15 @@ export const Custom: Story = {
`, diff --git a/libs/components/src/dialog/dialog/dialog.mdx b/libs/components/src/dialog/dialog/dialog.mdx index d4b37d202a0..c05bdd45445 100644 --- a/libs/components/src/dialog/dialog/dialog.mdx +++ b/libs/components/src/dialog/dialog/dialog.mdx @@ -5,7 +5,7 @@ import * as stories from "./dialog.stories"; ```ts -import { DialogModule } from "@bitwarden/components"; +import { DialogModule, DialogService } from "@bitwarden/components"; ``` # Dialog diff --git a/libs/components/src/dialog/dialog/dialog.stories.ts b/libs/components/src/dialog/dialog/dialog.stories.ts index ee377aa930c..905123ac90a 100644 --- a/libs/components/src/dialog/dialog/dialog.stories.ts +++ b/libs/components/src/dialog/dialog/dialog.stories.ts @@ -145,9 +145,9 @@ export const ScrollingContent: Story = { template: ` - Dialog body text goes here.
+ Dialog body text goes here.
- repeating lines of characters
+ repeating lines of characters
end of sequence!
diff --git a/libs/components/src/dialog/simple-dialog/simple-dialog.mdx b/libs/components/src/dialog/simple-dialog/simple-dialog.mdx index 10bdb3ea5c9..d188b3598e4 100644 --- a/libs/components/src/dialog/simple-dialog/simple-dialog.mdx +++ b/libs/components/src/dialog/simple-dialog/simple-dialog.mdx @@ -5,7 +5,7 @@ import * as stories from "./simple-dialog.stories"; ```ts -import { DialogModule } from "@bitwarden/components"; +import { DialogModule, DialogService } from "@bitwarden/components"; ``` # Simple Dialogs diff --git a/libs/components/src/dialog/simple-dialog/simple-dialog.stories.ts b/libs/components/src/dialog/simple-dialog/simple-dialog.stories.ts index dc34bf2a537..d86b56101b0 100644 --- a/libs/components/src/dialog/simple-dialog/simple-dialog.stories.ts +++ b/libs/components/src/dialog/simple-dialog/simple-dialog.stories.ts @@ -64,10 +64,9 @@ export const ScrollingContent: Story = { Alert Dialog - Message Content - Message text goes here.
+ Message Content Message text goes here.
- repeating lines of characters
+ repeating lines of characters
end of sequence!
diff --git a/libs/components/src/form-field/form-field.mdx b/libs/components/src/form-field/form-field.mdx new file mode 100644 index 00000000000..d84535481ac --- /dev/null +++ b/libs/components/src/form-field/form-field.mdx @@ -0,0 +1,60 @@ +import { Meta, Canvas, Source, Primary, Controls } from "@storybook/addon-docs"; + +import * as stories from "./form-field.stories"; + + + +```ts +import { FormFieldModule } from "@bitwarden/components"; +``` + +# Field + + + + +## Stories + +### Default + + + +### Required + + + +### Hint + + + +### Disabled + + + +### Readonly + + + +### Input Group + + + +### Button Input Group + + + +### Disabled Button Input Group + + + +### Select + + + +### Advanced Select + + + +### Textarea + + diff --git a/libs/components/src/form/form.stories.ts b/libs/components/src/form/form.stories.ts index cc4e9b62cf1..8f08966ba5c 100644 --- a/libs/components/src/form/form.stories.ts +++ b/libs/components/src/form/form.stories.ts @@ -96,19 +96,19 @@ export const FullExample: Story = { Name - + Email - + Country - + Age - + Agree to terms - + Required for the service to work properly - + Subscribe to updates? @@ -138,7 +138,7 @@ export const FullExample: Story = { Decide later - + `, diff --git a/libs/components/src/icon-button/icon-button.mdx b/libs/components/src/icon-button/icon-button.mdx index 817594a7728..8361d4c3997 100644 --- a/libs/components/src/icon-button/icon-button.mdx +++ b/libs/components/src/icon-button/icon-button.mdx @@ -4,6 +4,10 @@ import * as stories from "./icon-button.stories"; +```ts +import { IconButtonModule } from "@bitwarden/components"; +``` + # Icon Button Icon buttons are used when no text accompanies the button. It consists of an icon that may be diff --git a/libs/components/src/link/link.mdx b/libs/components/src/link/link.mdx index 100824277a5..39d8de72fc4 100644 --- a/libs/components/src/link/link.mdx +++ b/libs/components/src/link/link.mdx @@ -4,6 +4,10 @@ import * as stories from "./link.stories"; +```ts +import { LinkModule } from "@bitwarden/components"; +``` + # Link / Text button Text Links and Buttons use the `primary-600` color and can use either the `` or `