1
0
mirror of https://github.com/bitwarden/browser synced 2026-02-10 05:30:01 +00:00

fixed merge conflict

This commit is contained in:
gbubemismith
2025-04-15 15:24:56 -04:00
28 changed files with 1026 additions and 226 deletions

View File

@@ -250,6 +250,7 @@
"napi-derive",
"node-forge",
"node-ipc",
"nx",
"oo7",
"oslog",
"pin-project",

View File

@@ -242,7 +242,7 @@ jobs:
run: |
# If run-id was used, get the commit from the download-latest-artifacts-run-id step
if [ "${{ inputs.build-web-run-id }}" ]; then
echo "commit=${{ steps.download-latest-artifacts-run-id.outputs.artifact_build_commit }}" >> $GITHUB_OUTPUT
echo "commit=${{ steps.download-latest-artifacts-run-id.outputs.artifact-build-commit }}" >> $GITHUB_OUTPUT
elif [ "${{ steps.download-latest-artifacts.outcome }}" == "failure" ]; then
# If the download-latest-artifacts step failed, query the GH API to get the commit SHA of the artifact that was just built with trigger-build-web.
@@ -251,7 +251,7 @@ jobs:
else
# Set the commit to the output of step download-latest-artifacts.
echo "commit=${{ steps.download-latest-artifacts.outputs.artifact_build_commit }}" >> $GITHUB_OUTPUT
echo "commit=${{ steps.download-latest-artifacts.outputs.artifact-build-commit }}" >> $GITHUB_OUTPUT
fi
notify-start:

3
.gitignore vendored
View File

@@ -48,3 +48,6 @@ storybook-static
# Local app configuration
apps/**/config/local.json
# Nx
.nx

View File

