From af5f45443d870cf6012c3bdcdb6180786c951b30 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Carlos=20Gon=C3=A7alves?= Date: Tue, 2 Apr 2024 16:23:05 +0100 Subject: [PATCH 1/5] [PM-5434] Create VaultBrowserStateService and migrate components from BrowserStateService (#8017) * PM-5434 Initial work on migration * PM-5434 Migration and tests * PM-5434 Remove unnecessary comments * PM-5434 Add unit tests * PM-5434 Reverted last changes * PM-5434 Added unit test for deserialize * PM-5434 Minor changes * PM-5434 Fix pr comments --- .../abstractions/browser-state.service.ts | 13 --- .../services/browser-state.service.spec.ts | 22 ----- .../services/browser-state.service.ts | 45 ---------- apps/browser/src/popup/app.component.ts | 6 +- .../src/popup/services/services.module.ts | 8 ++ .../vault/vault-filter.component.ts | 12 +-- .../components/vault/vault-items.component.ts | 4 +- .../vault-browser-state.service.spec.ts | 87 +++++++++++++++++++ .../services/vault-browser-state.service.ts | 65 ++++++++++++++ .../src/platform/state/state-definitions.ts | 1 + 10 files changed, 173 insertions(+), 90 deletions(-) create mode 100644 apps/browser/src/vault/services/vault-browser-state.service.spec.ts create mode 100644 apps/browser/src/vault/services/vault-browser-state.service.ts diff --git a/apps/browser/src/platform/services/abstractions/browser-state.service.ts b/apps/browser/src/platform/services/abstractions/browser-state.service.ts index 88c2312762..82ec54975a 100644 --- a/apps/browser/src/platform/services/abstractions/browser-state.service.ts +++ b/apps/browser/src/platform/services/abstractions/browser-state.service.ts @@ -3,22 +3,9 @@ import { StorageOptions } from "@bitwarden/common/platform/models/domain/storage import { Account } from "../../../models/account"; import { BrowserComponentState } from "../../../models/browserComponentState"; -import { BrowserGroupingsComponentState } from "../../../models/browserGroupingsComponentState"; import { BrowserSendComponentState } from "../../../models/browserSendComponentState"; export abstract class BrowserStateService extends BaseStateServiceAbstraction { - getBrowserGroupingComponentState: ( - options?: StorageOptions, - ) => Promise; - setBrowserGroupingComponentState: ( - value: BrowserGroupingsComponentState, - options?: StorageOptions, - ) => Promise; - getBrowserVaultItemsComponentState: (options?: StorageOptions) => Promise; - setBrowserVaultItemsComponentState: ( - value: BrowserComponentState, - options?: StorageOptions, - ) => Promise; getBrowserSendComponentState: (options?: StorageOptions) => Promise; setBrowserSendComponentState: ( value: BrowserSendComponentState, diff --git a/apps/browser/src/platform/services/browser-state.service.spec.ts b/apps/browser/src/platform/services/browser-state.service.spec.ts index 3069b8f174..7e75b9b707 100644 --- a/apps/browser/src/platform/services/browser-state.service.spec.ts +++ b/apps/browser/src/platform/services/browser-state.service.spec.ts @@ -18,7 +18,6 @@ import { UserId } from "@bitwarden/common/types/guid"; import { Account } from "../../models/account"; import { BrowserComponentState } from "../../models/browserComponentState"; -import { BrowserGroupingsComponentState } from "../../models/browserGroupingsComponentState"; import { BrowserSendComponentState } from "../../models/browserSendComponentState"; import { BrowserStateService } from "./browser-state.service"; @@ -86,27 +85,6 @@ describe("Browser State Service", () => { ); }); - describe("getBrowserGroupingComponentState", () => { - it("should return a BrowserGroupingsComponentState", async () => { - state.accounts[userId].groupings = new BrowserGroupingsComponentState(); - - const actual = await sut.getBrowserGroupingComponentState(); - expect(actual).toBeInstanceOf(BrowserGroupingsComponentState); - }); - }); - - describe("getBrowserVaultItemsComponentState", () => { - it("should return a BrowserComponentState", async () => { - const componentState = new BrowserComponentState(); - componentState.scrollY = 0; - componentState.searchText = "test"; - state.accounts[userId].ciphers = componentState; - - const actual = await sut.getBrowserVaultItemsComponentState(); - expect(actual).toStrictEqual(componentState); - }); - }); - describe("getBrowserSendComponentState", () => { it("should return a BrowserSendComponentState", async () => { const sendState = new BrowserSendComponentState(); diff --git a/apps/browser/src/platform/services/browser-state.service.ts b/apps/browser/src/platform/services/browser-state.service.ts index f7ee74be21..ea410ee83a 100644 --- a/apps/browser/src/platform/services/browser-state.service.ts +++ b/apps/browser/src/platform/services/browser-state.service.ts @@ -16,7 +16,6 @@ import { StateService as BaseStateService } from "@bitwarden/common/platform/ser import { Account } from "../../models/account"; import { BrowserComponentState } from "../../models/browserComponentState"; -import { BrowserGroupingsComponentState } from "../../models/browserGroupingsComponentState"; import { BrowserSendComponentState } from "../../models/browserSendComponentState"; import { BrowserApi } from "../browser/browser-api"; import { browserSession, sessionSync } from "../decorators/session-sync-observable"; @@ -116,50 +115,6 @@ export class BrowserStateService ); } - async getBrowserGroupingComponentState( - options?: StorageOptions, - ): Promise { - return ( - await this.getAccount(this.reconcileOptions(options, await this.defaultInMemoryOptions())) - )?.groupings; - } - - async setBrowserGroupingComponentState( - value: BrowserGroupingsComponentState, - options?: StorageOptions, - ): Promise { - const account = await this.getAccount( - this.reconcileOptions(options, await this.defaultInMemoryOptions()), - ); - account.groupings = value; - await this.saveAccount( - account, - this.reconcileOptions(options, await this.defaultInMemoryOptions()), - ); - } - - async getBrowserVaultItemsComponentState( - options?: StorageOptions, - ): Promise { - return ( - await this.getAccount(this.reconcileOptions(options, await this.defaultInMemoryOptions())) - )?.ciphers; - } - - async setBrowserVaultItemsComponentState( - value: BrowserComponentState, - options?: StorageOptions, - ): Promise { - const account = await this.getAccount( - this.reconcileOptions(options, await this.defaultInMemoryOptions()), - ); - account.ciphers = value; - await this.saveAccount( - account, - this.reconcileOptions(options, await this.defaultInMemoryOptions()), - ); - } - async getBrowserSendComponentState(options?: StorageOptions): Promise { return ( await this.getAccount(this.reconcileOptions(options, await this.defaultInMemoryOptions())) diff --git a/apps/browser/src/popup/app.component.ts b/apps/browser/src/popup/app.component.ts index 9aa438d3b3..e0d898481b 100644 --- a/apps/browser/src/popup/app.component.ts +++ b/apps/browser/src/popup/app.component.ts @@ -12,6 +12,7 @@ import { BrowserApi } from "../platform/browser/browser-api"; import { ZonedMessageListenerService } from "../platform/browser/zoned-message-listener.service"; import { BrowserStateService } from "../platform/services/abstractions/browser-state.service"; import { ForegroundPlatformUtilsService } from "../platform/services/platform-utils/foreground-platform-utils.service"; +import { VaultBrowserStateService } from "../vault/services/vault-browser-state.service"; import { routerTransition } from "./app-routing.animations"; import { DesktopSyncVerificationDialogComponent } from "./components/desktop-sync-verification-dialog.component"; @@ -37,6 +38,7 @@ export class AppComponent implements OnInit, OnDestroy { private i18nService: I18nService, private router: Router, private stateService: BrowserStateService, + private vaultBrowserStateService: VaultBrowserStateService, private changeDetectorRef: ChangeDetectorRef, private ngZone: NgZone, private platformUtilsService: ForegroundPlatformUtilsService, @@ -227,8 +229,8 @@ export class AppComponent implements OnInit, OnDestroy { } await Promise.all([ - this.stateService.setBrowserGroupingComponentState(null), - this.stateService.setBrowserVaultItemsComponentState(null), + this.vaultBrowserStateService.setBrowserGroupingsComponentState(null), + this.vaultBrowserStateService.setBrowserVaultItemsComponentState(null), this.stateService.setBrowserSendComponentState(null), this.stateService.setBrowserSendTypeComponentState(null), ]); diff --git a/apps/browser/src/popup/services/services.module.ts b/apps/browser/src/popup/services/services.module.ts index fbeabca462..6d0f73f206 100644 --- a/apps/browser/src/popup/services/services.module.ts +++ b/apps/browser/src/popup/services/services.module.ts @@ -102,6 +102,7 @@ import { ForegroundPlatformUtilsService } from "../../platform/services/platform import { ForegroundDerivedStateProvider } from "../../platform/state/foreground-derived-state.provider"; import { ForegroundMemoryStorageService } from "../../platform/storage/foreground-memory-storage.service"; import { FilePopoutUtilsService } from "../../tools/popup/services/file-popout-utils.service"; +import { VaultBrowserStateService } from "../../vault/services/vault-browser-state.service"; import { VaultFilterService } from "../../vault/services/vault-filter.service"; import { DebounceNavigationService } from "./debounce-navigation.service"; @@ -377,6 +378,13 @@ const safeProviders: SafeProvider[] = [ provide: OBSERVABLE_DISK_STORAGE, useExisting: AbstractStorageService, }), + safeProvider({ + provide: VaultBrowserStateService, + useFactory: (stateProvider: StateProvider) => { + return new VaultBrowserStateService(stateProvider); + }, + deps: [StateProvider], + }), safeProvider({ provide: StateServiceAbstraction, useFactory: ( diff --git a/apps/browser/src/vault/popup/components/vault/vault-filter.component.ts b/apps/browser/src/vault/popup/components/vault/vault-filter.component.ts index 5e7959b38f..2510e2f966 100644 --- a/apps/browser/src/vault/popup/components/vault/vault-filter.component.ts +++ b/apps/browser/src/vault/popup/components/vault/vault-filter.component.ts @@ -20,7 +20,7 @@ import { FolderView } from "@bitwarden/common/vault/models/view/folder.view"; import { BrowserGroupingsComponentState } from "../../../../models/browserGroupingsComponentState"; import { BrowserApi } from "../../../../platform/browser/browser-api"; import BrowserPopupUtils from "../../../../platform/popup/browser-popup-utils"; -import { BrowserStateService } from "../../../../platform/services/abstractions/browser-state.service"; +import { VaultBrowserStateService } from "../../../services/vault-browser-state.service"; import { VaultFilterService } from "../../../services/vault-filter.service"; const ComponentId = "VaultComponent"; @@ -84,8 +84,8 @@ export class VaultFilterComponent implements OnInit, OnDestroy { private platformUtilsService: PlatformUtilsService, private searchService: SearchService, private location: Location, - private browserStateService: BrowserStateService, private vaultFilterService: VaultFilterService, + private vaultBrowserStateService: VaultBrowserStateService, ) { this.noFolderListSize = 100; } @@ -95,7 +95,7 @@ export class VaultFilterComponent implements OnInit, OnDestroy { this.showLeftHeader = !( BrowserPopupUtils.inSidebar(window) && this.platformUtilsService.isFirefox() ); - await this.browserStateService.setBrowserVaultItemsComponentState(null); + await this.vaultBrowserStateService.setBrowserVaultItemsComponentState(null); this.broadcasterService.subscribe(ComponentId, (message: any) => { // FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling. @@ -120,7 +120,7 @@ export class VaultFilterComponent implements OnInit, OnDestroy { const restoredScopeState = await this.restoreState(); // eslint-disable-next-line rxjs-angular/prefer-takeuntil, rxjs/no-async-subscribe this.route.queryParams.pipe(first()).subscribe(async (params) => { - this.state = await this.browserStateService.getBrowserGroupingComponentState(); + this.state = await this.vaultBrowserStateService.getBrowserGroupingsComponentState(); if (this.state?.searchText) { this.searchText = this.state.searchText; } else if (params.searchText) { @@ -413,11 +413,11 @@ export class VaultFilterComponent implements OnInit, OnDestroy { collections: this.collections, deletedCount: this.deletedCount, }); - await this.browserStateService.setBrowserGroupingComponentState(this.state); + await this.vaultBrowserStateService.setBrowserGroupingsComponentState(this.state); } private async restoreState(): Promise { - this.state = await this.browserStateService.getBrowserGroupingComponentState(); + this.state = await this.vaultBrowserStateService.getBrowserGroupingsComponentState(); if (this.state == null) { return false; } diff --git a/apps/browser/src/vault/popup/components/vault/vault-items.component.ts b/apps/browser/src/vault/popup/components/vault/vault-items.component.ts index 96d5fe170b..abb810c04d 100644 --- a/apps/browser/src/vault/popup/components/vault/vault-items.component.ts +++ b/apps/browser/src/vault/popup/components/vault/vault-items.component.ts @@ -21,7 +21,7 @@ import { FolderView } from "@bitwarden/common/vault/models/view/folder.view"; import { BrowserComponentState } from "../../../../models/browserComponentState"; import { BrowserApi } from "../../../../platform/browser/browser-api"; import BrowserPopupUtils from "../../../../platform/popup/browser-popup-utils"; -import { BrowserStateService } from "../../../../platform/services/abstractions/browser-state.service"; +import { VaultBrowserStateService } from "../../../services/vault-browser-state.service"; import { VaultFilterService } from "../../../services/vault-filter.service"; const ComponentId = "VaultItemsComponent"; @@ -59,7 +59,7 @@ export class VaultItemsComponent extends BaseVaultItemsComponent implements OnIn private ngZone: NgZone, private broadcasterService: BroadcasterService, private changeDetectorRef: ChangeDetectorRef, - private stateService: BrowserStateService, + private stateService: VaultBrowserStateService, private i18nService: I18nService, private collectionService: CollectionService, private platformUtilsService: PlatformUtilsService, diff --git a/apps/browser/src/vault/services/vault-browser-state.service.spec.ts b/apps/browser/src/vault/services/vault-browser-state.service.spec.ts new file mode 100644 index 0000000000..b9369aa826 --- /dev/null +++ b/apps/browser/src/vault/services/vault-browser-state.service.spec.ts @@ -0,0 +1,87 @@ +import { + FakeAccountService, + mockAccountServiceWith, +} from "@bitwarden/common/../spec/fake-account-service"; +import { FakeStateProvider } from "@bitwarden/common/../spec/fake-state-provider"; +import { Jsonify } from "type-fest"; + +import { Utils } from "@bitwarden/common/platform/misc/utils"; +import { UserId } from "@bitwarden/common/types/guid"; +import { CipherType } from "@bitwarden/common/vault/enums"; + +import { BrowserComponentState } from "../../models/browserComponentState"; +import { BrowserGroupingsComponentState } from "../../models/browserGroupingsComponentState"; + +import { + VAULT_BROWSER_COMPONENT, + VAULT_BROWSER_GROUPINGS_COMPONENT, + VaultBrowserStateService, +} from "./vault-browser-state.service"; + +describe("Vault Browser State Service", () => { + let stateProvider: FakeStateProvider; + + let accountService: FakeAccountService; + let stateService: VaultBrowserStateService; + const mockUserId = Utils.newGuid() as UserId; + + beforeEach(() => { + accountService = mockAccountServiceWith(mockUserId); + stateProvider = new FakeStateProvider(accountService); + + stateService = new VaultBrowserStateService(stateProvider); + }); + + describe("getBrowserGroupingsComponentState", () => { + it("should return a BrowserGroupingsComponentState", async () => { + await stateService.setBrowserGroupingsComponentState(new BrowserGroupingsComponentState()); + + const actual = await stateService.getBrowserGroupingsComponentState(); + + expect(actual).toBeInstanceOf(BrowserGroupingsComponentState); + }); + + it("should deserialize BrowserGroupingsComponentState", () => { + const sut = VAULT_BROWSER_GROUPINGS_COMPONENT; + + const expectedState = { + deletedCount: 0, + collectionCounts: new Map(), + folderCounts: new Map(), + typeCounts: new Map(), + }; + + const result = sut.deserializer( + JSON.parse(JSON.stringify(expectedState)) as Jsonify, + ); + + expect(result).toEqual(expectedState); + }); + }); + + describe("getBrowserVaultItemsComponentState", () => { + it("should deserialize BrowserComponentState", () => { + const sut = VAULT_BROWSER_COMPONENT; + + const expectedState = { + scrollY: 0, + searchText: "test", + }; + + const result = sut.deserializer(JSON.parse(JSON.stringify(expectedState))); + + expect(result).toEqual(expectedState); + }); + + it("should return a BrowserComponentState", async () => { + const componentState = new BrowserComponentState(); + componentState.scrollY = 0; + componentState.searchText = "test"; + + await stateService.setBrowserVaultItemsComponentState(componentState); + + const actual = await stateService.getBrowserVaultItemsComponentState(); + expect(actual).toStrictEqual(componentState); + }); + }); +}); diff --git a/apps/browser/src/vault/services/vault-browser-state.service.ts b/apps/browser/src/vault/services/vault-browser-state.service.ts new file mode 100644 index 0000000000..a0d55a9d55 --- /dev/null +++ b/apps/browser/src/vault/services/vault-browser-state.service.ts @@ -0,0 +1,65 @@ +import { Observable, firstValueFrom } from "rxjs"; +import { Jsonify } from "type-fest"; + +import { + ActiveUserState, + KeyDefinition, + StateProvider, + VAULT_BROWSER_MEMORY, +} from "@bitwarden/common/platform/state"; + +import { BrowserComponentState } from "../../models/browserComponentState"; +import { BrowserGroupingsComponentState } from "../../models/browserGroupingsComponentState"; + +export const VAULT_BROWSER_GROUPINGS_COMPONENT = new KeyDefinition( + VAULT_BROWSER_MEMORY, + "vault_browser_groupings_component", + { + deserializer: (obj: Jsonify) => + BrowserGroupingsComponentState.fromJSON(obj), + }, +); + +export const VAULT_BROWSER_COMPONENT = new KeyDefinition( + VAULT_BROWSER_MEMORY, + "vault_browser_component", + { + deserializer: (obj: Jsonify) => BrowserComponentState.fromJSON(obj), + }, +); + +export class VaultBrowserStateService { + vaultBrowserGroupingsComponentState$: Observable; + vaultBrowserComponentState$: Observable; + + private activeUserVaultBrowserGroupingsComponentState: ActiveUserState; + private activeUserVaultBrowserComponentState: ActiveUserState; + + constructor(protected stateProvider: StateProvider) { + this.activeUserVaultBrowserGroupingsComponentState = this.stateProvider.getActive( + VAULT_BROWSER_GROUPINGS_COMPONENT, + ); + this.activeUserVaultBrowserComponentState = + this.stateProvider.getActive(VAULT_BROWSER_COMPONENT); + + this.vaultBrowserGroupingsComponentState$ = + this.activeUserVaultBrowserGroupingsComponentState.state$; + this.vaultBrowserComponentState$ = this.activeUserVaultBrowserComponentState.state$; + } + + async getBrowserGroupingsComponentState(): Promise { + return await firstValueFrom(this.vaultBrowserGroupingsComponentState$); + } + + async setBrowserGroupingsComponentState(value: BrowserGroupingsComponentState): Promise { + await this.activeUserVaultBrowserGroupingsComponentState.update(() => value); + } + + async getBrowserVaultItemsComponentState(): Promise { + return await firstValueFrom(this.vaultBrowserComponentState$); + } + + async setBrowserVaultItemsComponentState(value: BrowserComponentState): Promise { + await this.activeUserVaultBrowserComponentState.update(() => value); + } +} diff --git a/libs/common/src/platform/state/state-definitions.ts b/libs/common/src/platform/state/state-definitions.ts index 466c3a2c11..9fca0e9445 100644 --- a/libs/common/src/platform/state/state-definitions.ts +++ b/libs/common/src/platform/state/state-definitions.ts @@ -118,3 +118,4 @@ export const VAULT_ONBOARDING = new StateDefinition("vaultOnboarding", "disk", { export const VAULT_SETTINGS_DISK = new StateDefinition("vaultSettings", "disk", { web: "disk-local", }); +export const VAULT_BROWSER_MEMORY = new StateDefinition("vaultBrowser", "memory"); From 2e6d977ef1aec00423b709de01c9c75cbec14655 Mon Sep 17 00:00:00 2001 From: Jake Fink Date: Tue, 2 Apr 2024 11:23:35 -0400 Subject: [PATCH 2/5] init observable on service (#8577) --- .../auth-request/auth-request.service.spec.ts | 17 +++++++++++++++++ .../auth-request/auth-request.service.ts | 4 +++- 2 files changed, 20 insertions(+), 1 deletion(-) 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 b1971f6b52..80d00b2a01 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 @@ -2,6 +2,7 @@ import { mock } from "jest-mock-extended"; import { ApiService } from "@bitwarden/common/abstractions/api.service"; import { AuthRequestResponse } from "@bitwarden/common/auth/models/response/auth-request.response"; +import { AuthRequestPushNotification } from "@bitwarden/common/models/response/notification.response"; import { AppIdService } from "@bitwarden/common/platform/abstractions/app-id.service"; import { CryptoService } from "@bitwarden/common/platform/abstractions/crypto.service"; import { StateService } from "@bitwarden/common/platform/abstractions/state.service"; @@ -30,6 +31,22 @@ describe("AuthRequestService", () => { mockPrivateKey = new Uint8Array(64); }); + describe("authRequestPushNotification$", () => { + it("should emit when sendAuthRequestPushNotification is called", () => { + const notification = { + id: "PUSH_NOTIFICATION", + userId: "USER_ID", + } as AuthRequestPushNotification; + + const spy = jest.fn(); + sut.authRequestPushNotification$.subscribe(spy); + + sut.sendAuthRequestPushNotification(notification); + + expect(spy).toHaveBeenCalledWith("PUSH_NOTIFICATION"); + }); + }); + describe("approveOrDenyAuthRequest", () => { beforeEach(() => { cryptoService.rsaEncrypt.mockResolvedValue({ 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 ff33eadfba..eb39659f53 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 @@ -22,7 +22,9 @@ export class AuthRequestService implements AuthRequestServiceAbstraction { private cryptoService: CryptoService, private apiService: ApiService, private stateService: StateService, - ) {} + ) { + this.authRequestPushNotification$ = this.authRequestPushNotificationSubject.asObservable(); + } async approveOrDenyAuthRequest( approve: boolean, From b9771c1e426746a202a4633089ce638fba10886a Mon Sep 17 00:00:00 2001 From: Cesar Gonzalez Date: Tue, 2 Apr 2024 10:24:16 -0500 Subject: [PATCH 3/5] [PM-5584] Set up a stay alive method to allow service worker in manifest v3 to stay alive indefinitely (#8535) --- apps/browser/src/platform/background.ts | 65 +++++++++++-------------- 1 file changed, 29 insertions(+), 36 deletions(-) diff --git a/apps/browser/src/platform/background.ts b/apps/browser/src/platform/background.ts index 5aa2820e5f..9c3510178c 100644 --- a/apps/browser/src/platform/background.ts +++ b/apps/browser/src/platform/background.ts @@ -1,42 +1,35 @@ +import { ConsoleLogService } from "@bitwarden/common/platform/services/console-log.service"; + import MainBackground from "../background/main.background"; -import { onAlarmListener } from "./alarms/on-alarm-listener"; -import { registerAlarms } from "./alarms/register-alarms"; import { BrowserApi } from "./browser/browser-api"; -import { - contextMenusClickedListener, - onCommandListener, - onInstallListener, - runtimeMessageListener, - windowsOnFocusChangedListener, - tabsOnActivatedListener, - tabsOnReplacedListener, - tabsOnUpdatedListener, -} from "./listeners"; -if (BrowserApi.isManifestVersion(3)) { - chrome.commands.onCommand.addListener(onCommandListener); - chrome.runtime.onInstalled.addListener(onInstallListener); - chrome.alarms.onAlarm.addListener(onAlarmListener); - registerAlarms(); - chrome.windows.onFocusChanged.addListener(windowsOnFocusChangedListener); - chrome.tabs.onActivated.addListener(tabsOnActivatedListener); - chrome.tabs.onReplaced.addListener(tabsOnReplacedListener); - chrome.tabs.onUpdated.addListener(tabsOnUpdatedListener); - chrome.contextMenus.onClicked.addListener(contextMenusClickedListener); - BrowserApi.messageListener( - "runtime.background", - (message: { command: string }, sender, sendResponse) => { - // 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 - runtimeMessageListener(message, sender); - }, - ); -} else { - const bitwardenMain = ((self as any).bitwardenMain = new MainBackground()); - // 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 - bitwardenMain.bootstrap().then(() => { +const logService = new ConsoleLogService(false); +const bitwardenMain = ((self as any).bitwardenMain = new MainBackground()); +bitwardenMain + .bootstrap() + .then(() => { // Finished bootstrapping - }); + if (BrowserApi.isManifestVersion(3)) { + startHeartbeat().catch((error) => logService.error(error)); + } + }) + .catch((error) => logService.error(error)); + +/** + * Tracks when a service worker was last alive and extends the service worker + * lifetime by writing the current time to extension storage every 20 seconds. + */ +async function runHeartbeat() { + await chrome.storage.local.set({ "last-heartbeat": new Date().getTime() }); +} + +/** + * Starts the heartbeat interval which keeps the service worker alive. + */ +async function startHeartbeat() { + // Run the heartbeat once at service worker startup, then again every 20 seconds. + runHeartbeat() + .then(() => setInterval(runHeartbeat, 20 * 1000)) + .catch((error) => logService.error(error)); } From 9956f020e75697c1af5df823b96a479702fa5439 Mon Sep 17 00:00:00 2001 From: cyprain-okeke <108260115+cyprain-okeke@users.noreply.github.com> Date: Tue, 2 Apr 2024 17:04:02 +0100 Subject: [PATCH 4/5] [AC-1911] Clients: Create components to manage client organization seat allocation (#8505) * implementing the clients changes * resolve pr comments on message.json * moved the method to billing-api.service * move the request and response files to billing folder * remove the adding existing orgs * resolve the routing issue * resolving the pr comments * code owner changes * fix the assignedseat * resolve the warning message * resolve the error on update * passing the right id * resolve the unassign value * removed unused logservice * Adding the loader on submit button --- .github/CODEOWNERS | 1 + apps/web/src/locales/en/messages.json | 36 ++++ .../providers/clients/clients.component.ts | 33 +++- .../providers/providers-layout.component.html | 6 +- .../providers/providers-layout.component.ts | 5 + .../providers/providers-routing.module.ts | 7 + .../providers/providers.module.ts | 5 + ...t-organization-subscription.component.html | 49 ++++++ ...ent-organization-subscription.component.ts | 115 +++++++++++++ ...manage-client-organizations.component.html | 90 ++++++++++ .../manage-client-organizations.component.ts | 160 ++++++++++++++++++ .../billilng-api.service.abstraction.ts | 8 + .../provider-subscription-update.request.ts | 3 + .../provider-subscription-response.ts | 38 +++++ .../billing/services/billing-api.service.ts | 27 +++ libs/common/src/enums/feature-flag.enum.ts | 1 + 16 files changed, 575 insertions(+), 9 deletions(-) create mode 100644 bitwarden_license/bit-web/src/app/billing/providers/clients/manage-client-organization-subscription.component.html create mode 100644 bitwarden_license/bit-web/src/app/billing/providers/clients/manage-client-organization-subscription.component.ts create mode 100644 bitwarden_license/bit-web/src/app/billing/providers/clients/manage-client-organizations.component.html create mode 100644 bitwarden_license/bit-web/src/app/billing/providers/clients/manage-client-organizations.component.ts create mode 100644 libs/common/src/billing/models/request/provider-subscription-update.request.ts create mode 100644 libs/common/src/billing/models/response/provider-subscription-response.ts diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index bfad3f2628..e9c1f229a5 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -61,6 +61,7 @@ apps/web/src/app/billing @bitwarden/team-billing-dev libs/angular/src/billing @bitwarden/team-billing-dev libs/common/src/billing @bitwarden/team-billing-dev libs/billing @bitwarden/team-billing-dev +bitwarden_license/bit-web/src/app/billing @bitwarden/team-billing-dev ## Platform team files ## apps/browser/src/platform @bitwarden/team-platform-dev diff --git a/apps/web/src/locales/en/messages.json b/apps/web/src/locales/en/messages.json index 95d1b03e72..b8e5a5ff4d 100644 --- a/apps/web/src/locales/en/messages.json +++ b/apps/web/src/locales/en/messages.json @@ -4956,6 +4956,9 @@ "addExistingOrganization": { "message": "Add existing organization" }, + "addNewOrganization": { + "message": "Add new organization" + }, "myProvider": { "message": "My Provider" }, @@ -7642,5 +7645,38 @@ }, "items": { "message": "Items" + }, + "assignedSeats": { + "message": "Assigned seats" + }, + "assigned": { + "message": "Assigned" + }, + "used": { + "message": "Used" + }, + "remaining": { + "message": "Remaining" + }, + "unlinkOrganization": { + "message": "Unlink organization" + }, + "manageSeats": { + "message": "MANAGE SEATS" + }, + "manageSeatsDescription": { + "message": "Adjustments to seats will be reflected in the next billing cycle." + }, + "unassignedSeatsDescription": { + "message": "Unassigned subscription seats" + }, + "purchaseSeatDescription": { + "message": "Additional seats purchased" + }, + "assignedSeatCannotUpdate": { + "message": "Assigned Seats can not be updated. Please contact your organization owner for assistance." + }, + "subscriptionUpdateFailed": { + "message": "Subscription update failed" } } diff --git a/bitwarden_license/bit-web/src/app/admin-console/providers/clients/clients.component.ts b/bitwarden_license/bit-web/src/app/admin-console/providers/clients/clients.component.ts index dc3dea3c9d..20e98ce084 100644 --- a/bitwarden_license/bit-web/src/app/admin-console/providers/clients/clients.component.ts +++ b/bitwarden_license/bit-web/src/app/admin-console/providers/clients/clients.component.ts @@ -1,5 +1,5 @@ import { Component, OnInit } from "@angular/core"; -import { ActivatedRoute } from "@angular/router"; +import { ActivatedRoute, Router } from "@angular/router"; import { firstValueFrom } from "rxjs"; import { first } from "rxjs/operators"; @@ -13,6 +13,8 @@ import { ProviderUserType } from "@bitwarden/common/admin-console/enums"; import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; import { ProviderOrganizationOrganizationDetailsResponse } from "@bitwarden/common/admin-console/models/response/provider/provider-organization.response"; import { PlanType } from "@bitwarden/common/billing/enums"; +import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; +import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; @@ -50,8 +52,14 @@ export class ClientsComponent implements OnInit { protected actionPromise: Promise; private pagedClientsCount = 0; + protected enableConsolidatedBilling$ = this.configService.getFeatureFlag$( + FeatureFlag.EnableConsolidatedBilling, + false, + ); + constructor( private route: ActivatedRoute, + private router: Router, private providerService: ProviderService, private apiService: ApiService, private searchService: SearchService, @@ -64,20 +72,29 @@ export class ClientsComponent implements OnInit { private organizationService: OrganizationService, private organizationApiService: OrganizationApiServiceAbstraction, private dialogService: DialogService, + private configService: ConfigService, ) {} async ngOnInit() { // eslint-disable-next-line rxjs-angular/prefer-takeuntil, rxjs/no-async-subscribe - this.route.parent.params.subscribe(async (params) => { - this.providerId = params.providerId; - await this.load(); + const enableConsolidatedBilling = await firstValueFrom(this.enableConsolidatedBilling$); - /* eslint-disable-next-line rxjs-angular/prefer-takeuntil, rxjs/no-async-subscribe, rxjs/no-nested-subscribe */ - this.route.queryParams.pipe(first()).subscribe(async (qParams) => { - this.searchText = qParams.search; + if (enableConsolidatedBilling) { + await this.router.navigate(["../manage-client-organizations"], { relativeTo: this.route }); + } else { + // eslint-disable-next-line rxjs-angular/prefer-takeuntil, rxjs/no-async-subscribe + this.route.parent.params.subscribe(async (params) => { + this.providerId = params.providerId; + + await this.load(); + + /* eslint-disable-next-line rxjs-angular/prefer-takeuntil, rxjs/no-async-subscribe, rxjs/no-nested-subscribe */ + this.route.queryParams.pipe(first()).subscribe(async (qParams) => { + this.searchText = qParams.search; + }); }); - }); + } } async load() { diff --git a/bitwarden_license/bit-web/src/app/admin-console/providers/providers-layout.component.html b/bitwarden_license/bit-web/src/app/admin-console/providers/providers-layout.component.html index 333ea66e26..fe7f051652 100644 --- a/bitwarden_license/bit-web/src/app/admin-console/providers/providers-layout.component.html +++ b/bitwarden_license/bit-web/src/app/admin-console/providers/providers-layout.component.html @@ -4,7 +4,11 @@ - + + + {{ "manageSeats" | i18n }} + {{ clientName }} + +
+

+ {{ "manageSeatsDescription" | i18n }} +

+ + + {{ "assignedSeats" | i18n }} + + + + +

+ {{ unassignedSeats }} + {{ "unassignedSeatsDescription" | i18n }} +

+

+ {{ AdditionalSeatPurchased }} + {{ "purchaseSeatDescription" | i18n }} +

+
+
+ + + + + diff --git a/bitwarden_license/bit-web/src/app/billing/providers/clients/manage-client-organization-subscription.component.ts b/bitwarden_license/bit-web/src/app/billing/providers/clients/manage-client-organization-subscription.component.ts new file mode 100644 index 0000000000..2c8d59edc3 --- /dev/null +++ b/bitwarden_license/bit-web/src/app/billing/providers/clients/manage-client-organization-subscription.component.ts @@ -0,0 +1,115 @@ +import { DIALOG_DATA, DialogRef } from "@angular/cdk/dialog"; +import { Component, Inject, OnInit } from "@angular/core"; + +import { ProviderOrganizationOrganizationDetailsResponse } from "@bitwarden/common/admin-console/models/response/provider/provider-organization.response"; +import { BillingApiServiceAbstraction as BillingApiService } from "@bitwarden/common/billing/abstractions/billilng-api.service.abstraction"; +import { ProviderSubscriptionUpdateRequest } from "@bitwarden/common/billing/models/request/provider-subscription-update.request"; +import { Plans } from "@bitwarden/common/billing/models/response/provider-subscription-response"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; +import { DialogService } from "@bitwarden/components"; + +type ManageClientOrganizationDialogParams = { + organization: ProviderOrganizationOrganizationDetailsResponse; +}; + +@Component({ + templateUrl: "manage-client-organization-subscription.component.html", +}) +// eslint-disable-next-line rxjs-angular/prefer-takeuntil +export class ManageClientOrganizationSubscriptionComponent implements OnInit { + loading = true; + providerOrganizationId: string; + providerId: string; + + clientName: string; + assignedSeats: number; + unassignedSeats: number; + planName: string; + AdditionalSeatPurchased: number; + remainingOpenSeats: number; + + constructor( + public dialogRef: DialogRef, + @Inject(DIALOG_DATA) protected data: ManageClientOrganizationDialogParams, + private billingApiService: BillingApiService, + private i18nService: I18nService, + private platformUtilsService: PlatformUtilsService, + ) { + this.providerOrganizationId = data.organization.id; + this.providerId = data.organization.providerId; + this.clientName = data.organization.organizationName; + this.assignedSeats = data.organization.seats; + this.planName = data.organization.plan; + } + + async ngOnInit() { + try { + const response = await this.billingApiService.getProviderClientSubscriptions(this.providerId); + this.AdditionalSeatPurchased = this.getPurchasedSeatsByPlan(this.planName, response.plans); + const seatMinimum = this.getProviderSeatMinimumByPlan(this.planName, response.plans); + const assignedByPlan = this.getAssignedByPlan(this.planName, response.plans); + this.remainingOpenSeats = seatMinimum - assignedByPlan; + this.unassignedSeats = Math.abs(this.remainingOpenSeats); + } catch (error) { + this.remainingOpenSeats = 0; + this.AdditionalSeatPurchased = 0; + } + this.loading = false; + } + + async updateSubscription(assignedSeats: number) { + this.loading = true; + if (!assignedSeats) { + this.platformUtilsService.showToast( + "error", + null, + this.i18nService.t("assignedSeatCannotUpdate"), + ); + return; + } + + const request = new ProviderSubscriptionUpdateRequest(); + request.assignedSeats = assignedSeats; + + await this.billingApiService.putProviderClientSubscriptions( + this.providerId, + this.providerOrganizationId, + request, + ); + this.platformUtilsService.showToast("success", null, this.i18nService.t("subscriptionUpdated")); + this.loading = false; + this.dialogRef.close(); + } + + getPurchasedSeatsByPlan(planName: string, plans: Plans[]): number { + const plan = plans.find((plan) => plan.planName === planName); + if (plan) { + return plan.purchasedSeats; + } else { + return 0; + } + } + + getAssignedByPlan(planName: string, plans: Plans[]): number { + const plan = plans.find((plan) => plan.planName === planName); + if (plan) { + return plan.assignedSeats; + } else { + return 0; + } + } + + getProviderSeatMinimumByPlan(planName: string, plans: Plans[]) { + const plan = plans.find((plan) => plan.planName === planName); + if (plan) { + return plan.seatMinimum; + } else { + return 0; + } + } + + static open(dialogService: DialogService, data: ManageClientOrganizationDialogParams) { + return dialogService.open(ManageClientOrganizationSubscriptionComponent, { data }); + } +} diff --git a/bitwarden_license/bit-web/src/app/billing/providers/clients/manage-client-organizations.component.html b/bitwarden_license/bit-web/src/app/billing/providers/clients/manage-client-organizations.component.html new file mode 100644 index 0000000000..dc303d338f --- /dev/null +++ b/bitwarden_license/bit-web/src/app/billing/providers/clients/manage-client-organizations.component.html @@ -0,0 +1,90 @@ + + + + + {{ "addNewOrganization" | i18n }} + + + + + + {{ "loading" | i18n }} + + + +

{{ "noClientsInList" | i18n }}

+ + + + + {{ "client" | i18n }} + {{ "assigned" | i18n }} + {{ "used" | i18n }} + {{ "remaining" | i18n }} + {{ "billingPlan" | i18n }} + + + + + + + + + + + + + {{ client.seats }} + + + {{ client.userCount }} + + + {{ client.seats - client.userCount }} + + + {{ client.plan }} + + + + + + + + + + + + +
diff --git a/bitwarden_license/bit-web/src/app/billing/providers/clients/manage-client-organizations.component.ts b/bitwarden_license/bit-web/src/app/billing/providers/clients/manage-client-organizations.component.ts new file mode 100644 index 0000000000..79dd25e891 --- /dev/null +++ b/bitwarden_license/bit-web/src/app/billing/providers/clients/manage-client-organizations.component.ts @@ -0,0 +1,160 @@ +import { SelectionModel } from "@angular/cdk/collections"; +import { Component, OnInit } from "@angular/core"; +import { ActivatedRoute } from "@angular/router"; +import { firstValueFrom } from "rxjs"; +import { first } from "rxjs/operators"; + +import { ApiService } from "@bitwarden/common/abstractions/api.service"; +import { SearchService } from "@bitwarden/common/abstractions/search.service"; +import { ProviderService } from "@bitwarden/common/admin-console/abstractions/provider.service"; +import { ProviderUserType } from "@bitwarden/common/admin-console/enums"; +import { ProviderOrganizationOrganizationDetailsResponse } from "@bitwarden/common/admin-console/models/response/provider/provider-organization.response"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; +import { ValidationService } from "@bitwarden/common/platform/abstractions/validation.service"; +import { DialogService, TableDataSource } from "@bitwarden/components"; + +import { WebProviderService } from "../../../admin-console/providers/services/web-provider.service"; + +import { ManageClientOrganizationSubscriptionComponent } from "./manage-client-organization-subscription.component"; + +@Component({ + templateUrl: "manage-client-organizations.component.html", +}) + +// eslint-disable-next-line rxjs-angular/prefer-takeuntil +export class ManageClientOrganizationsComponent implements OnInit { + providerId: string; + loading = true; + manageOrganizations = false; + + set searchText(search: string) { + this.selection.clear(); + this.dataSource.filter = search; + } + + clients: ProviderOrganizationOrganizationDetailsResponse[]; + pagedClients: ProviderOrganizationOrganizationDetailsResponse[]; + + protected didScroll = false; + protected pageSize = 100; + protected actionPromise: Promise; + private pagedClientsCount = 0; + selection = new SelectionModel(true, []); + protected dataSource = new TableDataSource(); + + constructor( + private route: ActivatedRoute, + private providerService: ProviderService, + private apiService: ApiService, + private searchService: SearchService, + private platformUtilsService: PlatformUtilsService, + private i18nService: I18nService, + private validationService: ValidationService, + private webProviderService: WebProviderService, + private dialogService: DialogService, + ) {} + + async ngOnInit() { + // eslint-disable-next-line rxjs-angular/prefer-takeuntil, rxjs/no-async-subscribe + this.route.parent.params.subscribe(async (params) => { + this.providerId = params.providerId; + + await this.load(); + + /* eslint-disable-next-line rxjs-angular/prefer-takeuntil, rxjs/no-async-subscribe, rxjs/no-nested-subscribe */ + this.route.queryParams.pipe(first()).subscribe(async (qParams) => { + this.searchText = qParams.search; + }); + }); + } + + async load() { + const response = await this.apiService.getProviderClients(this.providerId); + this.clients = response.data != null && response.data.length > 0 ? response.data : []; + this.dataSource.data = this.clients; + this.manageOrganizations = + (await this.providerService.get(this.providerId)).type === ProviderUserType.ProviderAdmin; + + this.loading = false; + } + + isPaging() { + const searching = this.isSearching(); + if (searching && this.didScroll) { + // 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.resetPaging(); + } + return !searching && this.clients && this.clients.length > this.pageSize; + } + + isSearching() { + return this.searchService.isSearchable(this.searchText); + } + + async resetPaging() { + this.pagedClients = []; + this.loadMore(); + } + + loadMore() { + if (!this.clients || this.clients.length <= this.pageSize) { + return; + } + const pagedLength = this.pagedClients.length; + let pagedSize = this.pageSize; + if (pagedLength === 0 && this.pagedClientsCount > this.pageSize) { + pagedSize = this.pagedClientsCount; + } + if (this.clients.length > pagedLength) { + this.pagedClients = this.pagedClients.concat( + this.clients.slice(pagedLength, pagedLength + pagedSize), + ); + } + this.pagedClientsCount = this.pagedClients.length; + this.didScroll = this.pagedClients.length > this.pageSize; + } + + async manageSubscription(organization: ProviderOrganizationOrganizationDetailsResponse) { + if (organization == null) { + return; + } + + const dialogRef = ManageClientOrganizationSubscriptionComponent.open(this.dialogService, { + organization: organization, + }); + + await firstValueFrom(dialogRef.closed); + await this.load(); + } + + async remove(organization: ProviderOrganizationOrganizationDetailsResponse) { + const confirmed = await this.dialogService.openSimpleDialog({ + title: organization.organizationName, + content: { key: "detachOrganizationConfirmation" }, + type: "warning", + }); + + if (!confirmed) { + return false; + } + + this.actionPromise = this.webProviderService.detachOrganization( + this.providerId, + organization.id, + ); + try { + await this.actionPromise; + this.platformUtilsService.showToast( + "success", + null, + this.i18nService.t("detachedOrganization", organization.organizationName), + ); + await this.load(); + } catch (e) { + this.validationService.showError(e); + } + this.actionPromise = null; + } +} diff --git a/libs/common/src/billing/abstractions/billilng-api.service.abstraction.ts b/libs/common/src/billing/abstractions/billilng-api.service.abstraction.ts index 3982fa917b..1311976c4b 100644 --- a/libs/common/src/billing/abstractions/billilng-api.service.abstraction.ts +++ b/libs/common/src/billing/abstractions/billilng-api.service.abstraction.ts @@ -1,5 +1,7 @@ import { SubscriptionCancellationRequest } from "../../billing/models/request/subscription-cancellation.request"; import { OrganizationBillingStatusResponse } from "../../billing/models/response/organization-billing-status.response"; +import { ProviderSubscriptionUpdateRequest } from "../models/request/provider-subscription-update.request"; +import { ProviderSubscriptionResponse } from "../models/response/provider-subscription-response"; export abstract class BillingApiServiceAbstraction { cancelOrganizationSubscription: ( @@ -8,4 +10,10 @@ export abstract class BillingApiServiceAbstraction { ) => Promise; cancelPremiumUserSubscription: (request: SubscriptionCancellationRequest) => Promise; getBillingStatus: (id: string) => Promise; + getProviderClientSubscriptions: (providerId: string) => Promise; + putProviderClientSubscriptions: ( + providerId: string, + organizationId: string, + request: ProviderSubscriptionUpdateRequest, + ) => Promise; } diff --git a/libs/common/src/billing/models/request/provider-subscription-update.request.ts b/libs/common/src/billing/models/request/provider-subscription-update.request.ts new file mode 100644 index 0000000000..f2bf4c7e97 --- /dev/null +++ b/libs/common/src/billing/models/request/provider-subscription-update.request.ts @@ -0,0 +1,3 @@ +export class ProviderSubscriptionUpdateRequest { + assignedSeats: number; +} diff --git a/libs/common/src/billing/models/response/provider-subscription-response.ts b/libs/common/src/billing/models/response/provider-subscription-response.ts new file mode 100644 index 0000000000..522c518725 --- /dev/null +++ b/libs/common/src/billing/models/response/provider-subscription-response.ts @@ -0,0 +1,38 @@ +import { BaseResponse } from "../../../models/response/base.response"; + +export class ProviderSubscriptionResponse extends BaseResponse { + status: string; + currentPeriodEndDate: Date; + discountPercentage?: number | null; + plans: Plans[] = []; + + constructor(response: any) { + super(response); + this.status = this.getResponseProperty("status"); + this.currentPeriodEndDate = new Date(this.getResponseProperty("currentPeriodEndDate")); + this.discountPercentage = this.getResponseProperty("discountPercentage"); + const plans = this.getResponseProperty("plans"); + if (plans != null) { + this.plans = plans.map((i: any) => new Plans(i)); + } + } +} + +export class Plans extends BaseResponse { + planName: string; + seatMinimum: number; + assignedSeats: number; + purchasedSeats: number; + cost: number; + cadence: string; + + constructor(response: any) { + super(response); + this.planName = this.getResponseProperty("PlanName"); + this.seatMinimum = this.getResponseProperty("SeatMinimum"); + this.assignedSeats = this.getResponseProperty("AssignedSeats"); + this.purchasedSeats = this.getResponseProperty("PurchasedSeats"); + this.cost = this.getResponseProperty("Cost"); + this.cadence = this.getResponseProperty("Cadence"); + } +} diff --git a/libs/common/src/billing/services/billing-api.service.ts b/libs/common/src/billing/services/billing-api.service.ts index 3d0ff550ea..48866ab90d 100644 --- a/libs/common/src/billing/services/billing-api.service.ts +++ b/libs/common/src/billing/services/billing-api.service.ts @@ -2,6 +2,8 @@ import { ApiService } from "../../abstractions/api.service"; import { BillingApiServiceAbstraction } from "../../billing/abstractions/billilng-api.service.abstraction"; import { SubscriptionCancellationRequest } from "../../billing/models/request/subscription-cancellation.request"; import { OrganizationBillingStatusResponse } from "../../billing/models/response/organization-billing-status.response"; +import { ProviderSubscriptionUpdateRequest } from "../models/request/provider-subscription-update.request"; +import { ProviderSubscriptionResponse } from "../models/response/provider-subscription-response"; export class BillingApiService implements BillingApiServiceAbstraction { constructor(private apiService: ApiService) {} @@ -34,4 +36,29 @@ export class BillingApiService implements BillingApiServiceAbstraction { return new OrganizationBillingStatusResponse(r); } + + async getProviderClientSubscriptions(providerId: string): Promise { + const r = await this.apiService.send( + "GET", + "/providers/" + providerId + "/billing/subscription", + null, + true, + true, + ); + return new ProviderSubscriptionResponse(r); + } + + async putProviderClientSubscriptions( + providerId: string, + organizationId: string, + request: ProviderSubscriptionUpdateRequest, + ): Promise { + return await this.apiService.send( + "PUT", + "/providers/" + providerId + "/organizations/" + organizationId, + request, + true, + false, + ); + } } diff --git a/libs/common/src/enums/feature-flag.enum.ts b/libs/common/src/enums/feature-flag.enum.ts index ca5ccc17b5..9470db9447 100644 --- a/libs/common/src/enums/feature-flag.enum.ts +++ b/libs/common/src/enums/feature-flag.enum.ts @@ -7,6 +7,7 @@ export enum FeatureFlag { KeyRotationImprovements = "key-rotation-improvements", FlexibleCollectionsMigration = "flexible-collections-migration", ShowPaymentMethodWarningBanners = "show-payment-method-warning-banners", + EnableConsolidatedBilling = "enable-consolidated-billing", } // Replace this with a type safe lookup of the feature flag values in PM-2282 From a6e178f1e60e8de42bd205e27dd4295a6ab79a6a Mon Sep 17 00:00:00 2001 From: Tom <144813356+ttalty@users.noreply.github.com> Date: Tue, 2 Apr 2024 12:39:06 -0400 Subject: [PATCH 5/5] [PM-5574] sends state provider (#8373) * Adding the key definitions and tests and initial send state service * Adding the abstraction and implementing * Planning comments * Everything but fixing the send tests * Moving send tests over to the state provider * jslib needed name refactor * removing get/set encrypted sends from web vault state service * browser send state service factory * Fixing conflicts * Removing send service from services module and fixing send service observable * Commenting the migrator to be clear on why only encrypted * No need for service factories in browser * browser send service is no longer needed * Key def test cases to use toStrictEqual * Running prettier * Creating send test data to avoid code duplication * Adding state provider and account service to send in cli * Fixing the send service test cases * Fixing state definition keys * Moving to observables and implementing encryption service * Fixing key def tests * The cli was using the deprecated get method * The observables init doesn't need to happen in constructor * Missed commented out code * If enc key is null get user key * Service factory fix --- .../browser/src/background/main.background.ts | 7 +- .../service-factories/send-service.factory.ts | 17 +- .../send-state-provider.factory.ts | 28 ++ apps/cli/src/bw.ts | 7 +- .../send/commands/remove-password.command.ts | 2 +- apps/web/src/app/core/state/state.service.ts | 14 - .../src/services/jslib-services.module.ts | 10 +- libs/angular/src/tools/send/send.component.ts | 14 +- .../platform/abstractions/state.service.ts | 18 -- .../src/platform/models/domain/account.ts | 3 - .../src/platform/services/state.service.ts | 41 --- .../src/platform/state/state-definitions.ts | 4 + libs/common/src/state-migrations/migrate.ts | 7 +- .../54-move-encrypted-sends.spec.ts | 236 +++++++++++++++ .../migrations/54-move-encrypted-sends.ts | 67 +++++ .../send/services/key-definitions.spec.ts | 21 ++ .../tools/send/services/key-definitions.ts | 13 + .../send-state.provider.abstraction.ts | 17 ++ .../send/services/send-state.provider.spec.ts | 48 ++++ .../send/services/send-state.provider.ts | 47 +++ .../send/services/send.service.abstraction.ts | 5 - .../tools/send/services/send.service.spec.ts | 270 +++++++----------- .../src/tools/send/services/send.service.ts | 117 +++----- .../services/test-data/send-tests.data.ts | 79 +++++ .../src/vault/services/sync/sync.service.ts | 2 +- 25 files changed, 767 insertions(+), 327 deletions(-) create mode 100644 apps/browser/src/background/service-factories/send-state-provider.factory.ts create mode 100644 libs/common/src/state-migrations/migrations/54-move-encrypted-sends.spec.ts create mode 100644 libs/common/src/state-migrations/migrations/54-move-encrypted-sends.ts create mode 100644 libs/common/src/tools/send/services/key-definitions.spec.ts create mode 100644 libs/common/src/tools/send/services/key-definitions.ts create mode 100644 libs/common/src/tools/send/services/send-state.provider.abstraction.ts create mode 100644 libs/common/src/tools/send/services/send-state.provider.spec.ts create mode 100644 libs/common/src/tools/send/services/send-state.provider.ts create mode 100644 libs/common/src/tools/send/services/test-data/send-tests.data.ts diff --git a/apps/browser/src/background/main.background.ts b/apps/browser/src/background/main.background.ts index 102dad80a7..ea43aecff9 100644 --- a/apps/browser/src/background/main.background.ts +++ b/apps/browser/src/background/main.background.ts @@ -146,6 +146,7 @@ import { } from "@bitwarden/common/tools/password-strength"; import { SendApiService } from "@bitwarden/common/tools/send/services/send-api.service"; import { SendApiService as SendApiServiceAbstraction } from "@bitwarden/common/tools/send/services/send-api.service.abstraction"; +import { SendStateProvider } from "@bitwarden/common/tools/send/services/send-state.provider"; import { SendService } from "@bitwarden/common/tools/send/services/send.service"; import { InternalSendService as InternalSendServiceAbstraction } from "@bitwarden/common/tools/send/services/send.service.abstraction"; import { UserId } from "@bitwarden/common/types/guid"; @@ -276,6 +277,7 @@ export default class MainBackground { eventUploadService: EventUploadServiceAbstraction; policyService: InternalPolicyServiceAbstraction; sendService: InternalSendServiceAbstraction; + sendStateProvider: SendStateProvider; fileUploadService: FileUploadServiceAbstraction; cipherFileUploadService: CipherFileUploadServiceAbstraction; organizationService: InternalOrganizationServiceAbstraction; @@ -707,11 +709,14 @@ export default class MainBackground { logoutCallback, ); this.containerService = new ContainerService(this.cryptoService, this.encryptService); + + this.sendStateProvider = new SendStateProvider(this.stateProvider); this.sendService = new SendService( this.cryptoService, this.i18nService, this.keyGenerationService, - this.stateService, + this.sendStateProvider, + this.encryptService, ); this.sendApiService = new SendApiService( this.apiService, diff --git a/apps/browser/src/background/service-factories/send-service.factory.ts b/apps/browser/src/background/service-factories/send-service.factory.ts index 7c64bc076a..942861b926 100644 --- a/apps/browser/src/background/service-factories/send-service.factory.ts +++ b/apps/browser/src/background/service-factories/send-service.factory.ts @@ -5,6 +5,10 @@ import { CryptoServiceInitOptions, cryptoServiceFactory, } from "../../platform/background/service-factories/crypto-service.factory"; +import { + EncryptServiceInitOptions, + encryptServiceFactory, +} from "../../platform/background/service-factories/encrypt-service.factory"; import { FactoryOptions, CachedServices, @@ -18,10 +22,11 @@ import { KeyGenerationServiceInitOptions, keyGenerationServiceFactory, } from "../../platform/background/service-factories/key-generation-service.factory"; + import { - stateServiceFactory, - StateServiceInitOptions, -} from "../../platform/background/service-factories/state-service.factory"; + SendStateProviderInitOptions, + sendStateProviderFactory, +} from "./send-state-provider.factory"; type SendServiceFactoryOptions = FactoryOptions; @@ -29,7 +34,8 @@ export type SendServiceInitOptions = SendServiceFactoryOptions & CryptoServiceInitOptions & I18nServiceInitOptions & KeyGenerationServiceInitOptions & - StateServiceInitOptions; + SendStateProviderInitOptions & + EncryptServiceInitOptions; export function sendServiceFactory( cache: { sendService?: InternalSendService } & CachedServices, @@ -44,7 +50,8 @@ export function sendServiceFactory( await cryptoServiceFactory(cache, opts), await i18nServiceFactory(cache, opts), await keyGenerationServiceFactory(cache, opts), - await stateServiceFactory(cache, opts), + await sendStateProviderFactory(cache, opts), + await encryptServiceFactory(cache, opts), ), ); } diff --git a/apps/browser/src/background/service-factories/send-state-provider.factory.ts b/apps/browser/src/background/service-factories/send-state-provider.factory.ts new file mode 100644 index 0000000000..01319756e4 --- /dev/null +++ b/apps/browser/src/background/service-factories/send-state-provider.factory.ts @@ -0,0 +1,28 @@ +import { SendStateProvider } from "@bitwarden/common/tools/send/services/send-state.provider"; + +import { + CachedServices, + FactoryOptions, + factory, +} from "../../platform/background/service-factories/factory-options"; +import { + StateProviderInitOptions, + stateProviderFactory, +} from "../../platform/background/service-factories/state-provider.factory"; + +type SendStateProviderFactoryOptions = FactoryOptions; + +export type SendStateProviderInitOptions = SendStateProviderFactoryOptions & + StateProviderInitOptions; + +export function sendStateProviderFactory( + cache: { sendStateProvider?: SendStateProvider } & CachedServices, + opts: SendStateProviderInitOptions, +): Promise { + return factory( + cache, + "sendStateProvider", + opts, + async () => new SendStateProvider(await stateProviderFactory(cache, opts)), + ); +} diff --git a/apps/cli/src/bw.ts b/apps/cli/src/bw.ts index 804b05e8e3..3815fc773b 100644 --- a/apps/cli/src/bw.ts +++ b/apps/cli/src/bw.ts @@ -106,6 +106,7 @@ import { PasswordStrengthServiceAbstraction, } from "@bitwarden/common/tools/password-strength"; import { SendApiService } from "@bitwarden/common/tools/send/services/send-api.service"; +import { SendStateProvider } from "@bitwarden/common/tools/send/services/send-state.provider"; import { SendService } from "@bitwarden/common/tools/send/services/send.service"; import { UserId } from "@bitwarden/common/types/guid"; import { InternalFolderService } from "@bitwarden/common/vault/abstractions/folder/folder.service.abstraction"; @@ -194,6 +195,7 @@ export class Main { sendProgram: SendProgram; logService: ConsoleLogService; sendService: SendService; + sendStateProvider: SendStateProvider; fileUploadService: FileUploadService; cipherFileUploadService: CipherFileUploadService; keyConnectorService: KeyConnectorService; @@ -388,11 +390,14 @@ export class Main { this.fileUploadService = new FileUploadService(this.logService); + this.sendStateProvider = new SendStateProvider(this.stateProvider); + this.sendService = new SendService( this.cryptoService, this.i18nService, this.keyGenerationService, - this.stateService, + this.sendStateProvider, + this.encryptService, ); this.cipherFileUploadService = new CipherFileUploadService( diff --git a/apps/cli/src/tools/send/commands/remove-password.command.ts b/apps/cli/src/tools/send/commands/remove-password.command.ts index 1c7289bf08..2613004a8c 100644 --- a/apps/cli/src/tools/send/commands/remove-password.command.ts +++ b/apps/cli/src/tools/send/commands/remove-password.command.ts @@ -18,7 +18,7 @@ export class SendRemovePasswordCommand { try { await this.sendApiService.removePassword(id); - const updatedSend = await this.sendService.get(id); + const updatedSend = await firstValueFrom(this.sendService.get$(id)); const decSend = await updatedSend.decrypt(); const env = await firstValueFrom(this.environmentService.environment$); const webVaultUrl = env.getWebVaultUrl(); diff --git a/apps/web/src/app/core/state/state.service.ts b/apps/web/src/app/core/state/state.service.ts index a80384d179..54e456d34c 100644 --- a/apps/web/src/app/core/state/state.service.ts +++ b/apps/web/src/app/core/state/state.service.ts @@ -18,7 +18,6 @@ import { StateFactory } from "@bitwarden/common/platform/factories/state-factory import { StorageOptions } from "@bitwarden/common/platform/models/domain/storage-options"; import { MigrationRunner } from "@bitwarden/common/platform/services/migration-runner"; import { StateService as BaseStateService } from "@bitwarden/common/platform/services/state.service"; -import { SendData } from "@bitwarden/common/tools/send/models/data/send.data"; import { CipherData } from "@bitwarden/common/vault/models/data/cipher.data"; import { Account } from "./account"; @@ -71,19 +70,6 @@ export class StateService extends BaseStateService { return await super.setEncryptedCiphers(value, options); } - async getEncryptedSends(options?: StorageOptions): Promise<{ [id: string]: SendData }> { - options = this.reconcileOptions(options, await this.defaultInMemoryOptions()); - return await super.getEncryptedSends(options); - } - - async setEncryptedSends( - value: { [id: string]: SendData }, - options?: StorageOptions, - ): Promise { - options = this.reconcileOptions(options, await this.defaultInMemoryOptions()); - return await super.setEncryptedSends(value, options); - } - override async getLastSync(options?: StorageOptions): Promise { options = this.reconcileOptions(options, await this.defaultInMemoryOptions()); return await super.getLastSync(options); diff --git a/libs/angular/src/services/jslib-services.module.ts b/libs/angular/src/services/jslib-services.module.ts index 6378eed755..73f2bb4a32 100644 --- a/libs/angular/src/services/jslib-services.module.ts +++ b/libs/angular/src/services/jslib-services.module.ts @@ -191,6 +191,8 @@ import { } from "@bitwarden/common/tools/password-strength"; import { SendApiService } from "@bitwarden/common/tools/send/services/send-api.service"; import { SendApiService as SendApiServiceAbstraction } from "@bitwarden/common/tools/send/services/send-api.service.abstraction"; +import { SendStateProvider as SendStateProvider } from "@bitwarden/common/tools/send/services/send-state.provider"; +import { SendStateProvider as SendStateProviderAbstraction } from "@bitwarden/common/tools/send/services/send-state.provider.abstraction"; import { SendService } from "@bitwarden/common/tools/send/services/send.service"; import { InternalSendService, @@ -567,9 +569,15 @@ const safeProviders: SafeProvider[] = [ CryptoServiceAbstraction, I18nServiceAbstraction, KeyGenerationServiceAbstraction, - StateServiceAbstraction, + SendStateProviderAbstraction, + EncryptService, ], }), + safeProvider({ + provide: SendStateProviderAbstraction, + useClass: SendStateProvider, + deps: [StateProvider], + }), safeProvider({ provide: SendApiServiceAbstraction, useClass: SendApiService, diff --git a/libs/angular/src/tools/send/send.component.ts b/libs/angular/src/tools/send/send.component.ts index b3b871a177..90d9b39e8c 100644 --- a/libs/angular/src/tools/send/send.component.ts +++ b/libs/angular/src/tools/send/send.component.ts @@ -1,5 +1,5 @@ import { Directive, NgZone, OnDestroy, OnInit } from "@angular/core"; -import { Subject, firstValueFrom, takeUntil } from "rxjs"; +import { Subject, firstValueFrom, mergeMap, takeUntil } from "rxjs"; import { SearchService } from "@bitwarden/common/abstractions/search.service"; import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; @@ -77,9 +77,15 @@ export class SendComponent implements OnInit, OnDestroy { async load(filter: (send: SendView) => boolean = null) { this.loading = true; - this.sendService.sendViews$.pipe(takeUntil(this.destroy$)).subscribe((sends) => { - this.sends = sends; - }); + this.sendService.sendViews$ + .pipe( + mergeMap(async (sends) => { + this.sends = sends; + await this.search(null); + }), + takeUntil(this.destroy$), + ) + .subscribe(); if (this.onSuccessfulLoad != null) { await this.onSuccessfulLoad(); } else { diff --git a/libs/common/src/platform/abstractions/state.service.ts b/libs/common/src/platform/abstractions/state.service.ts index 9bc6d698a7..4c876316cd 100644 --- a/libs/common/src/platform/abstractions/state.service.ts +++ b/libs/common/src/platform/abstractions/state.service.ts @@ -7,8 +7,6 @@ import { BiometricKey } from "../../auth/types/biometric-key"; import { GeneratorOptions } from "../../tools/generator/generator-options"; import { GeneratedPasswordHistory, PasswordGeneratorOptions } from "../../tools/generator/password"; import { UsernameGeneratorOptions } from "../../tools/generator/username"; -import { SendData } from "../../tools/send/models/data/send.data"; -import { SendView } from "../../tools/send/models/view/send.view"; import { UserId } from "../../types/guid"; import { MasterKey } from "../../types/key"; import { CipherData } from "../../vault/models/data/cipher.data"; @@ -151,14 +149,6 @@ export abstract class StateService { * @deprecated For migration purposes only, use setDecryptedUserKeyPin instead */ setDecryptedPinProtected: (value: EncString, options?: StorageOptions) => Promise; - /** - * @deprecated Do not call this directly, use SendService - */ - getDecryptedSends: (options?: StorageOptions) => Promise; - /** - * @deprecated Do not call this directly, use SendService - */ - setDecryptedSends: (value: SendView[], options?: StorageOptions) => Promise; getDuckDuckGoSharedKey: (options?: StorageOptions) => Promise; setDuckDuckGoSharedKey: (value: string, options?: StorageOptions) => Promise; getAdminAuthRequest: (options?: StorageOptions) => Promise; @@ -197,14 +187,6 @@ export abstract class StateService { * @deprecated For migration purposes only, use setEncryptedUserKeyPin instead */ setEncryptedPinProtected: (value: string, options?: StorageOptions) => Promise; - /** - * @deprecated Do not call this directly, use SendService - */ - getEncryptedSends: (options?: StorageOptions) => Promise<{ [id: string]: SendData }>; - /** - * @deprecated Do not call this directly, use SendService - */ - setEncryptedSends: (value: { [id: string]: SendData }, options?: StorageOptions) => Promise; getEverBeenUnlocked: (options?: StorageOptions) => Promise; setEverBeenUnlocked: (value: boolean, options?: StorageOptions) => Promise; getForceSetPasswordReason: (options?: StorageOptions) => Promise; diff --git a/libs/common/src/platform/models/domain/account.ts b/libs/common/src/platform/models/domain/account.ts index 798a60600a..4ed36fd389 100644 --- a/libs/common/src/platform/models/domain/account.ts +++ b/libs/common/src/platform/models/domain/account.ts @@ -9,8 +9,6 @@ import { PasswordGeneratorOptions, } from "../../../tools/generator/password"; import { UsernameGeneratorOptions } from "../../../tools/generator/username/username-generation-options"; -import { SendData } from "../../../tools/send/models/data/send.data"; -import { SendView } from "../../../tools/send/models/view/send.view"; import { DeepJsonify } from "../../../types/deep-jsonify"; import { MasterKey } from "../../../types/key"; import { CipherData } from "../../../vault/models/data/cipher.data"; @@ -71,7 +69,6 @@ export class AccountData { CipherView >(); localData?: any; - sends?: DataEncryptionPair = new DataEncryptionPair(); passwordGenerationHistory?: EncryptionPair< GeneratedPasswordHistory[], GeneratedPasswordHistory[] diff --git a/libs/common/src/platform/services/state.service.ts b/libs/common/src/platform/services/state.service.ts index 57a2085ccf..fb62af250b 100644 --- a/libs/common/src/platform/services/state.service.ts +++ b/libs/common/src/platform/services/state.service.ts @@ -11,8 +11,6 @@ import { BiometricKey } from "../../auth/types/biometric-key"; import { GeneratorOptions } from "../../tools/generator/generator-options"; import { GeneratedPasswordHistory, PasswordGeneratorOptions } from "../../tools/generator/password"; import { UsernameGeneratorOptions } from "../../tools/generator/username"; -import { SendData } from "../../tools/send/models/data/send.data"; -import { SendView } from "../../tools/send/models/view/send.view"; import { UserId } from "../../types/guid"; import { MasterKey } from "../../types/key"; import { CipherData } from "../../vault/models/data/cipher.data"; @@ -614,24 +612,6 @@ export class StateService< ); } - @withPrototypeForArrayMembers(SendView) - async getDecryptedSends(options?: StorageOptions): Promise { - return ( - await this.getAccount(this.reconcileOptions(options, await this.defaultInMemoryOptions())) - )?.data?.sends?.decrypted; - } - - async setDecryptedSends(value: SendView[], options?: StorageOptions): Promise { - const account = await this.getAccount( - this.reconcileOptions(options, await this.defaultInMemoryOptions()), - ); - account.data.sends.decrypted = value; - await this.saveAccount( - account, - this.reconcileOptions(options, await this.defaultInMemoryOptions()), - ); - } - async getDuckDuckGoSharedKey(options?: StorageOptions): Promise { options = this.reconcileOptions(options, await this.defaultSecureStorageOptions()); if (options?.userId == null) { @@ -825,27 +805,6 @@ export class StateService< ); } - @withPrototypeForObjectValues(SendData) - async getEncryptedSends(options?: StorageOptions): Promise<{ [id: string]: SendData }> { - return ( - await this.getAccount(this.reconcileOptions(options, await this.defaultOnDiskMemoryOptions())) - )?.data?.sends.encrypted; - } - - async setEncryptedSends( - value: { [id: string]: SendData }, - options?: StorageOptions, - ): Promise { - const account = await this.getAccount( - this.reconcileOptions(options, await this.defaultOnDiskMemoryOptions()), - ); - account.data.sends.encrypted = value; - await this.saveAccount( - account, - this.reconcileOptions(options, await this.defaultOnDiskMemoryOptions()), - ); - } - async getEverBeenUnlocked(options?: StorageOptions): Promise { return ( (await this.getAccount(this.reconcileOptions(options, await this.defaultInMemoryOptions()))) diff --git a/libs/common/src/platform/state/state-definitions.ts b/libs/common/src/platform/state/state-definitions.ts index 9fca0e9445..d50a3e6ac7 100644 --- a/libs/common/src/platform/state/state-definitions.ts +++ b/libs/common/src/platform/state/state-definitions.ts @@ -102,6 +102,10 @@ export const SM_ONBOARDING_DISK = new StateDefinition("smOnboarding", "disk", { export const GENERATOR_DISK = new StateDefinition("generator", "disk"); export const GENERATOR_MEMORY = new StateDefinition("generator", "memory"); export const EVENT_COLLECTION_DISK = new StateDefinition("eventCollection", "disk"); +export const SEND_DISK = new StateDefinition("encryptedSend", "disk", { + web: "memory", +}); +export const SEND_MEMORY = new StateDefinition("decryptedSend", "memory"); // Vault diff --git a/libs/common/src/state-migrations/migrate.ts b/libs/common/src/state-migrations/migrate.ts index 4e1a0529fc..faccddb0af 100644 --- a/libs/common/src/state-migrations/migrate.ts +++ b/libs/common/src/state-migrations/migrate.ts @@ -50,6 +50,7 @@ import { KeyConnectorMigrator } from "./migrations/50-move-key-connector-to-stat import { RememberedEmailMigrator } from "./migrations/51-move-remembered-email-to-state-providers"; import { DeleteInstalledVersion } from "./migrations/52-delete-installed-version"; import { DeviceTrustCryptoServiceStateProviderMigrator } from "./migrations/53-migrate-device-trust-crypto-svc-to-state-providers"; +import { SendMigrator } from "./migrations/54-move-encrypted-sends"; import { RemoveLegacyEtmKeyMigrator } from "./migrations/6-remove-legacy-etm-key"; import { MoveBiometricAutoPromptToAccount } from "./migrations/7-move-biometric-auto-prompt-to-account"; import { MoveStateVersionMigrator } from "./migrations/8-move-state-version"; @@ -57,7 +58,8 @@ import { MoveBrowserSettingsToGlobal } from "./migrations/9-move-browser-setting import { MinVersionMigrator } from "./migrations/min-version"; export const MIN_VERSION = 3; -export const CURRENT_VERSION = 53; +export const CURRENT_VERSION = 54; + export type MinVersion = typeof MIN_VERSION; export function createMigrationBuilder() { @@ -112,7 +114,8 @@ export function createMigrationBuilder() { .with(KeyConnectorMigrator, 49, 50) .with(RememberedEmailMigrator, 50, 51) .with(DeleteInstalledVersion, 51, 52) - .with(DeviceTrustCryptoServiceStateProviderMigrator, 52, CURRENT_VERSION); + .with(DeviceTrustCryptoServiceStateProviderMigrator, 52, 53) + .with(SendMigrator, 53, 54); } export async function currentVersion( diff --git a/libs/common/src/state-migrations/migrations/54-move-encrypted-sends.spec.ts b/libs/common/src/state-migrations/migrations/54-move-encrypted-sends.spec.ts new file mode 100644 index 0000000000..9e73a1258a --- /dev/null +++ b/libs/common/src/state-migrations/migrations/54-move-encrypted-sends.spec.ts @@ -0,0 +1,236 @@ +import { MockProxy, any } from "jest-mock-extended"; + +import { MigrationHelper } from "../migration-helper"; +import { mockMigrationHelper } from "../migration-helper.spec"; + +import { SendMigrator } from "./54-move-encrypted-sends"; + +function exampleJSON() { + return { + global: { + otherStuff: "otherStuff1", + }, + authenticatedAccounts: ["user-1", "user-2"], + "user-1": { + data: { + sends: { + encrypted: { + "2ebadc23-e101-471b-bf2d-b125015337a0": { + id: "2ebadc23-e101-471b-bf2d-b125015337a0", + accessId: "I9y6LgHhG0e_LbElAVM3oA", + deletionDate: "2024-03-07T20:35:03Z", + disabled: false, + hideEmail: false, + key: "2.sR07sf4f18Rw6YQH9R/fPw==|DlLIYdTlFBktHVEJixqrOZmW/dTDGmZ+9iVftYkRh4s=|2mXH2fKgtItEMi8rcP1ykkVwRbxztw5MGboBwRl/kKM=", + name: "2.A0wIvDbyzuh6AjgFtv2gqQ==|D0FymzfCdYJQcAk5MARfjg==|2g52y7e/33A7Bafaaoy3Yvae7vxbIxoABZdZeoZuyg4=", + text: { + hidden: false, + text: "2.MkcPiJUnNfpcyETsoH3b8g==|/oHZ5g6pmcerXAJidP9sXg==|JDhd1Blsxm/ubp2AAggHZr6gZhyW4UYwZkF5rxlO6X0=", + }, + type: 0, + }, + "3b31c20d-b783-4912-9170-b12501555398": { + id: "3b31c20d-b783-4912-9170-b12501555398", + accessId: "DcIxO4O3EkmRcLElAVVTmA", + deletionDate: "2024-03-07T20:42:43Z", + disabled: false, + hideEmail: false, + key: "2.366XwLCi7RJnXuAvpsEVNw==|XfLoSsdOIYsHfcSMmv+7VJY97bKfS3fjpbq3ez+KCdk=|iTJxf4Pc3ub6hTFXGeU8NpUV3KxnuxzaHuNoFo/I6Vs=", + name: "2.uJ2FoouFJr/SR9gv3jYY/Q==|ksVre4/YqwY/XOtPyIfIJw==|/LVT842LJgyAchl7NffogXkrmCFwOEHX9NFd0zgLqKo=", + text: { + hidden: false, + text: "2.zBeOzMKtjnP5YI5lJWQTWA==|vxrGt4GKtydhrqaW35b/jw==|36Jtg172awn9YsgfzNs4pJ/OpA59NBnUkLNt6lg7Zw8=", + }, + type: 0, + }, + }, + }, + otherStuff: "otherStuff2", + }, + otherStuff: "otherStuff3", + }, + "user-2": { + data: { + otherStuff: "otherStuff4", + }, + otherStuff: "otherStuff5", + }, + }; +} + +function rollbackJSON() { + return { + "user_user-1_send_sends": { + "2ebadc23-e101-471b-bf2d-b125015337a0": { + id: "2ebadc23-e101-471b-bf2d-b125015337a0", + accessId: "I9y6LgHhG0e_LbElAVM3oA", + deletionDate: "2024-03-07T20:35:03Z", + disabled: false, + hideEmail: false, + key: "2.sR07sf4f18Rw6YQH9R/fPw==|DlLIYdTlFBktHVEJixqrOZmW/dTDGmZ+9iVftYkRh4s=|2mXH2fKgtItEMi8rcP1ykkVwRbxztw5MGboBwRl/kKM=", + name: "2.A0wIvDbyzuh6AjgFtv2gqQ==|D0FymzfCdYJQcAk5MARfjg==|2g52y7e/33A7Bafaaoy3Yvae7vxbIxoABZdZeoZuyg4=", + text: { + hidden: false, + text: "2.MkcPiJUnNfpcyETsoH3b8g==|/oHZ5g6pmcerXAJidP9sXg==|JDhd1Blsxm/ubp2AAggHZr6gZhyW4UYwZkF5rxlO6X0=", + }, + type: 0, + }, + "3b31c20d-b783-4912-9170-b12501555398": { + id: "3b31c20d-b783-4912-9170-b12501555398", + accessId: "DcIxO4O3EkmRcLElAVVTmA", + deletionDate: "2024-03-07T20:42:43Z", + disabled: false, + hideEmail: false, + key: "2.366XwLCi7RJnXuAvpsEVNw==|XfLoSsdOIYsHfcSMmv+7VJY97bKfS3fjpbq3ez+KCdk=|iTJxf4Pc3ub6hTFXGeU8NpUV3KxnuxzaHuNoFo/I6Vs=", + name: "2.uJ2FoouFJr/SR9gv3jYY/Q==|ksVre4/YqwY/XOtPyIfIJw==|/LVT842LJgyAchl7NffogXkrmCFwOEHX9NFd0zgLqKo=", + text: { + hidden: false, + text: "2.zBeOzMKtjnP5YI5lJWQTWA==|vxrGt4GKtydhrqaW35b/jw==|36Jtg172awn9YsgfzNs4pJ/OpA59NBnUkLNt6lg7Zw8=", + }, + type: 0, + }, + }, + "user_user-2_send_data": null as any, + global: { + otherStuff: "otherStuff1", + }, + authenticatedAccounts: ["user-1", "user-2"], + "user-1": { + data: { + otherStuff: "otherStuff2", + }, + otherStuff: "otherStuff3", + }, + "user-2": { + data: { + otherStuff: "otherStuff4", + }, + otherStuff: "otherStuff5", + }, + }; +} + +describe("SendMigrator", () => { + let helper: MockProxy; + let sut: SendMigrator; + const keyDefinitionLike = { + stateDefinition: { + name: "send", + }, + key: "sends", + }; + + describe("migrate", () => { + beforeEach(() => { + helper = mockMigrationHelper(exampleJSON(), 53); + sut = new SendMigrator(53, 54); + }); + + it("should remove encrypted sends from all accounts", async () => { + await sut.migrate(helper); + expect(helper.set).toHaveBeenCalledWith("user-1", { + data: { + otherStuff: "otherStuff2", + }, + otherStuff: "otherStuff3", + }); + }); + + it("should set encrypted sends for each account", async () => { + await sut.migrate(helper); + + expect(helper.setToUser).toHaveBeenCalledWith("user-1", keyDefinitionLike, { + "2ebadc23-e101-471b-bf2d-b125015337a0": { + id: "2ebadc23-e101-471b-bf2d-b125015337a0", + accessId: "I9y6LgHhG0e_LbElAVM3oA", + deletionDate: "2024-03-07T20:35:03Z", + disabled: false, + hideEmail: false, + key: "2.sR07sf4f18Rw6YQH9R/fPw==|DlLIYdTlFBktHVEJixqrOZmW/dTDGmZ+9iVftYkRh4s=|2mXH2fKgtItEMi8rcP1ykkVwRbxztw5MGboBwRl/kKM=", + name: "2.A0wIvDbyzuh6AjgFtv2gqQ==|D0FymzfCdYJQcAk5MARfjg==|2g52y7e/33A7Bafaaoy3Yvae7vxbIxoABZdZeoZuyg4=", + text: { + hidden: false, + text: "2.MkcPiJUnNfpcyETsoH3b8g==|/oHZ5g6pmcerXAJidP9sXg==|JDhd1Blsxm/ubp2AAggHZr6gZhyW4UYwZkF5rxlO6X0=", + }, + type: 0, + }, + "3b31c20d-b783-4912-9170-b12501555398": { + id: "3b31c20d-b783-4912-9170-b12501555398", + accessId: "DcIxO4O3EkmRcLElAVVTmA", + deletionDate: "2024-03-07T20:42:43Z", + disabled: false, + hideEmail: false, + key: "2.366XwLCi7RJnXuAvpsEVNw==|XfLoSsdOIYsHfcSMmv+7VJY97bKfS3fjpbq3ez+KCdk=|iTJxf4Pc3ub6hTFXGeU8NpUV3KxnuxzaHuNoFo/I6Vs=", + name: "2.uJ2FoouFJr/SR9gv3jYY/Q==|ksVre4/YqwY/XOtPyIfIJw==|/LVT842LJgyAchl7NffogXkrmCFwOEHX9NFd0zgLqKo=", + text: { + hidden: false, + text: "2.zBeOzMKtjnP5YI5lJWQTWA==|vxrGt4GKtydhrqaW35b/jw==|36Jtg172awn9YsgfzNs4pJ/OpA59NBnUkLNt6lg7Zw8=", + }, + type: 0, + }, + }); + }); + }); + + describe("rollback", () => { + beforeEach(() => { + helper = mockMigrationHelper(rollbackJSON(), 54); + sut = new SendMigrator(53, 54); + }); + + 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 encrypted send values back to accounts", async () => { + await sut.rollback(helper); + + expect(helper.set).toHaveBeenCalled(); + expect(helper.set).toHaveBeenCalledWith("user-1", { + data: { + sends: { + encrypted: { + "2ebadc23-e101-471b-bf2d-b125015337a0": { + id: "2ebadc23-e101-471b-bf2d-b125015337a0", + accessId: "I9y6LgHhG0e_LbElAVM3oA", + deletionDate: "2024-03-07T20:35:03Z", + disabled: false, + hideEmail: false, + key: "2.sR07sf4f18Rw6YQH9R/fPw==|DlLIYdTlFBktHVEJixqrOZmW/dTDGmZ+9iVftYkRh4s=|2mXH2fKgtItEMi8rcP1ykkVwRbxztw5MGboBwRl/kKM=", + name: "2.A0wIvDbyzuh6AjgFtv2gqQ==|D0FymzfCdYJQcAk5MARfjg==|2g52y7e/33A7Bafaaoy3Yvae7vxbIxoABZdZeoZuyg4=", + text: { + hidden: false, + text: "2.MkcPiJUnNfpcyETsoH3b8g==|/oHZ5g6pmcerXAJidP9sXg==|JDhd1Blsxm/ubp2AAggHZr6gZhyW4UYwZkF5rxlO6X0=", + }, + type: 0, + }, + "3b31c20d-b783-4912-9170-b12501555398": { + id: "3b31c20d-b783-4912-9170-b12501555398", + accessId: "DcIxO4O3EkmRcLElAVVTmA", + deletionDate: "2024-03-07T20:42:43Z", + disabled: false, + hideEmail: false, + key: "2.366XwLCi7RJnXuAvpsEVNw==|XfLoSsdOIYsHfcSMmv+7VJY97bKfS3fjpbq3ez+KCdk=|iTJxf4Pc3ub6hTFXGeU8NpUV3KxnuxzaHuNoFo/I6Vs=", + name: "2.uJ2FoouFJr/SR9gv3jYY/Q==|ksVre4/YqwY/XOtPyIfIJw==|/LVT842LJgyAchl7NffogXkrmCFwOEHX9NFd0zgLqKo=", + text: { + hidden: false, + text: "2.zBeOzMKtjnP5YI5lJWQTWA==|vxrGt4GKtydhrqaW35b/jw==|36Jtg172awn9YsgfzNs4pJ/OpA59NBnUkLNt6lg7Zw8=", + }, + type: 0, + }, + }, + }, + otherStuff: "otherStuff2", + }, + 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()); + }); + }); +}); diff --git a/libs/common/src/state-migrations/migrations/54-move-encrypted-sends.ts b/libs/common/src/state-migrations/migrations/54-move-encrypted-sends.ts new file mode 100644 index 0000000000..7f60d18ffe --- /dev/null +++ b/libs/common/src/state-migrations/migrations/54-move-encrypted-sends.ts @@ -0,0 +1,67 @@ +import { KeyDefinitionLike, MigrationHelper } from "../migration-helper"; +import { Migrator } from "../migrator"; + +export enum SendType { + Text = 0, + File = 1, +} + +type SendData = { + id: string; + accessId: string; +}; + +type ExpectedSendState = { + data?: { + sends?: { + encrypted?: Record; + }; + }; +}; + +const ENCRYPTED_SENDS: KeyDefinitionLike = { + stateDefinition: { + name: "send", + }, + key: "sends", +}; + +/** + * Only encrypted sends are stored on disk. Only the encrypted items need to be + * migrated from the previous sends state data. + */ +export class SendMigrator extends Migrator<53, 54> { + async migrate(helper: MigrationHelper): Promise { + const accounts = await helper.getAccounts(); + + async function migrateAccount(userId: string, account: ExpectedSendState): Promise { + const value = account?.data?.sends?.encrypted; + if (value != null) { + await helper.setToUser(userId, ENCRYPTED_SENDS, value); + delete account.data.sends; + await helper.set(userId, account); + } + } + + await Promise.all([...accounts.map(({ userId, account }) => migrateAccount(userId, account))]); + } + + async rollback(helper: MigrationHelper): Promise { + const accounts = await helper.getAccounts(); + + async function rollbackAccount(userId: string, account: ExpectedSendState): Promise { + const value = await helper.getFromUser(userId, ENCRYPTED_SENDS); + if (account) { + account.data = Object.assign(account.data ?? {}, { + sends: { + encrypted: value, + }, + }); + + await helper.set(userId, account); + } + await helper.setToUser(userId, ENCRYPTED_SENDS, null); + } + await Promise.all([...accounts.map(({ userId, account }) => rollbackAccount(userId, account))]); + } +} diff --git a/libs/common/src/tools/send/services/key-definitions.spec.ts b/libs/common/src/tools/send/services/key-definitions.spec.ts new file mode 100644 index 0000000000..9916237349 --- /dev/null +++ b/libs/common/src/tools/send/services/key-definitions.spec.ts @@ -0,0 +1,21 @@ +import { SEND_USER_ENCRYPTED, SEND_USER_DECRYPTED } from "./key-definitions"; +import { testSendData, testSendViewData } from "./test-data/send-tests.data"; + +describe("Key definitions", () => { + describe("SEND_USER_ENCRYPTED", () => { + it("should pass through deserialization", () => { + const result = SEND_USER_ENCRYPTED.deserializer( + JSON.parse(JSON.stringify(testSendData("1", "Test Send Data"))), + ); + expect(result).toEqual(testSendData("1", "Test Send Data")); + }); + }); + + describe("SEND_USER_DECRYPTED", () => { + it("should pass through deserialization", () => { + const sendViews = [testSendViewData("1", "Test Send View")]; + const result = SEND_USER_DECRYPTED.deserializer(JSON.parse(JSON.stringify(sendViews))); + expect(result).toEqual(sendViews); + }); + }); +}); diff --git a/libs/common/src/tools/send/services/key-definitions.ts b/libs/common/src/tools/send/services/key-definitions.ts new file mode 100644 index 0000000000..b117c52268 --- /dev/null +++ b/libs/common/src/tools/send/services/key-definitions.ts @@ -0,0 +1,13 @@ +import { KeyDefinition, SEND_DISK, SEND_MEMORY } from "../../../platform/state"; +import { SendData } from "../models/data/send.data"; +import { SendView } from "../models/view/send.view"; + +/** Encrypted send state stored on disk */ +export const SEND_USER_ENCRYPTED = KeyDefinition.record(SEND_DISK, "sendUserEncrypted", { + deserializer: (obj: SendData) => obj, +}); + +/** Decrypted send state stored in memory */ +export const SEND_USER_DECRYPTED = new KeyDefinition(SEND_MEMORY, "sendUserDecrypted", { + deserializer: (obj) => obj, +}); diff --git a/libs/common/src/tools/send/services/send-state.provider.abstraction.ts b/libs/common/src/tools/send/services/send-state.provider.abstraction.ts new file mode 100644 index 0000000000..7a35506b56 --- /dev/null +++ b/libs/common/src/tools/send/services/send-state.provider.abstraction.ts @@ -0,0 +1,17 @@ +import { Observable } from "rxjs"; + +import { SendData } from "../models/data/send.data"; +import { SendView } from "../models/view/send.view"; + +export abstract class SendStateProvider { + encryptedState$: Observable>; + decryptedState$: Observable; + + getEncryptedSends: () => Promise<{ [id: string]: SendData }>; + + setEncryptedSends: (value: { [id: string]: SendData }) => Promise; + + getDecryptedSends: () => Promise; + + setDecryptedSends: (value: SendView[]) => Promise; +} diff --git a/libs/common/src/tools/send/services/send-state.provider.spec.ts b/libs/common/src/tools/send/services/send-state.provider.spec.ts new file mode 100644 index 0000000000..069e0d8069 --- /dev/null +++ b/libs/common/src/tools/send/services/send-state.provider.spec.ts @@ -0,0 +1,48 @@ +import { + FakeAccountService, + FakeStateProvider, + awaitAsync, + mockAccountServiceWith, +} from "../../../../spec"; +import { Utils } from "../../../platform/misc/utils"; +import { UserId } from "../../../types/guid"; + +import { SendStateProvider } from "./send-state.provider"; +import { testSendData, testSendViewData } from "./test-data/send-tests.data"; + +describe("Send State Provider", () => { + let stateProvider: FakeStateProvider; + let accountService: FakeAccountService; + let sendStateProvider: SendStateProvider; + + const mockUserId = Utils.newGuid() as UserId; + + beforeEach(() => { + accountService = mockAccountServiceWith(mockUserId); + stateProvider = new FakeStateProvider(accountService); + + sendStateProvider = new SendStateProvider(stateProvider); + }); + + describe("Encrypted Sends", () => { + it("should return SendData", async () => { + const sendData = { "1": testSendData("1", "Test Send Data") }; + await sendStateProvider.setEncryptedSends(sendData); + await awaitAsync(); + + const actual = await sendStateProvider.getEncryptedSends(); + expect(actual).toStrictEqual(sendData); + }); + }); + + describe("Decrypted Sends", () => { + it("should return SendView", async () => { + const state = [testSendViewData("1", "Test")]; + await sendStateProvider.setDecryptedSends(state); + await awaitAsync(); + + const actual = await sendStateProvider.getDecryptedSends(); + expect(actual).toStrictEqual(state); + }); + }); +}); diff --git a/libs/common/src/tools/send/services/send-state.provider.ts b/libs/common/src/tools/send/services/send-state.provider.ts new file mode 100644 index 0000000000..1e9397b7a9 --- /dev/null +++ b/libs/common/src/tools/send/services/send-state.provider.ts @@ -0,0 +1,47 @@ +import { Observable, firstValueFrom } from "rxjs"; + +import { ActiveUserState, StateProvider } from "../../../platform/state"; +import { SendData } from "../models/data/send.data"; +import { SendView } from "../models/view/send.view"; + +import { SEND_USER_DECRYPTED, SEND_USER_ENCRYPTED } from "./key-definitions"; +import { SendStateProvider as SendStateProviderAbstraction } from "./send-state.provider.abstraction"; + +/** State provider for sends */ +export class SendStateProvider implements SendStateProviderAbstraction { + /** Observable for the encrypted sends for an active user */ + encryptedState$: Observable>; + /** Observable with the decrypted sends for an active user */ + decryptedState$: Observable; + + private activeUserEncryptedState: ActiveUserState>; + private activeUserDecryptedState: ActiveUserState; + + constructor(protected stateProvider: StateProvider) { + this.activeUserEncryptedState = this.stateProvider.getActive(SEND_USER_ENCRYPTED); + this.encryptedState$ = this.activeUserEncryptedState.state$; + + this.activeUserDecryptedState = this.stateProvider.getActive(SEND_USER_DECRYPTED); + this.decryptedState$ = this.activeUserDecryptedState.state$; + } + + /** Gets the encrypted sends from state for an active user */ + async getEncryptedSends(): Promise<{ [id: string]: SendData }> { + return await firstValueFrom(this.encryptedState$); + } + + /** Sets the encrypted send state for an active user */ + async setEncryptedSends(value: { [id: string]: SendData }): Promise { + await this.activeUserEncryptedState.update(() => value); + } + + /** Gets the decrypted sends from state for the active user */ + async getDecryptedSends(): Promise { + return await firstValueFrom(this.decryptedState$); + } + + /** Sets the decrypted send state for an active user */ + async setDecryptedSends(value: SendView[]): Promise { + await this.activeUserDecryptedState.update(() => value); + } +} diff --git a/libs/common/src/tools/send/services/send.service.abstraction.ts b/libs/common/src/tools/send/services/send.service.abstraction.ts index 45f623537d..e9f9387169 100644 --- a/libs/common/src/tools/send/services/send.service.abstraction.ts +++ b/libs/common/src/tools/send/services/send.service.abstraction.ts @@ -18,10 +18,6 @@ export abstract class SendService { password: string, key?: SymmetricCryptoKey, ) => Promise<[Send, EncArrayBuffer]>; - /** - * @deprecated Do not call this, use the get$ method - */ - get: (id: string) => Send; /** * Provides a send for a determined id * updates after a change occurs to the send that matches the id @@ -53,6 +49,5 @@ export abstract class SendService { export abstract class InternalSendService extends SendService { upsert: (send: SendData | SendData[]) => Promise; replace: (sends: { [id: string]: SendData }) => Promise; - clear: (userId: string) => Promise; delete: (id: string | string[]) => Promise; } diff --git a/libs/common/src/tools/send/services/send.service.spec.ts b/libs/common/src/tools/send/services/send.service.spec.ts index 568bd70d52..fc793dba67 100644 --- a/libs/common/src/tools/send/services/send.service.spec.ts +++ b/libs/common/src/tools/send/services/send.service.spec.ts @@ -1,14 +1,23 @@ -import { any, mock, MockProxy } from "jest-mock-extended"; -import { BehaviorSubject, firstValueFrom } from "rxjs"; +import { mock } from "jest-mock-extended"; +import { firstValueFrom } from "rxjs"; +import { + FakeAccountService, + FakeActiveUserState, + FakeStateProvider, + awaitAsync, + mockAccountServiceWith, +} from "../../../../spec"; +import { AuthenticationStatus } from "../../../auth/enums/authentication-status"; import { CryptoService } from "../../../platform/abstractions/crypto.service"; import { EncryptService } from "../../../platform/abstractions/encrypt.service"; import { I18nService } from "../../../platform/abstractions/i18n.service"; import { KeyGenerationService } from "../../../platform/abstractions/key-generation.service"; -import { StateService } from "../../../platform/abstractions/state.service"; +import { Utils } from "../../../platform/misc/utils"; import { EncString } from "../../../platform/models/domain/enc-string"; import { SymmetricCryptoKey } from "../../../platform/models/domain/symmetric-crypto-key"; import { ContainerService } from "../../../platform/services/container.service"; +import { UserId } from "../../../types/guid"; import { UserKey } from "../../../types/key"; import { SendType } from "../enums/send-type"; import { SendFileApi } from "../models/api/send-file.api"; @@ -16,10 +25,17 @@ import { SendTextApi } from "../models/api/send-text.api"; import { SendFileData } from "../models/data/send-file.data"; import { SendTextData } from "../models/data/send-text.data"; import { SendData } from "../models/data/send.data"; -import { Send } from "../models/domain/send"; import { SendView } from "../models/view/send.view"; +import { SEND_USER_DECRYPTED, SEND_USER_ENCRYPTED } from "./key-definitions"; +import { SendStateProvider } from "./send-state.provider"; import { SendService } from "./send.service"; +import { + createSendData, + testSend, + testSendData, + testSendViewData, +} from "./test-data/send-tests.data"; describe("SendService", () => { const cryptoService = mock(); @@ -27,56 +43,53 @@ describe("SendService", () => { const keyGenerationService = mock(); const encryptService = mock(); + let sendStateProvider: SendStateProvider; let sendService: SendService; - let stateService: MockProxy; - let activeAccount: BehaviorSubject; - let activeAccountUnlocked: BehaviorSubject; + let stateProvider: FakeStateProvider; + let encryptedState: FakeActiveUserState>; + let decryptedState: FakeActiveUserState; + + const mockUserId = Utils.newGuid() as UserId; + let accountService: FakeAccountService; beforeEach(() => { - activeAccount = new BehaviorSubject("123"); - activeAccountUnlocked = new BehaviorSubject(true); + accountService = mockAccountServiceWith(mockUserId); + stateProvider = new FakeStateProvider(accountService); + sendStateProvider = new SendStateProvider(stateProvider); - stateService = mock(); - stateService.activeAccount$ = activeAccount; - stateService.activeAccountUnlocked$ = activeAccountUnlocked; (window as any).bitwardenContainerService = new ContainerService(cryptoService, encryptService); - stateService.getEncryptedSends.calledWith(any()).mockResolvedValue({ - "1": sendData("1", "Test Send"), + accountService.activeAccountSubject.next({ + id: mockUserId, + email: "email", + name: "name", + status: AuthenticationStatus.Unlocked, }); - stateService.getDecryptedSends - .calledWith(any()) - .mockResolvedValue([sendView("1", "Test Send")]); - - sendService = new SendService(cryptoService, i18nService, keyGenerationService, stateService); - }); - - afterEach(() => { - activeAccount.complete(); - activeAccountUnlocked.complete(); - }); - - describe("get", () => { - it("exists", async () => { - const result = sendService.get("1"); - - expect(result).toEqual(send("1", "Test Send")); + // Initial encrypted state + encryptedState = stateProvider.activeUser.getFake(SEND_USER_ENCRYPTED); + encryptedState.nextState({ + "1": testSendData("1", "Test Send"), }); + // Initial decrypted state + decryptedState = stateProvider.activeUser.getFake(SEND_USER_DECRYPTED); + decryptedState.nextState([testSendViewData("1", "Test Send")]); - it("does not exist", async () => { - const result = sendService.get("2"); - - expect(result).toBe(undefined); - }); + sendService = new SendService( + cryptoService, + i18nService, + keyGenerationService, + sendStateProvider, + encryptService, + ); }); describe("get$", () => { it("exists", async () => { const result = await firstValueFrom(sendService.get$("1")); - expect(result).toEqual(send("1", "Test Send")); + expect(result).toEqual(testSend("1", "Test Send")); }); it("does not exist", async () => { @@ -88,14 +101,14 @@ describe("SendService", () => { it("updated observable", async () => { const singleSendObservable = sendService.get$("1"); const result = await firstValueFrom(singleSendObservable); - expect(result).toEqual(send("1", "Test Send")); + expect(result).toEqual(testSend("1", "Test Send")); await sendService.replace({ - "1": sendData("1", "Test Send Updated"), + "1": testSendData("1", "Test Send Updated"), }); const result2 = await firstValueFrom(singleSendObservable); - expect(result2).toEqual(send("1", "Test Send Updated")); + expect(result2).toEqual(testSend("1", "Test Send Updated")); }); it("reports a change when name changes on a new send", async () => { @@ -103,13 +116,13 @@ describe("SendService", () => { sendService.get$("1").subscribe(() => { changed = true; }); - const sendDataObject = sendData("1", "Test Send 2"); + const sendDataObject = testSendData("1", "Test Send 2"); //it is immediately called when subscribed, we need to reset the value changed = false; await sendService.replace({ "1": sendDataObject, - "2": sendData("2", "Test Send 2"), + "2": testSendData("2", "Test Send 2"), }); expect(changed).toEqual(true); @@ -120,7 +133,7 @@ describe("SendService", () => { await sendService.replace({ "1": sendDataObject, - "2": sendData("2", "Test Send 2"), + "2": testSendData("2", "Test Send 2"), }); let changed = false; @@ -134,7 +147,7 @@ describe("SendService", () => { await sendService.replace({ "1": sendDataObject, - "2": sendData("2", "Test Send 2"), + "2": testSendData("2", "Test Send 2"), }); expect(changed).toEqual(true); @@ -145,7 +158,7 @@ describe("SendService", () => { await sendService.replace({ "1": sendDataObject, - "2": sendData("2", "Test Send 2"), + "2": testSendData("2", "Test Send 2"), }); let changed = false; @@ -159,7 +172,7 @@ describe("SendService", () => { sendDataObject.text.text = "new text"; await sendService.replace({ "1": sendDataObject, - "2": sendData("2", "Test Send 2"), + "2": testSendData("2", "Test Send 2"), }); expect(changed).toEqual(true); @@ -170,7 +183,7 @@ describe("SendService", () => { await sendService.replace({ "1": sendDataObject, - "2": sendData("2", "Test Send 2"), + "2": testSendData("2", "Test Send 2"), }); let changed = false; @@ -184,7 +197,7 @@ describe("SendService", () => { sendDataObject.text = null; await sendService.replace({ "1": sendDataObject, - "2": sendData("2", "Test Send 2"), + "2": testSendData("2", "Test Send 2"), }); expect(changed).toEqual(true); @@ -197,7 +210,7 @@ describe("SendService", () => { }) as SendData; await sendService.replace({ "1": sendDataObject, - "2": sendData("2", "Test Send 2"), + "2": testSendData("2", "Test Send 2"), }); sendDataObject.file = new SendFileData(new SendFileApi({ FileName: "updated name of file" })); @@ -211,7 +224,7 @@ describe("SendService", () => { await sendService.replace({ "1": sendDataObject, - "2": sendData("2", "Test Send 2"), + "2": testSendData("2", "Test Send 2"), }); expect(changed).toEqual(false); @@ -222,7 +235,7 @@ describe("SendService", () => { await sendService.replace({ "1": sendDataObject, - "2": sendData("2", "Test Send 2"), + "2": testSendData("2", "Test Send 2"), }); let changed = false; @@ -236,7 +249,7 @@ describe("SendService", () => { sendDataObject.key = "newKey"; await sendService.replace({ "1": sendDataObject, - "2": sendData("2", "Test Send 2"), + "2": testSendData("2", "Test Send 2"), }); expect(changed).toEqual(true); @@ -247,7 +260,7 @@ describe("SendService", () => { await sendService.replace({ "1": sendDataObject, - "2": sendData("2", "Test Send 2"), + "2": testSendData("2", "Test Send 2"), }); let changed = false; @@ -261,7 +274,7 @@ describe("SendService", () => { sendDataObject.revisionDate = "2025-04-05"; await sendService.replace({ "1": sendDataObject, - "2": sendData("2", "Test Send 2"), + "2": testSendData("2", "Test Send 2"), }); expect(changed).toEqual(true); @@ -272,7 +285,7 @@ describe("SendService", () => { await sendService.replace({ "1": sendDataObject, - "2": sendData("2", "Test Send 2"), + "2": testSendData("2", "Test Send 2"), }); let changed = false; @@ -286,7 +299,7 @@ describe("SendService", () => { sendDataObject.name = null; await sendService.replace({ "1": sendDataObject, - "2": sendData("2", "Test Send 2"), + "2": testSendData("2", "Test Send 2"), }); expect(changed).toEqual(true); @@ -299,7 +312,7 @@ describe("SendService", () => { await sendService.replace({ "1": sendDataObject, - "2": sendData("2", "Test Send 2"), + "2": testSendData("2", "Test Send 2"), }); let changed = false; @@ -312,7 +325,7 @@ describe("SendService", () => { await sendService.replace({ "1": sendDataObject, - "2": sendData("2", "Test Send 2"), + "2": testSendData("2", "Test Send 2"), }); expect(changed).toEqual(false); @@ -320,7 +333,7 @@ describe("SendService", () => { sendDataObject.text.text = "Asdf"; await sendService.replace({ "1": sendDataObject, - "2": sendData("2", "Test Send 2"), + "2": testSendData("2", "Test Send 2"), }); expect(changed).toEqual(true); @@ -332,14 +345,14 @@ describe("SendService", () => { changed = true; }); - const sendDataObject = sendData("1", "Test Send"); + const sendDataObject = testSendData("1", "Test Send"); //it is immediately called when subscribed, we need to reset the value changed = false; await sendService.replace({ "1": sendDataObject, - "2": sendData("3", "Test Send 3"), + "2": testSendData("3", "Test Send 3"), }); expect(changed).toEqual(false); @@ -354,7 +367,7 @@ describe("SendService", () => { changed = false; await sendService.replace({ - "2": sendData("2", "Test Send 2"), + "2": testSendData("2", "Test Send 2"), }); expect(changed).toEqual(true); @@ -366,14 +379,14 @@ describe("SendService", () => { const send1 = sends[0]; expect(sends).toHaveLength(1); - expect(send1).toEqual(send("1", "Test Send")); + expect(send1).toEqual(testSend("1", "Test Send")); }); describe("getFromState", () => { it("exists", async () => { const result = await sendService.getFromState("1"); - expect(result).toEqual(send("1", "Test Send")); + expect(result).toEqual(testSend("1", "Test Send")); }); it("does not exist", async () => { const result = await sendService.getFromState("2"); @@ -383,17 +396,17 @@ describe("SendService", () => { }); it("getAllDecryptedFromState", async () => { - await sendService.getAllDecryptedFromState(); + const sends = await sendService.getAllDecryptedFromState(); - expect(stateService.getDecryptedSends).toHaveBeenCalledTimes(1); + expect(sends[0]).toMatchObject(testSendViewData("1", "Test Send")); }); describe("getRotatedKeys", () => { let encryptedKey: EncString; beforeEach(() => { - cryptoService.decryptToBytes.mockResolvedValue(new Uint8Array(32)); + encryptService.decryptToBytes.mockResolvedValue(new Uint8Array(32)); encryptedKey = new EncString("Re-encrypted Send Key"); - cryptoService.encrypt.mockResolvedValue(encryptedKey); + encryptService.encrypt.mockResolvedValue(encryptedKey); }); it("returns re-encrypted user sends", async () => { @@ -408,6 +421,8 @@ describe("SendService", () => { // eslint-disable-next-line @typescript-eslint/no-floating-promises sendService.replace(null); + await awaitAsync(); + const newUserKey = new SymmetricCryptoKey(new Uint8Array(32)) as UserKey; const result = await sendService.getRotatedKeys(newUserKey); @@ -424,114 +439,51 @@ describe("SendService", () => { // InternalSendService it("upsert", async () => { - await sendService.upsert(sendData("2", "Test 2")); + await sendService.upsert(testSendData("2", "Test 2")); expect(await firstValueFrom(sendService.sends$)).toEqual([ - send("1", "Test Send"), - send("2", "Test 2"), + testSend("1", "Test Send"), + testSend("2", "Test 2"), ]); }); it("replace", async () => { - await sendService.replace({ "2": sendData("2", "test 2") }); + await sendService.replace({ "2": testSendData("2", "test 2") }); - expect(await firstValueFrom(sendService.sends$)).toEqual([send("2", "test 2")]); + expect(await firstValueFrom(sendService.sends$)).toEqual([testSend("2", "test 2")]); }); it("clear", async () => { await sendService.clear(); - + await awaitAsync(); expect(await firstValueFrom(sendService.sends$)).toEqual([]); }); + describe("Delete", () => { + it("Sends count should decrease after delete", async () => { + const sendsBeforeDelete = await firstValueFrom(sendService.sends$); + await sendService.delete(sendsBeforeDelete[0].id); - describe("delete", () => { - it("exists", async () => { - await sendService.delete("1"); - - expect(stateService.getEncryptedSends).toHaveBeenCalledTimes(2); - expect(stateService.setEncryptedSends).toHaveBeenCalledTimes(1); + const sendsAfterDelete = await firstValueFrom(sendService.sends$); + expect(sendsAfterDelete.length).toBeLessThan(sendsBeforeDelete.length); }); - it("does not exist", async () => { - // 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 - sendService.delete("1"); + it("Intended send should be delete", async () => { + const sendsBeforeDelete = await firstValueFrom(sendService.sends$); + await sendService.delete(sendsBeforeDelete[0].id); + const sendsAfterDelete = await firstValueFrom(sendService.sends$); + expect(sendsAfterDelete[0]).not.toBe(sendsBeforeDelete[0]); + }); - expect(stateService.getEncryptedSends).toHaveBeenCalledTimes(2); + it("Deleting on an empty sends array should not throw", async () => { + sendStateProvider.getEncryptedSends = jest.fn().mockResolvedValue(null); + await expect(sendService.delete("2")).resolves.not.toThrow(); + }); + + it("Delete multiple sends", async () => { + await sendService.upsert(testSendData("2", "send data 2")); + await sendService.delete(["1", "2"]); + const sendsAfterDelete = await firstValueFrom(sendService.sends$); + expect(sendsAfterDelete.length).toBe(0); }); }); - - // Send object helper functions - - function sendData(id: string, name: string) { - const data = new SendData({} as any); - data.id = id; - data.name = name; - data.disabled = false; - data.accessCount = 2; - data.accessId = "1"; - data.revisionDate = null; - data.expirationDate = null; - data.deletionDate = null; - data.notes = "Notes!!"; - data.key = null; - return data; - } - - const defaultSendData: Partial = { - id: "1", - name: "Test Send", - accessId: "123", - type: SendType.Text, - notes: "notes!", - file: null, - text: new SendTextData(new SendTextApi({ Text: "send text" })), - key: "key", - maxAccessCount: 12, - accessCount: 2, - revisionDate: "2024-09-04", - expirationDate: "2024-09-04", - deletionDate: "2024-09-04", - password: "password", - disabled: false, - hideEmail: false, - }; - - function createSendData(value: Partial = {}) { - const testSend: any = {}; - for (const prop in defaultSendData) { - testSend[prop] = value[prop as keyof SendData] ?? defaultSendData[prop as keyof SendData]; - } - return testSend; - } - - function sendView(id: string, name: string) { - const data = new SendView({} as any); - data.id = id; - data.name = name; - data.disabled = false; - data.accessCount = 2; - data.accessId = "1"; - data.revisionDate = null; - data.expirationDate = null; - data.deletionDate = null; - data.notes = "Notes!!"; - data.key = null; - return data; - } - - function send(id: string, name: string) { - const data = new Send({} as any); - data.id = id; - data.name = new EncString(name); - data.disabled = false; - data.accessCount = 2; - data.accessId = "1"; - data.revisionDate = null; - data.expirationDate = null; - data.deletionDate = null; - data.notes = new EncString("Notes!!"); - data.key = null; - return data; - } }); diff --git a/libs/common/src/tools/send/services/send.service.ts b/libs/common/src/tools/send/services/send.service.ts index 528f90c1dc..33b1f28be0 100644 --- a/libs/common/src/tools/send/services/send.service.ts +++ b/libs/common/src/tools/send/services/send.service.ts @@ -1,9 +1,9 @@ -import { BehaviorSubject, Observable, concatMap, distinctUntilChanged, map } from "rxjs"; +import { Observable, concatMap, distinctUntilChanged, firstValueFrom, map } from "rxjs"; import { CryptoService } from "../../../platform/abstractions/crypto.service"; +import { EncryptService } from "../../../platform/abstractions/encrypt.service"; import { I18nService } from "../../../platform/abstractions/i18n.service"; import { KeyGenerationService } from "../../../platform/abstractions/key-generation.service"; -import { StateService } from "../../../platform/abstractions/state.service"; import { KdfType } from "../../../platform/enums"; import { Utils } from "../../../platform/misc/utils"; import { EncArrayBuffer } from "../../../platform/models/domain/enc-array-buffer"; @@ -19,48 +19,29 @@ import { SendWithIdRequest } from "../models/request/send-with-id.request"; import { SendView } from "../models/view/send.view"; import { SEND_KDF_ITERATIONS } from "../send-kdf"; +import { SendStateProvider } from "./send-state.provider.abstraction"; import { InternalSendService as InternalSendServiceAbstraction } from "./send.service.abstraction"; export class SendService implements InternalSendServiceAbstraction { readonly sendKeySalt = "bitwarden-send"; readonly sendKeyPurpose = "send"; - protected _sends: BehaviorSubject = new BehaviorSubject([]); - protected _sendViews: BehaviorSubject = new BehaviorSubject([]); - - sends$ = this._sends.asObservable(); - sendViews$ = this._sendViews.asObservable(); + sends$ = this.stateProvider.encryptedState$.pipe( + map((record) => Object.values(record || {}).map((data) => new Send(data))), + ); + sendViews$ = this.stateProvider.encryptedState$.pipe( + concatMap((record) => + this.decryptSends(Object.values(record || {}).map((data) => new Send(data))), + ), + ); constructor( private cryptoService: CryptoService, private i18nService: I18nService, private keyGenerationService: KeyGenerationService, - private stateService: StateService, - ) { - this.stateService.activeAccountUnlocked$ - .pipe( - concatMap(async (unlocked) => { - if (Utils.global.bitwardenContainerService == null) { - return; - } - - if (!unlocked) { - this._sends.next([]); - this._sendViews.next([]); - return; - } - - const data = await this.stateService.getEncryptedSends(); - - await this.updateObservables(data); - }), - ) - .subscribe(); - } - - async clearCache(): Promise { - await this._sendViews.next([]); - } + private stateProvider: SendStateProvider, + private encryptService: EncryptService, + ) {} async encrypt( model: SendView, @@ -93,12 +74,15 @@ export class SendService implements InternalSendServiceAbstraction { ); send.password = passwordKey.keyB64; } - send.key = await this.cryptoService.encrypt(model.key, key); - send.name = await this.cryptoService.encrypt(model.name, model.cryptoKey); - send.notes = await this.cryptoService.encrypt(model.notes, model.cryptoKey); + if (key == null) { + key = await this.cryptoService.getUserKey(); + } + send.key = await this.encryptService.encrypt(model.key, key); + send.name = await this.encryptService.encrypt(model.name, model.cryptoKey); + send.notes = await this.encryptService.encrypt(model.notes, model.cryptoKey); if (send.type === SendType.Text) { send.text = new SendText(); - send.text.text = await this.cryptoService.encrypt(model.text.text, model.cryptoKey); + send.text.text = await this.encryptService.encrypt(model.text.text, model.cryptoKey); send.text.hidden = model.text.hidden; } else if (send.type === SendType.File) { send.file = new SendFile(); @@ -120,11 +104,6 @@ export class SendService implements InternalSendServiceAbstraction { return [send, fileData]; } - get(id: string): Send { - const sends = this._sends.getValue(); - return sends.find((send) => send.id === id); - } - get$(id: string): Observable { return this.sends$.pipe( distinctUntilChanged((oldSends, newSends) => { @@ -188,7 +167,7 @@ export class SendService implements InternalSendServiceAbstraction { } async getFromState(id: string): Promise { - const sends = await this.stateService.getEncryptedSends(); + const sends = await this.stateProvider.getEncryptedSends(); // eslint-disable-next-line if (sends == null || !sends.hasOwnProperty(id)) { return null; @@ -198,7 +177,7 @@ export class SendService implements InternalSendServiceAbstraction { } async getAll(): Promise { - const sends = await this.stateService.getEncryptedSends(); + const sends = await this.stateProvider.getEncryptedSends(); const response: Send[] = []; for (const id in sends) { // eslint-disable-next-line @@ -210,7 +189,7 @@ export class SendService implements InternalSendServiceAbstraction { } async getAllDecryptedFromState(): Promise { - let decSends = await this.stateService.getDecryptedSends(); + let decSends = await this.stateProvider.getDecryptedSends(); if (decSends != null) { return decSends; } @@ -230,12 +209,12 @@ export class SendService implements InternalSendServiceAbstraction { await Promise.all(promises); decSends.sort(Utils.getSortFunction(this.i18nService, "name")); - await this.stateService.setDecryptedSends(decSends); + await this.stateProvider.setDecryptedSends(decSends); return decSends; } async upsert(send: SendData | SendData[]): Promise { - let sends = await this.stateService.getEncryptedSends(); + let sends = await this.stateProvider.getEncryptedSends(); if (sends == null) { sends = {}; } @@ -252,16 +231,12 @@ export class SendService implements InternalSendServiceAbstraction { } async clear(userId?: string): Promise { - if (userId == null || userId == (await this.stateService.getUserId())) { - this._sends.next([]); - this._sendViews.next([]); - } - await this.stateService.setDecryptedSends(null, { userId: userId }); - await this.stateService.setEncryptedSends(null, { userId: userId }); + await this.stateProvider.setDecryptedSends(null); + await this.stateProvider.setEncryptedSends(null); } async delete(id: string | string[]): Promise { - const sends = await this.stateService.getEncryptedSends(); + const sends = await this.stateProvider.getEncryptedSends(); if (sends == null) { return; } @@ -281,8 +256,7 @@ export class SendService implements InternalSendServiceAbstraction { } async replace(sends: { [id: string]: SendData }): Promise { - await this.updateObservables(sends); - await this.stateService.setEncryptedSends(sends); + await this.stateProvider.setEncryptedSends(sends); } async getRotatedKeys(newUserKey: UserKey): Promise { @@ -290,14 +264,21 @@ export class SendService implements InternalSendServiceAbstraction { throw new Error("New user key is required for rotation."); } + const req = await firstValueFrom( + this.sends$.pipe(concatMap(async (sends) => this.toRotatedKeyRequestMap(sends, newUserKey))), + ); + // separate return for easier debugging + return req; + } + + private async toRotatedKeyRequestMap(sends: Send[], newUserKey: UserKey) { const requests = await Promise.all( - this._sends.value.map(async (send) => { - const sendKey = await this.cryptoService.decryptToBytes(send.key); - send.key = await this.cryptoService.encrypt(sendKey, newUserKey); + sends.map(async (send) => { + const sendKey = await this.encryptService.decryptToBytes(send.key, newUserKey); + send.key = await this.encryptService.encrypt(sendKey, newUserKey); return new SendWithIdRequest(send); }), ); - // separate return for easier debugging return requests; } @@ -329,18 +310,12 @@ export class SendService implements InternalSendServiceAbstraction { data: ArrayBuffer, key: SymmetricCryptoKey, ): Promise<[EncString, EncArrayBuffer]> { - const encFileName = await this.cryptoService.encrypt(fileName, key); - const encFileData = await this.cryptoService.encryptToBytes(new Uint8Array(data), key); - return [encFileName, encFileData]; - } - - private async updateObservables(sendsMap: { [id: string]: SendData }) { - const sends = Object.values(sendsMap || {}).map((f) => new Send(f)); - this._sends.next(sends); - - if (await this.cryptoService.hasUserKey()) { - this._sendViews.next(await this.decryptSends(sends)); + if (key == null) { + key = await this.cryptoService.getUserKey(); } + const encFileName = await this.encryptService.encrypt(fileName, key); + const encFileData = await this.encryptService.encryptToBytes(new Uint8Array(data), key); + return [encFileName, encFileData]; } private async decryptSends(sends: Send[]) { diff --git a/libs/common/src/tools/send/services/test-data/send-tests.data.ts b/libs/common/src/tools/send/services/test-data/send-tests.data.ts new file mode 100644 index 0000000000..a57a39782e --- /dev/null +++ b/libs/common/src/tools/send/services/test-data/send-tests.data.ts @@ -0,0 +1,79 @@ +import { EncString } from "../../../../platform/models/domain/enc-string"; +import { SendType } from "../../enums/send-type"; +import { SendTextApi } from "../../models/api/send-text.api"; +import { SendTextData } from "../../models/data/send-text.data"; +import { SendData } from "../../models/data/send.data"; +import { Send } from "../../models/domain/send"; +import { SendView } from "../../models/view/send.view"; + +export function testSendViewData(id: string, name: string) { + const data = new SendView({} as any); + data.id = id; + data.name = name; + data.disabled = false; + data.accessCount = 2; + data.accessId = "1"; + data.revisionDate = null; + data.expirationDate = null; + data.deletionDate = null; + data.notes = "Notes!!"; + data.key = null; + return data; +} + +export function createSendData(value: Partial = {}) { + const defaultSendData: Partial = { + id: "1", + name: "Test Send", + accessId: "123", + type: SendType.Text, + notes: "notes!", + file: null, + text: new SendTextData(new SendTextApi({ Text: "send text" })), + key: "key", + maxAccessCount: 12, + accessCount: 2, + revisionDate: "2024-09-04", + expirationDate: "2024-09-04", + deletionDate: "2024-09-04", + password: "password", + disabled: false, + hideEmail: false, + }; + + const testSend: any = {}; + for (const prop in defaultSendData) { + testSend[prop] = value[prop as keyof SendData] ?? defaultSendData[prop as keyof SendData]; + } + return testSend; +} + +export function testSendData(id: string, name: string) { + const data = new SendData({} as any); + data.id = id; + data.name = name; + data.disabled = false; + data.accessCount = 2; + data.accessId = "1"; + data.revisionDate = null; + data.expirationDate = null; + data.deletionDate = null; + data.notes = "Notes!!"; + data.key = null; + return data; +} + +export function testSend(id: string, name: string) { + const data = new Send({} as any); + data.id = id; + data.name = new EncString(name); + data.disabled = false; + data.accessCount = 2; + data.accessId = "1"; + data.revisionDate = null; + data.expirationDate = null; + data.deletionDate = null; + data.notes = new EncString("Notes!!"); + data.key = null; + return data; +} diff --git a/libs/common/src/vault/services/sync/sync.service.ts b/libs/common/src/vault/services/sync/sync.service.ts index 3e8bd92a7a..d4601d9621 100644 --- a/libs/common/src/vault/services/sync/sync.service.ts +++ b/libs/common/src/vault/services/sync/sync.service.ts @@ -244,7 +244,7 @@ export class SyncService implements SyncServiceAbstraction { this.syncStarted(); if (await this.stateService.getIsAuthenticated()) { try { - const localSend = this.sendService.get(notification.id); + const localSend = await firstValueFrom(this.sendService.get$(notification.id)); if ( (!isEdit && localSend == null) || (isEdit && localSend != null && localSend.revisionDate < notification.revisionDate)