1
0
mirror of https://github.com/bitwarden/browser synced 2026-02-13 23:13:36 +00:00

Merge branch 'main' into ps/extension-refresh

This commit is contained in:
Vicki League
2024-08-07 13:23:52 -04:00
28 changed files with 1071 additions and 31 deletions

View File

@@ -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<Fido2ClientService>;
let vaultSettingsService!: MockProxy<VaultSettingsService>;
let scriptInjectorServiceMock!: MockProxy<BrowserScriptInjectorService>;
let configServiceMock!: MockProxy<ConfigService>;
let enablePasskeysMock$!: BehaviorSubject<boolean>;
let fido2Background!: Fido2Background;
@@ -71,6 +73,7 @@ describe("Fido2Background", () => {
abortController = mock<AbortController>();
registeredContentScripsMock = mock<browser.contentScripts.RegisteredContentScript>();
scriptInjectorServiceMock = mock<BrowserScriptInjectorService>();
configServiceMock = mock<ConfigService>();
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;

View File

@@ -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;
}
}

View File

@@ -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);

View File

@@ -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;

View File

@@ -1025,6 +1025,7 @@ export default class MainBackground {
this.fido2ClientService,
this.vaultSettingsService,
this.scriptInjectorService,
this.configService,
);
this.runtimeBackground = new RuntimeBackground(
this,

View File

@@ -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 {

View File

@@ -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<FeatureFlag, AllowedFeatureFlagTypes>;
export type DefaultFeatureFlagValueType = typeof DefaultFeatureFlagValue;

View File

@@ -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<Policy[]>;
};
/** 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<UserId>;
};
/** 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<UserId>;
};
/** 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<void>;
};
/** 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<boolean>;
};
/** 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<Settings> = {
/** 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<Settings>;
};
/** 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<TCombine> = {
dependencies$: Observable<TCombine>;
};

View File

@@ -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<TestType>(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<TestType>(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<TestType>(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<TestType>(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<TestType>(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<TestType>(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<TestType>(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<TestType>(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<TestType>(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<TestType>(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<TestType>(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<TestType>(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<TestType>(SomeUser, initialValue);
const singleUserId$ = new Subject<UserId>();
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<TestType>(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<TestType>(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<TestType>(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<TestType>(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<TestType>(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<TestType>(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<TestType>(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<TestType>(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<TestType>(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<TestType>(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<TestType>(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);
});
});
});

View File

@@ -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<State, Dependency> = Simplify<
SingleUserDependency &
Partial<WhenDependency> &
Partial<Dependencies<Dependency>> & {
/** 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<TCombine>`
* @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<TCombine>`
* @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<State, Dependencies = null> implements SubjectLike<State> {
/**
* 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<State>,
private dependencies: UserStateSubjectDependencies<State, Dependencies>,
) {
// 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<Observer<State>> | ((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<State>();
private readonly output = new ReplaySubject<State>(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;
}
}
}

View File

@@ -4,6 +4,10 @@ import * as stories from "./badge.stories";
<Meta of={stories} />
```ts
import { BadgeModule } from "@bitwarden/components";
```
# Badge
Badges are primarily used as labels, counters, and small buttons.

View File

@@ -70,9 +70,9 @@ export const Primary: Story = {
props: args,
template: /*html*/ `
<span class="tw-text-main">Span </span><span bitBadge [variant]="variant" [truncate]="truncate">Badge containing lengthy text</span>
<br><br>
<br /><br />
<span class="tw-text-main">Link </span><a href="#" bitBadge [variant]="variant" [truncate]="truncate">Badge</a>
<br><br>
<br /><br />
<span class="tw-text-main">Button </span><button bitBadge [variant]="variant" [truncate]="truncate">Badge</button>
`,
}),

View File

@@ -4,6 +4,10 @@ import * as stories from "./button.stories";
<Meta of={stories} />
```ts
import { ButtonModule } from "@bitwarden/components";
```
# Button
Buttons are interactive elements that can be triggered using a mouse, keyboard, or touch. They are

View File

@@ -0,0 +1,32 @@
import { Meta, Canvas, Source, Primary, Controls } from "@storybook/addon-docs";
import * as stories from "./checkbox.stories";
<Meta of={stories} />
```ts
import { CheckboxModule } from "@bitwarden/components";
```
# Checkbox
<Primary />
<Controls />
## Stories
### Default
<Canvas of={stories.Default} />
### Hint
<Canvas of={stories.Hint} />
### Custom
<Canvas of={stories.Custom} />
### Intermediate
<Canvas of={stories.Indeterminate} />

View File