@@ -95,6 +95,7 @@ type NotificationBackgroundExtensionMessageHandlers = {
unlockCompleted: ({ message, sender }: BackgroundOnMessageHandlerParams) => Promise<void>;
bgGetFolderData: ({ message, sender }: BackgroundOnMessageHandlerParams) => Promise<FolderView[]>;
bgCloseNotificationBar: ({ message, sender }: BackgroundOnMessageHandlerParams) => Promise<void>;
bgOpenAtRisksPasswords: ({ message, sender }: BackgroundOnMessageHandlerParams) => Promise<void>;
bgAdjustNotificationBar: ({ message, sender }: BackgroundOnMessageHandlerParams) => Promise<void>;
bgAddLogin: ({ message, sender }: BackgroundOnMessageHandlerParams) => Promise<void>;
bgChangedPassword: ({ message, sender }: BackgroundOnMessageHandlerParams) => Promise<void>;

View File

@@ -1,5 +1,5 @@
import { mock, MockProxy } from "jest-mock-extended";
import { BehaviorSubject, firstValueFrom } from "rxjs";
import { BehaviorSubject, firstValueFrom, of } from "rxjs";
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
import { DefaultPolicyService } from "@bitwarden/common/admin-console/services/policy/default-policy.service";
@@ -12,6 +12,7 @@ import { UserNotificationSettingsService } from "@bitwarden/common/autofill/serv
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service";
import { SelfHostedEnvironment } from "@bitwarden/common/platform/services/default-environment.service";
import { ThemeStateService } from "@bitwarden/common/platform/theming/theme-state.service";
import { UserId } from "@bitwarden/common/types/guid";
@@ -19,6 +20,7 @@ import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
import { FolderView } from "@bitwarden/common/vault/models/view/folder.view";
import { CipherService } from "@bitwarden/common/vault/services/cipher.service";
import { FolderService } from "@bitwarden/common/vault/services/folder/folder.service";
import { TaskService, SecurityTask } from "@bitwarden/common/vault/tasks";
import { BrowserApi } from "../../platform/browser/browser-api";
import { NotificationQueueMessageType } from "../enums/notification-queue-message-type.enum";
@@ -46,6 +48,8 @@ jest.mock("rxjs", () => {
});
describe("NotificationBackground", () => {
const messagingService = mock<MessagingService>();
const taskService = mock<TaskService>();
let notificationBackground: NotificationBackground;
const autofillService = mock<AutofillService>();
const cipherService = mock<CipherService>();
@@ -88,6 +92,8 @@ describe("NotificationBackground", () => {
policyService,
themeStateService,
userNotificationSettingsService,
taskService,
messagingService,
);
});
@@ -201,8 +207,8 @@ describe("NotificationBackground", () => {
await flushPromises();
expect(notificationBackground["handleSaveCipherMessage"]).toHaveBeenCalledWith(
message.data.commandToRetry.message,
message.data.commandToRetry.sender,
message.data?.commandToRetry?.message,
message.data?.commandToRetry?.sender,
);
});
});
@@ -498,7 +504,7 @@ describe("NotificationBackground", () => {
expect(pushChangePasswordToQueueSpy).toHaveBeenCalledWith(
null,
"example.com",
message.data.newPassword,
message.data?.newPassword,
sender.tab,
true,
);
@@ -570,7 +576,7 @@ describe("NotificationBackground", () => {
expect(pushChangePasswordToQueueSpy).toHaveBeenCalledWith(
"cipher-id",
"example.com",
message.data.newPassword,
message.data?.newPassword,
sender.tab,
);
});
@@ -618,7 +624,7 @@ describe("NotificationBackground", () => {
expect(pushChangePasswordToQueueSpy).toHaveBeenCalledWith(
"cipher-id",
"example.com",
message.data.newPassword,
message.data?.newPassword,
sender.tab,
);
});
@@ -844,6 +850,86 @@ describe("NotificationBackground", () => {
);
});
it("completes password update notification with a security task notice if any are present for the cipher, and dismisses tasks for the updated cipher", async () => {
const mockCipherId = "testId";
const mockOrgId = "testOrgId";
const mockSecurityTask = {
id: "testTaskId",
organizationId: mockOrgId,
cipherId: mockCipherId,
type: 0,
status: 0,
creationDate: new Date(),
revisionDate: new Date(),
} as SecurityTask;
const mockSecurityTask2 = {
...mockSecurityTask,
id: "testTaskId2",
cipherId: "testId2",
} as SecurityTask;
taskService.tasksEnabled$.mockImplementation(() => of(true));
taskService.pendingTasks$.mockImplementation(() =>
of([mockSecurityTask, mockSecurityTask2]),
);
jest
.spyOn(notificationBackground as any, "getNotificationFlag")
.mockResolvedValueOnce(true);
jest.spyOn(notificationBackground as any, "getOrgData").mockResolvedValueOnce([
{
id: mockOrgId,
name: "Org Name, LLC",
productTierType: 3,
},
]);
const tab = createChromeTabMock({ id: 1, url: "https://example.com" });
const sender = mock<chrome.runtime.MessageSender>({ tab });
const message: NotificationBackgroundExtensionMessage = {
command: "bgSaveCipher",
edit: false,
folder: "folder-id",
};
const queueMessage = mock<AddChangePasswordQueueMessage>({
type: NotificationQueueMessageType.ChangePassword,
tab,
domain: "example.com",
newPassword: "newPassword",
});
notificationBackground["notificationQueue"] = [queueMessage];
const cipherView = mock<CipherView>({
id: mockCipherId,
organizationId: mockOrgId,
login: { username: "testUser" },
});
getDecryptedCipherByIdSpy.mockResolvedValueOnce(cipherView);
sendMockExtensionMessage(message, sender);
await flushPromises();
expect(editItemSpy).not.toHaveBeenCalled();
expect(createWithServerSpy).not.toHaveBeenCalled();
expect(updatePasswordSpy).toHaveBeenCalledWith(
cipherView,
queueMessage.newPassword,
message.edit,
sender.tab,
mockCipherId,
);
expect(updateWithServerSpy).toHaveBeenCalled();
expect(tabSendMessageDataSpy).toHaveBeenCalledWith(
sender.tab,
"saveCipherAttemptCompleted",
{
cipherId: "testId",
task: {
orgName: "Org Name, LLC",
remainingTasksCount: 1,
},
username: "testUser",
},
);
});
it("updates the cipher password if the queue message was locked and an existing cipher has the same username as the message", async () => {
const tab = createChromeTabMock({ id: 1, url: "https://example.com" });
const sender = mock<chrome.runtime.MessageSender>({ tab });

View File

@@ -1,6 +1,6 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { firstValueFrom, switchMap } from "rxjs";
import { firstValueFrom, switchMap, map } from "rxjs";
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
@@ -22,16 +22,21 @@ import { ConfigService } from "@bitwarden/common/platform/abstractions/config/co
import { ServerConfig } from "@bitwarden/common/platform/abstractions/config/server-config";
import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service";
import { Utils } from "@bitwarden/common/platform/misc/utils";
import { ThemeStateService } from "@bitwarden/common/platform/theming/theme-state.service";
import { UserId } from "@bitwarden/common/types/guid";
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
import { FolderService } from "@bitwarden/common/vault/abstractions/folder/folder.service.abstraction";
import { CipherType } from "@bitwarden/common/vault/enums";
import { VaultMessages } from "@bitwarden/common/vault/enums/vault-messages.enum";
import { buildCipherIcon } from "@bitwarden/common/vault/icon/build-cipher-icon";
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
import { LoginUriView } from "@bitwarden/common/vault/models/view/login-uri.view";
import { LoginView } from "@bitwarden/common/vault/models/view/login.view";
import { TaskService } from "@bitwarden/common/vault/tasks";
import { SecurityTaskType } from "@bitwarden/common/vault/tasks/enums";
import { SecurityTask } from "@bitwarden/common/vault/tasks/models/security-task";
import { openUnlockPopout } from "../../auth/popup/utils/auth-popout-window";
import { BrowserApi } from "../../platform/browser/browser-api";
@@ -70,6 +75,8 @@ export default class NotificationBackground {
bgChangedPassword: ({ message, sender }) => this.changedPassword(message, sender),
bgCloseNotificationBar: ({ message, sender }) =>
this.handleCloseNotificationBarMessage(message, sender),
bgOpenAtRisksPasswords: ({ message, sender }) =>
this.handleOpenAtRisksPasswordsMessage(message, sender),
bgGetActiveUserServerConfig: () => this.getActiveUserServerConfig(),
bgGetDecryptedCiphers: () => this.getNotificationCipherData(),
bgGetEnableChangedPasswordPrompt: () => this.getEnableChangedPasswordPrompt(),
@@ -106,6 +113,8 @@ export default class NotificationBackground {
private policyService: PolicyService,
private themeStateService: ThemeStateService,
private userNotificationSettingsService: UserNotificationSettingsServiceAbstraction,
private taskService: TaskService,
protected messagingService: MessagingService,
) {}
init() {
@@ -154,17 +163,20 @@ export default class NotificationBackground {
firstValueFrom(this.domainSettingsService.showFavicons$),
firstValueFrom(this.environmentService.environment$),
]);
const iconsServerUrl = env.getIconsUrl();
const activeUserId = await firstValueFrom(
this.accountService.activeAccount$.pipe(getOptionalUserId),
);
const decryptedCiphers = await this.cipherService.getAllDecryptedForUrl(
currentTab.url,
currentTab?.url,
activeUserId,
);
return decryptedCiphers.map((view) => {
const { id, name, reprompt, favorite, login } = view;
return {
id,
name,
@@ -599,13 +611,13 @@ export default class NotificationBackground {
try {
await this.cipherService.createWithServer(cipher);
await BrowserApi.tabSendMessageData(tab, "saveCipherAttemptCompleted", {
username: String(queueMessage?.username),
cipherId: String(cipher?.id),
username: queueMessage?.username && String(queueMessage.username),
cipherId: cipher?.id && String(cipher.id),
});
await BrowserApi.tabSendMessage(tab, { command: "addedCipher" });
} catch (error) {
await BrowserApi.tabSendMessageData(tab, "saveCipherAttemptCompleted", {
error: String(error.message),
error: error?.message && String(error.message),
});
}
}
@@ -638,15 +650,49 @@ export default class NotificationBackground {
return;
}
const cipher = await this.cipherService.encrypt(cipherView, userId);
const shouldGetTasks = await this.getNotificationFlag();
try {
const tasks = shouldGetTasks ? await this.getSecurityTasks(userId) : [];
const updatedCipherTask = tasks.find((task) => task.cipherId === cipherView?.id);
const cipherHasTask = !!updatedCipherTask?.id;
let taskOrgName: string;
if (cipherHasTask && updatedCipherTask?.organizationId) {
const userOrgs = await this.getOrgData();
taskOrgName = userOrgs.find(({ id }) => id === updatedCipherTask.organizationId)?.name;
}
const taskData = cipherHasTask
? {
remainingTasksCount: tasks.length - 1,
orgName: taskOrgName,
}
: undefined;
await this.cipherService.updateWithServer(cipher);
await BrowserApi.tabSendMessageData(tab, "saveCipherAttemptCompleted", {
username: String(cipherView?.login?.username),
cipherId: String(cipherView?.id),
username: cipherView?.login?.username && String(cipherView.login.username),
cipherId: cipherView?.id && String(cipherView.id),
task: taskData,
});
// If the cipher had a security task, mark it as complete
if (cipherHasTask) {
// guard against multiple (redundant) security tasks per cipher
await Promise.all(
tasks.map((task) => {
if (task.cipherId === cipherView?.id) {
return this.taskService.markAsComplete(task.id, userId);
}
}),
);
}
} catch (error) {
await BrowserApi.tabSendMessageData(tab, "saveCipherAttemptCompleted", {
error: String(error?.message),
error: error?.message && String(error.message),
});
}
}
@@ -697,6 +743,32 @@ export default class NotificationBackground {
return null;
}
private async getSecurityTasks(userId: UserId) {
let tasks: SecurityTask[] = [];
if (userId) {
tasks = await firstValueFrom(
this.taskService.tasksEnabled$(userId).pipe(
switchMap((tasksEnabled) => {
if (!tasksEnabled) {
return [];
}
return this.taskService
.pendingTasks$(userId)
.pipe(
map((tasks) =>
tasks.filter(({ type }) => type === SecurityTaskType.UpdateAtRiskCredential),
),
);
}),
),
);
}
return tasks;
}
/**
* Saves the current tab's domain to the never save list.
*
@@ -817,6 +889,41 @@ export default class NotificationBackground {
});
}
/**
* Sends a message to the background to open the
* at-risk passwords extension view. Triggers
* notification closure as a side-effect.
*
* @param message - The extension message
* @param sender - The contextual sender of the message
*/
private async handleOpenAtRisksPasswordsMessage(
message: NotificationBackgroundExtensionMessage,
sender: chrome.runtime.MessageSender,
) {
const browserAction = BrowserApi.getBrowserAction();
try {
// Set route of the popup before attempting to open it.
// If the vault is locked, this won't have an effect as the auth guards will
// redirect the user to the login page.
await browserAction.setPopup({ popup: "popup/index.html#/at-risk-passwords" });
await Promise.all([
this.messagingService.send(VaultMessages.OpenAtRiskPasswords),
BrowserApi.tabSendMessageData(sender.tab, "closeNotificationBar", {
fadeOutNotification: !!message.fadeOutNotification,
}),
]);
} finally {
// Reset the popup route to the default route so any subsequent
// popup openings will not open to the at-risk-passwords page.
await browserAction.setPopup({
popup: "popup/index.html#/",
});
}
}
/**
* Sends a message back to the sender tab which triggers
* an CSS adjustment of the notification bar.

View File

@@ -117,7 +117,6 @@ describe("OverlayBackground", () => {
let getFrameDetailsSpy: jest.SpyInstance;
let tabsSendMessageSpy: jest.SpyInstance;
let tabSendMessageDataSpy: jest.SpyInstance;
let sendMessageSpy: jest.SpyInstance;
let getTabFromCurrentWindowIdSpy: jest.SpyInstance;
let getTabSpy: jest.SpyInstance;
let openUnlockPopoutSpy: jest.SpyInstance;
@@ -228,7 +227,6 @@ describe("OverlayBackground", () => {
tabSendMessageDataSpy = jest
.spyOn(BrowserApi, "tabSendMessageData")
.mockImplementation(() => Promise.resolve());
sendMessageSpy = jest.spyOn(BrowserApi, "sendMessage");
getTabFromCurrentWindowIdSpy = jest.spyOn(BrowserApi, "getTabFromCurrentWindowId");
getTabSpy = jest.spyOn(BrowserApi, "getTab");
openUnlockPopoutSpy = jest.spyOn(overlayBackground as any, "openUnlockPopout");
@@ -1553,7 +1551,6 @@ describe("OverlayBackground", () => {
await flushPromises();
expect(cipherService.setAddEditCipherInfo).toHaveBeenCalled();
expect(sendMessageSpy).toHaveBeenCalledWith("inlineAutofillMenuRefreshAddEditCipher");
expect(openAddEditVaultItemPopoutSpy).toHaveBeenCalled();
});
@@ -1579,7 +1576,6 @@ describe("OverlayBackground", () => {
await flushPromises();
expect(cipherService.setAddEditCipherInfo).toHaveBeenCalled();
expect(sendMessageSpy).toHaveBeenCalledWith("inlineAutofillMenuRefreshAddEditCipher");
expect(openAddEditVaultItemPopoutSpy).toHaveBeenCalled();
});
@@ -1618,7 +1614,6 @@ describe("OverlayBackground", () => {
await flushPromises();
expect(cipherService.setAddEditCipherInfo).toHaveBeenCalled();
expect(sendMessageSpy).toHaveBeenCalledWith("inlineAutofillMenuRefreshAddEditCipher");
expect(openAddEditVaultItemPopoutSpy).toHaveBeenCalled();
});

View File

@@ -2434,7 +2434,6 @@ export class OverlayBackground implements OverlayBackgroundInterface {
cipherId: cipherView.id,
cipherType: addNewCipherType,
});
await BrowserApi.sendMessage("inlineAutofillMenuRefreshAddEditCipher");
} catch (error) {
this.logService.error("Error building cipher and opening add/edit vault item popout", error);
}

View File

@@ -647,9 +647,6 @@ describe("OverlayBackground", () => {
await flushPromises();
expect(overlayBackground["cipherService"].setAddEditCipherInfo).toHaveBeenCalled();
expect(BrowserApi.sendMessage).toHaveBeenCalledWith(
"inlineAutofillMenuRefreshAddEditCipher",
);
expect(overlayBackground["openAddEditVaultItemPopout"]).toHaveBeenCalled();
});
});

View File

@@ -678,7 +678,6 @@ class LegacyOverlayBackground implements OverlayBackgroundInterface {
);
await this.openAddEditVaultItemPopout(sender.tab, { cipherId: cipherView.id });
await BrowserApi.sendMessage("inlineAutofillMenuRefreshAddEditCipher");
}
/**

View File

@@ -29,11 +29,14 @@ type NotificationBarIframeInitData = {
};
type NotificationBarWindowMessage = {
cipherId?: string;
command: string;
data?: {
cipherId?: string;
task?: NotificationTaskInfo;
username?: string;
};
error?: string;
initData?: NotificationBarIframeInitData;
username?: string;
};
type NotificationBarWindowMessageHandlers = {

View File

@@ -356,7 +356,8 @@ function openViewVaultItemPopout(e: Event, cipherId: string) {
function handleSaveCipherConfirmation(message: NotificationBarWindowMessage) {
const { theme, type } = notificationBarIframeInitData;
const { error, username, cipherId } = message;
const { error, data } = message;
const { username, cipherId, task } = data || {};
const i18n = getI18n();
const resolvedTheme = getResolvedTheme(theme ?? ThemeTypes.Light);
@@ -371,8 +372,9 @@ function handleSaveCipherConfirmation(message: NotificationBarWindowMessage) {
i18n,
error,
username: username ?? i18n.typeLogin,
task,
handleOpenVault: (e) => cipherId && openViewVaultItemPopout(e, cipherId),
handleOpenTasks: () => {},
handleOpenTasks: () => sendPlatformMessage({ command: "bgOpenAtRisksPasswords" }),
}),
document.body,
);

View File

@@ -23,8 +23,8 @@ describe("OverlayNotificationsContentService", () => {
autofillInit = new AutofillInit(
domQueryService,
domElementVisibilityService,
null,
null,
undefined,
undefined,
overlayNotificationsContentService,
);
autofillInit.init();
@@ -89,7 +89,7 @@ describe("OverlayNotificationsContentService", () => {
await flushPromises();
expect(
overlayNotificationsContentService["notificationBarIframeElement"].style.transform,
overlayNotificationsContentService["notificationBarIframeElement"]?.style.transform,
).toBe("translateX(100%)");
});
@@ -103,12 +103,12 @@ describe("OverlayNotificationsContentService", () => {
});
await flushPromises();
overlayNotificationsContentService["notificationBarIframeElement"].dispatchEvent(
overlayNotificationsContentService["notificationBarIframeElement"]?.dispatchEvent(
new Event("load"),
);
expect(
overlayNotificationsContentService["notificationBarIframeElement"].style.transform,
overlayNotificationsContentService["notificationBarIframeElement"]?.style.transform,
).toBe("translateX(0)");
});
@@ -134,7 +134,7 @@ describe("OverlayNotificationsContentService", () => {
globalThis.dispatchEvent(
new MessageEvent("message", {
data: { command: "initNotificationBar" },
source: overlayNotificationsContentService["notificationBarIframeElement"].contentWindow,
source: overlayNotificationsContentService["notificationBarIframeElement"]?.contentWindow,
}),
);
await flushPromises();
@@ -168,9 +168,9 @@ describe("OverlayNotificationsContentService", () => {
data: { fadeOutNotification: true },
});
expect(overlayNotificationsContentService["notificationBarIframeElement"].style.opacity).toBe(
"0",
);
expect(
overlayNotificationsContentService["notificationBarIframeElement"]?.style.opacity,
).toBe("0");
jest.advanceTimersByTime(150);
@@ -210,7 +210,7 @@ describe("OverlayNotificationsContentService", () => {
data: { height: 1000 },
});
expect(overlayNotificationsContentService["notificationBarElement"].style.height).toBe(
expect(overlayNotificationsContentService["notificationBarElement"]?.style.height).toBe(
"1000px",
);
});
@@ -236,13 +236,13 @@ describe("OverlayNotificationsContentService", () => {
sendMockExtensionMessage({
command: "saveCipherAttemptCompleted",
data: { error: "" },
data: { error: undefined },
});
expect(
overlayNotificationsContentService["notificationBarIframeElement"].contentWindow
.postMessage,
).toHaveBeenCalledWith({ command: "saveCipherAttemptCompleted", error: "" }, "*");
).toHaveBeenCalledWith({ command: "saveCipherAttemptCompleted", error: undefined }, "*");
});
});

View File

@@ -135,9 +135,13 @@ export class OverlayNotificationsContentService
* @private
*/
private handleSaveCipherAttemptCompletedMessage(message: NotificationsExtensionMessage) {
// destructure error out of data
const { error, ...otherData } = message?.data || {};
this.sendMessageToNotificationBarIframe({
command: "saveCipherAttemptCompleted",
error: message.data?.error,
data: Object.keys(otherData).length ? otherData : undefined,
error,
});
}

View File

@@ -1195,6 +1195,17 @@ export default class MainBackground {
this.authService,
() => this.generatePasswordToClipboard(),
);
this.taskService = new DefaultTaskService(
this.stateProvider,
this.apiService,
this.organizationService,
this.configService,
this.authService,
this.notificationsService,
messageListener,
);
this.notificationBackground = new NotificationBackground(
this.accountService,
this.authService,
@@ -1209,6 +1220,8 @@ export default class MainBackground {
this.policyService,
this.themeStateService,
this.userNotificationSettingsService,
this.taskService,
this.messagingService,
);
this.overlayNotificationsBackground = new OverlayNotificationsBackground(
@@ -1313,16 +1326,6 @@ export default class MainBackground {
this.configService,
);
this.taskService = new DefaultTaskService(
this.stateProvider,
this.apiService,
this.organizationService,
this.configService,
this.authService,
this.notificationsService,
messageListener,
);
this.inlineMenuFieldQualificationService = new InlineMenuFieldQualificationService();
this.ipcContentScriptManagerService = new IpcContentScriptManagerService(this.configService);

View File

@@ -27,9 +27,6 @@ export class VaultItemsComponent extends BaseVaultItemsComponent {
// eslint-disable-next-line rxjs-angular/prefer-takeuntil
searchBarService.searchText$.pipe(distinctUntilChanged()).subscribe((searchText) => {
this.searchText = searchText;
// 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
this.search(200);
});
}

View File

@@ -126,9 +126,7 @@ export class ViewComponent extends BaseViewComponent implements OnInit, OnDestro
}
async ngOnChanges() {
await super.load();
if (this.cipher.decryptionFailure) {
if (this.cipher?.decryptionFailure) {
DecryptionFailureDialogComponent.open(this.dialogService, {
cipherIds: [this.cipherId as CipherId],
});

View File

@@ -13,6 +13,7 @@ import {
Subject,
} from "rxjs";
import {
catchError,
concatMap,
debounceTime,
filter,
@@ -23,7 +24,6 @@ import {
take,
takeUntil,
tap,
catchError,
} from "rxjs/operators";
import {
@@ -64,6 +64,7 @@ import { CipherRepromptType } from "@bitwarden/common/vault/enums/cipher-repromp
import { TreeNode } from "@bitwarden/common/vault/models/domain/tree-node";
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
import { ServiceUtils } from "@bitwarden/common/vault/service-utils";
import { filterOutNullish } from "@bitwarden/common/vault/utils/observable-utilities";
import { DialogRef, DialogService, Icons, ToastService } from "@bitwarden/components";
import {
AddEditFolderDialogComponent,
@@ -138,7 +139,6 @@ const SearchTextDebounceInterval = 200;
VaultFilterModule,
VaultItemsModule,
SharedModule,
DecryptionFailureDialogComponent,
],
providers: [
RoutedVaultFilterService,
@@ -348,9 +348,8 @@ export class VaultComponent implements OnInit, OnDestroy {
]).pipe(
filter(([ciphers, filter]) => ciphers != undefined && filter != undefined),
concatMap(async ([ciphers, filter, searchText]) => {
const failedCiphers = await firstValueFrom(
this.cipherService.failedToDecryptCiphers$(activeUserId),
);
const failedCiphers =
(await firstValueFrom(this.cipherService.failedToDecryptCiphers$(activeUserId))) ?? [];
const filterFunction = createFilterFunction(filter);
// Append any failed to decrypt ciphers to the top of the cipher list
const allCiphers = [...failedCiphers, ...ciphers];
@@ -472,6 +471,7 @@ export class VaultComponent implements OnInit, OnDestroy {
firstSetup$
.pipe(
switchMap(() => this.cipherService.failedToDecryptCiphers$(activeUserId)),
filterOutNullish(),
map((ciphers) => ciphers.filter((c) => !c.isDeleted)),
filter((ciphers) => ciphers.length > 0),
take(1),
@@ -528,6 +528,10 @@ export class VaultComponent implements OnInit, OnDestroy {
this.isEmpty = collections?.length === 0 && ciphers?.length === 0;
this.performingInitialLoad = false;
this.refreshing = false;
// Explicitly mark for check to ensure the view is updated
// Some sources are not always emitted within the Angular zone (e.g. ciphers updated via WS notifications)
this.changeDetectorRef.markForCheck();
},
);
}

View File

@@ -420,10 +420,15 @@ export class AddEditComponent implements OnInit, OnDestroy {
const activeUserId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId));
const cipher = await this.encryptCipher(activeUserId);
try {
this.formPromise = this.saveCipher(cipher);
await this.formPromise;
this.cipher.id = cipher.id;
const savedCipher = await this.formPromise;
// Reset local cipher from the saved cipher returned from the server
this.cipher = await savedCipher.decrypt(
await this.cipherService.getKeyForCipherKeyDecryption(savedCipher, activeUserId),
);
this.toastService.showToast({
variant: "success",
title: null,

View File

@@ -1,13 +1,22 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { Directive, EventEmitter, Input, OnDestroy, OnInit, Output } from "@angular/core";
import { BehaviorSubject, Subject, firstValueFrom, from, switchMap, takeUntil } from "rxjs";
import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
import {
BehaviorSubject,
Subject,
combineLatest,
filter,
from,
of,
switchMap,
takeUntil,
} from "rxjs";
import { SearchService } from "@bitwarden/common/abstractions/search.service";
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { getUserId } from "@bitwarden/common/auth/services/account.service";
import { UserId } from "@bitwarden/common/types/guid";
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
@@ -21,17 +30,17 @@ export class VaultItemsComponent implements OnInit, OnDestroy {
loaded = false;
ciphers: CipherView[] = [];
filter: (cipher: CipherView) => boolean = null;
deleted = false;
organization: Organization;
protected searchPending = false;
private userId: UserId;
/** Construct filters as an observable so it can be appended to the cipher stream. */
private _filter$ = new BehaviorSubject<(cipher: CipherView) => boolean | null>(null);
private destroy$ = new Subject<void>();
private searchTimeout: any = null;
private isSearchable: boolean = false;
private _searchText$ = new BehaviorSubject<string>("");
get searchText() {
return this._searchText$.value;
}
@@ -39,18 +48,28 @@ export class VaultItemsComponent implements OnInit, OnDestroy {
this._searchText$.next(value);
}
get filter() {
return this._filter$.value;
}
set filter(value: (cipher: CipherView) => boolean | null) {
this._filter$.next(value);
}
constructor(
protected searchService: SearchService,
protected cipherService: CipherService,
protected accountService: AccountService,
) {}
) {
this.subscribeToCiphers();
}
async ngOnInit() {
this.userId = await firstValueFrom(getUserId(this.accountService.activeAccount$));
this._searchText$
combineLatest([getUserId(this.accountService.activeAccount$), this._searchText$])
.pipe(
switchMap((searchText) => from(this.searchService.isSearchable(this.userId, searchText))),
switchMap(([userId, searchText]) =>
from(this.searchService.isSearchable(userId, searchText)),
),
takeUntil(this.destroy$),
)
.subscribe((isSearchable) => {
@@ -80,23 +99,6 @@ export class VaultItemsComponent implements OnInit, OnDestroy {
async applyFilter(filter: (cipher: CipherView) => boolean = null) {
this.filter = filter;
await this.search(null);
}
async search(timeout: number = null, indexedCiphers?: CipherView[]) {
this.searchPending = false;
if (this.searchTimeout != null) {
clearTimeout(this.searchTimeout);
}
if (timeout == null) {
await this.doSearch(indexedCiphers);
return;
}
this.searchPending = true;
this.searchTimeout = setTimeout(async () => {
await this.doSearch(indexedCiphers);
this.searchPending = false;
}, timeout);
}
selectCipher(cipher: CipherView) {
@@ -121,25 +123,44 @@ export class VaultItemsComponent implements OnInit, OnDestroy {
protected deletedFilter: (cipher: CipherView) => boolean = (c) => c.isDeleted === this.deleted;
protected async doSearch(indexedCiphers?: CipherView[], userId?: UserId) {
// Get userId from activeAccount if not provided from parent stream
if (!userId) {
userId = await firstValueFrom(getUserId(this.accountService.activeAccount$));
}
/**
* Creates stream of dependencies that results in the list of ciphers to display
* within the vault list.
*
* Note: This previously used promises but race conditions with how the ciphers were
* stored in electron. Using observables is more reliable as fresh values will always
* cascade through the components.
*/
private subscribeToCiphers() {
getUserId(this.accountService.activeAccount$)
.pipe(
switchMap((userId) =>
combineLatest([
this.cipherService.cipherViews$(userId).pipe(filter((ciphers) => ciphers != null)),
this.cipherService.failedToDecryptCiphers$(userId),
this._searchText$,
this._filter$,
of(userId),
]),
),
switchMap(([indexedCiphers, failedCiphers, searchText, filter, userId]) => {
let allCiphers = indexedCiphers ?? [];
const _failedCiphers = failedCiphers ?? [];
indexedCiphers =
indexedCiphers ?? (await firstValueFrom(this.cipherService.cipherViews$(userId)));
allCiphers = [..._failedCiphers, ...allCiphers];
const failedCiphers = await firstValueFrom(this.cipherService.failedToDecryptCiphers$(userId));
if (failedCiphers != null && failedCiphers.length > 0) {
indexedCiphers = [...failedCiphers, ...indexedCiphers];
}
this.ciphers = await this.searchService.searchCiphers(
this.userId,
this.searchText,
[this.filter, this.deletedFilter],
indexedCiphers,
);
return this.searchService.searchCiphers(
userId,
searchText,
[filter, this.deletedFilter],
allCiphers,
);
}),
takeUntilDestroyed(),
)
.subscribe((ciphers) => {
this.ciphers = ciphers;
this.loaded = true;
});
}
}

View File

@@ -11,7 +11,17 @@ import {
OnInit,
Output,
} from "@angular/core";
import { filter, firstValueFrom, map, Observable } from "rxjs";
import {
BehaviorSubject,
combineLatest,
filter,
firstValueFrom,
map,
Observable,
of,
switchMap,
tap,
} from "rxjs";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { AuditService } from "@bitwarden/common/abstractions/audit.service";
@@ -46,11 +56,22 @@ import { DialogService, ToastService } from "@bitwarden/components";
import { KeyService } from "@bitwarden/key-management";
import { PasswordRepromptService } from "@bitwarden/vault";
const BroadcasterSubscriptionId = "ViewComponent";
const BroadcasterSubscriptionId = "BaseViewComponent";
@Directive()
export class ViewComponent implements OnDestroy, OnInit {
@Input() cipherId: string;
/** Observable of cipherId$ that will update each time the `Input` updates */
private _cipherId$ = new BehaviorSubject<string>(null);
@Input()
set cipherId(value: string) {
this._cipherId$.next(value);
}
get cipherId(): string {
return this._cipherId$.getValue();
}
@Input() collectionId: string;
@Output() onEditCipher = new EventEmitter<CipherView>();
@Output() onCloneCipher = new EventEmitter<CipherView>();
@@ -126,13 +147,30 @@ export class ViewComponent implements OnDestroy, OnInit {
switch (message.command) {
case "syncCompleted":
if (message.successfully) {
await this.load();
this.changeDetectorRef.detectChanges();
}
break;
}
});
});
// Set up the subscription to the activeAccount$ and cipherId$ observables
combineLatest([this.accountService.activeAccount$.pipe(getUserId), this._cipherId$])
.pipe(
tap(() => this.cleanUp()),
switchMap(([userId, cipherId]) => {
const cipher$ = this.cipherService.cipherViews$(userId).pipe(
map((ciphers) => ciphers?.find((c) => c.id === cipherId)),
filter((cipher) => !!cipher),
);
return combineLatest([of(userId), cipher$]);
}),
)
.subscribe(([userId, cipher]) => {
this.cipher = cipher;
void this.constructCipherDetails(userId);
});
}
ngOnDestroy() {
@@ -140,70 +178,6 @@ export class ViewComponent implements OnDestroy, OnInit {
this.cleanUp();
}
async load() {
this.cleanUp();
// Grab individual cipher from `cipherViews$` for the most up-to-date information
const activeUserId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId));
this.cipher = await firstValueFrom(
this.cipherService.cipherViews$(activeUserId).pipe(
map((ciphers) => ciphers?.find((c) => c.id === this.cipherId)),
filter((cipher) => !!cipher),
),
);
this.canAccessPremium = await firstValueFrom(
this.billingAccountProfileStateService.hasPremiumFromAnySource$(activeUserId),
);
this.showPremiumRequiredTotp =
this.cipher.login.totp && !this.canAccessPremium && !this.cipher.organizationUseTotp;
this.canDeleteCipher$ = this.cipherAuthorizationService.canDeleteCipher$(this.cipher, [
this.collectionId as CollectionId,
]);
this.canRestoreCipher$ = this.cipherAuthorizationService.canRestoreCipher$(this.cipher);
if (this.cipher.folderId) {
this.folder = await (
await firstValueFrom(this.folderService.folderViews$(activeUserId))
).find((f) => f.id == this.cipher.folderId);
}
const canGenerateTotp =
this.cipher.type === CipherType.Login &&
this.cipher.login.totp &&
(this.cipher.organizationUseTotp || this.canAccessPremium);
this.totpInfo$ = canGenerateTotp
? this.totpService.getCode$(this.cipher.login.totp).pipe(
map((response) => {
const epoch = Math.round(new Date().getTime() / 1000.0);
const mod = epoch % response.period;
// Format code
const totpCodeFormatted =
response.code.length > 4
? `${response.code.slice(0, Math.floor(response.code.length / 2))} ${response.code.slice(Math.floor(response.code.length / 2))}`
: response.code;
return {
totpCode: response.code,
totpCodeFormatted,
totpDash: +(Math.round(((78.6 / response.period) * mod + "e+2") as any) + "e-2"),
totpSec: response.period - mod,
totpLow: response.period - mod <= 7,
} as TotpInfo;
}),
)
: undefined;
if (this.previousCipherId !== this.cipherId) {
// 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
this.eventCollectionService.collect(EventType.Cipher_ClientViewed, this.cipherId);
}
this.previousCipherId = this.cipherId;
}
async edit() {
this.onEditCipher.emit(this.cipher);
}
@@ -533,4 +507,61 @@ export class ViewComponent implements OnDestroy, OnInit {
this.showCardCode = false;
this.passwordReprompted = false;
}
/**
* When a cipher is viewed, construct all details for the view that are not directly
* available from the cipher object itself.
*/
private async constructCipherDetails(userId: UserId) {
this.canAccessPremium = await firstValueFrom(
this.billingAccountProfileStateService.hasPremiumFromAnySource$(userId),
);
this.showPremiumRequiredTotp =
this.cipher.login.totp && !this.canAccessPremium && !this.cipher.organizationUseTotp;
this.canDeleteCipher$ = this.cipherAuthorizationService.canDeleteCipher$(this.cipher, [
this.collectionId as CollectionId,
]);
this.canRestoreCipher$ = this.cipherAuthorizationService.canRestoreCipher$(this.cipher);
if (this.cipher.folderId) {
this.folder = await (
await firstValueFrom(this.folderService.folderViews$(userId))
).find((f) => f.id == this.cipher.folderId);
}
const canGenerateTotp =
this.cipher.type === CipherType.Login &&
this.cipher.login.totp &&
(this.cipher.organizationUseTotp || this.canAccessPremium);
this.totpInfo$ = canGenerateTotp
? this.totpService.getCode$(this.cipher.login.totp).pipe(
map((response) => {
const epoch = Math.round(new Date().getTime() / 1000.0);
const mod = epoch % response.period;
// Format code
const totpCodeFormatted =
response.code.length > 4
? `${response.code.slice(0, Math.floor(response.code.length / 2))} ${response.code.slice(Math.floor(response.code.length / 2))}`
: response.code;
return {
totpCode: response.code,
totpCodeFormatted,
totpDash: +(Math.round(((78.6 / response.period) * mod + "e+2") as any) + "e-2"),
totpSec: response.period - mod,
totpLow: response.period - mod <= 7,
} as TotpInfo;
}),
)
: undefined;
if (this.previousCipherId !== this.cipherId) {
// 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
this.eventCollectionService.collect(EventType.Cipher_ClientViewed, this.cipherId);
}
this.previousCipherId = this.cipherId;
}
}

View File

@@ -153,14 +153,14 @@ export class DefaultNotificationsService implements NotificationsServiceAbstract
await this.syncService.syncUpsertCipher(
notification.payload as SyncCipherNotification,
notification.type === NotificationType.SyncCipherUpdate,
payloadUserId,
userId,
);
break;
case NotificationType.SyncCipherDelete:
case NotificationType.SyncLoginDelete:
await this.syncService.syncDeleteCipher(
notification.payload as SyncCipherNotification,
payloadUserId,
userId,
);
break;
case NotificationType.SyncFolderCreate:

View File

@@ -1,17 +1,6 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import {
combineLatest,
filter,
firstValueFrom,
map,
merge,
Observable,
of,
shareReplay,
Subject,
switchMap,
} from "rxjs";
import { combineLatest, filter, firstValueFrom, map, Observable, Subject, switchMap } from "rxjs";
import { SemVer } from "semver";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
@@ -40,6 +29,7 @@ import { SymmetricCryptoKey } from "../../platform/models/domain/symmetric-crypt
import { StateProvider } from "../../platform/state";
import { CipherId, CollectionId, OrganizationId, UserId } from "../../types/guid";
import { OrgKey, UserKey } from "../../types/key";
import { perUserCache$ } from "../../vault/utils/observable-utilities";
import { CipherEncryptionService } from "../abstractions/cipher-encryption.service";
import { CipherService as CipherServiceAbstraction } from "../abstractions/cipher.service";
import { CipherFileUploadService } from "../abstractions/file-upload/cipher-file-upload.service";
@@ -92,11 +82,12 @@ export class CipherService implements CipherServiceAbstraction {
this.sortCiphersByLastUsed,
);
/**
* Observable that forces the `cipherViews$` observable to re-emit with the provided value.
* Used to let subscribers of `cipherViews$` know that the decrypted ciphers have been cleared for the active user.
* Observable that forces the `cipherViews$` observable for the given user to emit a null value.
* Used to let subscribers of `cipherViews$` know that the decrypted ciphers have been cleared for the user and to
* clear them from the shareReplay buffer created in perUserCache$().
* @private
*/
private forceCipherViews$: Subject<CipherView[]> = new Subject<CipherView[]>();
private clearCipherViewsForUser$: Subject<UserId> = new Subject<UserId>();
constructor(
private keyService: KeyService,
@@ -134,13 +125,16 @@ export class CipherService implements CipherServiceAbstraction {
* A `null` value indicates that the latest encrypted ciphers have not been decrypted yet and that
* decryption is in progress. The latest decrypted ciphers will be emitted once decryption is complete.
*/
cipherViews$(userId: UserId): Observable<CipherView[] | null> {
return combineLatest([this.encryptedCiphersState(userId).state$, this.localData$(userId)]).pipe(
filter(([ciphers]) => ciphers != null), // Skip if ciphers haven't been loaded yor synced yet
switchMap(() => merge(this.forceCipherViews$, this.getAllDecrypted(userId))),
shareReplay({ bufferSize: 1, refCount: true }),
cipherViews$ = perUserCache$((userId: UserId): Observable<CipherView[] | null> => {
return combineLatest([
this.encryptedCiphersState(userId).state$,
this.localData$(userId),
this.keyService.cipherDecryptionKeys$(userId, true),
]).pipe(
filter(([ciphers, keys]) => ciphers != null && keys != null), // Skip if ciphers haven't been loaded yor synced yet
switchMap(() => this.getAllDecrypted(userId)),
);
}
}, this.clearCipherViewsForUser$);
addEditCipherInfo$(userId: UserId): Observable<AddEditCipherInfo> {
return this.addEditCipherInfoState(userId).state$;
@@ -151,13 +145,11 @@ export class CipherService implements CipherServiceAbstraction {
*
* An empty array indicates that all ciphers were successfully decrypted.
*/
failedToDecryptCiphers$(userId: UserId): Observable<CipherView[]> {
failedToDecryptCiphers$ = perUserCache$((userId: UserId): Observable<CipherView[]> => {
return this.failedToDecryptCiphersState(userId).state$.pipe(
filter((ciphers) => ciphers != null),
switchMap((ciphers) => merge(this.forceCipherViews$, of(ciphers))),
shareReplay({ bufferSize: 1, refCount: true }),
);
}
}, this.clearCipherViewsForUser$);
async setDecryptedCipherCache(value: CipherView[], userId: UserId) {
// Sometimes we might prematurely decrypt the vault and that will result in no ciphers
@@ -192,10 +184,8 @@ export class CipherService implements CipherServiceAbstraction {
userId ??= activeUserId;
await this.clearDecryptedCiphersState(userId);
// Force the cipherView$ observable (which always tracks the active user) to re-emit
if (userId == activeUserId) {
this.forceCipherViews$.next(null);
}
// Force the cached cipherView$ observable(s) to emit a null value
this.clearCipherViewsForUser$.next(userId);
}
async encrypt(
@@ -404,10 +394,14 @@ export class CipherService implements CipherServiceAbstraction {
return await this.getDecryptedCiphers(userId);
}
const [newDecCiphers, failedCiphers] = await this.decryptCiphers(
await this.getAll(userId),
userId,
);
const decrypted = await this.decryptCiphers(await this.getAll(userId), userId);
// We failed to decrypt, return empty array but do not cache
if (decrypted == null) {
return [];
}
const [newDecCiphers, failedCiphers] = decrypted;
await this.setDecryptedCipherCache(newDecCiphers, userId);
await this.setFailedDecryptedCiphers(failedCiphers, userId);
@@ -431,14 +425,14 @@ export class CipherService implements CipherServiceAbstraction {
private async decryptCiphers(
ciphers: Cipher[],
userId: UserId,
): Promise<[CipherView[], CipherView[]]> {
): Promise<[CipherView[], CipherView[]] | null> {
if (await this.configService.getFeatureFlag(FeatureFlag.PM19941MigrateCipherDomainToSdk)) {
return this.decryptCiphersWithSdk(ciphers, userId);
} else {
const keys = await firstValueFrom(this.keyService.cipherDecryptionKeys$(userId, true));
if (keys == null || (keys.userKey == null && Object.keys(keys.orgKeys).length === 0)) {
// return early if there are no keys to decrypt with
return [[], []];
return null;
}
// Group ciphers by orgId or under 'null' for the user's ciphers
const grouped = ciphers.reduce(

View File

@@ -12,8 +12,11 @@ import { MessageListener } from "@bitwarden/common/platform/messaging";
import { NotificationsService } from "@bitwarden/common/platform/notifications";
import { StateProvider } from "@bitwarden/common/platform/state";
import { SecurityTaskId, UserId } from "@bitwarden/common/types/guid";
import {
filterOutNullish,
perUserCache$,
} from "@bitwarden/common/vault/utils/observable-utilities";
import { filterOutNullish, perUserCache$ } from "../../utils/observable-utilities";
import { TaskService } from "../abstractions/task.service";
import { SecurityTaskStatus } from "../enums";
import { SecurityTask, SecurityTaskData, SecurityTaskResponse } from "../models";

View File

@@ -1,20 +1,38 @@
import { filter, Observable, OperatorFunction, shareReplay } from "rxjs";
import { EMPTY, filter, map, merge, Observable, OperatorFunction, shareReplay } from "rxjs";
import { UserId } from "@bitwarden/common/types/guid";
/**
* Builds an observable once per userId and caches it for future requests.
* The built observables are shared among subscribers with a replay buffer size of 1.
*
* Optionally, a clearBuffer$ observable can be provided to clear the replay buffer for a specific or all userIds.
* @param create - A function that creates an observable for a given userId.
* @param clearBuffer$ - An observable that, when emitted, clears the buffer for the emitted userId. When null is emitted, all caches are cleared.
*/
export function perUserCache$<TValue>(
create: (userId: UserId) => Observable<TValue>,
): (userId: UserId) => Observable<TValue> {
const cache = new Map<UserId, Observable<TValue>>();
clearBuffer$: Observable<UserId | null>,
): (userId: UserId) => Observable<TValue | null>;
export function perUserCache$<TValue>(
create: (userId: UserId) => Observable<TValue>,
): (userId: UserId) => Observable<TValue>;
export function perUserCache$<TValue>(
create: (userId: UserId) => Observable<TValue>,
clearBuffer$: Observable<UserId | null> | undefined = undefined,
): (userId: UserId) => Observable<TValue | null> {
const cache = new Map<UserId, Observable<TValue | null>>();
return (userId: UserId) => {
let observable = cache.get(userId);
if (!observable) {
observable = create(userId).pipe(shareReplay({ bufferSize: 1, refCount: false }));
clearBuffer$ ??= EMPTY;
observable = merge(
create(userId),
clearBuffer$.pipe(
filter((clearId) => clearId === userId || clearId === null),
map(() => null),
),
).pipe(shareReplay({ bufferSize: 1, refCount: false }));
cache.set(userId, observable);
}
return observable;

10
nx.json Normal file
View File

@@ -0,0 +1,10 @@
{
"cacheDirectory": ".nx/cache",
"defaultBase": "main",
"namedInputs": {
"default": ["{projectRoot}/**/*"],
"production": ["!{projectRoot}/**/*.spec.ts"]
},
"parallel": 4,
"targetDefaults": {}
}

528
package-lock.json generated
View File

@@ -156,6 +156,7 @@
"json5": "2.2.3",
"lint-staged": "15.4.1",
"mini-css-extract-plugin": "2.9.2",
"nx": "20.8.0",
"postcss": "8.5.1",
"postcss-loader": "8.1.1",
"prettier": "3.4.2",
@@ -5954,6 +5955,34 @@
"node": "*"
}
},
"node_modules/@emnapi/core": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.4.1.tgz",
"integrity": "sha512-4JFstCTaToCFrPqrGzgkF8N2NHjtsaY4uRh6brZQ5L9e4wbMieX8oDT8N7qfVFTQecHFEtkj4ve49VIZ3mKVqw==",
"dev": true,
"dependencies": {
"@emnapi/wasi-threads": "1.0.1",
"tslib": "^2.4.0"
}
},
"node_modules/@emnapi/runtime": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.4.1.tgz",
"integrity": "sha512-LMshMVP0ZhACNjQNYXiU1iZJ6QCcv0lUdPDPugqGvCGXt5xtRVBPdtA0qU12pEXZzpWAhWlZYptfdAFq10DOVQ==",
"dev": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@emnapi/wasi-threads": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.0.1.tgz",
"integrity": "sha512-iIBu7mwkq4UQGeMEM8bLwNK962nXdhodeScX4slfQnRhEMMzvYivHhutCIk8uojvmASXXPC2WNEjwxFWk72Oqw==",
"dev": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@emotion/babel-plugin": {
"version": "11.13.5",
"resolved": "https://registry.npmjs.org/@emotion/babel-plugin/-/babel-plugin-11.13.5.tgz",
@@ -8223,6 +8252,17 @@
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@napi-rs/wasm-runtime": {
"version": "0.2.4",
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.4.tgz",
"integrity": "sha512-9zESzOO5aDByvhIAsOy9TbpZ0Ur2AJbUI7UT73kcUTS2mxAMHOBaa1st/jAymNoCtvrit99kkzT1FZuXVcgfIQ==",
"dev": true,
"dependencies": {
"@emnapi/core": "^1.1.0",
"@emnapi/runtime": "^1.1.0",
"@tybys/wasm-util": "^0.9.0"
}
},
"node_modules/@ng-select/ng-select": {
"version": "13.9.1",
"resolved": "https://registry.npmjs.org/@ng-select/ng-select/-/ng-select-13.9.1.tgz",
@@ -8758,6 +8798,166 @@
"node": "^16.13.0 || >=18.0.0"
}
},
"node_modules/@nx/nx-darwin-arm64": {
"version": "20.8.0",
"resolved": "https://registry.npmjs.org/@nx/nx-darwin-arm64/-/nx-darwin-arm64-20.8.0.tgz",
"integrity": "sha512-A6Te2KlINtcOo/depXJzPyjbk9E0cmgbom/sm/49XdQ8G94aDfyIIY1RIdwmDCK5NVd74KFG3JIByTk5+VnAhA==",
"cpu": [
"arm64"
],
"dev": true,
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@nx/nx-darwin-x64": {
"version": "20.8.0",
"resolved": "https://registry.npmjs.org/@nx/nx-darwin-x64/-/nx-darwin-x64-20.8.0.tgz",
"integrity": "sha512-UpqayUjgalArXaDvOoshqSelTrEp42cGDsZGy0sqpxwBpm3oPQ8wE1d7oBAmRo208rAxOuFP0LZRFUqRrwGvLA==",
"cpu": [
"x64"
],
"dev": true,
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@nx/nx-freebsd-x64": {
"version": "20.8.0",
"resolved": "https://registry.npmjs.org/@nx/nx-freebsd-x64/-/nx-freebsd-x64-20.8.0.tgz",
"integrity": "sha512-dUR2fsLyKZYMHByvjy2zvmdMbsdXAiP+6uTlIAuu8eHMZ2FPQCAtt7lPYLwOFUxUXChbek2AJ+uCI0gRAgK/eg==",
"cpu": [
"x64"
],
"dev": true,
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@nx/nx-linux-arm-gnueabihf": {
"version": "20.8.0",
"resolved": "https://registry.npmjs.org/@nx/nx-linux-arm-gnueabihf/-/nx-linux-arm-gnueabihf-20.8.0.tgz",
"integrity": "sha512-GuZ7t0SzSX5ksLYva7koKZovQ5h/Kr1pFbOsQcBf3VLREBqFPSz6t7CVYpsIsMhiu/I3EKq6FZI3wDOJbee5uw==",
"cpu": [
"arm"
],
"dev": true,
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@nx/nx-linux-arm64-gnu": {
"version": "20.8.0",
"resolved": "https://registry.npmjs.org/@nx/nx-linux-arm64-gnu/-/nx-linux-arm64-gnu-20.8.0.tgz",
"integrity": "sha512-CiI955Q+XZmBBZ7cQqQg0MhGEFwZIgSpJnjPfWBt3iOYP8aE6nZpNOkmD7O8XcN/nEwwyeCOF8euXqEStwsk8w==",
"cpu": [
"arm64"
],
"dev": true,
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@nx/nx-linux-arm64-musl": {
"version": "20.8.0",
"resolved": "https://registry.npmjs.org/@nx/nx-linux-arm64-musl/-/nx-linux-arm64-musl-20.8.0.tgz",
"integrity": "sha512-Iy9DpvVisxsfNh4gOinmMQ4cLWdBlgvt1wmry1UwvcXg479p1oJQ1Kp1wksUZoWYqrAG8VPZUmkE0f7gjyHTGg==",
"cpu": [
"arm64"
],
"dev": true,
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@nx/nx-linux-x64-gnu": {
"version": "20.8.0",
"resolved": "https://registry.npmjs.org/@nx/nx-linux-x64-gnu/-/nx-linux-x64-gnu-20.8.0.tgz",
"integrity": "sha512-kZrrXXzVSbqwmdTmQ9xL4Jhi0/FSLrePSxYCL9oOM3Rsj0lmo/aC9kz4NBv1ZzuqT7fumpBOnhqiL1QyhOWOeQ==",
"cpu": [
"x64"
],
"dev": true,
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@nx/nx-linux-x64-musl": {
"version": "20.8.0",
"resolved": "https://registry.npmjs.org/@nx/nx-linux-x64-musl/-/nx-linux-x64-musl-20.8.0.tgz",
"integrity": "sha512-0l9jEMN8NhULKYCFiDF7QVpMMNG40duya+OF8dH0OzFj52N0zTsvsgLY72TIhslCB/cC74oAzsmWEIiFslscnA==",
"cpu": [
"x64"
],
"dev": true,
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@nx/nx-win32-arm64-msvc": {
"version": "20.8.0",
"resolved": "https://registry.npmjs.org/@nx/nx-win32-arm64-msvc/-/nx-win32-arm64-msvc-20.8.0.tgz",
"integrity": "sha512-5miZJmRSwx1jybBsiB3NGocXL9TxGdT2D+dOqR2fsLklpGz0ItEWm8+i8lhDjgOdAr2nFcuQUfQMY57f9FOHrA==",
"cpu": [
"arm64"
],
"dev": true,
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@nx/nx-win32-x64-msvc": {
"version": "20.8.0",
"resolved": "https://registry.npmjs.org/@nx/nx-win32-x64-msvc/-/nx-win32-x64-msvc-20.8.0.tgz",
"integrity": "sha512-0P5r+bDuSNvoWys+6C1/KqGpYlqwSHpigCcyRzR62iZpT3OooZv+nWO06RlURkxMR8LNvYXTSSLvoLkjxqM8uQ==",
"cpu": [
"x64"
],
"dev": true,
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@parcel/watcher": {
"version": "2.5.1",
"resolved": "https://registry.npmjs.org/@parcel/watcher/-/watcher-2.5.1.tgz",
@@ -10856,6 +11056,15 @@
"node": "^16.14.0 || >=18.0.0"
}
},
"node_modules/@tybys/wasm-util": {
"version": "0.9.0",
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.9.0.tgz",
"integrity": "sha512-6+7nlbMVX/PVDCwaIQ8nTOPveOcFLSt8GcXdx8hD0bt39uWxYT88uXzqTd4fTvqta7oeUJqudepapKNt2DYJFw==",
"dev": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@types/accepts": {
"version": "1.3.7",
"resolved": "https://registry.npmjs.org/@types/accepts/-/accepts-1.3.7.tgz",
@@ -13196,6 +13405,59 @@
"integrity": "sha512-GpSwvyXOcOOlV70vbnzjj4fW5xW/FdUF6nQEt1ENy7m4ZCczi1+/buVUPAqmGfqznsORNFzUMjctTIp8a9tuCQ==",
"license": "BSD-2-Clause"
},
"node_modules/@yarnpkg/parsers": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/@yarnpkg/parsers/-/parsers-3.0.2.tgz",
"integrity": "sha512-/HcYgtUSiJiot/XWGLOlGxPYUG65+/31V8oqk17vZLW1xlCoR4PampyePljOxY2n8/3jz9+tIFzICsyGujJZoA==",
"dev": true,
"dependencies": {
"js-yaml": "^3.10.0",
"tslib": "^2.4.0"
},
"engines": {
"node": ">=18.12.0"
}
},
"node_modules/@yarnpkg/parsers/node_modules/argparse": {
"version": "1.0.10",
"resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz",
"integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==",
"dev": true,
"dependencies": {
"sprintf-js": "~1.0.2"
}
},
"node_modules/@yarnpkg/parsers/node_modules/js-yaml": {
"version": "3.14.1",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.1.tgz",
"integrity": "sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g==",
"dev": true,
"dependencies": {
"argparse": "^1.0.7",
"esprima": "^4.0.0"
},
"bin": {
"js-yaml": "bin/js-yaml.js"
}
},
"node_modules/@yarnpkg/parsers/node_modules/sprintf-js": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz",
"integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==",
"dev": true
},
"node_modules/@zkochan/js-yaml": {
"version": "0.0.7",
"resolved": "https://registry.npmjs.org/@zkochan/js-yaml/-/js-yaml-0.0.7.tgz",
"integrity": "sha512-nrUSn7hzt7J6JWgWGz78ZYI8wj+gdIJdk0Ynjpp8l+trkn58Uqsf6RYrYkEK+3X18EX+TNdtJI0WxAtc+L84SQ==",
"dev": true,
"dependencies": {
"argparse": "^2.0.1"
},
"bin": {
"js-yaml": "bin/js-yaml.js"
}
},
"node_modules/7zip-bin": {
"version": "5.2.0",
"resolved": "https://registry.npmjs.org/7zip-bin/-/7zip-bin-5.2.0.tgz",
@@ -14298,11 +14560,10 @@
}
},
"node_modules/axios": {
"version": "1.7.9",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.7.9.tgz",
"integrity": "sha512-LhLcE7Hbiryz8oMDdDptSrWowmB4Bl6RCt6sIJKpRB4XtVf0iEgewX3au/pJqm+Py1kCASkb/FFKjxQaLtxJvw==",
"version": "1.8.4",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.8.4.tgz",
"integrity": "sha512-eBSYY4Y68NNlHbHBMdeDmKNtDgXWhQsJcGqzO3iLUM0GraQFSS9cVgPX5I9b3lbdFKyYoAEGAZF1DwhTaljNAw==",
"dev": true,
"license": "MIT",
"dependencies": {
"follow-redirects": "^1.15.6",
"form-data": "^4.0.0",
@@ -18077,6 +18338,18 @@
"node": ">=10.13.0"
}
},
"node_modules/enquirer": {
"version": "2.3.6",
"resolved": "https://registry.npmjs.org/enquirer/-/enquirer-2.3.6.tgz",
"integrity": "sha512-yjNnPr315/FjS4zIsUxYguYUPP2e1NK4d7E7ZOLiyYCcbFBiTMyID+2wvm2w6+pZ/odMA7cRkjhsPbltwBOrLg==",
"dev": true,
"dependencies": {
"ansi-colors": "^4.1.1"
},
"engines": {
"node": ">=8.6"
}
},
"node_modules/entities": {
"version": "4.5.0",
"resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz",
@@ -20259,6 +20532,43 @@
"safe-buffer": "~5.1.0"
}
},
"node_modules/front-matter": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/front-matter/-/front-matter-4.0.2.tgz",
"integrity": "sha512-I8ZuJ/qG92NWX8i5x1Y8qyj3vizhXS31OxjKDu3LKP+7/qBgfIKValiZIEwoVoJKUHlhWtYrktkxV1XsX+pPlg==",
"dev": true,
"dependencies": {
"js-yaml": "^3.13.1"
}
},
"node_modules/front-matter/node_modules/argparse": {
"version": "1.0.10",
"resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz",
"integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==",
"dev": true,
"dependencies": {
"sprintf-js": "~1.0.2"
}
},
"node_modules/front-matter/node_modules/js-yaml": {
"version": "3.14.1",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.1.tgz",
"integrity": "sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g==",
"dev": true,
"dependencies": {
"argparse": "^1.0.7",
"esprima": "^4.0.0"
},
"bin": {
"js-yaml": "bin/js-yaml.js"
}
},
"node_modules/front-matter/node_modules/sprintf-js": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz",
"integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==",
"dev": true
},
"node_modules/fs-constants": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz",
@@ -28045,6 +28355,12 @@
"license": "MIT",
"peer": true
},
"node_modules/node-machine-id": {
"version": "1.1.12",
"resolved": "https://registry.npmjs.org/node-machine-id/-/node-machine-id-1.1.12.tgz",
"integrity": "sha512-QNABxbrPa3qEIfrE6GOJ7BYIuignnJw7iQ2YPbc3Nla1HzRJjXzZOiikfF8m7eAMfichLt3M4VgLOetqgDmgGQ==",
"dev": true
},
"node_modules/node-releases": {
"version": "2.0.19",
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.19.tgz",
@@ -28513,6 +28829,209 @@
"integrity": "sha512-F1I/bimDpj3ncaNDhfyMWuFqmQDBwDB0Fogc2qpL3BWvkQteFD/8BzWuIRl83rq0DXfm8SGt/HFhLXZyljTXcQ==",
"license": "MIT"
},
"node_modules/nx": {
"version": "20.8.0",
"resolved": "https://registry.npmjs.org/nx/-/nx-20.8.0.tgz",
"integrity": "sha512-+BN5B5DFBB5WswD8flDDTnr4/bf1VTySXOv60aUAllHqR+KS6deT0p70TTMZF4/A2n/L2UCWDaDro37MGaYozA==",
"dev": true,
"hasInstallScript": true,
"dependencies": {
"@napi-rs/wasm-runtime": "0.2.4",
"@yarnpkg/lockfile": "^1.1.0",
"@yarnpkg/parsers": "3.0.2",
"@zkochan/js-yaml": "0.0.7",
"axios": "^1.8.3",
"chalk": "^4.1.0",
"cli-cursor": "3.1.0",
"cli-spinners": "2.6.1",
"cliui": "^8.0.1",
"dotenv": "~16.4.5",
"dotenv-expand": "~11.0.6",
"enquirer": "~2.3.6",
"figures": "3.2.0",
"flat": "^5.0.2",
"front-matter": "^4.0.2",
"ignore": "^5.0.4",
"jest-diff": "^29.4.1",
"jsonc-parser": "3.2.0",
"lines-and-columns": "2.0.3",
"minimatch": "9.0.3",
"node-machine-id": "1.1.12",
"npm-run-path": "^4.0.1",
"open": "^8.4.0",
"ora": "5.3.0",
"resolve.exports": "2.0.3",
"semver": "^7.5.3",
"string-width": "^4.2.3",
"tar-stream": "~2.2.0",
"tmp": "~0.2.1",
"tsconfig-paths": "^4.1.2",
"tslib": "^2.3.0",
"yaml": "^2.6.0",
"yargs": "^17.6.2",
"yargs-parser": "21.1.1"
},
"bin": {
"nx": "bin/nx.js",
"nx-cloud": "bin/nx-cloud.js"
},
"optionalDependencies": {
"@nx/nx-darwin-arm64": "20.8.0",
"@nx/nx-darwin-x64": "20.8.0",
"@nx/nx-freebsd-x64": "20.8.0",
"@nx/nx-linux-arm-gnueabihf": "20.8.0",
"@nx/nx-linux-arm64-gnu": "20.8.0",
"@nx/nx-linux-arm64-musl": "20.8.0",
"@nx/nx-linux-x64-gnu": "20.8.0",
"@nx/nx-linux-x64-musl": "20.8.0",
"@nx/nx-win32-arm64-msvc": "20.8.0",
"@nx/nx-win32-x64-msvc": "20.8.0"
},
"peerDependencies": {
"@swc-node/register": "^1.8.0",
"@swc/core": "^1.3.85"
},
"peerDependenciesMeta": {
"@swc-node/register": {
"optional": true
},
"@swc/core": {
"optional": true
}
}
},
"node_modules/nx/node_modules/cli-spinners": {
"version": "2.6.1",
"resolved": "https://registry.npmjs.org/cli-spinners/-/cli-spinners-2.6.1.tgz",
"integrity": "sha512-x/5fWmGMnbKQAaNwN+UZlV79qBLM9JFnJuJ03gIi5whrob0xV0ofNVHy9DhwGdsMJQc2OKv0oGmLzvaqvAVv+g==",
"dev": true,
"engines": {
"node": ">=6"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/nx/node_modules/dotenv": {
"version": "16.4.7",
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.4.7.tgz",
"integrity": "sha512-47qPchRCykZC03FhkYAhrvwU4xDBFIj1QPqaarj6mdM/hgUzfPHcpkHJOn3mJAufFeeAxAzeGsr5X0M4k6fLZQ==",
"dev": true,
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://dotenvx.com"
}
},
"node_modules/nx/node_modules/dotenv-expand": {
"version": "11.0.7",
"resolved": "https://registry.npmjs.org/dotenv-expand/-/dotenv-expand-11.0.7.tgz",
"integrity": "sha512-zIHwmZPRshsCdpMDyVsqGmgyP0yT8GAgXUnkdAoJisxvf33k7yO6OuoKmcTGuXPWSsm8Oh88nZicRLA9Y0rUeA==",
"dev": true,
"dependencies": {
"dotenv": "^16.4.5"
},
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://dotenvx.com"
}
},
"node_modules/nx/node_modules/ignore": {
"version": "5.3.2",
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
"integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==",
"dev": true,
"engines": {
"node": ">= 4"
}
},
"node_modules/nx/node_modules/jsonc-parser": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/jsonc-parser/-/jsonc-parser-3.2.0.tgz",
"integrity": "sha512-gfFQZrcTc8CnKXp6Y4/CBT3fTc0OVuDofpre4aEeEpSBPV5X5v4+Vmx+8snU7RLPrNHPKSgLxGo9YuQzz20o+w==",
"dev": true
},
"node_modules/nx/node_modules/lines-and-columns": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-2.0.3.tgz",
"integrity": "sha512-cNOjgCnLB+FnvWWtyRTzmB3POJ+cXxTA81LoW7u8JdmhfXzriropYwpjShnz1QLLWsQwY7nIxoDmcPTwphDK9w==",
"dev": true,
"engines": {
"node": "^12.20.0 || ^14.13.1 || >=16.0.0"
}
},
"node_modules/nx/node_modules/minimatch": {
"version": "9.0.3",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.3.tgz",
"integrity": "sha512-RHiac9mvaRw0x3AYRgDC1CxAP7HTcNrrECeA8YYJeWnpo+2Q5CegtZjaotWTWxDG3UeGA1coE05iH1mPjT/2mg==",
"dev": true,
"dependencies": {
"brace-expansion": "^2.0.1"
},
"engines": {
"node": ">=16 || 14 >=14.17"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/nx/node_modules/ora": {
"version": "5.3.0",
"resolved": "https://registry.npmjs.org/ora/-/ora-5.3.0.tgz",
"integrity": "sha512-zAKMgGXUim0Jyd6CXK9lraBnD3H5yPGBPPOkC23a2BG6hsm4Zu6OQSjQuEtV0BHDf4aKHcUFvJiGRrFuW3MG8g==",
"dev": true,
"dependencies": {
"bl": "^4.0.3",
"chalk": "^4.1.0",
"cli-cursor": "^3.1.0",
"cli-spinners": "^2.5.0",
"is-interactive": "^1.0.0",
"log-symbols": "^4.0.0",
"strip-ansi": "^6.0.0",
"wcwidth": "^1.0.1"
},
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/nx/node_modules/strip-bom": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-3.0.0.tgz",
"integrity": "sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==",
"dev": true,
"engines": {
"node": ">=4"
}
},
"node_modules/nx/node_modules/tmp": {
"version": "0.2.3",
"resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.3.tgz",
"integrity": "sha512-nZD7m9iCPC5g0pYmcaxogYKggSfLsdxl8of3Q/oIbqCqLLIO9IAF0GWjX1z9NZRHPiXv8Wex4yDCaZsgEw0Y8w==",
"dev": true,
"engines": {
"node": ">=14.14"
}
},
"node_modules/nx/node_modules/tsconfig-paths": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-4.2.0.tgz",
"integrity": "sha512-NoZ4roiN7LnbKn9QqE1amc9DJfzvZXxF4xDavcOWt1BPkdx+m+0gJuPM+S0vCe7zTJMYUP0R8pO2XMr+Y8oLIg==",
"dev": true,
"dependencies": {
"json5": "^2.2.2",
"minimist": "^1.2.6",
"strip-bom": "^3.0.0"
},
"engines": {
"node": ">=6"
}
},
"node_modules/object-assign": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
@@ -31294,7 +31813,6 @@
"integrity": "sha512-OcXjMsGdhL4XnbShKpAcSqPMzQoYkYyhbEaeSko47MjRP9NfEQMhZkXL1DoFlt9LWQn4YttrdnV6X2OiyzBi+A==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=10"
}

View File

@@ -118,6 +118,7 @@
"json5": "2.2.3",
"lint-staged": "15.4.1",
"mini-css-extract-plugin": "2.9.2",
"nx": "20.8.0",
"postcss": "8.5.1",
"postcss-loader": "8.1.1",
"prettier": "3.4.2",