diff --git a/apps/browser/src/background/main.background.ts b/apps/browser/src/background/main.background.ts index 908f1211b5..f4ffb1f978 100644 --- a/apps/browser/src/background/main.background.ts +++ b/apps/browser/src/background/main.background.ts @@ -44,6 +44,7 @@ import { ApiService } from "@bitwarden/common/services/api.service"; import { AppIdService } from "@bitwarden/common/services/appId.service"; import { AuditService } from "@bitwarden/common/services/audit.service"; import { AuthService } from "@bitwarden/common/services/auth.service"; +import { BroadcasterService } from "@bitwarden/common/services/broadcaster.service"; import { CipherService } from "@bitwarden/common/services/cipher.service"; import { CollectionService } from "@bitwarden/common/services/collection.service"; import { ConsoleLogService } from "@bitwarden/common/services/consoleLog.service"; @@ -149,6 +150,7 @@ export default class MainBackground { vaultFilterService: VaultFilterService; usernameGenerationService: UsernameGenerationServiceAbstraction; encryptService: EncryptService; + broadcasterService: BroadcasterService; folderApiService: FolderApiServiceAbstraction; onUpdatedRan: boolean; @@ -267,11 +269,13 @@ export default class MainBackground { this.logService, this.stateService ); + this.broadcasterService = new BroadcasterService(); this.folderService = new FolderService( this.cryptoService, this.i18nService, this.cipherService, - this.stateService + this.stateService, + this.broadcasterService ); this.folderApiService = new FolderApiService(this.folderService, this.apiService); this.collectionService = new CollectionService( diff --git a/apps/browser/src/background/notification.background.ts b/apps/browser/src/background/notification.background.ts index f8db259cb7..7cbdd0993e 100644 --- a/apps/browser/src/background/notification.background.ts +++ b/apps/browser/src/background/notification.background.ts @@ -1,3 +1,5 @@ +import { firstValueFrom } from "rxjs"; + import { AuthService } from "@bitwarden/common/abstractions/auth.service"; import { CipherService } from "@bitwarden/common/abstractions/cipher.service"; import { FolderService } from "@bitwarden/common/abstractions/folder/folder.service.abstraction"; @@ -385,7 +387,7 @@ export default class NotificationBackground { model.login = loginModel; if (!Utils.isNullOrWhitespace(folderId)) { - const folders = await this.folderService.getAllDecrypted(); + const folders = await firstValueFrom(this.folderService.folderViews$); if (folders.some((x) => x.id === folderId)) { model.folderId = folderId; } @@ -437,7 +439,7 @@ export default class NotificationBackground { private async getDataForTab(tab: chrome.tabs.Tab, responseCommand: string) { const responseData: any = {}; if (responseCommand === "notificationBarGetFoldersList") { - responseData.folders = await this.folderService.getAllDecrypted(); + responseData.folders = await firstValueFrom(this.folderService.folderViews$); } await BrowserApi.tabSendMessageData(tab, responseCommand, responseData); diff --git a/apps/browser/src/popup/services/services.module.ts b/apps/browser/src/popup/services/services.module.ts index 63360194cd..657c64fe72 100644 --- a/apps/browser/src/popup/services/services.module.ts +++ b/apps/browser/src/popup/services/services.module.ts @@ -11,6 +11,7 @@ import { ApiService } from "@bitwarden/common/abstractions/api.service"; import { AppIdService } from "@bitwarden/common/abstractions/appId.service"; import { AuditService } from "@bitwarden/common/abstractions/audit.service"; import { AuthService as AuthServiceAbstraction } from "@bitwarden/common/abstractions/auth.service"; +import { BroadcasterService as BroadcasterServiceAbstraction } from "@bitwarden/common/abstractions/broadcaster.service"; import { CipherService } from "@bitwarden/common/abstractions/cipher.service"; import { CollectionService } from "@bitwarden/common/abstractions/collection.service"; import { CryptoService } from "@bitwarden/common/abstractions/crypto.service"; @@ -111,6 +112,10 @@ function getBgService(service: keyof MainBackground) { : new BrowserMessagingService(); }, }, + { + provide: BroadcasterServiceAbstraction, + useFactory: getBgService("broadcasterService"), + }, { provide: TwoFactorService, useFactory: getBgService("twoFactorService"), diff --git a/apps/browser/src/popup/settings/folders.component.html b/apps/browser/src/popup/settings/folders.component.html index 47b6f78a1d..23e2e22789 100644 --- a/apps/browser/src/popup/settings/folders.component.html +++ b/apps/browser/src/popup/settings/folders.component.html @@ -20,20 +20,20 @@
-
+
-
+

{{ "noFolders" | i18n }}

diff --git a/apps/browser/src/popup/settings/folders.component.ts b/apps/browser/src/popup/settings/folders.component.ts index 0e2a603621..f0fb2204d8 100644 --- a/apps/browser/src/popup/settings/folders.component.ts +++ b/apps/browser/src/popup/settings/folders.component.ts @@ -1,5 +1,6 @@ -import { Component, OnInit } from "@angular/core"; +import { Component } from "@angular/core"; import { Router } from "@angular/router"; +import { map, Observable } from "rxjs"; import { FolderService } from "@bitwarden/common/abstractions/folder/folder.service.abstraction"; import { FolderView } from "@bitwarden/common/models/view/folderView"; @@ -8,17 +9,19 @@ import { FolderView } from "@bitwarden/common/models/view/folderView"; selector: "app-folders", templateUrl: "folders.component.html", }) -export class FoldersComponent implements OnInit { - folders: FolderView[]; +export class FoldersComponent { + folders$: Observable; - constructor(private folderService: FolderService, private router: Router) {} + constructor(private folderService: FolderService, private router: Router) { + this.folders$ = this.folderService.folderViews$.pipe( + map((folders) => { + if (folders.length > 0) { + folders = folders.slice(0, folders.length - 1); + } - async ngOnInit() { - this.folders = await this.folderService.getAllDecrypted(); - // Remove "No Folder" - if (this.folders.length > 0) { - this.folders = this.folders.slice(0, this.folders.length - 1); - } + return folders; + }) + ); } folderSelected(folder: FolderView) { diff --git a/apps/browser/src/popup/vault/add-edit.component.html b/apps/browser/src/popup/vault/add-edit.component.html index 9876da114c..2686eb32fb 100644 --- a/apps/browser/src/popup/vault/add-edit.component.html +++ b/apps/browser/src/popup/vault/add-edit.component.html @@ -515,7 +515,7 @@
diff --git a/apps/browser/src/popup/vault/ciphers.component.ts b/apps/browser/src/popup/vault/ciphers.component.ts index 383633a1c2..8637cc57b6 100644 --- a/apps/browser/src/popup/vault/ciphers.component.ts +++ b/apps/browser/src/popup/vault/ciphers.component.ts @@ -120,7 +120,7 @@ export class CiphersComponent extends BaseCiphersComponent implements OnInit, On this.searchPlaceholder = this.i18nService.t("searchFolder"); if (this.folderId != null) { this.showOrganizations = false; - const folderNode = await this.folderService.getNested(this.folderId); + const folderNode = await this.vaultFilterService.getFolderNested(this.folderId); if (folderNode != null && folderNode.node != null) { this.groupingTitle = folderNode.node.name; this.nestedFolders = diff --git a/apps/browser/src/popup/vault/vault-filter.component.ts b/apps/browser/src/popup/vault/vault-filter.component.ts index d068686e54..ddace24de5 100644 --- a/apps/browser/src/popup/vault/vault-filter.component.ts +++ b/apps/browser/src/popup/vault/vault-filter.component.ts @@ -1,6 +1,7 @@ import { Location } from "@angular/common"; import { ChangeDetectorRef, Component, NgZone, OnDestroy, OnInit } from "@angular/core"; import { ActivatedRoute, Router } from "@angular/router"; +import { firstValueFrom } from "rxjs"; import { first } from "rxjs/operators"; import { VaultFilter } from "@bitwarden/angular/modules/vault-filter/models/vault-filter.model"; @@ -182,9 +183,11 @@ export class VaultFilterComponent implements OnInit, OnDestroy { } async loadFolders() { - const allFolders = await this.vaultFilterService.buildFolders(this.selectedOrganization); + const allFolders = await firstValueFrom( + this.vaultFilterService.buildNestedFolders(this.selectedOrganization) + ); this.folders = allFolders.fullList; - this.nestedFolders = await allFolders.nestedList; + this.nestedFolders = allFolders.nestedList; } async search(timeout: number = null) { diff --git a/apps/cli/src/bw.ts b/apps/cli/src/bw.ts index 82a63bff96..45df7aa52b 100644 --- a/apps/cli/src/bw.ts +++ b/apps/cli/src/bw.ts @@ -14,6 +14,7 @@ import { GlobalState } from "@bitwarden/common/models/domain/globalState"; import { AppIdService } from "@bitwarden/common/services/appId.service"; import { AuditService } from "@bitwarden/common/services/audit.service"; import { AuthService } from "@bitwarden/common/services/auth.service"; +import { BroadcasterService } from "@bitwarden/common/services/broadcaster.service"; import { CipherService } from "@bitwarden/common/services/cipher.service"; import { CollectionService } from "@bitwarden/common/services/collection.service"; import { ContainerService } from "@bitwarden/common/services/container.service"; @@ -103,6 +104,7 @@ export class Main { organizationService: OrganizationService; providerService: ProviderService; twoFactorService: TwoFactorService; + broadcasterService: BroadcasterService; folderApiService: FolderApiService; constructor() { @@ -198,11 +200,14 @@ export class Main { this.stateService ); + this.broadcasterService = new BroadcasterService(); + this.folderService = new FolderService( this.cryptoService, this.i18nService, this.cipherService, - this.stateService + this.stateService, + this.broadcasterService ); this.folderApiService = new FolderApiService(this.folderService, this.apiService); diff --git a/apps/cli/src/commands/get.command.ts b/apps/cli/src/commands/get.command.ts index c597745fcb..bdf71b1f08 100644 --- a/apps/cli/src/commands/get.command.ts +++ b/apps/cli/src/commands/get.command.ts @@ -1,3 +1,5 @@ +import { firstValueFrom } from "rxjs"; + import { ApiService } from "@bitwarden/common/abstractions/api.service"; import { AuditService } from "@bitwarden/common/abstractions/audit.service"; import { CipherService } from "@bitwarden/common/abstractions/cipher.service"; @@ -358,7 +360,7 @@ export class GetCommand extends DownloadCommand { decFolder = await folder.decrypt(); } } else if (id.trim() !== "") { - let folders = await this.folderService.getAllDecrypted(); + let folders = await firstValueFrom(this.folderService.folderViews$); folders = CliUtils.searchFolders(folders, id); if (folders.length > 1) { return Response.multipleResults(folders.map((f) => f.id)); diff --git a/apps/cli/src/commands/list.command.ts b/apps/cli/src/commands/list.command.ts index e32bc3f791..c4b4c592af 100644 --- a/apps/cli/src/commands/list.command.ts +++ b/apps/cli/src/commands/list.command.ts @@ -1,3 +1,5 @@ +import { firstValueFrom } from "rxjs"; + import { ApiService } from "@bitwarden/common/abstractions/api.service"; import { CipherService } from "@bitwarden/common/abstractions/cipher.service"; import { CollectionService } from "@bitwarden/common/abstractions/collection.service"; @@ -126,7 +128,7 @@ export class ListCommand { } private async listFolders(options: Options) { - let folders = await this.folderService.getAllDecrypted(); + let folders = await firstValueFrom(this.folderService.folderViews$); if (options.search != null && options.search.trim() !== "") { folders = CliUtils.searchFolders(folders, options.search); diff --git a/apps/desktop/src/app/modules/vault-filter/vault-filter.component.html b/apps/desktop/src/app/modules/vault-filter/vault-filter.component.html index 62158fd332..f44fc8f930 100644 --- a/apps/desktop/src/app/modules/vault-filter/vault-filter.component.html +++ b/apps/desktop/src/app/modules/vault-filter/vault-filter.component.html @@ -32,7 +32,7 @@ [hide]="hideFolders" [activeFilter]="activeFilter" [collapsedFilterNodes]="collapsedFilterNodes" - [folderNodes]="folders" + [folderNodes]="folders$ | async" (onNodeCollapseStateChange)="toggleFilterNodeCollapseState($event)" (onFilterChange)="applyFilter($event)" (onAddFolder)="addFolder()" diff --git a/apps/desktop/src/app/vault/add-edit.component.html b/apps/desktop/src/app/vault/add-edit.component.html index 6f78e0ceba..05c9e130e0 100644 --- a/apps/desktop/src/app/vault/add-edit.component.html +++ b/apps/desktop/src/app/vault/add-edit.component.html @@ -455,7 +455,7 @@
diff --git a/apps/web/src/app/modules/vault-filter/vault-filter.component.html b/apps/web/src/app/modules/vault-filter/vault-filter.component.html index bbe75c61e6..af77967e64 100644 --- a/apps/web/src/app/modules/vault-filter/vault-filter.component.html +++ b/apps/web/src/app/modules/vault-filter/vault-filter.component.html @@ -57,7 +57,7 @@ [hide]="hideFolders" [activeFilter]="activeFilter" [collapsedFilterNodes]="collapsedFilterNodes" - [folderNodes]="folders" + [folderNodes]="folders$ | async" (onNodeCollapseStateChange)="toggleFilterNodeCollapseState($event)" (onFilterChange)="applyFilter($event)" (onAddFolder)="addFolder()" diff --git a/apps/web/src/app/settings/change-password.component.ts b/apps/web/src/app/settings/change-password.component.ts index 073314510f..d36a1eb1cb 100644 --- a/apps/web/src/app/settings/change-password.component.ts +++ b/apps/web/src/app/settings/change-password.component.ts @@ -1,5 +1,6 @@ import { Component } from "@angular/core"; import { Router } from "@angular/router"; +import { firstValueFrom } from "rxjs"; import { ChangePasswordComponent as BaseChangePasswordComponent } from "@bitwarden/angular/components/change-password.component"; import { ApiService } from "@bitwarden/common/abstractions/api.service"; @@ -192,7 +193,7 @@ export class ChangePasswordComponent extends BaseChangePasswordComponent { request.key = encKey[1].encryptedString; request.masterPasswordHash = masterPasswordHash; - const folders = await this.folderService.getAllDecrypted(); + const folders = await firstValueFrom(this.folderService.folderViews$); for (let i = 0; i < folders.length; i++) { if (folders[i].id == null) { continue; diff --git a/apps/web/src/app/settings/update-key.component.ts b/apps/web/src/app/settings/update-key.component.ts index d771d57a79..b995ce42bf 100644 --- a/apps/web/src/app/settings/update-key.component.ts +++ b/apps/web/src/app/settings/update-key.component.ts @@ -1,4 +1,5 @@ import { Component } from "@angular/core"; +import { firstValueFrom } from "rxjs"; import { ApiService } from "@bitwarden/common/abstractions/api.service"; import { CipherService } from "@bitwarden/common/abstractions/cipher.service"; @@ -81,7 +82,7 @@ export class UpdateKeyComponent { await this.syncService.fullSync(true); - const folders = await this.folderService.getAllDecrypted(); + const folders = await firstValueFrom(this.folderService.folderViews$); for (let i = 0; i < folders.length; i++) { if (folders[i].id == null) { continue; diff --git a/apps/web/src/app/vault/add-edit.component.html b/apps/web/src/app/vault/add-edit.component.html index e21b1ae10a..54b707c9f6 100644 --- a/apps/web/src/app/vault/add-edit.component.html +++ b/apps/web/src/app/vault/add-edit.component.html @@ -60,7 +60,7 @@ class="form-control" [disabled]="cipher.isDeleted || viewOnly" > - +
diff --git a/apps/web/src/app/vault/bulk-move.component.html b/apps/web/src/app/vault/bulk-move.component.html index 866cc49c2c..552b2efa51 100644 --- a/apps/web/src/app/vault/bulk-move.component.html +++ b/apps/web/src/app/vault/bulk-move.component.html @@ -19,7 +19,7 @@
diff --git a/apps/web/src/app/vault/bulk-move.component.ts b/apps/web/src/app/vault/bulk-move.component.ts index c4c674a9cc..29e017b935 100644 --- a/apps/web/src/app/vault/bulk-move.component.ts +++ b/apps/web/src/app/vault/bulk-move.component.ts @@ -1,4 +1,5 @@ import { Component, EventEmitter, Input, OnInit, Output } from "@angular/core"; +import { firstValueFrom, Observable } from "rxjs"; import { CipherService } from "@bitwarden/common/abstractions/cipher.service"; import { FolderService } from "@bitwarden/common/abstractions/folder/folder.service.abstraction"; @@ -15,7 +16,7 @@ export class BulkMoveComponent implements OnInit { @Output() onMoved = new EventEmitter(); folderId: string = null; - folders: FolderView[] = []; + folders$: Observable; formPromise: Promise; constructor( @@ -26,8 +27,8 @@ export class BulkMoveComponent implements OnInit { ) {} async ngOnInit() { - this.folders = await this.folderService.getAllDecrypted(); - this.folderId = this.folders[0].id; + this.folders$ = this.folderService.folderViews$; + this.folderId = (await firstValueFrom(this.folders$))[0].id; } async submit() { diff --git a/libs/angular/src/components/add-edit.component.ts b/libs/angular/src/components/add-edit.component.ts index aeac25aade..bac45023a9 100644 --- a/libs/angular/src/components/add-edit.component.ts +++ b/libs/angular/src/components/add-edit.component.ts @@ -1,4 +1,5 @@ import { Directive, EventEmitter, Input, OnInit, Output } from "@angular/core"; +import { Observable } from "rxjs"; import { AuditService } from "@bitwarden/common/abstractions/audit.service"; import { CipherService } from "@bitwarden/common/abstractions/cipher.service"; @@ -51,7 +52,7 @@ export class AddEditComponent implements OnInit { editMode = false; cipher: CipherView; - folders: FolderView[]; + folders$: Observable; collections: CollectionView[] = []; title: string; formPromise: Promise; @@ -243,7 +244,7 @@ export class AddEditComponent implements OnInit { } } - this.folders = await this.folderService.getAllDecrypted(); + this.folders$ = this.folderService.folderViews$; if (this.editMode && this.previousCipherId !== this.cipherId) { this.eventService.collect(EventType.Cipher_ClientViewed, this.cipherId); diff --git a/libs/angular/src/modules/vault-filter/vault-filter.component.ts b/libs/angular/src/modules/vault-filter/vault-filter.component.ts index 2e77e2fd87..84396472ef 100644 --- a/libs/angular/src/modules/vault-filter/vault-filter.component.ts +++ b/libs/angular/src/modules/vault-filter/vault-filter.component.ts @@ -1,4 +1,5 @@ import { Directive, EventEmitter, Input, OnInit, Output } from "@angular/core"; +import { firstValueFrom, Observable } from "rxjs"; import { Organization } from "@bitwarden/common/models/domain/organization"; import { ITreeNodeObject } from "@bitwarden/common/models/domain/treeNode"; @@ -28,7 +29,7 @@ export class VaultFilterComponent implements OnInit { activePersonalOwnershipPolicy: boolean; activeSingleOrganizationPolicy: boolean; collections: DynamicTreeNode; - folders: DynamicTreeNode; + folders$: Observable>; constructor(protected vaultFilterService: VaultFilterService) {} @@ -45,7 +46,7 @@ export class VaultFilterComponent implements OnInit { this.activeSingleOrganizationPolicy = await this.vaultFilterService.checkForSingleOrganizationPolicy(); } - this.folders = await this.vaultFilterService.buildFolders(); + this.folders$ = await this.vaultFilterService.buildNestedFolders(); this.collections = await this.initCollections(); this.isLoaded = true; } @@ -67,13 +68,13 @@ export class VaultFilterComponent implements OnInit { async applyFilter(filter: VaultFilter) { if (filter.refreshCollectionsAndFolders) { await this.reloadCollectionsAndFolders(filter); - filter = this.pruneInvalidatedFilterSelections(filter); + filter = await this.pruneInvalidatedFilterSelections(filter); } this.onFilterChange.emit(filter); } async reloadCollectionsAndFolders(filter: VaultFilter) { - this.folders = await this.vaultFilterService.buildFolders(filter.selectedOrganizationId); + this.folders$ = await this.vaultFilterService.buildNestedFolders(filter.selectedOrganizationId); this.collections = filter.myVaultOnly ? null : await this.vaultFilterService.buildCollections(filter.selectedOrganizationId); @@ -95,14 +96,17 @@ export class VaultFilterComponent implements OnInit { this.onEditFolder.emit(folder); } - protected pruneInvalidatedFilterSelections(filter: VaultFilter): VaultFilter { - filter = this.pruneInvalidFolderSelection(filter); + protected async pruneInvalidatedFilterSelections(filter: VaultFilter): Promise { + filter = await this.pruneInvalidFolderSelection(filter); filter = this.pruneInvalidCollectionSelection(filter); return filter; } - protected pruneInvalidFolderSelection(filter: VaultFilter): VaultFilter { - if (filter.selectedFolder && !this.folders?.hasId(filter.selectedFolderId)) { + protected async pruneInvalidFolderSelection(filter: VaultFilter): Promise { + if ( + filter.selectedFolder && + !(await firstValueFrom(this.folders$))?.hasId(filter.selectedFolderId) + ) { filter.selectedFolder = false; filter.selectedFolderId = null; } diff --git a/libs/angular/src/modules/vault-filter/vault-filter.service.ts b/libs/angular/src/modules/vault-filter/vault-filter.service.ts index 6223433c03..294774948d 100644 --- a/libs/angular/src/modules/vault-filter/vault-filter.service.ts +++ b/libs/angular/src/modules/vault-filter/vault-filter.service.ts @@ -1,4 +1,5 @@ import { Injectable } from "@angular/core"; +import { from, mergeMap, Observable } from "rxjs"; import { CipherService } from "@bitwarden/common/abstractions/cipher.service"; import { CollectionService } from "@bitwarden/common/abstractions/collection.service"; @@ -7,12 +8,16 @@ import { OrganizationService } from "@bitwarden/common/abstractions/organization import { PolicyService } from "@bitwarden/common/abstractions/policy.service"; import { StateService } from "@bitwarden/common/abstractions/state.service"; import { PolicyType } from "@bitwarden/common/enums/policyType"; +import { ServiceUtils } from "@bitwarden/common/misc/serviceUtils"; import { Organization } from "@bitwarden/common/models/domain/organization"; +import { TreeNode } from "@bitwarden/common/models/domain/treeNode"; import { CollectionView } from "@bitwarden/common/models/view/collectionView"; import { FolderView } from "@bitwarden/common/models/view/folderView"; import { DynamicTreeNode } from "./models/dynamic-tree-node.model"; +const NestingDelimiter = "/"; + @Injectable() export class VaultFilterService { constructor( @@ -36,25 +41,30 @@ export class VaultFilterService { return await this.organizationService.getAll(); } - async buildFolders(organizationId?: string): Promise> { - const storedFolders = await this.folderService.getAllDecrypted(); - let folders: FolderView[]; - if (organizationId != null) { - const ciphers = await this.cipherService.getAllDecrypted(); - const orgCiphers = ciphers.filter((c) => c.organizationId == organizationId); - folders = storedFolders.filter( - (f) => - orgCiphers.filter((oc) => oc.folderId == f.id).length > 0 || - ciphers.filter((c) => c.folderId == f.id).length < 1 - ); - } else { - folders = storedFolders; - } - const nestedFolders = await this.folderService.getAllNested(folders); - return new DynamicTreeNode({ - fullList: folders, - nestedList: nestedFolders, - }); + buildNestedFolders(organizationId?: string): Observable> { + const transformation = async (storedFolders: FolderView[]) => { + let folders: FolderView[]; + if (organizationId != null) { + const ciphers = await this.cipherService.getAllDecrypted(); + const orgCiphers = ciphers.filter((c) => c.organizationId == organizationId); + folders = storedFolders.filter( + (f) => + orgCiphers.filter((oc) => oc.folderId == f.id).length > 0 || + ciphers.filter((c) => c.folderId == f.id).length < 1 + ); + } else { + folders = storedFolders; + } + const nestedFolders = await this.getAllFoldersNested(folders); + return new DynamicTreeNode({ + fullList: folders, + nestedList: nestedFolders, + }); + }; + + return this.folderService.folderViews$.pipe( + mergeMap((folders) => from(transformation(folders))) + ); } async buildCollections(organizationId?: string): Promise> { @@ -79,4 +89,21 @@ export class VaultFilterService { async checkForPersonalOwnershipPolicy(): Promise { return await this.policyService.policyAppliesToUser(PolicyType.PersonalOwnership); } + + protected async getAllFoldersNested(folders?: FolderView[]): Promise[]> { + const nodes: TreeNode[] = []; + folders.forEach((f) => { + const folderCopy = new FolderView(); + folderCopy.id = f.id; + folderCopy.revisionDate = f.revisionDate; + const parts = f.name != null ? f.name.replace(/^\/+|\/+$/g, "").split(NestingDelimiter) : []; + ServiceUtils.nestedTraverse(nodes, 0, parts, folderCopy, null, NestingDelimiter); + }); + return nodes; + } + + async getFolderNested(id: string): Promise> { + const folders = await this.getAllFoldersNested(); + return ServiceUtils.getTreeNodeObject(folders, id) as TreeNode; + } } diff --git a/libs/angular/src/services/jslib-services.module.ts b/libs/angular/src/services/jslib-services.module.ts index e90acd075e..1aa5ebaa54 100644 --- a/libs/angular/src/services/jslib-services.module.ts +++ b/libs/angular/src/services/jslib-services.module.ts @@ -223,6 +223,7 @@ export const LOG_MAC_FAILURES = new InjectionToken("LOG_MAC_FAILURES"); I18nServiceAbstraction, CipherServiceAbstraction, StateServiceAbstraction, + BroadcasterServiceAbstraction, ], }, { diff --git a/libs/common/spec/services/export.service.spec.ts b/libs/common/spec/services/export.service.spec.ts index 089073cb37..bad26ab351 100644 --- a/libs/common/spec/services/export.service.spec.ts +++ b/libs/common/spec/services/export.service.spec.ts @@ -1,4 +1,5 @@ import { Arg, Substitute, SubstituteOf } from "@fluffy-spoon/substitute"; +import { BehaviorSubject } from "rxjs"; import { ApiService } from "@bitwarden/common/abstractions/api.service"; import { CipherService } from "@bitwarden/common/abstractions/cipher.service"; @@ -97,8 +98,8 @@ describe("ExportService", () => { folderService = Substitute.for(); cryptoService = Substitute.for(); - folderService.getAllDecrypted().resolves([]); - folderService.getAll().resolves([]); + folderService.folderViews$.returns(new BehaviorSubject([])); + folderService.folders$.returns(new BehaviorSubject([])); exportService = new ExportService( folderService, diff --git a/libs/common/spec/services/folder.service.spec.ts b/libs/common/spec/services/folder.service.spec.ts new file mode 100644 index 0000000000..e4f269b536 --- /dev/null +++ b/libs/common/spec/services/folder.service.spec.ts @@ -0,0 +1,195 @@ +import { Arg, Substitute, SubstituteOf } from "@fluffy-spoon/substitute"; +import { BehaviorSubject, firstValueFrom } from "rxjs"; + +import { BroadcasterService } from "@bitwarden/common/abstractions/broadcaster.service"; +import { CipherService } from "@bitwarden/common/abstractions/cipher.service"; +import { CryptoService } from "@bitwarden/common/abstractions/crypto.service"; +import { I18nService } from "@bitwarden/common/abstractions/i18n.service"; +import { FolderData } from "@bitwarden/common/models/data/folderData"; +import { EncString } from "@bitwarden/common/models/domain/encString"; +import { FolderView } from "@bitwarden/common/models/view/folderView"; +import { ContainerService } from "@bitwarden/common/services/container.service"; +import { FolderService } from "@bitwarden/common/services/folder/folder.service"; +import { StateService } from "@bitwarden/common/services/state.service"; + +describe("Folder Service", () => { + let folderService: FolderService; + + let cryptoService: SubstituteOf; + let i18nService: SubstituteOf; + let cipherService: SubstituteOf; + let stateService: SubstituteOf; + let broadcasterService: SubstituteOf; + let activeAccount: BehaviorSubject; + + beforeEach(() => { + cryptoService = Substitute.for(); + i18nService = Substitute.for(); + cipherService = Substitute.for(); + stateService = Substitute.for(); + broadcasterService = Substitute.for(); + activeAccount = new BehaviorSubject("123"); + + stateService.getEncryptedFolders().resolves({ + "1": folderData("1", "test"), + }); + stateService.activeAccount.returns(activeAccount); + (window as any).bitwardenContainerService = new ContainerService(cryptoService); + + folderService = new FolderService( + cryptoService, + i18nService, + cipherService, + stateService, + broadcasterService + ); + }); + + it("encrypt", async () => { + const model = new FolderView(); + model.id = "2"; + model.name = "Test Folder"; + + cryptoService.encrypt(Arg.any()).resolves(new EncString("ENC")); + cryptoService.decryptToUtf8(Arg.any()).resolves("DEC"); + + const result = await folderService.encrypt(model); + + expect(result).toEqual({ + id: "2", + name: { + encryptedString: "ENC", + encryptionType: 0, + }, + }); + }); + + describe("get", () => { + it("exists", async () => { + const result = await folderService.get("1"); + + expect(result).toEqual({ + id: "1", + name: { + decryptedValue: [], + encryptedString: "test", + encryptionType: 0, + }, + revisionDate: null, + }); + }); + + it("not exists", async () => { + const result = await folderService.get("2"); + + expect(result).toBe(undefined); + }); + }); + + it("upsert", async () => { + await folderService.upsert(folderData("2", "test 2")); + + expect(await firstValueFrom(folderService.folders$)).toEqual([ + { + id: "1", + name: { + decryptedValue: [], + encryptedString: "test", + encryptionType: 0, + }, + revisionDate: null, + }, + { + id: "2", + name: { + decryptedValue: [], + encryptedString: "test 2", + encryptionType: 0, + }, + revisionDate: null, + }, + ]); + + expect(await firstValueFrom(folderService.folderViews$)).toEqual([ + { id: "1", name: [], revisionDate: null }, + { id: "2", name: [], revisionDate: null }, + { id: null, name: [], revisionDate: null }, + ]); + }); + + it("replace", async () => { + await folderService.replace({ "2": folderData("2", "test 2") }); + + expect(await firstValueFrom(folderService.folders$)).toEqual([ + { + id: "2", + name: { + decryptedValue: [], + encryptedString: "test 2", + encryptionType: 0, + }, + revisionDate: null, + }, + ]); + + expect(await firstValueFrom(folderService.folderViews$)).toEqual([ + { id: "2", name: [], revisionDate: null }, + { id: null, name: [], revisionDate: null }, + ]); + }); + + it("delete", async () => { + await folderService.delete("1"); + + expect((await firstValueFrom(folderService.folders$)).length).toBe(0); + + expect(await firstValueFrom(folderService.folderViews$)).toEqual([ + { id: null, name: [], revisionDate: null }, + ]); + }); + + it("clearCache", async () => { + await folderService.clearCache(); + + expect((await firstValueFrom(folderService.folders$)).length).toBe(1); + expect((await firstValueFrom(folderService.folderViews$)).length).toBe(0); + }); + + describe("clear", () => { + it("null userId", async () => { + await folderService.clear(); + + stateService.received(1).setEncryptedFolders(Arg.any(), Arg.any()); + + expect((await firstValueFrom(folderService.folders$)).length).toBe(0); + expect((await firstValueFrom(folderService.folderViews$)).length).toBe(0); + }); + + it("matching userId", async () => { + stateService.getUserId().resolves("1"); + await folderService.clear("1"); + + stateService.received(1).setEncryptedFolders(Arg.any(), Arg.any()); + + expect((await firstValueFrom(folderService.folders$)).length).toBe(0); + expect((await firstValueFrom(folderService.folderViews$)).length).toBe(0); + }); + + it("missmatching userId", async () => { + await folderService.clear("12"); + + stateService.received(1).setEncryptedFolders(Arg.any(), Arg.any()); + + expect((await firstValueFrom(folderService.folders$)).length).toBe(1); + expect((await firstValueFrom(folderService.folderViews$)).length).toBe(2); + }); + }); + + function folderData(id: string, name: string) { + const data = new FolderData({} as any); + data.id = id; + data.name = name; + + return data; + } +}); diff --git a/libs/common/src/abstractions/folder/folder.service.abstraction.ts b/libs/common/src/abstractions/folder/folder.service.abstraction.ts index e7655bbd81..8110fa01ac 100644 --- a/libs/common/src/abstractions/folder/folder.service.abstraction.ts +++ b/libs/common/src/abstractions/folder/folder.service.abstraction.ts @@ -1,22 +1,22 @@ +import { Observable } from "rxjs"; + import { FolderData } from "../../models/data/folderData"; import { Folder } from "../../models/domain/folder"; import { SymmetricCryptoKey } from "../../models/domain/symmetricCryptoKey"; -import { TreeNode } from "../../models/domain/treeNode"; import { FolderView } from "../../models/view/folderView"; export abstract class FolderService { - clearCache: (userId?: string) => Promise; + folders$: Observable; + folderViews$: Observable; + + clearCache: () => Promise; encrypt: (model: FolderView, key?: SymmetricCryptoKey) => Promise; get: (id: string) => Promise; - getAll: () => Promise; - getAllDecrypted: () => Promise; - getAllNested: (folders?: FolderView[]) => Promise[]>; - getNested: (id: string) => Promise>; } export abstract class InternalFolderService extends FolderService { - upsert: (folder: FolderData | FolderData[]) => Promise; - replace: (folders: { [id: string]: FolderData }) => Promise; + upsert: (folder: FolderData | FolderData[]) => Promise; + replace: (folders: { [id: string]: FolderData }) => Promise; clear: (userId: string) => Promise; delete: (id: string | string[]) => Promise; } diff --git a/libs/common/src/abstractions/state.service.ts b/libs/common/src/abstractions/state.service.ts index ec24884e3c..5d3a7880a5 100644 --- a/libs/common/src/abstractions/state.service.ts +++ b/libs/common/src/abstractions/state.service.ts @@ -21,7 +21,6 @@ import { SymmetricCryptoKey } from "../models/domain/symmetricCryptoKey"; import { WindowState } from "../models/domain/windowState"; import { CipherView } from "../models/view/cipherView"; import { CollectionView } from "../models/view/collectionView"; -import { FolderView } from "../models/view/folderView"; import { SendView } from "../models/view/sendView"; export abstract class StateService { @@ -88,8 +87,6 @@ export abstract class StateService { value: SymmetricCryptoKey, options?: StorageOptions ) => Promise; - getDecryptedFolders: (options?: StorageOptions) => Promise; - setDecryptedFolders: (value: FolderView[], options?: StorageOptions) => Promise; getDecryptedOrganizationKeys: ( options?: StorageOptions ) => Promise>; @@ -183,7 +180,13 @@ export abstract class StateService { ) => Promise; getEncryptedCryptoSymmetricKey: (options?: StorageOptions) => Promise; setEncryptedCryptoSymmetricKey: (value: string, options?: StorageOptions) => Promise; + /** + * @deprecated Do not call this directly, use FolderService + */ getEncryptedFolders: (options?: StorageOptions) => Promise<{ [id: string]: FolderData }>; + /** + * @deprecated Do not call this directly, use FolderService + */ setEncryptedFolders: ( value: { [id: string]: FolderData }, options?: StorageOptions diff --git a/libs/common/src/models/domain/account.ts b/libs/common/src/models/domain/account.ts index 7d2077960f..525acf1527 100644 --- a/libs/common/src/models/domain/account.ts +++ b/libs/common/src/models/domain/account.ts @@ -11,7 +11,6 @@ import { ProviderData } from "../data/providerData"; import { SendData } from "../data/sendData"; import { CipherView } from "../view/cipherView"; import { CollectionView } from "../view/collectionView"; -import { FolderView } from "../view/folderView"; import { SendView } from "../view/sendView"; import { EncString } from "./encString"; @@ -31,15 +30,19 @@ export class DataEncryptionPair { decrypted?: TDecrypted[]; } +// This is a temporary structure to handle migrated `DataEncryptionPair` to +// avoid needing a data migration at this stage. It should be replaced with +// proper data migrations when `DataEncryptionPair` is deprecated. +export class TemporaryDataEncryption { + encrypted?: { [id: string]: TEncrypted }; +} + export class AccountData { ciphers?: DataEncryptionPair = new DataEncryptionPair< CipherData, CipherView >(); - folders?: DataEncryptionPair = new DataEncryptionPair< - FolderData, - FolderView - >(); + folders? = new TemporaryDataEncryption(); localData?: any; sends?: DataEncryptionPair = new DataEncryptionPair(); collections?: DataEncryptionPair = new DataEncryptionPair< diff --git a/libs/common/src/services/export.service.ts b/libs/common/src/services/export.service.ts index 06b4221ba0..1169593619 100644 --- a/libs/common/src/services/export.service.ts +++ b/libs/common/src/services/export.service.ts @@ -1,4 +1,5 @@ import * as papa from "papaparse"; +import { firstValueFrom } from "rxjs"; import { ApiService } from "../abstractions/api.service"; import { CipherService } from "../abstractions/cipher.service"; @@ -115,7 +116,7 @@ export class ExportService implements ExportServiceAbstraction { const promises = []; promises.push( - this.folderService.getAllDecrypted().then((folders) => { + firstValueFrom(this.folderService.folderViews$).then((folders) => { decFolders = folders; }) ); @@ -191,7 +192,7 @@ export class ExportService implements ExportServiceAbstraction { const promises = []; promises.push( - this.folderService.getAll().then((f) => { + firstValueFrom(this.folderService.folders$).then((f) => { folders = f; }) ); diff --git a/libs/common/src/services/folder/folder.service.ts b/libs/common/src/services/folder/folder.service.ts index 77d8cfc0a9..f2b7d37550 100644 --- a/libs/common/src/services/folder/folder.service.ts +++ b/libs/common/src/services/folder/folder.service.ts @@ -1,31 +1,70 @@ +import { BehaviorSubject } from "rxjs"; + +import { BroadcasterService } from "../../abstractions/broadcaster.service"; import { CipherService } from "../../abstractions/cipher.service"; import { CryptoService } from "../../abstractions/crypto.service"; import { FolderService as FolderServiceAbstraction } from "../../abstractions/folder/folder.service.abstraction"; import { I18nService } from "../../abstractions/i18n.service"; import { StateService } from "../../abstractions/state.service"; -import { ServiceUtils } from "../../misc/serviceUtils"; import { Utils } from "../../misc/utils"; import { CipherData } from "../../models/data/cipherData"; import { FolderData } from "../../models/data/folderData"; import { Folder } from "../../models/domain/folder"; import { SymmetricCryptoKey } from "../../models/domain/symmetricCryptoKey"; -import { TreeNode } from "../../models/domain/treeNode"; import { FolderView } from "../../models/view/folderView"; -const NestingDelimiter = "/"; +const BroadcasterSubscriptionId = "FolderService"; export class FolderService implements FolderServiceAbstraction { + private _folders: BehaviorSubject = new BehaviorSubject([]); + private _folderViews: BehaviorSubject = new BehaviorSubject([]); + + folders$ = this._folders.asObservable(); + folderViews$ = this._folderViews.asObservable(); + constructor( private cryptoService: CryptoService, private i18nService: I18nService, private cipherService: CipherService, - private stateService: StateService - ) {} + private stateService: StateService, + private broadcasterService: BroadcasterService + ) { + this.stateService.activeAccount.subscribe(async (activeAccount) => { + if ((Utils.global as any).bitwardenContainerService == null) { + return; + } - async clearCache(userId?: string): Promise { - await this.stateService.setDecryptedFolders(null, { userId: userId }); + if (activeAccount == null) { + this._folders.next([]); + this._folderViews.next([]); + return; + } + + const data = await this.stateService.getEncryptedFolders(); + + await this.updateObservables(data); + }); + + // TODO: Broadcasterservice should be removed or replaced with observables + this.broadcasterService.subscribe(BroadcasterSubscriptionId, async (message: any) => { + switch (message.command) { + case "unlocked": { + const data = await this.stateService.getEncryptedFolders(); + + await this.updateObservables(data); + break; + } + default: + break; + } + }); } + async clearCache(): Promise { + this._folderViews.next([]); + } + + // TODO: This should be moved to EncryptService or something async encrypt(model: FolderView, key?: SymmetricCryptoKey): Promise { const folder = new Folder(); folder.id = model.id; @@ -34,75 +73,12 @@ export class FolderService implements FolderServiceAbstraction { } async get(id: string): Promise { - const folders = await this.stateService.getEncryptedFolders(); - // eslint-disable-next-line - if (folders == null || !folders.hasOwnProperty(id)) { - return null; - } + const folders = this._folders.getValue(); - return new Folder(folders[id]); + return folders.find((folder) => folder.id === id); } - async getAll(): Promise { - const folders = await this.stateService.getEncryptedFolders(); - const response: Folder[] = []; - for (const id in folders) { - // eslint-disable-next-line - if (folders.hasOwnProperty(id)) { - response.push(new Folder(folders[id])); - } - } - return response; - } - - async getAllDecrypted(): Promise { - const decryptedFolders = await this.stateService.getDecryptedFolders(); - if (decryptedFolders != null) { - return decryptedFolders; - } - - const hasKey = await this.cryptoService.hasKey(); - if (!hasKey) { - throw new Error("No key."); - } - - const decFolders: FolderView[] = []; - const promises: Promise[] = []; - const folders = await this.getAll(); - folders.forEach((folder) => { - promises.push(folder.decrypt().then((f) => decFolders.push(f))); - }); - - await Promise.all(promises); - decFolders.sort(Utils.getSortFunction(this.i18nService, "name")); - - const noneFolder = new FolderView(); - noneFolder.name = this.i18nService.t("noneFolder"); - decFolders.push(noneFolder); - - await this.stateService.setDecryptedFolders(decFolders); - return decFolders; - } - - async getAllNested(folders?: FolderView[]): Promise[]> { - folders = folders ?? (await this.getAllDecrypted()); - const nodes: TreeNode[] = []; - folders.forEach((f) => { - const folderCopy = new FolderView(); - folderCopy.id = f.id; - folderCopy.revisionDate = f.revisionDate; - const parts = f.name != null ? f.name.replace(/^\/+|\/+$/g, "").split(NestingDelimiter) : []; - ServiceUtils.nestedTraverse(nodes, 0, parts, folderCopy, null, NestingDelimiter); - }); - return nodes; - } - - async getNested(id: string): Promise> { - const folders = await this.getAllNested(); - return ServiceUtils.getTreeNodeObject(folders, id) as TreeNode; - } - - async upsert(folder: FolderData | FolderData[]): Promise { + async upsert(folder: FolderData | FolderData[]): Promise { let folders = await this.stateService.getEncryptedFolders(); if (folders == null) { folders = {}; @@ -117,17 +93,20 @@ export class FolderService implements FolderServiceAbstraction { }); } - await this.stateService.setDecryptedFolders(null); + await this.updateObservables(folders); await this.stateService.setEncryptedFolders(folders); } - async replace(folders: { [id: string]: FolderData }): Promise { - await this.stateService.setDecryptedFolders(null); + async replace(folders: { [id: string]: FolderData }): Promise { + await this.updateObservables(folders); await this.stateService.setEncryptedFolders(folders); } async clear(userId?: string): Promise { - await this.stateService.setDecryptedFolders(null, { userId: userId }); + if (userId == null || userId == (await this.stateService.getUserId())) { + this._folders.next([]); + this._folderViews.next([]); + } await this.stateService.setEncryptedFolders(null, { userId: userId }); } @@ -148,7 +127,7 @@ export class FolderService implements FolderServiceAbstraction { }); } - await this.stateService.setDecryptedFolders(null); + await this.updateObservables(folders); await this.stateService.setEncryptedFolders(folders); // Items in a deleted folder are re-assigned to "No Folder" @@ -166,4 +145,20 @@ export class FolderService implements FolderServiceAbstraction { } } } + + private async updateObservables(foldersMap: { [id: string]: FolderData }) { + const folders = Object.values(foldersMap || {}).map((f) => new Folder(f)); + + const decryptFolderPromises = folders.map((f) => f.decrypt()); + const decryptedFolders = await Promise.all(decryptFolderPromises); + + decryptedFolders.sort(Utils.getSortFunction(this.i18nService, "name")); + + const noneFolder = new FolderView(); + noneFolder.name = this.i18nService.t("noneFolder"); + decryptedFolders.push(noneFolder); + + this._folders.next(folders); + this._folderViews.next(decryptedFolders); + } } diff --git a/libs/common/src/services/state.service.ts b/libs/common/src/services/state.service.ts index fcb4a78595..d4901810f3 100644 --- a/libs/common/src/services/state.service.ts +++ b/libs/common/src/services/state.service.ts @@ -31,7 +31,6 @@ import { SymmetricCryptoKey } from "../models/domain/symmetricCryptoKey"; import { WindowState } from "../models/domain/windowState"; import { CipherView } from "../models/view/cipherView"; import { CollectionView } from "../models/view/collectionView"; -import { FolderView } from "../models/view/folderView"; import { SendView } from "../models/view/sendView"; const keys = { @@ -658,24 +657,6 @@ export class StateService< ); } - @withPrototypeForArrayMembers(FolderView) - async getDecryptedFolders(options?: StorageOptions): Promise { - return ( - await this.getAccount(this.reconcileOptions(options, await this.defaultInMemoryOptions())) - )?.data?.folders?.decrypted; - } - - async setDecryptedFolders(value: FolderView[], options?: StorageOptions): Promise { - const account = await this.getAccount( - this.reconcileOptions(options, await this.defaultInMemoryOptions()) - ); - account.data.folders.decrypted = value; - await this.saveAccount( - account, - this.reconcileOptions(options, await this.defaultInMemoryOptions()) - ); - } - @withPrototypeForMap(SymmetricCryptoKey, SymmetricCryptoKey.initFromJson) async getDecryptedOrganizationKeys( options?: StorageOptions diff --git a/libs/common/src/services/vaultTimeout.service.ts b/libs/common/src/services/vaultTimeout.service.ts index dcb5db62d2..d087ab4277 100644 --- a/libs/common/src/services/vaultTimeout.service.ts +++ b/libs/common/src/services/vaultTimeout.service.ts @@ -80,6 +80,7 @@ export class VaultTimeoutService implements VaultTimeoutServiceAbstraction { if (userId == null || userId === (await this.stateService.getUserId())) { this.searchService.clearIndex(); + await this.folderService.clearCache(); } await this.stateService.setEverBeenUnlocked(true, { userId: userId }); @@ -91,7 +92,6 @@ export class VaultTimeoutService implements VaultTimeoutServiceAbstraction { await this.cryptoService.clearKeyPair(true, userId); await this.cryptoService.clearEncKey(true, userId); - await this.folderService.clearCache(userId); await this.cipherService.clearCache(userId); await this.collectionService.clearCache(userId);