@@ -20,7 +20,7 @@ import { CheckboxModule } from "./checkbox.module";
const template = /*html*/ `
<form [formGroup]="formObj">
<bit-form-control>
<input type="checkbox" bitCheckbox formControlName="checkbox">
<input type="checkbox" bitCheckbox formControlName="checkbox" />
<bit-label>Click me</bit-label>
</bit-form-control>
</form>
@@ -133,7 +133,7 @@ export const Hint: Story = {
template: /*html*/ `
<form [formGroup]="formObj">
<bit-form-control>
<input type="checkbox" bitCheckbox formControlName="checkbox">
<input type="checkbox" bitCheckbox formControlName="checkbox" />
<bit-label>Really long value that never ends.</bit-label>
<bit-hint>
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Curabitur iaculis consequat enim vitae elementum.
@@ -181,15 +181,15 @@ export const Custom: Story = {
<div class="tw-flex tw-flex-col tw-w-32">
<label class="tw-text-main tw-flex tw-bg-secondary-300 tw-p-2">
A-Z
<input class="tw-ml-auto focus-visible:tw-ring-offset-secondary-300" type="checkbox" bitCheckbox>
<input class="tw-ml-auto focus-visible:tw-ring-offset-secondary-300" type="checkbox" bitCheckbox />
</label>
<label class="tw-text-main tw-flex tw-bg-secondary-300 tw-p-2">
a-z
<input class="tw-ml-auto focus-visible:tw-ring-offset-secondary-300" type="checkbox" bitCheckbox>
<input class="tw-ml-auto focus-visible:tw-ring-offset-secondary-300" type="checkbox" bitCheckbox />
</label>
<label class="tw-text-main tw-flex tw-bg-secondary-300 tw-p-2">
0-9
<input class="tw-ml-auto focus-visible:tw-ring-offset-secondary-300" type="checkbox" bitCheckbox>
<input class="tw-ml-auto focus-visible:tw-ring-offset-secondary-300" type="checkbox" bitCheckbox />
</label>
</div>
`,

View File

@@ -5,7 +5,7 @@ import * as stories from "./dialog.stories";
<Meta of={stories} />
```ts
import { DialogModule } from "@bitwarden/components";
import { DialogModule, DialogService } from "@bitwarden/components";
```
# Dialog

View File

@@ -145,9 +145,9 @@ export const ScrollingContent: Story = {
template: `
<bit-dialog title="Scrolling Example" [dialogSize]="dialogSize" [loading]="loading" [disablePadding]="disablePadding">
<span bitDialogContent>
Dialog body text goes here.<br>
Dialog body text goes here.<br />
<ng-container *ngFor="let _ of [].constructor(100)">
repeating lines of characters <br>
repeating lines of characters <br />
</ng-container>
end of sequence!
</span>

View File

@@ -5,7 +5,7 @@ import * as stories from "./simple-dialog.stories";
<Meta of={stories} />
```ts
import { DialogModule } from "@bitwarden/components";
import { DialogModule, DialogService } from "@bitwarden/components";
```
# Simple Dialogs

View File

@@ -64,10 +64,9 @@ export const ScrollingContent: Story = {
<bit-simple-dialog>
<span bitDialogTitle>Alert Dialog</span>
<span bitDialogContent>
Message Content
Message text goes here.<br>
Message Content Message text goes here.<br />
<ng-container *ngFor="let _ of [].constructor(100)">
repeating lines of characters <br>
repeating lines of characters <br />
</ng-container>
end of sequence!
</span>

View File

@@ -0,0 +1,60 @@
import { Meta, Canvas, Source, Primary, Controls } from "@storybook/addon-docs";
import * as stories from "./form-field.stories";
<Meta of={stories} />
```ts
import { FormFieldModule } from "@bitwarden/components";
```
# Field
<Primary />
<Controls />
## Stories
### Default
<Canvas of={stories.Default} />
### Required
<Canvas of={stories.Required} />
### Hint
<Canvas of={stories.Hint} />
### Disabled
<Canvas of={stories.Disabled} />
### Readonly
<Canvas of={stories.Readonly} />
### Input Group
<Canvas of={stories.InputGroup} />
### Button Input Group
<Canvas of={stories.ButtonInputGroup} />
### Disabled Button Input Group
<Canvas of={stories.DisabledButtonInputGroup} />
### Select
<Canvas of={stories.Select} />
### Advanced Select
<Canvas of={stories.AdvancedSelect} />
### Textarea
<Canvas of={stories.Textarea} />

View File

@@ -96,19 +96,19 @@ export const FullExample: Story = {
<bit-label>Name</bit-label>
<input bitInput formControlName="name" />
</bit-form-field>
<bit-form-field>
<bit-label>Email</bit-label>
<input bitInput formControlName="email" />
</bit-form-field>
<bit-form-field>
<bit-label>Country</bit-label>
<bit-select formControlName="country">
<bit-option *ngFor="let country of countries" [value]="country.value" [label]="country.name"></bit-option>
</bit-select>
</bit-form-field>
<bit-form-field>
<bit-label>Age</bit-label>
<input
@@ -119,13 +119,13 @@ export const FullExample: Story = {
max="150"
/>
</bit-form-field>
<bit-form-control>
<bit-label>Agree to terms</bit-label>
<input type="checkbox" bitCheckbox formControlName="terms">
<input type="checkbox" bitCheckbox formControlName="terms" />
<bit-hint>Required for the service to work properly</bit-hint>
</bit-form-control>
<bit-radio-group formControlName="updates">
<bit-label>Subscribe to updates?</bit-label>
<bit-radio-button value="yes">
@@ -138,7 +138,7 @@ export const FullExample: Story = {
<bit-label>Decide later</bit-label>
</bit-radio-button>
</bit-radio-group>
<button type="submit" bitButton buttonType="primary">Submit</button>
</form>
`,

View File

@@ -4,6 +4,10 @@ import * as stories from "./icon-button.stories";
<Meta of={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

View File

@@ -4,6 +4,10 @@ import * as stories from "./link.stories";
<Meta of={stories} />
```ts
import { LinkModule } from "@bitwarden/components";
```
# Link / Text button
Text Links and Buttons use the `primary-600` color and can use either the `<a>` or `<button>` tags.

View File

@@ -1,12 +1,10 @@
<div
class="tw-mx-auto tw-flex tw-flex-col tw-items-center tw-justify-center tw-pt-6 tw-text-center"
>
<div class="tw-max-w-sm">
<div class="tw-mx-auto tw-flex tw-flex-col tw-items-center tw-justify-center tw-pt-6">
<div class="tw-max-w-sm tw-flex tw-flex-col tw-items-center">
<bit-icon [icon]="icon" aria-hidden="true"></bit-icon>
<h3 class="tw-font-semibold">
<h3 class="tw-font-semibold tw-text-center">
<ng-content select="[slot=title]"></ng-content>
</h3>
<p>
<p class="tw-text-center">
<ng-content select="[slot=description]"></ng-content>
</p>
</div>

View File

@@ -0,0 +1,20 @@
import { Meta, Canvas, Source, Primary, Controls } from "@storybook/addon-docs";
import * as stories from "./search.stories";
<Meta of={stories} />
```ts
import { SearchModule } from "@bitwarden/components";
```
# Search
<Primary />
<Controls />
## Stories
### Default
<Canvas of={stories.Default} />

View File

@@ -0,0 +1,20 @@
import { Meta, Canvas, Source, Primary, Controls } from "@storybook/addon-docs";
import * as stories from "./select.stories";
<Meta of={stories} />
```ts
import { SelectModule } from "@bitwarden/components";
```
# Select
<Primary />
<Controls />
## Stories
### Default
<Canvas of={stories.Default} />

View File

@@ -44,7 +44,7 @@ export const Default: Story = {
<ng-template body>
<tr bitRow [alignContent]="alignRowContent">
<td bitCell>Cell 1</td>
<td bitCell>Cell 2 <br> Multiline Cell</td>
<td bitCell>Cell 2 <br /> Multiline Cell</td>
<td bitCell>Cell 3</td>
</tr>
<tr bitRow [alignContent]="alignRowContent">
@@ -53,7 +53,7 @@ export const Default: Story = {
<td bitCell>Cell 6</td>
</tr>
<tr bitRow [alignContent]="alignRowContent">
<td bitCell>Cell 7 <br> Multiline Cell</td>
<td bitCell>Cell 7 <br /> Multiline Cell</td>
<td bitCell>Cell 8</td>
<td bitCell>Cell 9</td>
</tr>

View File

@@ -0,0 +1,30 @@
import { Meta, Canvas, Source, Primary, Controls } from "@storybook/addon-docs";
import * as stories from "./toast.stories";
<Meta of={stories} />
```ts
import { ToastService } from "@bitwarden/components";
```
# Toast
<Primary />
<Controls />
## Stories
### Default
<Canvas of={stories.Default} />
### Long Content
Avoid using long messages in toasts.
<Canvas of={stories.LongContent} />
### Service
<Canvas of={stories.Service} />