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>; bgReopenUnlockPopout: ({ sender }: BackgroundSenderParam) => Promise<void>;
checkNotificationQueue: ({ sender }: BackgroundSenderParam) => Promise<void>; checkNotificationQueue: ({ sender }: BackgroundSenderParam) => Promise<void>;
collectPageDetailsResponse: ({ message }: BackgroundMessageParam) => Promise<void>; collectPageDetailsResponse: ({ message }: BackgroundMessageParam) => Promise<void>;
bgGetEnableChangedPasswordPrompt: () => Promise<boolean>;
bgGetEnableAddedLoginPrompt: () => Promise<boolean>;
getWebVaultUrlForNotification: () => string; getWebVaultUrlForNotification: () => string;
}; };

View File

@@ -4,6 +4,7 @@ import { firstValueFrom } from "rxjs";
import { PolicyService } from "@bitwarden/common/admin-console/services/policy/policy.service"; import { PolicyService } from "@bitwarden/common/admin-console/services/policy/policy.service";
import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status"; import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status";
import { AuthService } from "@bitwarden/common/auth/services/auth.service"; 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 { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { EnvironmentService } from "@bitwarden/common/platform/services/environment.service"; import { EnvironmentService } from "@bitwarden/common/platform/services/environment.service";
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
@@ -45,6 +46,7 @@ describe("NotificationBackground", () => {
const policyService = mock<PolicyService>(); const policyService = mock<PolicyService>();
const folderService = mock<FolderService>(); const folderService = mock<FolderService>();
const stateService = mock<BrowserStateService>(); const stateService = mock<BrowserStateService>();
const userNotificationSettingsService = mock<UserNotificationSettingsService>();
const environmentService = mock<EnvironmentService>(); const environmentService = mock<EnvironmentService>();
const logService = mock<LogService>(); const logService = mock<LogService>();
@@ -56,6 +58,7 @@ describe("NotificationBackground", () => {
policyService, policyService,
folderService, folderService,
stateService, stateService,
userNotificationSettingsService,
environmentService, environmentService,
logService, logService,
); );
@@ -235,8 +238,8 @@ describe("NotificationBackground", () => {
let tab: chrome.tabs.Tab; let tab: chrome.tabs.Tab;
let sender: chrome.runtime.MessageSender; let sender: chrome.runtime.MessageSender;
let getAuthStatusSpy: jest.SpyInstance; let getAuthStatusSpy: jest.SpyInstance;
let getDisableAddLoginNotificationSpy: jest.SpyInstance; let getEnableAddedLoginPromptSpy: jest.SpyInstance;
let getDisableChangedPasswordNotificationSpy: jest.SpyInstance; let getEnableChangedPasswordPromptSpy: jest.SpyInstance;
let pushAddLoginToQueueSpy: jest.SpyInstance; let pushAddLoginToQueueSpy: jest.SpyInstance;
let pushChangePasswordToQueueSpy: jest.SpyInstance; let pushChangePasswordToQueueSpy: jest.SpyInstance;
let getAllDecryptedForUrlSpy: jest.SpyInstance; let getAllDecryptedForUrlSpy: jest.SpyInstance;
@@ -245,13 +248,13 @@ describe("NotificationBackground", () => {
tab = createChromeTabMock(); tab = createChromeTabMock();
sender = mock<chrome.runtime.MessageSender>({ tab }); sender = mock<chrome.runtime.MessageSender>({ tab });
getAuthStatusSpy = jest.spyOn(authService, "getAuthStatus"); getAuthStatusSpy = jest.spyOn(authService, "getAuthStatus");
getDisableAddLoginNotificationSpy = jest.spyOn( getEnableAddedLoginPromptSpy = jest.spyOn(
stateService, notificationBackground as any,
"getDisableAddLoginNotification", "getEnableAddedLoginPrompt",
); );
getDisableChangedPasswordNotificationSpy = jest.spyOn( getEnableChangedPasswordPromptSpy = jest.spyOn(
stateService, notificationBackground as any,
"getDisableChangedPasswordNotification", "getEnableChangedPasswordPrompt",
); );
pushAddLoginToQueueSpy = jest.spyOn(notificationBackground as any, "pushAddLoginToQueue"); pushAddLoginToQueueSpy = jest.spyOn(notificationBackground as any, "pushAddLoginToQueue");
pushChangePasswordToQueueSpy = jest.spyOn( pushChangePasswordToQueueSpy = jest.spyOn(
@@ -272,7 +275,7 @@ describe("NotificationBackground", () => {
await flushPromises(); await flushPromises();
expect(getAuthStatusSpy).toHaveBeenCalled(); expect(getAuthStatusSpy).toHaveBeenCalled();
expect(getDisableAddLoginNotificationSpy).not.toHaveBeenCalled(); expect(getEnableAddedLoginPromptSpy).not.toHaveBeenCalled();
expect(pushAddLoginToQueueSpy).not.toHaveBeenCalled(); expect(pushAddLoginToQueueSpy).not.toHaveBeenCalled();
}); });
@@ -287,7 +290,7 @@ describe("NotificationBackground", () => {
await flushPromises(); await flushPromises();
expect(getAuthStatusSpy).toHaveBeenCalled(); expect(getAuthStatusSpy).toHaveBeenCalled();
expect(getDisableAddLoginNotificationSpy).not.toHaveBeenCalled(); expect(getEnableAddedLoginPromptSpy).not.toHaveBeenCalled();
expect(pushAddLoginToQueueSpy).not.toHaveBeenCalled(); expect(pushAddLoginToQueueSpy).not.toHaveBeenCalled();
}); });
@@ -297,13 +300,13 @@ describe("NotificationBackground", () => {
login: { username: "test", password: "password", url: "https://example.com" }, login: { username: "test", password: "password", url: "https://example.com" },
}; };
getAuthStatusSpy.mockResolvedValueOnce(AuthenticationStatus.Locked); getAuthStatusSpy.mockResolvedValueOnce(AuthenticationStatus.Locked);
getDisableAddLoginNotificationSpy.mockReturnValueOnce(true); getEnableAddedLoginPromptSpy.mockReturnValueOnce(false);
sendExtensionRuntimeMessage(message, sender); sendExtensionRuntimeMessage(message, sender);
await flushPromises(); await flushPromises();
expect(getAuthStatusSpy).toHaveBeenCalled(); expect(getAuthStatusSpy).toHaveBeenCalled();
expect(getDisableAddLoginNotificationSpy).toHaveBeenCalled(); expect(getEnableAddedLoginPromptSpy).toHaveBeenCalled();
expect(getAllDecryptedForUrlSpy).not.toHaveBeenCalled(); expect(getAllDecryptedForUrlSpy).not.toHaveBeenCalled();
expect(pushAddLoginToQueueSpy).not.toHaveBeenCalled(); expect(pushAddLoginToQueueSpy).not.toHaveBeenCalled();
expect(pushChangePasswordToQueueSpy).not.toHaveBeenCalled(); expect(pushChangePasswordToQueueSpy).not.toHaveBeenCalled();
@@ -315,14 +318,14 @@ describe("NotificationBackground", () => {
login: { username: "test", password: "password", url: "https://example.com" }, login: { username: "test", password: "password", url: "https://example.com" },
}; };
getAuthStatusSpy.mockResolvedValueOnce(AuthenticationStatus.Unlocked); getAuthStatusSpy.mockResolvedValueOnce(AuthenticationStatus.Unlocked);
getDisableAddLoginNotificationSpy.mockReturnValueOnce(true); getEnableAddedLoginPromptSpy.mockReturnValueOnce(false);
getAllDecryptedForUrlSpy.mockResolvedValueOnce([]); getAllDecryptedForUrlSpy.mockResolvedValueOnce([]);
sendExtensionRuntimeMessage(message, sender); sendExtensionRuntimeMessage(message, sender);
await flushPromises(); await flushPromises();
expect(getAuthStatusSpy).toHaveBeenCalled(); expect(getAuthStatusSpy).toHaveBeenCalled();
expect(getDisableAddLoginNotificationSpy).toHaveBeenCalled(); expect(getEnableAddedLoginPromptSpy).toHaveBeenCalled();
expect(getAllDecryptedForUrlSpy).toHaveBeenCalled(); expect(getAllDecryptedForUrlSpy).toHaveBeenCalled();
expect(pushAddLoginToQueueSpy).not.toHaveBeenCalled(); expect(pushAddLoginToQueueSpy).not.toHaveBeenCalled();
expect(pushChangePasswordToQueueSpy).not.toHaveBeenCalled(); expect(pushChangePasswordToQueueSpy).not.toHaveBeenCalled();
@@ -334,8 +337,8 @@ describe("NotificationBackground", () => {
login: { username: "test", password: "password", url: "https://example.com" }, login: { username: "test", password: "password", url: "https://example.com" },
}; };
getAuthStatusSpy.mockResolvedValueOnce(AuthenticationStatus.Unlocked); getAuthStatusSpy.mockResolvedValueOnce(AuthenticationStatus.Unlocked);
getDisableAddLoginNotificationSpy.mockReturnValueOnce(false); getEnableAddedLoginPromptSpy.mockReturnValueOnce(true);
getDisableChangedPasswordNotificationSpy.mockReturnValueOnce(true); getEnableChangedPasswordPromptSpy.mockReturnValueOnce(false);
getAllDecryptedForUrlSpy.mockResolvedValueOnce([ getAllDecryptedForUrlSpy.mockResolvedValueOnce([
mock<CipherView>({ login: { username: "test", password: "oldPassword" } }), mock<CipherView>({ login: { username: "test", password: "oldPassword" } }),
]); ]);
@@ -344,9 +347,9 @@ describe("NotificationBackground", () => {
await flushPromises(); await flushPromises();
expect(getAuthStatusSpy).toHaveBeenCalled(); expect(getAuthStatusSpy).toHaveBeenCalled();
expect(getDisableAddLoginNotificationSpy).toHaveBeenCalled(); expect(getEnableAddedLoginPromptSpy).toHaveBeenCalled();
expect(getAllDecryptedForUrlSpy).toHaveBeenCalled(); expect(getAllDecryptedForUrlSpy).toHaveBeenCalled();
expect(getDisableChangedPasswordNotificationSpy).toHaveBeenCalled(); expect(getEnableChangedPasswordPromptSpy).toHaveBeenCalled();
expect(pushAddLoginToQueueSpy).not.toHaveBeenCalled(); expect(pushAddLoginToQueueSpy).not.toHaveBeenCalled();
expect(pushChangePasswordToQueueSpy).not.toHaveBeenCalled(); expect(pushChangePasswordToQueueSpy).not.toHaveBeenCalled();
}); });
@@ -357,7 +360,7 @@ describe("NotificationBackground", () => {
login: { username: "test", password: "password", url: "https://example.com" }, login: { username: "test", password: "password", url: "https://example.com" },
}; };
getAuthStatusSpy.mockResolvedValueOnce(AuthenticationStatus.Unlocked); getAuthStatusSpy.mockResolvedValueOnce(AuthenticationStatus.Unlocked);
getDisableAddLoginNotificationSpy.mockReturnValueOnce(false); getEnableAddedLoginPromptSpy.mockReturnValueOnce(true);
getAllDecryptedForUrlSpy.mockResolvedValueOnce([ getAllDecryptedForUrlSpy.mockResolvedValueOnce([
mock<CipherView>({ login: { username: "test", password: "password" } }), mock<CipherView>({ login: { username: "test", password: "password" } }),
]); ]);
@@ -366,7 +369,7 @@ describe("NotificationBackground", () => {
await flushPromises(); await flushPromises();
expect(getAuthStatusSpy).toHaveBeenCalled(); expect(getAuthStatusSpy).toHaveBeenCalled();
expect(getDisableAddLoginNotificationSpy).toHaveBeenCalled(); expect(getEnableAddedLoginPromptSpy).toHaveBeenCalled();
expect(getAllDecryptedForUrlSpy).toHaveBeenCalled(); expect(getAllDecryptedForUrlSpy).toHaveBeenCalled();
expect(pushAddLoginToQueueSpy).not.toHaveBeenCalled(); expect(pushAddLoginToQueueSpy).not.toHaveBeenCalled();
expect(pushChangePasswordToQueueSpy).not.toHaveBeenCalled(); expect(pushChangePasswordToQueueSpy).not.toHaveBeenCalled();
@@ -376,7 +379,7 @@ describe("NotificationBackground", () => {
const login = { username: "test", password: "password", url: "https://example.com" }; const login = { username: "test", password: "password", url: "https://example.com" };
const message: NotificationBackgroundExtensionMessage = { command: "bgAddLogin", login }; const message: NotificationBackgroundExtensionMessage = { command: "bgAddLogin", login };
getAuthStatusSpy.mockResolvedValueOnce(AuthenticationStatus.Locked); getAuthStatusSpy.mockResolvedValueOnce(AuthenticationStatus.Locked);
getDisableAddLoginNotificationSpy.mockReturnValueOnce(false); getEnableAddedLoginPromptSpy.mockReturnValueOnce(true);
sendExtensionRuntimeMessage(message, sender); sendExtensionRuntimeMessage(message, sender);
await flushPromises(); await flushPromises();
@@ -393,7 +396,7 @@ describe("NotificationBackground", () => {
} as any; } as any;
const message: NotificationBackgroundExtensionMessage = { command: "bgAddLogin", login }; const message: NotificationBackgroundExtensionMessage = { command: "bgAddLogin", login };
getAuthStatusSpy.mockResolvedValueOnce(AuthenticationStatus.Unlocked); getAuthStatusSpy.mockResolvedValueOnce(AuthenticationStatus.Unlocked);
getDisableAddLoginNotificationSpy.mockReturnValueOnce(false); getEnableAddedLoginPromptSpy.mockReturnValueOnce(true);
getAllDecryptedForUrlSpy.mockResolvedValueOnce([ getAllDecryptedForUrlSpy.mockResolvedValueOnce([
mock<CipherView>({ login: { username: "anotherTestUsername", password: "password" } }), mock<CipherView>({ login: { username: "anotherTestUsername", password: "password" } }),
]); ]);
@@ -409,7 +412,8 @@ describe("NotificationBackground", () => {
const login = { username: "tEsT", password: "password", url: "https://example.com" }; const login = { username: "tEsT", password: "password", url: "https://example.com" };
const message: NotificationBackgroundExtensionMessage = { command: "bgAddLogin", login }; const message: NotificationBackgroundExtensionMessage = { command: "bgAddLogin", login };
getAuthStatusSpy.mockResolvedValueOnce(AuthenticationStatus.Unlocked); getAuthStatusSpy.mockResolvedValueOnce(AuthenticationStatus.Unlocked);
getDisableAddLoginNotificationSpy.mockReturnValueOnce(false); getEnableAddedLoginPromptSpy.mockResolvedValueOnce(true);
getEnableChangedPasswordPromptSpy.mockResolvedValueOnce(true);
getAllDecryptedForUrlSpy.mockResolvedValueOnce([ getAllDecryptedForUrlSpy.mockResolvedValueOnce([
mock<CipherView>({ mock<CipherView>({
id: "cipher-id", 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 { PolicyType } from "@bitwarden/common/admin-console/enums";
import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service"; import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service";
import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status"; 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 { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { Utils } from "@bitwarden/common/platform/misc/utils"; import { Utils } from "@bitwarden/common/platform/misc/utils";
@@ -57,6 +58,8 @@ export default class NotificationBackground {
bgUnlockPopoutOpened: ({ message, sender }) => this.unlockVault(message, sender.tab), bgUnlockPopoutOpened: ({ message, sender }) => this.unlockVault(message, sender.tab),
checkNotificationQueue: ({ sender }) => this.checkNotificationQueue(sender.tab), checkNotificationQueue: ({ sender }) => this.checkNotificationQueue(sender.tab),
bgReopenUnlockPopout: ({ sender }) => this.openUnlockPopout(sender.tab), bgReopenUnlockPopout: ({ sender }) => this.openUnlockPopout(sender.tab),
bgGetEnableChangedPasswordPrompt: () => this.getEnableChangedPasswordPrompt(),
bgGetEnableAddedLoginPrompt: () => this.getEnableAddedLoginPrompt(),
getWebVaultUrlForNotification: () => this.getWebVaultUrl(), getWebVaultUrlForNotification: () => this.getWebVaultUrl(),
}; };
@@ -67,6 +70,7 @@ export default class NotificationBackground {
private policyService: PolicyService, private policyService: PolicyService,
private folderService: FolderService, private folderService: FolderService,
private stateService: BrowserStateService, private stateService: BrowserStateService,
private userNotificationSettingsService: UserNotificationSettingsServiceAbstraction,
private environmentService: EnvironmentService, private environmentService: EnvironmentService,
private logService: LogService, private logService: LogService,
) {} ) {}
@@ -81,6 +85,20 @@ export default class NotificationBackground {
this.cleanupNotificationQueue(); 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 * 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. * specified tab. If no tab is specified, the current tab will be used.
@@ -194,9 +212,10 @@ export default class NotificationBackground {
return; return;
} }
const disabledAddLogin = await this.stateService.getDisableAddLoginNotification(); const addLoginIsEnabled = await this.getEnableAddedLoginPrompt();
if (authStatus === AuthenticationStatus.Locked) { if (authStatus === AuthenticationStatus.Locked) {
if (!disabledAddLogin) { if (addLoginIsEnabled) {
await this.pushAddLoginToQueue(loginDomain, loginInfo, sender.tab, true); await this.pushAddLoginToQueue(loginDomain, loginInfo, sender.tab, true);
} }
@@ -207,14 +226,15 @@ export default class NotificationBackground {
const usernameMatches = ciphers.filter( const usernameMatches = ciphers.filter(
(c) => c.login.username != null && c.login.username.toLowerCase() === normalizedUsername, (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); await this.pushAddLoginToQueue(loginDomain, loginInfo, sender.tab);
return; return;
} }
const disabledChangePassword = await this.stateService.getDisableChangedPasswordNotification(); const changePasswordIsEnabled = await this.getEnableChangedPasswordPrompt();
if ( if (
!disabledChangePassword && changePasswordIsEnabled &&
usernameMatches.length === 1 && usernameMatches.length === 1 &&
usernameMatches[0].login.password !== loginInfo.password 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 { NotificationBarIframeInitData } from "../notification/abstractions/notification-bar";
import { FormData } from "../services/abstractions/autofill.service"; import { FormData } from "../services/abstractions/autofill.service";
import { GlobalSettings, UserSettings } from "../types"; import { GlobalSettings, UserSettings } from "../types";
import { getFromLocalStorage, setupExtensionDisconnectAction } from "../utils"; import {
getFromLocalStorage,
sendExtensionMessage,
setupExtensionDisconnectAction,
} from "../utils";
interface HTMLElementWithFormOpId extends HTMLElement { interface HTMLElementWithFormOpId extends HTMLElement {
formOpId: string; formOpId: string;
@@ -86,12 +90,11 @@ async function loadNotificationBar() {
]); ]);
const changePasswordButtonContainsNames = new Set(["pass", "change", "contras", "senha"]); const changePasswordButtonContainsNames = new Set(["pass", "change", "contras", "senha"]);
// These are preferences for whether to show the notification bar based on the user's settings const enableChangedPasswordPrompt = await sendExtensionMessage(
// and they are set in the Settings > Options page in the browser extension. "bgGetEnableChangedPasswordPrompt",
let disabledAddLoginNotification = false; );
let disabledChangedPasswordNotification = false; const enableAddedLoginPrompt = await sendExtensionMessage("bgGetEnableAddedLoginPrompt");
let showNotificationBar = true; let showNotificationBar = true;
// Look up the active user id from storage // Look up the active user id from storage
const activeUserIdKey = "activeUserId"; const activeUserIdKey = "activeUserId";
const globalStorageKey = "global"; const globalStorageKey = "global";
@@ -121,11 +124,7 @@ async function loadNotificationBar() {
// Example: '{"bitwarden.com":null}' // Example: '{"bitwarden.com":null}'
const excludedDomainsDict = globalSettings.neverDomains; const excludedDomainsDict = globalSettings.neverDomains;
if (!excludedDomainsDict || !(window.location.hostname in excludedDomainsDict)) { if (!excludedDomainsDict || !(window.location.hostname in excludedDomainsDict)) {
// Set local disabled preferences if (enableAddedLoginPrompt || enableChangedPasswordPrompt) {
disabledAddLoginNotification = globalSettings.disableAddLoginNotification;
disabledChangedPasswordNotification = globalSettings.disableChangedPasswordNotification;
if (!disabledAddLoginNotification || !disabledChangedPasswordNotification) {
// If the user has not disabled both notifications, then handle the initial page change (null -> actual page) // If the user has not disabled both notifications, then handle the initial page change (null -> actual page)
handlePageChange(); handlePageChange();
} }
@@ -352,9 +351,7 @@ async function loadNotificationBar() {
// to avoid missing any forms that are added after the page loads // to avoid missing any forms that are added after the page loads
observeDom(); observeDom();
sendPlatformMessage({ void sendExtensionMessage("checkNotificationQueue");
command: "checkNotificationQueue",
});
} }
// This is a safeguard in case the observer misses a SPA page change. // This is a safeguard in case the observer misses a SPA page change.
@@ -392,10 +389,7 @@ async function loadNotificationBar() {
* *
* */ * */
function collectPageDetails() { function collectPageDetails() {
sendPlatformMessage({ void sendExtensionMessage("bgCollectPageDetails", { sender: "notificationBar" });
command: "bgCollectPageDetails",
sender: "notificationBar",
});
} }
// End Page Detail Collection Methods // End Page Detail Collection Methods
@@ -620,10 +614,9 @@ async function loadNotificationBar() {
continue; continue;
} }
const disabledBoth = disabledChangedPasswordNotification && disabledAddLoginNotification; // if user has enabled either add login or change password notification, and we have a username and password field
// if user has not disabled both notifications and we have a username and password field,
if ( if (
!disabledBoth && (enableChangedPasswordPrompt || enableAddedLoginPrompt) &&
watchedForms[i].usernameEl != null && watchedForms[i].usernameEl != null &&
watchedForms[i].passwordEl != null watchedForms[i].passwordEl != null
) { ) {
@@ -639,10 +632,7 @@ async function loadNotificationBar() {
const passwordPopulated = login.password != null && login.password !== ""; const passwordPopulated = login.password != null && login.password !== "";
if (userNamePopulated && passwordPopulated) { if (userNamePopulated && passwordPopulated) {
processedForm(form); processedForm(form);
sendPlatformMessage({ void sendExtensionMessage("bgAddLogin", { login });
command: "bgAddLogin",
login,
});
break; break;
} else if ( } else if (
userNamePopulated && userNamePopulated &&
@@ -659,7 +649,7 @@ async function loadNotificationBar() {
// if user has not disabled the password changed notification and we have multiple password fields, // if user has not disabled the password changed notification and we have multiple password fields,
// then check if the user has changed their password // 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 // Get the values of the password fields
const passwords: string[] = watchedForms[i].passwordEls const passwords: string[] = watchedForms[i].passwordEls
.filter((el: HTMLInputElement) => el.value != null && el.value !== "") .filter((el: HTMLInputElement) => el.value != null && el.value !== "")
@@ -716,7 +706,7 @@ async function loadNotificationBar() {
currentPassword: curPass, currentPassword: curPass,
url: document.URL, url: document.URL,
}; };
sendPlatformMessage({ command: "bgChangedPassword", data }); void sendExtensionMessage("bgChangedPassword", { data });
break; break;
} }
} }
@@ -954,9 +944,7 @@ async function loadNotificationBar() {
switch (barType) { switch (barType) {
case "add": case "add":
case "change": case "change":
sendPlatformMessage({ void sendExtensionMessage("bgRemoveTabFromNotificationQueue");
command: "bgRemoveTabFromNotificationQueue",
});
break; break;
default: default:
break; break;
@@ -981,12 +969,6 @@ async function loadNotificationBar() {
// End Notification Bar Functions (open, close, height adjustment, etc.) // End Notification Bar Functions (open, close, height adjustment, etc.)
// Helper Functions // 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() { function isInIframe() {
try { try {
return window.self !== window.top; return window.self !== window.top;

View File

@@ -166,11 +166,10 @@ describe("AutofillService", () => {
jest jest
.spyOn(autofillService, "getOverlayVisibility") .spyOn(autofillService, "getOverlayVisibility")
.mockResolvedValue(AutofillOverlayVisibility.OnFieldFocus); .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 () => { 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); await autofillService.injectAutofillScripts(sender.tab, sender.frameId, true);
[autofillOverlayBootstrapScript, ...defaultAutofillScripts].forEach((scriptName) => { [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 () => { 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); await autofillService.injectAutofillScripts(sender.tab, sender.frameId);
expect(BrowserApi.executeScriptInTab).toHaveBeenCalledWith(tabMock.id, { expect(BrowserApi.executeScriptInTab).toHaveBeenCalledWith(tabMock.id, {
@@ -218,7 +212,6 @@ describe("AutofillService", () => {
jest jest
.spyOn(autofillService, "getOverlayVisibility") .spyOn(autofillService, "getOverlayVisibility")
.mockResolvedValue(AutofillOverlayVisibility.Off); .mockResolvedValue(AutofillOverlayVisibility.Off);
jest.spyOn(autofillService, "getAutofillOnPageLoad").mockResolvedValue(true);
await autofillService.injectAutofillScripts(sender.tab, sender.frameId); 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 () => { 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); await autofillService.injectAutofillScripts(sender.tab, sender.frameId, false);
expect(BrowserApi.executeScriptInTab).toHaveBeenCalledWith(tabMock.id, { expect(BrowserApi.executeScriptInTab).toHaveBeenCalledWith(tabMock.id, {

View File

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

View File

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

View File

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

View File

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

View File

@@ -31,6 +31,12 @@
"matches": ["http://*/*", "https://*/*", "file:///*"], "matches": ["http://*/*", "https://*/*", "file:///*"],
"run_at": "document_start" "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, "all_frames": true,
"css": ["content/autofill.css"], "css": ["content/autofill.css"],

View File

@@ -9,18 +9,20 @@ import {
import { CachedServices, FactoryOptions, factory } from "./factory-options"; import { CachedServices, FactoryOptions, factory } from "./factory-options";
import { import {
DiskStorageServiceInitOptions, StateEventRegistrarServiceInitOptions,
MemoryStorageServiceInitOptions, stateEventRegistrarServiceFactory,
observableDiskStorageServiceFactory, } from "./state-event-registrar-service.factory";
observableMemoryStorageServiceFactory, import {
} from "./storage-service.factory"; StorageServiceProviderInitOptions,
storageServiceProviderFactory,
} from "./storage-service-provider.factory";
type ActiveUserStateProviderFactory = FactoryOptions; type ActiveUserStateProviderFactory = FactoryOptions;
export type ActiveUserStateProviderInitOptions = ActiveUserStateProviderFactory & export type ActiveUserStateProviderInitOptions = ActiveUserStateProviderFactory &
AccountServiceInitOptions & AccountServiceInitOptions &
MemoryStorageServiceInitOptions & StorageServiceProviderInitOptions &
DiskStorageServiceInitOptions; StateEventRegistrarServiceInitOptions;
export async function activeUserStateProviderFactory( export async function activeUserStateProviderFactory(
cache: { activeUserStateProvider?: ActiveUserStateProvider } & CachedServices, cache: { activeUserStateProvider?: ActiveUserStateProvider } & CachedServices,
@@ -33,8 +35,8 @@ export async function activeUserStateProviderFactory(
async () => async () =>
new DefaultActiveUserStateProvider( new DefaultActiveUserStateProvider(
await accountServiceFactory(cache, opts), await accountServiceFactory(cache, opts),
await observableMemoryStorageServiceFactory(cache, opts), await storageServiceProviderFactory(cache, opts),
await observableDiskStorageServiceFactory(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 { CachedServices, FactoryOptions, factory } from "./factory-options";
import { import {
DiskStorageServiceInitOptions, StorageServiceProviderInitOptions,
MemoryStorageServiceInitOptions, storageServiceProviderFactory,
observableDiskStorageServiceFactory, } from "./storage-service-provider.factory";
observableMemoryStorageServiceFactory,
} from "./storage-service.factory";
type GlobalStateProviderFactoryOptions = FactoryOptions; type GlobalStateProviderFactoryOptions = FactoryOptions;
export type GlobalStateProviderInitOptions = GlobalStateProviderFactoryOptions & export type GlobalStateProviderInitOptions = GlobalStateProviderFactoryOptions &
MemoryStorageServiceInitOptions & StorageServiceProviderInitOptions;
DiskStorageServiceInitOptions;
export async function globalStateProviderFactory( export async function globalStateProviderFactory(
cache: { globalStateProvider?: GlobalStateProvider } & CachedServices, cache: { globalStateProvider?: GlobalStateProvider } & CachedServices,
@@ -24,10 +21,6 @@ export async function globalStateProviderFactory(
cache, cache,
"globalStateProvider", "globalStateProvider",
opts, opts,
async () => async () => new DefaultGlobalStateProvider(await storageServiceProviderFactory(cache, opts)),
new DefaultGlobalStateProvider(
await observableMemoryStorageServiceFactory(cache, opts),
await observableDiskStorageServiceFactory(cache, opts),
),
); );
} }

View File

@@ -4,17 +4,19 @@ import { DefaultSingleUserStateProvider } from "@bitwarden/common/platform/state
import { CachedServices, FactoryOptions, factory } from "./factory-options"; import { CachedServices, FactoryOptions, factory } from "./factory-options";
import { import {
DiskStorageServiceInitOptions, StateEventRegistrarServiceInitOptions,
MemoryStorageServiceInitOptions, stateEventRegistrarServiceFactory,
observableDiskStorageServiceFactory, } from "./state-event-registrar-service.factory";
observableMemoryStorageServiceFactory, import {
} from "./storage-service.factory"; StorageServiceProviderInitOptions,
storageServiceProviderFactory,
} from "./storage-service-provider.factory";
type SingleUserStateProviderFactoryOptions = FactoryOptions; type SingleUserStateProviderFactoryOptions = FactoryOptions;
export type SingleUserStateProviderInitOptions = SingleUserStateProviderFactoryOptions & export type SingleUserStateProviderInitOptions = SingleUserStateProviderFactoryOptions &
MemoryStorageServiceInitOptions & StorageServiceProviderInitOptions &
DiskStorageServiceInitOptions; StateEventRegistrarServiceInitOptions;
export async function singleUserStateProviderFactory( export async function singleUserStateProviderFactory(
cache: { singleUserStateProvider?: SingleUserStateProvider } & CachedServices, cache: { singleUserStateProvider?: SingleUserStateProvider } & CachedServices,
@@ -26,8 +28,8 @@ export async function singleUserStateProviderFactory(
opts, opts,
async () => async () =>
new DefaultSingleUserStateProvider( new DefaultSingleUserStateProvider(
await observableMemoryStorageServiceFactory(cache, opts), await storageServiceProviderFactory(cache, opts),
await observableDiskStorageServiceFactory(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], files: [injectDetails.file],
injectImmediately: true, 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); 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. * Extension API helper method used to execute a script in a tab.
*
* @see https://developer.chrome.com/docs/extensions/reference/tabs/#method-executeScript * @see https://developer.chrome.com/docs/extensions/reference/tabs/#method-executeScript
* @param {number} tabId * @param tabId - The id of the tab to execute the script in.
* @param {chrome.tabs.InjectDetails} details * @param details {@link "InjectDetails" https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/extensionTypes/InjectDetails}
* @returns {Promise<unknown>} * @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) { if (BrowserApi.manifestVersion === 3) {
return chrome.scripting.executeScript({ return chrome.scripting.executeScript({
target: { target: {
@@ -490,6 +497,7 @@ export class BrowserApi {
}, },
files: details.file ? [details.file] : null, files: details.file ? [details.file] : null,
injectImmediately: details.runAt === "document_start", injectImmediately: details.runAt === "document_start",
world: scriptingApiDetails?.world || "ISOLATED",
}); });
} }

View File

@@ -15,7 +15,6 @@ import {
LoginStrategyServiceAbstraction, LoginStrategyServiceAbstraction,
} from "@bitwarden/auth/common"; } from "@bitwarden/auth/common";
import { ApiService } from "@bitwarden/common/abstractions/api.service"; 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 { EventCollectionService } from "@bitwarden/common/abstractions/event/event-collection.service";
import { EventUploadService } from "@bitwarden/common/abstractions/event/event-upload.service"; import { EventUploadService } from "@bitwarden/common/abstractions/event/event-upload.service";
import { NotificationsService } from "@bitwarden/common/abstractions/notifications.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 { VaultTimeoutService } from "@bitwarden/common/abstractions/vault-timeout/vault-timeout.service";
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; 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 { 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 { AccountService as AccountServiceAbstraction } from "@bitwarden/common/auth/abstractions/account.service";
import { AuthService as AuthServiceAbstraction } from "@bitwarden/common/auth/abstractions/auth.service"; import { AuthService as AuthServiceAbstraction } from "@bitwarden/common/auth/abstractions/auth.service";
import { DeviceTrustCryptoServiceAbstraction } from "@bitwarden/common/auth/abstractions/device-trust-crypto.service.abstraction"; import { DeviceTrustCryptoServiceAbstraction } from "@bitwarden/common/auth/abstractions/device-trust-crypto.service.abstraction";
@@ -42,6 +40,10 @@ import {
AutofillSettingsService, AutofillSettingsService,
AutofillSettingsServiceAbstraction, AutofillSettingsServiceAbstraction,
} from "@bitwarden/common/autofill/services/autofill-settings.service"; } 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 { AppIdService } from "@bitwarden/common/platform/abstractions/app-id.service";
import { ConfigApiServiceAbstraction } from "@bitwarden/common/platform/abstractions/config/config-api.service.abstraction"; import { ConfigApiServiceAbstraction } from "@bitwarden/common/platform/abstractions/config/config-api.service.abstraction";
import { CryptoFunctionService } from "@bitwarden/common/platform/abstractions/crypto-function.service"; import { CryptoFunctionService } from "@bitwarden/common/platform/abstractions/crypto-function.service";
@@ -207,7 +209,6 @@ function getBgService<T>(service: keyof MainBackground) {
}, },
deps: [LogServiceAbstraction, I18nServiceAbstraction], deps: [LogServiceAbstraction, I18nServiceAbstraction],
}, },
{ provide: AuditService, useFactory: getBgService<AuditService>("auditService"), deps: [] },
{ {
provide: CipherFileUploadService, provide: CipherFileUploadService,
useFactory: getBgService<CipherFileUploadService>("cipherFileUploadService"), useFactory: getBgService<CipherFileUploadService>("cipherFileUploadService"),
@@ -434,11 +435,6 @@ function getBgService<T>(service: keyof MainBackground) {
AccountServiceAbstraction, AccountServiceAbstraction,
], ],
}, },
{
provide: ProviderService,
useFactory: getBgService<ProviderService>("providerService"),
deps: [],
},
{ {
provide: SECURE_STORAGE, provide: SECURE_STORAGE,
useFactory: getBgService<AbstractStorageService>("secureStorageService"), useFactory: getBgService<AbstractStorageService>("secureStorageService"),
@@ -513,13 +509,15 @@ function getBgService<T>(service: keyof MainBackground) {
stateService: StateServiceAbstraction, stateService: StateServiceAbstraction,
platformUtilsService: PlatformUtilsService, 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. // 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. // 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, let windowContext = window;
document, const backgroundWindow = BrowserApi.getBackgroundPage();
); if (platformUtilsService.isSafari() && backgroundWindow) {
windowContext = backgroundWindow;
}
return new ThemingService(stateService, windowContext, document);
}, },
deps: [StateServiceAbstraction, PlatformUtilsService], deps: [StateServiceAbstraction, PlatformUtilsService],
}, },
@@ -551,6 +549,11 @@ function getBgService<T>(service: keyof MainBackground) {
useClass: AutofillSettingsService, useClass: AutofillSettingsService,
deps: [StateProvider, PolicyService], deps: [StateProvider, PolicyService],
}, },
{
provide: UserNotificationSettingsServiceAbstraction,
useClass: UserNotificationSettingsService,
deps: [StateProvider],
},
], ],
}) })
export class ServicesModule {} 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 { SettingsService } from "@bitwarden/common/abstractions/settings.service";
import { AutofillSettingsServiceAbstraction } from "@bitwarden/common/autofill/services/autofill-settings.service"; import { AutofillSettingsServiceAbstraction } from "@bitwarden/common/autofill/services/autofill-settings.service";
import { BadgeSettingsServiceAbstraction } from "@bitwarden/common/autofill/services/badge-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 { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service"; import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service";
import { StateService } from "@bitwarden/common/platform/abstractions/state.service"; import { StateService } from "@bitwarden/common/platform/abstractions/state.service";
@@ -47,6 +48,7 @@ export class OptionsComponent implements OnInit {
constructor( constructor(
private messagingService: MessagingService, private messagingService: MessagingService,
private stateService: StateService, private stateService: StateService,
private userNotificationSettingsService: UserNotificationSettingsServiceAbstraction,
private autofillSettingsService: AutofillSettingsServiceAbstraction, private autofillSettingsService: AutofillSettingsServiceAbstraction,
private badgeSettingsService: BadgeSettingsServiceAbstraction, private badgeSettingsService: BadgeSettingsServiceAbstraction,
i18nService: I18nService, i18nService: I18nService,
@@ -95,10 +97,13 @@ export class OptionsComponent implements OnInit {
this.autofillSettingsService.autofillOnPageLoadDefault$, this.autofillSettingsService.autofillOnPageLoadDefault$,
); );
this.enableAddLoginNotification = !(await this.stateService.getDisableAddLoginNotification()); this.enableAddLoginNotification = await firstValueFrom(
this.userNotificationSettingsService.enableAddedLoginPrompt$,
);
this.enableChangedPasswordNotification = this.enableChangedPasswordNotification = await firstValueFrom(
!(await this.stateService.getDisableChangedPasswordNotification()); this.userNotificationSettingsService.enableChangedPasswordPrompt$,
);
this.enableContextMenuItem = !(await this.stateService.getDisableContextMenuItem()); this.enableContextMenuItem = !(await this.stateService.getDisableContextMenuItem());
@@ -122,12 +127,14 @@ export class OptionsComponent implements OnInit {
} }
async updateAddLoginNotification() { async updateAddLoginNotification() {
await this.stateService.setDisableAddLoginNotification(!this.enableAddLoginNotification); await this.userNotificationSettingsService.setEnableAddedLoginPrompt(
this.enableAddLoginNotification,
);
} }
async updateChangedPasswordNotification() { async updateChangedPasswordNotification() {
await this.stateService.setDisableChangedPasswordNotification( await this.userNotificationSettingsService.setEnableChangedPasswordPrompt(
!this.enableChangedPasswordNotification, this.enableChangedPasswordNotification,
); );
} }

View File

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

View File

@@ -1,4 +1,5 @@
import { mock } from "jest-mock-extended"; import { mock } from "jest-mock-extended";
import { firstValueFrom } from "rxjs";
import { PolicyService } from "@bitwarden/common/admin-console/services/policy/policy.service"; import { PolicyService } from "@bitwarden/common/admin-console/services/policy/policy.service";
import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status"; import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status";
@@ -14,10 +15,20 @@ import {
sendPortMessage, sendPortMessage,
triggerRuntimeOnConnectEvent, triggerRuntimeOnConnectEvent,
} from "../../autofill/spec/testing-utils"; } from "../../autofill/spec/testing-utils";
import { BrowserApi } from "../../platform/browser/browser-api";
import { FilelessImportPort, FilelessImportType } from "../enums/fileless-import.enums"; import { FilelessImportPort, FilelessImportType } from "../enums/fileless-import.enums";
import FilelessImporterBackground from "./fileless-importer.background"; 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 ", () => { describe("FilelessImporterBackground ", () => {
let filelessImporterBackground: FilelessImporterBackground; let filelessImporterBackground: FilelessImporterBackground;
const configService = mock<ConfigService>(); const configService = mock<ConfigService>();
@@ -51,14 +62,17 @@ describe("FilelessImporterBackground ", () => {
describe("handle ports onConnect", () => { describe("handle ports onConnect", () => {
let lpImporterPort: chrome.runtime.Port; let lpImporterPort: chrome.runtime.Port;
let manifestVersionSpy: jest.SpyInstance;
let executeScriptInTabSpy: jest.SpyInstance;
beforeEach(() => { beforeEach(() => {
lpImporterPort = createPortSpyMock(FilelessImportPort.LpImporter); 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(authService, "getAuthStatus").mockResolvedValue(AuthenticationStatus.Unlocked);
jest.spyOn(configService, "getFeatureFlag").mockResolvedValue(true); jest.spyOn(configService, "getFeatureFlag").mockResolvedValue(true);
jest jest.spyOn(filelessImporterBackground as any, "removeIndividualVault");
.spyOn(filelessImporterBackground as any, "removeIndividualVault") (firstValueFrom as jest.Mock).mockResolvedValue(false);
.mockResolvedValue(false);
}); });
it("ignores the port connection if the port name is not present in the set of filelessImportNames", async () => { 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 () => { 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 (firstValueFrom as jest.Mock).mockResolvedValue(true);
.spyOn(filelessImporterBackground as any, "removeIndividualVault")
.mockResolvedValue(true);
triggerRuntimeOnConnectEvent(lpImporterPort); triggerRuntimeOnConnectEvent(lpImporterPort);
await flushPromises(); await flushPromises();
@@ -117,6 +129,35 @@ describe("FilelessImporterBackground ", () => {
filelessImportEnabled: true, 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", () => { describe("port messages", () => {
@@ -126,9 +167,7 @@ describe("FilelessImporterBackground ", () => {
beforeEach(async () => { beforeEach(async () => {
jest.spyOn(authService, "getAuthStatus").mockResolvedValue(AuthenticationStatus.Unlocked); jest.spyOn(authService, "getAuthStatus").mockResolvedValue(AuthenticationStatus.Unlocked);
jest.spyOn(configService, "getFeatureFlag").mockResolvedValue(true); jest.spyOn(configService, "getFeatureFlag").mockResolvedValue(true);
jest (firstValueFrom as jest.Mock).mockResolvedValue(false);
.spyOn(filelessImporterBackground as any, "removeIndividualVault")
.mockResolvedValue(false);
triggerRuntimeOnConnectEvent(createPortSpyMock(FilelessImportPort.NotificationBar)); triggerRuntimeOnConnectEvent(createPortSpyMock(FilelessImportPort.NotificationBar));
triggerRuntimeOnConnectEvent(createPortSpyMock(FilelessImportPort.LpImporter)); triggerRuntimeOnConnectEvent(createPortSpyMock(FilelessImportPort.LpImporter));
await flushPromises(); await flushPromises();

View File

@@ -11,6 +11,7 @@ import { ImportServiceAbstraction } from "@bitwarden/importer/core";
import NotificationBackground from "../../autofill/background/notification.background"; import NotificationBackground from "../../autofill/background/notification.background";
import { BrowserApi } from "../../platform/browser/browser-api"; import { BrowserApi } from "../../platform/browser/browser-api";
import { FilelessImporterInjectedScriptsConfig } from "../config/fileless-importer-injected-scripts";
import { import {
FilelessImportPort, FilelessImportPort,
FilelessImportType, FilelessImportType,
@@ -22,6 +23,7 @@ import {
LpImporterMessageHandlers, LpImporterMessageHandlers,
FilelessImporterBackground as FilelessImporterBackgroundInterface, FilelessImporterBackground as FilelessImporterBackgroundInterface,
FilelessImportPortMessage, FilelessImportPortMessage,
SuppressDownloadScriptInjectionConfig,
} from "./abstractions/fileless-importer.background"; } from "./abstractions/fileless-importer.background";
class FilelessImporterBackground implements FilelessImporterBackgroundInterface { class FilelessImporterBackground implements FilelessImporterBackgroundInterface {
@@ -108,6 +110,23 @@ class FilelessImporterBackground implements FilelessImporterBackgroundInterface
await this.notificationBackground.requestFilelessImport(tab, importType); 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 * 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. * 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) { switch (port.name) {
case FilelessImportPort.LpImporter: case FilelessImportPort.LpImporter:
this.lpImporterPort = port; this.lpImporterPort = port;
await this.injectScriptConfig(
port.sender,
BrowserApi.manifestVersion === 3
? FilelessImporterInjectedScriptsConfig.LpSuppressImportDownload.mv3
: FilelessImporterInjectedScriptsConfig.LpSuppressImportDownload.mv2,
);
break; break;
case FilelessImportPort.NotificationBar: case FilelessImportPort.NotificationBar:
this.importNotificationsPort = port; 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(); 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`", () => { it("sets up an event listener for DOMContentLoaded that triggers the importer when the document ready state is `loading`", () => {
Object.defineProperty(document, "readyState", { Object.defineProperty(document, "readyState", {
value: "loading", value: "loading",

View File

@@ -36,7 +36,6 @@ class LpFilelessImporter implements LpFilelessImporterInterface {
return; return;
} }
this.suppressDownload();
if (document.readyState === "loading") { if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", this.loadImporter); document.addEventListener("DOMContentLoaded", this.loadImporter);
return; return;
@@ -52,46 +51,6 @@ class LpFilelessImporter implements LpFilelessImporterInterface {
this.postWindowMessage({ command: "triggerCsvDownload" }); 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. * 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. * 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", "overlay/list": "./src/autofill/overlay/pages/list/bootstrap-autofill-overlay-list.ts",
"encrypt-worker": "../../libs/common/src/platform/services/cryptography/encrypt.worker.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-fileless-importer": "./src/tools/content/lp-fileless-importer.ts",
"content/lp-suppress-import-download": "./src/tools/content/lp-suppress-import-download.ts",
}, },
optimization: { optimization: {
minimize: ENV !== "development", minimize: ENV !== "development",
@@ -276,6 +277,8 @@ if (manifestVersion == 2) {
// Manifest V2 background pages can be run through the regular build pipeline. // Manifest V2 background pages can be run through the regular build pipeline.
// Since it's a standard webpage. // Since it's a standard webpage.
mainConfig.entry.background = "./src/platform/background.ts"; 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); configs.push(mainConfig);
} else { } 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 { MigrationRunner } from "@bitwarden/common/platform/services/migration-runner";
import { NoopMessagingService } from "@bitwarden/common/platform/services/noop-messaging.service"; import { NoopMessagingService } from "@bitwarden/common/platform/services/noop-messaging.service";
import { StateService } from "@bitwarden/common/platform/services/state.service"; import { StateService } from "@bitwarden/common/platform/services/state.service";
import { StorageServiceProvider } from "@bitwarden/common/platform/services/storage-service.provider";
import { import {
ActiveUserStateProvider, ActiveUserStateProvider,
DerivedStateProvider, DerivedStateProvider,
GlobalStateProvider, GlobalStateProvider,
SingleUserStateProvider, SingleUserStateProvider,
StateEventRunnerService,
StateProvider, StateProvider,
} from "@bitwarden/common/platform/state"; } from "@bitwarden/common/platform/state";
/* eslint-disable import/no-restricted-paths -- We need the implementation to inject, but generally these should not be accessed */ /* 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 { 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 { DefaultSingleUserStateProvider } from "@bitwarden/common/platform/state/implementations/default-single-user-state.provider";
import { DefaultStateProvider } from "@bitwarden/common/platform/state/implementations/default-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"; import { MemoryStorageService as MemoryStorageServiceForStateProviders } from "@bitwarden/common/platform/state/storage/memory-storage.service";
/* eslint-enable import/no-restricted-paths */ /* eslint-enable import/no-restricted-paths */
import { AuditService } from "@bitwarden/common/services/audit.service"; import { AuditService } from "@bitwarden/common/services/audit.service";
@@ -208,6 +211,7 @@ export class Main {
derivedStateProvider: DerivedStateProvider; derivedStateProvider: DerivedStateProvider;
stateProvider: StateProvider; stateProvider: StateProvider;
loginStrategyService: LoginStrategyServiceAbstraction; loginStrategyService: LoginStrategyServiceAbstraction;
stateEventRunnerService: StateEventRunnerService;
biometricStateService: BiometricStateService; biometricStateService: BiometricStateService;
constructor() { constructor() {
@@ -249,14 +253,26 @@ export class Main {
this.memoryStorageService = new MemoryStorageService(); this.memoryStorageService = new MemoryStorageService();
this.memoryStorageForStateProviders = new MemoryStorageServiceForStateProviders(); this.memoryStorageForStateProviders = new MemoryStorageServiceForStateProviders();
this.globalStateProvider = new DefaultGlobalStateProvider( const storageServiceProvider = new StorageServiceProvider(
this.memoryStorageForStateProviders,
this.storageService, 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.singleUserStateProvider = new DefaultSingleUserStateProvider(
this.memoryStorageForStateProviders, storageServiceProvider,
this.storageService, stateEventRegistrarService,
); );
this.messagingService = new NoopMessagingService(); this.messagingService = new NoopMessagingService();
@@ -269,8 +285,8 @@ export class Main {
this.activeUserStateProvider = new DefaultActiveUserStateProvider( this.activeUserStateProvider = new DefaultActiveUserStateProvider(
this.accountService, this.accountService,
this.memoryStorageForStateProviders, storageServiceProvider,
this.storageService, stateEventRegistrarService,
); );
this.derivedStateProvider = new DefaultDerivedStateProvider( this.derivedStateProvider = new DefaultDerivedStateProvider(
@@ -372,7 +388,7 @@ export class Main {
this.stateProvider, this.stateProvider,
); );
this.providerService = new ProviderService(this.stateService); this.providerService = new ProviderService(this.stateProvider);
this.organizationService = new OrganizationService(this.stateService, this.stateProvider); this.organizationService = new OrganizationService(this.stateService, this.stateProvider);
@@ -530,6 +546,7 @@ export class Main {
this.stateService, this.stateService,
this.authService, this.authService,
this.vaultTimeoutSettingsService, this.vaultTimeoutSettingsService,
this.stateEventRunnerService,
lockedCallback, lockedCallback,
null, null,
); );
@@ -638,7 +655,11 @@ export class Main {
this.collectionService.clear(userId as UserId), this.collectionService.clear(userId as UserId),
this.policyService.clear(userId), this.policyService.clear(userId),
this.passwordGenerationService.clear(), this.passwordGenerationService.clear(),
this.providerService.save(null, userId as UserId),
]); ]);
await this.stateEventRunnerService.handleEvent("logout", userId as UserId);
await this.stateService.clean(); await this.stateService.clean();
process.env.BW_SESSION = null; 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 { VaultTimeoutSettingsService } from "@bitwarden/common/abstractions/vault-timeout/vault-timeout-settings.service";
import { VaultTimeoutService } from "@bitwarden/common/abstractions/vault-timeout/vault-timeout.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 { 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 { AuthService } from "@bitwarden/common/auth/abstractions/auth.service";
import { KeyConnectorService } from "@bitwarden/common/auth/abstractions/key-connector.service"; import { KeyConnectorService } from "@bitwarden/common/auth/abstractions/key-connector.service";
import { UserVerificationService } from "@bitwarden/common/auth/abstractions/user-verification/user-verification.service.abstraction"; 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 { StateService } from "@bitwarden/common/platform/abstractions/state.service";
import { SystemService } from "@bitwarden/common/platform/abstractions/system.service"; import { SystemService } from "@bitwarden/common/platform/abstractions/system.service";
import { BiometricStateService } from "@bitwarden/common/platform/biometrics/biometric-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 { PasswordGenerationServiceAbstraction } from "@bitwarden/common/tools/generator/password";
import { UserId } from "@bitwarden/common/types/guid"; import { UserId } from "@bitwarden/common/types/guid";
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
@@ -149,6 +151,8 @@ export class AppComponent implements OnInit, OnDestroy {
private configService: ConfigServiceAbstraction, private configService: ConfigServiceAbstraction,
private dialogService: DialogService, private dialogService: DialogService,
private biometricStateService: BiometricStateService, private biometricStateService: BiometricStateService,
private stateEventRunnerService: StateEventRunnerService,
private providerService: ProviderService,
) {} ) {}
ngOnInit() { ngOnInit() {
@@ -219,13 +223,13 @@ export class AppComponent implements OnInit, OnDestroy {
const currentUser = await this.stateService.getUserId(); const currentUser = await this.stateService.getUserId();
const accounts = await firstValueFrom(this.stateService.accounts$); const accounts = await firstValueFrom(this.stateService.accounts$);
await this.vaultTimeoutService.lock(currentUser); 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. for (const account of Object.keys(accounts)) {
// eslint-disable-next-line @typescript-eslint/no-floating-promises if (account === currentUser) {
Promise.all( continue;
Object.keys(accounts) }
.filter((u) => u !== currentUser)
.map((u) => this.vaultTimeoutService.lock(u)), await this.vaultTimeoutService.lock(account);
); }
break; break;
} }
case "locked": case "locked":
@@ -582,6 +586,9 @@ export class AppComponent implements OnInit, OnDestroy {
await this.policyService.clear(userBeingLoggedOut); await this.policyService.clear(userBeingLoggedOut);
await this.keyConnectorService.clear(); await this.keyConnectorService.clear();
await this.biometricStateService.logout(userBeingLoggedOut as UserId); 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; preLogoutActiveUserId = this.activeUserId;
await this.stateService.clean({ userId: userBeingLoggedOut }); 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 { MigrationRunner } from "@bitwarden/common/platform/services/migration-runner";
import { NoopMessagingService } from "@bitwarden/common/platform/services/noop-messaging.service"; 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 */ /* 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 { 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 { DefaultDerivedStateProvider } from "@bitwarden/common/platform/state/implementations/default-derived-state.provider";
import { DefaultGlobalStateProvider } from "@bitwarden/common/platform/state/implementations/default-global-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 { DefaultSingleUserStateProvider } from "@bitwarden/common/platform/state/implementations/default-single-user-state.provider";
import { DefaultStateProvider } from "@bitwarden/common/platform/state/implementations/default-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"; import { MemoryStorageService as MemoryStorageServiceForStateProviders } from "@bitwarden/common/platform/state/storage/memory-storage.service";
/* eslint-enable import/no-restricted-paths */ /* eslint-enable import/no-restricted-paths */
@@ -104,10 +106,11 @@ export class Main {
this.storageService = new ElectronStorageService(app.getPath("userData"), storageDefaults); this.storageService = new ElectronStorageService(app.getPath("userData"), storageDefaults);
this.memoryStorageService = new MemoryStorageService(); this.memoryStorageService = new MemoryStorageService();
this.memoryStorageForStateProviders = new MemoryStorageServiceForStateProviders(); this.memoryStorageForStateProviders = new MemoryStorageServiceForStateProviders();
const globalStateProvider = new DefaultGlobalStateProvider( const storageServiceProvider = new StorageServiceProvider(
this.memoryStorageForStateProviders,
this.storageService, this.storageService,
this.memoryStorageForStateProviders,
); );
const globalStateProvider = new DefaultGlobalStateProvider(storageServiceProvider);
const accountService = new AccountServiceImplementation( const accountService = new AccountServiceImplementation(
new NoopMessagingService(), new NoopMessagingService(),
@@ -115,13 +118,18 @@ export class Main {
globalStateProvider, globalStateProvider,
); );
const stateEventRegistrarService = new StateEventRegistrarService(
globalStateProvider,
storageServiceProvider,
);
const stateProvider = new DefaultStateProvider( const stateProvider = new DefaultStateProvider(
new DefaultActiveUserStateProvider( new DefaultActiveUserStateProvider(
accountService, accountService,
this.memoryStorageForStateProviders, storageServiceProvider,
this.storageService, stateEventRegistrarService,
), ),
new DefaultSingleUserStateProvider(this.memoryStorageForStateProviders, this.storageService), new DefaultSingleUserStateProvider(storageServiceProvider, stateEventRegistrarService),
globalStateProvider, globalStateProvider,
new DefaultDerivedStateProvider(this.memoryStorageForStateProviders), new DefaultDerivedStateProvider(this.memoryStorageForStateProviders),
); );

View File

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

View File

@@ -8,6 +8,7 @@ import {
canAccessBillingTab, canAccessBillingTab,
canAccessGroupsTab, canAccessGroupsTab,
canAccessMembersTab, canAccessMembersTab,
canAccessOrgAdmin,
canAccessReportingTab, canAccessReportingTab,
canAccessSettingsTab, canAccessSettingsTab,
canAccessVaultTab, canAccessVaultTab,
@@ -43,7 +44,7 @@ import { AdminConsoleLogo } from "../../icons/admin-console-logo";
export class OrganizationLayoutComponent implements OnInit, OnDestroy { export class OrganizationLayoutComponent implements OnInit, OnDestroy {
protected readonly logo = AdminConsoleLogo; protected readonly logo = AdminConsoleLogo;
protected orgFilter = (org: Organization) => org.isAdmin; protected orgFilter = (org: Organization) => canAccessOrgAdmin(org);
organization$: Observable<Organization>; organization$: Observable<Organization>;
showPaymentAndHistory$: Observable<boolean>; 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 { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { StateService } from "@bitwarden/common/platform/abstractions/state.service"; import { StateService } from "@bitwarden/common/platform/abstractions/state.service";
import { BiometricStateService } from "@bitwarden/common/platform/biometrics/biometric-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 { PasswordGenerationServiceAbstraction } from "@bitwarden/common/tools/generator/password";
import { UserId } from "@bitwarden/common/types/guid"; import { UserId } from "@bitwarden/common/types/guid";
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
@@ -89,6 +90,7 @@ export class AppComponent implements OnDestroy, OnInit {
private configService: ConfigServiceAbstraction, private configService: ConfigServiceAbstraction,
private dialogService: DialogService, private dialogService: DialogService,
private biometricStateService: BiometricStateService, private biometricStateService: BiometricStateService,
private stateEventRunnerService: StateEventRunnerService,
private paymentMethodWarningService: PaymentMethodWarningService, private paymentMethodWarningService: PaymentMethodWarningService,
private organizationService: OrganizationService, private organizationService: OrganizationService,
) {} ) {}
@@ -284,6 +286,8 @@ export class AppComponent implements OnDestroy, OnInit {
this.paymentMethodWarningService.clear(), this.paymentMethodWarningService.clear(),
]); ]);
await this.stateEventRunnerService.handleEvent("logout", userId as UserId);
this.searchService.clearIndex(); this.searchService.clearIndex();
this.authService.logOut(async () => { this.authService.logOut(async () => {
if (expired) { if (expired) {

View File

@@ -14,7 +14,6 @@ import {
} from "@bitwarden/angular/services/injection-tokens"; } from "@bitwarden/angular/services/injection-tokens";
import { JslibServicesModule } from "@bitwarden/angular/services/jslib-services.module"; import { JslibServicesModule } from "@bitwarden/angular/services/jslib-services.module";
import { ModalService as ModalServiceAbstraction } from "@bitwarden/angular/services/modal.service"; 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 as LoginServiceAbstraction } from "@bitwarden/common/auth/abstractions/login.service";
import { LoginService } from "@bitwarden/common/auth/services/login.service"; import { LoginService } from "@bitwarden/common/auth/services/login.service";
import { FileDownloadService } from "@bitwarden/common/platform/abstractions/file-download/file-download.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 { MemoryStorageService } from "@bitwarden/common/platform/services/memory-storage.service";
import { MigrationBuilderService } from "@bitwarden/common/platform/services/migration-builder.service"; import { MigrationBuilderService } from "@bitwarden/common/platform/services/migration-builder.service";
import { MigrationRunner } from "@bitwarden/common/platform/services/migration-runner"; import { MigrationRunner } from "@bitwarden/common/platform/services/migration-runner";
import { /* eslint-disable import/no-restricted-paths -- Implementation for memory storage */
ActiveUserStateProvider, import { StorageServiceProvider } from "@bitwarden/common/platform/services/storage-service.provider";
GlobalStateProvider,
SingleUserStateProvider,
} from "@bitwarden/common/platform/state";
// eslint-disable-next-line import/no-restricted-paths -- Implementation for memory storage
import { MemoryStorageService as MemoryStorageServiceForStateProviders } from "@bitwarden/common/platform/state/storage/memory-storage.service"; 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 { PolicyListService } from "../admin-console/core/policy-list.service";
import { HtmlStorageService } from "../core/html-storage.service"; import { HtmlStorageService } from "../core/html-storage.service";
import { I18nService } from "../core/i18n.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 { 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 { WindowStorageService } from "../platform/window-storage.service";
import { CollectionAdminService } from "../vault/core/collection-admin.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), useFactory: () => new WindowStorageService(window.localStorage),
}, },
{ {
provide: SingleUserStateProvider, provide: StorageServiceProvider,
useClass: WebSingleUserStateProvider, useClass: WebStorageServiceProvider,
deps: [OBSERVABLE_MEMORY_STORAGE, OBSERVABLE_DISK_STORAGE, OBSERVABLE_DISK_LOCAL_STORAGE], deps: [OBSERVABLE_DISK_STORAGE, OBSERVABLE_MEMORY_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: MigrationRunner, 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. // If the active route org doesn't have access to AC, find the first org that does.
const acOrg = const acOrg =
routeOrg != null && canAccessOrgAdmin(routeOrg) && routeOrg.enabled routeOrg != null && canAccessOrgAdmin(routeOrg)
? 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. // TODO: This should be migrated to an Observable provided by the provider service and moved to the combineLatest above. See AC-2092.
const providers = await this.providerService.getAll(); const 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 { MigrationRunner } from "@bitwarden/common/platform/services/migration-runner";
import { NoopNotificationsService } from "@bitwarden/common/platform/services/noop-notifications.service"; import { NoopNotificationsService } from "@bitwarden/common/platform/services/noop-notifications.service";
import { StateService } from "@bitwarden/common/platform/services/state.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 { ValidationService } from "@bitwarden/common/platform/services/validation.service";
import { WebCryptoFunctionService } from "@bitwarden/common/platform/services/web-crypto-function.service"; import { WebCryptoFunctionService } from "@bitwarden/common/platform/services/web-crypto-function.service";
import { 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 { 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 { DefaultSingleUserStateProvider } from "@bitwarden/common/platform/state/implementations/default-single-user-state.provider";
import { DefaultStateProvider } from "@bitwarden/common/platform/state/implementations/default-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 */ /* eslint-enable import/no-restricted-paths */
import { AvatarUpdateService } from "@bitwarden/common/services/account/avatar-update.service"; import { AvatarUpdateService } from "@bitwarden/common/services/account/avatar-update.service";
import { ApiService } from "@bitwarden/common/services/api.service"; import { ApiService } from "@bitwarden/common/services/api.service";
@@ -551,6 +554,7 @@ import { ModalService } from "./modal.service";
StateServiceAbstraction, StateServiceAbstraction,
AuthServiceAbstraction, AuthServiceAbstraction,
VaultTimeoutSettingsServiceAbstraction, VaultTimeoutSettingsServiceAbstraction,
StateEventRunnerService,
LOCKED_CALLBACK, LOCKED_CALLBACK,
LOGOUT_CALLBACK, LOGOUT_CALLBACK,
], ],
@@ -741,7 +745,7 @@ import { ModalService } from "./modal.service";
{ {
provide: ProviderServiceAbstraction, provide: ProviderServiceAbstraction,
useClass: ProviderService, useClass: ProviderService,
deps: [StateServiceAbstraction], deps: [StateProvider],
}, },
{ {
provide: TwoFactorServiceAbstraction, provide: TwoFactorServiceAbstraction,
@@ -890,20 +894,35 @@ import { ModalService } from "./modal.service";
LogService, 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, provide: GlobalStateProvider,
useClass: DefaultGlobalStateProvider, useClass: DefaultGlobalStateProvider,
deps: [OBSERVABLE_MEMORY_STORAGE, OBSERVABLE_DISK_STORAGE], deps: [StorageServiceProvider],
}, },
{ {
provide: ActiveUserStateProvider, provide: ActiveUserStateProvider,
useClass: DefaultActiveUserStateProvider, useClass: DefaultActiveUserStateProvider,
deps: [AccountServiceAbstraction, OBSERVABLE_MEMORY_STORAGE, OBSERVABLE_DISK_STORAGE], deps: [AccountServiceAbstraction, StorageServiceProvider, StateEventRegistrarService],
}, },
{ {
provide: SingleUserStateProvider, provide: SingleUserStateProvider,
useClass: DefaultSingleUserStateProvider, useClass: DefaultSingleUserStateProvider,
deps: [OBSERVABLE_MEMORY_STORAGE, OBSERVABLE_DISK_STORAGE], deps: [StorageServiceProvider, StateEventRegistrarService],
}, },
{ {
provide: DerivedStateProvider, provide: DerivedStateProvider,

View File

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

View File

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

View File

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

View File

@@ -1,8 +1,9 @@
import { UserId } from "../../types/guid";
import { ProviderData } from "../models/data/provider.data"; import { ProviderData } from "../models/data/provider.data";
import { Provider } from "../models/domain/provider"; import { Provider } from "../models/domain/provider";
export abstract class ProviderService { export abstract class ProviderService {
get: (id: string) => Promise<Provider>; get: (id: string) => Promise<Provider>;
getAll: () => 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 { ProviderUserStatusType, ProviderUserType } from "../enums";
import { ProviderData } from "../models/data/provider.data"; 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", () => { describe("PROVIDERS key definition", () => {
const sut = PROVIDERS; const sut = PROVIDERS;
@@ -21,3 +70,75 @@ describe("PROVIDERS key definition", () => {
expect(result).toEqual(expectedResult); 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 { Observable, map, firstValueFrom } from "rxjs";
import { KeyDefinition, PROVIDERS_DISK } from "../../platform/state";
import { KeyDefinition, PROVIDERS_DISK, StateProvider } from "../../platform/state";
import { UserId } from "../../types/guid";
import { ProviderService as ProviderServiceAbstraction } from "../abstractions/provider.service"; import { ProviderService as ProviderServiceAbstraction } from "../abstractions/provider.service";
import { ProviderData } from "../models/data/provider.data"; import { ProviderData } from "../models/data/provider.data";
import { Provider } from "../models/domain/provider"; import { Provider } from "../models/domain/provider";
@@ -8,32 +10,34 @@ export const PROVIDERS = KeyDefinition.record<ProviderData>(PROVIDERS_DISK, "pro
deserializer: (obj: ProviderData) => obj, deserializer: (obj: ProviderData) => obj,
}); });
export class ProviderService implements ProviderServiceAbstraction { function mapToSingleProvider(providerId: string) {
constructor(private stateService: StateService) {} return map<Provider[], Provider>((providers) => providers?.find((p) => p.id === providerId));
}
async get(id: string): Promise<Provider> { export class ProviderService implements ProviderServiceAbstraction {
const providers = await this.stateService.getProviders(); constructor(private stateProvider: StateProvider) {}
// eslint-disable-next-line
if (providers == null || !providers.hasOwnProperty(id)) { private providers$(userId?: UserId): Observable<Provider[] | undefined> {
return null; return this.stateProvider
.getUserState$(PROVIDERS, userId)
.pipe(this.mapProviderRecordToArray());
} }
return new Provider(providers[id]); private mapProviderRecordToArray() {
return map<Record<string, ProviderData>, Provider[]>((providers) =>
Object.values(providers ?? {})?.map((o) => new Provider(o)),
);
}
async get(id: string): Promise<Provider> {
return await firstValueFrom(this.providers$().pipe(mapToSingleProvider(id)));
} }
async getAll(): Promise<Provider[]> { async getAll(): Promise<Provider[]> {
const providers = await this.stateService.getProviders(); return await firstValueFrom(this.providers$());
const response: Provider[] = [];
for (const id in providers) {
// eslint-disable-next-line
if (providers.hasOwnProperty(id)) {
response.push(new Provider(providers[id]));
}
}
return response;
} }
async save(providers: { [id: string]: ProviderData }) { async save(providers: { [id: string]: ProviderData }, userId?: UserId) {
await this.stateService.setProviders(providers); 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 { OrganizationData } from "../../admin-console/models/data/organization.data";
import { PolicyData } from "../../admin-console/models/data/policy.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 { Policy } from "../../admin-console/models/domain/policy";
import { AdminAuthRequestStorable } from "../../auth/models/domain/admin-auth-req-storable"; import { AdminAuthRequestStorable } from "../../auth/models/domain/admin-auth-req-storable";
import { ForceSetPasswordReason } from "../../auth/models/domain/force-set-password-reason"; 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>; setDecryptedSends: (value: SendView[], options?: StorageOptions) => Promise<void>;
getDefaultUriMatch: (options?: StorageOptions) => Promise<UriMatchType>; getDefaultUriMatch: (options?: StorageOptions) => Promise<UriMatchType>;
setDefaultUriMatch: (value: UriMatchType, options?: StorageOptions) => Promise<void>; 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>; getDisableContextMenuItem: (options?: StorageOptions) => Promise<boolean>;
setDisableContextMenuItem: (value: boolean, options?: StorageOptions) => Promise<void>; 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 * Sets the user's Pin, encrypted by the user key
*/ */
setProtectedPin: (value: string, options?: StorageOptions) => Promise<void>; 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>; getRefreshToken: (options?: StorageOptions) => Promise<string>;
setRefreshToken: (value: string, options?: StorageOptions) => Promise<void>; setRefreshToken: (value: string, options?: StorageOptions) => Promise<void>;
getRememberedEmail: (options?: StorageOptions) => Promise<string>; 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 { OrganizationData } from "../../../admin-console/models/data/organization.data";
import { PolicyData } from "../../../admin-console/models/data/policy.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 { Policy } from "../../../admin-console/models/domain/policy";
import { AdminAuthRequestStorable } from "../../../auth/models/domain/admin-auth-req-storable"; import { AdminAuthRequestStorable } from "../../../auth/models/domain/admin-auth-req-storable";
import { ForceSetPasswordReason } from "../../../auth/models/domain/force-set-password-reason"; import { ForceSetPasswordReason } from "../../../auth/models/domain/force-set-password-reason";
@@ -96,7 +95,6 @@ export class AccountData {
addEditCipherInfo?: AddEditCipherInfo; addEditCipherInfo?: AddEditCipherInfo;
eventCollection?: EventData[]; eventCollection?: EventData[];
organizations?: { [id: string]: OrganizationData }; organizations?: { [id: string]: OrganizationData };
providers?: { [id: string]: ProviderData };
static fromJSON(obj: DeepJsonify<AccountData>): AccountData { static fromJSON(obj: DeepJsonify<AccountData>): AccountData {
if (obj == null) { if (obj == null) {

View File

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

View File

@@ -1,3 +1,4 @@
import { mock } from "jest-mock-extended";
import { firstValueFrom, timeout } from "rxjs"; import { firstValueFrom, timeout } from "rxjs";
import { awaitAsync } from "../../../spec"; 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 { DefaultGlobalStateProvider } from "../state/implementations/default-global-state.provider";
import { DefaultSingleUserStateProvider } from "../state/implementations/default-single-user-state.provider"; import { DefaultSingleUserStateProvider } from "../state/implementations/default-single-user-state.provider";
import { DefaultStateProvider } from "../state/implementations/default-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 { EnvironmentService } from "./environment.service";
import { StorageServiceProvider } from "./storage-service.provider";
// There are a few main states EnvironmentService could be in when first used // 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 // 1. Not initialized, no active user. Hopefully not to likely but possible
@@ -26,6 +29,8 @@ import { EnvironmentService } from "./environment.service";
describe("EnvironmentService", () => { describe("EnvironmentService", () => {
let diskStorageService: FakeStorageService; let diskStorageService: FakeStorageService;
let memoryStorageService: FakeStorageService; let memoryStorageService: FakeStorageService;
let storageServiceProvider: StorageServiceProvider;
const stateEventRegistrarService = mock<StateEventRegistrarService>();
let accountService: FakeAccountService; let accountService: FakeAccountService;
let stateProvider: StateProvider; let stateProvider: StateProvider;
@@ -37,16 +42,17 @@ describe("EnvironmentService", () => {
beforeEach(async () => { beforeEach(async () => {
diskStorageService = new FakeStorageService(); diskStorageService = new FakeStorageService();
memoryStorageService = new FakeStorageService(); memoryStorageService = new FakeStorageService();
storageServiceProvider = new StorageServiceProvider(diskStorageService, memoryStorageService);
accountService = mockAccountServiceWith(undefined); accountService = mockAccountServiceWith(undefined);
stateProvider = new DefaultStateProvider( stateProvider = new DefaultStateProvider(
new DefaultActiveUserStateProvider( new DefaultActiveUserStateProvider(
accountService, accountService,
memoryStorageService as any, storageServiceProvider,
diskStorageService, stateEventRegistrarService,
), ),
new DefaultSingleUserStateProvider(memoryStorageService as any, diskStorageService), new DefaultSingleUserStateProvider(storageServiceProvider, stateEventRegistrarService),
new DefaultGlobalStateProvider(memoryStorageService as any, diskStorageService), new DefaultGlobalStateProvider(storageServiceProvider),
new DefaultDerivedStateProvider(memoryStorageService), 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 { OrganizationData } from "../../admin-console/models/data/organization.data";
import { PolicyData } from "../../admin-console/models/data/policy.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 { Policy } from "../../admin-console/models/domain/policy";
import { AccountService } from "../../auth/abstractions/account.service"; import { AccountService } from "../../auth/abstractions/account.service";
import { AuthenticationStatus } from "../../auth/enums/authentication-status"; 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> { async getDisableContextMenuItem(options?: StorageOptions): Promise<boolean> {
return ( return (
(await this.getGlobals(this.reconcileOptions(options, await this.defaultOnDiskOptions()))) (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> { async getRefreshToken(options?: StorageOptions): Promise<string> {
options = await this.getTimeoutBasedStorageOptions(options); options = await this.getTimeoutBasedStorageOptions(options);
return (await this.getAccount(options))?.tokens?.refreshToken; 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 { mockAccountServiceWith, trackEmissions } from "../../../../spec";
import { AuthenticationStatus } from "../../../auth/enums/authentication-status"; import { AuthenticationStatus } from "../../../auth/enums/authentication-status";
import { UserId } from "../../../types/guid"; import { UserId } from "../../../types/guid";
import { import { StorageServiceProvider } from "../../services/storage-service.provider";
AbstractMemoryStorageService, import { StateEventRegistrarService } from "../state-event-registrar.service";
AbstractStorageService,
ObservableStorageService,
} from "../../abstractions/storage.service";
import { DefaultActiveUserStateProvider } from "./default-active-user-state.provider"; import { DefaultActiveUserStateProvider } from "./default-active-user-state.provider";
describe("DefaultActiveUserStateProvider", () => { describe("DefaultActiveUserStateProvider", () => {
const memoryStorage = mock<AbstractMemoryStorageService & ObservableStorageService>(); const storageServiceProvider = mock<StorageServiceProvider>();
const diskStorage = mock<AbstractStorageService & ObservableStorageService>(); const stateEventRegistrarService = mock<StateEventRegistrarService>();
const userId = "userId" as UserId; const userId = "userId" as UserId;
const accountInfo = { const accountInfo = {
id: userId, id: userId,
@@ -25,7 +22,11 @@ describe("DefaultActiveUserStateProvider", () => {
let sut: DefaultActiveUserStateProvider; let sut: DefaultActiveUserStateProvider;
beforeEach(() => { beforeEach(() => {
sut = new DefaultActiveUserStateProvider(accountService, memoryStorage, diskStorage); sut = new DefaultActiveUserStateProvider(
accountService,
storageServiceProvider,
stateEventRegistrarService,
);
}); });
afterEach(() => { afterEach(() => {

View File

@@ -2,13 +2,9 @@ import { Observable, map } from "rxjs";
import { AccountService } from "../../../auth/abstractions/account.service"; import { AccountService } from "../../../auth/abstractions/account.service";
import { UserId } from "../../../types/guid"; import { UserId } from "../../../types/guid";
import { import { StorageServiceProvider } from "../../services/storage-service.provider";
AbstractMemoryStorageService,
AbstractStorageService,
ObservableStorageService,
} from "../../abstractions/storage.service";
import { KeyDefinition } from "../key-definition"; 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 { UserKeyDefinition, isUserKeyDefinition } from "../user-key-definition";
import { ActiveUserState } from "../user-state"; import { ActiveUserState } from "../user-state";
import { ActiveUserStateProvider } from "../user-state.provider"; import { ActiveUserStateProvider } from "../user-state.provider";
@@ -21,9 +17,9 @@ export class DefaultActiveUserStateProvider implements ActiveUserStateProvider {
activeUserId$: Observable<UserId | undefined>; activeUserId$: Observable<UserId | undefined>;
constructor( constructor(
protected readonly accountService: AccountService, private readonly accountService: AccountService,
protected readonly memoryStorage: AbstractMemoryStorageService & ObservableStorageService, private readonly storageServiceProvider: StorageServiceProvider,
protected readonly diskStorage: AbstractStorageService & ObservableStorageService, private readonly stateEventRegistrarService: StateEventRegistrarService,
) { ) {
this.activeUserId$ = this.accountService.activeAccount$.pipe(map((account) => account?.id)); this.activeUserId$ = this.accountService.activeAccount$.pipe(map((account) => account?.id));
} }
@@ -32,7 +28,11 @@ export class DefaultActiveUserStateProvider implements ActiveUserStateProvider {
if (!isUserKeyDefinition(keyDefinition)) { if (!isUserKeyDefinition(keyDefinition)) {
keyDefinition = UserKeyDefinition.fromBaseKeyDefinition(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]; const existingUserState = this.cache[cacheKey];
if (existingUserState != null) { if (existingUserState != null) {
// I have to cast out of the unknown generic but this should be safe if rules // 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>; 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; this.cache[cacheKey] = newUserState;
return newUserState; return newUserState;
} }
private buildCacheKey(keyDefinition: UserKeyDefinition<unknown>) { private buildCacheKey(location: string, keyDefinition: UserKeyDefinition<unknown>) {
return `${this.getLocationString(keyDefinition)}_${keyDefinition.fullName}`; return `${location}_${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;
}
} }
} }

View File

@@ -12,6 +12,7 @@ import { AccountInfo, AccountService } from "../../../auth/abstractions/account.
import { AuthenticationStatus } from "../../../auth/enums/authentication-status"; import { AuthenticationStatus } from "../../../auth/enums/authentication-status";
import { UserId } from "../../../types/guid"; import { UserId } from "../../../types/guid";
import { StateDefinition } from "../state-definition"; import { StateDefinition } from "../state-definition";
import { StateEventRegistrarService } from "../state-event-registrar.service";
import { UserKeyDefinition } from "../user-key-definition"; import { UserKeyDefinition } from "../user-key-definition";
import { DefaultActiveUserState } from "./default-active-user-state"; import { DefaultActiveUserState } from "./default-active-user-state";
@@ -42,6 +43,7 @@ const testKeyDefinition = new UserKeyDefinition<TestState>(testStateDefinition,
describe("DefaultActiveUserState", () => { describe("DefaultActiveUserState", () => {
const accountService = mock<AccountService>(); const accountService = mock<AccountService>();
let diskStorageService: FakeStorageService; let diskStorageService: FakeStorageService;
const stateEventRegistrarService = mock<StateEventRegistrarService>();
let activeAccountSubject: BehaviorSubject<{ id: UserId } & AccountInfo>; let activeAccountSubject: BehaviorSubject<{ id: UserId } & AccountInfo>;
let userState: DefaultActiveUserState<TestState>; let userState: DefaultActiveUserState<TestState>;
@@ -50,7 +52,12 @@ describe("DefaultActiveUserState", () => {
accountService.activeAccount$ = activeAccountSubject; accountService.activeAccount$ = activeAccountSubject;
diskStorageService = new FakeStorageService(); diskStorageService = new FakeStorageService();
userState = new DefaultActiveUserState(testKeyDefinition, accountService, diskStorageService); userState = new DefaultActiveUserState(
testKeyDefinition,
accountService,
diskStorageService,
stateEventRegistrarService,
);
}); });
const makeUserId = (id: string) => { const makeUserId = (id: string) => {
@@ -391,6 +398,48 @@ describe("DefaultActiveUserState", () => {
"No active user at this time.", "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", () => { describe("update races", () => {

View File

@@ -21,6 +21,7 @@ import {
AbstractStorageService, AbstractStorageService,
ObservableStorageService, ObservableStorageService,
} from "../../abstractions/storage.service"; } from "../../abstractions/storage.service";
import { StateEventRegistrarService } from "../state-event-registrar.service";
import { StateUpdateOptions, populateOptionsWithDefault } from "../state-update-options"; import { StateUpdateOptions, populateOptionsWithDefault } from "../state-update-options";
import { UserKeyDefinition } from "../user-key-definition"; import { UserKeyDefinition } from "../user-key-definition";
import { ActiveUserState, CombinedState, activeMarker } from "../user-state"; import { ActiveUserState, CombinedState, activeMarker } from "../user-state";
@@ -42,6 +43,7 @@ export class DefaultActiveUserState<T> implements ActiveUserState<T> {
protected keyDefinition: UserKeyDefinition<T>, protected keyDefinition: UserKeyDefinition<T>,
private accountService: AccountService, private accountService: AccountService,
private chosenStorageLocation: AbstractStorageService & ObservableStorageService, private chosenStorageLocation: AbstractStorageService & ObservableStorageService,
private stateEventRegistrarService: StateEventRegistrarService,
) { ) {
this.activeUserId$ = this.accountService.activeAccount$.pipe( this.activeUserId$ = this.accountService.activeAccount$.pipe(
// We only care about the UserId but we do want to know about no user as well. // 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); const newState = configureState(currentState, combinedDependencies);
await this.saveToStorage(key, newState); 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]; return [userId, newState];
} }

View File

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

View File

@@ -1,11 +1,7 @@
import { UserId } from "../../../types/guid"; import { UserId } from "../../../types/guid";
import { import { StorageServiceProvider } from "../../services/storage-service.provider";
AbstractMemoryStorageService,
AbstractStorageService,
ObservableStorageService,
} from "../../abstractions/storage.service";
import { KeyDefinition } from "../key-definition"; 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 { UserKeyDefinition, isUserKeyDefinition } from "../user-key-definition";
import { SingleUserState } from "../user-state"; import { SingleUserState } from "../user-state";
import { SingleUserStateProvider } from "../user-state.provider"; import { SingleUserStateProvider } from "../user-state.provider";
@@ -16,8 +12,8 @@ export class DefaultSingleUserStateProvider implements SingleUserStateProvider {
private cache: Record<string, SingleUserState<unknown>> = {}; private cache: Record<string, SingleUserState<unknown>> = {};
constructor( constructor(
protected readonly memoryStorage: AbstractMemoryStorageService & ObservableStorageService, private readonly storageServiceProvider: StorageServiceProvider,
protected readonly diskStorage: AbstractStorageService & ObservableStorageService, private readonly stateEventRegistrarService: StateEventRegistrarService,
) {} ) {}
get<T>( get<T>(
@@ -27,7 +23,11 @@ export class DefaultSingleUserStateProvider implements SingleUserStateProvider {
if (!isUserKeyDefinition(keyDefinition)) { if (!isUserKeyDefinition(keyDefinition)) {
keyDefinition = UserKeyDefinition.fromBaseKeyDefinition(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]; const existingUserState = this.cache[cacheKey];
if (existingUserState != null) { if (existingUserState != null) {
// I have to cast out of the unknown generic but this should be safe if rules // 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>; 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; this.cache[cacheKey] = newUserState;
return newUserState; return newUserState;
} }
private buildCacheKey(userId: UserId, keyDefinition: UserKeyDefinition<unknown>) { private buildCacheKey(
return `${this.getLocationString(keyDefinition)}_${keyDefinition.fullName}_${userId}`; location: string,
}
protected buildSingleUserState<T>(
userId: UserId, userId: UserId,
keyDefinition: UserKeyDefinition<T>, keyDefinition: UserKeyDefinition<unknown>,
): SingleUserState<T> { ) {
return new DefaultSingleUserState<T>( return `${location}_${keyDefinition.fullName}_${userId}`;
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;
}
} }
} }

View File

@@ -3,6 +3,7 @@
* @jest-environment ../shared/test.environment.ts * @jest-environment ../shared/test.environment.ts
*/ */
import { mock } from "jest-mock-extended";
import { firstValueFrom, of } from "rxjs"; import { firstValueFrom, of } from "rxjs";
import { Jsonify } from "type-fest"; import { Jsonify } from "type-fest";
@@ -11,6 +12,7 @@ import { FakeStorageService } from "../../../../spec/fake-storage.service";
import { UserId } from "../../../types/guid"; import { UserId } from "../../../types/guid";
import { Utils } from "../../misc/utils"; import { Utils } from "../../misc/utils";
import { StateDefinition } from "../state-definition"; import { StateDefinition } from "../state-definition";
import { StateEventRegistrarService } from "../state-event-registrar.service";
import { UserKeyDefinition } from "../user-key-definition"; import { UserKeyDefinition } from "../user-key-definition";
import { DefaultSingleUserState } from "./default-single-user-state"; import { DefaultSingleUserState } from "./default-single-user-state";
@@ -42,11 +44,17 @@ const userKey = testKeyDefinition.buildKey(userId);
describe("DefaultSingleUserState", () => { describe("DefaultSingleUserState", () => {
let diskStorageService: FakeStorageService; let diskStorageService: FakeStorageService;
let userState: DefaultSingleUserState<TestState>; let userState: DefaultSingleUserState<TestState>;
const stateEventRegistrarService = mock<StateEventRegistrarService>();
const newData = { date: new Date() }; const newData = { date: new Date() };
beforeEach(() => { beforeEach(() => {
diskStorageService = new FakeStorageService(); diskStorageService = new FakeStorageService();
userState = new DefaultSingleUserState(userId, testKeyDefinition, diskStorageService); userState = new DefaultSingleUserState(
userId,
testKeyDefinition,
diskStorageService,
stateEventRegistrarService,
);
}); });
afterEach(() => { afterEach(() => {
@@ -255,6 +263,49 @@ describe("DefaultSingleUserState", () => {
expect(emissions).toHaveLength(2); expect(emissions).toHaveLength(2);
expect(emissions).toEqual(expect.arrayContaining([initialState, newState])); 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", () => { describe("update races", () => {

View File

@@ -18,6 +18,7 @@ import {
AbstractStorageService, AbstractStorageService,
ObservableStorageService, ObservableStorageService,
} from "../../abstractions/storage.service"; } from "../../abstractions/storage.service";
import { StateEventRegistrarService } from "../state-event-registrar.service";
import { StateUpdateOptions, populateOptionsWithDefault } from "../state-update-options"; import { StateUpdateOptions, populateOptionsWithDefault } from "../state-update-options";
import { UserKeyDefinition } from "../user-key-definition"; import { UserKeyDefinition } from "../user-key-definition";
import { CombinedState, SingleUserState } from "../user-state"; import { CombinedState, SingleUserState } from "../user-state";
@@ -35,6 +36,7 @@ export class DefaultSingleUserState<T> implements SingleUserState<T> {
readonly userId: UserId, readonly userId: UserId,
private keyDefinition: UserKeyDefinition<T>, private keyDefinition: UserKeyDefinition<T>,
private chosenLocation: AbstractStorageService & ObservableStorageService, private chosenLocation: AbstractStorageService & ObservableStorageService,
private stateEventRegistrarService: StateEventRegistrarService,
) { ) {
this.storageKey = this.keyDefinition.buildKey(this.userId); this.storageKey = this.keyDefinition.buildKey(this.userId);
const initialStorageGet$ = defer(() => { const initialStorageGet$ = defer(() => {
@@ -100,6 +102,11 @@ export class DefaultSingleUserState<T> implements SingleUserState<T> {
const newState = configureState(currentState, combinedDependencies); const newState = configureState(currentState, combinedDependencies);
await this.chosenLocation.save(this.storageKey, newState); 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; return newState;
} }

View File

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

View File

@@ -4,7 +4,7 @@ export { DerivedState } from "./derived-state";
export { GlobalState } from "./global-state"; export { GlobalState } from "./global-state";
export { StateProvider } from "./state.provider"; export { StateProvider } from "./state.provider";
export { GlobalStateProvider } from "./global-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 { ActiveUserStateProvider, SingleUserStateProvider } from "./user-state.provider";
export { KeyDefinition } from "./key-definition"; export { KeyDefinition } from "./key-definition";
export { StateUpdateOptions } from "./state-update-options"; export { StateUpdateOptions } from "./state-update-options";

View File

@@ -63,6 +63,10 @@ export const VAULT_FILTER_DISK = new StateDefinition("vaultFilter", "disk", {
web: "disk-local", web: "disk-local",
}); });
export const USER_NOTIFICATION_SETTINGS_DISK = new StateDefinition(
"userNotificationSettings",
"disk",
);
export const CLEAR_EVENT_DISK = new StateDefinition("clearEvent", "disk"); export const CLEAR_EVENT_DISK = new StateDefinition("clearEvent", "disk");
export const NEW_WEB_LAYOUT_BANNER_DISK = new StateDefinition("newWebLayoutBanner", "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 { PlatformUtilsService } from "../../platform/abstractions/platform-utils.service";
import { StateService } from "../../platform/abstractions/state.service"; import { StateService } from "../../platform/abstractions/state.service";
import { Account } from "../../platform/models/domain/account"; import { Account } from "../../platform/models/domain/account";
import { StateEventRunnerService } from "../../platform/state";
import { CipherService } from "../../vault/abstractions/cipher.service"; import { CipherService } from "../../vault/abstractions/cipher.service";
import { CollectionService } from "../../vault/abstractions/collection.service"; import { CollectionService } from "../../vault/abstractions/collection.service";
import { FolderService } from "../../vault/abstractions/folder/folder.service.abstraction"; import { FolderService } from "../../vault/abstractions/folder/folder.service.abstraction";
@@ -28,6 +29,7 @@ describe("VaultTimeoutService", () => {
let stateService: MockProxy<StateService>; let stateService: MockProxy<StateService>;
let authService: MockProxy<AuthService>; let authService: MockProxy<AuthService>;
let vaultTimeoutSettingsService: MockProxy<VaultTimeoutSettingsService>; let vaultTimeoutSettingsService: MockProxy<VaultTimeoutSettingsService>;
let stateEventRunnerService: MockProxy<StateEventRunnerService>;
let lockedCallback: jest.Mock<Promise<void>, [userId: string]>; let lockedCallback: jest.Mock<Promise<void>, [userId: string]>;
let loggedOutCallback: jest.Mock<Promise<void>, [expired: boolean, userId?: string]>; let loggedOutCallback: jest.Mock<Promise<void>, [expired: boolean, userId?: string]>;
@@ -48,6 +50,7 @@ describe("VaultTimeoutService", () => {
stateService = mock(); stateService = mock();
authService = mock(); authService = mock();
vaultTimeoutSettingsService = mock(); vaultTimeoutSettingsService = mock();
stateEventRunnerService = mock();
lockedCallback = jest.fn(); lockedCallback = jest.fn();
loggedOutCallback = jest.fn(); loggedOutCallback = jest.fn();
@@ -73,6 +76,7 @@ describe("VaultTimeoutService", () => {
stateService, stateService,
authService, authService,
vaultTimeoutSettingsService, vaultTimeoutSettingsService,
stateEventRunnerService,
lockedCallback, lockedCallback,
loggedOutCallback, loggedOutCallback,
); );
@@ -103,7 +107,8 @@ describe("VaultTimeoutService", () => {
return Promise.resolve(accounts[userId]?.authStatus); return Promise.resolve(accounts[userId]?.authStatus);
}); });
stateService.getIsAuthenticated.mockImplementation((options) => { 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) => { vaultTimeoutSettingsService.getVaultTimeout.mockImplementation((userId) => {
@@ -337,4 +342,80 @@ describe("VaultTimeoutService", () => {
expectNoAction("1"); 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 { MessagingService } from "../../platform/abstractions/messaging.service";
import { PlatformUtilsService } from "../../platform/abstractions/platform-utils.service"; import { PlatformUtilsService } from "../../platform/abstractions/platform-utils.service";
import { StateService } from "../../platform/abstractions/state.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 { CipherService } from "../../vault/abstractions/cipher.service";
import { CollectionService } from "../../vault/abstractions/collection.service"; import { CollectionService } from "../../vault/abstractions/collection.service";
import { FolderService } from "../../vault/abstractions/folder/folder.service.abstraction"; import { FolderService } from "../../vault/abstractions/folder/folder.service.abstraction";
@@ -29,6 +31,7 @@ export class VaultTimeoutService implements VaultTimeoutServiceAbstraction {
private stateService: StateService, private stateService: StateService,
private authService: AuthService, private authService: AuthService,
private vaultTimeoutSettingsService: VaultTimeoutSettingsService, private vaultTimeoutSettingsService: VaultTimeoutSettingsService,
private stateEventRunnerService: StateEventRunnerService,
private lockedCallback: (userId?: string) => Promise<void> = null, private lockedCallback: (userId?: string) => Promise<void> = null,
private loggedOutCallback: (expired: boolean, 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); 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(); this.searchService.clearIndex();
await this.folderService.clearCache(); await this.folderService.clearCache();
await this.collectionService.clearActiveUserCache(); await this.collectionService.clearActiveUserCache();
@@ -98,6 +103,11 @@ export class VaultTimeoutService implements VaultTimeoutServiceAbstraction {
await this.cipherService.clearCache(userId); 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 }); this.messagingService.send("locked", { userId: userId });
if (this.lockedCallback != null) { 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 { RevertLastSyncMigrator } from "./migrations/26-revert-move-last-sync-to-state-provider";
import { BadgeSettingsMigrator } from "./migrations/27-move-badge-settings-to-state-providers"; import { BadgeSettingsMigrator } from "./migrations/27-move-badge-settings-to-state-providers";
import { MoveBiometricUnlockToStateProviders } from "./migrations/28-move-biometric-unlock-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 { FixPremiumMigrator } from "./migrations/3-fix-premium";
import { RemoveEverBeenUnlockedMigrator } from "./migrations/4-remove-ever-been-unlocked"; import { RemoveEverBeenUnlockedMigrator } from "./migrations/4-remove-ever-been-unlocked";
import { AddKeyTypeToOrgKeysMigrator } from "./migrations/5-add-key-type-to-org-keys"; 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"; import { MinVersionMigrator } from "./migrations/min-version";
export const MIN_VERSION = 2; export const MIN_VERSION = 2;
export const CURRENT_VERSION = 28; export const CURRENT_VERSION = 29;
export type MinVersion = typeof MIN_VERSION; export type MinVersion = typeof MIN_VERSION;
export function createMigrationBuilder() { export function createMigrationBuilder() {
@@ -64,7 +65,8 @@ export function createMigrationBuilder() {
.with(ClearClipboardDelayMigrator, 24, 25) .with(ClearClipboardDelayMigrator, 24, 25)
.with(RevertLastSyncMigrator, 25, 26) .with(RevertLastSyncMigrator, 25, 26)
.with(BadgeSettingsMigrator, 26, 27) .with(BadgeSettingsMigrator, 26, 27)
.with(MoveBiometricUnlockToStateProviders, 27, CURRENT_VERSION); .with(MoveBiometricUnlockToStateProviders, 27, 28)
.with(UserNotificationSettingsKeyMigrator, 28, CURRENT_VERSION);
} }
export async function currentVersion( 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 // FIXME: use index.ts imports once policy abstractions and models
// implement ADR-0002 // implement ADR-0002
import { Policy as AdminPolicy } from "../../../admin-console/models/domain/policy"; 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"; import { PolicyEvaluator } from "./policy-evaluator.abstraction";
/** Tailors the generator service to generate a specific kind of credentials */ /** Tailors the generator service to generate a specific kind of credentials */
export abstract class GeneratorStrategy<Options, Policy> { export abstract class GeneratorStrategy<Options, Policy> {
/** The key used when storing credentials on disk. */ /** Retrieve application state that persists across locks.
disk: KeyDefinition<Options>; * @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. */ /** Identifies the policy enforced by the generator. */
policy: PolicyType; policy: PolicyType;
@@ -19,7 +23,8 @@ export abstract class GeneratorStrategy<Options, Policy> {
/** Creates an evaluator from a generator policy. /** Creates an evaluator from a generator policy.
* @param policy The policy being evaluated. * @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. * @throws when the policy's type does not match the generator's policy type.
*/ */
evaluator: (policy: AdminPolicy) => PolicyEvaluator<Policy, Options>; evaluator: (policy: AdminPolicy) => PolicyEvaluator<Policy, Options>;

View File

@@ -1,5 +1,7 @@
import { Observable } from "rxjs"; import { Observable } from "rxjs";
import { UserId } from "../../../types/guid";
import { PolicyEvaluator } from "./policy-evaluator.abstraction"; import { PolicyEvaluator } from "./policy-evaluator.abstraction";
/** Generates credentials used for user authentication /** Generates credentials used for user authentication
@@ -9,19 +11,22 @@ import { PolicyEvaluator } from "./policy-evaluator.abstraction";
export abstract class GeneratorService<Options, Policy> { export abstract class GeneratorService<Options, Policy> {
/** An observable monitoring the options saved to disk. /** An observable monitoring the options saved to disk.
* The observable updates when the options are saved. * 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. /** An observable monitoring the options used to enforce policy.
* The observable updates when the policy changes. * 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 /** Enforces the policy on the given options
* @param userId: Identifies the user making the request
* @param options the options to enforce the policy on * @param options the options to enforce the policy on
* @returns a new instance of the options with the policy enforced * @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 /** Generates credentials
* @param options the options to generate credentials with * @param options the options to generate credentials with
@@ -30,8 +35,9 @@ export abstract class GeneratorService<Options, Policy> {
generate: (options: Options) => Promise<string>; generate: (options: Options) => Promise<string>;
/** Saves the given options to disk. /** Saves the given options to disk.
* @param userId: Identifies the user making the request
* @param options the options to save * @param options the options to save
* @returns a promise that resolves when the options are saved * @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 { mock } from "jest-mock-extended";
import { BehaviorSubject, firstValueFrom } from "rxjs"; 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 { PolicyService } from "../../admin-console/abstractions/policy/policy.service.abstraction";
import { PolicyType } from "../../admin-console/enums"; import { PolicyType } from "../../admin-console/enums";
// FIXME: use index.ts imports once policy abstractions and models // FIXME: use index.ts imports once policy abstractions and models
// implement ADR-0002 // implement ADR-0002
import { Policy } from "../../admin-console/models/domain/policy"; import { Policy } from "../../admin-console/models/domain/policy";
import { Utils } from "../../platform/misc/utils"; import { SingleUserState } from "../../platform/state";
import { ActiveUserState, ActiveUserStateProvider, KeyDefinition } from "../../platform/state";
import { UserId } from "../../types/guid"; import { UserId } from "../../types/guid";
import { GeneratorStrategy, PolicyEvaluator } from "./abstractions"; import { GeneratorStrategy, PolicyEvaluator } from "./abstractions";
import { PASSPHRASE_SETTINGS, PASSWORD_SETTINGS } from "./key-definitions";
import { PasswordGenerationOptions } from "./password"; import { PasswordGenerationOptions } from "./password";
import { DefaultGeneratorService } from "."; import { DefaultGeneratorService } from ".";
function mockPolicyService(config?: { data?: any; policy?: BehaviorSubject<Policy> }) { function mockPolicyService(config?: { state?: BehaviorSubject<Policy> }) {
const state = mock<Policy>({ data: config?.data ?? {} });
const subject = config?.policy ?? new BehaviorSubject<Policy>(state);
const service = mock<PolicyService>(); 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; return service;
} }
function mockGeneratorStrategy(config?: { function mockGeneratorStrategy(config?: {
disk?: KeyDefinition<any>; userState?: SingleUserState<any>;
policy?: PolicyType; policy?: PolicyType;
evaluator?: any; evaluator?: any;
}) { }) {
const durableState =
config?.userState ?? new FakeSingleUserState<PasswordGenerationOptions>(SomeUser);
const strategy = mock<GeneratorStrategy<any, any>>({ const strategy = mock<GeneratorStrategy<any, any>>({
// intentionally arbitrary so that tests that need to check // intentionally arbitrary so that tests that need to check
// whether they're used properly are guaranteed to test // whether they're used properly are guaranteed to test
// the value from `config`. // the value from `config`.
disk: config?.disk ?? {}, durableState: jest.fn(() => durableState),
policy: config?.policy ?? PolicyType.DisableSend, policy: config?.policy ?? PolicyType.DisableSend,
evaluator: jest.fn(() => config?.evaluator ?? mock<PolicyEvaluator<any, any>>()), evaluator: jest.fn(() => config?.evaluator ?? mock<PolicyEvaluator<any, any>>()),
}); });
@@ -49,129 +52,123 @@ function mockGeneratorStrategy(config?: {
return strategy; return strategy;
} }
// FIXME: Use the fake instead, once it's updated to monitor its method calls. const SomeUser = "some user" as UserId;
function mockStateProvider(): [ const AnotherUser = "another user" as UserId;
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;
}
describe("Password generator service", () => { 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$", () => { describe("options$", () => {
it("should return the state from strategy.key", () => { it("should retrieve durable state from the service", () => {
const policy = mockPolicyService(); const policy = mockPolicyService();
const strategy = mockGeneratorStrategy({ disk: PASSPHRASE_SETTINGS }); const userState = new FakeSingleUserState<PasswordGenerationOptions>(SomeUser);
const [state] = mockStateProvider(); const strategy = mockGeneratorStrategy({ userState });
const service = new DefaultGeneratorService(strategy, policy, state); const service = new DefaultGeneratorService(strategy, policy);
// invoke the getter. It returns the state but that's not important. const result = service.options$(SomeUser);
service.options$;
expect(state.get).toHaveBeenCalledWith(PASSPHRASE_SETTINGS); expect(strategy.durableState).toHaveBeenCalledWith(SomeUser);
expect(result).toBe(userState.state$);
}); });
}); });
describe("saveOptions()", () => { 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 () => { it("should trigger an options$ update", async () => {
const policy = mockPolicyService(); const policy = mockPolicyService();
const strategy = mockGeneratorStrategy(); const userState = new FakeSingleUserState<PasswordGenerationOptions>(SomeUser, { length: 9 });
// using the fake here because we're testing that the update and the const strategy = mockGeneratorStrategy({ userState });
// property are wired together. If we were to mock that, we'd be testing const service = new DefaultGeneratorService(strategy, policy);
// the mock configuration instead of the wiring.
const provider = fakeStateProvider(strategy.disk, { length: 9 });
const service = new DefaultGeneratorService(strategy, policy, provider);
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 }); 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 () => { it("should map the policy using the generation strategy", async () => {
const policyService = mockPolicyService(); const policyService = mockPolicyService();
const evaluator = mock<PolicyEvaluator<any, any>>(); const evaluator = mock<PolicyEvaluator<any, any>>();
const strategy = mockGeneratorStrategy({ evaluator }); const strategy = mockGeneratorStrategy({ evaluator });
const service = new DefaultGeneratorService(strategy, policyService);
const service = new DefaultGeneratorService(strategy, policyService, null); const policy = await firstValueFrom(service.evaluator$(SomeUser));
const policy = await firstValueFrom(service.policy$);
expect(policy).toBe(evaluator); 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("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 () => { it("should evaluate the policy using the generation strategy", async () => {
const policy = mockPolicyService(); const policy = mockPolicyService();
const evaluator = mock<PolicyEvaluator<any, any>>(); const evaluator = mock<PolicyEvaluator<any, any>>();
const strategy = mockGeneratorStrategy({ evaluator }); 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.applyPolicy).toHaveBeenCalled();
expect(evaluator.sanitize).toHaveBeenCalled(); expect(evaluator.sanitize).toHaveBeenCalled();
@@ -182,7 +179,7 @@ describe("Password generator service", () => {
it("should invoke the generation strategy", async () => { it("should invoke the generation strategy", async () => {
const strategy = mockGeneratorStrategy(); const strategy = mockGeneratorStrategy();
const policy = mockPolicyService(); const policy = mockPolicyService();
const service = new DefaultGeneratorService(strategy, policy, null); const service = new DefaultGeneratorService(strategy, policy);
await service.generate({}); 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 // FIXME: use index.ts imports once policy abstractions and models
// implement ADR-0002 // implement ADR-0002
import { PolicyService } from "../../admin-console/abstractions/policy/policy.service.abstraction"; 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"; 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 * @param strategy tailors the service to a specific generator type
* (e.g. password, passphrase) * (e.g. password, passphrase)
* @param policy provides the policy to enforce * @param policy provides the policy to enforce
* @param state saves and loads password generation options to the location
* specified by the strategy
*/ */
constructor( constructor(
private strategy: GeneratorStrategy<Options, Policy>, private strategy: GeneratorStrategy<Options, Policy>,
private policy: PolicyService, private policy: PolicyService,
private state: ActiveUserStateProvider, ) {}
) {
this._policy$ = this.policy.get$(this.strategy.policy).pipe( private _evaluators$ = new Map<UserId, Observable<PolicyEvaluator<Policy, Options>>>();
map((policy) => this.strategy.evaluator(policy)),
share({ /** {@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 // cache evaluator in a replay subject to amortize creation cost
// and reduce GC pressure. // and reduce GC pressure.
const evaluator$ = policies$.pipe(
map((policy) => this.strategy.evaluator(policy)),
share({
connector: () => new ReplaySubject(1), connector: () => new ReplaySubject(1),
resetOnRefCountZero: () => timer(this.strategy.cache_ms), resetOnRefCountZero: () => timer(this.strategy.cache_ms),
}), }),
); );
return evaluator$;
} }
private _policy$: Observable<PolicyEvaluator<Policy, Options>>; /** {@link GeneratorService.enforcePolicy()} */
async enforcePolicy(userId: UserId, options: Options): Promise<Options> {
/** {@link GeneratorService.options$} */ const policy = await firstValueFrom(this.evaluator$(userId));
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$);
const evaluated = policy.applyPolicy(options); const evaluated = policy.applyPolicy(options);
const sanitized = policy.sanitize(evaluated); const sanitized = policy.sanitize(evaluated);
return sanitized; return sanitized;

View File

@@ -9,15 +9,21 @@ import { PolicyType } from "../../../admin-console/enums";
// FIXME: use index.ts imports once policy abstractions and models // FIXME: use index.ts imports once policy abstractions and models
// implement ADR-0002 // implement ADR-0002
import { Policy } from "../../../admin-console/models/domain/policy"; 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 { PASSPHRASE_SETTINGS } from "../key-definitions";
import { PasswordGenerationServiceAbstraction } from "../password/password-generation.service.abstraction"; import { PasswordGenerationServiceAbstraction } from "../password/password-generation.service.abstraction";
import { DisabledPassphraseGeneratorPolicy } from "./passphrase-generator-policy";
import { PassphraseGeneratorOptionsEvaluator, PassphraseGeneratorStrategy } from "."; import { PassphraseGeneratorOptionsEvaluator, PassphraseGeneratorStrategy } from ".";
const SomeUser = "some user" as UserId;
describe("Password generation strategy", () => { describe("Password generation strategy", () => {
describe("evaluator()", () => { describe("evaluator()", () => {
it("should throw if the policy type is incorrect", () => { it("should throw if the policy type is incorrect", () => {
const strategy = new PassphraseGeneratorStrategy(null); const strategy = new PassphraseGeneratorStrategy(null, null);
const policy = mock<Policy>({ const policy = mock<Policy>({
type: PolicyType.DisableSend, type: PolicyType.DisableSend,
}); });
@@ -26,7 +32,7 @@ describe("Password generation strategy", () => {
}); });
it("should map to the policy evaluator", () => { it("should map to the policy evaluator", () => {
const strategy = new PassphraseGeneratorStrategy(null); const strategy = new PassphraseGeneratorStrategy(null, null);
const policy = mock<Policy>({ const policy = mock<Policy>({
type: PolicyType.PasswordGenerator, type: PolicyType.PasswordGenerator,
data: { data: {
@@ -45,21 +51,32 @@ describe("Password generation strategy", () => {
includeNumber: true, 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", () => { it("should use password settings key", () => {
const provider = mock<StateProvider>();
const legacy = mock<PasswordGenerationServiceAbstraction>(); 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", () => { describe("cache_ms", () => {
it("should be a positive non-zero number", () => { it("should be a positive non-zero number", () => {
const legacy = mock<PasswordGenerationServiceAbstraction>(); const legacy = mock<PasswordGenerationServiceAbstraction>();
const strategy = new PassphraseGeneratorStrategy(legacy); const strategy = new PassphraseGeneratorStrategy(legacy, null);
expect(strategy.cache_ms).toBeGreaterThan(0); expect(strategy.cache_ms).toBeGreaterThan(0);
}); });
@@ -68,7 +85,7 @@ describe("Password generation strategy", () => {
describe("policy", () => { describe("policy", () => {
it("should use password generator policy", () => { it("should use password generator policy", () => {
const legacy = mock<PasswordGenerationServiceAbstraction>(); const legacy = mock<PasswordGenerationServiceAbstraction>();
const strategy = new PassphraseGeneratorStrategy(legacy); const strategy = new PassphraseGeneratorStrategy(legacy, null);
expect(strategy.policy).toBe(PolicyType.PasswordGenerator); expect(strategy.policy).toBe(PolicyType.PasswordGenerator);
}); });
@@ -77,7 +94,7 @@ describe("Password generation strategy", () => {
describe("generate()", () => { describe("generate()", () => {
it("should call the legacy service with the given options", async () => { it("should call the legacy service with the given options", async () => {
const legacy = mock<PasswordGenerationServiceAbstraction>(); const legacy = mock<PasswordGenerationServiceAbstraction>();
const strategy = new PassphraseGeneratorStrategy(legacy); const strategy = new PassphraseGeneratorStrategy(legacy, null);
const options = { const options = {
type: "passphrase", type: "passphrase",
minNumberWords: 1, minNumberWords: 1,
@@ -92,7 +109,7 @@ describe("Password generation strategy", () => {
it("should set the generation type to passphrase", async () => { it("should set the generation type to passphrase", async () => {
const legacy = mock<PasswordGenerationServiceAbstraction>(); const legacy = mock<PasswordGenerationServiceAbstraction>();
const strategy = new PassphraseGeneratorStrategy(legacy); const strategy = new PassphraseGeneratorStrategy(legacy, null);
await strategy.generate({ type: "foo" } as any); 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 // FIXME: use index.ts imports once policy abstractions and models
// implement ADR-0002 // implement ADR-0002
import { Policy } from "../../../admin-console/models/domain/policy"; 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 { PASSPHRASE_SETTINGS } from "../key-definitions";
import { PasswordGenerationServiceAbstraction } from "../password/password-generation.service.abstraction"; import { PasswordGenerationServiceAbstraction } from "../password/password-generation.service.abstraction";
import { PassphraseGenerationOptions } from "./passphrase-generation-options"; import { PassphraseGenerationOptions } from "./passphrase-generation-options";
import { PassphraseGeneratorOptionsEvaluator } from "./passphrase-generator-options-evaluator"; 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; const ONE_MINUTE = 60 * 1000;
@@ -19,11 +24,14 @@ export class PassphraseGeneratorStrategy
/** instantiates the password generator strategy. /** instantiates the password generator strategy.
* @param legacy generates the passphrase * @param legacy generates the passphrase
*/ */
constructor(private legacy: PasswordGenerationServiceAbstraction) {} constructor(
private legacy: PasswordGenerationServiceAbstraction,
private stateProvider: StateProvider,
) {}
/** {@link GeneratorStrategy.disk} */ /** {@link GeneratorStrategy.durableState} */
get disk() { durableState(id: UserId) {
return PASSPHRASE_SETTINGS; return this.stateProvider.getUser(id, PASSPHRASE_SETTINGS);
} }
/** {@link GeneratorStrategy.policy} */ /** {@link GeneratorStrategy.policy} */
@@ -37,6 +45,10 @@ export class PassphraseGeneratorStrategy
/** {@link GeneratorStrategy.evaluator} */ /** {@link GeneratorStrategy.evaluator} */
evaluator(policy: Policy): PassphraseGeneratorOptionsEvaluator { evaluator(policy: Policy): PassphraseGeneratorOptionsEvaluator {
if (!policy) {
return new PassphraseGeneratorOptionsEvaluator(DisabledPassphraseGeneratorPolicy);
}
if (policy.type !== this.policy) { if (policy.type !== this.policy) {
const details = `Expected: ${this.policy}. Received: ${policy.type}`; const details = `Expected: ${this.policy}. Received: ${policy.type}`;
throw Error("Mismatched policy type. " + details); 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 // FIXME: use index.ts imports once policy abstractions and models
// implement ADR-0002 // implement ADR-0002
import { Policy } from "../../../admin-console/models/domain/policy"; 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 { PASSWORD_SETTINGS } from "../key-definitions";
import { DisabledPasswordGeneratorPolicy } from "./password-generator-policy";
import { import {
PasswordGenerationServiceAbstraction, PasswordGenerationServiceAbstraction,
PasswordGeneratorOptionsEvaluator, PasswordGeneratorOptionsEvaluator,
PasswordGeneratorStrategy, PasswordGeneratorStrategy,
} from "."; } from ".";
const SomeUser = "some user" as UserId;
describe("Password generation strategy", () => { describe("Password generation strategy", () => {
describe("evaluator()", () => { describe("evaluator()", () => {
it("should throw if the policy type is incorrect", () => { it("should throw if the policy type is incorrect", () => {
const strategy = new PasswordGeneratorStrategy(null); const strategy = new PasswordGeneratorStrategy(null, null);
const policy = mock<Policy>({ const policy = mock<Policy>({
type: PolicyType.DisableSend, type: PolicyType.DisableSend,
}); });
@@ -29,7 +35,7 @@ describe("Password generation strategy", () => {
}); });
it("should map to the policy evaluator", () => { it("should map to the policy evaluator", () => {
const strategy = new PasswordGeneratorStrategy(null); const strategy = new PasswordGeneratorStrategy(null, null);
const policy = mock<Policy>({ const policy = mock<Policy>({
type: PolicyType.PasswordGenerator, type: PolicyType.PasswordGenerator,
data: { data: {
@@ -56,21 +62,32 @@ describe("Password generation strategy", () => {
specialCount: 1, 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", () => { it("should use password settings key", () => {
const provider = mock<StateProvider>();
const legacy = mock<PasswordGenerationServiceAbstraction>(); 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", () => { describe("cache_ms", () => {
it("should be a positive non-zero number", () => { it("should be a positive non-zero number", () => {
const legacy = mock<PasswordGenerationServiceAbstraction>(); const legacy = mock<PasswordGenerationServiceAbstraction>();
const strategy = new PasswordGeneratorStrategy(legacy); const strategy = new PasswordGeneratorStrategy(legacy, null);
expect(strategy.cache_ms).toBeGreaterThan(0); expect(strategy.cache_ms).toBeGreaterThan(0);
}); });
@@ -79,7 +96,7 @@ describe("Password generation strategy", () => {
describe("policy", () => { describe("policy", () => {
it("should use password generator policy", () => { it("should use password generator policy", () => {
const legacy = mock<PasswordGenerationServiceAbstraction>(); const legacy = mock<PasswordGenerationServiceAbstraction>();
const strategy = new PasswordGeneratorStrategy(legacy); const strategy = new PasswordGeneratorStrategy(legacy, null);
expect(strategy.policy).toBe(PolicyType.PasswordGenerator); expect(strategy.policy).toBe(PolicyType.PasswordGenerator);
}); });
@@ -88,7 +105,7 @@ describe("Password generation strategy", () => {
describe("generate()", () => { describe("generate()", () => {
it("should call the legacy service with the given options", async () => { it("should call the legacy service with the given options", async () => {
const legacy = mock<PasswordGenerationServiceAbstraction>(); const legacy = mock<PasswordGenerationServiceAbstraction>();
const strategy = new PasswordGeneratorStrategy(legacy); const strategy = new PasswordGeneratorStrategy(legacy, null);
const options = { const options = {
type: "password", type: "password",
minLength: 1, minLength: 1,
@@ -107,7 +124,7 @@ describe("Password generation strategy", () => {
it("should set the generation type to password", async () => { it("should set the generation type to password", async () => {
const legacy = mock<PasswordGenerationServiceAbstraction>(); const legacy = mock<PasswordGenerationServiceAbstraction>();
const strategy = new PasswordGeneratorStrategy(legacy); const strategy = new PasswordGeneratorStrategy(legacy, null);
await strategy.generate({ type: "foo" } as any); 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 // FIXME: use index.ts imports once policy abstractions and models
// implement ADR-0002 // implement ADR-0002
import { Policy } from "../../../admin-console/models/domain/policy"; 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 { PASSWORD_SETTINGS } from "../key-definitions";
import { PasswordGenerationOptions } from "./password-generation-options"; import { PasswordGenerationOptions } from "./password-generation-options";
import { PasswordGenerationServiceAbstraction } from "./password-generation.service.abstraction"; import { PasswordGenerationServiceAbstraction } from "./password-generation.service.abstraction";
import { PasswordGeneratorOptionsEvaluator } from "./password-generator-options-evaluator"; 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; const ONE_MINUTE = 60 * 1000;
@@ -19,11 +24,14 @@ export class PasswordGeneratorStrategy
/** instantiates the password generator strategy. /** instantiates the password generator strategy.
* @param legacy generates the password * @param legacy generates the password
*/ */
constructor(private legacy: PasswordGenerationServiceAbstraction) {} constructor(
private legacy: PasswordGenerationServiceAbstraction,
private stateProvider: StateProvider,
) {}
/** {@link GeneratorStrategy.disk} */ /** {@link GeneratorStrategy.durableState} */
get disk() { durableState(id: UserId) {
return PASSWORD_SETTINGS; return this.stateProvider.getUser(id, PASSWORD_SETTINGS);
} }
/** {@link GeneratorStrategy.policy} */ /** {@link GeneratorStrategy.policy} */
@@ -37,6 +45,10 @@ export class PasswordGeneratorStrategy
/** {@link GeneratorStrategy.evaluator} */ /** {@link GeneratorStrategy.evaluator} */
evaluator(policy: Policy): PasswordGeneratorOptionsEvaluator { evaluator(policy: Policy): PasswordGeneratorOptionsEvaluator {
if (!policy) {
return new PasswordGeneratorOptionsEvaluator(DisabledPasswordGeneratorPolicy);
}
if (policy.type !== this.policy) { if (policy.type !== this.policy) {
const details = `Expected: ${this.policy}. Received: ${policy.type}`; const details = `Expected: ${this.policy}. Received: ${policy.type}`;
throw Error("Mismatched policy type. " + details); throw Error("Mismatched policy type. " + details);

View File

@@ -83,7 +83,16 @@ describe("UserEncryptor", () => {
}); });
describe("instance", () => { 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 provider = await fakeStateProvider();
const encryptor = mockEncryptor(); const encryptor = mockEncryptor();
const state = SecretState.from(SomeUser, FOOBAR_KEY, provider, encryptor); const state = SecretState.from(SomeUser, FOOBAR_KEY, provider, encryptor);
@@ -96,6 +105,20 @@ describe("UserEncryptor", () => {
expect(result).toEqual(value); 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 () => { it("round-trips json-serializable values", async () => {
const provider = await fakeStateProvider(); const provider = await fakeStateProvider();
const encryptor = mockEncryptor(); 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 { Jsonify } from "type-fest";
import { EncString } from "../../../platform/models/domain/enc-string"; import { EncString } from "../../../platform/models/domain/enc-string";
@@ -9,6 +9,7 @@ import {
SingleUserState, SingleUserState,
StateProvider, StateProvider,
StateUpdateOptions, StateUpdateOptions,
CombinedState,
} from "../../../platform/state"; } from "../../../platform/state";
import { UserId } from "../../../types/guid"; import { UserId } from "../../../types/guid";
@@ -37,7 +38,9 @@ type ClassifiedFormat<Disclosed> = {
* *
* DO NOT USE THIS for synchronized data. * 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 // The constructor is private to avoid creating a circular dependency when
// wiring the derived and secret states together. // wiring the derived and secret states together.
private constructor( private constructor(
@@ -46,8 +49,23 @@ export class SecretState<Plaintext extends object, Disclosed> {
private readonly plaintext: DerivedState<Plaintext>, private readonly plaintext: DerivedState<Plaintext>,
) { ) {
this.state$ = plaintext.state$; 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 /** Creates a secret state bound to an account encryptor. The account must be unlocked
* when this method is called. * when this method is called.
* @param userId: the user to which the secret state is bound. * @param userId: the user to which the secret state is bound.
@@ -106,12 +124,6 @@ export class SecretState<Plaintext extends object, Disclosed> {
return secretState; 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. /** Updates the secret stored by this state.
* @param configureState a callback that returns an updated decrypted * @param configureState a callback that returns an updated decrypted
* secret state. The callback receives the state's present value as its * 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 // FIXME: use index.ts imports once policy abstractions and models
// implement ADR-0002 // implement ADR-0002
import { Policy } from "../../../admin-console/models/domain/policy"; 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 { DefaultPolicyEvaluator } from "../default-policy-evaluator";
import { CATCHALL_SETTINGS } from "../key-definitions"; import { CATCHALL_SETTINGS } from "../key-definitions";
import { CatchallGeneratorStrategy, UsernameGenerationServiceAbstraction } from "."; import { CatchallGeneratorStrategy, UsernameGenerationServiceAbstraction } from ".";
const SomeUser = "some user" as UserId;
describe("Email subaddress list generation strategy", () => { describe("Email subaddress list generation strategy", () => {
describe("evaluator()", () => { describe("evaluator()", () => {
it("should throw if the policy type is incorrect", () => { it("should throw if the policy type is incorrect", () => {
const strategy = new CatchallGeneratorStrategy(null); const strategy = new CatchallGeneratorStrategy(null, null);
const policy = mock<Policy>({ const policy = mock<Policy>({
type: PolicyType.DisableSend, type: PolicyType.DisableSend,
}); });
@@ -21,7 +25,7 @@ describe("Email subaddress list generation strategy", () => {
}); });
it("should map to the policy evaluator", () => { it("should map to the policy evaluator", () => {
const strategy = new CatchallGeneratorStrategy(null); const strategy = new CatchallGeneratorStrategy(null, null);
const policy = mock<Policy>({ const policy = mock<Policy>({
type: PolicyType.PasswordGenerator, type: PolicyType.PasswordGenerator,
data: { data: {
@@ -34,21 +38,31 @@ describe("Email subaddress list generation strategy", () => {
expect(evaluator).toBeInstanceOf(DefaultPolicyEvaluator); expect(evaluator).toBeInstanceOf(DefaultPolicyEvaluator);
expect(evaluator.policy).toMatchObject({}); 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", () => { it("should use password settings key", () => {
const provider = mock<StateProvider>();
const legacy = mock<UsernameGenerationServiceAbstraction>(); 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", () => { describe("cache_ms", () => {
it("should be a positive non-zero number", () => { it("should be a positive non-zero number", () => {
const legacy = mock<UsernameGenerationServiceAbstraction>(); const legacy = mock<UsernameGenerationServiceAbstraction>();
const strategy = new CatchallGeneratorStrategy(legacy); const strategy = new CatchallGeneratorStrategy(legacy, null);
expect(strategy.cache_ms).toBeGreaterThan(0); expect(strategy.cache_ms).toBeGreaterThan(0);
}); });
@@ -57,7 +71,7 @@ describe("Email subaddress list generation strategy", () => {
describe("policy", () => { describe("policy", () => {
it("should use password generator policy", () => { it("should use password generator policy", () => {
const legacy = mock<UsernameGenerationServiceAbstraction>(); const legacy = mock<UsernameGenerationServiceAbstraction>();
const strategy = new CatchallGeneratorStrategy(legacy); const strategy = new CatchallGeneratorStrategy(legacy, null);
expect(strategy.policy).toBe(PolicyType.PasswordGenerator); expect(strategy.policy).toBe(PolicyType.PasswordGenerator);
}); });
@@ -66,7 +80,7 @@ describe("Email subaddress list generation strategy", () => {
describe("generate()", () => { describe("generate()", () => {
it("should call the legacy service with the given options", async () => { it("should call the legacy service with the given options", async () => {
const legacy = mock<UsernameGenerationServiceAbstraction>(); const legacy = mock<UsernameGenerationServiceAbstraction>();
const strategy = new CatchallGeneratorStrategy(legacy); const strategy = new CatchallGeneratorStrategy(legacy, null);
const options = { const options = {
type: "website-name" as const, type: "website-name" as const,
domain: "example.com", domain: "example.com",

View File

@@ -1,5 +1,7 @@
import { PolicyType } from "../../../admin-console/enums"; import { PolicyType } from "../../../admin-console/enums";
import { Policy } from "../../../admin-console/models/domain/policy"; import { Policy } from "../../../admin-console/models/domain/policy";
import { StateProvider } from "../../../platform/state";
import { UserId } from "../../../types/guid";
import { GeneratorStrategy } from "../abstractions"; import { GeneratorStrategy } from "../abstractions";
import { DefaultPolicyEvaluator } from "../default-policy-evaluator"; import { DefaultPolicyEvaluator } from "../default-policy-evaluator";
import { CATCHALL_SETTINGS } from "../key-definitions"; import { CATCHALL_SETTINGS } from "../key-definitions";
@@ -17,11 +19,14 @@ export class CatchallGeneratorStrategy
/** Instantiates the generation strategy /** Instantiates the generation strategy
* @param usernameService generates a catchall address for a domain * @param usernameService generates a catchall address for a domain
*/ */
constructor(private usernameService: UsernameGenerationServiceAbstraction) {} constructor(
private usernameService: UsernameGenerationServiceAbstraction,
private stateProvider: StateProvider,
) {}
/** {@link GeneratorStrategy.disk} */ /** {@link GeneratorStrategy.durableState} */
get disk() { durableState(id: UserId) {
return CATCHALL_SETTINGS; return this.stateProvider.getUser(id, CATCHALL_SETTINGS);
} }
/** {@link GeneratorStrategy.policy} */ /** {@link GeneratorStrategy.policy} */
@@ -38,6 +43,10 @@ export class CatchallGeneratorStrategy
/** {@link GeneratorStrategy.evaluator} */ /** {@link GeneratorStrategy.evaluator} */
evaluator(policy: Policy) { evaluator(policy: Policy) {
if (!policy) {
return new DefaultPolicyEvaluator<CatchallGenerationOptions>();
}
if (policy.type !== this.policy) { if (policy.type !== this.policy) {
const details = `Expected: ${this.policy}. Received: ${policy.type}`; const details = `Expected: ${this.policy}. Received: ${policy.type}`;
throw Error("Mismatched policy type. " + details); 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 // FIXME: use index.ts imports once policy abstractions and models
// implement ADR-0002 // implement ADR-0002
import { Policy } from "../../../admin-console/models/domain/policy"; 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 { DefaultPolicyEvaluator } from "../default-policy-evaluator";
import { EFF_USERNAME_SETTINGS } from "../key-definitions"; import { EFF_USERNAME_SETTINGS } from "../key-definitions";
import { EffUsernameGeneratorStrategy, UsernameGenerationServiceAbstraction } from "."; import { EffUsernameGeneratorStrategy, UsernameGenerationServiceAbstraction } from ".";
const SomeUser = "some user" as UserId;
describe("EFF long word list generation strategy", () => { describe("EFF long word list generation strategy", () => {
describe("evaluator()", () => { describe("evaluator()", () => {
it("should throw if the policy type is incorrect", () => { it("should throw if the policy type is incorrect", () => {
const strategy = new EffUsernameGeneratorStrategy(null); const strategy = new EffUsernameGeneratorStrategy(null, null);
const policy = mock<Policy>({ const policy = mock<Policy>({
type: PolicyType.DisableSend, type: PolicyType.DisableSend,
}); });
@@ -21,7 +25,7 @@ describe("EFF long word list generation strategy", () => {
}); });
it("should map to the policy evaluator", () => { it("should map to the policy evaluator", () => {
const strategy = new EffUsernameGeneratorStrategy(null); const strategy = new EffUsernameGeneratorStrategy(null, null);
const policy = mock<Policy>({ const policy = mock<Policy>({
type: PolicyType.PasswordGenerator, type: PolicyType.PasswordGenerator,
data: { data: {
@@ -34,21 +38,31 @@ describe("EFF long word list generation strategy", () => {
expect(evaluator).toBeInstanceOf(DefaultPolicyEvaluator); expect(evaluator).toBeInstanceOf(DefaultPolicyEvaluator);
expect(evaluator.policy).toMatchObject({}); 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", () => { it("should use password settings key", () => {
const provider = mock<StateProvider>();
const legacy = mock<UsernameGenerationServiceAbstraction>(); 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", () => { describe("cache_ms", () => {
it("should be a positive non-zero number", () => { it("should be a positive non-zero number", () => {
const legacy = mock<UsernameGenerationServiceAbstraction>(); const legacy = mock<UsernameGenerationServiceAbstraction>();
const strategy = new EffUsernameGeneratorStrategy(legacy); const strategy = new EffUsernameGeneratorStrategy(legacy, null);
expect(strategy.cache_ms).toBeGreaterThan(0); expect(strategy.cache_ms).toBeGreaterThan(0);
}); });
@@ -57,7 +71,7 @@ describe("EFF long word list generation strategy", () => {
describe("policy", () => { describe("policy", () => {
it("should use password generator policy", () => { it("should use password generator policy", () => {
const legacy = mock<UsernameGenerationServiceAbstraction>(); const legacy = mock<UsernameGenerationServiceAbstraction>();
const strategy = new EffUsernameGeneratorStrategy(legacy); const strategy = new EffUsernameGeneratorStrategy(legacy, null);
expect(strategy.policy).toBe(PolicyType.PasswordGenerator); expect(strategy.policy).toBe(PolicyType.PasswordGenerator);
}); });
@@ -66,7 +80,7 @@ describe("EFF long word list generation strategy", () => {
describe("generate()", () => { describe("generate()", () => {
it("should call the legacy service with the given options", async () => { it("should call the legacy service with the given options", async () => {
const legacy = mock<UsernameGenerationServiceAbstraction>(); const legacy = mock<UsernameGenerationServiceAbstraction>();
const strategy = new EffUsernameGeneratorStrategy(legacy); const strategy = new EffUsernameGeneratorStrategy(legacy, null);
const options = { const options = {
wordCapitalize: false, wordCapitalize: false,
wordIncludeNumber: false, wordIncludeNumber: false,

View File

@@ -1,5 +1,7 @@
import { PolicyType } from "../../../admin-console/enums"; import { PolicyType } from "../../../admin-console/enums";
import { Policy } from "../../../admin-console/models/domain/policy"; import { Policy } from "../../../admin-console/models/domain/policy";
import { StateProvider } from "../../../platform/state";
import { UserId } from "../../../types/guid";
import { GeneratorStrategy } from "../abstractions"; import { GeneratorStrategy } from "../abstractions";
import { DefaultPolicyEvaluator } from "../default-policy-evaluator"; import { DefaultPolicyEvaluator } from "../default-policy-evaluator";
import { EFF_USERNAME_SETTINGS } from "../key-definitions"; import { EFF_USERNAME_SETTINGS } from "../key-definitions";
@@ -17,11 +19,14 @@ export class EffUsernameGeneratorStrategy
/** Instantiates the generation strategy /** Instantiates the generation strategy
* @param usernameService generates a username from EFF word list * @param usernameService generates a username from EFF word list
*/ */
constructor(private usernameService: UsernameGenerationServiceAbstraction) {} constructor(
private usernameService: UsernameGenerationServiceAbstraction,
private stateProvider: StateProvider,
) {}
/** {@link GeneratorStrategy.disk} */ /** {@link GeneratorStrategy.durableState} */
get disk() { durableState(id: UserId) {
return EFF_USERNAME_SETTINGS; return this.stateProvider.getUser(id, EFF_USERNAME_SETTINGS);
} }
/** {@link GeneratorStrategy.policy} */ /** {@link GeneratorStrategy.policy} */
@@ -38,6 +43,10 @@ export class EffUsernameGeneratorStrategy
/** {@link GeneratorStrategy.evaluator} */ /** {@link GeneratorStrategy.evaluator} */
evaluator(policy: Policy) { evaluator(policy: Policy) {
if (!policy) {
return new DefaultPolicyEvaluator<EffUsernameGenerationOptions>();
}
if (policy.type !== this.policy) { if (policy.type !== this.policy) {
const details = `Expected: ${this.policy}. Received: ${policy.type}`; const details = `Expected: ${this.policy}. Received: ${policy.type}`;
throw Error("Mismatched policy type. " + details); 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 // FIXME: use index.ts imports once policy abstractions and models
// implement ADR-0002 // implement ADR-0002
import { Policy } from "../../../admin-console/models/domain/policy"; 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 { DefaultPolicyEvaluator } from "../default-policy-evaluator";
import { SUBADDRESS_SETTINGS } from "../key-definitions"; import { SUBADDRESS_SETTINGS } from "../key-definitions";
import { SubaddressGeneratorStrategy, UsernameGenerationServiceAbstraction } from "."; import { SubaddressGeneratorStrategy, UsernameGenerationServiceAbstraction } from ".";
const SomeUser = "some user" as UserId;
describe("Email subaddress list generation strategy", () => { describe("Email subaddress list generation strategy", () => {
describe("evaluator()", () => { describe("evaluator()", () => {
it("should throw if the policy type is incorrect", () => { it("should throw if the policy type is incorrect", () => {
const strategy = new SubaddressGeneratorStrategy(null); const strategy = new SubaddressGeneratorStrategy(null, null);
const policy = mock<Policy>({ const policy = mock<Policy>({
type: PolicyType.DisableSend, type: PolicyType.DisableSend,
}); });
@@ -21,7 +25,7 @@ describe("Email subaddress list generation strategy", () => {
}); });
it("should map to the policy evaluator", () => { it("should map to the policy evaluator", () => {
const strategy = new SubaddressGeneratorStrategy(null); const strategy = new SubaddressGeneratorStrategy(null, null);
const policy = mock<Policy>({ const policy = mock<Policy>({
type: PolicyType.PasswordGenerator, type: PolicyType.PasswordGenerator,
data: { data: {
@@ -34,21 +38,31 @@ describe("Email subaddress list generation strategy", () => {
expect(evaluator).toBeInstanceOf(DefaultPolicyEvaluator); expect(evaluator).toBeInstanceOf(DefaultPolicyEvaluator);
expect(evaluator.policy).toMatchObject({}); 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", () => { it("should use password settings key", () => {
const provider = mock<StateProvider>();
const legacy = mock<UsernameGenerationServiceAbstraction>(); 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", () => { describe("cache_ms", () => {
it("should be a positive non-zero number", () => { it("should be a positive non-zero number", () => {
const legacy = mock<UsernameGenerationServiceAbstraction>(); const legacy = mock<UsernameGenerationServiceAbstraction>();
const strategy = new SubaddressGeneratorStrategy(legacy); const strategy = new SubaddressGeneratorStrategy(legacy, null);
expect(strategy.cache_ms).toBeGreaterThan(0); expect(strategy.cache_ms).toBeGreaterThan(0);
}); });
@@ -57,7 +71,7 @@ describe("Email subaddress list generation strategy", () => {
describe("policy", () => { describe("policy", () => {
it("should use password generator policy", () => { it("should use password generator policy", () => {
const legacy = mock<UsernameGenerationServiceAbstraction>(); const legacy = mock<UsernameGenerationServiceAbstraction>();
const strategy = new SubaddressGeneratorStrategy(legacy); const strategy = new SubaddressGeneratorStrategy(legacy, null);
expect(strategy.policy).toBe(PolicyType.PasswordGenerator); expect(strategy.policy).toBe(PolicyType.PasswordGenerator);
}); });
@@ -66,7 +80,7 @@ describe("Email subaddress list generation strategy", () => {
describe("generate()", () => { describe("generate()", () => {
it("should call the legacy service with the given options", async () => { it("should call the legacy service with the given options", async () => {
const legacy = mock<UsernameGenerationServiceAbstraction>(); const legacy = mock<UsernameGenerationServiceAbstraction>();
const strategy = new SubaddressGeneratorStrategy(legacy); const strategy = new SubaddressGeneratorStrategy(legacy, null);
const options = { const options = {
type: "website-name" as const, type: "website-name" as const,
email: "someone@example.com", email: "someone@example.com",

View File

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

161
package-lock.json generated
View File

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

View File

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