Compare commits

...

32 Commits

Author SHA1 Message Date
Nick Krantz 1e4d182a3c
Merge d748d006b8 into 6ae086f89a 2024-04-26 18:05:47 -04:00
Jake Fink 6ae086f89a
pass userId when logging out and add error handling if one isn't found in background (#8946) 2024-04-26 18:02:45 -04:00
nick-livefront d748d006b8
Merge branch 'main' of https://github.com/bitwarden/clients into pm-7231-product-switcher-navigation 2024-04-26 16:39:10 -05:00
nick-livefront 6e49eeaf09
apply margin-top via class on the host component 2024-04-26 16:38:36 -05:00
nick-livefront 68792b81b3
move observables to protected readonly 2024-04-26 16:34:15 -05:00
nick-livefront 749fcdfb33
refactor `navigationUI` to `otherProductOverrides` 2024-04-26 16:30:57 -05:00
nick-livefront bd54d8713d
update to satisfies 2024-04-26 16:24:45 -05:00
nick-livefront 7c622e8e59
only use wrapping div in navigation switcher story
- less vertical space is taken up
2024-04-26 16:22:17 -05:00
nick-livefront 6dd95d60f6
remove double subscription to `moreProducts$` 2024-04-26 16:16:25 -05:00
nick-livefront dafaf01b39
migrate stories to CSF3 2024-04-26 15:00:11 -05:00
Cesar Gonzalez 5dc200577c
[PM-7663] Update Build Pipeline for Beta Labelling (#8903)
* [PM-7663] Update build pipeline for beta labeling

* [PM-7663] Update build pipeline for beta labelling

* [PM-7663] Update build pipeline for beta labelling

* [PM-7663] Update build pipeline for beta labelling

* [PM-7663] Update build pipeline for beta labelling

* [PM-7663] Incorporate build workflow for the Chrome manifest v3 beta

* [PM-7663] Update build pipeline for beta labeling

* [PM-7663] Update build pipeline for beta labeling

* [PM-7663] Update build pipeline for beta labeling

* [PM-7663] Ensure we can have a valid version number based on the github run id

* [PM-7663] Ensure we can have a valid version number based on the github run id

* [PM-7663] Reverting change made to the run id, as it will not function

* [PM-7663] Reverting change made to the run id, as it will not function

* [PM-7663] Reverting change made to the run id, as it will not function

* [PM-7663] Reverting change made to the run id, as it will not function

* [PM-7663] Reverting a typo

* Fix Duplicate `process.env

* Learn how to use

---------

Co-authored-by: Justin Baur <19896123+justindbaur@users.noreply.github.com>
2024-04-26 15:15:36 -04:00
Justin Baur a8e4366ec0
Check that `self` is undefined instead of `window` (#8940) 2024-04-26 15:08:59 -04:00
Matt Gibson 089f251a0c
Remove memory storage cache from derived state. Use observable cache and port messaging (#8939) 2024-04-26 15:08:39 -04:00
renovate[bot] b3242145f9
[deps] Platform (CL): Update autoprefixer to v10.4.19 (#8735)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-04-26 14:59:15 -04:00
Justin Baur b482a15d34
Bandaid Folders Not Emitting (#8934)
* Bandaid Folders Not Emitting

* Remove VaultFilterComponent Change
2024-04-26 14:41:57 -04:00
nick-livefront 7714118116
use protected readonly variable instead of getter 2024-04-26 13:22:08 -05:00
nick-livefront 5a5f9ee147
remove unneeded full width style 2024-04-26 13:15:46 -05:00
nick-livefront 5c27e8dc84
Merge branch 'main' of https://github.com/bitwarden/clients into pm-7231-product-switcher-navigation 2024-04-26 13:05:15 -05:00
nick-livefront ad8bb930c5
update storybook for product switcher stories 2024-04-25 10:22:11 -05:00
nick-livefront 7dfdd60ca5
hide active styles from navigation product switcher 2024-04-25 10:10:49 -05:00
Thomas Rittson d6efe9a007
Merge branch 'main' into pm-7231-product-switcher-navigation 2024-04-25 14:01:27 +10:00
nick-livefront 409e4b6027
Merge branch 'main' of https://github.com/bitwarden/clients into pm-7231-product-switcher-navigation 2024-04-19 11:12:02 -05:00
nick-livefront 20c5d93ded
add translation for "switch" 2024-04-18 11:09:27 -05:00
nick-livefront 5dd329053a
integrate navigation product switcher into organizations 2024-04-18 10:46:40 -05:00
nick-livefront bb11a82f4b
integrate navigation product switcher into user layout 2024-04-18 10:46:40 -05:00
nick-livefront 3d81657d24
integrate navigation product switcher into provider console 2024-04-18 10:46:40 -05:00
nick-livefront 85dea1d4dd
integrate navigation product switcher into secrets manager 2024-04-18 10:46:40 -05:00
nick-livefront e5e9d93132
add navigation oriented product switcher 2024-04-18 10:46:39 -05:00
nick-livefront 052d073b27
update product switcher to have UI details that are only shown in the navigation pane 2024-04-18 10:46:37 -05:00
nick-livefront 7b1920fdd0
remove absolute positioning from toggle width component
- it now sits beneath the product switcher
2024-04-18 10:39:18 -05:00
nick-livefront 30f0ce3b81
add extra small font size to tailwind config 2024-04-18 10:39:18 -05:00
nick-livefront d471077569
refactor: move logic for products into a service
- This is in preparation for having having the navigation menu show products based off of the same logic.
2024-04-18 10:39:17 -05:00
46 changed files with 988 additions and 462 deletions

View File

@ -164,6 +164,10 @@ jobs:
run: npm run dist:mv3
working-directory: browser-source/apps/browser
- name: Build Chrome Manifest v3 Beta
run: npm run dist:chrome:beta
working-directory: browser-source/apps/browser
- name: Gulp
run: gulp ci
working-directory: browser-source/apps/browser
@ -196,6 +200,13 @@ jobs:
path: browser-source/apps/browser/dist/dist-chrome-mv3.zip
if-no-files-found: error
- name: Upload Chrome MV3 Beta artifact (DO NOT USE FOR PROD)
uses: actions/upload-artifact@5d5d22a31266ced268874388b861e4b58bb5c2f3 # v4.3.1
with:
name: DO-NOT-USE-FOR-PROD-dist-chrome-MV3-beta-${{ env._BUILD_NUMBER }}.zip
path: browser-source/apps/browser/dist/dist-chrome-mv3-beta.zip
if-no-files-found: error
- name: Upload Firefox artifact
uses: actions/upload-artifact@5d5d22a31266ced268874388b861e4b58bb5c2f3 # v4.3.1
with:

View File

@ -35,6 +35,9 @@ function buildString() {
if (process.env.MANIFEST_VERSION) {
build = `-mv${process.env.MANIFEST_VERSION}`;
}
if (process.env.BETA_BUILD === "1") {
build += "-beta";
}
if (process.env.BUILD_NUMBER && process.env.BUILD_NUMBER !== "") {
build = `-${process.env.BUILD_NUMBER}`;
}
@ -65,6 +68,9 @@ function distFirefox() {
manifest.optional_permissions = manifest.optional_permissions.filter(
(permission) => permission !== "privacy",
);
if (process.env.BETA_BUILD === "1") {
manifest = applyBetaLabels(manifest);
}
return manifest;
});
}
@ -72,6 +78,9 @@ function distFirefox() {
function distOpera() {
return dist("opera", (manifest) => {
delete manifest.applications;
if (process.env.BETA_BUILD === "1") {
manifest = applyBetaLabels(manifest);
}
return manifest;
});
}
@ -81,6 +90,9 @@ function distChrome() {
delete manifest.applications;
delete manifest.sidebar_action;
delete manifest.commands._execute_sidebar_action;
if (process.env.BETA_BUILD === "1") {
manifest = applyBetaLabels(manifest);
}
return manifest;
});
}
@ -90,6 +102,9 @@ function distEdge() {
delete manifest.applications;
delete manifest.sidebar_action;
delete manifest.commands._execute_sidebar_action;
if (process.env.BETA_BUILD === "1") {
manifest = applyBetaLabels(manifest);
}
return manifest;
});
}
@ -210,6 +225,9 @@ async function safariCopyBuild(source, dest) {
delete manifest.commands._execute_sidebar_action;
delete manifest.optional_permissions;
manifest.permissions.push("nativeMessaging");
if (process.env.BETA_BUILD === "1") {
manifest = applyBetaLabels(manifest);
}
return manifest;
}),
),
@ -235,6 +253,19 @@ async function ciCoverage(cb) {
.pipe(gulp.dest(paths.coverage));
}
function applyBetaLabels(manifest) {
manifest.name = "Bitwarden Password Manager BETA";
manifest.short_name = "Bitwarden BETA";
manifest.description = "THIS EXTENSION IS FOR BETA TESTING BITWARDEN.";
if (process.env.GITHUB_RUN_ID) {
manifest.version_name = `${manifest.version} beta - ${process.env.GITHUB_SHA.slice(0, 8)}`;
manifest.version = `${manifest.version}.${parseInt(process.env.GITHUB_RUN_ID.slice(-4))}`;
} else {
manifest.version = `${manifest.version}.0`;
}
return manifest;
}
exports["dist:firefox"] = distFirefox;
exports["dist:chrome"] = distChrome;
exports["dist:opera"] = distOpera;

View File

@ -7,10 +7,14 @@
"build:watch": "webpack --watch",
"build:watch:mv3": "cross-env MANIFEST_VERSION=3 webpack --watch",
"build:prod": "cross-env NODE_ENV=production webpack",
"build:prod:beta": "cross-env BETA_BUILD=1 NODE_ENV=production webpack",
"build:prod:watch": "cross-env NODE_ENV=production webpack --watch",
"dist": "npm run build:prod && gulp dist",
"dist:beta": "npm run build:prod:beta && cross-env BETA_BUILD=1 gulp dist",
"dist:mv3": "cross-env MANIFEST_VERSION=3 npm run build:prod && cross-env MANIFEST_VERSION=3 gulp dist",
"dist:mv3:beta": "cross-env MANIFEST_VERSION=3 npm run build:prod:beta && cross-env MANIFEST_VERSION=3 BETA_BUILD=1 gulp dist",
"dist:chrome": "npm run build:prod && gulp dist:chrome",
"dist:chrome:beta": "cross-env MANIFEST_VERSION=3 npm run build:prod:beta && cross-env MANIFEST_VERSION=3 BETA_BUILD=1 gulp dist:chrome",
"dist:firefox": "npm run build:prod && gulp dist:firefox",
"dist:opera": "npm run build:prod && gulp dist:opera",
"dist:safari": "npm run build:prod && gulp dist:safari",

View File

@ -1,4 +1,4 @@
import { Subject, firstValueFrom, merge } from "rxjs";
import { Subject, firstValueFrom, merge, timeout } from "rxjs";
import {
PinCryptoServiceAbstraction,
@ -490,7 +490,7 @@ export default class MainBackground {
this.accountService,
this.singleUserStateProvider,
);
this.derivedStateProvider = new BackgroundDerivedStateProvider(storageServiceProvider);
this.derivedStateProvider = new BackgroundDerivedStateProvider();
this.stateProvider = new DefaultStateProvider(
this.activeUserStateProvider,
this.singleUserStateProvider,
@ -1196,7 +1196,18 @@ export default class MainBackground {
}
async logout(expired: boolean, userId?: UserId) {
userId ??= (await firstValueFrom(this.accountService.activeAccount$))?.id;
userId ??= (
await firstValueFrom(
this.accountService.activeAccount$.pipe(
timeout({
first: 2000,
with: () => {
throw new Error("No active account found to logout");
},
}),
),
)
)?.id;
await this.eventUploadService.uploadEvents(userId as UserId);

View File

@ -3,15 +3,10 @@ import { DerivedStateProvider } from "@bitwarden/common/platform/state";
import { BackgroundDerivedStateProvider } from "../../state/background-derived-state.provider";
import { CachedServices, FactoryOptions, factory } from "./factory-options";
import {
StorageServiceProviderInitOptions,
storageServiceProviderFactory,
} from "./storage-service-provider.factory";
type DerivedStateProviderFactoryOptions = FactoryOptions;
export type DerivedStateProviderInitOptions = DerivedStateProviderFactoryOptions &
StorageServiceProviderInitOptions;
export type DerivedStateProviderInitOptions = DerivedStateProviderFactoryOptions;
export async function derivedStateProviderFactory(
cache: { derivedStateProvider?: DerivedStateProvider } & CachedServices,
@ -21,7 +16,6 @@ export async function derivedStateProviderFactory(
cache,
"derivedStateProvider",
opts,
async () =>
new BackgroundDerivedStateProvider(await storageServiceProviderFactory(cache, opts)),
async () => new BackgroundDerivedStateProvider(),
);
}

View File

@ -1,9 +1,5 @@
import { Observable } from "rxjs";
import {
AbstractStorageService,
ObservableStorageService,
} from "@bitwarden/common/platform/abstractions/storage.service";
import { DeriveDefinition, DerivedState } from "@bitwarden/common/platform/state";
// eslint-disable-next-line import/no-restricted-paths -- extending this class for this client
import { DefaultDerivedStateProvider } from "@bitwarden/common/platform/state/implementations/default-derived-state.provider";
@ -16,14 +12,11 @@ export class BackgroundDerivedStateProvider extends DefaultDerivedStateProvider
parentState$: Observable<TFrom>,
deriveDefinition: DeriveDefinition<TFrom, TTo, TDeps>,
dependencies: TDeps,
storageLocation: [string, AbstractStorageService & ObservableStorageService],
): DerivedState<TTo> {
const [location, storageService] = storageLocation;
return new BackgroundDerivedState(
parentState$,
deriveDefinition,
storageService,
deriveDefinition.buildCacheKey(location),
deriveDefinition.buildCacheKey(),
dependencies,
);
}

View File

@ -1,10 +1,7 @@
import { Observable, Subscription } from "rxjs";
import { Observable, Subscription, concatMap } from "rxjs";
import { Jsonify } from "type-fest";
import {
AbstractStorageService,
ObservableStorageService,
} from "@bitwarden/common/platform/abstractions/storage.service";
import { Utils } from "@bitwarden/common/platform/misc/utils";
import { DeriveDefinition } from "@bitwarden/common/platform/state";
// eslint-disable-next-line import/no-restricted-paths -- extending this class for this client
import { DefaultDerivedState } from "@bitwarden/common/platform/state/implementations/default-derived-state";
@ -22,11 +19,10 @@ export class BackgroundDerivedState<
constructor(
parentState$: Observable<TFrom>,
deriveDefinition: DeriveDefinition<TFrom, TTo, TDeps>,
memoryStorage: AbstractStorageService & ObservableStorageService,
portName: string,
dependencies: TDeps,
) {
super(parentState$, deriveDefinition, memoryStorage, dependencies);
super(parentState$, deriveDefinition, dependencies);
// listen for foreground derived states to connect
BrowserApi.addListener(chrome.runtime.onConnect, (port) => {
@ -42,7 +38,20 @@ export class BackgroundDerivedState<
});
port.onMessage.addListener(listenerCallback);
const stateSubscription = this.state$.subscribe();
const stateSubscription = this.state$
.pipe(
concatMap(async (state) => {
await this.sendMessage(
{
action: "nextState",
data: JSON.stringify(state),
id: Utils.newGuid(),
},
port,
);
}),
)
.subscribe();
this.portSubscriptions.set(port, stateSubscription);
});

View File

@ -4,14 +4,13 @@
*/
import { NgZone } from "@angular/core";
import { FakeStorageService } from "@bitwarden/common/../spec/fake-storage.service";
import { awaitAsync, trackEmissions } from "@bitwarden/common/../spec/utils";
import { mock } from "jest-mock-extended";
import { Subject, firstValueFrom } from "rxjs";
import { DeriveDefinition } from "@bitwarden/common/platform/state";
// eslint-disable-next-line import/no-restricted-paths -- needed to define a derive definition
import { StateDefinition } from "@bitwarden/common/platform/state/state-definition";
import { awaitAsync, trackEmissions, ObservableTracker } from "@bitwarden/common/spec";
import { mockPorts } from "../../../spec/mock-port.spec-util";
@ -22,6 +21,7 @@ const stateDefinition = new StateDefinition("test", "memory");
const deriveDefinition = new DeriveDefinition(stateDefinition, "test", {
derive: (dateString: string) => (dateString == null ? null : new Date(dateString)),
deserializer: (dateString: string) => (dateString == null ? null : new Date(dateString)),
cleanupDelayMs: 1000,
});
// Mock out the runInsideAngular operator so we don't have to deal with zone.js
@ -35,7 +35,6 @@ describe("foreground background derived state interactions", () => {
let foreground: ForegroundDerivedState<Date>;
let background: BackgroundDerivedState<string, Date, Record<string, unknown>>;
let parentState$: Subject<string>;
let memoryStorage: FakeStorageService;
const initialParent = "2020-01-01";
const ngZone = mock<NgZone>();
const portName = "testPort";
@ -43,16 +42,9 @@ describe("foreground background derived state interactions", () => {
beforeEach(() => {
mockPorts();
parentState$ = new Subject<string>();
memoryStorage = new FakeStorageService();
background = new BackgroundDerivedState(
parentState$,
deriveDefinition,
memoryStorage,
portName,
{},
);
foreground = new ForegroundDerivedState(deriveDefinition, memoryStorage, portName, ngZone);
background = new BackgroundDerivedState(parentState$, deriveDefinition, portName, {});
foreground = new ForegroundDerivedState(deriveDefinition, portName, ngZone);
});
afterEach(() => {
@ -72,21 +64,13 @@ describe("foreground background derived state interactions", () => {
});
it("should initialize a late-connected foreground", async () => {
const newForeground = new ForegroundDerivedState(
deriveDefinition,
memoryStorage,
portName,
ngZone,
);
const backgroundEmissions = trackEmissions(background.state$);
const newForeground = new ForegroundDerivedState(deriveDefinition, portName, ngZone);
const backgroundTracker = new ObservableTracker(background.state$);
parentState$.next(initialParent);
await awaitAsync();
const foregroundTracker = new ObservableTracker(newForeground.state$);
const foregroundEmissions = trackEmissions(newForeground.state$);
await awaitAsync(10);
expect(backgroundEmissions).toEqual([new Date(initialParent)]);
expect(foregroundEmissions).toEqual([new Date(initialParent)]);
expect(await backgroundTracker.expectEmission()).toEqual(new Date(initialParent));
expect(await foregroundTracker.expectEmission()).toEqual(new Date(initialParent));
});
describe("forceValue", () => {

View File

@ -1,11 +1,6 @@
import { NgZone } from "@angular/core";
import { Observable } from "rxjs";
import {
AbstractStorageService,
ObservableStorageService,
} from "@bitwarden/common/platform/abstractions/storage.service";
import { StorageServiceProvider } from "@bitwarden/common/platform/services/storage-service.provider";
import { DeriveDefinition, DerivedState } from "@bitwarden/common/platform/state";
// eslint-disable-next-line import/no-restricted-paths -- extending this class for this client
import { DefaultDerivedStateProvider } from "@bitwarden/common/platform/state/implementations/default-derived-state.provider";
@ -14,23 +9,17 @@ import { DerivedStateDependencies } from "@bitwarden/common/src/types/state";
import { ForegroundDerivedState } from "./foreground-derived-state";
export class ForegroundDerivedStateProvider extends DefaultDerivedStateProvider {
constructor(
storageServiceProvider: StorageServiceProvider,
private ngZone: NgZone,
) {
super(storageServiceProvider);
constructor(private ngZone: NgZone) {
super();
}
override buildDerivedState<TFrom, TTo, TDeps extends DerivedStateDependencies>(
_parentState$: Observable<TFrom>,
deriveDefinition: DeriveDefinition<TFrom, TTo, TDeps>,
_dependencies: TDeps,
storageLocation: [string, AbstractStorageService & ObservableStorageService],
): DerivedState<TTo> {
const [location, storageService] = storageLocation;
return new ForegroundDerivedState(
deriveDefinition,
storageService,
deriveDefinition.buildCacheKey(location),
deriveDefinition.buildCacheKey(),
this.ngZone,
);
}

View File

@ -1,11 +1,5 @@
/**
* need to update test environment so structuredClone works appropriately
* @jest-environment ../../libs/shared/test.environment.ts
*/
import { NgZone } from "@angular/core";
import { awaitAsync, trackEmissions } from "@bitwarden/common/../spec";
import { FakeStorageService } from "@bitwarden/common/../spec/fake-storage.service";
import { awaitAsync } from "@bitwarden/common/../spec";
import { mock } from "jest-mock-extended";
import { DeriveDefinition } from "@bitwarden/common/platform/state";
@ -32,15 +26,12 @@ jest.mock("../browser/run-inside-angular.operator", () => {
describe("ForegroundDerivedState", () => {
let sut: ForegroundDerivedState<Date>;
let memoryStorage: FakeStorageService;
const portName = "testPort";
const ngZone = mock<NgZone>();
beforeEach(() => {
memoryStorage = new FakeStorageService();
memoryStorage.internalUpdateValuesRequireDeserialization(true);
mockPorts();
sut = new ForegroundDerivedState(deriveDefinition, memoryStorage, portName, ngZone);
sut = new ForegroundDerivedState(deriveDefinition, portName, ngZone);
});
afterEach(() => {
@ -67,18 +58,4 @@ describe("ForegroundDerivedState", () => {
expect(disconnectSpy).toHaveBeenCalled();
expect(sut["port"]).toBeNull();
});
it("should emit when the memory storage updates", async () => {
const dateString = "2020-01-01";
const emissions = trackEmissions(sut.state$);
await memoryStorage.save(deriveDefinition.storageKey, {
derived: true,
value: new Date(dateString),
});
await awaitAsync();
expect(emissions).toEqual([new Date(dateString)]);
});
});

View File

@ -6,19 +6,14 @@ import {
filter,
firstValueFrom,
map,
merge,
of,
share,
switchMap,
tap,
timer,
} from "rxjs";
import { Jsonify, JsonObject } from "type-fest";
import { Jsonify } from "type-fest";
import {
AbstractStorageService,
ObservableStorageService,
} from "@bitwarden/common/platform/abstractions/storage.service";
import { Utils } from "@bitwarden/common/platform/misc/utils";
import { DeriveDefinition, DerivedState } from "@bitwarden/common/platform/state";
import { DerivedStateDependencies } from "@bitwarden/common/types/state";
@ -27,41 +22,28 @@ import { fromChromeEvent } from "../browser/from-chrome-event";
import { runInsideAngular } from "../browser/run-inside-angular.operator";
export class ForegroundDerivedState<TTo> implements DerivedState<TTo> {
private storageKey: string;
private port: chrome.runtime.Port;
private backgroundResponses$: Observable<DerivedStateMessage>;
state$: Observable<TTo>;
constructor(
private deriveDefinition: DeriveDefinition<unknown, TTo, DerivedStateDependencies>,
private memoryStorage: AbstractStorageService & ObservableStorageService,
private portName: string,
private ngZone: NgZone,
) {
this.storageKey = deriveDefinition.storageKey;
const initialStorageGet$ = defer(() => {
return this.getStoredValue();
}).pipe(
filter((s) => s.derived),
map((s) => s.value),
);
const latestStorage$ = this.memoryStorage.updates$.pipe(
filter((s) => s.key === this.storageKey),
switchMap(async (storageUpdate) => {
if (storageUpdate.updateType === "remove") {
return null;
}
return await this.getStoredValue();
}),
filter((s) => s.derived),
map((s) => s.value),
);
const latestValueFromPort$ = (port: chrome.runtime.Port) => {
return fromChromeEvent(port.onMessage).pipe(
map(([message]) => message as DerivedStateMessage),
filter((message) => message.originator === "background" && message.action === "nextState"),
map((message) => {
const json = JSON.parse(message.data) as Jsonify<TTo>;
return this.deriveDefinition.deserialize(json);
}),
);
};
this.state$ = defer(() => of(this.initializePort())).pipe(
switchMap(() => merge(initialStorageGet$, latestStorage$)),
switchMap(() => latestValueFromPort$(this.port)),
share({
connector: () => new ReplaySubject<TTo>(1),
resetOnRefCountZero: () =>
@ -130,28 +112,4 @@ export class ForegroundDerivedState<TTo> implements DerivedState<TTo> {
this.port = null;
this.backgroundResponses$ = null;
}
protected async getStoredValue(): Promise<{ derived: boolean; value: TTo | null }> {
if (this.memoryStorage.valuesRequireDeserialization) {
const storedJson = await this.memoryStorage.get<
Jsonify<{ derived: true; value: JsonObject }>
>(this.storageKey);
if (!storedJson?.derived) {
return { derived: false, value: null };
}
const value = this.deriveDefinition.deserialize(storedJson.value as any);
return { derived: true, value };
} else {
const stored = await this.memoryStorage.get<{ derived: true; value: TTo }>(this.storageKey);
if (!stored?.derived) {
return { derived: false, value: null };
}
return { derived: true, value: stored.value };
}
}
}

View File

@ -473,7 +473,7 @@ const safeProviders: SafeProvider[] = [
safeProvider({
provide: DerivedStateProvider,
useClass: ForegroundDerivedStateProvider,
deps: [StorageServiceProvider, NgZone],
deps: [NgZone],
}),
safeProvider({
provide: AutofillSettingsServiceAbstraction,

View File

@ -21,6 +21,7 @@ import { VaultTimeoutSettingsService } from "@bitwarden/common/abstractions/vaul
import { VaultTimeoutService } from "@bitwarden/common/abstractions/vault-timeout/vault-timeout.service";
import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
import { PolicyType } from "@bitwarden/common/admin-console/enums";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { UserVerificationService } from "@bitwarden/common/auth/abstractions/user-verification/user-verification.service.abstraction";
import { DeviceType } from "@bitwarden/common/enums";
import { VaultTimeoutAction } from "@bitwarden/common/enums/vault-timeout-action.enum";
@ -86,6 +87,7 @@ export class SettingsComponent implements OnInit {
private destroy$ = new Subject<void>();
constructor(
private accountService: AccountService,
private policyService: PolicyService,
private formBuilder: FormBuilder,
private platformUtilsService: PlatformUtilsService,
@ -434,8 +436,9 @@ export class SettingsComponent implements OnInit {
type: "info",
});
const userId = (await firstValueFrom(this.accountService.activeAccount$))?.id;
if (confirmed) {
this.messagingService.send("logout");
this.messagingService.send("logout", { userId: userId });
}
}

View File

@ -314,7 +314,7 @@ export class Main {
this.singleUserStateProvider,
);
this.derivedStateProvider = new DefaultDerivedStateProvider(storageServiceProvider);
this.derivedStateProvider = new DefaultDerivedStateProvider();
this.stateProvider = new DefaultStateProvider(
this.activeUserStateProvider,

View File

@ -157,7 +157,7 @@ export class Main {
activeUserStateProvider,
singleUserStateProvider,
globalStateProvider,
new DefaultDerivedStateProvider(storageServiceProvider),
new DefaultDerivedStateProvider(),
);
this.environmentService = new DefaultEnvironmentService(stateProvider, accountService);

View File

@ -1,5 +1,9 @@
<bit-layout variant="secondary">
<nav slot="sidebar" *ngIf="organization$ | async as organization">
<nav
slot="sidebar"
*ngIf="organization$ | async as organization"
class="tw-flex tw-flex-col tw-h-full"
>
<a routerLink="." class="tw-m-5 tw-mt-7 tw-block" [appA11yTitle]="'adminConsole' | i18n">
<bit-icon [icon]="logo"></bit-icon>
</a>
@ -106,6 +110,8 @@
></bit-nav-item>
</bit-nav-group>
<navigation-product-switcher class="tw-mt-auto"></navigation-product-switcher>
<app-toggle-width></app-toggle-width>
</nav>

View File

@ -25,6 +25,7 @@ import { BannerModule, IconModule, LayoutComponent, NavigationModule } from "@bi
import { PaymentMethodWarningsModule } from "../../../billing/shared";
import { OrgSwitcherComponent } from "../../../layouts/org-switcher/org-switcher.component";
import { ProductSwitcherModule } from "../../../layouts/product-switcher/product-switcher.module";
import { ToggleWidthComponent } from "../../../layouts/toggle-width.component";
import { AdminConsoleLogo } from "../../icons/admin-console-logo";
@ -43,6 +44,7 @@ import { AdminConsoleLogo } from "../../icons/admin-console-logo";
BannerModule,
PaymentMethodWarningsModule,
ToggleWidthComponent,
ProductSwitcherModule,
],
})
export class OrganizationLayoutComponent implements OnInit, OnDestroy {

View File

@ -0,0 +1,39 @@
<div class="tw-mt-auto">
<bit-nav-item
*ngFor="let product of accessibleProducts$ | async"
[icon]="product.icon"
[text]="product.name"
[route]="product.appRoute"
[hideActiveStyles]="true"
class="tw-group"
>
<ng-container slot="end">
<span class="tw-text-xxs tw-hidden group-hover:tw-block group-focus:tw-block">
{{ "switch" | i18n }}
</span>
</ng-container>
</bit-nav-item>
<ng-container *ngIf="(moreProducts$ | async) ?? [] as moreProducts">
<section
*ngIf="moreProducts.length > 0"
class="tw-mt-2 tw-flex tw-w-full tw-flex-col tw-gap-2 tw-border-0 tw-border-t tw-border-solid tw-border-t-text-alt2"
>
<span class="tw-text-xs !tw-text-alt2 tw-p-2 tw-pb-0">{{ "moreFromBitwarden" | i18n }}</span>
<a
*ngFor="let more of moreProducts"
[href]="more.marketingRoute"
target="_blank"
rel="noreferrer"
class="tw-flex tw-py-2 tw-px-4 tw-font-semibold !tw-text-alt2 !tw-no-underline hover:tw-bg-primary-300/60 [&>:not(.bwi)]:hover:tw-underline"
>
<i class="bwi bwi-fw {{ more.icon }} tw-mt-1"></i>
<div>
{{ more.otherProductOverrides?.name ?? more.name }}
<div *ngIf="more.otherProductOverrides?.supportingText" class="tw-text-xs tw-font-normal">
{{ more.otherProductOverrides.supportingText }}
</div>
</div>
</a>
</section>
</ng-container>
</div>

View File

@ -0,0 +1,170 @@
import { ComponentFixture, TestBed } from "@angular/core/testing";
import { ActivatedRoute, RouterModule } from "@angular/router";
import { mock, MockProxy } from "jest-mock-extended";
import { BehaviorSubject } from "rxjs";
import { I18nPipe } from "@bitwarden/angular/platform/pipes/i18n.pipe";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { BitIconButtonComponent } from "@bitwarden/components/src/icon-button/icon-button.component";
import { NavItemComponent } from "@bitwarden/components/src/navigation/nav-item.component";
import { ProductSwitcherItem, ProductSwitcherService } from "../shared/product-switcher.service";
import { NavigationProductSwitcherComponent } from "./navigation-switcher.component";
describe("NavigationProductSwitcherComponent", () => {
let fixture: ComponentFixture<NavigationProductSwitcherComponent>;
let productSwitcherService: MockProxy<ProductSwitcherService>;
const mockProducts$ = new BehaviorSubject<{
bento: ProductSwitcherItem[];
other: ProductSwitcherItem[];
}>({
bento: [],
other: [],
});
beforeEach(async () => {
productSwitcherService = mock<ProductSwitcherService>();
productSwitcherService.products$ = mockProducts$;
mockProducts$.next({ bento: [], other: [] });
await TestBed.configureTestingModule({
imports: [RouterModule],
declarations: [
NavigationProductSwitcherComponent,
NavItemComponent,
BitIconButtonComponent,
I18nPipe,
],
providers: [
{ provide: ProductSwitcherService, useValue: productSwitcherService },
{
provide: I18nService,
useValue: mock<I18nService>(),
},
{
provide: ActivatedRoute,
useValue: mock<ActivatedRoute>(),
},
],
}).compileComponents();
});
beforeEach(() => {
fixture = TestBed.createComponent(NavigationProductSwitcherComponent);
fixture.detectChanges();
});
describe("other products", () => {
it("links to `marketingRoute`", () => {
mockProducts$.next({
bento: [],
other: [
{
isActive: false,
name: "Other Product",
icon: "bwi-lock",
marketingRoute: "https://www.example.com/",
},
],
});
fixture.detectChanges();
const link = fixture.nativeElement.querySelector("a");
expect(link.getAttribute("href")).toBe("https://www.example.com/");
});
it("uses `otherProductOverrides` when available", () => {
mockProducts$.next({
bento: [],
other: [
{
isActive: false,
name: "Other Product",
icon: "bwi-lock",
marketingRoute: "https://www.example.com/",
otherProductOverrides: { name: "Alternate name" },
},
],
});
fixture.detectChanges();
expect(fixture.nativeElement.querySelector("a").textContent.trim()).toBe("Alternate name");
mockProducts$.next({
bento: [],
other: [
{
isActive: false,
name: "Other Product",
icon: "bwi-lock",
marketingRoute: "https://www.example.com/",
otherProductOverrides: { name: "Alternate name", supportingText: "Supporting Text" },
},
],
});
fixture.detectChanges();
expect(fixture.nativeElement.querySelector("a").textContent.trim().replace(/\s+/g, " ")).toBe(
"Alternate name Supporting Text",
);
});
});
describe("available products", () => {
it("does not show active products", () => {
mockProducts$.next({
bento: [
{ isActive: true, name: "Password Manager", icon: "bwi-lock", appRoute: "/vault" },
{ isActive: false, name: "Secret Manager", icon: "bwi-lock", appRoute: "/sm" },
],
other: [],
});
fixture.detectChanges();
const links = fixture.nativeElement.querySelectorAll("a");
expect(links.length).toBe(1);
expect(links[0].textContent).toContain("Secret Manager");
});
it("shows inactive products", () => {
mockProducts$.next({
bento: [
{ isActive: false, name: "Password Manager", icon: "bwi-lock", appRoute: "/vault" },
{ isActive: false, name: "Secret Manager", icon: "bwi-lock", appRoute: "/sm" },
],
other: [],
});
fixture.detectChanges();
const links = fixture.nativeElement.querySelectorAll("a");
expect(links.length).toBe(2);
expect(links[0].textContent).toContain("Password Manager");
expect(links[1].textContent).toContain("Secret Manager");
});
});
it("links to `appRoute`", () => {
mockProducts$.next({
bento: [{ isActive: false, name: "Password Manager", icon: "bwi-lock", appRoute: "/vault" }],
other: [],
});
fixture.detectChanges();
const link = fixture.nativeElement.querySelector("a");
expect(link.getAttribute("href")).toBe("/vault");
});
});

View File

@ -0,0 +1,22 @@
import { Component } from "@angular/core";
import { map, Observable } from "rxjs";
import { ProductSwitcherItem, ProductSwitcherService } from "../shared/product-switcher.service";
@Component({
selector: "navigation-product-switcher",
templateUrl: "./navigation-switcher.component.html",
})
export class NavigationProductSwitcherComponent {
constructor(private productSwitcherService: ProductSwitcherService) {}
protected readonly accessibleProducts$: Observable<ProductSwitcherItem[]> =
this.productSwitcherService.products$.pipe(
map((products) => (products.bento ?? []).filter((item) => !item.isActive)),
);
protected readonly moreProducts$: Observable<ProductSwitcherItem[]> =
this.productSwitcherService.products$.pipe(
map((products) => (products.other ?? []).filter((item) => !item.isActive)),
);
}

View File

@ -0,0 +1,172 @@
import { Component, Directive, importProvidersFrom, Input } from "@angular/core";
import { RouterModule } from "@angular/router";
import { applicationConfig, Meta, moduleMetadata, StoryObj } from "@storybook/angular";
import { BehaviorSubject, firstValueFrom } from "rxjs";
import { I18nPipe } from "@bitwarden/angular/platform/pipes/i18n.pipe";
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
import { ProviderService } from "@bitwarden/common/admin-console/abstractions/provider.service";
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
import { Provider } from "@bitwarden/common/admin-console/models/domain/provider";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { LayoutComponent, NavigationModule } from "@bitwarden/components";
import { I18nMockService } from "@bitwarden/components/src/utils/i18n-mock.service";
import { ProductSwitcherService } from "../shared/product-switcher.service";
import { NavigationProductSwitcherComponent } from "./navigation-switcher.component";
@Directive({
selector: "[mockOrgs]",
})
class MockOrganizationService implements Partial<OrganizationService> {
private static _orgs = new BehaviorSubject<Organization[]>([]);
organizations$ = MockOrganizationService._orgs; // eslint-disable-line rxjs/no-exposed-subjects
@Input()
set mockOrgs(orgs: Organization[]) {
this.organizations$.next(orgs);
}
}
@Directive({
selector: "[mockProviders]",
})
class MockProviderService implements Partial<ProviderService> {
private static _providers = new BehaviorSubject<Provider[]>([]);
async getAll() {
return await firstValueFrom(MockProviderService._providers);
}
@Input()
set mockProviders(providers: Provider[]) {
MockProviderService._providers.next(providers);
}
}
@Component({
selector: "story-layout",
template: `<ng-content></ng-content>`,
})
class StoryLayoutComponent {}
@Component({
selector: "story-content",
template: ``,
})
class StoryContentComponent {}
const translations: Record<string, string> = {
moreFromBitwarden: "More from Bitwarden",
secureYourInfrastructure: "Secure your infrastructure",
protectYourFamilyOrBusiness: "Protect your family or business",
switch: "Switch",
skipToContent: "Skip to content",
};
export default {
title: "Web/Navigation Product Switcher",
decorators: [
moduleMetadata({
declarations: [
NavigationProductSwitcherComponent,
MockOrganizationService,
MockProviderService,
StoryLayoutComponent,
StoryContentComponent,
I18nPipe,
],
imports: [NavigationModule, RouterModule, LayoutComponent],
providers: [
{ provide: OrganizationService, useClass: MockOrganizationService },
{ provide: ProviderService, useClass: MockProviderService },
ProductSwitcherService,
{
provide: I18nPipe,
useFactory: () => ({
transform: (key: string) => translations[key],
}),
},
{
provide: I18nService,
useFactory: () => {
return new I18nMockService(translations);
},
},
],
}),
applicationConfig({
providers: [
importProvidersFrom(
RouterModule.forRoot([
{
path: "",
component: StoryLayoutComponent,
children: [
{
path: "**",
component: StoryContentComponent,
},
],
},
]),
),
],
}),
],
} as Meta<NavigationProductSwitcherComponent>;
type Story = StoryObj<
NavigationProductSwitcherComponent & MockProviderService & MockOrganizationService
>;
const Template: Story = {
render: (args) => ({
props: args,
template: `
<router-outlet [mockOrgs]="mockOrgs" [mockProviders]="mockProviders"></router-outlet>
<div class="tw-bg-background-alt3 tw-w-60">
<navigation-product-switcher></navigation-product-switcher>
</div>
`,
}),
};
export const OnlyPM: Story = {
...Template,
args: {
mockOrgs: [],
mockProviders: [],
},
};
export const SMAvailable: Story = {
...Template,
args: {
mockOrgs: [
{ id: "org-a", canManageUsers: false, canAccessSecretsManager: true, enabled: true },
] as Organization[],
mockProviders: [],
},
};
export const SMAndACAvailable: Story = {
...Template,
args: {
mockOrgs: [
{ id: "org-a", canManageUsers: true, canAccessSecretsManager: true, enabled: true },
] as Organization[],
mockProviders: [],
},
};
export const WithAllOptions: Story = {
...Template,
args: {
mockOrgs: [
{ id: "org-a", canManageUsers: true, canAccessSecretsManager: true, enabled: true },
] as Organization[],
mockProviders: [{ id: "provider-a" }] as Provider[],
},
};

View File

@ -1,40 +1,8 @@
import { Component, ViewChild } from "@angular/core";
import { ActivatedRoute, Router } from "@angular/router";
import { combineLatest, concatMap } from "rxjs";
import {
canAccessOrgAdmin,
OrganizationService,
} from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
import { ProviderService } from "@bitwarden/common/admin-console/abstractions/provider.service";
import { MenuComponent } from "@bitwarden/components";
type ProductSwitcherItem = {
/**
* Displayed name
*/
name: string;
/**
* Displayed icon
*/
icon: string;
/**
* Route for items in the `bentoProducts$` section
*/
appRoute?: string | any[];
/**
* Route for items in the `otherProducts$` section
*/
marketingRoute?: string | any[];
/**
* Used to apply css styles to show when a button is selected
*/
isActive?: boolean;
};
import { ProductSwitcherService } from "./shared/product-switcher.service";
@Component({
selector: "product-switcher-content",
@ -44,99 +12,7 @@ export class ProductSwitcherContentComponent {
@ViewChild("menu")
menu: MenuComponent;
protected products$ = combineLatest([
this.organizationService.organizations$,
this.route.paramMap,
]).pipe(
concatMap(async ([orgs, paramMap]) => {
const routeOrg = orgs.find((o) => o.id === paramMap.get("organizationId"));
// If the active route org doesn't have access to SM, find the first org that does.
const smOrg =
routeOrg?.canAccessSecretsManager && routeOrg?.enabled == true
? routeOrg
: orgs.find((o) => o.canAccessSecretsManager && o.enabled == true);
constructor(private productSwitcherService: ProductSwitcherService) {}
// If the active route org doesn't have access to AC, find the first org that does.
const acOrg =
routeOrg != null && canAccessOrgAdmin(routeOrg)
? routeOrg
: orgs.find((o) => canAccessOrgAdmin(o));
// TODO: This should be migrated to an Observable provided by the provider service and moved to the combineLatest above. See AC-2092.
const providers = await this.providerService.getAll();
/**
* We can update this to the "satisfies" type upon upgrading to TypeScript 4.9
* https://devblogs.microsoft.com/typescript/announcing-typescript-4-9/#satisfies
*/
const products: Record<"pm" | "sm" | "ac" | "provider" | "orgs", ProductSwitcherItem> = {
pm: {
name: "Password Manager",
icon: "bwi-lock",
appRoute: "/vault",
marketingRoute: "https://bitwarden.com/products/personal/",
isActive:
!this.router.url.includes("/sm/") &&
!this.router.url.includes("/organizations/") &&
!this.router.url.includes("/providers/"),
},
sm: {
name: "Secrets Manager",
icon: "bwi-cli",
appRoute: ["/sm", smOrg?.id],
marketingRoute: "https://bitwarden.com/products/secrets-manager/",
isActive: this.router.url.includes("/sm/"),
},
ac: {
name: "Admin Console",
icon: "bwi-business",
appRoute: ["/organizations", acOrg?.id],
marketingRoute: "https://bitwarden.com/products/business/",
isActive: this.router.url.includes("/organizations/"),
},
provider: {
name: "Provider Portal",
icon: "bwi-provider",
appRoute: ["/providers", providers[0]?.id],
isActive: this.router.url.includes("/providers/"),
},
orgs: {
name: "Organizations",
icon: "bwi-business",
marketingRoute: "https://bitwarden.com/products/business/",
},
};
const bento: ProductSwitcherItem[] = [products.pm];
const other: ProductSwitcherItem[] = [];
if (smOrg) {
bento.push(products.sm);
} else {
other.push(products.sm);
}
if (acOrg) {
bento.push(products.ac);
} else {
other.push(products.orgs);
}
if (providers.length > 0) {
bento.push(products.provider);
}
return {
bento,
other,
};
}),
);
constructor(
private organizationService: OrganizationService,
private providerService: ProviderService,
private route: ActivatedRoute,
private router: Router,
) {}
protected readonly products$ = this.productSwitcherService.products$;
}

View File

@ -3,16 +3,22 @@ import { NgModule } from "@angular/core";
import { RouterModule } from "@angular/router";
import { I18nPipe } from "@bitwarden/angular/platform/pipes/i18n.pipe";
import { NavigationModule } from "@bitwarden/components";
import { SharedModule } from "../../shared";
import { NavigationProductSwitcherComponent } from "./navigation-switcher/navigation-switcher.component";
import { ProductSwitcherContentComponent } from "./product-switcher-content.component";
import { ProductSwitcherComponent } from "./product-switcher.component";
@NgModule({
imports: [SharedModule, A11yModule, RouterModule],
declarations: [ProductSwitcherComponent, ProductSwitcherContentComponent],
exports: [ProductSwitcherComponent],
imports: [SharedModule, A11yModule, RouterModule, NavigationModule],
declarations: [
ProductSwitcherComponent,
ProductSwitcherContentComponent,
NavigationProductSwitcherComponent,
],
exports: [ProductSwitcherComponent, NavigationProductSwitcherComponent],
providers: [I18nPipe],
})
export class ProductSwitcherModule {}

View File

@ -1,6 +1,6 @@
import { Component, Directive, importProvidersFrom, Input } from "@angular/core";
import { RouterModule } from "@angular/router";
import { applicationConfig, Meta, moduleMetadata, Story } from "@storybook/angular";
import { applicationConfig, Meta, moduleMetadata, StoryObj } from "@storybook/angular";
import { BehaviorSubject, firstValueFrom } from "rxjs";
import { JslibModule } from "@bitwarden/angular/jslib.module";
@ -14,6 +14,7 @@ import { I18nMockService } from "@bitwarden/components/src/utils/i18n-mock.servi
import { ProductSwitcherContentComponent } from "./product-switcher-content.component";
import { ProductSwitcherComponent } from "./product-switcher.component";
import { ProductSwitcherService } from "./shared/product-switcher.service";
@Directive({
selector: "[mockOrgs]",
@ -74,12 +75,15 @@ export default {
MockOrganizationService,
{ provide: ProviderService, useClass: MockProviderService },
MockProviderService,
ProductSwitcherService,
{
provide: I18nService,
useFactory: () => {
return new I18nMockService({
moreFromBitwarden: "More from Bitwarden",
switchProducts: "Switch Products",
secureYourInfrastructure: "Secure your infrastructure",
protectYourFamilyOrBusiness: "Protect your family or business",
});
},
},
@ -120,11 +124,14 @@ export default {
],
}),
],
} as Meta;
} as Meta<ProductSwitcherComponent>;
const Template: Story = (args) => ({
props: args,
template: `
type Story = StoryObj<ProductSwitcherComponent & MockProviderService & MockOrganizationService>;
const Template: Story = {
render: (args) => ({
props: args,
template: `
<router-outlet [mockOrgs]="mockOrgs" [mockProviders]="mockProviders"></router-outlet>
<div class="tw-flex tw-gap-[200px]">
<div>
@ -142,28 +149,42 @@ const Template: Story = (args) => ({
</div>
</div>
`,
});
export const OnlyPM = Template.bind({});
OnlyPM.args = {
mockOrgs: [],
mockProviders: [],
}),
};
export const OnlyPM: Story = {
...Template,
args: {
mockOrgs: [],
mockProviders: [],
},
};
export const WithSM = Template.bind({});
WithSM.args = {
mockOrgs: [{ id: "org-a", canManageUsers: false, canAccessSecretsManager: true, enabled: true }],
mockProviders: [],
export const WithSM: Story = {
...Template,
args: {
mockOrgs: [
{ id: "org-a", canManageUsers: false, canAccessSecretsManager: true, enabled: true },
] as Organization[],
mockProviders: [],
},
};
export const WithSMAndAC = Template.bind({});
WithSMAndAC.args = {
mockOrgs: [{ id: "org-a", canManageUsers: true, canAccessSecretsManager: true, enabled: true }],
mockProviders: [],
export const WithSMAndAC: Story = {
...Template,
args: {
mockOrgs: [
{ id: "org-a", canManageUsers: true, canAccessSecretsManager: true, enabled: true },
] as Organization[],
mockProviders: [],
},
};
export const WithAllOptions = Template.bind({});
WithAllOptions.args = {
mockOrgs: [{ id: "org-a", canManageUsers: true, canAccessSecretsManager: true, enabled: true }],
mockProviders: [{ id: "provider-a" }],
export const WithAllOptions: Story = {
...Template,
args: {
mockOrgs: [
{ id: "org-a", canManageUsers: true, canAccessSecretsManager: true, enabled: true },
] as Organization[],
mockProviders: [{ id: "provider-a" }] as Provider[],
},
};

View File

@ -0,0 +1,186 @@
import { TestBed } from "@angular/core/testing";
import { ActivatedRoute, Router, convertToParamMap } from "@angular/router";
import { mock, MockProxy } from "jest-mock-extended";
import { firstValueFrom, of } from "rxjs";
import { I18nPipe } from "@bitwarden/angular/platform/pipes/i18n.pipe";
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
import { ProviderService } from "@bitwarden/common/admin-console/abstractions/provider.service";
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
import { Provider } from "@bitwarden/common/admin-console/models/domain/provider";
import { ProductSwitcherService } from "./product-switcher.service";
describe("ProductSwitcherService", () => {
let service: ProductSwitcherService;
let router: MockProxy<Router>;
let organizationService: MockProxy<OrganizationService>;
let providerService: MockProxy<ProviderService>;
let activeRouteParams = convertToParamMap({ organizationId: "1234" });
const setRouterURL = (path: string) => {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-expect-error
// `url` is a read-only property in practice but is mocked here for testing purposes
router.url = path;
};
beforeEach(() => {
router = mock<Router>();
organizationService = mock<OrganizationService>();
providerService = mock<ProviderService>();
setRouterURL("/");
organizationService.organizations$ = of([{}] as Organization[]);
providerService.getAll.mockResolvedValue([] as Provider[]);
TestBed.configureTestingModule({
providers: [
{ provide: Router, useValue: router },
{ provide: OrganizationService, useValue: organizationService },
{ provide: ProviderService, useValue: providerService },
{
provide: ActivatedRoute,
useValue: {
paramMap: of(activeRouteParams),
},
},
{
provide: I18nPipe,
useValue: {
transform: (key: string) => key,
},
},
],
});
});
describe("product separation", () => {
describe("Password Manager", () => {
it("is always included", async () => {
service = TestBed.inject(ProductSwitcherService);
const products = await firstValueFrom(service.products$);
expect(products.bento.find((p) => p.name === "Password Manager")).toBeDefined();
});
});
describe("Secret Manager", () => {
it("is included in other when there are no organizations with SM", async () => {
service = TestBed.inject(ProductSwitcherService);
const products = await firstValueFrom(service.products$);
expect(products.other.find((p) => p.name === "Secrets Manager")).toBeDefined();
});
it("is included in bento when there is an organization with SM", async () => {
organizationService.organizations$ = of([
{ id: "1234", canAccessSecretsManager: true, enabled: true },
] as Organization[]);
service = TestBed.inject(ProductSwitcherService);
const products = await firstValueFrom(service.products$);
expect(products.bento.find((p) => p.name === "Secrets Manager")).toBeDefined();
});
});
describe("Admin/Organizations", () => {
it("includes Organizations in other when there are organizations", async () => {
service = TestBed.inject(ProductSwitcherService);
const products = await firstValueFrom(service.products$);
expect(products.other.find((p) => p.name === "Organizations")).toBeDefined();
expect(products.bento.find((p) => p.name === "Admin Console")).toBeUndefined();
});
it("includes Admin Console in bento when a user has access to it", async () => {
organizationService.organizations$ = of([{ id: "1234", isOwner: true }] as Organization[]);
service = TestBed.inject(ProductSwitcherService);
const products = await firstValueFrom(service.products$);
expect(products.bento.find((p) => p.name === "Admin Console")).toBeDefined();
expect(products.other.find((p) => p.name === "Organizations")).toBeUndefined();
});
});
describe("Provider Portal", () => {
it("is not included when there are no providers", async () => {
service = TestBed.inject(ProductSwitcherService);
const products = await firstValueFrom(service.products$);
expect(products.bento.find((p) => p.name === "Provider Portal")).toBeUndefined();
expect(products.other.find((p) => p.name === "Provider Portal")).toBeUndefined();
});
it("is included when there are providers", async () => {
providerService.getAll.mockResolvedValue([{ id: "67899" }] as Provider[]);
service = TestBed.inject(ProductSwitcherService);
const products = await firstValueFrom(service.products$);
expect(products.bento.find((p) => p.name === "Provider Portal")).toBeDefined();
});
});
});
describe("active product", () => {
it("marks Password Manager as active", async () => {
service = TestBed.inject(ProductSwitcherService);
const products = await firstValueFrom(service.products$);
const { isActive } = products.bento.find((p) => p.name === "Password Manager");
expect(isActive).toBe(true);
});
it("marks Secret Manager as active", async () => {
setRouterURL("/sm/");
service = TestBed.inject(ProductSwitcherService);
const products = await firstValueFrom(service.products$);
const { isActive } = products.other.find((p) => p.name === "Secrets Manager");
expect(isActive).toBe(true);
});
it("marks Admin Console as active", async () => {
organizationService.organizations$ = of([{ id: "1234", isOwner: true }] as Organization[]);
activeRouteParams = convertToParamMap({ organizationId: "1" });
setRouterURL("/organizations/");
service = TestBed.inject(ProductSwitcherService);
const products = await firstValueFrom(service.products$);
const { isActive } = products.bento.find((p) => p.name === "Admin Console");
expect(isActive).toBe(true);
});
it("marks Provider Portal as active", async () => {
providerService.getAll.mockResolvedValue([{ id: "67899" }] as Provider[]);
setRouterURL("/providers/");
service = TestBed.inject(ProductSwitcherService);
const products = await firstValueFrom(service.products$);
const { isActive } = products.bento.find((p) => p.name === "Provider Portal");
expect(isActive).toBe(true);
});
});
});

View File

@ -0,0 +1,154 @@
import { Injectable } from "@angular/core";
import { ActivatedRoute, Router } from "@angular/router";
import { combineLatest, concatMap, Observable } from "rxjs";
import { I18nPipe } from "@bitwarden/angular/platform/pipes/i18n.pipe";
import {
canAccessOrgAdmin,
OrganizationService,
} from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
import { ProviderService } from "@bitwarden/common/admin-console/abstractions/provider.service";
export type ProductSwitcherItem = {
/**
* Displayed name
*/
name: string;
/**
* Displayed icon
*/
icon: string;
/**
* Route for items in the `bentoProducts$` section
*/
appRoute?: string | any[];
/**
* Route for items in the `otherProducts$` section
*/
marketingRoute?: string | any[];
/**
* Used to apply css styles to show when a button is selected
*/
isActive?: boolean;
/**
* A product switcher item can be shown in the left navigation menu.
* When shown under the "other" section the content can be overridden.
*/
otherProductOverrides?: {
/** Alternative navigation menu name */
name?: string;
/** Supporting text that is shown when the product is rendered in the "other" section */
supportingText?: string;
};
};
@Injectable({
providedIn: "root",
})
export class ProductSwitcherService {
constructor(
private organizationService: OrganizationService,
private providerService: ProviderService,
private route: ActivatedRoute,
private router: Router,
private i18n: I18nPipe,
) {}
products$: Observable<{
bento: ProductSwitcherItem[];
other: ProductSwitcherItem[];
}> = combineLatest([this.organizationService.organizations$, this.route.paramMap]).pipe(
concatMap(async ([orgs, paramMap]) => {
const routeOrg = orgs.find((o) => o.id === paramMap.get("organizationId"));
// If the active route org doesn't have access to SM, find the first org that does.
const smOrg =
routeOrg?.canAccessSecretsManager && routeOrg?.enabled == true
? routeOrg
: orgs.find((o) => o.canAccessSecretsManager && o.enabled == true);
// If the active route org doesn't have access to AC, find the first org that does.
const acOrg =
routeOrg != null && canAccessOrgAdmin(routeOrg)
? routeOrg
: orgs.find((o) => canAccessOrgAdmin(o));
// TODO: This should be migrated to an Observable provided by the provider service and moved to the combineLatest above. See AC-2092.
const providers = await this.providerService.getAll();
const products = {
pm: {
name: "Password Manager",
icon: "bwi-lock",
appRoute: "/vault",
marketingRoute: "https://bitwarden.com/products/personal/",
isActive:
!this.router.url.includes("/sm/") &&
!this.router.url.includes("/organizations/") &&
!this.router.url.includes("/providers/"),
},
sm: {
name: "Secrets Manager",
icon: "bwi-cli",
appRoute: ["/sm", smOrg?.id],
marketingRoute: "https://bitwarden.com/products/secrets-manager/",
isActive: this.router.url.includes("/sm/"),
otherProductOverrides: {
supportingText: this.i18n.transform("secureYourInfrastructure"),
},
},
ac: {
name: "Admin Console",
icon: "bwi-business",
appRoute: ["/organizations", acOrg?.id],
marketingRoute: "https://bitwarden.com/products/business/",
isActive: this.router.url.includes("/organizations/"),
},
provider: {
name: "Provider Portal",
icon: "bwi-provider",
appRoute: ["/providers", providers[0]?.id],
isActive: this.router.url.includes("/providers/"),
},
orgs: {
name: "Organizations",
icon: "bwi-business",
marketingRoute: "https://bitwarden.com/products/business/",
otherProductOverrides: {
name: "Share your passwords",
supportingText: this.i18n.transform("protectYourFamilyOrBusiness"),
},
},
} satisfies Record<string, ProductSwitcherItem>;
const bento: ProductSwitcherItem[] = [products.pm];
const other: ProductSwitcherItem[] = [];
if (smOrg) {
bento.push(products.sm);
} else {
other.push(products.sm);
}
if (acOrg) {
bento.push(products.ac);
} else {
other.push(products.orgs);
}
if (providers.length > 0) {
bento.push(products.provider);
}
return {
bento,
other,
};
}),
);
}

View File

@ -10,7 +10,6 @@ import { NavigationModule } from "@bitwarden/components";
text="Toggle Width"
icon="bwi-bug"
*ngIf="isDev"
class="tw-absolute tw-bottom-0 tw-w-full"
(click)="toggleWidth()"
></bit-nav-item>`,
standalone: true,

View File

@ -1,5 +1,5 @@
<bit-layout>
<nav slot="sidebar">
<nav slot="sidebar" class="tw-flex tw-flex-col tw-h-full">
<a routerLink="." class="tw-m-5 tw-mt-7 tw-block" [appA11yTitle]="'passwordManager' | i18n">
<bit-icon [icon]="logo"></bit-icon>
</a>
@ -33,6 +33,8 @@
></bit-nav-item>
</bit-nav-group>
<navigation-product-switcher class="tw-mt-auto"></navigation-product-switcher>
<app-toggle-width></app-toggle-width>
</nav>
<app-payment-method-warnings

View File

@ -16,6 +16,7 @@ import { IconModule, LayoutComponent, NavigationModule } from "@bitwarden/compon
import { PaymentMethodWarningsModule } from "../billing/shared";
import { PasswordManagerLogo } from "./password-manager-logo";
import { ProductSwitcherModule } from "./product-switcher/product-switcher.module";
import { ToggleWidthComponent } from "./toggle-width.component";
@Component({
@ -31,6 +32,7 @@ import { ToggleWidthComponent } from "./toggle-width.component";
NavigationModule,
PaymentMethodWarningsModule,
ToggleWidthComponent,
ProductSwitcherModule,
],
})
export class UserLayoutComponent implements OnInit {

View File

@ -8049,5 +8049,14 @@
},
"collectionItemSelect": {
"message": "Select collection item"
},
"secureYourInfrastructure": {
"message": "Secure your infrastructure"
},
"protectYourFamilyOrBusiness": {
"message": "Protect your family or business"
},
"switch":{
"message": "Switch"
}
}

View File

@ -1,5 +1,5 @@
<bit-layout variant="secondary">
<nav slot="sidebar" *ngIf="provider">
<nav slot="sidebar" *ngIf="provider" class="tw-flex tw-flex-col tw-h-full">
<a routerLink="." class="tw-m-5 tw-mt-7 tw-block" [appA11yTitle]="'providerPortal' | i18n">
<bit-icon [icon]="logo"></bit-icon>
</a>
@ -33,6 +33,8 @@
*ngIf="showSettingsTab"
></bit-nav-item>
<navigation-product-switcher class="tw-mt-auto"></navigation-product-switcher>
<app-toggle-width></app-toggle-width>
</nav>
<app-payment-method-warnings

View File

@ -11,6 +11,7 @@ import { ConfigService } from "@bitwarden/common/platform/abstractions/config/co
import { IconModule, LayoutComponent, NavigationModule } from "@bitwarden/components";
import { ProviderPortalLogo } from "@bitwarden/web-vault/app/admin-console/icons/provider-portal-logo";
import { PaymentMethodWarningsModule } from "@bitwarden/web-vault/app/billing/shared";
import { ProductSwitcherModule } from "@bitwarden/web-vault/app/layouts/product-switcher/product-switcher.module";
import { ToggleWidthComponent } from "@bitwarden/web-vault/app/layouts/toggle-width.component";
@Component({
@ -26,6 +27,7 @@ import { ToggleWidthComponent } from "@bitwarden/web-vault/app/layouts/toggle-wi
NavigationModule,
PaymentMethodWarningsModule,
ToggleWidthComponent,
ProductSwitcherModule,
],
})
// eslint-disable-next-line rxjs-angular/prefer-takeuntil

View File

@ -2,6 +2,7 @@ import { NgModule } from "@angular/core";
import { LayoutComponent as BitLayoutComponent, NavigationModule } from "@bitwarden/components";
import { OrgSwitcherComponent } from "@bitwarden/web-vault/app/layouts/org-switcher/org-switcher.component";
import { ProductSwitcherModule } from "@bitwarden/web-vault/app/layouts/product-switcher/product-switcher.module";
import { ToggleWidthComponent } from "@bitwarden/web-vault/app/layouts/toggle-width.component";
import { SharedModule } from "@bitwarden/web-vault/app/shared/shared.module";
@ -15,6 +16,7 @@ import { NavigationComponent } from "./navigation.component";
BitLayoutComponent,
OrgSwitcherComponent,
ToggleWidthComponent,
ProductSwitcherModule,
],
declarations: [LayoutComponent, NavigationComponent],
})

View File

@ -1,4 +1,4 @@
<nav>
<nav class="tw-flex tw-flex-col tw-h-full">
<a routerLink="." class="tw-m-5 tw-mt-7 tw-block">
<bit-icon [icon]="logo"></bit-icon>
</a>
@ -48,5 +48,7 @@
></bit-nav-item>
</bit-nav-group>
<navigation-product-switcher class="tw-mt-auto"></navigation-product-switcher>
<app-toggle-width></app-toggle-width>
</nav>

View File

@ -1047,7 +1047,7 @@ const safeProviders: SafeProvider[] = [
safeProvider({
provide: DerivedStateProvider,
useClass: DefaultDerivedStateProvider,
deps: [StorageServiceProvider],
deps: [],
}),
safeProvider({
provide: StateProvider,

View File

@ -249,11 +249,11 @@ export class FakeDerivedStateProvider implements DerivedStateProvider {
deriveDefinition: DeriveDefinition<TFrom, TTo, TDeps>,
dependencies: TDeps,
): DerivedState<TTo> {
let result = this.states.get(deriveDefinition.buildCacheKey("memory")) as DerivedState<TTo>;
let result = this.states.get(deriveDefinition.buildCacheKey()) as DerivedState<TTo>;
if (result == null) {
result = new FakeDerivedState(parentState$, deriveDefinition, dependencies);
this.states.set(deriveDefinition.buildCacheKey("memory"), result);
this.states.set(deriveDefinition.buildCacheKey(), result);
}
return result;
}

View File

@ -5,3 +5,4 @@ export * from "./fake-state-provider";
export * from "./fake-state";
export * from "./fake-account-service";
export * from "./fake-storage.service";
export * from "./observable-tracker";

View File

@ -16,9 +16,11 @@ export class ObservableTracker<T> {
/**
* Awaits the next emission from the observable, or throws if the timeout is exceeded
* @param msTimeout The maximum time to wait for another emission before throwing
* @returns The next emission from the observable
* @throws If the timeout is exceeded
*/
async expectEmission(msTimeout = 50) {
await firstValueFrom(
async expectEmission(msTimeout = 50): Promise<T> {
return await firstValueFrom(
this.observable.pipe(
timeout({
first: msTimeout,

View File

@ -10,7 +10,7 @@ import { CryptoService } from "../abstractions/crypto.service";
import { EncryptService } from "../abstractions/encrypt.service";
import { I18nService } from "../abstractions/i18n.service";
const nodeURL = typeof window === "undefined" ? require("url") : null;
const nodeURL = typeof self === "undefined" ? require("url") : null;
declare global {
/* eslint-disable-next-line no-var */

View File

@ -171,8 +171,8 @@ export class DeriveDefinition<TFrom, TTo, TDeps extends DerivedStateDependencies
return this.options.clearOnCleanup ?? true;
}
buildCacheKey(location: string): string {
return `derived_${location}_${this.stateDefinition.name}_${this.uniqueDerivationName}`;
buildCacheKey(): string {
return `derived_${this.stateDefinition.name}_${this.uniqueDerivationName}`;
}
/**

View File

@ -1,11 +1,6 @@
import { Observable } from "rxjs";
import { DerivedStateDependencies } from "../../../types/state";
import {
AbstractStorageService,
ObservableStorageService,
} from "../../abstractions/storage.service";
import { StorageServiceProvider } from "../../services/storage-service.provider";
import { DeriveDefinition } from "../derive-definition";
import { DerivedState } from "../derived-state";
import { DerivedStateProvider } from "../derived-state.provider";
@ -15,18 +10,14 @@ import { DefaultDerivedState } from "./default-derived-state";
export class DefaultDerivedStateProvider implements DerivedStateProvider {
private cache: Record<string, DerivedState<unknown>> = {};
constructor(protected storageServiceProvider: StorageServiceProvider) {}
constructor() {}
get<TFrom, TTo, TDeps extends DerivedStateDependencies>(
parentState$: Observable<TFrom>,
deriveDefinition: DeriveDefinition<TFrom, TTo, TDeps>,
dependencies: TDeps,
): DerivedState<TTo> {
// TODO: we probably want to support optional normal memory storage for browser
const [location, storageService] = this.storageServiceProvider.get("memory", {
browser: "memory-large-object",
});
const cacheKey = deriveDefinition.buildCacheKey(location);
const cacheKey = deriveDefinition.buildCacheKey();
const existingDerivedState = this.cache[cacheKey];
if (existingDerivedState != null) {
// I have to cast out of the unknown generic but this should be safe if rules
@ -34,10 +25,7 @@ export class DefaultDerivedStateProvider implements DerivedStateProvider {
return existingDerivedState as DefaultDerivedState<TFrom, TTo, TDeps>;
}
const newDerivedState = this.buildDerivedState(parentState$, deriveDefinition, dependencies, [
location,
storageService,
]);
const newDerivedState = this.buildDerivedState(parentState$, deriveDefinition, dependencies);
this.cache[cacheKey] = newDerivedState;
return newDerivedState;
}
@ -46,13 +34,7 @@ export class DefaultDerivedStateProvider implements DerivedStateProvider {
parentState$: Observable<TFrom>,
deriveDefinition: DeriveDefinition<TFrom, TTo, TDeps>,
dependencies: TDeps,
storageLocation: [string, AbstractStorageService & ObservableStorageService],
): DerivedState<TTo> {
return new DefaultDerivedState<TFrom, TTo, TDeps>(
parentState$,
deriveDefinition,
storageLocation[1],
dependencies,
);
return new DefaultDerivedState<TFrom, TTo, TDeps>(parentState$, deriveDefinition, dependencies);
}
}

View File

@ -5,7 +5,6 @@
import { Subject, firstValueFrom } from "rxjs";
import { awaitAsync, trackEmissions } from "../../../../spec";
import { FakeStorageService } from "../../../../spec/fake-storage.service";
import { DeriveDefinition } from "../derive-definition";
import { StateDefinition } from "../state-definition";
@ -29,7 +28,6 @@ const deriveDefinition = new DeriveDefinition<string, Date, { date: Date }>(
describe("DefaultDerivedState", () => {
let parentState$: Subject<string>;
let memoryStorage: FakeStorageService;
let sut: DefaultDerivedState<string, Date, { date: Date }>;
const deps = {
date: new Date(),
@ -38,8 +36,7 @@ describe("DefaultDerivedState", () => {
beforeEach(() => {
callCount = 0;
parentState$ = new Subject();
memoryStorage = new FakeStorageService();
sut = new DefaultDerivedState(parentState$, deriveDefinition, memoryStorage, deps);
sut = new DefaultDerivedState(parentState$, deriveDefinition, deps);
});
afterEach(() => {
@ -66,71 +63,33 @@ describe("DefaultDerivedState", () => {
expect(callCount).toBe(1);
});
it("should store the derived state in memory", async () => {
const dateString = "2020-01-01";
trackEmissions(sut.state$);
parentState$.next(dateString);
await awaitAsync();
expect(memoryStorage.internalStore[deriveDefinition.storageKey]).toEqual(
derivedValue(new Date(dateString)),
);
const calls = memoryStorage.mock.save.mock.calls;
expect(calls.length).toBe(1);
expect(calls[0][0]).toBe(deriveDefinition.storageKey);
expect(calls[0][1]).toEqual(derivedValue(new Date(dateString)));
});
describe("forceValue", () => {
const initialParentValue = "2020-01-01";
const forced = new Date("2020-02-02");
let emissions: Date[];
describe("without observers", () => {
beforeEach(async () => {
parentState$.next(initialParentValue);
await awaitAsync();
});
it("should store the forced value", async () => {
await sut.forceValue(forced);
expect(memoryStorage.internalStore[deriveDefinition.storageKey]).toEqual(
derivedValue(forced),
);
});
beforeEach(async () => {
emissions = trackEmissions(sut.state$);
parentState$.next(initialParentValue);
await awaitAsync();
});
describe("with observers", () => {
beforeEach(async () => {
emissions = trackEmissions(sut.state$);
parentState$.next(initialParentValue);
await awaitAsync();
});
it("should force the value", async () => {
await sut.forceValue(forced);
expect(emissions).toEqual([new Date(initialParentValue), forced]);
});
it("should store the forced value", async () => {
await sut.forceValue(forced);
expect(memoryStorage.internalStore[deriveDefinition.storageKey]).toEqual(
derivedValue(forced),
);
});
it("should only force the value once", async () => {
await sut.forceValue(forced);
it("should force the value", async () => {
await sut.forceValue(forced);
expect(emissions).toEqual([new Date(initialParentValue), forced]);
});
parentState$.next(initialParentValue);
await awaitAsync();
it("should only force the value once", async () => {
await sut.forceValue(forced);
parentState$.next(initialParentValue);
await awaitAsync();
expect(emissions).toEqual([
new Date(initialParentValue),
forced,
new Date(initialParentValue),
]);
});
expect(emissions).toEqual([
new Date(initialParentValue),
forced,
new Date(initialParentValue),
]);
});
});
@ -148,42 +107,6 @@ describe("DefaultDerivedState", () => {
expect(parentState$.observed).toBe(false);
});
it("should clear state after cleanup", async () => {
const subscription = sut.state$.subscribe();
parentState$.next(newDate);
await awaitAsync();
expect(memoryStorage.internalStore[deriveDefinition.storageKey]).toEqual(
derivedValue(new Date(newDate)),
);
subscription.unsubscribe();
// Wait for cleanup
await awaitAsync(cleanupDelayMs * 2);
expect(memoryStorage.internalStore[deriveDefinition.storageKey]).toBeUndefined();
});
it("should not clear state after cleanup if clearOnCleanup is false", async () => {
deriveDefinition.options.clearOnCleanup = false;
const subscription = sut.state$.subscribe();
parentState$.next(newDate);
await awaitAsync();
expect(memoryStorage.internalStore[deriveDefinition.storageKey]).toEqual(
derivedValue(new Date(newDate)),
);
subscription.unsubscribe();
// Wait for cleanup
await awaitAsync(cleanupDelayMs * 2);
expect(memoryStorage.internalStore[deriveDefinition.storageKey]).toEqual(
derivedValue(new Date(newDate)),
);
});
it("should not cleanup if there are still subscribers", async () => {
const subscription1 = sut.state$.subscribe();
const sub2Emissions: Date[] = [];
@ -260,7 +183,3 @@ describe("DefaultDerivedState", () => {
});
});
});
function derivedValue<T>(value: T) {
return { derived: true, value };
}

View File

@ -1,10 +1,6 @@
import { Observable, ReplaySubject, Subject, concatMap, merge, share, timer } from "rxjs";
import { DerivedStateDependencies } from "../../../types/state";
import {
AbstractStorageService,
ObservableStorageService,
} from "../../abstractions/storage.service";
import { DeriveDefinition } from "../derive-definition";
import { DerivedState } from "../derived-state";
@ -22,7 +18,6 @@ export class DefaultDerivedState<TFrom, TTo, TDeps extends DerivedStateDependenc
constructor(
private parentState$: Observable<TFrom>,
protected deriveDefinition: DeriveDefinition<TFrom, TTo, TDeps>,
private memoryStorage: AbstractStorageService & ObservableStorageService,
private dependencies: TDeps,
) {
this.storageKey = deriveDefinition.storageKey;
@ -34,7 +29,6 @@ export class DefaultDerivedState<TFrom, TTo, TDeps extends DerivedStateDependenc
derivedStateOrPromise = await derivedStateOrPromise;
}
const derivedState = derivedStateOrPromise;
await this.storeValue(derivedState);
return derivedState;
}),
);
@ -44,26 +38,13 @@ export class DefaultDerivedState<TFrom, TTo, TDeps extends DerivedStateDependenc
connector: () => {
return new ReplaySubject<TTo>(1);
},
resetOnRefCountZero: () =>
timer(this.deriveDefinition.cleanupDelayMs).pipe(
concatMap(async () => {
if (this.deriveDefinition.clearOnCleanup) {
await this.memoryStorage.remove(this.storageKey);
}
return true;
}),
),
resetOnRefCountZero: () => timer(this.deriveDefinition.cleanupDelayMs),
}),
);
}
async forceValue(value: TTo) {
await this.storeValue(value);
this.forcedValueSubject.next(value);
return value;
}
private storeValue(value: TTo) {
return this.memoryStorage.save(this.storageKey, { derived: true, value });
}
}

View File

@ -91,6 +91,9 @@ module.exports = {
...theme("colors"),
}),
extend: {
fontSize: {
xxs: ["0.625rem", "0.875rem"],
},
width: {
"50vw": "50vw",
"75vw": "75vw",

10
package-lock.json generated
View File

@ -120,7 +120,7 @@
"@typescript-eslint/eslint-plugin": "7.4.0",
"@typescript-eslint/parser": "7.4.0",
"@webcomponents/custom-elements": "1.6.0",
"autoprefixer": "10.4.18",
"autoprefixer": "10.4.19",
"base64-loader": "1.0.0",
"chromatic": "10.9.6",
"concurrently": "8.2.2",
@ -12930,9 +12930,9 @@
}
},
"node_modules/autoprefixer": {
"version": "10.4.18",
"resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.18.tgz",
"integrity": "sha512-1DKbDfsr6KUElM6wg+0zRNkB/Q7WcKYAaK+pzXn+Xqmszm/5Xa9coeNdtP88Vi+dPzZnMjhge8GIV49ZQkDa+g==",
"version": "10.4.19",
"resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.19.tgz",
"integrity": "sha512-BaENR2+zBZ8xXhM4pUaKUxlVdxZ0EZhjvbopwnXmxRUfqDmwSpC2lAi/QXvx7NRdPCo1WKEcEF6mV64si1z4Ew==",
"dev": true,
"funding": [
{
@ -12950,7 +12950,7 @@
],
"dependencies": {
"browserslist": "^4.23.0",
"caniuse-lite": "^1.0.30001591",
"caniuse-lite": "^1.0.30001599",
"fraction.js": "^4.3.7",
"normalize-range": "^0.1.2",
"picocolors": "^1.0.0",

View File

@ -81,7 +81,7 @@
"@typescript-eslint/eslint-plugin": "7.4.0",
"@typescript-eslint/parser": "7.4.0",
"@webcomponents/custom-elements": "1.6.0",
"autoprefixer": "10.4.18",
"autoprefixer": "10.4.19",
"base64-loader": "1.0.0",
"chromatic": "10.9.6",
"concurrently": "8.2.2",