diff --git a/apps/browser/src/background/main.background.ts b/apps/browser/src/background/main.background.ts index cfb44c6c36c0..c3ecb5d3fe73 100644 --- a/apps/browser/src/background/main.background.ts +++ b/apps/browser/src/background/main.background.ts @@ -632,6 +632,7 @@ export default class MainBackground { this.stateService, this.keyGenerationService, this.encryptService, + this.logService, ); this.i18nService = new I18nService(BrowserApi.getUILanguage(), this.globalStateProvider); diff --git a/apps/cli/src/auth/commands/unlock.command.ts b/apps/cli/src/auth/commands/unlock.command.ts index 9d7f7771dbc8..3389d022e5e9 100644 --- a/apps/cli/src/auth/commands/unlock.command.ts +++ b/apps/cli/src/auth/commands/unlock.command.ts @@ -68,7 +68,7 @@ export class UnlockCommand { return Response.error(e.message); } - const userKey = await this.masterPasswordService.decryptUserKeyWithMasterKey(masterKey); + const userKey = await this.masterPasswordService.decryptUserKeyWithMasterKey(masterKey, userId); await this.keyService.setUserKey(userKey, userId); if (await this.keyConnectorService.getConvertAccountRequired()) { diff --git a/apps/cli/src/service-container/service-container.ts b/apps/cli/src/service-container/service-container.ts index 8d1ad6b2e770..ae627e82e75f 100644 --- a/apps/cli/src/service-container/service-container.ts +++ b/apps/cli/src/service-container/service-container.ts @@ -404,6 +404,7 @@ export class ServiceContainer { this.stateService, this.keyGenerationService, this.encryptService, + this.logService, ); this.kdfConfigService = new KdfConfigService(this.stateProvider); diff --git a/apps/desktop/desktop_native/core/src/ipc/mod.rs b/apps/desktop/desktop_native/core/src/ipc/mod.rs index c7ac1a434046..d406b6aa137b 100644 --- a/apps/desktop/desktop_native/core/src/ipc/mod.rs +++ b/apps/desktop/desktop_native/core/src/ipc/mod.rs @@ -31,7 +31,7 @@ pub fn path(name: &str) -> std::path::PathBuf { format!(r"\\.\pipe\{hash_b64}.app.{name}").into() } - #[cfg(target_os = "macos")] + #[cfg(all(target_os = "macos", not(debug_assertions)))] { let mut home = dirs::home_dir().unwrap(); @@ -53,6 +53,13 @@ pub fn path(name: &str) -> std::path::PathBuf { tmp.join(format!("app.{name}")) } + #[cfg(all(target_os = "macos", debug_assertions))] + { + // When running in debug mode, we use the tmp dir because the app is not sandboxed + let dir = std::env::temp_dir(); + dir.join(format!("app.{name}")) + } + #[cfg(target_os = "linux")] { // On Linux, we use the user's cache directory. diff --git a/apps/desktop/src/app/accounts/settings.component.ts b/apps/desktop/src/app/accounts/settings.component.ts index 572dbc5e8be3..0c5fa187cb68 100644 --- a/apps/desktop/src/app/accounts/settings.component.ts +++ b/apps/desktop/src/app/accounts/settings.component.ts @@ -626,7 +626,8 @@ export class SettingsComponent implements OnInit, OnDestroy { async saveBrowserIntegration() { if ( ipc.platform.deviceType === DeviceType.MacOsDesktop && - !this.platformUtilsService.isMacAppStore() + !this.platformUtilsService.isMacAppStore() && + !ipc.platform.isDev ) { await this.dialogService.openSimpleDialog({ title: { key: "browserIntegrationUnsupportedTitle" }, diff --git a/apps/desktop/src/main/native-messaging.main.ts b/apps/desktop/src/main/native-messaging.main.ts index 9c9f1ae6a9b8..e383c1e1d3a0 100644 --- a/apps/desktop/src/main/native-messaging.main.ts +++ b/apps/desktop/src/main/native-messaging.main.ts @@ -132,18 +132,7 @@ export class NativeMessagingMain { }; const chromeJson = { ...baseJson, - ...{ - allowed_origins: [ - // Chrome extension - "chrome-extension://nngceckbapebfimnlniiiahkandclblb/", - // Chrome beta extension - "chrome-extension://hccnnhgbibccigepcmlgppchkpfdophk/", - // Edge extension - "chrome-extension://jbkfoedolllekgbhcbcoahefnbanhhlh/", - // Opera extension - "chrome-extension://ccnckbpmaceehanjmeomladnmlffdjgn/", - ], - }, + allowed_origins: await this.loadChromeIds(), }; switch (process.platform) { @@ -180,35 +169,26 @@ export class NativeMessagingMain { } break; } - case "linux": - if (existsSync(`${this.homedir()}/.mozilla/`)) { - await this.writeManifest( - `${this.homedir()}/.mozilla/native-messaging-hosts/com.8bit.bitwarden.json`, - firefoxJson, - ); - } - - if (existsSync(`${this.homedir()}/.config/google-chrome/`)) { - await this.writeManifest( - `${this.homedir()}/.config/google-chrome/NativeMessagingHosts/com.8bit.bitwarden.json`, - chromeJson, - ); - } - - if (existsSync(`${this.homedir()}/.config/microsoft-edge/`)) { - await this.writeManifest( - `${this.homedir()}/.config/microsoft-edge/NativeMessagingHosts/com.8bit.bitwarden.json`, - chromeJson, - ); - } - - if (existsSync(`${this.homedir()}/.config/chromium/`)) { - await this.writeManifest( - `${this.homedir()}/.config/chromium/NativeMessagingHosts/com.8bit.bitwarden.json`, - chromeJson, - ); + case "linux": { + for (const [key, value] of Object.entries(this.getLinuxNMHS())) { + if (existsSync(value)) { + if (key === "Firefox") { + await this.writeManifest( + path.join(value, "native-messaging-hosts", "com.8bit.bitwarden.json"), + firefoxJson, + ); + } else { + await this.writeManifest( + path.join(value, "NativeMessagingHosts", "com.8bit.bitwarden.json"), + chromeJson, + ); + } + } else { + this.logService.warning(`${key} not found, skipping.`); + } } break; + } default: break; } @@ -260,15 +240,18 @@ export class NativeMessagingMain { break; } case "linux": { - await this.removeIfExists( - `${this.homedir()}/.mozilla/native-messaging-hosts/com.8bit.bitwarden.json`, - ); - await this.removeIfExists( - `${this.homedir()}/.config/google-chrome/NativeMessagingHosts/com.8bit.bitwarden.json`, - ); - await this.removeIfExists( - `${this.homedir()}/.config/microsoft-edge/NativeMessagingHosts/com.8bit.bitwarden.json`, - ); + for (const [key, value] of Object.entries(this.getLinuxNMHS())) { + if (key === "Firefox") { + await this.removeIfExists( + path.join(value, "native-messaging-hosts", "com.8bit.bitwarden.json"), + ); + } else { + await this.removeIfExists( + path.join(value, "NativeMessagingHosts", "com.8bit.bitwarden.json"), + ); + } + } + break; } default: @@ -317,6 +300,15 @@ export class NativeMessagingMain { /* eslint-enable no-useless-escape */ } + private getLinuxNMHS() { + return { + Firefox: `${this.homedir()}/.mozilla/`, + Chrome: `${this.homedir()}/.config/google-chrome/`, + Chromium: `${this.homedir()}/.config/chromium/`, + "Microsoft Edge": `${this.homedir()}/.config/microsoft-edge/`, + }; + } + private async writeManifest(destination: string, manifest: object) { this.logService.debug(`Writing manifest: ${destination}`); @@ -327,6 +319,83 @@ export class NativeMessagingMain { await fs.writeFile(destination, JSON.stringify(manifest, null, 2)); } + private async loadChromeIds(): Promise { + const ids: Set = new Set([ + // Chrome extension + "chrome-extension://nngceckbapebfimnlniiiahkandclblb/", + // Chrome beta extension + "chrome-extension://hccnnhgbibccigepcmlgppchkpfdophk/", + // Edge extension + "chrome-extension://jbkfoedolllekgbhcbcoahefnbanhhlh/", + // Opera extension + "chrome-extension://ccnckbpmaceehanjmeomladnmlffdjgn/", + ]); + + if (!isDev()) { + return Array.from(ids); + } + + // The dev builds of the extension have a different random ID per user, so to make development easier + // we try to find the extension IDs from the user's Chrome profiles when we're running in dev mode. + let chromePaths: string[]; + switch (process.platform) { + case "darwin": { + chromePaths = Object.entries(this.getDarwinNMHS()) + .filter(([key]) => key !== "Firefox") + .map(([, value]) => value); + break; + } + case "linux": { + chromePaths = Object.entries(this.getLinuxNMHS()) + .filter(([key]) => key !== "Firefox") + .map(([, value]) => value); + break; + } + case "win32": { + // TODO: Add more supported browsers for Windows? + chromePaths = [ + path.join(process.env.LOCALAPPDATA, "Microsoft", "Edge", "User Data"), + path.join(process.env.LOCALAPPDATA, "Google", "Chrome", "User Data"), + ]; + break; + } + } + + for (const chromePath of chromePaths) { + try { + // The chrome profile directories are named "Default", "Profile 1", "Profile 2", etc. + const profiles = (await fs.readdir(chromePath)).filter((f) => { + const lower = f.toLowerCase(); + return lower == "default" || lower.startsWith("profile "); + }); + + for (const profile of profiles) { + try { + // Read the profile Preferences file and find the extension commands section + const prefs = JSON.parse( + await fs.readFile(path.join(chromePath, profile, "Preferences"), "utf8"), + ); + const commands: Map = prefs.extensions.commands; + + // If one of the commands is autofill_login or generate_password, we know it's probably the Bitwarden extension + for (const { command_name, extension } of Object.values(commands)) { + if (command_name === "autofill_login" || command_name === "generate_password") { + ids.add(`chrome-extension://${extension}/`); + this.logService.info(`Found extension from ${chromePath}: ${extension}`); + } + } + } catch (e) { + this.logService.info(`Error reading preferences: ${e}`); + } + } + } catch (e) { + // Browser is not installed, we can just skip it + } + } + + return Array.from(ids); + } + private binaryPath() { const ext = process.platform === "win32" ? ".exe" : ""; diff --git a/apps/web/src/app/auth/settings/change-password.component.ts b/apps/web/src/app/auth/settings/change-password.component.ts index 3406c2d5b619..f5f3e80b6bbf 100644 --- a/apps/web/src/app/auth/settings/change-password.component.ts +++ b/apps/web/src/app/auth/settings/change-password.component.ts @@ -194,7 +194,7 @@ export class ChangePasswordComponent HashPurpose.LocalAuthorization, ); - const userKey = await this.masterPasswordService.decryptUserKeyWithMasterKey(masterKey); + const userKey = await this.masterPasswordService.decryptUserKeyWithMasterKey(masterKey, userId); if (userKey == null) { this.toastService.showToast({ variant: "error", diff --git a/libs/angular/src/auth/components/lock.component.ts b/libs/angular/src/auth/components/lock.component.ts index bc9c667bc877..ce4100298537 100644 --- a/libs/angular/src/auth/components/lock.component.ts +++ b/libs/angular/src/auth/components/lock.component.ts @@ -267,6 +267,7 @@ export class LockComponent implements OnInit, OnDestroy { const userKey = await this.masterPasswordService.decryptUserKeyWithMasterKey( response.masterKey, + userId, ); await this.setUserKeyAndContinue(userKey, userId, true); } diff --git a/libs/angular/src/services/jslib-services.module.ts b/libs/angular/src/services/jslib-services.module.ts index 5d8822866d69..5bf81761ed64 100644 --- a/libs/angular/src/services/jslib-services.module.ts +++ b/libs/angular/src/services/jslib-services.module.ts @@ -921,7 +921,13 @@ const safeProviders: SafeProvider[] = [ safeProvider({ provide: InternalMasterPasswordServiceAbstraction, useClass: MasterPasswordService, - deps: [StateProvider, StateServiceAbstraction, KeyGenerationServiceAbstraction, EncryptService], + deps: [ + StateProvider, + StateServiceAbstraction, + KeyGenerationServiceAbstraction, + EncryptService, + LogService, + ], }), safeProvider({ provide: MasterPasswordServiceAbstraction, diff --git a/libs/auth/src/angular/lock/lock.component.ts b/libs/auth/src/angular/lock/lock.component.ts index 3d4bf51e8042..94c226f3f677 100644 --- a/libs/auth/src/angular/lock/lock.component.ts +++ b/libs/auth/src/angular/lock/lock.component.ts @@ -483,6 +483,7 @@ export class LockV2Component implements OnInit, OnDestroy { const userKey = await this.masterPasswordService.decryptUserKeyWithMasterKey( masterPasswordVerificationResponse.masterKey, + this.activeAccount.id, ); await this.setUserKeyAndContinue(userKey, true); } diff --git a/libs/auth/src/common/login-strategies/auth-request-login.strategy.ts b/libs/auth/src/common/login-strategies/auth-request-login.strategy.ts index 3f7e107fa98c..a3e2fda2f28e 100644 --- a/libs/auth/src/common/login-strategies/auth-request-login.strategy.ts +++ b/libs/auth/src/common/login-strategies/auth-request-login.strategy.ts @@ -114,7 +114,10 @@ export class AuthRequestLoginStrategy extends LoginStrategy { private async trySetUserKeyWithMasterKey(userId: UserId): Promise { const masterKey = await firstValueFrom(this.masterPasswordService.masterKey$(userId)); if (masterKey) { - const userKey = await this.masterPasswordService.decryptUserKeyWithMasterKey(masterKey); + const userKey = await this.masterPasswordService.decryptUserKeyWithMasterKey( + masterKey, + userId, + ); await this.keyService.setUserKey(userKey, userId); } } diff --git a/libs/auth/src/common/login-strategies/password-login.strategy.ts b/libs/auth/src/common/login-strategies/password-login.strategy.ts index 55e869e8229c..05faef1ba144 100644 --- a/libs/auth/src/common/login-strategies/password-login.strategy.ts +++ b/libs/auth/src/common/login-strategies/password-login.strategy.ts @@ -183,7 +183,10 @@ export class PasswordLoginStrategy extends LoginStrategy { const masterKey = await firstValueFrom(this.masterPasswordService.masterKey$(userId)); if (masterKey) { - const userKey = await this.masterPasswordService.decryptUserKeyWithMasterKey(masterKey); + const userKey = await this.masterPasswordService.decryptUserKeyWithMasterKey( + masterKey, + userId, + ); await this.keyService.setUserKey(userKey, userId); } } diff --git a/libs/auth/src/common/login-strategies/sso-login.strategy.spec.ts b/libs/auth/src/common/login-strategies/sso-login.strategy.spec.ts index d9827c2e2871..7b5ad4a31b67 100644 --- a/libs/auth/src/common/login-strategies/sso-login.strategy.spec.ts +++ b/libs/auth/src/common/login-strategies/sso-login.strategy.spec.ts @@ -496,7 +496,7 @@ describe("SsoLoginStrategy", () => { expect(masterPasswordService.mock.decryptUserKeyWithMasterKey).toHaveBeenCalledWith( masterKey, - undefined, + userId, undefined, ); expect(keyService.setUserKey).toHaveBeenCalledWith(userKey, userId); @@ -552,7 +552,7 @@ describe("SsoLoginStrategy", () => { expect(masterPasswordService.mock.decryptUserKeyWithMasterKey).toHaveBeenCalledWith( masterKey, - undefined, + userId, undefined, ); expect(keyService.setUserKey).toHaveBeenCalledWith(userKey, userId); diff --git a/libs/auth/src/common/login-strategies/sso-login.strategy.ts b/libs/auth/src/common/login-strategies/sso-login.strategy.ts index d2660eef8a21..b1dffea9b506 100644 --- a/libs/auth/src/common/login-strategies/sso-login.strategy.ts +++ b/libs/auth/src/common/login-strategies/sso-login.strategy.ts @@ -338,7 +338,7 @@ export class SsoLoginStrategy extends LoginStrategy { return; } - const userKey = await this.masterPasswordService.decryptUserKeyWithMasterKey(masterKey); + const userKey = await this.masterPasswordService.decryptUserKeyWithMasterKey(masterKey, userId); await this.keyService.setUserKey(userKey, userId); } diff --git a/libs/auth/src/common/login-strategies/user-api-login.strategy.spec.ts b/libs/auth/src/common/login-strategies/user-api-login.strategy.spec.ts index 14fafcb58c35..07d06a7567df 100644 --- a/libs/auth/src/common/login-strategies/user-api-login.strategy.spec.ts +++ b/libs/auth/src/common/login-strategies/user-api-login.strategy.spec.ts @@ -213,7 +213,7 @@ describe("UserApiLoginStrategy", () => { expect(masterPasswordService.mock.decryptUserKeyWithMasterKey).toHaveBeenCalledWith( masterKey, - undefined, + userId, undefined, ); expect(keyService.setUserKey).toHaveBeenCalledWith(userKey, userId); diff --git a/libs/auth/src/common/login-strategies/user-api-login.strategy.ts b/libs/auth/src/common/login-strategies/user-api-login.strategy.ts index 4ae95fdbc703..1097e8e04c27 100644 --- a/libs/auth/src/common/login-strategies/user-api-login.strategy.ts +++ b/libs/auth/src/common/login-strategies/user-api-login.strategy.ts @@ -69,7 +69,10 @@ export class UserApiLoginStrategy extends LoginStrategy { if (response.apiUseKeyConnector) { const masterKey = await firstValueFrom(this.masterPasswordService.masterKey$(userId)); if (masterKey) { - const userKey = await this.masterPasswordService.decryptUserKeyWithMasterKey(masterKey); + const userKey = await this.masterPasswordService.decryptUserKeyWithMasterKey( + masterKey, + userId, + ); await this.keyService.setUserKey(userKey, userId); } } diff --git a/libs/auth/src/common/services/auth-request/auth-request.service.spec.ts b/libs/auth/src/common/services/auth-request/auth-request.service.spec.ts index a4f1d5d97242..86b2a1dd3b64 100644 --- a/libs/auth/src/common/services/auth-request/auth-request.service.spec.ts +++ b/libs/auth/src/common/services/auth-request/auth-request.service.spec.ts @@ -200,7 +200,7 @@ describe("AuthRequestService", () => { ); expect(masterPasswordService.mock.decryptUserKeyWithMasterKey).toHaveBeenCalledWith( mockDecryptedMasterKey, - undefined, + mockUserId, undefined, ); expect(keyService.setUserKey).toHaveBeenCalledWith(mockDecryptedUserKey, mockUserId); diff --git a/libs/auth/src/common/services/auth-request/auth-request.service.ts b/libs/auth/src/common/services/auth-request/auth-request.service.ts index 0e416a4a2554..b6a7bfb26b9f 100644 --- a/libs/auth/src/common/services/auth-request/auth-request.service.ts +++ b/libs/auth/src/common/services/auth-request/auth-request.service.ts @@ -150,7 +150,7 @@ export class AuthRequestService implements AuthRequestServiceAbstraction { ); // Decrypt and set user key in state - const userKey = await this.masterPasswordService.decryptUserKeyWithMasterKey(masterKey); + const userKey = await this.masterPasswordService.decryptUserKeyWithMasterKey(masterKey, userId); // Set masterKey + masterKeyHash in state after decryption (in case decryption fails) await this.masterPasswordService.setMasterKey(masterKey, userId); diff --git a/libs/auth/src/common/services/pin/pin.service.implementation.ts b/libs/auth/src/common/services/pin/pin.service.implementation.ts index 39bb80e0b73f..2a01802fa57a 100644 --- a/libs/auth/src/common/services/pin/pin.service.implementation.ts +++ b/libs/auth/src/common/services/pin/pin.service.implementation.ts @@ -418,6 +418,7 @@ export class PinService implements PinServiceAbstraction { const userKey = await this.masterPasswordService.decryptUserKeyWithMasterKey( masterKey, + userId, encUserKey ? new EncString(encUserKey) : undefined, ); diff --git a/libs/common/src/auth/abstractions/master-password.service.abstraction.ts b/libs/common/src/auth/abstractions/master-password.service.abstraction.ts index bd4d73a0f220..c3a0f135a06f 100644 --- a/libs/common/src/auth/abstractions/master-password.service.abstraction.ts +++ b/libs/common/src/auth/abstractions/master-password.service.abstraction.ts @@ -33,16 +33,16 @@ export abstract class MasterPasswordServiceAbstraction { /** * Decrypts the user key with the provided master key * @param masterKey The user's master key + * * @param userId The desired user * @param userKey The user's encrypted symmetric key - * @param userId The desired user * @throws If either the MasterKey or UserKey are not resolved, or if the UserKey encryption type * is neither AesCbc256_B64 nor AesCbc256_HmacSha256_B64 * @returns The user key */ abstract decryptUserKeyWithMasterKey: ( masterKey: MasterKey, + userId: string, userKey?: EncString, - userId?: string, ) => Promise; } diff --git a/libs/common/src/auth/services/master-password/fake-master-password.service.ts b/libs/common/src/auth/services/master-password/fake-master-password.service.ts index f57614f5d511..0357018e6152 100644 --- a/libs/common/src/auth/services/master-password/fake-master-password.service.ts +++ b/libs/common/src/auth/services/master-password/fake-master-password.service.ts @@ -64,9 +64,9 @@ export class FakeMasterPasswordService implements InternalMasterPasswordServiceA decryptUserKeyWithMasterKey( masterKey: MasterKey, + userId: string, userKey?: EncString, - userId?: string, ): Promise { - return this.mock.decryptUserKeyWithMasterKey(masterKey, userKey, userId); + return this.mock.decryptUserKeyWithMasterKey(masterKey, userId, userKey); } } diff --git a/libs/common/src/auth/services/master-password/master-password.service.ts b/libs/common/src/auth/services/master-password/master-password.service.ts index e20c8c00e6e0..3a565e1c7864 100644 --- a/libs/common/src/auth/services/master-password/master-password.service.ts +++ b/libs/common/src/auth/services/master-password/master-password.service.ts @@ -1,5 +1,7 @@ import { firstValueFrom, map, Observable } from "rxjs"; +import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; + import { EncryptService } from "../../../platform/abstractions/encrypt.service"; import { KeyGenerationService } from "../../../platform/abstractions/key-generation.service"; import { StateService } from "../../../platform/abstractions/state.service"; @@ -55,6 +57,7 @@ export class MasterPasswordService implements InternalMasterPasswordServiceAbstr private stateService: StateService, private keyGenerationService: KeyGenerationService, private encryptService: EncryptService, + private logService: LogService, ) {} masterKey$(userId: UserId): Observable { @@ -149,10 +152,9 @@ export class MasterPasswordService implements InternalMasterPasswordServiceAbstr async decryptUserKeyWithMasterKey( masterKey: MasterKey, + userId: UserId, userKey?: EncString, - userId?: UserId, ): Promise { - userId ??= await firstValueFrom(this.stateProvider.activeUserId$); userKey ??= await this.getMasterKeyEncryptedUserKey(userId); masterKey ??= await firstValueFrom(this.masterKey$(userId)); @@ -185,6 +187,7 @@ export class MasterPasswordService implements InternalMasterPasswordServiceAbstr } if (decUserKey == null) { + this.logService.warning("Failed to decrypt user key with master key."); return null; } diff --git a/libs/common/src/platform/misc/index.ts b/libs/common/src/platform/misc/index.ts new file mode 100644 index 000000000000..56fc18c28260 --- /dev/null +++ b/libs/common/src/platform/misc/index.ts @@ -0,0 +1 @@ +export * from "./rxjs-operators"; diff --git a/libs/common/src/platform/misc/rxjs-operators.spec.ts b/libs/common/src/platform/misc/rxjs-operators.spec.ts new file mode 100644 index 000000000000..c9ec2c091ef5 --- /dev/null +++ b/libs/common/src/platform/misc/rxjs-operators.spec.ts @@ -0,0 +1,58 @@ +import { firstValueFrom, of } from "rxjs"; + +import { getById, getByIds } from "./rxjs-operators"; + +describe("custom rxjs operators", () => { + describe("getById", () => { + it("returns an object with a matching id", async () => { + const obs = of([ + { + id: 1, + data: "one", + }, + { + id: 2, + data: "two", + }, + { + id: 3, + data: "three", + }, + ]).pipe(getById(2)); + + const result = await firstValueFrom(obs); + + expect(result).toEqual({ id: 2, data: "two" }); + }); + }); + + describe("getByIds", () => { + it("returns an array of objects with matching ids", async () => { + const obs = of([ + { + id: 1, + data: "one", + }, + { + id: 2, + data: "two", + }, + { + id: 3, + data: "three", + }, + { + id: 4, + data: "four", + }, + ]).pipe(getByIds([2, 3])); + + const result = await firstValueFrom(obs); + + expect(result).toEqual([ + { id: 2, data: "two" }, + { id: 3, data: "three" }, + ]); + }); + }); +}); diff --git a/libs/common/src/platform/misc/rxjs-operators.ts b/libs/common/src/platform/misc/rxjs-operators.ts new file mode 100644 index 000000000000..689b928cd29f --- /dev/null +++ b/libs/common/src/platform/misc/rxjs-operators.ts @@ -0,0 +1,21 @@ +import { map } from "rxjs"; + +/** + * An rxjs operator that extracts an object by ID from an array of objects. + * @param id The ID of the object to return. + * @returns The first object with a matching ID, or undefined if no matching object is present. + */ +export const getById = (id: TId) => + map((objects) => objects.find((o) => o.id === id)); + +/** + * An rxjs operator that extracts a subset of objects by their IDs from an array of objects. + * @param id The IDs of the objects to return. + * @returns An array containing objects with matching IDs, or an empty array if there are no matching objects. + */ +export const getByIds = (ids: TId[]) => { + const idSet = new Set(ids); + return map((objects) => { + return objects.filter((o) => idSet.has(o.id)); + }); +};