1
0
mirror of https://github.com/bitwarden/browser synced 2025-12-19 09:43:23 +00:00

Merge branch 'main' into autofill/pm-6546-blurring-of-autofilled-elements-causes-problems-in-blur-event-listeners

This commit is contained in:
Cesar Gonzalez
2024-03-05 13:47:11 -06:00
committed by GitHub
90 changed files with 2144 additions and 869 deletions

View File

@@ -109,6 +109,8 @@ type NotificationBackgroundExtensionMessageHandlers = {
bgReopenUnlockPopout: ({ sender }: BackgroundSenderParam) => Promise<void>;
checkNotificationQueue: ({ sender }: BackgroundSenderParam) => Promise<void>;
collectPageDetailsResponse: ({ message }: BackgroundMessageParam) => Promise<void>;
bgGetEnableChangedPasswordPrompt: () => Promise<boolean>;
bgGetEnableAddedLoginPrompt: () => Promise<boolean>;
getWebVaultUrlForNotification: () => string;
};

View File

@@ -4,6 +4,7 @@ import { firstValueFrom } from "rxjs";
import { PolicyService } from "@bitwarden/common/admin-console/services/policy/policy.service";
import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status";
import { AuthService } from "@bitwarden/common/auth/services/auth.service";
import { UserNotificationSettingsService } from "@bitwarden/common/autofill/services/user-notification-settings.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { EnvironmentService } from "@bitwarden/common/platform/services/environment.service";
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
@@ -45,6 +46,7 @@ describe("NotificationBackground", () => {
const policyService = mock<PolicyService>();
const folderService = mock<FolderService>();
const stateService = mock<BrowserStateService>();
const userNotificationSettingsService = mock<UserNotificationSettingsService>();
const environmentService = mock<EnvironmentService>();
const logService = mock<LogService>();
@@ -56,6 +58,7 @@ describe("NotificationBackground", () => {
policyService,
folderService,
stateService,
userNotificationSettingsService,
environmentService,
logService,
);
@@ -235,8 +238,8 @@ describe("NotificationBackground", () => {
let tab: chrome.tabs.Tab;
let sender: chrome.runtime.MessageSender;
let getAuthStatusSpy: jest.SpyInstance;
let getDisableAddLoginNotificationSpy: jest.SpyInstance;
let getDisableChangedPasswordNotificationSpy: jest.SpyInstance;
let getEnableAddedLoginPromptSpy: jest.SpyInstance;
let getEnableChangedPasswordPromptSpy: jest.SpyInstance;
let pushAddLoginToQueueSpy: jest.SpyInstance;
let pushChangePasswordToQueueSpy: jest.SpyInstance;
let getAllDecryptedForUrlSpy: jest.SpyInstance;
@@ -245,13 +248,13 @@ describe("NotificationBackground", () => {
tab = createChromeTabMock();
sender = mock<chrome.runtime.MessageSender>({ tab });
getAuthStatusSpy = jest.spyOn(authService, "getAuthStatus");
getDisableAddLoginNotificationSpy = jest.spyOn(
stateService,
"getDisableAddLoginNotification",
getEnableAddedLoginPromptSpy = jest.spyOn(
notificationBackground as any,
"getEnableAddedLoginPrompt",
);
getDisableChangedPasswordNotificationSpy = jest.spyOn(
stateService,
"getDisableChangedPasswordNotification",
getEnableChangedPasswordPromptSpy = jest.spyOn(
notificationBackground as any,
"getEnableChangedPasswordPrompt",
);
pushAddLoginToQueueSpy = jest.spyOn(notificationBackground as any, "pushAddLoginToQueue");
pushChangePasswordToQueueSpy = jest.spyOn(
@@ -272,7 +275,7 @@ describe("NotificationBackground", () => {
await flushPromises();
expect(getAuthStatusSpy).toHaveBeenCalled();
expect(getDisableAddLoginNotificationSpy).not.toHaveBeenCalled();
expect(getEnableAddedLoginPromptSpy).not.toHaveBeenCalled();
expect(pushAddLoginToQueueSpy).not.toHaveBeenCalled();
});
@@ -287,7 +290,7 @@ describe("NotificationBackground", () => {
await flushPromises();
expect(getAuthStatusSpy).toHaveBeenCalled();
expect(getDisableAddLoginNotificationSpy).not.toHaveBeenCalled();
expect(getEnableAddedLoginPromptSpy).not.toHaveBeenCalled();
expect(pushAddLoginToQueueSpy).not.toHaveBeenCalled();
});
@@ -297,13 +300,13 @@ describe("NotificationBackground", () => {
login: { username: "test", password: "password", url: "https://example.com" },
};
getAuthStatusSpy.mockResolvedValueOnce(AuthenticationStatus.Locked);
getDisableAddLoginNotificationSpy.mockReturnValueOnce(true);
getEnableAddedLoginPromptSpy.mockReturnValueOnce(false);
sendExtensionRuntimeMessage(message, sender);
await flushPromises();
expect(getAuthStatusSpy).toHaveBeenCalled();
expect(getDisableAddLoginNotificationSpy).toHaveBeenCalled();
expect(getEnableAddedLoginPromptSpy).toHaveBeenCalled();
expect(getAllDecryptedForUrlSpy).not.toHaveBeenCalled();
expect(pushAddLoginToQueueSpy).not.toHaveBeenCalled();
expect(pushChangePasswordToQueueSpy).not.toHaveBeenCalled();
@@ -315,14 +318,14 @@ describe("NotificationBackground", () => {
login: { username: "test", password: "password", url: "https://example.com" },
};
getAuthStatusSpy.mockResolvedValueOnce(AuthenticationStatus.Unlocked);
getDisableAddLoginNotificationSpy.mockReturnValueOnce(true);
getEnableAddedLoginPromptSpy.mockReturnValueOnce(false);
getAllDecryptedForUrlSpy.mockResolvedValueOnce([]);
sendExtensionRuntimeMessage(message, sender);
await flushPromises();
expect(getAuthStatusSpy).toHaveBeenCalled();
expect(getDisableAddLoginNotificationSpy).toHaveBeenCalled();
expect(getEnableAddedLoginPromptSpy).toHaveBeenCalled();
expect(getAllDecryptedForUrlSpy).toHaveBeenCalled();
expect(pushAddLoginToQueueSpy).not.toHaveBeenCalled();
expect(pushChangePasswordToQueueSpy).not.toHaveBeenCalled();
@@ -334,8 +337,8 @@ describe("NotificationBackground", () => {
login: { username: "test", password: "password", url: "https://example.com" },
};
getAuthStatusSpy.mockResolvedValueOnce(AuthenticationStatus.Unlocked);
getDisableAddLoginNotificationSpy.mockReturnValueOnce(false);
getDisableChangedPasswordNotificationSpy.mockReturnValueOnce(true);
getEnableAddedLoginPromptSpy.mockReturnValueOnce(true);
getEnableChangedPasswordPromptSpy.mockReturnValueOnce(false);
getAllDecryptedForUrlSpy.mockResolvedValueOnce([
mock<CipherView>({ login: { username: "test", password: "oldPassword" } }),
]);
@@ -344,9 +347,9 @@ describe("NotificationBackground", () => {
await flushPromises();
expect(getAuthStatusSpy).toHaveBeenCalled();
expect(getDisableAddLoginNotificationSpy).toHaveBeenCalled();
expect(getEnableAddedLoginPromptSpy).toHaveBeenCalled();
expect(getAllDecryptedForUrlSpy).toHaveBeenCalled();
expect(getDisableChangedPasswordNotificationSpy).toHaveBeenCalled();
expect(getEnableChangedPasswordPromptSpy).toHaveBeenCalled();
expect(pushAddLoginToQueueSpy).not.toHaveBeenCalled();
expect(pushChangePasswordToQueueSpy).not.toHaveBeenCalled();
});
@@ -357,7 +360,7 @@ describe("NotificationBackground", () => {
login: { username: "test", password: "password", url: "https://example.com" },
};
getAuthStatusSpy.mockResolvedValueOnce(AuthenticationStatus.Unlocked);
getDisableAddLoginNotificationSpy.mockReturnValueOnce(false);
getEnableAddedLoginPromptSpy.mockReturnValueOnce(true);
getAllDecryptedForUrlSpy.mockResolvedValueOnce([
mock<CipherView>({ login: { username: "test", password: "password" } }),
]);
@@ -366,7 +369,7 @@ describe("NotificationBackground", () => {
await flushPromises();
expect(getAuthStatusSpy).toHaveBeenCalled();
expect(getDisableAddLoginNotificationSpy).toHaveBeenCalled();
expect(getEnableAddedLoginPromptSpy).toHaveBeenCalled();
expect(getAllDecryptedForUrlSpy).toHaveBeenCalled();
expect(pushAddLoginToQueueSpy).not.toHaveBeenCalled();
expect(pushChangePasswordToQueueSpy).not.toHaveBeenCalled();
@@ -376,7 +379,7 @@ describe("NotificationBackground", () => {
const login = { username: "test", password: "password", url: "https://example.com" };
const message: NotificationBackgroundExtensionMessage = { command: "bgAddLogin", login };
getAuthStatusSpy.mockResolvedValueOnce(AuthenticationStatus.Locked);
getDisableAddLoginNotificationSpy.mockReturnValueOnce(false);
getEnableAddedLoginPromptSpy.mockReturnValueOnce(true);
sendExtensionRuntimeMessage(message, sender);
await flushPromises();
@@ -393,7 +396,7 @@ describe("NotificationBackground", () => {
} as any;
const message: NotificationBackgroundExtensionMessage = { command: "bgAddLogin", login };
getAuthStatusSpy.mockResolvedValueOnce(AuthenticationStatus.Unlocked);
getDisableAddLoginNotificationSpy.mockReturnValueOnce(false);
getEnableAddedLoginPromptSpy.mockReturnValueOnce(true);
getAllDecryptedForUrlSpy.mockResolvedValueOnce([
mock<CipherView>({ login: { username: "anotherTestUsername", password: "password" } }),
]);
@@ -409,7 +412,8 @@ describe("NotificationBackground", () => {
const login = { username: "tEsT", password: "password", url: "https://example.com" };
const message: NotificationBackgroundExtensionMessage = { command: "bgAddLogin", login };
getAuthStatusSpy.mockResolvedValueOnce(AuthenticationStatus.Unlocked);
getDisableAddLoginNotificationSpy.mockReturnValueOnce(false);
getEnableAddedLoginPromptSpy.mockResolvedValueOnce(true);
getEnableChangedPasswordPromptSpy.mockResolvedValueOnce(true);
getAllDecryptedForUrlSpy.mockResolvedValueOnce([
mock<CipherView>({
id: "cipher-id",

View File

@@ -4,6 +4,7 @@ import { PolicyService } from "@bitwarden/common/admin-console/abstractions/poli
import { PolicyType } from "@bitwarden/common/admin-console/enums";
import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service";
import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status";
import { UserNotificationSettingsServiceAbstraction } from "@bitwarden/common/autofill/services/user-notification-settings.service";
import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { Utils } from "@bitwarden/common/platform/misc/utils";
@@ -57,6 +58,8 @@ export default class NotificationBackground {
bgUnlockPopoutOpened: ({ message, sender }) => this.unlockVault(message, sender.tab),
checkNotificationQueue: ({ sender }) => this.checkNotificationQueue(sender.tab),
bgReopenUnlockPopout: ({ sender }) => this.openUnlockPopout(sender.tab),
bgGetEnableChangedPasswordPrompt: () => this.getEnableChangedPasswordPrompt(),
bgGetEnableAddedLoginPrompt: () => this.getEnableAddedLoginPrompt(),
getWebVaultUrlForNotification: () => this.getWebVaultUrl(),
};
@@ -67,6 +70,7 @@ export default class NotificationBackground {
private policyService: PolicyService,
private folderService: FolderService,
private stateService: BrowserStateService,
private userNotificationSettingsService: UserNotificationSettingsServiceAbstraction,
private environmentService: EnvironmentService,
private logService: LogService,
) {}
@@ -81,6 +85,20 @@ export default class NotificationBackground {
this.cleanupNotificationQueue();
}
/**
* Gets the enableChangedPasswordPrompt setting from the user notification settings service.
*/
async getEnableChangedPasswordPrompt(): Promise<boolean> {
return await firstValueFrom(this.userNotificationSettingsService.enableChangedPasswordPrompt$);
}
/**
* Gets the enableAddedLoginPrompt setting from the user notification settings service.
*/
async getEnableAddedLoginPrompt(): Promise<boolean> {
return await firstValueFrom(this.userNotificationSettingsService.enableAddedLoginPrompt$);
}
/**
* Checks the notification queue for any messages that need to be sent to the
* specified tab. If no tab is specified, the current tab will be used.
@@ -194,9 +212,10 @@ export default class NotificationBackground {
return;
}
const disabledAddLogin = await this.stateService.getDisableAddLoginNotification();
const addLoginIsEnabled = await this.getEnableAddedLoginPrompt();
if (authStatus === AuthenticationStatus.Locked) {
if (!disabledAddLogin) {
if (addLoginIsEnabled) {
await this.pushAddLoginToQueue(loginDomain, loginInfo, sender.tab, true);
}
@@ -207,14 +226,15 @@ export default class NotificationBackground {
const usernameMatches = ciphers.filter(
(c) => c.login.username != null && c.login.username.toLowerCase() === normalizedUsername,
);
if (!disabledAddLogin && usernameMatches.length === 0) {
if (addLoginIsEnabled && usernameMatches.length === 0) {
await this.pushAddLoginToQueue(loginDomain, loginInfo, sender.tab);
return;
}
const disabledChangePassword = await this.stateService.getDisableChangedPasswordNotification();
const changePasswordIsEnabled = await this.getEnableChangedPasswordPrompt();
if (
!disabledChangePassword &&
changePasswordIsEnabled &&
usernameMatches.length === 1 &&
usernameMatches[0].login.password !== loginInfo.password
) {

View File

@@ -0,0 +1,25 @@
import { UserNotificationSettingsService } from "@bitwarden/common/autofill/services/user-notification-settings.service";
import {
CachedServices,
factory,
FactoryOptions,
} from "../../../platform/background/service-factories/factory-options";
import {
stateProviderFactory,
StateProviderInitOptions,
} from "../../../platform/background/service-factories/state-provider.factory";
export type UserNotificationSettingsServiceInitOptions = FactoryOptions & StateProviderInitOptions;
export function userNotificationSettingsServiceFactory(
cache: { userNotificationSettingsService?: UserNotificationSettingsService } & CachedServices,
opts: UserNotificationSettingsServiceInitOptions,
): Promise<UserNotificationSettingsService> {
return factory(
cache,
"userNotificationSettingsService",
opts,
async () => new UserNotificationSettingsService(await stateProviderFactory(cache, opts)),
);
}

View File

@@ -7,7 +7,11 @@ import { WatchedForm } from "../models/watched-form";
import { NotificationBarIframeInitData } from "../notification/abstractions/notification-bar";
import { FormData } from "../services/abstractions/autofill.service";
import { GlobalSettings, UserSettings } from "../types";
import { getFromLocalStorage, setupExtensionDisconnectAction } from "../utils";
import {
getFromLocalStorage,
sendExtensionMessage,
setupExtensionDisconnectAction,
} from "../utils";
interface HTMLElementWithFormOpId extends HTMLElement {
formOpId: string;
@@ -86,12 +90,11 @@ async function loadNotificationBar() {
]);
const changePasswordButtonContainsNames = new Set(["pass", "change", "contras", "senha"]);
// These are preferences for whether to show the notification bar based on the user's settings
// and they are set in the Settings > Options page in the browser extension.
let disabledAddLoginNotification = false;
let disabledChangedPasswordNotification = false;
const enableChangedPasswordPrompt = await sendExtensionMessage(
"bgGetEnableChangedPasswordPrompt",
);
const enableAddedLoginPrompt = await sendExtensionMessage("bgGetEnableAddedLoginPrompt");
let showNotificationBar = true;
// Look up the active user id from storage
const activeUserIdKey = "activeUserId";
const globalStorageKey = "global";
@@ -121,11 +124,7 @@ async function loadNotificationBar() {
// Example: '{"bitwarden.com":null}'
const excludedDomainsDict = globalSettings.neverDomains;
if (!excludedDomainsDict || !(window.location.hostname in excludedDomainsDict)) {
// Set local disabled preferences
disabledAddLoginNotification = globalSettings.disableAddLoginNotification;
disabledChangedPasswordNotification = globalSettings.disableChangedPasswordNotification;
if (!disabledAddLoginNotification || !disabledChangedPasswordNotification) {
if (enableAddedLoginPrompt || enableChangedPasswordPrompt) {
// If the user has not disabled both notifications, then handle the initial page change (null -> actual page)
handlePageChange();
}
@@ -352,9 +351,7 @@ async function loadNotificationBar() {
// to avoid missing any forms that are added after the page loads
observeDom();
sendPlatformMessage({
command: "checkNotificationQueue",
});
void sendExtensionMessage("checkNotificationQueue");
}
// This is a safeguard in case the observer misses a SPA page change.
@@ -392,10 +389,7 @@ async function loadNotificationBar() {
*
* */
function collectPageDetails() {
sendPlatformMessage({
command: "bgCollectPageDetails",
sender: "notificationBar",
});
void sendExtensionMessage("bgCollectPageDetails", { sender: "notificationBar" });
}
// End Page Detail Collection Methods
@@ -620,10 +614,9 @@ async function loadNotificationBar() {
continue;
}
const disabledBoth = disabledChangedPasswordNotification && disabledAddLoginNotification;
// if user has not disabled both notifications and we have a username and password field,
// if user has enabled either add login or change password notification, and we have a username and password field
if (
!disabledBoth &&
(enableChangedPasswordPrompt || enableAddedLoginPrompt) &&
watchedForms[i].usernameEl != null &&
watchedForms[i].passwordEl != null
) {
@@ -639,10 +632,7 @@ async function loadNotificationBar() {
const passwordPopulated = login.password != null && login.password !== "";
if (userNamePopulated && passwordPopulated) {
processedForm(form);
sendPlatformMessage({
command: "bgAddLogin",
login,
});
void sendExtensionMessage("bgAddLogin", { login });
break;
} else if (
userNamePopulated &&
@@ -659,7 +649,7 @@ async function loadNotificationBar() {
// if user has not disabled the password changed notification and we have multiple password fields,
// then check if the user has changed their password
if (!disabledChangedPasswordNotification && watchedForms[i].passwordEls != null) {
if (enableChangedPasswordPrompt && watchedForms[i].passwordEls != null) {
// Get the values of the password fields
const passwords: string[] = watchedForms[i].passwordEls
.filter((el: HTMLInputElement) => el.value != null && el.value !== "")
@@ -716,7 +706,7 @@ async function loadNotificationBar() {
currentPassword: curPass,
url: document.URL,
};
sendPlatformMessage({ command: "bgChangedPassword", data });
void sendExtensionMessage("bgChangedPassword", { data });
break;
}
}
@@ -954,9 +944,7 @@ async function loadNotificationBar() {
switch (barType) {
case "add":
case "change":
sendPlatformMessage({
command: "bgRemoveTabFromNotificationQueue",
});
void sendExtensionMessage("bgRemoveTabFromNotificationQueue");
break;
default:
break;
@@ -981,12 +969,6 @@ async function loadNotificationBar() {
// End Notification Bar Functions (open, close, height adjustment, etc.)
// Helper Functions
function sendPlatformMessage(msg: any) {
// FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling.
// eslint-disable-next-line @typescript-eslint/no-floating-promises
chrome.runtime.sendMessage(msg);
}
function isInIframe() {
try {
return window.self !== window.top;

View File

@@ -166,11 +166,10 @@ describe("AutofillService", () => {
jest
.spyOn(autofillService, "getOverlayVisibility")
.mockResolvedValue(AutofillOverlayVisibility.OnFieldFocus);
jest.spyOn(autofillService, "getAutofillOnPageLoad").mockResolvedValue(true);
});
it("accepts an extension message sender and injects the autofill scripts into the tab of the sender", async () => {
jest.spyOn(autofillService, "getAutofillOnPageLoad").mockResolvedValue(true);
await autofillService.injectAutofillScripts(sender.tab, sender.frameId, true);
[autofillOverlayBootstrapScript, ...defaultAutofillScripts].forEach((scriptName) => {
@@ -195,11 +194,6 @@ describe("AutofillService", () => {
});
it("will inject the bootstrap-autofill-overlay script if the user has the autofill overlay enabled", async () => {
jest
.spyOn(autofillService, "getOverlayVisibility")
.mockResolvedValue(AutofillOverlayVisibility.OnFieldFocus);
jest.spyOn(autofillService, "getAutofillOnPageLoad").mockResolvedValue(true);
await autofillService.injectAutofillScripts(sender.tab, sender.frameId);
expect(BrowserApi.executeScriptInTab).toHaveBeenCalledWith(tabMock.id, {
@@ -218,7 +212,6 @@ describe("AutofillService", () => {
jest
.spyOn(autofillService, "getOverlayVisibility")
.mockResolvedValue(AutofillOverlayVisibility.Off);
jest.spyOn(autofillService, "getAutofillOnPageLoad").mockResolvedValue(true);
await autofillService.injectAutofillScripts(sender.tab, sender.frameId);
@@ -235,8 +228,6 @@ describe("AutofillService", () => {
});
it("injects the content-message-handler script if not injecting on page load", async () => {
jest.spyOn(autofillService, "getAutofillOnPageLoad").mockResolvedValue(true);
await autofillService.injectAutofillScripts(sender.tab, sender.frameId, false);
expect(BrowserApi.executeScriptInTab).toHaveBeenCalledWith(tabMock.id, {

View File

@@ -39,10 +39,7 @@ export type UserSettings = {
vaultTimeoutAction: VaultTimeoutAction;
};
export type GlobalSettings = Pick<
GlobalState,
"disableAddLoginNotification" | "disableChangedPasswordNotification" | "neverDomains"
>;
export type GlobalSettings = Pick<GlobalState, "neverDomains">;
/**
* A HTMLElement (usually a form element) with additional custom properties added by this script

View File

@@ -55,6 +55,10 @@ import {
BadgeSettingsServiceAbstraction,
BadgeSettingsService,
} from "@bitwarden/common/autofill/services/badge-settings.service";
import {
UserNotificationSettingsService,
UserNotificationSettingsServiceAbstraction,
} from "@bitwarden/common/autofill/services/user-notification-settings.service";
import { AppIdService as AppIdServiceAbstraction } from "@bitwarden/common/platform/abstractions/app-id.service";
import { ConfigApiServiceAbstraction } from "@bitwarden/common/platform/abstractions/config/config-api.service.abstraction";
import { CryptoFunctionService as CryptoFunctionServiceAbstraction } from "@bitwarden/common/platform/abstractions/crypto-function.service";
@@ -89,6 +93,7 @@ import { KeyGenerationService } from "@bitwarden/common/platform/services/key-ge
import { MemoryStorageService } from "@bitwarden/common/platform/services/memory-storage.service";
import { MigrationBuilderService } from "@bitwarden/common/platform/services/migration-builder.service";
import { MigrationRunner } from "@bitwarden/common/platform/services/migration-runner";
import { StorageServiceProvider } from "@bitwarden/common/platform/services/storage-service.provider";
import { SystemService } from "@bitwarden/common/platform/services/system.service";
import { WebCryptoFunctionService } from "@bitwarden/common/platform/services/web-crypto-function.service";
import {
@@ -96,6 +101,7 @@ import {
DerivedStateProvider,
GlobalStateProvider,
SingleUserStateProvider,
StateEventRunnerService,
StateProvider,
} from "@bitwarden/common/platform/state";
/* eslint-disable import/no-restricted-paths -- We need the implementation to inject, but generally these should not be accessed */
@@ -103,6 +109,7 @@ import { DefaultActiveUserStateProvider } from "@bitwarden/common/platform/state
import { DefaultGlobalStateProvider } from "@bitwarden/common/platform/state/implementations/default-global-state.provider";
import { DefaultSingleUserStateProvider } from "@bitwarden/common/platform/state/implementations/default-single-user-state.provider";
import { DefaultStateProvider } from "@bitwarden/common/platform/state/implementations/default-state.provider";
import { StateEventRegistrarService } from "@bitwarden/common/platform/state/state-event-registrar.service";
/* eslint-enable import/no-restricted-paths */
import { AvatarUpdateService } from "@bitwarden/common/services/account/avatar-update.service";
import { ApiService } from "@bitwarden/common/services/api.service";
@@ -248,6 +255,7 @@ export default class MainBackground {
searchService: SearchServiceAbstraction;
notificationsService: NotificationsServiceAbstraction;
stateService: StateServiceAbstraction;
userNotificationSettingsService: UserNotificationSettingsServiceAbstraction;
autofillSettingsService: AutofillSettingsServiceAbstraction;
badgeSettingsService: BadgeSettingsServiceAbstraction;
systemService: SystemServiceAbstraction;
@@ -294,11 +302,9 @@ export default class MainBackground {
organizationVaultExportService: OrganizationVaultExportServiceAbstraction;
vaultSettingsService: VaultSettingsServiceAbstraction;
biometricStateService: BiometricStateService;
stateEventRunnerService: StateEventRunnerService;
ssoLoginService: SsoLoginServiceAbstraction;
// Passed to the popup for Safari to workaround issues with theming, downloading, etc.
backgroundWindow = window;
onUpdatedRan: boolean;
onReplacedRan: boolean;
loginToAutoFill: CipherView = null;
@@ -361,10 +367,24 @@ export default class MainBackground {
this.keyGenerationService,
)
: new BackgroundMemoryStorageService();
this.globalStateProvider = new DefaultGlobalStateProvider(
this.memoryStorageForStateProviders,
const storageServiceProvider = new StorageServiceProvider(
this.storageService as BrowserLocalStorageService,
this.memoryStorageForStateProviders,
);
this.globalStateProvider = new DefaultGlobalStateProvider(storageServiceProvider);
const stateEventRegistrarService = new StateEventRegistrarService(
this.globalStateProvider,
storageServiceProvider,
);
this.stateEventRunnerService = new StateEventRunnerService(
this.globalStateProvider,
storageServiceProvider,
);
this.encryptService = flagEnabled("multithreadDecryption")
? new MultithreadEncryptServiceImplementation(
this.cryptoFunctionService,
@@ -374,8 +394,8 @@ export default class MainBackground {
: new EncryptServiceImplementation(this.cryptoFunctionService, this.logService, true);
this.singleUserStateProvider = new DefaultSingleUserStateProvider(
this.memoryStorageForStateProviders,
this.storageService as BrowserLocalStorageService,
storageServiceProvider,
stateEventRegistrarService,
);
this.accountService = new AccountServiceImplementation(
this.messagingService,
@@ -384,8 +404,8 @@ export default class MainBackground {
);
this.activeUserStateProvider = new DefaultActiveUserStateProvider(
this.accountService,
this.memoryStorageForStateProviders,
this.storageService as BrowserLocalStorageService,
storageServiceProvider,
stateEventRegistrarService,
);
this.derivedStateProvider = new BackgroundDerivedStateProvider(
this.memoryStorageForStateProviders,
@@ -419,6 +439,7 @@ export default class MainBackground {
this.environmentService,
migrationRunner,
);
this.userNotificationSettingsService = new UserNotificationSettingsService(this.stateProvider);
this.platformUtilsService = new BrowserPlatformUtilsService(
this.messagingService,
(clipboardValue, clearMs) => {
@@ -660,6 +681,7 @@ export default class MainBackground {
this.stateService,
this.authService,
this.vaultTimeoutSettingsService,
this.stateEventRunnerService,
lockedCallback,
logoutCallback,
);
@@ -675,7 +697,7 @@ export default class MainBackground {
this.fileUploadService,
this.sendService,
);
this.providerService = new ProviderService(this.stateService);
this.providerService = new ProviderService(this.stateProvider);
this.syncService = new SyncService(
this.apiService,
this.settingsService,
@@ -846,6 +868,7 @@ export default class MainBackground {
this.policyService,
this.folderService,
this.stateService,
this.userNotificationSettingsService,
this.environmentService,
this.logService,
);
@@ -1088,9 +1111,11 @@ export default class MainBackground {
this.keyConnectorService.clear(),
this.vaultFilterService.clear(),
this.biometricStateService.logout(userId),
this.providerService.save(null, userId),
/* We intentionally do not clear:
* - autofillSettingsService
* - badgeSettingsService
* - userNotificationSettingsService
*/
]);
@@ -1104,6 +1129,8 @@ export default class MainBackground {
this.searchService.clearIndex();
}
await this.stateEventRunnerService.handleEvent("logout", currentUserId as UserId);
if (newActiveUser != null) {
// we have a new active user, do not continue tearing down application
await this.switchAccount(newActiveUser as UserId);

View File

@@ -21,6 +21,10 @@ import {
platformUtilsServiceFactory,
PlatformUtilsServiceInitOptions,
} from "../../platform/background/service-factories/platform-utils-service.factory";
import {
stateEventRunnerServiceFactory,
StateEventRunnerServiceInitOptions,
} from "../../platform/background/service-factories/state-event-runner-service.factory";
import {
StateServiceInitOptions,
stateServiceFactory,
@@ -62,7 +66,8 @@ export type VaultTimeoutServiceInitOptions = VaultTimeoutServiceFactoryOptions &
SearchServiceInitOptions &
StateServiceInitOptions &
AuthServiceInitOptions &
VaultTimeoutSettingsServiceInitOptions;
VaultTimeoutSettingsServiceInitOptions &
StateEventRunnerServiceInitOptions;
export function vaultTimeoutServiceFactory(
cache: { vaultTimeoutService?: AbstractVaultTimeoutService } & CachedServices,
@@ -84,6 +89,7 @@ export function vaultTimeoutServiceFactory(
await stateServiceFactory(cache, opts),
await authServiceFactory(cache, opts),
await vaultTimeoutSettingsServiceFactory(cache, opts),
await stateEventRunnerServiceFactory(cache, opts),
opts.vaultTimeoutServiceOptions.lockedCallback,
opts.vaultTimeoutServiceOptions.loggedOutCallback,
),

View File

@@ -108,6 +108,7 @@
},
"web_accessible_resources": [
"content/fido2/page-script.js",
"content/lp-suppress-import-download.js",
"notification/bar.html",
"images/icon38.png",
"images/icon38_locked.png",

View File

@@ -31,6 +31,12 @@
"matches": ["http://*/*", "https://*/*", "file:///*"],
"run_at": "document_start"
},
{
"all_frames": false,
"js": ["content/lp-fileless-importer.js"],
"matches": ["https://lastpass.com/export.php"],
"run_at": "document_start"
},
{
"all_frames": true,
"css": ["content/autofill.css"],

View File

@@ -9,18 +9,20 @@ import {
import { CachedServices, FactoryOptions, factory } from "./factory-options";
import {
DiskStorageServiceInitOptions,
MemoryStorageServiceInitOptions,
observableDiskStorageServiceFactory,
observableMemoryStorageServiceFactory,
} from "./storage-service.factory";
StateEventRegistrarServiceInitOptions,
stateEventRegistrarServiceFactory,
} from "./state-event-registrar-service.factory";
import {
StorageServiceProviderInitOptions,
storageServiceProviderFactory,
} from "./storage-service-provider.factory";
type ActiveUserStateProviderFactory = FactoryOptions;
export type ActiveUserStateProviderInitOptions = ActiveUserStateProviderFactory &
AccountServiceInitOptions &
MemoryStorageServiceInitOptions &
DiskStorageServiceInitOptions;
StorageServiceProviderInitOptions &
StateEventRegistrarServiceInitOptions;
export async function activeUserStateProviderFactory(
cache: { activeUserStateProvider?: ActiveUserStateProvider } & CachedServices,
@@ -33,8 +35,8 @@ export async function activeUserStateProviderFactory(
async () =>
new DefaultActiveUserStateProvider(
await accountServiceFactory(cache, opts),
await observableMemoryStorageServiceFactory(cache, opts),
await observableDiskStorageServiceFactory(cache, opts),
await storageServiceProviderFactory(cache, opts),
await stateEventRegistrarServiceFactory(cache, opts),
),
);
}

View File

@@ -4,17 +4,14 @@ import { DefaultGlobalStateProvider } from "@bitwarden/common/platform/state/imp
import { CachedServices, FactoryOptions, factory } from "./factory-options";
import {
DiskStorageServiceInitOptions,
MemoryStorageServiceInitOptions,
observableDiskStorageServiceFactory,
observableMemoryStorageServiceFactory,
} from "./storage-service.factory";
StorageServiceProviderInitOptions,
storageServiceProviderFactory,
} from "./storage-service-provider.factory";
type GlobalStateProviderFactoryOptions = FactoryOptions;
export type GlobalStateProviderInitOptions = GlobalStateProviderFactoryOptions &
MemoryStorageServiceInitOptions &
DiskStorageServiceInitOptions;
StorageServiceProviderInitOptions;
export async function globalStateProviderFactory(
cache: { globalStateProvider?: GlobalStateProvider } & CachedServices,
@@ -24,10 +21,6 @@ export async function globalStateProviderFactory(
cache,
"globalStateProvider",
opts,
async () =>
new DefaultGlobalStateProvider(
await observableMemoryStorageServiceFactory(cache, opts),
await observableDiskStorageServiceFactory(cache, opts),
),
async () => new DefaultGlobalStateProvider(await storageServiceProviderFactory(cache, opts)),
);
}

View File

@@ -4,17 +4,19 @@ import { DefaultSingleUserStateProvider } from "@bitwarden/common/platform/state
import { CachedServices, FactoryOptions, factory } from "./factory-options";
import {
DiskStorageServiceInitOptions,
MemoryStorageServiceInitOptions,
observableDiskStorageServiceFactory,
observableMemoryStorageServiceFactory,
} from "./storage-service.factory";
StateEventRegistrarServiceInitOptions,
stateEventRegistrarServiceFactory,
} from "./state-event-registrar-service.factory";
import {
StorageServiceProviderInitOptions,
storageServiceProviderFactory,
} from "./storage-service-provider.factory";
type SingleUserStateProviderFactoryOptions = FactoryOptions;
export type SingleUserStateProviderInitOptions = SingleUserStateProviderFactoryOptions &
MemoryStorageServiceInitOptions &
DiskStorageServiceInitOptions;
StorageServiceProviderInitOptions &
StateEventRegistrarServiceInitOptions;
export async function singleUserStateProviderFactory(
cache: { singleUserStateProvider?: SingleUserStateProvider } & CachedServices,
@@ -26,8 +28,8 @@ export async function singleUserStateProviderFactory(
opts,
async () =>
new DefaultSingleUserStateProvider(
await observableMemoryStorageServiceFactory(cache, opts),
await observableDiskStorageServiceFactory(cache, opts),
await storageServiceProviderFactory(cache, opts),
await stateEventRegistrarServiceFactory(cache, opts),
),
);
}

View File

@@ -0,0 +1,33 @@
import { StateEventRunnerService } from "@bitwarden/common/platform/state";
import { CachedServices, FactoryOptions, factory } from "./factory-options";
import {
GlobalStateProviderInitOptions,
globalStateProviderFactory,
} from "./global-state-provider.factory";
import {
StorageServiceProviderInitOptions,
storageServiceProviderFactory,
} from "./storage-service-provider.factory";
type StateEventRunnerServiceFactoryOptions = FactoryOptions;
export type StateEventRunnerServiceInitOptions = StateEventRunnerServiceFactoryOptions &
GlobalStateProviderInitOptions &
StorageServiceProviderInitOptions;
export function stateEventRunnerServiceFactory(
cache: { stateEventRunnerService?: StateEventRunnerService } & CachedServices,
opts: StateEventRunnerServiceInitOptions,
): Promise<StateEventRunnerService> {
return factory(
cache,
"stateEventRunnerService",
opts,
async () =>
new StateEventRunnerService(
await globalStateProviderFactory(cache, opts),
await storageServiceProviderFactory(cache, opts),
),
);
}

View File

@@ -320,6 +320,60 @@ describe("BrowserApi", () => {
},
files: [injectDetails.file],
injectImmediately: true,
world: "ISOLATED",
});
expect(result).toEqual(executeScriptResult);
});
it("injects the script into a specified frameId when the extension is built for manifest v3", async () => {
const tabId = 1;
const frameId = 2;
const injectDetails = mock<chrome.tabs.InjectDetails>({
file: "file.js",
allFrames: true,
runAt: "document_start",
frameId,
});
jest.spyOn(BrowserApi, "manifestVersion", "get").mockReturnValue(3);
(chrome.scripting.executeScript as jest.Mock).mockResolvedValue(executeScriptResult);
await BrowserApi.executeScriptInTab(tabId, injectDetails);
expect(chrome.scripting.executeScript).toHaveBeenCalledWith({
target: {
tabId: tabId,
allFrames: injectDetails.allFrames,
frameIds: [frameId],
},
files: [injectDetails.file],
injectImmediately: true,
world: "ISOLATED",
});
});
it("injects the script into the MAIN world context when injecting a script for manifest v3", async () => {
const tabId = 1;
const injectDetails = mock<chrome.tabs.InjectDetails>({
file: null,
allFrames: true,
runAt: "document_start",
frameId: null,
});
const scriptingApiDetails = { world: "MAIN" as chrome.scripting.ExecutionWorld };
jest.spyOn(BrowserApi, "manifestVersion", "get").mockReturnValue(3);
(chrome.scripting.executeScript as jest.Mock).mockResolvedValue(executeScriptResult);
const result = await BrowserApi.executeScriptInTab(tabId, injectDetails, scriptingApiDetails);
expect(chrome.scripting.executeScript).toHaveBeenCalledWith({
target: {
tabId: tabId,
allFrames: injectDetails.allFrames,
frameIds: null,
},
files: null,
injectImmediately: true,
world: "MAIN",
});
expect(result).toEqual(executeScriptResult);
});

View File

@@ -475,12 +475,19 @@ export class BrowserApi {
/**
* Extension API helper method used to execute a script in a tab.
*
* @see https://developer.chrome.com/docs/extensions/reference/tabs/#method-executeScript
* @param {number} tabId
* @param {chrome.tabs.InjectDetails} details
* @returns {Promise<unknown>}
* @param tabId - The id of the tab to execute the script in.
* @param details {@link "InjectDetails" https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/extensionTypes/InjectDetails}
* @param scriptingApiDetails {@link "ExecutionWorld" https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/scripting/ExecutionWorld}
*/
static executeScriptInTab(tabId: number, details: chrome.tabs.InjectDetails) {
static executeScriptInTab(
tabId: number,
details: chrome.tabs.InjectDetails,
scriptingApiDetails?: {
world: chrome.scripting.ExecutionWorld;
},
): Promise<unknown> {
if (BrowserApi.manifestVersion === 3) {
return chrome.scripting.executeScript({
target: {
@@ -490,6 +497,7 @@ export class BrowserApi {
},
files: details.file ? [details.file] : null,
injectImmediately: details.runAt === "document_start",
world: scriptingApiDetails?.world || "ISOLATED",
});
}

View File

@@ -15,7 +15,6 @@ import {
LoginStrategyServiceAbstraction,
} from "@bitwarden/auth/common";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { AuditService } from "@bitwarden/common/abstractions/audit.service";
import { EventCollectionService } from "@bitwarden/common/abstractions/event/event-collection.service";
import { EventUploadService } from "@bitwarden/common/abstractions/event/event-upload.service";
import { NotificationsService } from "@bitwarden/common/abstractions/notifications.service";
@@ -25,7 +24,6 @@ import { VaultTimeoutSettingsService } from "@bitwarden/common/abstractions/vaul
import { VaultTimeoutService } from "@bitwarden/common/abstractions/vault-timeout/vault-timeout.service";
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
import { ProviderService } from "@bitwarden/common/admin-console/abstractions/provider.service";
import { AccountService as AccountServiceAbstraction } from "@bitwarden/common/auth/abstractions/account.service";
import { AuthService as AuthServiceAbstraction } from "@bitwarden/common/auth/abstractions/auth.service";
import { DeviceTrustCryptoServiceAbstraction } from "@bitwarden/common/auth/abstractions/device-trust-crypto.service.abstraction";
@@ -42,6 +40,10 @@ import {
AutofillSettingsService,
AutofillSettingsServiceAbstraction,
} from "@bitwarden/common/autofill/services/autofill-settings.service";
import {
UserNotificationSettingsService,
UserNotificationSettingsServiceAbstraction,
} from "@bitwarden/common/autofill/services/user-notification-settings.service";
import { AppIdService } from "@bitwarden/common/platform/abstractions/app-id.service";
import { ConfigApiServiceAbstraction } from "@bitwarden/common/platform/abstractions/config/config-api.service.abstraction";
import { CryptoFunctionService } from "@bitwarden/common/platform/abstractions/crypto-function.service";
@@ -207,7 +209,6 @@ function getBgService<T>(service: keyof MainBackground) {
},
deps: [LogServiceAbstraction, I18nServiceAbstraction],
},
{ provide: AuditService, useFactory: getBgService<AuditService>("auditService"), deps: [] },
{
provide: CipherFileUploadService,
useFactory: getBgService<CipherFileUploadService>("cipherFileUploadService"),
@@ -434,11 +435,6 @@ function getBgService<T>(service: keyof MainBackground) {
AccountServiceAbstraction,
],
},
{
provide: ProviderService,
useFactory: getBgService<ProviderService>("providerService"),
deps: [],
},
{
provide: SECURE_STORAGE,
useFactory: getBgService<AbstractStorageService>("secureStorageService"),
@@ -513,13 +509,15 @@ function getBgService<T>(service: keyof MainBackground) {
stateService: StateServiceAbstraction,
platformUtilsService: PlatformUtilsService,
) => {
return new ThemingService(
stateService,
// Safari doesn't properly handle the (prefers-color-scheme) media query in the popup window, it always returns light.
// In Safari we have to use the background page instead, which comes with limitations like not dynamically changing the extension theme when the system theme is changed.
platformUtilsService.isSafari() ? getBgService<Window>("backgroundWindow")() : window,
document,
);
// Safari doesn't properly handle the (prefers-color-scheme) media query in the popup window, it always returns light.
// In Safari, we have to use the background page instead, which comes with limitations like not dynamically changing the extension theme when the system theme is changed.
let windowContext = window;
const backgroundWindow = BrowserApi.getBackgroundPage();
if (platformUtilsService.isSafari() && backgroundWindow) {
windowContext = backgroundWindow;
}
return new ThemingService(stateService, windowContext, document);
},
deps: [StateServiceAbstraction, PlatformUtilsService],
},
@@ -551,6 +549,11 @@ function getBgService<T>(service: keyof MainBackground) {
useClass: AutofillSettingsService,
deps: [StateProvider, PolicyService],
},
{
provide: UserNotificationSettingsServiceAbstraction,
useClass: UserNotificationSettingsService,
deps: [StateProvider],
},
],
})
export class ServicesModule {}

View File

@@ -5,6 +5,7 @@ import { AbstractThemingService } from "@bitwarden/angular/platform/services/the
import { SettingsService } from "@bitwarden/common/abstractions/settings.service";
import { AutofillSettingsServiceAbstraction } from "@bitwarden/common/autofill/services/autofill-settings.service";
import { BadgeSettingsServiceAbstraction } from "@bitwarden/common/autofill/services/badge-settings.service";
import { UserNotificationSettingsServiceAbstraction } from "@bitwarden/common/autofill/services/user-notification-settings.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service";
import { StateService } from "@bitwarden/common/platform/abstractions/state.service";
@@ -47,6 +48,7 @@ export class OptionsComponent implements OnInit {
constructor(
private messagingService: MessagingService,
private stateService: StateService,
private userNotificationSettingsService: UserNotificationSettingsServiceAbstraction,
private autofillSettingsService: AutofillSettingsServiceAbstraction,
private badgeSettingsService: BadgeSettingsServiceAbstraction,
i18nService: I18nService,
@@ -95,10 +97,13 @@ export class OptionsComponent implements OnInit {
this.autofillSettingsService.autofillOnPageLoadDefault$,
);
this.enableAddLoginNotification = !(await this.stateService.getDisableAddLoginNotification());
this.enableAddLoginNotification = await firstValueFrom(
this.userNotificationSettingsService.enableAddedLoginPrompt$,
);
this.enableChangedPasswordNotification =
!(await this.stateService.getDisableChangedPasswordNotification());
this.enableChangedPasswordNotification = await firstValueFrom(
this.userNotificationSettingsService.enableChangedPasswordPrompt$,
);
this.enableContextMenuItem = !(await this.stateService.getDisableContextMenuItem());
@@ -122,12 +127,14 @@ export class OptionsComponent implements OnInit {
}
async updateAddLoginNotification() {
await this.stateService.setDisableAddLoginNotification(!this.enableAddLoginNotification);
await this.userNotificationSettingsService.setEnableAddedLoginPrompt(
this.enableAddLoginNotification,
);
}
async updateChangedPasswordNotification() {
await this.stateService.setDisableChangedPasswordNotification(
!this.enableChangedPasswordNotification,
await this.userNotificationSettingsService.setEnableChangedPasswordPrompt(
this.enableChangedPasswordNotification,
);
}

View File

@@ -1,5 +1,10 @@
import { FilelessImportTypeKeys } from "../../enums/fileless-import.enums";
type SuppressDownloadScriptInjectionConfig = {
file: string;
scriptingApiDetails?: { world: chrome.scripting.ExecutionWorld };
};
type FilelessImportPortMessage = {
command?: string;
importType?: FilelessImportTypeKeys;
@@ -27,6 +32,7 @@ interface FilelessImporterBackground {
}
export {
SuppressDownloadScriptInjectionConfig,
FilelessImportPortMessage,
ImportNotificationMessageHandlers,
LpImporterMessageHandlers,

View File

@@ -1,4 +1,5 @@
import { mock } from "jest-mock-extended";
import { firstValueFrom } from "rxjs";
import { PolicyService } from "@bitwarden/common/admin-console/services/policy/policy.service";
import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status";
@@ -14,10 +15,20 @@ import {
sendPortMessage,
triggerRuntimeOnConnectEvent,
} from "../../autofill/spec/testing-utils";
import { BrowserApi } from "../../platform/browser/browser-api";
import { FilelessImportPort, FilelessImportType } from "../enums/fileless-import.enums";
import FilelessImporterBackground from "./fileless-importer.background";
jest.mock("rxjs", () => {
const rxjs = jest.requireActual("rxjs");
const { firstValueFrom } = rxjs;
return {
...rxjs,
firstValueFrom: jest.fn(firstValueFrom),
};
});
describe("FilelessImporterBackground ", () => {
let filelessImporterBackground: FilelessImporterBackground;
const configService = mock<ConfigService>();
@@ -51,14 +62,17 @@ describe("FilelessImporterBackground ", () => {
describe("handle ports onConnect", () => {
let lpImporterPort: chrome.runtime.Port;
let manifestVersionSpy: jest.SpyInstance;
let executeScriptInTabSpy: jest.SpyInstance;
beforeEach(() => {
lpImporterPort = createPortSpyMock(FilelessImportPort.LpImporter);
manifestVersionSpy = jest.spyOn(BrowserApi, "manifestVersion", "get");
executeScriptInTabSpy = jest.spyOn(BrowserApi, "executeScriptInTab").mockResolvedValue(null);
jest.spyOn(authService, "getAuthStatus").mockResolvedValue(AuthenticationStatus.Unlocked);
jest.spyOn(configService, "getFeatureFlag").mockResolvedValue(true);
jest
.spyOn(filelessImporterBackground as any, "removeIndividualVault")
.mockResolvedValue(false);
jest.spyOn(filelessImporterBackground as any, "removeIndividualVault");
(firstValueFrom as jest.Mock).mockResolvedValue(false);
});
it("ignores the port connection if the port name is not present in the set of filelessImportNames", async () => {
@@ -83,9 +97,7 @@ describe("FilelessImporterBackground ", () => {
});
it("posts a message to the port indicating that the fileless import feature is disabled if the user's policy removes individual vaults", async () => {
jest
.spyOn(filelessImporterBackground as any, "removeIndividualVault")
.mockResolvedValue(true);
(firstValueFrom as jest.Mock).mockResolvedValue(true);
triggerRuntimeOnConnectEvent(lpImporterPort);
await flushPromises();
@@ -117,6 +129,35 @@ describe("FilelessImporterBackground ", () => {
filelessImportEnabled: true,
});
});
it("triggers an injection of the `lp-suppress-import-download.js` script in manifest v3", async () => {
manifestVersionSpy.mockReturnValue(3);
triggerRuntimeOnConnectEvent(lpImporterPort);
await flushPromises();
expect(executeScriptInTabSpy).toHaveBeenCalledWith(
lpImporterPort.sender.tab.id,
{ file: "content/lp-suppress-import-download.js", runAt: "document_start" },
{ world: "MAIN" },
);
});
it("triggers an injection of the `lp-suppress-import-download-script-append-mv2.js` script in manifest v2", async () => {
manifestVersionSpy.mockReturnValue(2);
triggerRuntimeOnConnectEvent(lpImporterPort);
await flushPromises();
expect(executeScriptInTabSpy).toHaveBeenCalledWith(
lpImporterPort.sender.tab.id,
{
file: "content/lp-suppress-import-download-script-append-mv2.js",
runAt: "document_start",
},
undefined,
);
});
});
describe("port messages", () => {
@@ -126,9 +167,7 @@ describe("FilelessImporterBackground ", () => {
beforeEach(async () => {
jest.spyOn(authService, "getAuthStatus").mockResolvedValue(AuthenticationStatus.Unlocked);
jest.spyOn(configService, "getFeatureFlag").mockResolvedValue(true);
jest
.spyOn(filelessImporterBackground as any, "removeIndividualVault")
.mockResolvedValue(false);
(firstValueFrom as jest.Mock).mockResolvedValue(false);
triggerRuntimeOnConnectEvent(createPortSpyMock(FilelessImportPort.NotificationBar));
triggerRuntimeOnConnectEvent(createPortSpyMock(FilelessImportPort.LpImporter));
await flushPromises();

View File

@@ -11,6 +11,7 @@ import { ImportServiceAbstraction } from "@bitwarden/importer/core";
import NotificationBackground from "../../autofill/background/notification.background";
import { BrowserApi } from "../../platform/browser/browser-api";
import { FilelessImporterInjectedScriptsConfig } from "../config/fileless-importer-injected-scripts";
import {
FilelessImportPort,
FilelessImportType,
@@ -22,6 +23,7 @@ import {
LpImporterMessageHandlers,
FilelessImporterBackground as FilelessImporterBackgroundInterface,
FilelessImportPortMessage,
SuppressDownloadScriptInjectionConfig,
} from "./abstractions/fileless-importer.background";
class FilelessImporterBackground implements FilelessImporterBackgroundInterface {
@@ -108,6 +110,23 @@ class FilelessImporterBackground implements FilelessImporterBackgroundInterface
await this.notificationBackground.requestFilelessImport(tab, importType);
}
/**
* Injects the script used to suppress the download of the LP importer export file.
*
* @param sender - The sender of the message.
* @param injectionConfig - The configuration for the injection.
*/
private async injectScriptConfig(
sender: chrome.runtime.MessageSender,
injectionConfig: SuppressDownloadScriptInjectionConfig,
) {
await BrowserApi.executeScriptInTab(
sender.tab.id,
{ file: injectionConfig.file, runAt: "document_start" },
injectionConfig.scriptingApiDetails,
);
}
/**
* Triggers the download of the CSV file from the LP importer. This is triggered
* when the user opts to not save the export to Bitwarden within the notification bar.
@@ -200,6 +219,12 @@ class FilelessImporterBackground implements FilelessImporterBackgroundInterface
switch (port.name) {
case FilelessImportPort.LpImporter:
this.lpImporterPort = port;
await this.injectScriptConfig(
port.sender,
BrowserApi.manifestVersion === 3
? FilelessImporterInjectedScriptsConfig.LpSuppressImportDownload.mv3
: FilelessImporterInjectedScriptsConfig.LpSuppressImportDownload.mv2,
);
break;
case FilelessImportPort.NotificationBar:
this.importNotificationsPort = port;

View File

@@ -0,0 +1,22 @@
import { SuppressDownloadScriptInjectionConfig } from "../background/abstractions/fileless-importer.background";
type FilelessImporterInjectedScriptsConfigurations = {
LpSuppressImportDownload: {
mv2: SuppressDownloadScriptInjectionConfig;
mv3: SuppressDownloadScriptInjectionConfig;
};
};
const FilelessImporterInjectedScriptsConfig: FilelessImporterInjectedScriptsConfigurations = {
LpSuppressImportDownload: {
mv2: {
file: "content/lp-suppress-import-download-script-append-mv2.js",
},
mv3: {
file: "content/lp-suppress-import-download.js",
scriptingApiDetails: { world: "MAIN" },
},
},
} as const;
export { FilelessImporterInjectedScriptsConfig };

View File

@@ -43,20 +43,6 @@ describe("LpFilelessImporter", () => {
expect(portSpy.disconnect).toHaveBeenCalled();
});
it("injects a script element that suppresses the download of the LastPass export", () => {
const script = document.createElement("script");
jest.spyOn(document, "createElement").mockReturnValue(script);
jest.spyOn(document.documentElement, "appendChild");
lpFilelessImporter.handleFeatureFlagVerification({ filelessImportEnabled: true });
expect(document.createElement).toHaveBeenCalledWith("script");
expect(document.documentElement.appendChild).toHaveBeenCalled();
expect(script.textContent).toContain(
"const defaultAppendChild = Element.prototype.appendChild;",
);
});
it("sets up an event listener for DOMContentLoaded that triggers the importer when the document ready state is `loading`", () => {
Object.defineProperty(document, "readyState", {
value: "loading",

View File

@@ -36,7 +36,6 @@ class LpFilelessImporter implements LpFilelessImporterInterface {
return;
}
this.suppressDownload();
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", this.loadImporter);
return;
@@ -52,46 +51,6 @@ class LpFilelessImporter implements LpFilelessImporterInterface {
this.postWindowMessage({ command: "triggerCsvDownload" });
}
/**
* Suppresses the download of the CSV file by overriding the `download` attribute of the
* anchor element that is created by the LP importer. This is done by injecting a script
* into the page that overrides the `appendChild` method of the `Element` prototype.
*/
private suppressDownload() {
const script = document.createElement("script");
script.textContent = `
let csvDownload = '';
let csvHref = '';
const defaultAppendChild = Element.prototype.appendChild;
Element.prototype.appendChild = function (newChild) {
if (newChild.nodeName.toLowerCase() === 'a' && newChild.download) {
csvDownload = newChild.download;
csvHref = newChild.href;
newChild.setAttribute('href', 'javascript:void(0)');
newChild.setAttribute('download', '');
Element.prototype.appendChild = defaultAppendChild;
}
return defaultAppendChild.call(this, newChild);
};
window.addEventListener('message', (event) => {
const command = event.data?.command;
if (event.source !== window || command !== 'triggerCsvDownload') {
return;
}
const anchor = document.createElement('a');
anchor.setAttribute('href', csvHref);
anchor.setAttribute('download', csvDownload);
document.body.appendChild(anchor);
anchor.click();
document.body.removeChild(anchor);
});
`;
document.documentElement.appendChild(script);
}
/**
* Initializes the importing mechanism used to import the CSV file into Bitwarden.
* This is done by observing the DOM for the addition of the LP importer element.

View File

@@ -0,0 +1,21 @@
describe("LP Suppress Import Download for Manifest v2", () => {
it("appends the `lp-suppress-import-download.js` script to the document element", () => {
let createdScriptElement: HTMLScriptElement;
jest.spyOn(window.document, "createElement");
jest.spyOn(window.document.documentElement, "appendChild").mockImplementation((node) => {
createdScriptElement = node as HTMLScriptElement;
return node;
});
require("./lp-suppress-import-download-script-append.mv2");
expect(window.document.createElement).toHaveBeenCalledWith("script");
expect(chrome.runtime.getURL).toHaveBeenCalledWith("content/lp-suppress-import-download.js");
expect(window.document.documentElement.appendChild).toHaveBeenCalledWith(
expect.any(HTMLScriptElement),
);
expect(createdScriptElement.src).toBe(
"chrome-extension://id/content/lp-suppress-import-download.js",
);
});
});

View File

@@ -0,0 +1,9 @@
/**
* This script handles injection of the LP suppress import download script into the document.
* This is required for manifest v2, but will be removed when we migrate fully to manifest v3.
*/
(function (globalContext) {
const script = globalContext.document.createElement("script");
script.src = chrome.runtime.getURL("content/lp-suppress-import-download.js");
globalContext.document.documentElement.appendChild(script);
})(window);

View File

@@ -0,0 +1,81 @@
import { flushPromises, postWindowMessage } from "../../autofill/spec/testing-utils";
describe("LP Suppress Import Download", () => {
const downloadAttribute = "file.csv";
const hrefAttribute = "https://example.com/file.csv";
const overridenHrefAttribute = "javascript:void(0)";
let anchor: HTMLAnchorElement;
beforeEach(() => {
jest.spyOn(Element.prototype, "appendChild");
jest.spyOn(window, "addEventListener");
require("./lp-suppress-import-download");
anchor = document.createElement("a");
anchor.download = downloadAttribute;
anchor.href = hrefAttribute;
anchor.click = jest.fn();
});
afterEach(() => {
jest.resetModules();
jest.clearAllMocks();
});
it("disables the automatic download anchor", () => {
document.body.appendChild(anchor);
expect(anchor.href).toBe(overridenHrefAttribute);
expect(anchor.download).toBe("");
});
it("triggers the CSVDownload when receiving a `triggerCsvDownload` window message", async () => {
window.document.createElement = jest.fn(() => anchor);
jest.spyOn(window, "removeEventListener");
document.body.appendChild(anchor);
// Precondition - Ensure the anchor in the document has overridden href and download attributes
expect(anchor.href).toBe(overridenHrefAttribute);
expect(anchor.download).toBe("");
postWindowMessage({ command: "triggerCsvDownload" });
await flushPromises();
expect(anchor.click).toHaveBeenCalled();
expect(anchor.href).toEqual(hrefAttribute);
expect(anchor.download).toEqual(downloadAttribute);
expect(window.removeEventListener).toHaveBeenCalledWith("message", expect.any(Function));
});
it("skips subsequent calls to trigger a CSVDownload", async () => {
window.document.createElement = jest.fn(() => anchor);
document.body.appendChild(anchor);
postWindowMessage({ command: "triggerCsvDownload" });
await flushPromises();
postWindowMessage({ command: "triggerCsvDownload" });
await flushPromises();
expect(anchor.click).toHaveBeenCalledTimes(1);
});
it("skips triggering the CSV download for window messages that do not have the correct command", () => {
document.body.appendChild(anchor);
postWindowMessage({ command: "notTriggerCsvDownload" });
expect(anchor.click).not.toHaveBeenCalled();
});
it("skips triggering the CSV download for window messages that do not have a data value", () => {
document.body.appendChild(anchor);
postWindowMessage(null);
expect(anchor.click).not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,50 @@
/**
* Handles intercepting the injection of the CSV download link, and ensures the
* download of the script is suppressed until the user opts to download the file.
* The download is triggered by a window message sent from the LpFilelessImporter
* content script.
*/
(function (globalContext) {
let csvDownload = "";
let csvHref = "";
let isCsvDownloadTriggered = false;
const defaultAppendChild = Element.prototype.appendChild;
Element.prototype.appendChild = function (newChild: Node) {
if (isAnchorElement(newChild) && newChild.download) {
csvDownload = newChild.download;
csvHref = newChild.href;
newChild.setAttribute("href", "javascript:void(0)");
newChild.setAttribute("download", "");
Element.prototype.appendChild = defaultAppendChild;
}
return defaultAppendChild.call(this, newChild);
};
function isAnchorElement(node: Node): node is HTMLAnchorElement {
return node.nodeName.toLowerCase() === "a";
}
const handleWindowMessage = (event: MessageEvent) => {
const command = event.data?.command;
if (
event.source !== globalContext ||
command !== "triggerCsvDownload" ||
isCsvDownloadTriggered
) {
return;
}
isCsvDownloadTriggered = true;
globalContext.removeEventListener("message", handleWindowMessage);
const anchor = globalContext.document.createElement("a");
anchor.setAttribute("href", csvHref);
anchor.setAttribute("download", csvDownload);
globalContext.document.body.appendChild(anchor);
anchor.click();
globalContext.document.body.removeChild(anchor);
};
globalContext.addEventListener("message", handleWindowMessage);
})(window);

View File

@@ -179,6 +179,7 @@ const mainConfig = {
"overlay/list": "./src/autofill/overlay/pages/list/bootstrap-autofill-overlay-list.ts",
"encrypt-worker": "../../libs/common/src/platform/services/cryptography/encrypt.worker.ts",
"content/lp-fileless-importer": "./src/tools/content/lp-fileless-importer.ts",
"content/lp-suppress-import-download": "./src/tools/content/lp-suppress-import-download.ts",
},
optimization: {
minimize: ENV !== "development",
@@ -276,6 +277,8 @@ if (manifestVersion == 2) {
// Manifest V2 background pages can be run through the regular build pipeline.
// Since it's a standard webpage.
mainConfig.entry.background = "./src/platform/background.ts";
mainConfig.entry["content/lp-suppress-import-download-script-append-mv2"] =
"./src/tools/content/lp-suppress-import-download-script-append.mv2.ts";
configs.push(mainConfig);
} else {

View File

@@ -60,11 +60,13 @@ import { MigrationBuilderService } from "@bitwarden/common/platform/services/mig
import { MigrationRunner } from "@bitwarden/common/platform/services/migration-runner";
import { NoopMessagingService } from "@bitwarden/common/platform/services/noop-messaging.service";
import { StateService } from "@bitwarden/common/platform/services/state.service";
import { StorageServiceProvider } from "@bitwarden/common/platform/services/storage-service.provider";
import {
ActiveUserStateProvider,
DerivedStateProvider,
GlobalStateProvider,
SingleUserStateProvider,
StateEventRunnerService,
StateProvider,
} from "@bitwarden/common/platform/state";
/* eslint-disable import/no-restricted-paths -- We need the implementation to inject, but generally these should not be accessed */
@@ -73,6 +75,7 @@ import { DefaultDerivedStateProvider } from "@bitwarden/common/platform/state/im
import { DefaultGlobalStateProvider } from "@bitwarden/common/platform/state/implementations/default-global-state.provider";
import { DefaultSingleUserStateProvider } from "@bitwarden/common/platform/state/implementations/default-single-user-state.provider";
import { DefaultStateProvider } from "@bitwarden/common/platform/state/implementations/default-state.provider";
import { StateEventRegistrarService } from "@bitwarden/common/platform/state/state-event-registrar.service";
import { MemoryStorageService as MemoryStorageServiceForStateProviders } from "@bitwarden/common/platform/state/storage/memory-storage.service";
/* eslint-enable import/no-restricted-paths */
import { AuditService } from "@bitwarden/common/services/audit.service";
@@ -208,6 +211,7 @@ export class Main {
derivedStateProvider: DerivedStateProvider;
stateProvider: StateProvider;
loginStrategyService: LoginStrategyServiceAbstraction;
stateEventRunnerService: StateEventRunnerService;
biometricStateService: BiometricStateService;
constructor() {
@@ -249,14 +253,26 @@ export class Main {
this.memoryStorageService = new MemoryStorageService();
this.memoryStorageForStateProviders = new MemoryStorageServiceForStateProviders();
this.globalStateProvider = new DefaultGlobalStateProvider(
this.memoryStorageForStateProviders,
const storageServiceProvider = new StorageServiceProvider(
this.storageService,
this.memoryStorageForStateProviders,
);
this.globalStateProvider = new DefaultGlobalStateProvider(storageServiceProvider);
const stateEventRegistrarService = new StateEventRegistrarService(
this.globalStateProvider,
storageServiceProvider,
);
this.stateEventRunnerService = new StateEventRunnerService(
this.globalStateProvider,
storageServiceProvider,
);
this.singleUserStateProvider = new DefaultSingleUserStateProvider(
this.memoryStorageForStateProviders,
this.storageService,
storageServiceProvider,
stateEventRegistrarService,
);
this.messagingService = new NoopMessagingService();
@@ -269,8 +285,8 @@ export class Main {
this.activeUserStateProvider = new DefaultActiveUserStateProvider(
this.accountService,
this.memoryStorageForStateProviders,
this.storageService,
storageServiceProvider,
stateEventRegistrarService,
);
this.derivedStateProvider = new DefaultDerivedStateProvider(
@@ -372,7 +388,7 @@ export class Main {
this.stateProvider,
);
this.providerService = new ProviderService(this.stateService);
this.providerService = new ProviderService(this.stateProvider);
this.organizationService = new OrganizationService(this.stateService, this.stateProvider);
@@ -530,6 +546,7 @@ export class Main {
this.stateService,
this.authService,
this.vaultTimeoutSettingsService,
this.stateEventRunnerService,
lockedCallback,
null,
);
@@ -638,7 +655,11 @@ export class Main {
this.collectionService.clear(userId as UserId),
this.policyService.clear(userId),
this.passwordGenerationService.clear(),
this.providerService.save(null, userId as UserId),
]);
await this.stateEventRunnerService.handleEvent("logout", userId as UserId);
await this.stateService.clean();
process.env.BW_SESSION = null;
}

View File

@@ -23,6 +23,7 @@ import { SettingsService } from "@bitwarden/common/abstractions/settings.service
import { VaultTimeoutSettingsService } from "@bitwarden/common/abstractions/vault-timeout/vault-timeout-settings.service";
import { VaultTimeoutService } from "@bitwarden/common/abstractions/vault-timeout/vault-timeout.service";
import { InternalPolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
import { ProviderService } from "@bitwarden/common/admin-console/abstractions/provider.service";
import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service";
import { KeyConnectorService } from "@bitwarden/common/auth/abstractions/key-connector.service";
import { UserVerificationService } from "@bitwarden/common/auth/abstractions/user-verification/user-verification.service.abstraction";
@@ -39,6 +40,7 @@ import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/pl
import { StateService } from "@bitwarden/common/platform/abstractions/state.service";
import { SystemService } from "@bitwarden/common/platform/abstractions/system.service";
import { BiometricStateService } from "@bitwarden/common/platform/biometrics/biometric-state.service";
import { StateEventRunnerService } from "@bitwarden/common/platform/state";
import { PasswordGenerationServiceAbstraction } from "@bitwarden/common/tools/generator/password";
import { UserId } from "@bitwarden/common/types/guid";
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
@@ -149,6 +151,8 @@ export class AppComponent implements OnInit, OnDestroy {
private configService: ConfigServiceAbstraction,
private dialogService: DialogService,
private biometricStateService: BiometricStateService,
private stateEventRunnerService: StateEventRunnerService,
private providerService: ProviderService,
) {}
ngOnInit() {
@@ -219,13 +223,13 @@ export class AppComponent implements OnInit, OnDestroy {
const currentUser = await this.stateService.getUserId();
const accounts = await firstValueFrom(this.stateService.accounts$);
await this.vaultTimeoutService.lock(currentUser);
// FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling.
// eslint-disable-next-line @typescript-eslint/no-floating-promises
Promise.all(
Object.keys(accounts)
.filter((u) => u !== currentUser)
.map((u) => this.vaultTimeoutService.lock(u)),
);
for (const account of Object.keys(accounts)) {
if (account === currentUser) {
continue;
}
await this.vaultTimeoutService.lock(account);
}
break;
}
case "locked":
@@ -582,6 +586,9 @@ export class AppComponent implements OnInit, OnDestroy {
await this.policyService.clear(userBeingLoggedOut);
await this.keyConnectorService.clear();
await this.biometricStateService.logout(userBeingLoggedOut as UserId);
await this.providerService.save(null, userBeingLoggedOut as UserId);
await this.stateEventRunnerService.handleEvent("logout", userBeingLoggedOut as UserId);
preLogoutActiveUserId = this.activeUserId;
await this.stateService.clean({ userId: userBeingLoggedOut });

View File

@@ -13,11 +13,13 @@ import { MigrationBuilderService } from "@bitwarden/common/platform/services/mig
import { MigrationRunner } from "@bitwarden/common/platform/services/migration-runner";
import { NoopMessagingService } from "@bitwarden/common/platform/services/noop-messaging.service";
/* eslint-disable import/no-restricted-paths -- We need the implementation to inject, but generally this should not be accessed */
import { StorageServiceProvider } from "@bitwarden/common/platform/services/storage-service.provider";
import { DefaultActiveUserStateProvider } from "@bitwarden/common/platform/state/implementations/default-active-user-state.provider";
import { DefaultDerivedStateProvider } from "@bitwarden/common/platform/state/implementations/default-derived-state.provider";
import { DefaultGlobalStateProvider } from "@bitwarden/common/platform/state/implementations/default-global-state.provider";
import { DefaultSingleUserStateProvider } from "@bitwarden/common/platform/state/implementations/default-single-user-state.provider";
import { DefaultStateProvider } from "@bitwarden/common/platform/state/implementations/default-state.provider";
import { StateEventRegistrarService } from "@bitwarden/common/platform/state/state-event-registrar.service";
import { MemoryStorageService as MemoryStorageServiceForStateProviders } from "@bitwarden/common/platform/state/storage/memory-storage.service";
/* eslint-enable import/no-restricted-paths */
@@ -104,10 +106,11 @@ export class Main {
this.storageService = new ElectronStorageService(app.getPath("userData"), storageDefaults);
this.memoryStorageService = new MemoryStorageService();
this.memoryStorageForStateProviders = new MemoryStorageServiceForStateProviders();
const globalStateProvider = new DefaultGlobalStateProvider(
this.memoryStorageForStateProviders,
const storageServiceProvider = new StorageServiceProvider(
this.storageService,
this.memoryStorageForStateProviders,
);
const globalStateProvider = new DefaultGlobalStateProvider(storageServiceProvider);
const accountService = new AccountServiceImplementation(
new NoopMessagingService(),
@@ -115,13 +118,18 @@ export class Main {
globalStateProvider,
);
const stateEventRegistrarService = new StateEventRegistrarService(
globalStateProvider,
storageServiceProvider,
);
const stateProvider = new DefaultStateProvider(
new DefaultActiveUserStateProvider(
accountService,
this.memoryStorageForStateProviders,
this.storageService,
storageServiceProvider,
stateEventRegistrarService,
),
new DefaultSingleUserStateProvider(this.memoryStorageForStateProviders, this.storageService),
new DefaultSingleUserStateProvider(storageServiceProvider, stateEventRegistrarService),
globalStateProvider,
new DefaultDerivedStateProvider(this.memoryStorageForStateProviders),
);

View File

@@ -1,6 +1,6 @@
{
"name": "@bitwarden/web-vault",
"version": "2024.2.3",
"version": "2024.2.4",
"scripts": {
"build:oss": "webpack",
"build:bit": "webpack -c ../../bitwarden_license/bit-web/webpack.config.js",

View File

@@ -8,6 +8,7 @@ import {
canAccessBillingTab,
canAccessGroupsTab,
canAccessMembersTab,
canAccessOrgAdmin,
canAccessReportingTab,
canAccessSettingsTab,
canAccessVaultTab,
@@ -43,7 +44,7 @@ import { AdminConsoleLogo } from "../../icons/admin-console-logo";
export class OrganizationLayoutComponent implements OnInit, OnDestroy {
protected readonly logo = AdminConsoleLogo;
protected orgFilter = (org: Organization) => org.isAdmin;
protected orgFilter = (org: Organization) => canAccessOrgAdmin(org);
organization$: Observable<Organization>;
showPaymentAndHistory$: Observable<boolean>;

View File

@@ -23,6 +23,7 @@ import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.servic
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { StateService } from "@bitwarden/common/platform/abstractions/state.service";
import { BiometricStateService } from "@bitwarden/common/platform/biometrics/biometric-state.service";
import { StateEventRunnerService } from "@bitwarden/common/platform/state";
import { PasswordGenerationServiceAbstraction } from "@bitwarden/common/tools/generator/password";
import { UserId } from "@bitwarden/common/types/guid";
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
@@ -89,6 +90,7 @@ export class AppComponent implements OnDestroy, OnInit {
private configService: ConfigServiceAbstraction,
private dialogService: DialogService,
private biometricStateService: BiometricStateService,
private stateEventRunnerService: StateEventRunnerService,
private paymentMethodWarningService: PaymentMethodWarningService,
private organizationService: OrganizationService,
) {}
@@ -284,6 +286,8 @@ export class AppComponent implements OnDestroy, OnInit {
this.paymentMethodWarningService.clear(),
]);
await this.stateEventRunnerService.handleEvent("logout", userId as UserId);
this.searchService.clearIndex();
this.authService.logOut(async () => {
if (expired) {

View File

@@ -14,7 +14,6 @@ import {
} from "@bitwarden/angular/services/injection-tokens";
import { JslibServicesModule } from "@bitwarden/angular/services/jslib-services.module";
import { ModalService as ModalServiceAbstraction } from "@bitwarden/angular/services/modal.service";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { LoginService as LoginServiceAbstraction } from "@bitwarden/common/auth/abstractions/login.service";
import { LoginService } from "@bitwarden/common/auth/services/login.service";
import { FileDownloadService } from "@bitwarden/common/platform/abstractions/file-download/file-download.service";
@@ -28,21 +27,16 @@ import { StateFactory } from "@bitwarden/common/platform/factories/state-factory
import { MemoryStorageService } from "@bitwarden/common/platform/services/memory-storage.service";
import { MigrationBuilderService } from "@bitwarden/common/platform/services/migration-builder.service";
import { MigrationRunner } from "@bitwarden/common/platform/services/migration-runner";
import {
ActiveUserStateProvider,
GlobalStateProvider,
SingleUserStateProvider,
} from "@bitwarden/common/platform/state";
// eslint-disable-next-line import/no-restricted-paths -- Implementation for memory storage
/* eslint-disable import/no-restricted-paths -- Implementation for memory storage */
import { StorageServiceProvider } from "@bitwarden/common/platform/services/storage-service.provider";
import { MemoryStorageService as MemoryStorageServiceForStateProviders } from "@bitwarden/common/platform/state/storage/memory-storage.service";
/* eslint-enable import/no-restricted-paths -- Implementation for memory storage */
import { PolicyListService } from "../admin-console/core/policy-list.service";
import { HtmlStorageService } from "../core/html-storage.service";
import { I18nService } from "../core/i18n.service";
import { WebActiveUserStateProvider } from "../platform/web-active-user-state.provider";
import { WebGlobalStateProvider } from "../platform/web-global-state.provider";
import { WebMigrationRunner } from "../platform/web-migration-runner";
import { WebSingleUserStateProvider } from "../platform/web-single-user-state.provider";
import { WebStorageServiceProvider } from "../platform/web-storage-service.provider";
import { WindowStorageService } from "../platform/window-storage.service";
import { CollectionAdminService } from "../vault/core/collection-admin.service";
@@ -124,24 +118,9 @@ import { WebPlatformUtilsService } from "./web-platform-utils.service";
useFactory: () => new WindowStorageService(window.localStorage),
},
{
provide: SingleUserStateProvider,
useClass: WebSingleUserStateProvider,
deps: [OBSERVABLE_MEMORY_STORAGE, OBSERVABLE_DISK_STORAGE, OBSERVABLE_DISK_LOCAL_STORAGE],
},
{
provide: ActiveUserStateProvider,
useClass: WebActiveUserStateProvider,
deps: [
AccountService,
OBSERVABLE_MEMORY_STORAGE,
OBSERVABLE_DISK_STORAGE,
OBSERVABLE_DISK_LOCAL_STORAGE,
],
},
{
provide: GlobalStateProvider,
useClass: WebGlobalStateProvider,
deps: [OBSERVABLE_MEMORY_STORAGE, OBSERVABLE_DISK_STORAGE, OBSERVABLE_DISK_LOCAL_STORAGE],
provide: StorageServiceProvider,
useClass: WebStorageServiceProvider,
deps: [OBSERVABLE_DISK_STORAGE, OBSERVABLE_MEMORY_STORAGE, OBSERVABLE_DISK_LOCAL_STORAGE],
},
{
provide: MigrationRunner,

View File

@@ -58,9 +58,9 @@ export class ProductSwitcherContentComponent {
// If the active route org doesn't have access to AC, find the first org that does.
const acOrg =
routeOrg != null && canAccessOrgAdmin(routeOrg) && routeOrg.enabled
routeOrg != null && canAccessOrgAdmin(routeOrg)
? routeOrg
: orgs.find((o) => canAccessOrgAdmin(o) && o.enabled);
: 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();

View File

@@ -1,44 +0,0 @@
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import {
AbstractMemoryStorageService,
AbstractStorageService,
ObservableStorageService,
} from "@bitwarden/common/platform/abstractions/storage.service";
import { UserKeyDefinition } from "@bitwarden/common/platform/state";
/* eslint-disable import/no-restricted-paths -- Needed to extend class & in platform owned code */
import { DefaultActiveUserStateProvider } from "@bitwarden/common/platform/state/implementations/default-active-user-state.provider";
import { StateDefinition } from "@bitwarden/common/platform/state/state-definition";
/* eslint-enable import/no-restricted-paths */
export class WebActiveUserStateProvider extends DefaultActiveUserStateProvider {
constructor(
accountService: AccountService,
memoryStorage: AbstractMemoryStorageService & ObservableStorageService,
sessionStorage: AbstractStorageService & ObservableStorageService,
private readonly diskLocalStorage: AbstractStorageService & ObservableStorageService,
) {
super(accountService, memoryStorage, sessionStorage);
}
protected override getLocationString(keyDefinition: UserKeyDefinition<unknown>): string {
return (
keyDefinition.stateDefinition.storageLocationOverrides["web"] ??
keyDefinition.stateDefinition.defaultStorageLocation
);
}
protected override getLocation(
stateDefinition: StateDefinition,
): AbstractStorageService & ObservableStorageService {
const location =
stateDefinition.storageLocationOverrides["web"] ?? stateDefinition.defaultStorageLocation;
switch (location) {
case "disk":
return this.diskStorage;
case "memory":
return this.memoryStorage;
case "disk-local":
return this.diskLocalStorage;
}
}
}

View File

@@ -1,42 +0,0 @@
import {
AbstractMemoryStorageService,
AbstractStorageService,
ObservableStorageService,
} from "@bitwarden/common/platform/abstractions/storage.service";
import { KeyDefinition } from "@bitwarden/common/platform/state";
/* eslint-disable import/no-restricted-paths -- Needed to extend class & in platform owned code*/
import { DefaultGlobalStateProvider } from "@bitwarden/common/platform/state/implementations/default-global-state.provider";
import { StateDefinition } from "@bitwarden/common/platform/state/state-definition";
/* eslint-enable import/no-restricted-paths */
export class WebGlobalStateProvider extends DefaultGlobalStateProvider {
constructor(
memoryStorage: AbstractMemoryStorageService & ObservableStorageService,
sessionStorage: AbstractStorageService & ObservableStorageService,
private readonly diskLocalStorage: AbstractStorageService & ObservableStorageService,
) {
super(memoryStorage, sessionStorage);
}
protected getLocationString(keyDefinition: KeyDefinition<unknown>): string {
return (
keyDefinition.stateDefinition.storageLocationOverrides["web"] ??
keyDefinition.stateDefinition.defaultStorageLocation
);
}
protected override getLocation(
stateDefinition: StateDefinition,
): AbstractStorageService & ObservableStorageService {
const location =
stateDefinition.storageLocationOverrides["web"] ?? stateDefinition.defaultStorageLocation;
switch (location) {
case "disk":
return this.diskStorage;
case "memory":
return this.memoryStorage;
case "disk-local":
return this.diskLocalStorage;
}
}
}

View File

@@ -1,43 +0,0 @@
import {
AbstractMemoryStorageService,
AbstractStorageService,
ObservableStorageService,
} from "@bitwarden/common/platform/abstractions/storage.service";
import { UserKeyDefinition } from "@bitwarden/common/platform/state";
/* eslint-disable import/no-restricted-paths -- Needed to extend service & and in platform owned file */
import { DefaultSingleUserStateProvider } from "@bitwarden/common/platform/state/implementations/default-single-user-state.provider";
import { StateDefinition } from "@bitwarden/common/platform/state/state-definition";
/* eslint-enable import/no-restricted-paths */
export class WebSingleUserStateProvider extends DefaultSingleUserStateProvider {
constructor(
memoryStorageService: AbstractMemoryStorageService & ObservableStorageService,
sessionStorageService: AbstractStorageService & ObservableStorageService,
private readonly diskLocalStorageService: AbstractStorageService & ObservableStorageService,
) {
super(memoryStorageService, sessionStorageService);
}
protected override getLocationString(keyDefinition: UserKeyDefinition<unknown>): string {
return (
keyDefinition.stateDefinition.storageLocationOverrides["web"] ??
keyDefinition.stateDefinition.defaultStorageLocation
);
}
protected override getLocation(
stateDefinition: StateDefinition,
): AbstractStorageService & ObservableStorageService {
const location =
stateDefinition.storageLocationOverrides["web"] ?? stateDefinition.defaultStorageLocation;
switch (location) {
case "disk":
return this.diskStorage;
case "memory":
return this.memoryStorage;
case "disk-local":
return this.diskLocalStorageService;
}
}
}

View File

@@ -135,6 +135,7 @@ import { MigrationBuilderService } from "@bitwarden/common/platform/services/mig
import { MigrationRunner } from "@bitwarden/common/platform/services/migration-runner";
import { NoopNotificationsService } from "@bitwarden/common/platform/services/noop-notifications.service";
import { StateService } from "@bitwarden/common/platform/services/state.service";
import { StorageServiceProvider } from "@bitwarden/common/platform/services/storage-service.provider";
import { ValidationService } from "@bitwarden/common/platform/services/validation.service";
import { WebCryptoFunctionService } from "@bitwarden/common/platform/services/web-crypto-function.service";
import {
@@ -150,6 +151,8 @@ import { DefaultDerivedStateProvider } from "@bitwarden/common/platform/state/im
import { DefaultGlobalStateProvider } from "@bitwarden/common/platform/state/implementations/default-global-state.provider";
import { DefaultSingleUserStateProvider } from "@bitwarden/common/platform/state/implementations/default-single-user-state.provider";
import { DefaultStateProvider } from "@bitwarden/common/platform/state/implementations/default-state.provider";
import { StateEventRegistrarService } from "@bitwarden/common/platform/state/state-event-registrar.service";
import { StateEventRunnerService } from "@bitwarden/common/platform/state/state-event-runner.service";
/* eslint-enable import/no-restricted-paths */
import { AvatarUpdateService } from "@bitwarden/common/services/account/avatar-update.service";
import { ApiService } from "@bitwarden/common/services/api.service";
@@ -551,6 +554,7 @@ import { ModalService } from "./modal.service";
StateServiceAbstraction,
AuthServiceAbstraction,
VaultTimeoutSettingsServiceAbstraction,
StateEventRunnerService,
LOCKED_CALLBACK,
LOGOUT_CALLBACK,
],
@@ -741,7 +745,7 @@ import { ModalService } from "./modal.service";
{
provide: ProviderServiceAbstraction,
useClass: ProviderService,
deps: [StateServiceAbstraction],
deps: [StateProvider],
},
{
provide: TwoFactorServiceAbstraction,
@@ -890,20 +894,35 @@ import { ModalService } from "./modal.service";
LogService,
],
},
{
provide: StorageServiceProvider,
useClass: StorageServiceProvider,
deps: [OBSERVABLE_DISK_STORAGE, OBSERVABLE_MEMORY_STORAGE],
},
{
provide: StateEventRegistrarService,
useClass: StateEventRegistrarService,
deps: [GlobalStateProvider, StorageServiceProvider],
},
{
provide: StateEventRunnerService,
useClass: StateEventRunnerService,
deps: [GlobalStateProvider, StorageServiceProvider],
},
{
provide: GlobalStateProvider,
useClass: DefaultGlobalStateProvider,
deps: [OBSERVABLE_MEMORY_STORAGE, OBSERVABLE_DISK_STORAGE],
deps: [StorageServiceProvider],
},
{
provide: ActiveUserStateProvider,
useClass: DefaultActiveUserStateProvider,
deps: [AccountServiceAbstraction, OBSERVABLE_MEMORY_STORAGE, OBSERVABLE_DISK_STORAGE],
deps: [AccountServiceAbstraction, StorageServiceProvider, StateEventRegistrarService],
},
{
provide: SingleUserStateProvider,
useClass: DefaultSingleUserStateProvider,
deps: [OBSERVABLE_MEMORY_STORAGE, OBSERVABLE_DISK_STORAGE],
deps: [StorageServiceProvider, StateEventRegistrarService],
},
{
provide: DerivedStateProvider,

View File

@@ -32,7 +32,8 @@ export class FakeGlobalStateProvider implements GlobalStateProvider {
states: Map<string, GlobalState<unknown>> = new Map();
get<T>(keyDefinition: KeyDefinition<T>): GlobalState<T> {
this.mock.get(keyDefinition);
let result = this.states.get(keyDefinition.fullName);
const cacheKey = `${keyDefinition.fullName}_${keyDefinition.stateDefinition.defaultStorageLocation}`;
let result = this.states.get(cacheKey);
if (result == null) {
let fake: FakeGlobalState<T>;
@@ -44,10 +45,10 @@ export class FakeGlobalStateProvider implements GlobalStateProvider {
}
fake.keyDefinition = keyDefinition;
result = fake;
this.states.set(keyDefinition.fullName, result);
this.states.set(cacheKey, result);
result = new FakeGlobalState<T>();
this.states.set(keyDefinition.fullName, result);
this.states.set(cacheKey, result);
}
return result as GlobalState<T>;
}
@@ -76,7 +77,8 @@ export class FakeSingleUserStateProvider implements SingleUserStateProvider {
if (keyDefinition instanceof KeyDefinition) {
keyDefinition = UserKeyDefinition.fromBaseKeyDefinition(keyDefinition);
}
let result = this.states.get(`${keyDefinition.fullName}_${userId}`);
const cacheKey = `${keyDefinition.fullName}_${keyDefinition.stateDefinition.defaultStorageLocation}_${userId}`;
let result = this.states.get(cacheKey);
if (result == null) {
let fake: FakeSingleUserState<T>;
@@ -88,7 +90,7 @@ export class FakeSingleUserStateProvider implements SingleUserStateProvider {
}
fake.keyDefinition = keyDefinition;
result = fake;
this.states.set(`${keyDefinition.fullName}_${userId}`, result);
this.states.set(cacheKey, result);
}
return result as SingleUserState<T>;
}
@@ -119,7 +121,8 @@ export class FakeActiveUserStateProvider implements ActiveUserStateProvider {
if (keyDefinition instanceof KeyDefinition) {
keyDefinition = UserKeyDefinition.fromBaseKeyDefinition(keyDefinition);
}
let result = this.states.get(keyDefinition.fullName);
const cacheKey = `${keyDefinition.fullName}_${keyDefinition.stateDefinition.defaultStorageLocation}`;
let result = this.states.get(cacheKey);
if (result == null) {
// Look for established mock
@@ -129,7 +132,7 @@ export class FakeActiveUserStateProvider implements ActiveUserStateProvider {
result = new FakeActiveUserState<T>(this.accountService);
}
result.keyDefinition = keyDefinition;
this.states.set(keyDefinition.fullName, result);
this.states.set(cacheKey, result);
}
return result as ActiveUserState<T>;
}

View File

@@ -65,6 +65,7 @@ export class FakeGlobalState<T> implements GlobalState<T> {
this.nextMock(newState);
return newState;
}
/** Tracks update values resolved by `FakeState.update` */
nextMock = jest.fn<void, [T]>();

View File

@@ -2,4 +2,5 @@ export * from "./utils";
export * from "./intercept-console";
export * from "./matchers";
export * from "./fake-state-provider";
export * from "./fake-state";
export * from "./fake-account-service";

View File

@@ -1,8 +1,9 @@
import { UserId } from "../../types/guid";
import { ProviderData } from "../models/data/provider.data";
import { Provider } from "../models/domain/provider";
export abstract class ProviderService {
get: (id: string) => Promise<Provider>;
getAll: () => Promise<Provider[]>;
save: (providers: { [id: string]: ProviderData }) => Promise<any>;
save: (providers: { [id: string]: ProviderData }, userId?: UserId) => Promise<any>;
}

View File

@@ -1,7 +1,56 @@
import { FakeAccountService, FakeStateProvider, mockAccountServiceWith } from "../../../spec";
import { FakeActiveUserState } from "../../../spec/fake-state";
import { Utils } from "../../platform/misc/utils";
import { UserId } from "../../types/guid";
import { ProviderUserStatusType, ProviderUserType } from "../enums";
import { ProviderData } from "../models/data/provider.data";
import { Provider } from "../models/domain/provider";
import { PROVIDERS } from "./provider.service";
import { PROVIDERS, ProviderService } from "./provider.service";
/**
* It is easier to read arrays than records in code, but we store a record
* in state. This helper methods lets us build provider arrays in tests
* and easily map them to records before storing them in state.
*/
function arrayToRecord(input: ProviderData[]): Record<string, ProviderData> {
if (input == null) {
return undefined;
}
return Object.fromEntries(input?.map((i) => [i.id, i]));
}
/**
* Builds a simple mock `ProviderData[]` array that can be used in tests
* to populate state.
* @param count The number of organizations to populate the list with. The
* function returns undefined if this is less than 1. The default value is 1.
* @param suffix A string to append to data fields on each provider.
* This defaults to the index of the organization in the list.
* @returns a `ProviderData[]` array that can be used to populate
* stateProvider.
*/
function buildMockProviders(count = 1, suffix?: string): ProviderData[] {
if (count < 1) {
return undefined;
}
function buildMockProvider(id: string, name: string): ProviderData {
const data = new ProviderData({} as any);
data.id = id;
data.name = name;
return data;
}
const mockProviders = [];
for (let i = 0; i < count; i++) {
const s = suffix ? suffix + i.toString() : i.toString();
mockProviders.push(buildMockProvider("provider" + s, "provider" + s));
}
return mockProviders;
}
describe("PROVIDERS key definition", () => {
const sut = PROVIDERS;
@@ -21,3 +70,75 @@ describe("PROVIDERS key definition", () => {
expect(result).toEqual(expectedResult);
});
});
describe("ProviderService", () => {
let providerService: ProviderService;
const fakeUserId = Utils.newGuid() as UserId;
let fakeAccountService: FakeAccountService;
let fakeStateProvider: FakeStateProvider;
let fakeActiveUserState: FakeActiveUserState<Record<string, ProviderData>>;
beforeEach(async () => {
fakeAccountService = mockAccountServiceWith(fakeUserId);
fakeStateProvider = new FakeStateProvider(fakeAccountService);
fakeActiveUserState = fakeStateProvider.activeUser.getFake(PROVIDERS);
providerService = new ProviderService(fakeStateProvider);
});
describe("getAll()", () => {
it("Returns an array of all providers stored in state", async () => {
const mockData: ProviderData[] = buildMockProviders(5);
fakeActiveUserState.nextState(arrayToRecord(mockData));
const providers = await providerService.getAll();
expect(providers).toHaveLength(5);
expect(providers).toEqual(mockData.map((x) => new Provider(x)));
});
it("Returns an empty array if no providers are found in state", async () => {
const mockData: ProviderData[] = undefined;
fakeActiveUserState.nextState(arrayToRecord(mockData));
const result = await providerService.getAll();
expect(result).toEqual([]);
});
});
describe("get()", () => {
it("Returns a single provider from state that matches the specified id", async () => {
const mockData = buildMockProviders(5);
fakeActiveUserState.nextState(arrayToRecord(mockData));
const result = await providerService.get(mockData[3].id);
expect(result).toEqual(new Provider(mockData[3]));
});
it("Returns undefined if the specified provider id is not found", async () => {
const result = await providerService.get("this-provider-does-not-exist");
expect(result).toBe(undefined);
});
});
describe("save()", () => {
it("replaces the entire provider list in state for the active user", async () => {
const originalData = buildMockProviders(10);
fakeActiveUserState.nextState(arrayToRecord(originalData));
const newData = buildMockProviders(10, "newData");
await providerService.save(arrayToRecord(newData));
const result = await providerService.getAll();
expect(result).toEqual(newData);
expect(result).not.toEqual(originalData);
});
// This is more or less a test for logouts
it("can replace state with null", async () => {
const originalData = buildMockProviders(2);
fakeActiveUserState.nextState(arrayToRecord(originalData));
await providerService.save(null);
const result = await providerService.getAll();
expect(result).toEqual([]);
expect(result).not.toEqual(originalData);
});
});
});

View File

@@ -1,5 +1,7 @@
import { StateService } from "../../platform/abstractions/state.service";
import { KeyDefinition, PROVIDERS_DISK } from "../../platform/state";
import { Observable, map, firstValueFrom } from "rxjs";
import { KeyDefinition, PROVIDERS_DISK, StateProvider } from "../../platform/state";
import { UserId } from "../../types/guid";
import { ProviderService as ProviderServiceAbstraction } from "../abstractions/provider.service";
import { ProviderData } from "../models/data/provider.data";
import { Provider } from "../models/domain/provider";
@@ -8,32 +10,34 @@ export const PROVIDERS = KeyDefinition.record<ProviderData>(PROVIDERS_DISK, "pro
deserializer: (obj: ProviderData) => obj,
});
function mapToSingleProvider(providerId: string) {
return map<Provider[], Provider>((providers) => providers?.find((p) => p.id === providerId));
}
export class ProviderService implements ProviderServiceAbstraction {
constructor(private stateService: StateService) {}
constructor(private stateProvider: StateProvider) {}
private providers$(userId?: UserId): Observable<Provider[] | undefined> {
return this.stateProvider
.getUserState$(PROVIDERS, userId)
.pipe(this.mapProviderRecordToArray());
}
private mapProviderRecordToArray() {
return map<Record<string, ProviderData>, Provider[]>((providers) =>
Object.values(providers ?? {})?.map((o) => new Provider(o)),
);
}
async get(id: string): Promise<Provider> {
const providers = await this.stateService.getProviders();
// eslint-disable-next-line
if (providers == null || !providers.hasOwnProperty(id)) {
return null;
}
return new Provider(providers[id]);
return await firstValueFrom(this.providers$().pipe(mapToSingleProvider(id)));
}
async getAll(): Promise<Provider[]> {
const providers = await this.stateService.getProviders();
const response: Provider[] = [];
for (const id in providers) {
// eslint-disable-next-line
if (providers.hasOwnProperty(id)) {
response.push(new Provider(providers[id]));
}
}
return response;
return await firstValueFrom(this.providers$());
}
async save(providers: { [id: string]: ProviderData }) {
await this.stateService.setProviders(providers);
async save(providers: { [id: string]: ProviderData }, userId?: UserId) {
await this.stateProvider.setUserState(PROVIDERS, providers, userId);
}
}

View File

@@ -0,0 +1,60 @@
import { map, Observable } from "rxjs";
import {
USER_NOTIFICATION_SETTINGS_DISK,
GlobalState,
KeyDefinition,
StateProvider,
} from "../../platform/state";
const ENABLE_ADDED_LOGIN_PROMPT = new KeyDefinition(
USER_NOTIFICATION_SETTINGS_DISK,
"enableAddedLoginPrompt",
{
deserializer: (value: boolean) => value ?? true,
},
);
const ENABLE_CHANGED_PASSWORD_PROMPT = new KeyDefinition(
USER_NOTIFICATION_SETTINGS_DISK,
"enableChangedPasswordPrompt",
{
deserializer: (value: boolean) => value ?? true,
},
);
export abstract class UserNotificationSettingsServiceAbstraction {
enableAddedLoginPrompt$: Observable<boolean>;
setEnableAddedLoginPrompt: (newValue: boolean) => Promise<void>;
enableChangedPasswordPrompt$: Observable<boolean>;
setEnableChangedPasswordPrompt: (newValue: boolean) => Promise<void>;
}
export class UserNotificationSettingsService implements UserNotificationSettingsServiceAbstraction {
private enableAddedLoginPromptState: GlobalState<boolean>;
readonly enableAddedLoginPrompt$: Observable<boolean>;
private enableChangedPasswordPromptState: GlobalState<boolean>;
readonly enableChangedPasswordPrompt$: Observable<boolean>;
constructor(private stateProvider: StateProvider) {
this.enableAddedLoginPromptState = this.stateProvider.getGlobal(ENABLE_ADDED_LOGIN_PROMPT);
this.enableAddedLoginPrompt$ = this.enableAddedLoginPromptState.state$.pipe(
map((x) => x ?? true),
);
this.enableChangedPasswordPromptState = this.stateProvider.getGlobal(
ENABLE_CHANGED_PASSWORD_PROMPT,
);
this.enableChangedPasswordPrompt$ = this.enableChangedPasswordPromptState.state$.pipe(
map((x) => x ?? true),
);
}
async setEnableAddedLoginPrompt(newValue: boolean): Promise<void> {
await this.enableAddedLoginPromptState.update(() => newValue);
}
async setEnableChangedPasswordPrompt(newValue: boolean): Promise<void> {
await this.enableChangedPasswordPromptState.update(() => newValue);
}
}

View File

@@ -2,7 +2,6 @@ import { Observable } from "rxjs";
import { OrganizationData } from "../../admin-console/models/data/organization.data";
import { PolicyData } from "../../admin-console/models/data/policy.data";
import { ProviderData } from "../../admin-console/models/data/provider.data";
import { Policy } from "../../admin-console/models/domain/policy";
import { AdminAuthRequestStorable } from "../../auth/models/domain/admin-auth-req-storable";
import { ForceSetPasswordReason } from "../../auth/models/domain/force-set-password-reason";
@@ -200,13 +199,6 @@ export abstract class StateService<T extends Account = Account> {
setDecryptedSends: (value: SendView[], options?: StorageOptions) => Promise<void>;
getDefaultUriMatch: (options?: StorageOptions) => Promise<UriMatchType>;
setDefaultUriMatch: (value: UriMatchType, options?: StorageOptions) => Promise<void>;
getDisableAddLoginNotification: (options?: StorageOptions) => Promise<boolean>;
setDisableAddLoginNotification: (value: boolean, options?: StorageOptions) => Promise<void>;
getDisableChangedPasswordNotification: (options?: StorageOptions) => Promise<boolean>;
setDisableChangedPasswordNotification: (
value: boolean,
options?: StorageOptions,
) => Promise<void>;
getDisableContextMenuItem: (options?: StorageOptions) => Promise<boolean>;
setDisableContextMenuItem: (value: boolean, options?: StorageOptions) => Promise<void>;
/**
@@ -378,8 +370,6 @@ export abstract class StateService<T extends Account = Account> {
* Sets the user's Pin, encrypted by the user key
*/
setProtectedPin: (value: string, options?: StorageOptions) => Promise<void>;
getProviders: (options?: StorageOptions) => Promise<{ [id: string]: ProviderData }>;
setProviders: (value: { [id: string]: ProviderData }, options?: StorageOptions) => Promise<void>;
getRefreshToken: (options?: StorageOptions) => Promise<string>;
setRefreshToken: (value: string, options?: StorageOptions) => Promise<void>;
getRememberedEmail: (options?: StorageOptions) => Promise<string>;

View File

@@ -2,7 +2,6 @@ import { Jsonify } from "type-fest";
import { OrganizationData } from "../../../admin-console/models/data/organization.data";
import { PolicyData } from "../../../admin-console/models/data/policy.data";
import { ProviderData } from "../../../admin-console/models/data/provider.data";
import { Policy } from "../../../admin-console/models/domain/policy";
import { AdminAuthRequestStorable } from "../../../auth/models/domain/admin-auth-req-storable";
import { ForceSetPasswordReason } from "../../../auth/models/domain/force-set-password-reason";
@@ -96,7 +95,6 @@ export class AccountData {
addEditCipherInfo?: AddEditCipherInfo;
eventCollection?: EventData[];
organizations?: { [id: string]: OrganizationData };
providers?: { [id: string]: ProviderData };
static fromJSON(obj: DeepJsonify<AccountData>): AccountData {
if (obj == null) {

View File

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

View File

@@ -1,3 +1,4 @@
import { mock } from "jest-mock-extended";
import { firstValueFrom, timeout } from "rxjs";
import { awaitAsync } from "../../../spec";
@@ -14,9 +15,11 @@ import { DefaultDerivedStateProvider } from "../state/implementations/default-de
import { DefaultGlobalStateProvider } from "../state/implementations/default-global-state.provider";
import { DefaultSingleUserStateProvider } from "../state/implementations/default-single-user-state.provider";
import { DefaultStateProvider } from "../state/implementations/default-state.provider";
/* eslint-disable import/no-restricted-paths */
import { StateEventRegistrarService } from "../state/state-event-registrar.service";
/* eslint-enable import/no-restricted-paths */
import { EnvironmentService } from "./environment.service";
import { StorageServiceProvider } from "./storage-service.provider";
// There are a few main states EnvironmentService could be in when first used
// 1. Not initialized, no active user. Hopefully not to likely but possible
@@ -26,6 +29,8 @@ import { EnvironmentService } from "./environment.service";
describe("EnvironmentService", () => {
let diskStorageService: FakeStorageService;
let memoryStorageService: FakeStorageService;
let storageServiceProvider: StorageServiceProvider;
const stateEventRegistrarService = mock<StateEventRegistrarService>();
let accountService: FakeAccountService;
let stateProvider: StateProvider;
@@ -37,16 +42,17 @@ describe("EnvironmentService", () => {
beforeEach(async () => {
diskStorageService = new FakeStorageService();
memoryStorageService = new FakeStorageService();
storageServiceProvider = new StorageServiceProvider(diskStorageService, memoryStorageService);
accountService = mockAccountServiceWith(undefined);
stateProvider = new DefaultStateProvider(
new DefaultActiveUserStateProvider(
accountService,
memoryStorageService as any,
diskStorageService,
storageServiceProvider,
stateEventRegistrarService,
),
new DefaultSingleUserStateProvider(memoryStorageService as any, diskStorageService),
new DefaultGlobalStateProvider(memoryStorageService as any, diskStorageService),
new DefaultSingleUserStateProvider(storageServiceProvider, stateEventRegistrarService),
new DefaultGlobalStateProvider(storageServiceProvider),
new DefaultDerivedStateProvider(memoryStorageService),
);

View File

@@ -3,7 +3,6 @@ import { Jsonify, JsonValue } from "type-fest";
import { OrganizationData } from "../../admin-console/models/data/organization.data";
import { PolicyData } from "../../admin-console/models/data/policy.data";
import { ProviderData } from "../../admin-console/models/data/provider.data";
import { Policy } from "../../admin-console/models/domain/policy";
import { AccountService } from "../../auth/abstractions/account.service";
import { AuthenticationStatus } from "../../auth/enums/authentication-status";
@@ -853,45 +852,6 @@ export class StateService<
);
}
async getDisableAddLoginNotification(options?: StorageOptions): Promise<boolean> {
return (
(await this.getGlobals(this.reconcileOptions(options, await this.defaultOnDiskOptions())))
?.disableAddLoginNotification ?? false
);
}
async setDisableAddLoginNotification(value: boolean, options?: StorageOptions): Promise<void> {
const globals = await this.getGlobals(
this.reconcileOptions(options, await this.defaultOnDiskOptions()),
);
globals.disableAddLoginNotification = value;
await this.saveGlobals(
globals,
this.reconcileOptions(options, await this.defaultOnDiskOptions()),
);
}
async getDisableChangedPasswordNotification(options?: StorageOptions): Promise<boolean> {
return (
(await this.getGlobals(this.reconcileOptions(options, await this.defaultOnDiskOptions())))
?.disableChangedPasswordNotification ?? false
);
}
async setDisableChangedPasswordNotification(
value: boolean,
options?: StorageOptions,
): Promise<void> {
const globals = await this.getGlobals(
this.reconcileOptions(options, await this.defaultOnDiskOptions()),
);
globals.disableChangedPasswordNotification = value;
await this.saveGlobals(
globals,
this.reconcileOptions(options, await this.defaultOnDiskOptions()),
);
}
async getDisableContextMenuItem(options?: StorageOptions): Promise<boolean> {
return (
(await this.getGlobals(this.reconcileOptions(options, await this.defaultOnDiskOptions())))
@@ -1860,27 +1820,6 @@ export class StateService<
);
}
@withPrototypeForObjectValues(ProviderData)
async getProviders(options?: StorageOptions): Promise<{ [id: string]: ProviderData }> {
return (
await this.getAccount(this.reconcileOptions(options, await this.defaultOnDiskOptions()))
)?.data?.providers;
}
async setProviders(
value: { [id: string]: ProviderData },
options?: StorageOptions,
): Promise<void> {
const account = await this.getAccount(
this.reconcileOptions(options, await this.defaultOnDiskOptions()),
);
account.data.providers = value;
await this.saveAccount(
account,
this.reconcileOptions(options, await this.defaultOnDiskOptions()),
);
}
async getRefreshToken(options?: StorageOptions): Promise<string> {
options = await this.getTimeoutBasedStorageOptions(options);
return (await this.getAccount(options))?.tokens?.refreshToken;

View File

@@ -3,17 +3,14 @@ import { mock } from "jest-mock-extended";
import { mockAccountServiceWith, trackEmissions } from "../../../../spec";
import { AuthenticationStatus } from "../../../auth/enums/authentication-status";
import { UserId } from "../../../types/guid";
import {
AbstractMemoryStorageService,
AbstractStorageService,
ObservableStorageService,
} from "../../abstractions/storage.service";
import { StorageServiceProvider } from "../../services/storage-service.provider";
import { StateEventRegistrarService } from "../state-event-registrar.service";
import { DefaultActiveUserStateProvider } from "./default-active-user-state.provider";
describe("DefaultActiveUserStateProvider", () => {
const memoryStorage = mock<AbstractMemoryStorageService & ObservableStorageService>();
const diskStorage = mock<AbstractStorageService & ObservableStorageService>();
const storageServiceProvider = mock<StorageServiceProvider>();
const stateEventRegistrarService = mock<StateEventRegistrarService>();
const userId = "userId" as UserId;
const accountInfo = {
id: userId,
@@ -25,7 +22,11 @@ describe("DefaultActiveUserStateProvider", () => {
let sut: DefaultActiveUserStateProvider;
beforeEach(() => {
sut = new DefaultActiveUserStateProvider(accountService, memoryStorage, diskStorage);
sut = new DefaultActiveUserStateProvider(
accountService,
storageServiceProvider,
stateEventRegistrarService,
);
});
afterEach(() => {

View File

@@ -2,13 +2,9 @@ import { Observable, map } from "rxjs";
import { AccountService } from "../../../auth/abstractions/account.service";
import { UserId } from "../../../types/guid";
import {
AbstractMemoryStorageService,
AbstractStorageService,
ObservableStorageService,
} from "../../abstractions/storage.service";
import { StorageServiceProvider } from "../../services/storage-service.provider";
import { KeyDefinition } from "../key-definition";
import { StateDefinition } from "../state-definition";
import { StateEventRegistrarService } from "../state-event-registrar.service";
import { UserKeyDefinition, isUserKeyDefinition } from "../user-key-definition";
import { ActiveUserState } from "../user-state";
import { ActiveUserStateProvider } from "../user-state.provider";
@@ -21,9 +17,9 @@ export class DefaultActiveUserStateProvider implements ActiveUserStateProvider {
activeUserId$: Observable<UserId | undefined>;
constructor(
protected readonly accountService: AccountService,
protected readonly memoryStorage: AbstractMemoryStorageService & ObservableStorageService,
protected readonly diskStorage: AbstractStorageService & ObservableStorageService,
private readonly accountService: AccountService,
private readonly storageServiceProvider: StorageServiceProvider,
private readonly stateEventRegistrarService: StateEventRegistrarService,
) {
this.activeUserId$ = this.accountService.activeAccount$.pipe(map((account) => account?.id));
}
@@ -32,7 +28,11 @@ export class DefaultActiveUserStateProvider implements ActiveUserStateProvider {
if (!isUserKeyDefinition(keyDefinition)) {
keyDefinition = UserKeyDefinition.fromBaseKeyDefinition(keyDefinition);
}
const cacheKey = this.buildCacheKey(keyDefinition);
const [location, storageService] = this.storageServiceProvider.get(
keyDefinition.stateDefinition.defaultStorageLocation,
keyDefinition.stateDefinition.storageLocationOverrides,
);
const cacheKey = this.buildCacheKey(location, keyDefinition);
const existingUserState = this.cache[cacheKey];
if (existingUserState != null) {
// I have to cast out of the unknown generic but this should be safe if rules
@@ -40,36 +40,17 @@ export class DefaultActiveUserStateProvider implements ActiveUserStateProvider {
return existingUserState as ActiveUserState<T>;
}
const newUserState = this.buildActiveUserState(keyDefinition);
const newUserState = new DefaultActiveUserState<T>(
keyDefinition,
this.accountService,
storageService,
this.stateEventRegistrarService,
);
this.cache[cacheKey] = newUserState;
return newUserState;
}
private buildCacheKey(keyDefinition: UserKeyDefinition<unknown>) {
return `${this.getLocationString(keyDefinition)}_${keyDefinition.fullName}`;
}
protected buildActiveUserState<T>(keyDefinition: UserKeyDefinition<T>): ActiveUserState<T> {
return new DefaultActiveUserState<T>(
keyDefinition,
this.accountService,
this.getLocation(keyDefinition.stateDefinition),
);
}
protected getLocationString(keyDefinition: UserKeyDefinition<unknown>): string {
return keyDefinition.stateDefinition.defaultStorageLocation;
}
protected getLocation(stateDefinition: StateDefinition) {
// The default implementations don't support the client overrides
// it is up to the client to extend this class and add that support
const location = stateDefinition.defaultStorageLocation;
switch (location) {
case "disk":
return this.diskStorage;
case "memory":
return this.memoryStorage;
}
private buildCacheKey(location: string, keyDefinition: UserKeyDefinition<unknown>) {
return `${location}_${keyDefinition.fullName}`;
}
}

View File

@@ -12,6 +12,7 @@ import { AccountInfo, AccountService } from "../../../auth/abstractions/account.
import { AuthenticationStatus } from "../../../auth/enums/authentication-status";
import { UserId } from "../../../types/guid";
import { StateDefinition } from "../state-definition";
import { StateEventRegistrarService } from "../state-event-registrar.service";
import { UserKeyDefinition } from "../user-key-definition";
import { DefaultActiveUserState } from "./default-active-user-state";
@@ -42,6 +43,7 @@ const testKeyDefinition = new UserKeyDefinition<TestState>(testStateDefinition,
describe("DefaultActiveUserState", () => {
const accountService = mock<AccountService>();
let diskStorageService: FakeStorageService;
const stateEventRegistrarService = mock<StateEventRegistrarService>();
let activeAccountSubject: BehaviorSubject<{ id: UserId } & AccountInfo>;
let userState: DefaultActiveUserState<TestState>;
@@ -50,7 +52,12 @@ describe("DefaultActiveUserState", () => {
accountService.activeAccount$ = activeAccountSubject;
diskStorageService = new FakeStorageService();
userState = new DefaultActiveUserState(testKeyDefinition, accountService, diskStorageService);
userState = new DefaultActiveUserState(
testKeyDefinition,
accountService,
diskStorageService,
stateEventRegistrarService,
);
});
const makeUserId = (id: string) => {
@@ -391,6 +398,48 @@ describe("DefaultActiveUserState", () => {
"No active user at this time.",
);
});
it.each([null, undefined])(
"should register user key definition when state transitions from null-ish (%s) to non-null",
async (startingValue: TestState | null) => {
diskStorageService.internalUpdateStore({
"user_00000000-0000-1000-a000-000000000001_fake_fake": startingValue,
});
await userState.update(() => ({ array: ["one"], date: new Date() }));
expect(stateEventRegistrarService.registerEvents).toHaveBeenCalledWith(testKeyDefinition);
},
);
it("should not register user key definition when state has preexisting value", async () => {
diskStorageService.internalUpdateStore({
"user_00000000-0000-1000-a000-000000000001_fake_fake": {
date: new Date(2019, 1),
array: [],
},
});
await userState.update(() => ({ array: ["one"], date: new Date() }));
expect(stateEventRegistrarService.registerEvents).not.toHaveBeenCalled();
});
it.each([null, undefined])(
"should not register user key definition when setting value to null-ish (%s) value",
async (updatedValue: TestState | null) => {
diskStorageService.internalUpdateStore({
"user_00000000-0000-1000-a000-000000000001_fake_fake": {
date: new Date(2019, 1),
array: [],
},
});
await userState.update(() => updatedValue);
expect(stateEventRegistrarService.registerEvents).not.toHaveBeenCalled();
},
);
});
describe("update races", () => {

View File

@@ -21,6 +21,7 @@ import {
AbstractStorageService,
ObservableStorageService,
} from "../../abstractions/storage.service";
import { StateEventRegistrarService } from "../state-event-registrar.service";
import { StateUpdateOptions, populateOptionsWithDefault } from "../state-update-options";
import { UserKeyDefinition } from "../user-key-definition";
import { ActiveUserState, CombinedState, activeMarker } from "../user-state";
@@ -42,6 +43,7 @@ export class DefaultActiveUserState<T> implements ActiveUserState<T> {
protected keyDefinition: UserKeyDefinition<T>,
private accountService: AccountService,
private chosenStorageLocation: AbstractStorageService & ObservableStorageService,
private stateEventRegistrarService: StateEventRegistrarService,
) {
this.activeUserId$ = this.accountService.activeAccount$.pipe(
// We only care about the UserId but we do want to know about no user as well.
@@ -150,6 +152,11 @@ export class DefaultActiveUserState<T> implements ActiveUserState<T> {
const newState = configureState(currentState, combinedDependencies);
await this.saveToStorage(key, newState);
if (newState != null && currentState == null) {
// Only register this state as something clearable on the first time it saves something
// worth deleting. This is helpful in making sure there is less of a race to adding events.
await this.stateEventRegistrarService.registerEvents(this.keyDefinition);
}
return [userId, newState];
}

View File

@@ -1,25 +1,21 @@
import {
AbstractMemoryStorageService,
AbstractStorageService,
ObservableStorageService,
} from "../../abstractions/storage.service";
import { StorageServiceProvider } from "../../services/storage-service.provider";
import { GlobalState } from "../global-state";
import { GlobalStateProvider } from "../global-state.provider";
import { KeyDefinition } from "../key-definition";
import { StateDefinition } from "../state-definition";
import { DefaultGlobalState } from "./default-global-state";
export class DefaultGlobalStateProvider implements GlobalStateProvider {
private globalStateCache: Record<string, GlobalState<unknown>> = {};
constructor(
protected readonly memoryStorage: AbstractMemoryStorageService & ObservableStorageService,
protected readonly diskStorage: AbstractStorageService & ObservableStorageService,
) {}
constructor(private storageServiceProvider: StorageServiceProvider) {}
get<T>(keyDefinition: KeyDefinition<T>): GlobalState<T> {
const cacheKey = this.buildCacheKey(keyDefinition);
const [location, storageService] = this.storageServiceProvider.get(
keyDefinition.stateDefinition.defaultStorageLocation,
keyDefinition.stateDefinition.storageLocationOverrides,
);
const cacheKey = this.buildCacheKey(location, keyDefinition);
const existingGlobalState = this.globalStateCache[cacheKey];
if (existingGlobalState != null) {
// The cast into the actual generic is safe because of rules around key definitions
@@ -27,30 +23,13 @@ export class DefaultGlobalStateProvider implements GlobalStateProvider {
return existingGlobalState as DefaultGlobalState<T>;
}
const newGlobalState = new DefaultGlobalState<T>(
keyDefinition,
this.getLocation(keyDefinition.stateDefinition),
);
const newGlobalState = new DefaultGlobalState<T>(keyDefinition, storageService);
this.globalStateCache[cacheKey] = newGlobalState;
return newGlobalState;
}
private buildCacheKey(keyDefinition: KeyDefinition<unknown>) {
return `${this.getLocationString(keyDefinition)}_${keyDefinition.fullName}`;
}
protected getLocationString(keyDefinition: KeyDefinition<unknown>): string {
return keyDefinition.stateDefinition.defaultStorageLocation;
}
protected getLocation(stateDefinition: StateDefinition) {
const location = stateDefinition.defaultStorageLocation;
switch (location) {
case "disk":
return this.diskStorage;
case "memory":
return this.memoryStorage;
}
private buildCacheKey(location: string, keyDefinition: KeyDefinition<unknown>) {
return `${location}_${keyDefinition.fullName}`;
}
}

View File

@@ -1,11 +1,7 @@
import { UserId } from "../../../types/guid";
import {
AbstractMemoryStorageService,
AbstractStorageService,
ObservableStorageService,
} from "../../abstractions/storage.service";
import { StorageServiceProvider } from "../../services/storage-service.provider";
import { KeyDefinition } from "../key-definition";
import { StateDefinition } from "../state-definition";
import { StateEventRegistrarService } from "../state-event-registrar.service";
import { UserKeyDefinition, isUserKeyDefinition } from "../user-key-definition";
import { SingleUserState } from "../user-state";
import { SingleUserStateProvider } from "../user-state.provider";
@@ -16,8 +12,8 @@ export class DefaultSingleUserStateProvider implements SingleUserStateProvider {
private cache: Record<string, SingleUserState<unknown>> = {};
constructor(
protected readonly memoryStorage: AbstractMemoryStorageService & ObservableStorageService,
protected readonly diskStorage: AbstractStorageService & ObservableStorageService,
private readonly storageServiceProvider: StorageServiceProvider,
private readonly stateEventRegistrarService: StateEventRegistrarService,
) {}
get<T>(
@@ -27,7 +23,11 @@ export class DefaultSingleUserStateProvider implements SingleUserStateProvider {
if (!isUserKeyDefinition(keyDefinition)) {
keyDefinition = UserKeyDefinition.fromBaseKeyDefinition(keyDefinition);
}
const cacheKey = this.buildCacheKey(userId, keyDefinition);
const [location, storageService] = this.storageServiceProvider.get(
keyDefinition.stateDefinition.defaultStorageLocation,
keyDefinition.stateDefinition.storageLocationOverrides,
);
const cacheKey = this.buildCacheKey(location, userId, keyDefinition);
const existingUserState = this.cache[cacheKey];
if (existingUserState != null) {
// I have to cast out of the unknown generic but this should be safe if rules
@@ -35,38 +35,21 @@ export class DefaultSingleUserStateProvider implements SingleUserStateProvider {
return existingUserState as SingleUserState<T>;
}
const newUserState = this.buildSingleUserState(userId, keyDefinition);
const newUserState = new DefaultSingleUserState<T>(
userId,
keyDefinition,
storageService,
this.stateEventRegistrarService,
);
this.cache[cacheKey] = newUserState;
return newUserState;
}
private buildCacheKey(userId: UserId, keyDefinition: UserKeyDefinition<unknown>) {
return `${this.getLocationString(keyDefinition)}_${keyDefinition.fullName}_${userId}`;
}
protected buildSingleUserState<T>(
private buildCacheKey(
location: string,
userId: UserId,
keyDefinition: UserKeyDefinition<T>,
): SingleUserState<T> {
return new DefaultSingleUserState<T>(
userId,
keyDefinition,
this.getLocation(keyDefinition.stateDefinition),
);
}
protected getLocationString(keyDefinition: UserKeyDefinition<unknown>): string {
return keyDefinition.stateDefinition.defaultStorageLocation;
}
protected getLocation(stateDefinition: StateDefinition) {
// The default implementations don't support the client overrides
// it is up to the client to extend this class and add that support
switch (stateDefinition.defaultStorageLocation) {
case "disk":
return this.diskStorage;
case "memory":
return this.memoryStorage;
}
keyDefinition: UserKeyDefinition<unknown>,
) {
return `${location}_${keyDefinition.fullName}_${userId}`;
}
}

View File

@@ -3,6 +3,7 @@
* @jest-environment ../shared/test.environment.ts
*/
import { mock } from "jest-mock-extended";
import { firstValueFrom, of } from "rxjs";
import { Jsonify } from "type-fest";
@@ -11,6 +12,7 @@ import { FakeStorageService } from "../../../../spec/fake-storage.service";
import { UserId } from "../../../types/guid";
import { Utils } from "../../misc/utils";
import { StateDefinition } from "../state-definition";
import { StateEventRegistrarService } from "../state-event-registrar.service";
import { UserKeyDefinition } from "../user-key-definition";
import { DefaultSingleUserState } from "./default-single-user-state";
@@ -42,11 +44,17 @@ const userKey = testKeyDefinition.buildKey(userId);
describe("DefaultSingleUserState", () => {
let diskStorageService: FakeStorageService;
let userState: DefaultSingleUserState<TestState>;
const stateEventRegistrarService = mock<StateEventRegistrarService>();
const newData = { date: new Date() };
beforeEach(() => {
diskStorageService = new FakeStorageService();
userState = new DefaultSingleUserState(userId, testKeyDefinition, diskStorageService);
userState = new DefaultSingleUserState(
userId,
testKeyDefinition,
diskStorageService,
stateEventRegistrarService,
);
});
afterEach(() => {
@@ -255,6 +263,49 @@ describe("DefaultSingleUserState", () => {
expect(emissions).toHaveLength(2);
expect(emissions).toEqual(expect.arrayContaining([initialState, newState]));
});
it.each([null, undefined])(
"should register user key definition when state transitions from null-ish (%s) to non-null",
async (startingValue: TestState | null) => {
const initialState: Record<string, TestState> = {};
initialState[userKey] = startingValue;
diskStorageService.internalUpdateStore(initialState);
await userState.update(() => ({ array: ["one"], date: new Date() }));
expect(stateEventRegistrarService.registerEvents).toHaveBeenCalledWith(testKeyDefinition);
},
);
it("should not register user key definition when state has preexisting value", async () => {
const initialState: Record<string, TestState> = {};
initialState[userKey] = {
date: new Date(2019, 1),
};
diskStorageService.internalUpdateStore(initialState);
await userState.update(() => ({ array: ["one"], date: new Date() }));
expect(stateEventRegistrarService.registerEvents).not.toHaveBeenCalled();
});
it.each([null, undefined])(
"should not register user key definition when setting value to null-ish (%s) value",
async (updatedValue: TestState | null) => {
const initialState: Record<string, TestState> = {};
initialState[userKey] = {
date: new Date(2019, 1),
};
diskStorageService.internalUpdateStore(initialState);
await userState.update(() => updatedValue);
expect(stateEventRegistrarService.registerEvents).not.toHaveBeenCalled();
},
);
});
describe("update races", () => {

View File

@@ -18,6 +18,7 @@ import {
AbstractStorageService,
ObservableStorageService,
} from "../../abstractions/storage.service";
import { StateEventRegistrarService } from "../state-event-registrar.service";
import { StateUpdateOptions, populateOptionsWithDefault } from "../state-update-options";
import { UserKeyDefinition } from "../user-key-definition";
import { CombinedState, SingleUserState } from "../user-state";
@@ -35,6 +36,7 @@ export class DefaultSingleUserState<T> implements SingleUserState<T> {
readonly userId: UserId,
private keyDefinition: UserKeyDefinition<T>,
private chosenLocation: AbstractStorageService & ObservableStorageService,
private stateEventRegistrarService: StateEventRegistrarService,
) {
this.storageKey = this.keyDefinition.buildKey(this.userId);
const initialStorageGet$ = defer(() => {
@@ -100,6 +102,11 @@ export class DefaultSingleUserState<T> implements SingleUserState<T> {
const newState = configureState(currentState, combinedDependencies);
await this.chosenLocation.save(this.storageKey, newState);
if (newState != null && currentState == null) {
// Only register this state as something clearable on the first time it saves something
// worth deleting. This is helpful in making sure there is less of a race to adding events.
await this.stateEventRegistrarService.registerEvents(this.keyDefinition);
}
return newState;
}

View File

@@ -1,8 +1,12 @@
import { mock } from "jest-mock-extended";
import { mockAccountServiceWith } from "../../../../spec/fake-account-service";
import { FakeStorageService } from "../../../../spec/fake-storage.service";
import { UserId } from "../../../types/guid";
import { StorageServiceProvider } from "../../services/storage-service.provider";
import { KeyDefinition } from "../key-definition";
import { StateDefinition } from "../state-definition";
import { StateEventRegistrarService } from "../state-event-registrar.service";
import { DefaultActiveUserState } from "./default-active-user-state";
import { DefaultActiveUserStateProvider } from "./default-active-user-state.provider";
@@ -12,6 +16,9 @@ import { DefaultSingleUserState } from "./default-single-user-state";
import { DefaultSingleUserStateProvider } from "./default-single-user-state.provider";
describe("Specific State Providers", () => {
const storageServiceProvider = mock<StorageServiceProvider>();
const stateEventRegistrarService = mock<StateEventRegistrarService>();
let singleSut: DefaultSingleUserStateProvider;
let activeSut: DefaultActiveUserStateProvider;
let globalSut: DefaultGlobalStateProvider;
@@ -19,19 +26,20 @@ describe("Specific State Providers", () => {
const fakeUser1 = "00000000-0000-1000-a000-000000000001" as UserId;
beforeEach(() => {
storageServiceProvider.get.mockImplementation((location) => {
return [location, new FakeStorageService()];
});
singleSut = new DefaultSingleUserStateProvider(
new FakeStorageService() as any,
new FakeStorageService(),
storageServiceProvider,
stateEventRegistrarService,
);
activeSut = new DefaultActiveUserStateProvider(
mockAccountServiceWith(null),
new FakeStorageService() as any,
new FakeStorageService(),
);
globalSut = new DefaultGlobalStateProvider(
new FakeStorageService() as any,
new FakeStorageService(),
storageServiceProvider,
stateEventRegistrarService,
);
globalSut = new DefaultGlobalStateProvider(storageServiceProvider);
});
const fakeDiskStateDefinition = new StateDefinition("fake", "disk");

View File

@@ -4,7 +4,7 @@ export { DerivedState } from "./derived-state";
export { GlobalState } from "./global-state";
export { StateProvider } from "./state.provider";
export { GlobalStateProvider } from "./global-state.provider";
export { ActiveUserState, SingleUserState } from "./user-state";
export { ActiveUserState, SingleUserState, CombinedState } from "./user-state";
export { ActiveUserStateProvider, SingleUserStateProvider } from "./user-state.provider";
export { KeyDefinition } from "./key-definition";
export { StateUpdateOptions } from "./state-update-options";

View File

@@ -63,6 +63,10 @@ export const VAULT_FILTER_DISK = new StateDefinition("vaultFilter", "disk", {
web: "disk-local",
});
export const USER_NOTIFICATION_SETTINGS_DISK = new StateDefinition(
"userNotificationSettings",
"disk",
);
export const CLEAR_EVENT_DISK = new StateDefinition("clearEvent", "disk");
export const NEW_WEB_LAYOUT_BANNER_DISK = new StateDefinition("newWebLayoutBanner", "disk", {

View File

@@ -11,6 +11,7 @@ import { MessagingService } from "../../platform/abstractions/messaging.service"
import { PlatformUtilsService } from "../../platform/abstractions/platform-utils.service";
import { StateService } from "../../platform/abstractions/state.service";
import { Account } from "../../platform/models/domain/account";
import { StateEventRunnerService } from "../../platform/state";
import { CipherService } from "../../vault/abstractions/cipher.service";
import { CollectionService } from "../../vault/abstractions/collection.service";
import { FolderService } from "../../vault/abstractions/folder/folder.service.abstraction";
@@ -28,6 +29,7 @@ describe("VaultTimeoutService", () => {
let stateService: MockProxy<StateService>;
let authService: MockProxy<AuthService>;
let vaultTimeoutSettingsService: MockProxy<VaultTimeoutSettingsService>;
let stateEventRunnerService: MockProxy<StateEventRunnerService>;
let lockedCallback: jest.Mock<Promise<void>, [userId: string]>;
let loggedOutCallback: jest.Mock<Promise<void>, [expired: boolean, userId?: string]>;
@@ -48,6 +50,7 @@ describe("VaultTimeoutService", () => {
stateService = mock();
authService = mock();
vaultTimeoutSettingsService = mock();
stateEventRunnerService = mock();
lockedCallback = jest.fn();
loggedOutCallback = jest.fn();
@@ -73,6 +76,7 @@ describe("VaultTimeoutService", () => {
stateService,
authService,
vaultTimeoutSettingsService,
stateEventRunnerService,
lockedCallback,
loggedOutCallback,
);
@@ -103,7 +107,8 @@ describe("VaultTimeoutService", () => {
return Promise.resolve(accounts[userId]?.authStatus);
});
stateService.getIsAuthenticated.mockImplementation((options) => {
return Promise.resolve(accounts[options.userId]?.isAuthenticated);
// Just like actual state service, if no userId is given fallback to active userId
return Promise.resolve(accounts[options.userId ?? globalSetups?.userId]?.isAuthenticated);
});
vaultTimeoutSettingsService.getVaultTimeout.mockImplementation((userId) => {
@@ -337,4 +342,80 @@ describe("VaultTimeoutService", () => {
expectNoAction("1");
});
});
describe("lock", () => {
const setupLock = () => {
setupAccounts(
{
user1: {
authStatus: AuthenticationStatus.Unlocked,
isAuthenticated: true,
},
user2: {
authStatus: AuthenticationStatus.Unlocked,
isAuthenticated: true,
},
},
{
userId: "user1",
},
);
};
it("should call state event runner with currently active user if no user passed into lock", async () => {
setupLock();
await vaultTimeoutService.lock();
expect(stateEventRunnerService.handleEvent).toHaveBeenCalledWith("lock", "user1");
});
it("should call messaging service locked message if no user passed into lock", async () => {
setupLock();
await vaultTimeoutService.lock();
// Currently these pass `undefined` (or what they were given) as the userId back
// but we could change this to give the user that was locked (active) to these methods
// so they don't have to get it their own way, but that is a behavioral change that needs
// to be tested.
expect(messagingService.send).toHaveBeenCalledWith("locked", { userId: undefined });
});
it("should call locked callback if no user passed into lock", async () => {
setupLock();
await vaultTimeoutService.lock();
// Currently these pass `undefined` (or what they were given) as the userId back
// but we could change this to give the user that was locked (active) to these methods
// so they don't have to get it their own way, but that is a behavioral change that needs
// to be tested.
expect(lockedCallback).toHaveBeenCalledWith(undefined);
});
it("should call state event runner with user passed into lock", async () => {
setupLock();
await vaultTimeoutService.lock("user2");
expect(stateEventRunnerService.handleEvent).toHaveBeenCalledWith("lock", "user2");
});
it("should call messaging service locked message with user passed into lock", async () => {
setupLock();
await vaultTimeoutService.lock("user2");
expect(messagingService.send).toHaveBeenCalledWith("locked", { userId: "user2" });
});
it("should call locked callback with user passed into lock", async () => {
setupLock();
await vaultTimeoutService.lock("user2");
expect(lockedCallback).toHaveBeenCalledWith("user2");
});
});
});

View File

@@ -11,6 +11,8 @@ import { CryptoService } from "../../platform/abstractions/crypto.service";
import { MessagingService } from "../../platform/abstractions/messaging.service";
import { PlatformUtilsService } from "../../platform/abstractions/platform-utils.service";
import { StateService } from "../../platform/abstractions/state.service";
import { StateEventRunnerService } from "../../platform/state";
import { UserId } from "../../types/guid";
import { CipherService } from "../../vault/abstractions/cipher.service";
import { CollectionService } from "../../vault/abstractions/collection.service";
import { FolderService } from "../../vault/abstractions/folder/folder.service.abstraction";
@@ -29,6 +31,7 @@ export class VaultTimeoutService implements VaultTimeoutServiceAbstraction {
private stateService: StateService,
private authService: AuthService,
private vaultTimeoutSettingsService: VaultTimeoutSettingsService,
private stateEventRunnerService: StateEventRunnerService,
private lockedCallback: (userId?: string) => Promise<void> = null,
private loggedOutCallback: (expired: boolean, userId?: string) => Promise<void> = null,
) {}
@@ -81,7 +84,9 @@ export class VaultTimeoutService implements VaultTimeoutServiceAbstraction {
await this.logOut(userId);
}
if (userId == null || userId === (await this.stateService.getUserId())) {
const currentUserId = await this.stateService.getUserId();
if (userId == null || userId === currentUserId) {
this.searchService.clearIndex();
await this.folderService.clearCache();
await this.collectionService.clearActiveUserCache();
@@ -98,6 +103,11 @@ export class VaultTimeoutService implements VaultTimeoutServiceAbstraction {
await this.cipherService.clearCache(userId);
await this.stateEventRunnerService.handleEvent("lock", (userId ?? currentUserId) as UserId);
// FIXME: We should send the userId of the user that was locked, in the case of this method being passed
// undefined then it should give back the currentUserId. Better yet, this method shouldn't take
// an undefined userId at all. All receivers need to be checked for how they handle getting undefined.
this.messagingService.send("locked", { userId: userId });
if (this.lockedCallback != null) {

View File

@@ -23,6 +23,7 @@ import { ClearClipboardDelayMigrator } from "./migrations/25-move-clear-clipboar
import { RevertLastSyncMigrator } from "./migrations/26-revert-move-last-sync-to-state-provider";
import { BadgeSettingsMigrator } from "./migrations/27-move-badge-settings-to-state-providers";
import { MoveBiometricUnlockToStateProviders } from "./migrations/28-move-biometric-unlock-to-state-providers";
import { UserNotificationSettingsKeyMigrator } from "./migrations/29-move-user-notification-settings-to-state-provider";
import { FixPremiumMigrator } from "./migrations/3-fix-premium";
import { RemoveEverBeenUnlockedMigrator } from "./migrations/4-remove-ever-been-unlocked";
import { AddKeyTypeToOrgKeysMigrator } from "./migrations/5-add-key-type-to-org-keys";
@@ -33,7 +34,7 @@ import { MoveBrowserSettingsToGlobal } from "./migrations/9-move-browser-setting
import { MinVersionMigrator } from "./migrations/min-version";
export const MIN_VERSION = 2;
export const CURRENT_VERSION = 28;
export const CURRENT_VERSION = 29;
export type MinVersion = typeof MIN_VERSION;
export function createMigrationBuilder() {
@@ -64,7 +65,8 @@ export function createMigrationBuilder() {
.with(ClearClipboardDelayMigrator, 24, 25)
.with(RevertLastSyncMigrator, 25, 26)
.with(BadgeSettingsMigrator, 26, 27)
.with(MoveBiometricUnlockToStateProviders, 27, CURRENT_VERSION);
.with(MoveBiometricUnlockToStateProviders, 27, 28)
.with(UserNotificationSettingsKeyMigrator, 28, CURRENT_VERSION);
}
export async function currentVersion(

View File

@@ -0,0 +1,145 @@
import { any, MockProxy } from "jest-mock-extended";
import { MigrationHelper } from "../migration-helper";
import { mockMigrationHelper } from "../migration-helper.spec";
import { ProviderMigrator } from "./28-move-provider-state-to-state-provider";
function exampleProvider1() {
return JSON.stringify({
id: "id",
name: "name",
status: 0,
type: 0,
enabled: true,
useEvents: true,
});
}
function exampleJSON() {
return {
global: {
otherStuff: "otherStuff1",
},
authenticatedAccounts: ["user-1", "user-2"],
"user-1": {
data: {
providers: {
"provider-id-1": exampleProvider1(),
"provider-id-2": {
// ...
},
},
otherStuff: "overStuff2",
},
otherStuff: "otherStuff3",
},
"user-2": {
data: {
otherStuff: "otherStuff4",
},
otherStuff: "otherStuff5",
},
};
}
function rollbackJSON() {
return {
"user_user-1_providers_providers": {
"provider-id-1": exampleProvider1(),
"provider-id-2": {
// ...
},
},
"user_user-2_providers_providers": null as any,
global: {
otherStuff: "otherStuff1",
},
authenticatedAccounts: ["user-1", "user-2"],
"user-1": {
data: {
otherStuff: "overStuff2",
},
otherStuff: "otherStuff3",
},
"user-2": {
data: {
otherStuff: "otherStuff4",
},
otherStuff: "otherStuff5",
},
};
}
describe("ProviderMigrator", () => {
let helper: MockProxy<MigrationHelper>;
let sut: ProviderMigrator;
const keyDefinitionLike = {
key: "providers",
stateDefinition: {
name: "providers",
},
};
describe("migrate", () => {
beforeEach(() => {
helper = mockMigrationHelper(exampleJSON(), 28);
sut = new ProviderMigrator(27, 28);
});
it("should remove providers from all accounts", async () => {
await sut.migrate(helper);
expect(helper.set).toHaveBeenCalledWith("user-1", {
data: {
otherStuff: "overStuff2",
},
otherStuff: "otherStuff3",
});
});
it("should set providers value for each account", async () => {
await sut.migrate(helper);
expect(helper.setToUser).toHaveBeenCalledWith("user-1", keyDefinitionLike, {
"provider-id-1": exampleProvider1(),
"provider-id-2": {
// ...
},
});
});
});
describe("rollback", () => {
beforeEach(() => {
helper = mockMigrationHelper(rollbackJSON(), 27);
sut = new ProviderMigrator(27, 28);
});
it.each(["user-1", "user-2"])("should null out new values", async (userId) => {
await sut.rollback(helper);
expect(helper.setToUser).toHaveBeenCalledWith(userId, keyDefinitionLike, null);
});
it("should add explicit value back to accounts", async () => {
await sut.rollback(helper);
expect(helper.set).toHaveBeenCalledWith("user-1", {
data: {
providers: {
"provider-id-1": exampleProvider1(),
"provider-id-2": {
// ...
},
},
otherStuff: "overStuff2",
},
otherStuff: "otherStuff3",
});
});
it("should not try to restore values to missing accounts", async () => {
await sut.rollback(helper);
expect(helper.set).not.toHaveBeenCalledWith("user-3", any());
});
});
});

View File

@@ -0,0 +1,71 @@
import { Jsonify } from "type-fest";
import { KeyDefinitionLike, MigrationHelper } from "../migration-helper";
import { Migrator } from "../migrator";
enum ProviderUserStatusType {
Invited = 0,
Accepted = 1,
Confirmed = 2,
Revoked = -1,
}
enum ProviderUserType {
ProviderAdmin = 0,
ServiceUser = 1,
}
type ProviderData = {
id: string;
name: string;
status: ProviderUserStatusType;
type: ProviderUserType;
enabled: boolean;
userId: string;
useEvents: boolean;
};
type ExpectedAccountType = {
data?: {
providers?: Record<string, Jsonify<ProviderData>>;
};
};
const USER_PROVIDERS: KeyDefinitionLike = {
key: "providers",
stateDefinition: {
name: "providers",
},
};
export class ProviderMigrator extends Migrator<27, 28> {
async migrate(helper: MigrationHelper): Promise<void> {
const accounts = await helper.getAccounts<ExpectedAccountType>();
async function migrateAccount(userId: string, account: ExpectedAccountType): Promise<void> {
const value = account?.data?.providers;
if (value != null) {
await helper.setToUser(userId, USER_PROVIDERS, value);
delete account.data.providers;
await helper.set(userId, account);
}
}
await Promise.all(accounts.map(({ userId, account }) => migrateAccount(userId, account)));
}
async rollback(helper: MigrationHelper): Promise<void> {
const accounts = await helper.getAccounts<ExpectedAccountType>();
async function rollbackAccount(userId: string, account: ExpectedAccountType): Promise<void> {
const value = await helper.getFromUser(userId, USER_PROVIDERS);
if (account) {
account.data = Object.assign(account.data ?? {}, {
providers: value,
});
await helper.set(userId, account);
}
await helper.setToUser(userId, USER_PROVIDERS, null);
}
await Promise.all(accounts.map(({ userId, account }) => rollbackAccount(userId, account)));
}
}

View File

@@ -0,0 +1,102 @@
import { MockProxy } from "jest-mock-extended";
import { StateDefinitionLike, MigrationHelper } from "../migration-helper";
import { mockMigrationHelper } from "../migration-helper.spec";
import { UserNotificationSettingsKeyMigrator } from "./29-move-user-notification-settings-to-state-provider";
function exampleJSON() {
return {
global: {
disableAddLoginNotification: false,
disableChangedPasswordNotification: false,
otherStuff: "otherStuff1",
},
};
}
function rollbackJSON() {
return {
global_userNotificationSettings_enableAddedLoginPrompt: true,
global_userNotificationSettings_enableChangedPasswordPrompt: true,
global: {
otherStuff: "otherStuff1",
},
};
}
const userNotificationSettingsLocalStateDefinition: {
stateDefinition: StateDefinitionLike;
} = {
stateDefinition: {
name: "userNotificationSettings",
},
};
describe("ProviderKeysMigrator", () => {
let helper: MockProxy<MigrationHelper>;
let sut: UserNotificationSettingsKeyMigrator;
describe("migrate", () => {
beforeEach(() => {
helper = mockMigrationHelper(exampleJSON(), 28);
sut = new UserNotificationSettingsKeyMigrator(28, 29);
});
it("should remove disableAddLoginNotification and disableChangedPasswordNotification global setting", async () => {
await sut.migrate(helper);
expect(helper.set).toHaveBeenCalledTimes(2);
expect(helper.set).toHaveBeenCalledWith("global", { otherStuff: "otherStuff1" });
expect(helper.set).toHaveBeenCalledWith("global", { otherStuff: "otherStuff1" });
});
it("should set global user notification setting values", async () => {
await sut.migrate(helper);
expect(helper.setToGlobal).toHaveBeenCalledTimes(2);
expect(helper.setToGlobal).toHaveBeenCalledWith(
{ ...userNotificationSettingsLocalStateDefinition, key: "enableAddedLoginPrompt" },
true,
);
expect(helper.setToGlobal).toHaveBeenCalledWith(
{ ...userNotificationSettingsLocalStateDefinition, key: "enableChangedPasswordPrompt" },
true,
);
});
});
describe("rollback", () => {
beforeEach(() => {
helper = mockMigrationHelper(rollbackJSON(), 29);
sut = new UserNotificationSettingsKeyMigrator(28, 29);
});
it("should null out new global values", async () => {
await sut.rollback(helper);
expect(helper.setToGlobal).toHaveBeenCalledTimes(2);
expect(helper.setToGlobal).toHaveBeenCalledWith(
{ ...userNotificationSettingsLocalStateDefinition, key: "enableAddedLoginPrompt" },
null,
);
expect(helper.setToGlobal).toHaveBeenCalledWith(
{ ...userNotificationSettingsLocalStateDefinition, key: "enableChangedPasswordPrompt" },
null,
);
});
it("should add explicit global values back", async () => {
await sut.rollback(helper);
expect(helper.set).toHaveBeenCalledTimes(2);
expect(helper.set).toHaveBeenCalledWith("global", {
disableAddLoginNotification: false,
otherStuff: "otherStuff1",
});
expect(helper.set).toHaveBeenCalledWith("global", {
disableChangedPasswordNotification: false,
otherStuff: "otherStuff1",
});
});
});
});

View File

@@ -0,0 +1,105 @@
import { MigrationHelper } from "../migration-helper";
import { Migrator } from "../migrator";
type ExpectedGlobalState = {
disableAddLoginNotification?: boolean;
disableChangedPasswordNotification?: boolean;
};
export class UserNotificationSettingsKeyMigrator extends Migrator<28, 29> {
async migrate(helper: MigrationHelper): Promise<void> {
const globalState = await helper.get<ExpectedGlobalState>("global");
// disableAddLoginNotification -> enableAddedLoginPrompt
if (globalState?.disableAddLoginNotification != null) {
await helper.setToGlobal(
{
stateDefinition: {
name: "userNotificationSettings",
},
key: "enableAddedLoginPrompt",
},
!globalState.disableAddLoginNotification,
);
// delete `disableAddLoginNotification` from state global
delete globalState.disableAddLoginNotification;
await helper.set<ExpectedGlobalState>("global", globalState);
}
// disableChangedPasswordNotification -> enableChangedPasswordPrompt
if (globalState?.disableChangedPasswordNotification != null) {
await helper.setToGlobal(
{
stateDefinition: {
name: "userNotificationSettings",
},
key: "enableChangedPasswordPrompt",
},
!globalState.disableChangedPasswordNotification,
);
// delete `disableChangedPasswordNotification` from state global
delete globalState.disableChangedPasswordNotification;
await helper.set<ExpectedGlobalState>("global", globalState);
}
}
async rollback(helper: MigrationHelper): Promise<void> {
const globalState = (await helper.get<ExpectedGlobalState>("global")) || {};
const enableAddedLoginPrompt: boolean = await helper.getFromGlobal({
stateDefinition: {
name: "userNotificationSettings",
},
key: "enableAddedLoginPrompt",
});
const enableChangedPasswordPrompt: boolean = await helper.getFromGlobal({
stateDefinition: {
name: "userNotificationSettings",
},
key: "enableChangedPasswordPrompt",
});
// enableAddedLoginPrompt -> disableAddLoginNotification
if (enableAddedLoginPrompt) {
await helper.set<ExpectedGlobalState>("global", {
...globalState,
disableAddLoginNotification: !enableAddedLoginPrompt,
});
// remove the global state provider framework key for `enableAddedLoginPrompt`
await helper.setToGlobal(
{
stateDefinition: {
name: "userNotificationSettings",
},
key: "enableAddedLoginPrompt",
},
null,
);
}
// enableChangedPasswordPrompt -> disableChangedPasswordNotification
if (enableChangedPasswordPrompt) {
await helper.set<ExpectedGlobalState>("global", {
...globalState,
disableChangedPasswordNotification: !enableChangedPasswordPrompt,
});
// remove the global state provider framework key for `enableChangedPasswordPrompt`
await helper.setToGlobal(
{
stateDefinition: {
name: "userNotificationSettings",
},
key: "enableChangedPasswordPrompt",
},
null,
);
}
}
}

View File

@@ -2,14 +2,18 @@ import { PolicyType } from "../../../admin-console/enums";
// FIXME: use index.ts imports once policy abstractions and models
// implement ADR-0002
import { Policy as AdminPolicy } from "../../../admin-console/models/domain/policy";
import { KeyDefinition } from "../../../platform/state";
import { SingleUserState } from "../../../platform/state";
import { UserId } from "../../../types/guid";
import { PolicyEvaluator } from "./policy-evaluator.abstraction";
/** Tailors the generator service to generate a specific kind of credentials */
export abstract class GeneratorStrategy<Options, Policy> {
/** The key used when storing credentials on disk. */
disk: KeyDefinition<Options>;
/** Retrieve application state that persists across locks.
* @param userId: identifies the user state to retrieve
* @returns the strategy's durable user state
*/
durableState: (userId: UserId) => SingleUserState<Options>;
/** Identifies the policy enforced by the generator. */
policy: PolicyType;
@@ -19,7 +23,8 @@ export abstract class GeneratorStrategy<Options, Policy> {
/** Creates an evaluator from a generator policy.
* @param policy The policy being evaluated.
* @returns the policy evaluator.
* @returns the policy evaluator. If `policy` is is `null` or `undefined`,
* then the evaluator defaults to the application's limits.
* @throws when the policy's type does not match the generator's policy type.
*/
evaluator: (policy: AdminPolicy) => PolicyEvaluator<Policy, Options>;

View File

@@ -1,5 +1,7 @@
import { Observable } from "rxjs";
import { UserId } from "../../../types/guid";
import { PolicyEvaluator } from "./policy-evaluator.abstraction";
/** Generates credentials used for user authentication
@@ -9,19 +11,22 @@ import { PolicyEvaluator } from "./policy-evaluator.abstraction";
export abstract class GeneratorService<Options, Policy> {
/** An observable monitoring the options saved to disk.
* The observable updates when the options are saved.
* @param userId: Identifies the user making the request
*/
options$: Observable<Options>;
options$: (userId: UserId) => Observable<Options>;
/** An observable monitoring the options used to enforce policy.
* The observable updates when the policy changes.
* @param userId: Identifies the user making the request
*/
policy$: Observable<PolicyEvaluator<Policy, Options>>;
evaluator$: (userId: UserId) => Observable<PolicyEvaluator<Policy, Options>>;
/** Enforces the policy on the given options
* @param userId: Identifies the user making the request
* @param options the options to enforce the policy on
* @returns a new instance of the options with the policy enforced
*/
enforcePolicy: (options: Options) => Promise<Options>;
enforcePolicy: (userId: UserId, options: Options) => Promise<Options>;
/** Generates credentials
* @param options the options to generate credentials with
@@ -30,8 +35,9 @@ export abstract class GeneratorService<Options, Policy> {
generate: (options: Options) => Promise<string>;
/** Saves the given options to disk.
* @param userId: Identifies the user making the request
* @param options the options to save
* @returns a promise that resolves when the options are saved
*/
saveOptions: (options: Options) => Promise<void>;
saveOptions: (userId: UserId, options: Options) => Promise<void>;
}

View File

@@ -6,42 +6,45 @@
import { mock } from "jest-mock-extended";
import { BehaviorSubject, firstValueFrom } from "rxjs";
import { FakeActiveUserStateProvider, mockAccountServiceWith } from "../../../spec";
import { FakeSingleUserState, awaitAsync } from "../../../spec";
import { PolicyService } from "../../admin-console/abstractions/policy/policy.service.abstraction";
import { PolicyType } from "../../admin-console/enums";
// FIXME: use index.ts imports once policy abstractions and models
// implement ADR-0002
import { Policy } from "../../admin-console/models/domain/policy";
import { Utils } from "../../platform/misc/utils";
import { ActiveUserState, ActiveUserStateProvider, KeyDefinition } from "../../platform/state";
import { SingleUserState } from "../../platform/state";
import { UserId } from "../../types/guid";
import { GeneratorStrategy, PolicyEvaluator } from "./abstractions";
import { PASSPHRASE_SETTINGS, PASSWORD_SETTINGS } from "./key-definitions";
import { PasswordGenerationOptions } from "./password";
import { DefaultGeneratorService } from ".";
function mockPolicyService(config?: { data?: any; policy?: BehaviorSubject<Policy> }) {
const state = mock<Policy>({ data: config?.data ?? {} });
const subject = config?.policy ?? new BehaviorSubject<Policy>(state);
function mockPolicyService(config?: { state?: BehaviorSubject<Policy> }) {
const service = mock<PolicyService>();
service.get$.mockReturnValue(subject.asObservable());
// FIXME: swap out the mock return value when `getAll$` becomes available
const stateValue = config?.state ?? new BehaviorSubject<Policy>(null);
service.get$.mockReturnValue(stateValue);
// const stateValue = config?.state ?? new BehaviorSubject<Policy[]>(null);
// service.getAll$.mockReturnValue(stateValue);
return service;
}
function mockGeneratorStrategy(config?: {
disk?: KeyDefinition<any>;
userState?: SingleUserState<any>;
policy?: PolicyType;
evaluator?: any;
}) {
const durableState =
config?.userState ?? new FakeSingleUserState<PasswordGenerationOptions>(SomeUser);
const strategy = mock<GeneratorStrategy<any, any>>({
// intentionally arbitrary so that tests that need to check
// whether they're used properly are guaranteed to test
// the value from `config`.
disk: config?.disk ?? {},
durableState: jest.fn(() => durableState),
policy: config?.policy ?? PolicyType.DisableSend,
evaluator: jest.fn(() => config?.evaluator ?? mock<PolicyEvaluator<any, any>>()),
});
@@ -49,129 +52,123 @@ function mockGeneratorStrategy(config?: {
return strategy;
}
// FIXME: Use the fake instead, once it's updated to monitor its method calls.
function mockStateProvider(): [
ActiveUserStateProvider,
ActiveUserState<PasswordGenerationOptions>,
] {
const state = mock<ActiveUserState<PasswordGenerationOptions>>();
const provider = mock<ActiveUserStateProvider>();
provider.get.mockReturnValue(state);
return [provider, state];
}
function fakeStateProvider(key: KeyDefinition<any>, initalValue: any): FakeActiveUserStateProvider {
const userId = Utils.newGuid() as UserId;
const acctService = mockAccountServiceWith(userId);
const provider = new FakeActiveUserStateProvider(acctService);
provider.mockFor(key.key, initalValue);
return provider;
}
const SomeUser = "some user" as UserId;
const AnotherUser = "another user" as UserId;
describe("Password generator service", () => {
describe("constructor()", () => {
it("should initialize the password generator policy", () => {
const policy = mockPolicyService();
const strategy = mockGeneratorStrategy({ policy: PolicyType.PasswordGenerator });
new DefaultGeneratorService(strategy, policy, null);
expect(policy.get$).toHaveBeenCalledWith(PolicyType.PasswordGenerator);
});
});
describe("options$", () => {
it("should return the state from strategy.key", () => {
it("should retrieve durable state from the service", () => {
const policy = mockPolicyService();
const strategy = mockGeneratorStrategy({ disk: PASSPHRASE_SETTINGS });
const [state] = mockStateProvider();
const service = new DefaultGeneratorService(strategy, policy, state);
const userState = new FakeSingleUserState<PasswordGenerationOptions>(SomeUser);
const strategy = mockGeneratorStrategy({ userState });
const service = new DefaultGeneratorService(strategy, policy);
// invoke the getter. It returns the state but that's not important.
service.options$;
const result = service.options$(SomeUser);
expect(state.get).toHaveBeenCalledWith(PASSPHRASE_SETTINGS);
expect(strategy.durableState).toHaveBeenCalledWith(SomeUser);
expect(result).toBe(userState.state$);
});
});
describe("saveOptions()", () => {
it("should update the state at strategy.key", async () => {
const policy = mockPolicyService();
const [provider, state] = mockStateProvider();
const strategy = mockGeneratorStrategy({ disk: PASSWORD_SETTINGS });
const service = new DefaultGeneratorService(strategy, policy, provider);
await service.saveOptions({});
expect(provider.get).toHaveBeenCalledWith(PASSWORD_SETTINGS);
expect(state.update).toHaveBeenCalled();
});
it("should trigger an options$ update", async () => {
const policy = mockPolicyService();
const strategy = mockGeneratorStrategy();
// using the fake here because we're testing that the update and the
// property are wired together. If we were to mock that, we'd be testing
// the mock configuration instead of the wiring.
const provider = fakeStateProvider(strategy.disk, { length: 9 });
const service = new DefaultGeneratorService(strategy, policy, provider);
const userState = new FakeSingleUserState<PasswordGenerationOptions>(SomeUser, { length: 9 });
const strategy = mockGeneratorStrategy({ userState });
const service = new DefaultGeneratorService(strategy, policy);
await service.saveOptions({ length: 10 });
await service.saveOptions(SomeUser, { length: 10 });
await awaitAsync();
const options = await firstValueFrom(service.options$(SomeUser));
const options = await firstValueFrom(service.options$);
expect(strategy.durableState).toHaveBeenCalledWith(SomeUser);
expect(options).toEqual({ length: 10 });
});
});
describe("policy$", () => {
describe("evaluator$", () => {
it("should initialize the password generator policy", async () => {
const policy = mockPolicyService();
const strategy = mockGeneratorStrategy({ policy: PolicyType.PasswordGenerator });
const service = new DefaultGeneratorService(strategy, policy);
await firstValueFrom(service.evaluator$(SomeUser));
// FIXME: swap out the expect when `getAll$` becomes available
expect(policy.get$).toHaveBeenCalledWith(PolicyType.PasswordGenerator);
//expect(policy.getAll$).toHaveBeenCalledWith(PolicyType.PasswordGenerator, SomeUser);
});
it("should map the policy using the generation strategy", async () => {
const policyService = mockPolicyService();
const evaluator = mock<PolicyEvaluator<any, any>>();
const strategy = mockGeneratorStrategy({ evaluator });
const service = new DefaultGeneratorService(strategy, policyService);
const service = new DefaultGeneratorService(strategy, policyService, null);
const policy = await firstValueFrom(service.policy$);
const policy = await firstValueFrom(service.evaluator$(SomeUser));
expect(policy).toBe(evaluator);
});
it("should update the evaluator when the password generator policy changes", async () => {
// set up dependencies
const state = new BehaviorSubject<Policy>(null);
const policy = mockPolicyService({ state });
const strategy = mockGeneratorStrategy();
const service = new DefaultGeneratorService(strategy, policy);
// model responses for the observable update
const firstEvaluator = mock<PolicyEvaluator<any, any>>();
strategy.evaluator.mockReturnValueOnce(firstEvaluator);
const secondEvaluator = mock<PolicyEvaluator<any, any>>();
strategy.evaluator.mockReturnValueOnce(secondEvaluator);
// act
const evaluator$ = service.evaluator$(SomeUser);
const firstResult = await firstValueFrom(evaluator$);
state.next(null);
const secondResult = await firstValueFrom(evaluator$);
// assert
expect(firstResult).toBe(firstEvaluator);
expect(secondResult).toBe(secondEvaluator);
});
it("should cache the password generator policy", async () => {
const policy = mockPolicyService();
const strategy = mockGeneratorStrategy({ policy: PolicyType.PasswordGenerator });
const service = new DefaultGeneratorService(strategy, policy);
await firstValueFrom(service.evaluator$(SomeUser));
await firstValueFrom(service.evaluator$(SomeUser));
// FIXME: swap out the expect when `getAll$` becomes available
expect(policy.get$).toHaveBeenCalledTimes(1);
//expect(policy.getAll$).toHaveBeenCalledTimes(1);
});
it("should cache the password generator policy for each user", async () => {
const policy = mockPolicyService();
const strategy = mockGeneratorStrategy({ policy: PolicyType.PasswordGenerator });
const service = new DefaultGeneratorService(strategy, policy);
await firstValueFrom(service.evaluator$(SomeUser));
await firstValueFrom(service.evaluator$(AnotherUser));
// FIXME: enable this test when `getAll$` becomes available
// expect(policy.getAll$).toHaveBeenNthCalledWith(1, PolicyType.PasswordGenerator, SomeUser);
// expect(policy.getAll$).toHaveBeenNthCalledWith(2, PolicyType.PasswordGenerator, AnotherUser);
});
});
describe("enforcePolicy()", () => {
describe("should load the policy", () => {
it("from the cache by default", async () => {
const policy = mockPolicyService();
const strategy = mockGeneratorStrategy();
const service = new DefaultGeneratorService(strategy, policy, null);
await service.enforcePolicy({});
await service.enforcePolicy({});
expect(strategy.evaluator).toHaveBeenCalledTimes(1);
});
it("from the policy service when the policy changes", async () => {
const policy = new BehaviorSubject<Policy>(mock<Policy>({ data: {} }));
const policyService = mockPolicyService({ policy });
const strategy = mockGeneratorStrategy();
const service = new DefaultGeneratorService(strategy, policyService, null);
await service.enforcePolicy({});
policy.next(mock<Policy>({ data: { some: "change" } }));
await service.enforcePolicy({});
expect(strategy.evaluator).toHaveBeenCalledTimes(2);
});
});
it("should evaluate the policy using the generation strategy", async () => {
const policy = mockPolicyService();
const evaluator = mock<PolicyEvaluator<any, any>>();
const strategy = mockGeneratorStrategy({ evaluator });
const service = new DefaultGeneratorService(strategy, policy, null);
const service = new DefaultGeneratorService(strategy, policy);
await service.enforcePolicy({});
await service.enforcePolicy(SomeUser, {});
expect(evaluator.applyPolicy).toHaveBeenCalled();
expect(evaluator.sanitize).toHaveBeenCalled();
@@ -182,7 +179,7 @@ describe("Password generator service", () => {
it("should invoke the generation strategy", async () => {
const strategy = mockGeneratorStrategy();
const policy = mockPolicyService();
const service = new DefaultGeneratorService(strategy, policy, null);
const service = new DefaultGeneratorService(strategy, policy);
await service.generate({});

View File

@@ -3,7 +3,7 @@ import { firstValueFrom, map, share, timer, ReplaySubject, Observable } from "rx
// FIXME: use index.ts imports once policy abstractions and models
// implement ADR-0002
import { PolicyService } from "../../admin-console/abstractions/policy/policy.service.abstraction";
import { ActiveUserStateProvider } from "../../platform/state";
import { UserId } from "../../types/guid";
import { GeneratorStrategy, GeneratorService, PolicyEvaluator } from "./abstractions";
@@ -13,45 +13,57 @@ export class DefaultGeneratorService<Options, Policy> implements GeneratorServic
* @param strategy tailors the service to a specific generator type
* (e.g. password, passphrase)
* @param policy provides the policy to enforce
* @param state saves and loads password generation options to the location
* specified by the strategy
*/
constructor(
private strategy: GeneratorStrategy<Options, Policy>,
private policy: PolicyService,
private state: ActiveUserStateProvider,
) {
this._policy$ = this.policy.get$(this.strategy.policy).pipe(
) {}
private _evaluators$ = new Map<UserId, Observable<PolicyEvaluator<Policy, Options>>>();
/** {@link GeneratorService.options$()} */
options$(userId: UserId) {
return this.strategy.durableState(userId).state$;
}
/** {@link GeneratorService.saveOptions} */
async saveOptions(userId: UserId, options: Options): Promise<void> {
await this.strategy.durableState(userId).update(() => options);
}
/** {@link GeneratorService.evaluator$()} */
evaluator$(userId: UserId) {
let evaluator$ = this._evaluators$.get(userId);
if (!evaluator$) {
evaluator$ = this.createEvaluator(userId);
this._evaluators$.set(userId, evaluator$);
}
return evaluator$;
}
private createEvaluator(userId: UserId) {
// FIXME: when it becomes possible to get a user-specific policy observable
// (`getAll$`) update this code to call it instead of `get$`.
const policies$ = this.policy.get$(this.strategy.policy);
// cache evaluator in a replay subject to amortize creation cost
// and reduce GC pressure.
const evaluator$ = policies$.pipe(
map((policy) => this.strategy.evaluator(policy)),
share({
// cache evaluator in a replay subject to amortize creation cost
// and reduce GC pressure.
connector: () => new ReplaySubject(1),
resetOnRefCountZero: () => timer(this.strategy.cache_ms),
}),
);
return evaluator$;
}
private _policy$: Observable<PolicyEvaluator<Policy, Options>>;
/** {@link GeneratorService.options$} */
get options$() {
return this.state.get(this.strategy.disk).state$;
}
/** {@link GeneratorService.saveOptions} */
async saveOptions(options: Options): Promise<void> {
await this.state.get(this.strategy.disk).update(() => options);
}
/** {@link GeneratorService.policy$} */
get policy$() {
return this._policy$;
}
/** {@link GeneratorService.enforcePolicy} */
async enforcePolicy(options: Options): Promise<Options> {
const policy = await firstValueFrom(this._policy$);
/** {@link GeneratorService.enforcePolicy()} */
async enforcePolicy(userId: UserId, options: Options): Promise<Options> {
const policy = await firstValueFrom(this.evaluator$(userId));
const evaluated = policy.applyPolicy(options);
const sanitized = policy.sanitize(evaluated);
return sanitized;

View File

@@ -9,15 +9,21 @@ import { PolicyType } from "../../../admin-console/enums";
// FIXME: use index.ts imports once policy abstractions and models
// implement ADR-0002
import { Policy } from "../../../admin-console/models/domain/policy";
import { StateProvider } from "../../../platform/state";
import { UserId } from "../../../types/guid";
import { PASSPHRASE_SETTINGS } from "../key-definitions";
import { PasswordGenerationServiceAbstraction } from "../password/password-generation.service.abstraction";
import { DisabledPassphraseGeneratorPolicy } from "./passphrase-generator-policy";
import { PassphraseGeneratorOptionsEvaluator, PassphraseGeneratorStrategy } from ".";
const SomeUser = "some user" as UserId;
describe("Password generation strategy", () => {
describe("evaluator()", () => {
it("should throw if the policy type is incorrect", () => {
const strategy = new PassphraseGeneratorStrategy(null);
const strategy = new PassphraseGeneratorStrategy(null, null);
const policy = mock<Policy>({
type: PolicyType.DisableSend,
});
@@ -26,7 +32,7 @@ describe("Password generation strategy", () => {
});
it("should map to the policy evaluator", () => {
const strategy = new PassphraseGeneratorStrategy(null);
const strategy = new PassphraseGeneratorStrategy(null, null);
const policy = mock<Policy>({
type: PolicyType.PasswordGenerator,
data: {
@@ -45,21 +51,32 @@ describe("Password generation strategy", () => {
includeNumber: true,
});
});
it("should map `null` to a default policy evaluator", () => {
const strategy = new PassphraseGeneratorStrategy(null, null);
const evaluator = strategy.evaluator(null);
expect(evaluator).toBeInstanceOf(PassphraseGeneratorOptionsEvaluator);
expect(evaluator.policy).toMatchObject(DisabledPassphraseGeneratorPolicy);
});
});
describe("disk", () => {
describe("durableState", () => {
it("should use password settings key", () => {
const provider = mock<StateProvider>();
const legacy = mock<PasswordGenerationServiceAbstraction>();
const strategy = new PassphraseGeneratorStrategy(legacy);
const strategy = new PassphraseGeneratorStrategy(legacy, provider);
expect(strategy.disk).toBe(PASSPHRASE_SETTINGS);
strategy.durableState(SomeUser);
expect(provider.getUser).toHaveBeenCalledWith(SomeUser, PASSPHRASE_SETTINGS);
});
});
describe("cache_ms", () => {
it("should be a positive non-zero number", () => {
const legacy = mock<PasswordGenerationServiceAbstraction>();
const strategy = new PassphraseGeneratorStrategy(legacy);
const strategy = new PassphraseGeneratorStrategy(legacy, null);
expect(strategy.cache_ms).toBeGreaterThan(0);
});
@@ -68,7 +85,7 @@ describe("Password generation strategy", () => {
describe("policy", () => {
it("should use password generator policy", () => {
const legacy = mock<PasswordGenerationServiceAbstraction>();
const strategy = new PassphraseGeneratorStrategy(legacy);
const strategy = new PassphraseGeneratorStrategy(legacy, null);
expect(strategy.policy).toBe(PolicyType.PasswordGenerator);
});
@@ -77,7 +94,7 @@ describe("Password generation strategy", () => {
describe("generate()", () => {
it("should call the legacy service with the given options", async () => {
const legacy = mock<PasswordGenerationServiceAbstraction>();
const strategy = new PassphraseGeneratorStrategy(legacy);
const strategy = new PassphraseGeneratorStrategy(legacy, null);
const options = {
type: "passphrase",
minNumberWords: 1,
@@ -92,7 +109,7 @@ describe("Password generation strategy", () => {
it("should set the generation type to passphrase", async () => {
const legacy = mock<PasswordGenerationServiceAbstraction>();
const strategy = new PassphraseGeneratorStrategy(legacy);
const strategy = new PassphraseGeneratorStrategy(legacy, null);
await strategy.generate({ type: "foo" } as any);

View File

@@ -3,12 +3,17 @@ import { PolicyType } from "../../../admin-console/enums";
// FIXME: use index.ts imports once policy abstractions and models
// implement ADR-0002
import { Policy } from "../../../admin-console/models/domain/policy";
import { StateProvider } from "../../../platform/state";
import { UserId } from "../../../types/guid";
import { PASSPHRASE_SETTINGS } from "../key-definitions";
import { PasswordGenerationServiceAbstraction } from "../password/password-generation.service.abstraction";
import { PassphraseGenerationOptions } from "./passphrase-generation-options";
import { PassphraseGeneratorOptionsEvaluator } from "./passphrase-generator-options-evaluator";
import { PassphraseGeneratorPolicy } from "./passphrase-generator-policy";
import {
DisabledPassphraseGeneratorPolicy,
PassphraseGeneratorPolicy,
} from "./passphrase-generator-policy";
const ONE_MINUTE = 60 * 1000;
@@ -19,11 +24,14 @@ export class PassphraseGeneratorStrategy
/** instantiates the password generator strategy.
* @param legacy generates the passphrase
*/
constructor(private legacy: PasswordGenerationServiceAbstraction) {}
constructor(
private legacy: PasswordGenerationServiceAbstraction,
private stateProvider: StateProvider,
) {}
/** {@link GeneratorStrategy.disk} */
get disk() {
return PASSPHRASE_SETTINGS;
/** {@link GeneratorStrategy.durableState} */
durableState(id: UserId) {
return this.stateProvider.getUser(id, PASSPHRASE_SETTINGS);
}
/** {@link GeneratorStrategy.policy} */
@@ -37,6 +45,10 @@ export class PassphraseGeneratorStrategy
/** {@link GeneratorStrategy.evaluator} */
evaluator(policy: Policy): PassphraseGeneratorOptionsEvaluator {
if (!policy) {
return new PassphraseGeneratorOptionsEvaluator(DisabledPassphraseGeneratorPolicy);
}
if (policy.type !== this.policy) {
const details = `Expected: ${this.policy}. Received: ${policy.type}`;
throw Error("Mismatched policy type. " + details);

View File

@@ -9,18 +9,24 @@ import { PolicyType } from "../../../admin-console/enums";
// FIXME: use index.ts imports once policy abstractions and models
// implement ADR-0002
import { Policy } from "../../../admin-console/models/domain/policy";
import { StateProvider } from "../../../platform/state";
import { UserId } from "../../../types/guid";
import { PASSWORD_SETTINGS } from "../key-definitions";
import { DisabledPasswordGeneratorPolicy } from "./password-generator-policy";
import {
PasswordGenerationServiceAbstraction,
PasswordGeneratorOptionsEvaluator,
PasswordGeneratorStrategy,
} from ".";
const SomeUser = "some user" as UserId;
describe("Password generation strategy", () => {
describe("evaluator()", () => {
it("should throw if the policy type is incorrect", () => {
const strategy = new PasswordGeneratorStrategy(null);
const strategy = new PasswordGeneratorStrategy(null, null);
const policy = mock<Policy>({
type: PolicyType.DisableSend,
});
@@ -29,7 +35,7 @@ describe("Password generation strategy", () => {
});
it("should map to the policy evaluator", () => {
const strategy = new PasswordGeneratorStrategy(null);
const strategy = new PasswordGeneratorStrategy(null, null);
const policy = mock<Policy>({
type: PolicyType.PasswordGenerator,
data: {
@@ -56,21 +62,32 @@ describe("Password generation strategy", () => {
specialCount: 1,
});
});
it("should map `null` to a default policy evaluator", () => {
const strategy = new PasswordGeneratorStrategy(null, null);
const evaluator = strategy.evaluator(null);
expect(evaluator).toBeInstanceOf(PasswordGeneratorOptionsEvaluator);
expect(evaluator.policy).toMatchObject(DisabledPasswordGeneratorPolicy);
});
});
describe("disk", () => {
describe("durableState", () => {
it("should use password settings key", () => {
const provider = mock<StateProvider>();
const legacy = mock<PasswordGenerationServiceAbstraction>();
const strategy = new PasswordGeneratorStrategy(legacy);
const strategy = new PasswordGeneratorStrategy(legacy, provider);
expect(strategy.disk).toBe(PASSWORD_SETTINGS);
strategy.durableState(SomeUser);
expect(provider.getUser).toHaveBeenCalledWith(SomeUser, PASSWORD_SETTINGS);
});
});
describe("cache_ms", () => {
it("should be a positive non-zero number", () => {
const legacy = mock<PasswordGenerationServiceAbstraction>();
const strategy = new PasswordGeneratorStrategy(legacy);
const strategy = new PasswordGeneratorStrategy(legacy, null);
expect(strategy.cache_ms).toBeGreaterThan(0);
});
@@ -79,7 +96,7 @@ describe("Password generation strategy", () => {
describe("policy", () => {
it("should use password generator policy", () => {
const legacy = mock<PasswordGenerationServiceAbstraction>();
const strategy = new PasswordGeneratorStrategy(legacy);
const strategy = new PasswordGeneratorStrategy(legacy, null);
expect(strategy.policy).toBe(PolicyType.PasswordGenerator);
});
@@ -88,7 +105,7 @@ describe("Password generation strategy", () => {
describe("generate()", () => {
it("should call the legacy service with the given options", async () => {
const legacy = mock<PasswordGenerationServiceAbstraction>();
const strategy = new PasswordGeneratorStrategy(legacy);
const strategy = new PasswordGeneratorStrategy(legacy, null);
const options = {
type: "password",
minLength: 1,
@@ -107,7 +124,7 @@ describe("Password generation strategy", () => {
it("should set the generation type to password", async () => {
const legacy = mock<PasswordGenerationServiceAbstraction>();
const strategy = new PasswordGeneratorStrategy(legacy);
const strategy = new PasswordGeneratorStrategy(legacy, null);
await strategy.generate({ type: "foo" } as any);

View File

@@ -3,12 +3,17 @@ import { PolicyType } from "../../../admin-console/enums";
// FIXME: use index.ts imports once policy abstractions and models
// implement ADR-0002
import { Policy } from "../../../admin-console/models/domain/policy";
import { StateProvider } from "../../../platform/state";
import { UserId } from "../../../types/guid";
import { PASSWORD_SETTINGS } from "../key-definitions";
import { PasswordGenerationOptions } from "./password-generation-options";
import { PasswordGenerationServiceAbstraction } from "./password-generation.service.abstraction";
import { PasswordGeneratorOptionsEvaluator } from "./password-generator-options-evaluator";
import { PasswordGeneratorPolicy } from "./password-generator-policy";
import {
DisabledPasswordGeneratorPolicy,
PasswordGeneratorPolicy,
} from "./password-generator-policy";
const ONE_MINUTE = 60 * 1000;
@@ -19,11 +24,14 @@ export class PasswordGeneratorStrategy
/** instantiates the password generator strategy.
* @param legacy generates the password
*/
constructor(private legacy: PasswordGenerationServiceAbstraction) {}
constructor(
private legacy: PasswordGenerationServiceAbstraction,
private stateProvider: StateProvider,
) {}
/** {@link GeneratorStrategy.disk} */
get disk() {
return PASSWORD_SETTINGS;
/** {@link GeneratorStrategy.durableState} */
durableState(id: UserId) {
return this.stateProvider.getUser(id, PASSWORD_SETTINGS);
}
/** {@link GeneratorStrategy.policy} */
@@ -37,6 +45,10 @@ export class PasswordGeneratorStrategy
/** {@link GeneratorStrategy.evaluator} */
evaluator(policy: Policy): PasswordGeneratorOptionsEvaluator {
if (!policy) {
return new PasswordGeneratorOptionsEvaluator(DisabledPasswordGeneratorPolicy);
}
if (policy.type !== this.policy) {
const details = `Expected: ${this.policy}. Received: ${policy.type}`;
throw Error("Mismatched policy type. " + details);

View File

@@ -83,7 +83,16 @@ describe("UserEncryptor", () => {
});
describe("instance", () => {
it("gets a set value", async () => {
it("userId outputs the user input during construction", async () => {
const provider = await fakeStateProvider();
const encryptor = mockEncryptor();
const state = SecretState.from(SomeUser, FOOBAR_KEY, provider, encryptor);
expect(state.userId).toEqual(SomeUser);
});
it("state$ gets a set value", async () => {
const provider = await fakeStateProvider();
const encryptor = mockEncryptor();
const state = SecretState.from(SomeUser, FOOBAR_KEY, provider, encryptor);
@@ -96,6 +105,20 @@ describe("UserEncryptor", () => {
expect(result).toEqual(value);
});
it("combinedState$ gets a set value with the userId", async () => {
const provider = await fakeStateProvider();
const encryptor = mockEncryptor();
const state = SecretState.from(SomeUser, FOOBAR_KEY, provider, encryptor);
const value = { foo: true, bar: false };
await state.update(() => value);
await awaitAsync();
const [userId, result] = await firstValueFrom(state.combinedState$);
expect(result).toEqual(value);
expect(userId).toEqual(SomeUser);
});
it("round-trips json-serializable values", async () => {
const provider = await fakeStateProvider();
const encryptor = mockEncryptor();

View File

@@ -1,4 +1,4 @@
import { Observable, concatMap, of, zip } from "rxjs";
import { Observable, concatMap, of, zip, map } from "rxjs";
import { Jsonify } from "type-fest";
import { EncString } from "../../../platform/models/domain/enc-string";
@@ -9,6 +9,7 @@ import {
SingleUserState,
StateProvider,
StateUpdateOptions,
CombinedState,
} from "../../../platform/state";
import { UserId } from "../../../types/guid";
@@ -37,7 +38,9 @@ type ClassifiedFormat<Disclosed> = {
*
* DO NOT USE THIS for synchronized data.
*/
export class SecretState<Plaintext extends object, Disclosed> {
export class SecretState<Plaintext extends object, Disclosed>
implements SingleUserState<Plaintext>
{
// The constructor is private to avoid creating a circular dependency when
// wiring the derived and secret states together.
private constructor(
@@ -46,8 +49,23 @@ export class SecretState<Plaintext extends object, Disclosed> {
private readonly plaintext: DerivedState<Plaintext>,
) {
this.state$ = plaintext.state$;
this.combinedState$ = plaintext.state$.pipe(map((state) => [this.encrypted.userId, state]));
}
/** {@link SingleUserState.userId} */
get userId() {
return this.encrypted.userId;
}
/** Observes changes to the decrypted secret state. The observer
* updates after the secret has been recorded to state storage.
* @returns `undefined` when the account is locked.
*/
readonly state$: Observable<Plaintext>;
/** {@link SingleUserState.combinedState$} */
readonly combinedState$: Observable<CombinedState<Plaintext>>;
/** Creates a secret state bound to an account encryptor. The account must be unlocked
* when this method is called.
* @param userId: the user to which the secret state is bound.
@@ -106,12 +124,6 @@ export class SecretState<Plaintext extends object, Disclosed> {
return secretState;
}
/** Observes changes to the decrypted secret state. The observer
* updates after the secret has been recorded to state storage.
* @returns `undefined` when the account is locked.
*/
readonly state$: Observable<Plaintext>;
/** Updates the secret stored by this state.
* @param configureState a callback that returns an updated decrypted
* secret state. The callback receives the state's present value as its

View File

@@ -4,15 +4,19 @@ import { PolicyType } from "../../../admin-console/enums";
// FIXME: use index.ts imports once policy abstractions and models
// implement ADR-0002
import { Policy } from "../../../admin-console/models/domain/policy";
import { StateProvider } from "../../../platform/state";
import { UserId } from "../../../types/guid";
import { DefaultPolicyEvaluator } from "../default-policy-evaluator";
import { CATCHALL_SETTINGS } from "../key-definitions";
import { CatchallGeneratorStrategy, UsernameGenerationServiceAbstraction } from ".";
const SomeUser = "some user" as UserId;
describe("Email subaddress list generation strategy", () => {
describe("evaluator()", () => {
it("should throw if the policy type is incorrect", () => {
const strategy = new CatchallGeneratorStrategy(null);
const strategy = new CatchallGeneratorStrategy(null, null);
const policy = mock<Policy>({
type: PolicyType.DisableSend,
});
@@ -21,7 +25,7 @@ describe("Email subaddress list generation strategy", () => {
});
it("should map to the policy evaluator", () => {
const strategy = new CatchallGeneratorStrategy(null);
const strategy = new CatchallGeneratorStrategy(null, null);
const policy = mock<Policy>({
type: PolicyType.PasswordGenerator,
data: {
@@ -34,21 +38,31 @@ describe("Email subaddress list generation strategy", () => {
expect(evaluator).toBeInstanceOf(DefaultPolicyEvaluator);
expect(evaluator.policy).toMatchObject({});
});
it("should map `null` to a default policy evaluator", () => {
const strategy = new CatchallGeneratorStrategy(null, null);
const evaluator = strategy.evaluator(null);
expect(evaluator).toBeInstanceOf(DefaultPolicyEvaluator);
});
});
describe("disk", () => {
describe("durableState", () => {
it("should use password settings key", () => {
const provider = mock<StateProvider>();
const legacy = mock<UsernameGenerationServiceAbstraction>();
const strategy = new CatchallGeneratorStrategy(legacy);
const strategy = new CatchallGeneratorStrategy(legacy, provider);
expect(strategy.disk).toBe(CATCHALL_SETTINGS);
strategy.durableState(SomeUser);
expect(provider.getUser).toHaveBeenCalledWith(SomeUser, CATCHALL_SETTINGS);
});
});
describe("cache_ms", () => {
it("should be a positive non-zero number", () => {
const legacy = mock<UsernameGenerationServiceAbstraction>();
const strategy = new CatchallGeneratorStrategy(legacy);
const strategy = new CatchallGeneratorStrategy(legacy, null);
expect(strategy.cache_ms).toBeGreaterThan(0);
});
@@ -57,7 +71,7 @@ describe("Email subaddress list generation strategy", () => {
describe("policy", () => {
it("should use password generator policy", () => {
const legacy = mock<UsernameGenerationServiceAbstraction>();
const strategy = new CatchallGeneratorStrategy(legacy);
const strategy = new CatchallGeneratorStrategy(legacy, null);
expect(strategy.policy).toBe(PolicyType.PasswordGenerator);
});
@@ -66,7 +80,7 @@ describe("Email subaddress list generation strategy", () => {
describe("generate()", () => {
it("should call the legacy service with the given options", async () => {
const legacy = mock<UsernameGenerationServiceAbstraction>();
const strategy = new CatchallGeneratorStrategy(legacy);
const strategy = new CatchallGeneratorStrategy(legacy, null);
const options = {
type: "website-name" as const,
domain: "example.com",

View File

@@ -1,5 +1,7 @@
import { PolicyType } from "../../../admin-console/enums";
import { Policy } from "../../../admin-console/models/domain/policy";
import { StateProvider } from "../../../platform/state";
import { UserId } from "../../../types/guid";
import { GeneratorStrategy } from "../abstractions";
import { DefaultPolicyEvaluator } from "../default-policy-evaluator";
import { CATCHALL_SETTINGS } from "../key-definitions";
@@ -17,11 +19,14 @@ export class CatchallGeneratorStrategy
/** Instantiates the generation strategy
* @param usernameService generates a catchall address for a domain
*/
constructor(private usernameService: UsernameGenerationServiceAbstraction) {}
constructor(
private usernameService: UsernameGenerationServiceAbstraction,
private stateProvider: StateProvider,
) {}
/** {@link GeneratorStrategy.disk} */
get disk() {
return CATCHALL_SETTINGS;
/** {@link GeneratorStrategy.durableState} */
durableState(id: UserId) {
return this.stateProvider.getUser(id, CATCHALL_SETTINGS);
}
/** {@link GeneratorStrategy.policy} */
@@ -38,6 +43,10 @@ export class CatchallGeneratorStrategy
/** {@link GeneratorStrategy.evaluator} */
evaluator(policy: Policy) {
if (!policy) {
return new DefaultPolicyEvaluator<CatchallGenerationOptions>();
}
if (policy.type !== this.policy) {
const details = `Expected: ${this.policy}. Received: ${policy.type}`;
throw Error("Mismatched policy type. " + details);

View File

@@ -4,15 +4,19 @@ import { PolicyType } from "../../../admin-console/enums";
// FIXME: use index.ts imports once policy abstractions and models
// implement ADR-0002
import { Policy } from "../../../admin-console/models/domain/policy";
import { StateProvider } from "../../../platform/state";
import { UserId } from "../../../types/guid";
import { DefaultPolicyEvaluator } from "../default-policy-evaluator";
import { EFF_USERNAME_SETTINGS } from "../key-definitions";
import { EffUsernameGeneratorStrategy, UsernameGenerationServiceAbstraction } from ".";
const SomeUser = "some user" as UserId;
describe("EFF long word list generation strategy", () => {
describe("evaluator()", () => {
it("should throw if the policy type is incorrect", () => {
const strategy = new EffUsernameGeneratorStrategy(null);
const strategy = new EffUsernameGeneratorStrategy(null, null);
const policy = mock<Policy>({
type: PolicyType.DisableSend,
});
@@ -21,7 +25,7 @@ describe("EFF long word list generation strategy", () => {
});
it("should map to the policy evaluator", () => {
const strategy = new EffUsernameGeneratorStrategy(null);
const strategy = new EffUsernameGeneratorStrategy(null, null);
const policy = mock<Policy>({
type: PolicyType.PasswordGenerator,
data: {
@@ -34,21 +38,31 @@ describe("EFF long word list generation strategy", () => {
expect(evaluator).toBeInstanceOf(DefaultPolicyEvaluator);
expect(evaluator.policy).toMatchObject({});
});
it("should map `null` to a default policy evaluator", () => {
const strategy = new EffUsernameGeneratorStrategy(null, null);
const evaluator = strategy.evaluator(null);
expect(evaluator).toBeInstanceOf(DefaultPolicyEvaluator);
});
});
describe("disk", () => {
describe("durableState", () => {
it("should use password settings key", () => {
const provider = mock<StateProvider>();
const legacy = mock<UsernameGenerationServiceAbstraction>();
const strategy = new EffUsernameGeneratorStrategy(legacy);
const strategy = new EffUsernameGeneratorStrategy(legacy, provider);
expect(strategy.disk).toBe(EFF_USERNAME_SETTINGS);
strategy.durableState(SomeUser);
expect(provider.getUser).toHaveBeenCalledWith(SomeUser, EFF_USERNAME_SETTINGS);
});
});
describe("cache_ms", () => {
it("should be a positive non-zero number", () => {
const legacy = mock<UsernameGenerationServiceAbstraction>();
const strategy = new EffUsernameGeneratorStrategy(legacy);
const strategy = new EffUsernameGeneratorStrategy(legacy, null);
expect(strategy.cache_ms).toBeGreaterThan(0);
});
@@ -57,7 +71,7 @@ describe("EFF long word list generation strategy", () => {
describe("policy", () => {
it("should use password generator policy", () => {
const legacy = mock<UsernameGenerationServiceAbstraction>();
const strategy = new EffUsernameGeneratorStrategy(legacy);
const strategy = new EffUsernameGeneratorStrategy(legacy, null);
expect(strategy.policy).toBe(PolicyType.PasswordGenerator);
});
@@ -66,7 +80,7 @@ describe("EFF long word list generation strategy", () => {
describe("generate()", () => {
it("should call the legacy service with the given options", async () => {
const legacy = mock<UsernameGenerationServiceAbstraction>();
const strategy = new EffUsernameGeneratorStrategy(legacy);
const strategy = new EffUsernameGeneratorStrategy(legacy, null);
const options = {
wordCapitalize: false,
wordIncludeNumber: false,

View File

@@ -1,5 +1,7 @@
import { PolicyType } from "../../../admin-console/enums";
import { Policy } from "../../../admin-console/models/domain/policy";
import { StateProvider } from "../../../platform/state";
import { UserId } from "../../../types/guid";
import { GeneratorStrategy } from "../abstractions";
import { DefaultPolicyEvaluator } from "../default-policy-evaluator";
import { EFF_USERNAME_SETTINGS } from "../key-definitions";
@@ -17,11 +19,14 @@ export class EffUsernameGeneratorStrategy
/** Instantiates the generation strategy
* @param usernameService generates a username from EFF word list
*/
constructor(private usernameService: UsernameGenerationServiceAbstraction) {}
constructor(
private usernameService: UsernameGenerationServiceAbstraction,
private stateProvider: StateProvider,
) {}
/** {@link GeneratorStrategy.disk} */
get disk() {
return EFF_USERNAME_SETTINGS;
/** {@link GeneratorStrategy.durableState} */
durableState(id: UserId) {
return this.stateProvider.getUser(id, EFF_USERNAME_SETTINGS);
}
/** {@link GeneratorStrategy.policy} */
@@ -38,6 +43,10 @@ export class EffUsernameGeneratorStrategy
/** {@link GeneratorStrategy.evaluator} */
evaluator(policy: Policy) {
if (!policy) {
return new DefaultPolicyEvaluator<EffUsernameGenerationOptions>();
}
if (policy.type !== this.policy) {
const details = `Expected: ${this.policy}. Received: ${policy.type}`;
throw Error("Mismatched policy type. " + details);

View File

@@ -4,15 +4,19 @@ import { PolicyType } from "../../../admin-console/enums";
// FIXME: use index.ts imports once policy abstractions and models
// implement ADR-0002
import { Policy } from "../../../admin-console/models/domain/policy";
import { StateProvider } from "../../../platform/state";
import { UserId } from "../../../types/guid";
import { DefaultPolicyEvaluator } from "../default-policy-evaluator";
import { SUBADDRESS_SETTINGS } from "../key-definitions";
import { SubaddressGeneratorStrategy, UsernameGenerationServiceAbstraction } from ".";
const SomeUser = "some user" as UserId;
describe("Email subaddress list generation strategy", () => {
describe("evaluator()", () => {
it("should throw if the policy type is incorrect", () => {
const strategy = new SubaddressGeneratorStrategy(null);
const strategy = new SubaddressGeneratorStrategy(null, null);
const policy = mock<Policy>({
type: PolicyType.DisableSend,
});
@@ -21,7 +25,7 @@ describe("Email subaddress list generation strategy", () => {
});
it("should map to the policy evaluator", () => {
const strategy = new SubaddressGeneratorStrategy(null);
const strategy = new SubaddressGeneratorStrategy(null, null);
const policy = mock<Policy>({
type: PolicyType.PasswordGenerator,
data: {
@@ -34,21 +38,31 @@ describe("Email subaddress list generation strategy", () => {
expect(evaluator).toBeInstanceOf(DefaultPolicyEvaluator);
expect(evaluator.policy).toMatchObject({});
});
it("should map `null` to a default policy evaluator", () => {
const strategy = new SubaddressGeneratorStrategy(null, null);
const evaluator = strategy.evaluator(null);
expect(evaluator).toBeInstanceOf(DefaultPolicyEvaluator);
});
});
describe("disk", () => {
describe("durableState", () => {
it("should use password settings key", () => {
const provider = mock<StateProvider>();
const legacy = mock<UsernameGenerationServiceAbstraction>();
const strategy = new SubaddressGeneratorStrategy(legacy);
const strategy = new SubaddressGeneratorStrategy(legacy, provider);
expect(strategy.disk).toBe(SUBADDRESS_SETTINGS);
strategy.durableState(SomeUser);
expect(provider.getUser).toHaveBeenCalledWith(SomeUser, SUBADDRESS_SETTINGS);
});
});
describe("cache_ms", () => {
it("should be a positive non-zero number", () => {
const legacy = mock<UsernameGenerationServiceAbstraction>();
const strategy = new SubaddressGeneratorStrategy(legacy);
const strategy = new SubaddressGeneratorStrategy(legacy, null);
expect(strategy.cache_ms).toBeGreaterThan(0);
});
@@ -57,7 +71,7 @@ describe("Email subaddress list generation strategy", () => {
describe("policy", () => {
it("should use password generator policy", () => {
const legacy = mock<UsernameGenerationServiceAbstraction>();
const strategy = new SubaddressGeneratorStrategy(legacy);
const strategy = new SubaddressGeneratorStrategy(legacy, null);
expect(strategy.policy).toBe(PolicyType.PasswordGenerator);
});
@@ -66,7 +80,7 @@ describe("Email subaddress list generation strategy", () => {
describe("generate()", () => {
it("should call the legacy service with the given options", async () => {
const legacy = mock<UsernameGenerationServiceAbstraction>();
const strategy = new SubaddressGeneratorStrategy(legacy);
const strategy = new SubaddressGeneratorStrategy(legacy, null);
const options = {
type: "website-name" as const,
email: "someone@example.com",

View File

@@ -1,5 +1,7 @@
import { PolicyType } from "../../../admin-console/enums";
import { Policy } from "../../../admin-console/models/domain/policy";
import { StateProvider } from "../../../platform/state";
import { UserId } from "../../../types/guid";
import { GeneratorStrategy } from "../abstractions";
import { DefaultPolicyEvaluator } from "../default-policy-evaluator";
import { SUBADDRESS_SETTINGS } from "../key-definitions";
@@ -17,11 +19,14 @@ export class SubaddressGeneratorStrategy
/** Instantiates the generation strategy
* @param usernameService generates an email subaddress from an email address
*/
constructor(private usernameService: UsernameGenerationServiceAbstraction) {}
constructor(
private usernameService: UsernameGenerationServiceAbstraction,
private stateProvider: StateProvider,
) {}
/** {@link GeneratorStrategy.disk} */
get disk() {
return SUBADDRESS_SETTINGS;
/** {@link GeneratorStrategy.durableState} */
durableState(id: UserId) {
return this.stateProvider.getUser(id, SUBADDRESS_SETTINGS);
}
/** {@link GeneratorStrategy.policy} */
@@ -38,6 +43,10 @@ export class SubaddressGeneratorStrategy
/** {@link GeneratorStrategy.evaluator} */
evaluator(policy: Policy) {
if (!policy) {
return new DefaultPolicyEvaluator<SubaddressGenerationOptions>();
}
if (policy.type !== this.policy) {
const details = `Expected: ${this.policy}. Received: ${policy.type}`;
throw Error("Mismatched policy type. " + details);

161
package-lock.json generated
View File

@@ -16,7 +16,7 @@
],
"dependencies": {
"@angular/animations": "16.2.12",
"@angular/cdk": "16.2.13",
"@angular/cdk": "16.2.14",
"@angular/common": "16.2.12",
"@angular/compiler": "16.2.12",
"@angular/core": "16.2.12",
@@ -159,8 +159,8 @@
"mini-css-extract-plugin": "2.7.6",
"node-ipc": "9.2.1",
"pkg": "5.8.1",
"postcss": "8.4.32",
"postcss-loader": "7.3.3",
"postcss": "8.4.35",
"postcss-loader": "7.3.4",
"prettier": "3.2.2",
"prettier-plugin-tailwindcss": "0.5.11",
"process": "0.11.10",
@@ -170,7 +170,7 @@
"remark-gfm": "3.0.1",
"rimraf": "5.0.5",
"sass": "1.69.5",
"sass-loader": "13.3.2",
"sass-loader": "13.3.3",
"storybook": "7.6.17",
"style-loader": "3.3.3",
"tailwindcss": "3.4.1",
@@ -264,7 +264,7 @@
},
"apps/web": {
"name": "@bitwarden/web-vault",
"version": "2024.2.3"
"version": "2024.2.4"
},
"libs/admin-console": {
"name": "@bitwarden/admin-console",
@@ -576,6 +576,12 @@
"ajv": "^6.9.1"
}
},
"node_modules/@angular-devkit/build-angular/node_modules/argparse": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
"integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==",
"dev": true
},
"node_modules/@angular-devkit/build-angular/node_modules/autoprefixer": {
"version": "10.4.14",
"resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.14.tgz",
@@ -609,6 +615,32 @@
"postcss": "^8.1.0"
}
},
"node_modules/@angular-devkit/build-angular/node_modules/cosmiconfig": {
"version": "8.3.6",
"resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-8.3.6.tgz",
"integrity": "sha512-kcZ6+W5QzcJ3P1Mt+83OUv/oHFqZHIx8DuxG6eZ5RGMERoLqp4BuGjhHLYGK+Kf5XVkQvqBSmAy/nGWN3qDgEA==",
"dev": true,
"dependencies": {
"import-fresh": "^3.3.0",
"js-yaml": "^4.1.0",
"parse-json": "^5.2.0",
"path-type": "^4.0.0"
},
"engines": {
"node": ">=14"
},
"funding": {
"url": "https://github.com/sponsors/d-fischer"
},
"peerDependencies": {
"typescript": ">=4.9.5"
},
"peerDependenciesMeta": {
"typescript": {
"optional": true
}
}
},
"node_modules/@angular-devkit/build-angular/node_modules/eslint-scope": {
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-5.1.1.tgz",
@@ -695,6 +727,18 @@
"tslib": "^2.1.0"
}
},
"node_modules/@angular-devkit/build-angular/node_modules/js-yaml": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz",
"integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==",
"dev": true,
"dependencies": {
"argparse": "^2.0.1"
},
"bin": {
"js-yaml": "bin/js-yaml.js"
}
},
"node_modules/@angular-devkit/build-angular/node_modules/json-schema-traverse": {
"version": "0.4.1",
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz",
@@ -729,6 +773,28 @@
"node": "^10 || ^12 || >=14"
}
},
"node_modules/@angular-devkit/build-angular/node_modules/postcss-loader": {
"version": "7.3.3",
"resolved": "https://registry.npmjs.org/postcss-loader/-/postcss-loader-7.3.3.tgz",
"integrity": "sha512-YgO/yhtevGO/vJePCQmTxiaEwER94LABZN0ZMT4A0vsak9TpO+RvKRs7EmJ8peIlB9xfXCsS7M8LjqncsUZ5HA==",
"dev": true,
"dependencies": {
"cosmiconfig": "^8.2.0",
"jiti": "^1.18.2",
"semver": "^7.3.8"
},
"engines": {
"node": ">= 14.15.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/webpack"
},
"peerDependencies": {
"postcss": "^7.0.0 || ^8.0.1",
"webpack": "^5.0.0"
}
},
"node_modules/@angular-devkit/build-angular/node_modules/sass": {
"version": "1.64.1",
"resolved": "https://registry.npmjs.org/sass/-/sass-1.64.1.tgz",
@@ -746,6 +812,43 @@
"node": ">=14.0.0"
}
},
"node_modules/@angular-devkit/build-angular/node_modules/sass-loader": {
"version": "13.3.2",
"resolved": "https://registry.npmjs.org/sass-loader/-/sass-loader-13.3.2.tgz",
"integrity": "sha512-CQbKl57kdEv+KDLquhC+gE3pXt74LEAzm+tzywcA0/aHZuub8wTErbjAoNI57rPUWRYRNC5WUnNl8eGJNbDdwg==",
"dev": true,
"dependencies": {
"neo-async": "^2.6.2"
},
"engines": {
"node": ">= 14.15.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/webpack"
},
"peerDependencies": {
"fibers": ">= 3.1.0",
"node-sass": "^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0 || ^9.0.0",
"sass": "^1.3.0",
"sass-embedded": "*",
"webpack": "^5.0.0"
},
"peerDependenciesMeta": {
"fibers": {
"optional": true
},
"node-sass": {
"optional": true
},
"sass": {
"optional": true
},
"sass-embedded": {
"optional": true
}
}
},
"node_modules/@angular-devkit/build-angular/node_modules/schema-utils": {
"version": "3.3.0",
"resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-3.3.0.tgz",
@@ -965,9 +1068,9 @@
}
},
"node_modules/@angular/cdk": {
"version": "16.2.13",
"resolved": "https://registry.npmjs.org/@angular/cdk/-/cdk-16.2.13.tgz",
"integrity": "sha512-8kn2X2yesvgfIbCUNoS9EDjooIx9LwEglYBbD89Y/do8EeN/CC3Tn02gqSrEfgMhYBLBJmHXbfOhbDDvcvOCeg==",
"version": "16.2.14",
"resolved": "https://registry.npmjs.org/@angular/cdk/-/cdk-16.2.14.tgz",
"integrity": "sha512-n6PrGdiVeSTEmM/HEiwIyg6YQUUymZrb5afaNLGFRM5YL0Y8OBqd+XhCjb0OfD/AfgCUtedVEPwNqrfW8KzgGw==",
"dependencies": {
"tslib": "^2.3.0"
},
@@ -30701,9 +30804,9 @@
}
},
"node_modules/postcss": {
"version": "8.4.32",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.32.tgz",
"integrity": "sha512-D/kj5JNu6oo2EIy+XL/26JEDTlIbB8hw85G8StOE6L74RQAVVP5rej6wxCNqyMbR4RkPfqvezVbPw81Ngd6Kcw==",
"version": "8.4.35",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.35.tgz",
"integrity": "sha512-u5U8qYpBCpN13BsiEB0CbR1Hhh4Gc0zLFuedrHJKMctHCHAGrMdG0PRM/KErzAL3CU6/eckEtmHNB3x6e3c0vA==",
"dev": true,
"funding": [
{
@@ -30803,14 +30906,14 @@
}
},
"node_modules/postcss-loader": {
"version": "7.3.3",
"resolved": "https://registry.npmjs.org/postcss-loader/-/postcss-loader-7.3.3.tgz",
"integrity": "sha512-YgO/yhtevGO/vJePCQmTxiaEwER94LABZN0ZMT4A0vsak9TpO+RvKRs7EmJ8peIlB9xfXCsS7M8LjqncsUZ5HA==",
"version": "7.3.4",
"resolved": "https://registry.npmjs.org/postcss-loader/-/postcss-loader-7.3.4.tgz",
"integrity": "sha512-iW5WTTBSC5BfsBJ9daFMPVrLT36MrNiC6fqOZTTaHjBNX6Pfd5p+hSBqe/fEeNd7pc13QiAyGt7VdGMw4eRC4A==",
"dev": true,
"dependencies": {
"cosmiconfig": "^8.2.0",
"jiti": "^1.18.2",
"semver": "^7.3.8"
"cosmiconfig": "^8.3.5",
"jiti": "^1.20.0",
"semver": "^7.5.4"
},
"engines": {
"node": ">= 14.15.0"
@@ -30831,14 +30934,14 @@
"dev": true
},
"node_modules/postcss-loader/node_modules/cosmiconfig": {
"version": "8.2.0",
"resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-8.2.0.tgz",
"integrity": "sha512-3rTMnFJA1tCOPwRxtgF4wd7Ab2qvDbL8jX+3smjIbS4HlZBagTlpERbdN7iAbWlrfxE3M8c27kTwTawQ7st+OQ==",
"version": "8.3.6",
"resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-8.3.6.tgz",
"integrity": "sha512-kcZ6+W5QzcJ3P1Mt+83OUv/oHFqZHIx8DuxG6eZ5RGMERoLqp4BuGjhHLYGK+Kf5XVkQvqBSmAy/nGWN3qDgEA==",
"dev": true,
"dependencies": {
"import-fresh": "^3.2.1",
"import-fresh": "^3.3.0",
"js-yaml": "^4.1.0",
"parse-json": "^5.0.0",
"parse-json": "^5.2.0",
"path-type": "^4.0.0"
},
"engines": {
@@ -30846,6 +30949,14 @@
},
"funding": {
"url": "https://github.com/sponsors/d-fischer"
},
"peerDependencies": {
"typescript": ">=4.9.5"
},
"peerDependenciesMeta": {
"typescript": {
"optional": true
}
}
},
"node_modules/postcss-loader/node_modules/js-yaml": {
@@ -33122,9 +33233,9 @@
}
},
"node_modules/sass-loader": {
"version": "13.3.2",
"resolved": "https://registry.npmjs.org/sass-loader/-/sass-loader-13.3.2.tgz",
"integrity": "sha512-CQbKl57kdEv+KDLquhC+gE3pXt74LEAzm+tzywcA0/aHZuub8wTErbjAoNI57rPUWRYRNC5WUnNl8eGJNbDdwg==",
"version": "13.3.3",
"resolved": "https://registry.npmjs.org/sass-loader/-/sass-loader-13.3.3.tgz",
"integrity": "sha512-mt5YN2F1MOZr3d/wBRcZxeFgwgkH44wVc2zohO2YF6JiOMkiXe4BYRZpSu2sO1g71mo/j16txzUhsKZlqjVGzA==",
"dev": true,
"dependencies": {
"neo-async": "^2.6.2"

View File

@@ -120,8 +120,8 @@
"mini-css-extract-plugin": "2.7.6",
"node-ipc": "9.2.1",
"pkg": "5.8.1",
"postcss": "8.4.32",
"postcss-loader": "7.3.3",
"postcss": "8.4.35",
"postcss-loader": "7.3.4",
"prettier": "3.2.2",
"prettier-plugin-tailwindcss": "0.5.11",
"process": "0.11.10",
@@ -131,7 +131,7 @@
"remark-gfm": "3.0.1",
"rimraf": "5.0.5",
"sass": "1.69.5",
"sass-loader": "13.3.2",
"sass-loader": "13.3.3",
"storybook": "7.6.17",
"style-loader": "3.3.3",
"tailwindcss": "3.4.1",
@@ -150,7 +150,7 @@
},
"dependencies": {
"@angular/animations": "16.2.12",
"@angular/cdk": "16.2.13",
"@angular/cdk": "16.2.14",
"@angular/common": "16.2.12",
"@angular/compiler": "16.2.12",
"@angular/core": "16.2.12",