diff --git a/apps/desktop/src/app/vault/vault-filter/vault-filter.module.ts b/apps/desktop/src/app/vault/vault-filter/vault-filter.module.ts index 6442a2b7b8..996bdf807a 100644 --- a/apps/desktop/src/app/vault/vault-filter/vault-filter.module.ts +++ b/apps/desktop/src/app/vault/vault-filter/vault-filter.module.ts @@ -1,6 +1,7 @@ import { NgModule } from "@angular/core"; import { BrowserModule } from "@angular/platform-browser"; +import { DeprecatedVaultFilterService as DeprecatedVaultFilterServiceAbstraction } from "@bitwarden/angular/abstractions/deprecated-vault-filter.service"; import { JslibModule } from "@bitwarden/angular/jslib.module"; import { VaultFilterService } from "@bitwarden/angular/vault/vault-filter/services/vault-filter.service"; @@ -22,6 +23,11 @@ import { VaultFilterComponent } from "./vault-filter.component"; TypeFilterComponent, ], exports: [VaultFilterComponent], - providers: [VaultFilterService], + providers: [ + { + provide: DeprecatedVaultFilterServiceAbstraction, + useClass: VaultFilterService, + }, + ], }) export class VaultFilterModule {} diff --git a/apps/web/src/app/organizations/vault/vault-filter/vault-filter.component.ts b/apps/web/src/app/organizations/vault/vault-filter/vault-filter.component.ts index 8e12cde619..672d89b444 100644 --- a/apps/web/src/app/organizations/vault/vault-filter/vault-filter.component.ts +++ b/apps/web/src/app/organizations/vault/vault-filter/vault-filter.component.ts @@ -1,28 +1,75 @@ -import { Component } from "@angular/core"; +import { Component, Input, OnDestroy, OnInit } from "@angular/core"; +import { firstValueFrom, Subject, switchMap, takeUntil } from "rxjs"; 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 { VaultFilterComponent as BaseVaultFilterComponent } from "../../../vault/vault-filter/vault-filter.component"; +import { VaultFilterComponent as BaseVaultFilterComponent } from "../../../vault/vault-filter/components/vault-filter.component"; +import { + VaultFilterList, + VaultFilterType, +} from "../../../vault/vault-filter/shared/models/vault-filter-section.type"; +import { CollectionFilter } from "../../../vault/vault-filter/shared/models/vault-filter.type"; @Component({ selector: "app-organization-vault-filter", - templateUrl: "../../../vault/vault-filter/vault-filter.component.html", + templateUrl: "../../../vault/vault-filter/components/vault-filter.component.html", }) -export class VaultFilterComponent extends BaseVaultFilterComponent { - hideOrganizations = true; - hideFavorites = true; - hideFolders = true; - - organization: Organization; - - async initCollections() { - if (this.organization.canEditAnyCollection) { - return await this.vaultFilterService.buildAdminCollections(this.organization.id); +export class VaultFilterComponent extends BaseVaultFilterComponent implements OnInit, OnDestroy { + @Input() set organization(value: Organization) { + if (value && value !== this._organization) { + this._organization = value; + this.vaultFilterService.setOrganizationFilter(this._organization); } - return await this.vaultFilterService.buildCollections(this.organization.id); + } + _organization: Organization; + destroy$: Subject; + + async ngOnInit() { + this.filters = await this.buildAllFilters(); + if (!this.activeFilter.selectedCipherTypeNode) { + this.applyCollectionFilter((await this.getDefaultFilter()) as TreeNode); + } + this.isLoaded = true; } - async reloadCollectionsAndFolders() { - this.collections = await this.initCollections(); + ngOnDestroy() { + this.destroy$.next(); + this.destroy$.complete(); + } + + protected loadSubscriptions() { + this.vaultFilterService.filteredCollections$ + .pipe( + switchMap(async (collections) => { + this.removeInvalidCollectionSelection(collections); + }), + takeUntil(this.destroy$) + ) + .subscribe(); + } + + protected async removeInvalidCollectionSelection(collections: CollectionView[]) { + if (this.activeFilter.selectedCollectionNode) { + if (!collections.some((f) => f.id === this.activeFilter.collectionId)) { + this.activeFilter.resetFilter(); + this.activeFilter.selectedCollectionNode = + (await this.getDefaultFilter()) as TreeNode; + this.applyVaultFilter(this.activeFilter); + } + } + } + + async buildAllFilters(): Promise { + const builderFilter = {} as VaultFilterList; + builderFilter.typeFilter = await this.addTypeFilter(); + builderFilter.collectionFilter = await this.addCollectionFilter(); + builderFilter.trashFilter = await this.addTrashFilter(); + return builderFilter; + } + + async getDefaultFilter(): Promise> { + return await firstValueFrom(this.filters?.collectionFilter.data$); } } diff --git a/apps/web/src/app/organizations/vault/vault-filter/vault-filter.module.ts b/apps/web/src/app/organizations/vault/vault-filter/vault-filter.module.ts index ceb85d91b1..04f08d8546 100644 --- a/apps/web/src/app/organizations/vault/vault-filter/vault-filter.module.ts +++ b/apps/web/src/app/organizations/vault/vault-filter/vault-filter.module.ts @@ -1,12 +1,20 @@ import { NgModule } from "@angular/core"; +import { VaultFilterService as VaultFilterServiceAbstraction } from "../../../vault/vault-filter/services/abstractions/vault-filter.service"; import { VaultFilterSharedModule } from "../../../vault/vault-filter/shared/vault-filter-shared.module"; import { VaultFilterComponent } from "./vault-filter.component"; +import { VaultFilterService } from "./vault-filter.service"; @NgModule({ imports: [VaultFilterSharedModule], declarations: [VaultFilterComponent], exports: [VaultFilterComponent], + providers: [ + { + provide: VaultFilterServiceAbstraction, + useClass: VaultFilterService, + }, + ], }) export class VaultFilterModule {} diff --git a/apps/web/src/app/organizations/vault/vault-filter/vault-filter.service.ts b/apps/web/src/app/organizations/vault/vault-filter/vault-filter.service.ts new file mode 100644 index 0000000000..37196186ec --- /dev/null +++ b/apps/web/src/app/organizations/vault/vault-filter/vault-filter.service.ts @@ -0,0 +1,106 @@ +import { Injectable } from "@angular/core"; +import { combineLatestWith, ReplaySubject, switchMap, takeUntil } 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"; +import { FolderService } from "@bitwarden/common/abstractions/folder/folder.service.abstraction"; +import { I18nService } from "@bitwarden/common/abstractions/i18n.service"; +import { OrganizationService } from "@bitwarden/common/abstractions/organization/organization.service.abstraction"; +import { PolicyService } from "@bitwarden/common/abstractions/policy/policy.service.abstraction"; +import { StateService } from "@bitwarden/common/abstractions/state.service"; +import { CollectionData } from "@bitwarden/common/models/data/collectionData"; +import { Collection } from "@bitwarden/common/models/domain/collection"; +import { Organization } from "@bitwarden/common/models/domain/organization"; +import { CollectionDetailsResponse } from "@bitwarden/common/models/response/collectionResponse"; +import { CollectionView } from "@bitwarden/common/models/view/collectionView"; + +import { VaultFilterService as BaseVaultFilterService } from "../../../vault/vault-filter/services/vault-filter.service"; + +@Injectable() +export class VaultFilterService extends BaseVaultFilterService { + protected collectionViews$ = new ReplaySubject(1); + + constructor( + stateService: StateService, + organizationService: OrganizationService, + folderService: FolderService, + cipherService: CipherService, + collectionService: CollectionService, + policyService: PolicyService, + protected apiService: ApiService, + i18nService: I18nService + ) { + super( + stateService, + organizationService, + folderService, + cipherService, + collectionService, + policyService, + i18nService + ); + } + + protected loadSubscriptions() { + this.folderService.folderViews$ + .pipe( + combineLatestWith(this._organizationFilter), + switchMap(async ([folders, org]) => { + return this.filterFolders(folders, org); + }), + takeUntil(this.destroy$) + ) + .subscribe(this._filteredFolders); + + this._organizationFilter + .pipe( + switchMap((org) => { + return this.loadCollections(org); + }) + ) + .subscribe(this.collectionViews$); + + this.collectionViews$ + .pipe( + combineLatestWith(this._organizationFilter), + switchMap(async ([collections, org]) => { + if (org?.canUseAdminCollections) { + return collections; + } else { + return await this.filterCollections(collections, org); + } + }), + takeUntil(this.destroy$) + ) + .subscribe(this._filteredCollections); + } + + protected async loadCollections(org: Organization) { + if (org?.permissions && org?.canEditAnyCollection) { + return await this.loadAdminCollections(org); + } else { + // TODO: remove when collections is refactored with observables + return await this.collectionService.getAllDecrypted(); + } + } + + async loadAdminCollections(org: Organization): Promise { + let collections: CollectionView[] = []; + if (org?.permissions && org?.canEditAnyCollection) { + const collectionResponse = await this.apiService.getCollections(org.id); + if (collectionResponse?.data != null && collectionResponse.data.length) { + const collectionDomains = collectionResponse.data.map( + (r: CollectionDetailsResponse) => new Collection(new CollectionData(r)) + ); + collections = await this.collectionService.decryptMany(collectionDomains); + } + + const noneCollection = new CollectionView(); + noneCollection.name = this.i18nService.t("unassigned"); + noneCollection.organizationId = org.id; + collections.push(noneCollection); + } + return collections; + } +} diff --git a/apps/web/src/app/organizations/vault/vault.component.html b/apps/web/src/app/organizations/vault/vault.component.html index a8837011e0..d579b08ae2 100644 --- a/apps/web/src/app/organizations/vault/vault.component.html +++ b/apps/web/src/app/organizations/vault/vault.component.html @@ -6,8 +6,9 @@
@@ -32,7 +33,7 @@
@@ -40,13 +41,17 @@ type="button" class="btn btn-outline-primary btn-sm ml-auto" (click)="addCipher()" - *ngIf="!deleted" + *ngIf="!activeFilter.isDeleted" > {{ "addItem" | i18n }}
- + {{ trashCleanupWarning }} { - this.organization = await this.organizationService.get(params.organizationId); - this.vaultFilterComponent.organization = this.organization; + this.organization = this.organizationService.get(params.organizationId); this.ciphersComponent.organization = this.organization; /* eslint-disable-next-line rxjs-angular/prefer-takeuntil, rxjs/no-async-subscribe, rxjs/no-nested-subscribe */ @@ -103,7 +95,7 @@ export class VaultComponent implements OnInit, OnDestroy { case "syncCompleted": if (message.successfully) { await Promise.all([ - this.vaultFilterComponent.reloadCollectionsAndFolders(), + this.vaultFilterService.reloadCollections(), this.ciphersComponent.refresh(), ]); this.changeDetectorRef.detectChanges(); @@ -114,12 +106,10 @@ export class VaultComponent implements OnInit, OnDestroy { }); } - if (this.firstLoaded) { - await this.vaultFilterComponent.reloadCollectionsAndFolders(); - } - this.firstLoaded = true; - - await this.ciphersComponent.reload(); + await this.ciphersComponent.reload( + this.activeFilter.buildFilter(), + this.activeFilter.isDeleted + ); if (qParams.viewEvents != null) { const cipher = this.ciphersComponent.ciphers.filter((c) => c.id === qParams.viewEvents); @@ -134,7 +124,7 @@ export class VaultComponent implements OnInit, OnDestroy { if (cipherId) { if ( // Handle users with implicit collection access since they use the admin endpoint - this.organization.canEditAnyCollection || + this.organization.canUseAdminCollections || (await this.cipherService.get(cipherId)) != null ) { this.editCipherId(cipherId); @@ -155,23 +145,17 @@ export class VaultComponent implements OnInit, OnDestroy { }); } - get deleted(): boolean { - return this.activeFilter.status === "trash"; - } - ngOnDestroy() { this.broadcasterService.unsubscribe(BroadcasterSubscriptionId); } - async applyVaultFilter(vaultFilter: VaultFilter) { - this.ciphersComponent.showAddNew = vaultFilter.status !== "trash"; - this.activeFilter = vaultFilter; + async applyVaultFilter(filter: VaultFilter) { + this.activeFilter = filter; + this.ciphersComponent.showAddNew = !this.activeFilter.isDeleted; await this.ciphersComponent.reload( this.activeFilter.buildFilter(), - vaultFilter.status === "trash" + this.activeFilter.isDeleted ); - this.vaultFilterComponent.searchPlaceholder = - this.vaultService.calculateSearchBarLocalizationString(this.activeFilter); this.go(); } @@ -211,16 +195,13 @@ export class VaultComponent implements OnInit, OnDestroy { } async editCipherCollections(cipher: CipherView) { + const currCollections = await firstValueFrom(this.vaultFilterService.filteredCollections$); const [modal] = await this.modalService.openViewRef( CollectionsComponent, this.collectionsModalRef, (comp) => { - if (this.organization.canEditAnyCollection) { - comp.collectionIds = cipher.collectionIds; - comp.collections = this.vaultFilterComponent.collections.fullList.filter( - (c) => !c.readOnly && c.id != null - ); - } + comp.collectionIds = cipher.collectionIds; + comp.collections = currCollections.filter((c) => !c.readOnly && c.id != null); comp.organization = this.organization; comp.cipherId = cipher.id; // eslint-disable-next-line rxjs-angular/prefer-takeuntil, rxjs/no-async-subscribe @@ -235,14 +216,12 @@ export class VaultComponent implements OnInit, OnDestroy { async addCipher() { const component = await this.editCipher(null); component.organizationId = this.organization.id; - component.type = this.type; - if (this.organization.canEditAnyCollection) { - component.collections = this.vaultFilterComponent.collections.fullList.filter( - (c) => !c.readOnly && c.id != null - ); - } - if (this.collectionId != null) { - component.collectionIds = [this.collectionId]; + component.type = this.activeFilter.cipherType; + component.collections = ( + await firstValueFrom(this.vaultFilterService.filteredCollections$) + ).filter((c) => !c.readOnly && c.id != null); + if (this.activeFilter.collectionId) { + component.collectionIds = [this.activeFilter.collectionId]; } } @@ -294,13 +273,9 @@ export class VaultComponent implements OnInit, OnDestroy { const component = await this.editCipher(cipher); component.cloneMode = true; component.organizationId = this.organization.id; - if (this.organization.canEditAnyCollection) { - component.collections = this.vaultFilterComponent.collections.fullList.filter( - (c) => !c.readOnly && c.id != null - ); - } - // Regardless of Admin state, the collection Ids need to passed manually as they are not assigned value - // in the add-edit componenet + component.collections = ( + await firstValueFrom(this.vaultFilterService.filteredCollections$) + ).filter((c) => !c.readOnly && c.id != null); component.collectionIds = cipher.collectionIds; } @@ -318,8 +293,8 @@ export class VaultComponent implements OnInit, OnDestroy { if (queryParams == null) { queryParams = { type: this.activeFilter.cipherType, - collectionId: this.activeFilter.selectedCollectionId, - deleted: this.deleted ? true : null, + collectionId: this.activeFilter.collectionId, + deleted: this.activeFilter.isDeleted || null, }; } diff --git a/apps/web/src/app/organizations/vault/vault.module.ts b/apps/web/src/app/organizations/vault/vault.module.ts index 91a9973e20..e924d9a3b0 100644 --- a/apps/web/src/app/organizations/vault/vault.module.ts +++ b/apps/web/src/app/organizations/vault/vault.module.ts @@ -1,6 +1,7 @@ import { NgModule } from "@angular/core"; -import { VaultSharedModule } from "../../vault/shared/vault-shared.module"; +import { LooseComponentsModule } from "../../shared/loose-components.module"; +import { SharedModule } from "../../shared/shared.module"; import { CiphersComponent } from "./ciphers.component"; import { VaultFilterModule } from "./vault-filter/vault-filter.module"; @@ -8,7 +9,7 @@ import { VaultRoutingModule } from "./vault-routing.module"; import { VaultComponent } from "./vault.component"; @NgModule({ - imports: [VaultSharedModule, VaultRoutingModule, VaultFilterModule], + imports: [VaultRoutingModule, VaultFilterModule, SharedModule, LooseComponentsModule], declarations: [VaultComponent, CiphersComponent], exports: [VaultComponent], }) diff --git a/apps/web/src/app/shared/loose-components.module.ts b/apps/web/src/app/shared/loose-components.module.ts index 1dfbc8da6b..cc97da24a6 100644 --- a/apps/web/src/app/shared/loose-components.module.ts +++ b/apps/web/src/app/shared/loose-components.module.ts @@ -126,20 +126,13 @@ import { CollectionsComponent } from "../vault/collections.component"; import { FolderAddEditComponent } from "../vault/folder-add-edit.component"; import { OrganizationBadgeModule } from "../vault/organization-badge/organization-badge.module"; import { ShareComponent } from "../vault/share.component"; -import { VaultFilterModule } from "../vault/vault-filter/vault-filter.module"; import { SharedModule } from "."; // Please do not add to this list of declarations - we should refactor these into modules when doing so makes sense until there are none left. // If you are building new functionality, please create or extend a feature module instead. @NgModule({ - imports: [ - SharedModule, - VaultFilterModule, - OrganizationBadgeModule, - OrganizationCreateModule, - RegisterFormModule, - ], + imports: [SharedModule, OrganizationBadgeModule, OrganizationCreateModule, RegisterFormModule], declarations: [ PremiumBadgeComponent, AcceptEmergencyComponent, diff --git a/apps/web/src/app/vault/shared/pipes/get-organization-name.pipe.ts b/apps/web/src/app/vault/pipes/get-organization-name.pipe.ts similarity index 100% rename from apps/web/src/app/vault/shared/pipes/get-organization-name.pipe.ts rename to apps/web/src/app/vault/pipes/get-organization-name.pipe.ts diff --git a/apps/web/src/app/vault/shared/pipes/pipes.module.ts b/apps/web/src/app/vault/pipes/pipes.module.ts similarity index 100% rename from apps/web/src/app/vault/shared/pipes/pipes.module.ts rename to apps/web/src/app/vault/pipes/pipes.module.ts diff --git a/apps/web/src/app/vault/shared/vault-shared.module.ts b/apps/web/src/app/vault/shared/vault-shared.module.ts deleted file mode 100644 index 0af3a32730..0000000000 --- a/apps/web/src/app/vault/shared/vault-shared.module.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { NgModule } from "@angular/core"; - -import { SharedModule } from "../../shared"; -import { LooseComponentsModule } from "../../shared/loose-components.module"; -import { VaultFilterModule } from "../vault-filter/vault-filter.module"; - -import { PipesModule } from "./pipes/pipes.module"; -import { VaultService } from "./vault.service"; - -@NgModule({ - imports: [SharedModule, VaultFilterModule, LooseComponentsModule, PipesModule], - exports: [SharedModule, VaultFilterModule, LooseComponentsModule, PipesModule], - providers: [ - { - provide: VaultService, - useClass: VaultService, - }, - ], -}) -export class VaultSharedModule {} diff --git a/apps/web/src/app/vault/shared/vault.service.ts b/apps/web/src/app/vault/shared/vault.service.ts deleted file mode 100644 index 757261c859..0000000000 --- a/apps/web/src/app/vault/shared/vault.service.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { VaultFilter } from "@bitwarden/angular/vault/vault-filter/models/vault-filter.model"; - -export class VaultService { - calculateSearchBarLocalizationString(vaultFilter: VaultFilter): string { - if (vaultFilter.status === "favorites") { - return "searchFavorites"; - } - if (vaultFilter.status === "trash") { - return "searchTrash"; - } - if (vaultFilter.cipherType != null) { - return "searchType"; - } - if (vaultFilter.selectedFolderId != null && vaultFilter.selectedFolderId != "none") { - return "searchFolder"; - } - if (vaultFilter.selectedCollection) { - return "searchCollection"; - } - if (vaultFilter.selectedOrganizationId != null) { - return "searchOrganization"; - } - if (vaultFilter.myVaultOnly) { - return "searchMyVault"; - } - - return "searchVault"; - } -} diff --git a/apps/web/src/app/vault/vault-filter/organization-filter/link-sso.component.html b/apps/web/src/app/vault/vault-filter/components/link-sso.component.html similarity index 100% rename from apps/web/src/app/vault/vault-filter/organization-filter/link-sso.component.html rename to apps/web/src/app/vault/vault-filter/components/link-sso.component.html diff --git a/apps/web/src/app/vault/vault-filter/organization-filter/link-sso.component.ts b/apps/web/src/app/vault/vault-filter/components/link-sso.component.ts similarity index 100% rename from apps/web/src/app/vault/vault-filter/organization-filter/link-sso.component.ts rename to apps/web/src/app/vault/vault-filter/components/link-sso.component.ts diff --git a/apps/web/src/app/vault/vault-filter/organization-filter/organization-options.component.html b/apps/web/src/app/vault/vault-filter/components/organization-options.component.html similarity index 100% rename from apps/web/src/app/vault/vault-filter/organization-filter/organization-options.component.html rename to apps/web/src/app/vault/vault-filter/components/organization-options.component.html diff --git a/apps/web/src/app/vault/vault-filter/organization-filter/organization-options.component.ts b/apps/web/src/app/vault/vault-filter/components/organization-options.component.ts similarity index 94% rename from apps/web/src/app/vault/vault-filter/organization-filter/organization-options.component.ts rename to apps/web/src/app/vault/vault-filter/components/organization-options.component.ts index fb1009fa51..66ee006cc8 100644 --- a/apps/web/src/app/vault/vault-filter/organization-filter/organization-options.component.ts +++ b/apps/web/src/app/vault/vault-filter/components/organization-options.component.ts @@ -1,4 +1,4 @@ -import { Component, Input } from "@angular/core"; +import { Component, Inject } from "@angular/core"; import { ModalService } from "@bitwarden/angular/services/modal.service"; import { ApiService } from "@bitwarden/common/abstractions/api.service"; @@ -14,6 +14,8 @@ import { Policy } from "@bitwarden/common/models/domain/policy"; import { OrganizationUserResetPasswordEnrollmentRequest } from "@bitwarden/common/models/request/organizationUserResetPasswordEnrollmentRequest"; import { EnrollMasterPasswordReset } from "../../../organizations/users/enroll-master-password-reset.component"; +import { OptionsInput } from "../shared/components/vault-filter-section.component"; +import { OrganizationFilter } from "../shared/models/vault-filter.type"; @Component({ selector: "app-organization-options", @@ -24,9 +26,8 @@ export class OrganizationOptionsComponent { policies: Policy[]; loaded = false; - @Input() organization: Organization; - constructor( + @Inject(OptionsInput) private organization: OrganizationFilter, private platformUtilsService: PlatformUtilsService, private i18nService: I18nService, private apiService: ApiService, diff --git a/apps/web/src/app/vault/vault-filter/components/vault-filter.component.html b/apps/web/src/app/vault/vault-filter/components/vault-filter.component.html new file mode 100644 index 0000000000..247a7b0c40 --- /dev/null +++ b/apps/web/src/app/vault/vault-filter/components/vault-filter.component.html @@ -0,0 +1,35 @@ +
+
+ +
+
+
+ {{ "filters" | i18n }} + + + +
+
+ + +
+ +
+
+
+
+
diff --git a/apps/web/src/app/vault/vault-filter/components/vault-filter.component.ts b/apps/web/src/app/vault/vault-filter/components/vault-filter.component.ts new file mode 100644 index 0000000000..dce52f7fce --- /dev/null +++ b/apps/web/src/app/vault/vault-filter/components/vault-filter.component.ts @@ -0,0 +1,351 @@ +import { Component, EventEmitter, Input, OnDestroy, OnInit, Output } from "@angular/core"; +import { firstValueFrom, Subject, switchMap, takeUntil } from "rxjs"; + +import { I18nService } from "@bitwarden/common/abstractions/i18n.service"; +import { PlatformUtilsService } from "@bitwarden/common/abstractions/platformUtils.service"; +import { PolicyService } from "@bitwarden/common/abstractions/policy/policy.service.abstraction"; +import { CipherType } from "@bitwarden/common/enums/cipherType"; +import { PolicyType } from "@bitwarden/common/enums/policyType"; +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 { VaultFilterService } from "../services/abstractions/vault-filter.service"; +import { + VaultFilterList, + VaultFilterSection, + VaultFilterType, +} from "../shared/models/vault-filter-section.type"; +import { VaultFilter } from "../shared/models/vault-filter.model"; +import { + CipherTypeFilter, + CollectionFilter, + FolderFilter, + OrganizationFilter, +} from "../shared/models/vault-filter.type"; + +import { OrganizationOptionsComponent } from "./organization-options.component"; + +@Component({ + selector: "app-vault-filter", + templateUrl: "vault-filter.component.html", +}) +export class VaultFilterComponent implements OnInit, OnDestroy { + filters?: VaultFilterList; + @Input() activeFilter: VaultFilter = new VaultFilter(); + @Output() activeFilterChanged = new EventEmitter(); + @Output() onSearchTextChanged = new EventEmitter(); + @Output() onAddFolder = new EventEmitter(); + @Output() onEditFolder = new EventEmitter(); + + isLoaded = false; + searchText = ""; + + protected destroy$: Subject = new Subject(); + + get filtersList() { + return this.filters ? Object.values(this.filters) : []; + } + + get searchPlaceholder() { + if (this.activeFilter.isFavorites) { + return "searchFavorites"; + } + if (this.activeFilter.isDeleted) { + return "searchTrash"; + } + if (this.activeFilter.cipherType === CipherType.Login) { + return "searchLogin"; + } + if (this.activeFilter.cipherType === CipherType.Card) { + return "searchCard"; + } + if (this.activeFilter.cipherType === CipherType.Identity) { + return "searchIdentity"; + } + if (this.activeFilter.cipherType === CipherType.SecureNote) { + return "searchSecureNote"; + } + if (this.activeFilter.selectedFolderNode?.node) { + return "searchFolder"; + } + if (this.activeFilter.selectedCollectionNode?.node) { + return "searchCollection"; + } + if (this.activeFilter.organizationId === "MyVault") { + return "searchMyVault"; + } + if (this.activeFilter.organizationId) { + return "searchOrganization"; + } + + return "searchVault"; + } + + constructor( + protected vaultFilterService: VaultFilterService, + protected policyService: PolicyService, + protected i18nService: I18nService, + protected platformUtilsService: PlatformUtilsService + ) { + this.loadSubscriptions(); + } + + async ngOnInit(): Promise { + this.filters = await this.buildAllFilters(); + await this.applyTypeFilter( + (await firstValueFrom(this.filters?.typeFilter.data$)) as TreeNode + ); + this.isLoaded = true; + } + + ngOnDestroy() { + this.destroy$.next(); + this.destroy$.complete(); + } + + protected loadSubscriptions() { + this.vaultFilterService.filteredFolders$ + .pipe( + switchMap(async (folders) => { + this.removeInvalidFolderSelection(folders); + }), + takeUntil(this.destroy$) + ) + .subscribe(); + + this.vaultFilterService.filteredCollections$ + .pipe( + switchMap(async (collections) => { + this.removeInvalidCollectionSelection(collections); + }), + takeUntil(this.destroy$) + ) + .subscribe(); + } + + searchTextChanged(t: string) { + this.searchText = t; + this.onSearchTextChanged.emit(t); + } + + protected applyVaultFilter(filter: VaultFilter) { + this.activeFilterChanged.emit(filter); + } + + applyOrganizationFilter = async (orgNode: TreeNode): Promise => { + if (!orgNode?.node.enabled) { + this.platformUtilsService.showToast( + "error", + null, + this.i18nService.t("disabledOrganizationFilterError") + ); + return; + } + const filter = this.activeFilter; + filter.resetOrganization(); + if (orgNode?.node.id !== "AllVaults") { + filter.selectedOrganizationNode = orgNode; + } + this.vaultFilterService.setOrganizationFilter(orgNode.node); + await this.vaultFilterService.expandOrgFilter(); + this.applyVaultFilter(filter); + }; + + applyTypeFilter = async (filterNode: TreeNode): Promise => { + const filter = this.activeFilter; + filter.resetFilter(); + filter.selectedCipherTypeNode = filterNode; + this.applyVaultFilter(filter); + }; + + applyFolderFilter = async (folderNode: TreeNode): Promise => { + const filter = this.activeFilter; + filter.resetFilter(); + filter.selectedFolderNode = folderNode; + this.applyVaultFilter(filter); + }; + + applyCollectionFilter = async (collectionNode: TreeNode): Promise => { + const filter = this.activeFilter; + filter.resetFilter(); + filter.selectedCollectionNode = collectionNode; + this.applyVaultFilter(filter); + }; + + addFolder = async (): Promise => { + this.onAddFolder.emit(); + }; + + editFolder = async (folder: FolderFilter): Promise => { + this.onEditFolder.emit(folder); + }; + + async getDefaultFilter(): Promise> { + return await firstValueFrom(this.filters?.typeFilter.data$); + } + + protected async removeInvalidFolderSelection(folders: FolderView[]) { + if (this.activeFilter.selectedFolderNode) { + if (!folders.some((f) => f.id === this.activeFilter.folderId)) { + const filter = this.activeFilter; + filter.resetFilter(); + filter.selectedCipherTypeNode = + (await this.getDefaultFilter()) as TreeNode; + this.applyVaultFilter(filter); + } + } + } + + protected async removeInvalidCollectionSelection(collections: CollectionView[]) { + if (this.activeFilter.selectedCollectionNode) { + if (!collections.some((f) => f.id === this.activeFilter.collectionId)) { + const filter = this.activeFilter; + filter.resetFilter(); + filter.selectedCipherTypeNode = + (await this.getDefaultFilter()) as TreeNode; + this.applyVaultFilter(filter); + } + } + } + + async buildAllFilters(): Promise { + const builderFilter = {} as VaultFilterList; + builderFilter.organizationFilter = await this.addOrganizationFilter(); + builderFilter.typeFilter = await this.addTypeFilter(); + builderFilter.folderFilter = await this.addFolderFilter(); + builderFilter.collectionFilter = await this.addCollectionFilter(); + builderFilter.trashFilter = await this.addTrashFilter(); + return builderFilter; + } + + protected async addOrganizationFilter(): Promise { + const singleOrgPolicy = await this.policyService.policyAppliesToUser(PolicyType.SingleOrg); + const personalVaultPolicy = await this.policyService.policyAppliesToUser( + PolicyType.PersonalOwnership + ); + + const addAction = !singleOrgPolicy + ? { text: "newOrganization", route: "/create-organization" } + : null; + + const orgFilterSection: VaultFilterSection = { + data$: this.vaultFilterService.organizationTree$, + header: { + showHeader: !(singleOrgPolicy && personalVaultPolicy), + isSelectable: true, + }, + action: this.applyOrganizationFilter, + options: { component: OrganizationOptionsComponent }, + add: addAction, + divider: true, + }; + + return orgFilterSection; + } + + protected async addTypeFilter(): Promise { + const typeFilterSection: VaultFilterSection = { + data$: this.vaultFilterService.buildTypeTree( + { id: "AllItems", name: "allItems", type: "all", icon: "" }, + [ + { + id: "favorites", + name: this.i18nService.t("favorites"), + type: "favorites", + icon: "bwi-star", + }, + { + id: "login", + name: this.i18nService.t("typeLogin"), + type: CipherType.Login, + icon: "bwi-globe", + }, + { + id: "card", + name: this.i18nService.t("typeCard"), + type: CipherType.Card, + icon: "bwi-credit-card", + }, + { + id: "identity", + name: this.i18nService.t("typeIdentity"), + type: CipherType.Identity, + icon: "bwi-id-card", + }, + { + id: "note", + name: this.i18nService.t("typeSecureNote"), + type: CipherType.SecureNote, + icon: "bwi-sticky-note", + }, + ] + ), + header: { + showHeader: true, + isSelectable: true, + }, + action: this.applyTypeFilter, + }; + return typeFilterSection; + } + + protected async addFolderFilter(): Promise { + const folderFilterSection: VaultFilterSection = { + data$: this.vaultFilterService.folderTree$, + header: { + showHeader: true, + isSelectable: false, + }, + action: this.applyFolderFilter, + edit: { + text: "editFolder", + action: this.editFolder, + }, + add: { + text: "Add Folder", + action: this.addFolder, + }, + }; + return folderFilterSection; + } + + protected async addCollectionFilter(): Promise { + const collectionFilterSection: VaultFilterSection = { + data$: this.vaultFilterService.collectionTree$, + header: { + showHeader: true, + isSelectable: true, + }, + action: this.applyCollectionFilter, + }; + return collectionFilterSection; + } + + protected async addTrashFilter(): Promise { + const trashFilterSection: VaultFilterSection = { + data$: this.vaultFilterService.buildTypeTree( + { + id: "headTrash", + name: "HeadTrash", + type: "trash", + icon: "bwi-trash", + }, + [ + { + id: "trash", + name: this.i18nService.t("trash"), + type: "trash", + icon: "bwi-trash", + }, + ] + ), + header: { + showHeader: false, + isSelectable: true, + }, + action: this.applyTypeFilter, + }; + return trashFilterSection; + } +} diff --git a/apps/web/src/app/vault/vault-filter/organization-filter/organization-filter.component.html b/apps/web/src/app/vault/vault-filter/organization-filter/organization-filter.component.html deleted file mode 100644 index 2977f63ed7..0000000000 --- a/apps/web/src/app/vault/vault-filter/organization-filter/organization-filter.component.html +++ /dev/null @@ -1,186 +0,0 @@ - - - - - - -
- - -
- -
- -
- -
-
- -
- - -
- -
-
-
-
diff --git a/apps/web/src/app/vault/vault-filter/organization-filter/organization-filter.component.ts b/apps/web/src/app/vault/vault-filter/organization-filter/organization-filter.component.ts deleted file mode 100644 index 277768ec81..0000000000 --- a/apps/web/src/app/vault/vault-filter/organization-filter/organization-filter.component.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { Component } from "@angular/core"; - -import { OrganizationFilterComponent as BaseOrganizationFilterComponent } from "@bitwarden/angular/vault/vault-filter/components/organization-filter.component"; -import { I18nService } from "@bitwarden/common/abstractions/i18n.service"; -import { PlatformUtilsService } from "@bitwarden/common/abstractions/platformUtils.service"; -import { Organization } from "@bitwarden/common/models/domain/organization"; - -@Component({ - selector: "app-organization-filter", - templateUrl: "organization-filter.component.html", -}) -export class OrganizationFilterComponent extends BaseOrganizationFilterComponent { - displayText = "allVaults"; - - constructor( - private i18nService: I18nService, - private platformUtilsService: PlatformUtilsService - ) { - super(); - } - - async applyOrganizationFilter(organization: Organization) { - if (organization.enabled) { - //proceed with default behaviour for enabled organizations - super.applyOrganizationFilter(organization); - } else { - this.platformUtilsService.showToast( - "error", - null, - this.i18nService.t("disabledOrganizationFilterError") - ); - } - } -} diff --git a/apps/web/src/app/vault/vault-filter/services/abstractions/vault-filter.service.ts b/apps/web/src/app/vault/vault-filter/services/abstractions/vault-filter.service.ts new file mode 100644 index 0000000000..61ce3d06a5 --- /dev/null +++ b/apps/web/src/app/vault/vault-filter/services/abstractions/vault-filter.service.ts @@ -0,0 +1,30 @@ +import { Observable } from "rxjs"; + +import { Organization } from "@bitwarden/common/src/models/domain/organization"; +import { TreeNode } from "@bitwarden/common/src/models/domain/treeNode"; +import { CollectionView } from "@bitwarden/common/src/models/view/collectionView"; +import { FolderView } from "@bitwarden/common/src/models/view/folderView"; + +import { + FolderFilter, + CollectionFilter, + OrganizationFilter, + CipherTypeFilter, +} from "../../shared/models/vault-filter.type"; + +export abstract class VaultFilterService { + collapsedFilterNodes$: Observable>; + filteredFolders$: Observable; + filteredCollections$: Observable; + organizationTree$: Observable>; + folderTree$: Observable>; + collectionTree$: Observable>; + reloadCollections: () => Promise; + setCollapsedFilterNodes: (collapsedFilterNodes: Set) => Promise; + expandOrgFilter: () => Promise; + setOrganizationFilter: (organization: Organization) => void; + buildTypeTree: ( + head: CipherTypeFilter, + array: CipherTypeFilter[] + ) => Observable>; +} diff --git a/apps/web/src/app/vault/vault-filter/services/vault-filter.service.spec.ts b/apps/web/src/app/vault/vault-filter/services/vault-filter.service.spec.ts new file mode 100644 index 0000000000..a80da07823 --- /dev/null +++ b/apps/web/src/app/vault/vault-filter/services/vault-filter.service.spec.ts @@ -0,0 +1,237 @@ +import { mock, MockProxy } from "jest-mock-extended"; +import { firstValueFrom, ReplaySubject, take } from "rxjs"; + +import { CipherService } from "@bitwarden/common/abstractions/cipher.service"; +import { CollectionService } from "@bitwarden/common/abstractions/collection.service"; +import { FolderService } from "@bitwarden/common/abstractions/folder/folder.service.abstraction"; +import { I18nService } from "@bitwarden/common/abstractions/i18n.service"; +import { OrganizationService } from "@bitwarden/common/abstractions/organization/organization.service.abstraction"; +import { PolicyService } from "@bitwarden/common/abstractions/policy/policy.service.abstraction"; +import { StateService } from "@bitwarden/common/abstractions/state.service"; +import { PolicyType } from "@bitwarden/common/enums/policyType"; +import { Organization } from "@bitwarden/common/models/domain/organization"; +import { CipherView } from "@bitwarden/common/models/view/cipherView"; +import { CollectionView } from "@bitwarden/common/models/view/collectionView"; +import { FolderView } from "@bitwarden/common/models/view/folderView"; + +import { VaultFilterService } from "./vault-filter.service"; + +describe("vault filter service", () => { + let vaultFilterService: VaultFilterService; + + let stateService: MockProxy; + let organizationService: MockProxy; + let folderService: MockProxy; + let cipherService: MockProxy; + let collectionService: MockProxy; + let policyService: MockProxy; + let i18nService: MockProxy; + let organizations: ReplaySubject; + let folderViews: ReplaySubject; + + beforeEach(() => { + stateService = mock(); + organizationService = mock(); + folderService = mock(); + cipherService = mock(); + collectionService = mock(); + policyService = mock(); + i18nService = mock(); + + organizations = new ReplaySubject(1); + folderViews = new ReplaySubject(1); + + organizationService.organizations$ = organizations; + folderService.folderViews$ = folderViews; + + vaultFilterService = new VaultFilterService( + stateService, + organizationService, + folderService, + cipherService, + collectionService, + policyService, + i18nService + ); + }); + + describe("collapsed filter nodes", () => { + const nodes = new Set(["1", "2"]); + it("updates observable when saving", (complete) => { + vaultFilterService.collapsedFilterNodes$.pipe(take(1)).subscribe((value) => { + if (value === nodes) { + complete(); + } + }); + + vaultFilterService.setCollapsedFilterNodes(nodes); + }); + + it("loads from state on initialization", async () => { + stateService.getCollapsedGroupings.mockResolvedValue(["1", "2"]); + + await expect(firstValueFrom(vaultFilterService.collapsedFilterNodes$)).resolves.toEqual( + nodes + ); + }); + }); + + describe("organizations", () => { + beforeEach(() => { + const storedOrgs = [createOrganization("1", "org1"), createOrganization("2", "org2")]; + organizations.next(storedOrgs); + }); + + it("returns a nested tree", async () => { + const tree = await firstValueFrom(vaultFilterService.organizationTree$); + + expect(tree.children.length).toBe(3); + expect(tree.children.find((o) => o.node.name === "org1")); + expect(tree.children.find((o) => o.node.name === "org2")); + }); + + it("hides My Vault if personal ownership policy is enabled", async () => { + policyService.policyAppliesToUser + .calledWith(PolicyType.PersonalOwnership) + .mockResolvedValue(true); + + const tree = await firstValueFrom(vaultFilterService.organizationTree$); + + expect(tree.children.length).toBe(2); + expect(!tree.children.find((o) => o.node.id === "MyVault")); + }); + + it("returns 1 organization and My Vault if single organization policy is enabled", async () => { + policyService.policyAppliesToUser.calledWith(PolicyType.SingleOrg).mockResolvedValue(true); + + const tree = await firstValueFrom(vaultFilterService.organizationTree$); + + expect(tree.children.length).toBe(2); + expect(tree.children.find((o) => o.node.name === "org1")); + expect(tree.children.find((o) => o.node.id === "MyVault")); + }); + + it("returns 1 organization if both single organization and personal ownership policies are enabled", async () => { + policyService.policyAppliesToUser.calledWith(PolicyType.SingleOrg).mockResolvedValue(true); + policyService.policyAppliesToUser + .calledWith(PolicyType.PersonalOwnership) + .mockResolvedValue(true); + + const tree = await firstValueFrom(vaultFilterService.organizationTree$); + + expect(tree.children.length).toBe(1); + expect(tree.children.find((o) => o.node.name === "org1")); + }); + }); + + describe("folders", () => { + describe("filtered folders with organization", () => { + beforeEach(() => { + // Org must be updated before folderService else the subscription uses the null org default value + vaultFilterService.setOrganizationFilter(createOrganization("org test id", "Test Org")); + }); + it("returns folders filtered by current organization", async () => { + const storedCiphers = [ + createCipherView("1", "org test id", "folder test id"), + createCipherView("2", "non matching org id", "non matching folder id"), + ]; + cipherService.getAllDecrypted.mockResolvedValue(storedCiphers); + + const storedFolders = [ + createFolderView("folder test id", "test"), + createFolderView("non matching folder id", "test2"), + ]; + folderViews.next(storedFolders); + + await expect(firstValueFrom(vaultFilterService.filteredFolders$)).resolves.toEqual([ + createFolderView("folder test id", "test"), + ]); + }); + }); + + describe("folder tree", () => { + it("returns a nested tree", async () => { + const storedFolders = [ + createFolderView("Folder 1 Id", "Folder 1"), + createFolderView("Folder 2 Id", "Folder 1/Folder 2"), + createFolderView("Folder 3 Id", "Folder 1/Folder 3"), + ]; + folderViews.next(storedFolders); + + const result = await firstValueFrom(vaultFilterService.folderTree$); + + expect(result.children[0].node.id === "Folder 1 Id"); + expect(result.children[0].children.find((c) => c.node.id === "Folder 2 Id")); + expect(result.children[0].children.find((c) => c.node.id === "Folder 3 Id")); + }, 10000); + }); + }); + + describe("collections", () => { + describe("filtered collections", () => { + it("returns collections filtered by current organization", async () => { + vaultFilterService.setOrganizationFilter(createOrganization("org test id", "Test Org")); + + const storedCollections = [ + createCollectionView("1", "collection 1", "org test id"), + createCollectionView("2", "collection 2", "non matching org id"), + ]; + collectionService.getAllDecrypted.mockResolvedValue(storedCollections); + vaultFilterService.reloadCollections(); + + await expect(firstValueFrom(vaultFilterService.filteredCollections$)).resolves.toEqual([ + createCollectionView("1", "collection 1", "org test id"), + ]); + }); + }); + + describe("collection tree", () => { + it("returns a nested tree", async () => { + const storedCollections = [ + createCollectionView("Collection 1 Id", "Collection 1", "org test id"), + createCollectionView("Collection 2 Id", "Collection 1/Collection 2", "org test id"), + createCollectionView("Collection 3 Id", "Collection 1/Collection 3", "org test id"), + ]; + collectionService.getAllDecrypted.mockResolvedValue(storedCollections); + vaultFilterService.reloadCollections(); + + const result = await firstValueFrom(vaultFilterService.collectionTree$); + + expect(result.children[0].node.id === "Collection 1 Id"); + expect(result.children[0].children.find((c) => c.node.id === "Collection 2 Id")); + expect(result.children[0].children.find((c) => c.node.id === "Collection 3 Id")); + }); + }); + }); + + function createOrganization(id: string, name: string) { + const org = new Organization(); + org.id = id; + org.name = name; + org.identifier = name; + return org; + } + + function createCipherView(id: string, orgId: string, folderId: string) { + const cipher = new CipherView(); + cipher.id = id; + cipher.organizationId = orgId; + cipher.folderId = folderId; + return cipher; + } + + function createFolderView(id: string, name: string): FolderView { + const folder = new FolderView(); + folder.id = id; + folder.name = name; + return folder; + } + + function createCollectionView(id: string, name: string, orgId: string): CollectionView { + const collection = new CollectionView(); + collection.id = id; + collection.name = name; + collection.organizationId = orgId; + return collection; + } +}); diff --git a/apps/web/src/app/vault/vault-filter/services/vault-filter.service.ts b/apps/web/src/app/vault/vault-filter/services/vault-filter.service.ts new file mode 100644 index 0000000000..f6886f1b54 --- /dev/null +++ b/apps/web/src/app/vault/vault-filter/services/vault-filter.service.ts @@ -0,0 +1,268 @@ +import { Injectable, OnDestroy } from "@angular/core"; +import { + BehaviorSubject, + combineLatestWith, + Observable, + of, + Subject, + takeUntil, + map, + switchMap, + ReplaySubject, + firstValueFrom, +} from "rxjs"; + +import { CipherService } from "@bitwarden/common/abstractions/cipher.service"; +import { CollectionService } from "@bitwarden/common/abstractions/collection.service"; +import { FolderService } from "@bitwarden/common/abstractions/folder/folder.service.abstraction"; +import { I18nService } from "@bitwarden/common/abstractions/i18n.service"; +import { OrganizationService } from "@bitwarden/common/abstractions/organization/organization.service.abstraction"; +import { PolicyService } from "@bitwarden/common/abstractions/policy/policy.service.abstraction"; +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 { + FolderFilter, + CollectionFilter, + OrganizationFilter, + CipherTypeFilter, +} from "../shared/models/vault-filter.type"; + +import { VaultFilterService as VaultFilterServiceAbstraction } from "./abstractions/vault-filter.service"; + +const NestingDelimiter = "/"; + +@Injectable() +export class VaultFilterService implements VaultFilterServiceAbstraction, OnDestroy { + protected _collapsedFilterNodes = new BehaviorSubject>(null); + collapsedFilterNodes$: Observable> = this._collapsedFilterNodes.pipe( + switchMap(async (nodes) => nodes ?? (await this.getCollapsedFilterNodes())) + ); + + organizationTree$: Observable> = + this.organizationService.organizations$.pipe( + switchMap((orgs) => this.buildOrganizationTree(orgs)) + ); + + protected _filteredFolders = new ReplaySubject(1); + filteredFolders$: Observable = this._filteredFolders.asObservable(); + protected _filteredCollections = new ReplaySubject(1); + filteredCollections$: Observable = this._filteredCollections.asObservable(); + + folderTree$: Observable> = this.filteredFolders$.pipe( + map((folders) => this.buildFolderTree(folders)) + ); + collectionTree$: Observable> = this.filteredCollections$.pipe( + map((collections) => this.buildCollectionTree(collections)) + ); + + protected _organizationFilter = new BehaviorSubject(null); + protected destroy$: Subject = new Subject(); + + // TODO: Remove once collections is refactored with observables + protected collectionViews$ = new ReplaySubject(1); + + constructor( + protected stateService: StateService, + protected organizationService: OrganizationService, + protected folderService: FolderService, + protected cipherService: CipherService, + protected collectionService: CollectionService, + protected policyService: PolicyService, + protected i18nService: I18nService + ) { + this.loadSubscriptions(); + } + + protected loadSubscriptions() { + this.folderService.folderViews$ + .pipe( + combineLatestWith(this._organizationFilter), + switchMap(([folders, org]) => { + return this.filterFolders(folders, org); + }), + takeUntil(this.destroy$) + ) + .subscribe(this._filteredFolders); + + // TODO: Use collectionService once collections is refactored + this.collectionViews$ + .pipe( + combineLatestWith(this._organizationFilter), + switchMap(([collections, org]) => { + return this.filterCollections(collections, org); + }), + takeUntil(this.destroy$) + ) + .subscribe(this._filteredCollections); + } + + ngOnDestroy(): void { + this.destroy$.next(); + this.destroy$.complete(); + } + + // TODO: Remove once collections is refactored with observables + async reloadCollections() { + this.collectionViews$.next(await this.collectionService.getAllDecrypted()); + } + + async setCollapsedFilterNodes(collapsedFilterNodes: Set): Promise { + await this.stateService.setCollapsedGroupings(Array.from(collapsedFilterNodes)); + this._collapsedFilterNodes.next(collapsedFilterNodes); + } + + protected async getCollapsedFilterNodes(): Promise> { + const nodes = new Set(await this.stateService.getCollapsedGroupings()); + return nodes; + } + + setOrganizationFilter(organization: Organization) { + if (organization?.id != "AllVaults") { + this._organizationFilter.next(organization); + } else { + this._organizationFilter.next(null); + } + } + + async expandOrgFilter() { + const collapsedFilterNodes = await firstValueFrom(this.collapsedFilterNodes$); + if (!collapsedFilterNodes.has("AllVaults")) { + return; + } + collapsedFilterNodes.delete("AllVaults"); + await this.setCollapsedFilterNodes(collapsedFilterNodes); + } + + protected async buildOrganizationTree( + orgs?: Organization[] + ): Promise> { + const headNode = this.getOrganizationFilterHead(); + if (!(await this.policyService.policyAppliesToUser(PolicyType.PersonalOwnership))) { + const myVaultNode = this.getOrganizationFilterMyVault(); + headNode.children.push(myVaultNode); + } + if (await this.policyService.policyAppliesToUser(PolicyType.SingleOrg)) { + orgs = orgs.slice(0, 1); + } + if (orgs) { + orgs.forEach((org) => { + const orgCopy = org as OrganizationFilter; + orgCopy.icon = "bwi-business"; + const node = new TreeNode(orgCopy, headNode.node, orgCopy.name); + headNode.children.push(node); + }); + } + return headNode; + } + + protected getOrganizationFilterHead(): TreeNode { + const head = new Organization() as OrganizationFilter; + head.enabled = true; + return new TreeNode(head, null, "allVaults", "AllVaults"); + } + + protected getOrganizationFilterMyVault(): TreeNode { + const myVault = new Organization() as OrganizationFilter; + myVault.id = "MyVault"; + myVault.icon = "bwi-user"; + myVault.enabled = true; + myVault.hideOptions = true; + return new TreeNode(myVault, null, this.i18nService.t("myVault")); + } + + buildTypeTree( + head: CipherTypeFilter, + array?: CipherTypeFilter[] + ): Observable> { + const headNode = new TreeNode(head, null); + array?.forEach((filter) => { + const node = new TreeNode(filter, head, filter.name); + headNode.children.push(node); + }); + return of(headNode); + } + + protected async filterCollections( + storedCollections: CollectionView[], + org?: Organization + ): Promise { + return org?.id != null + ? storedCollections.filter((c) => c.organizationId === org.id) + : storedCollections; + } + + protected buildCollectionTree(collections?: CollectionView[]): TreeNode { + const headNode = this.getCollectionFilterHead(); + if (!collections) { + return headNode; + } + const nodes: TreeNode[] = []; + collections.forEach((c) => { + const collectionCopy = new CollectionView() as CollectionFilter; + collectionCopy.id = c.id; + collectionCopy.organizationId = c.organizationId; + collectionCopy.icon = "bwi-collection"; + const parts = c.name != null ? c.name.replace(/^\/+|\/+$/g, "").split(NestingDelimiter) : []; + ServiceUtils.nestedTraverse(nodes, 0, parts, collectionCopy, null, NestingDelimiter); + }); + nodes.forEach((n) => { + n.parent = headNode.node; + headNode.children.push(n); + }); + return headNode; + } + + protected getCollectionFilterHead(): TreeNode { + const head = new CollectionView() as CollectionFilter; + return new TreeNode(head, null, "collections", "AllCollections"); + } + + protected async filterFolders( + storedFolders: FolderView[], + org?: Organization + ): Promise { + if (org?.id == null) { + return storedFolders; + } + const ciphers = await this.cipherService.getAllDecrypted(); + const orgCiphers = ciphers.filter((c) => c.organizationId == org?.id); + return storedFolders.filter( + (f) => + orgCiphers.filter((oc) => oc.folderId == f.id).length > 0 || + ciphers.filter((c) => c.folderId == f.id).length < 1 + ); + } + + protected buildFolderTree(folders?: FolderView[]): TreeNode { + const headNode = this.getFolderFilterHead(); + if (!folders) { + return headNode; + } + const nodes: TreeNode[] = []; + folders.forEach((f) => { + const folderCopy = new FolderView() as FolderFilter; + folderCopy.id = f.id; + folderCopy.revisionDate = f.revisionDate; + folderCopy.icon = "bwi-folder"; + const parts = f.name != null ? f.name.replace(/^\/+|\/+$/g, "").split(NestingDelimiter) : []; + ServiceUtils.nestedTraverse(nodes, 0, parts, folderCopy, null, NestingDelimiter); + }); + + nodes.forEach((n) => { + n.parent = headNode.node; + headNode.children.push(n); + }); + return headNode; + } + + protected getFolderFilterHead(): TreeNode { + const head = new FolderView() as FolderFilter; + return new TreeNode(head, null, "folders", "AllFolders"); + } +} diff --git a/apps/web/src/app/vault/vault-filter/shared/collection-filter/collection-filter.component.html b/apps/web/src/app/vault/vault-filter/shared/collection-filter/collection-filter.component.html deleted file mode 100644 index 417d635bad..0000000000 --- a/apps/web/src/app/vault/vault-filter/shared/collection-filter/collection-filter.component.html +++ /dev/null @@ -1,74 +0,0 @@ - -
- -

 {{ collectionsGrouping.name | i18n }}

-
-
    - -
  • - - - - -
      - - -
    -
  • -
    - - -
-
diff --git a/apps/web/src/app/vault/vault-filter/shared/collection-filter/collection-filter.component.ts b/apps/web/src/app/vault/vault-filter/shared/collection-filter/collection-filter.component.ts deleted file mode 100644 index 161c9ae535..0000000000 --- a/apps/web/src/app/vault/vault-filter/shared/collection-filter/collection-filter.component.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { Component } from "@angular/core"; - -import { CollectionFilterComponent as BaseCollectionFilterComponent } from "@bitwarden/angular/vault/vault-filter/components/collection-filter.component"; - -@Component({ - selector: "app-collection-filter", - templateUrl: "collection-filter.component.html", -}) -export class CollectionFilterComponent extends BaseCollectionFilterComponent {} diff --git a/apps/web/src/app/vault/vault-filter/shared/components/vault-filter-section.component.html b/apps/web/src/app/vault/vault-filter/shared/components/vault-filter-section.component.html new file mode 100644 index 0000000000..364132a3cd --- /dev/null +++ b/apps/web/src/app/vault/vault-filter/shared/components/vault-filter-section.component.html @@ -0,0 +1,137 @@ + +
+ + +

+  {{ headerNode.node.name | i18n }} +

+ + +
+ +
+
diff --git a/apps/web/src/app/vault/vault-filter/shared/components/vault-filter-section.component.ts b/apps/web/src/app/vault/vault-filter/shared/components/vault-filter-section.component.ts new file mode 100644 index 0000000000..4a3a21a626 --- /dev/null +++ b/apps/web/src/app/vault/vault-filter/shared/components/vault-filter-section.component.ts @@ -0,0 +1,137 @@ +import { Component, InjectionToken, Injector, Input, OnDestroy, OnInit } from "@angular/core"; +import { Subject, takeUntil } from "rxjs"; + +import { Organization } from "@bitwarden/common/models/domain/organization"; +import { ITreeNodeObject, TreeNode } from "@bitwarden/common/models/domain/treeNode"; + +import { VaultFilterService } from "../../services/abstractions/vault-filter.service"; +import { VaultFilterSection, VaultFilterType } from "../models/vault-filter-section.type"; +import { VaultFilter } from "../models/vault-filter.model"; + +@Component({ + selector: "app-filter-section", + templateUrl: "vault-filter-section.component.html", +}) +export class VaultFilterSectionComponent implements OnInit, OnDestroy { + private destroy$ = new Subject(); + + @Input() activeFilter: VaultFilter; + @Input() section: VaultFilterSection; + + data: TreeNode; + collapsedFilterNodes: Set = new Set(); + + private injectors = new Map(); + + constructor(private vaultFilterService: VaultFilterService, private injector: Injector) { + this.vaultFilterService.collapsedFilterNodes$ + .pipe(takeUntil(this.destroy$)) + .subscribe((nodes) => { + this.collapsedFilterNodes = nodes; + }); + } + + ngOnInit() { + this.section?.data$?.pipe(takeUntil(this.destroy$)).subscribe((data) => { + this.data = data; + }); + } + + ngOnDestroy() { + this.destroy$.next(); + this.destroy$.complete(); + } + + get headerNode() { + return this.data; + } + + get headerInfo() { + return this.section.header; + } + + get filters() { + return this.data?.children; + } + + get isOrganizationFilter() { + return this.data.node instanceof Organization; + } + + get isAllVaultsSelected() { + return this.isOrganizationFilter && !this.activeFilter.selectedOrganizationNode; + } + + isNodeSelected(filterNode: TreeNode) { + return ( + this.activeFilter.organizationId === filterNode?.node.id || + this.activeFilter.cipherTypeId === filterNode?.node.id || + this.activeFilter.folderId === filterNode?.node.id || + this.activeFilter.collectionId === filterNode?.node.id + ); + } + + async onFilterSelect(filterNode: TreeNode) { + await this.section?.action(filterNode); + } + + get editInfo() { + return this.section?.edit; + } + + onEdit(filterNode: TreeNode) { + this.section?.edit?.action(filterNode.node); + } + + get addInfo() { + return this.section.add; + } + + get showAddButton() { + return this.section.add && !this.section.add.route; + } + + get showAddLink() { + return this.section.add && this.section.add.route; + } + + async onAdd() { + this.section?.add?.action(); + } + + get optionsInfo() { + return this.section?.options; + } + + get divider() { + return this.section?.divider; + } + + isCollapsed(node: ITreeNodeObject) { + return this.collapsedFilterNodes.has(node.id); + } + + async toggleCollapse(node: ITreeNodeObject) { + if (this.collapsedFilterNodes.has(node.id)) { + this.collapsedFilterNodes.delete(node.id); + } else { + this.collapsedFilterNodes.add(node.id); + } + await this.vaultFilterService.setCollapsedFilterNodes(this.collapsedFilterNodes); + } + + // an injector is necessary to pass data into a dynamic component + // here we are creating a new injector for each filter that has options + createInjector(data: VaultFilterType) { + let inject = this.injectors.get(data.id); + if (!inject) { + inject = Injector.create({ + providers: [{ provide: OptionsInput, useValue: data }], + parent: this.injector, + }); + this.injectors.set(data.id, inject); + } + return inject; + } +} +export const OptionsInput = new InjectionToken("OptionsInput"); diff --git a/apps/web/src/app/vault/vault-filter/shared/folder-filter/folder-filter.component.html b/apps/web/src/app/vault/vault-filter/shared/folder-filter/folder-filter.component.html deleted file mode 100644 index 9e89e28dcb..0000000000 --- a/apps/web/src/app/vault/vault-filter/shared/folder-filter/folder-filter.component.html +++ /dev/null @@ -1,82 +0,0 @@ - -
- -

 {{ "folders" | i18n }}

- -
-
    - -
  • - - - - - -
      - - -
    -
  • -
    - -
-
diff --git a/apps/web/src/app/vault/vault-filter/shared/folder-filter/folder-filter.component.ts b/apps/web/src/app/vault/vault-filter/shared/folder-filter/folder-filter.component.ts deleted file mode 100644 index 790d31a65e..0000000000 --- a/apps/web/src/app/vault/vault-filter/shared/folder-filter/folder-filter.component.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { Component } from "@angular/core"; - -import { FolderFilterComponent as BaseFolderFilterComponent } from "@bitwarden/angular/vault/vault-filter/components/folder-filter.component"; - -@Component({ - selector: "app-folder-filter", - templateUrl: "folder-filter.component.html", -}) -export class FolderFilterComponent extends BaseFolderFilterComponent {} diff --git a/apps/web/src/app/vault/vault-filter/shared/models/vault-filter-section.type.ts b/apps/web/src/app/vault/vault-filter/shared/models/vault-filter-section.type.ts new file mode 100644 index 0000000000..45a0a573fa --- /dev/null +++ b/apps/web/src/app/vault/vault-filter/shared/models/vault-filter-section.type.ts @@ -0,0 +1,50 @@ +import { Observable } from "rxjs"; + +import { TreeNode } from "@bitwarden/common/src/models/domain/treeNode"; + +import { + CipherTypeFilter, + CollectionFilter, + FolderFilter, + OrganizationFilter, +} from "./vault-filter.type"; + +export type VaultFilterType = + | OrganizationFilter + | CipherTypeFilter + | FolderFilter + | CollectionFilter; + +export enum VaultFilterLabel { + OrganizationFilter = "organizationFilter", + TypeFilter = "typeFilter", + FolderFilter = "folderFilter", + CollectionFilter = "collectionFilter", + TrashFilter = "trashFilter", +} + +export type VaultFilterSection = { + data$: Observable>; + header: { + showHeader: boolean; + isSelectable: boolean; + }; + action: (filterNode: TreeNode) => Promise; + edit?: { + text: string; + action: (filter: VaultFilterType) => void; + }; + add?: { + text: string; + route?: string; + action?: () => void; + }; + options?: { + component: any; + }; + divider?: boolean; +}; + +export type VaultFilterList = { + [key in VaultFilterLabel]?: VaultFilterSection; +}; diff --git a/apps/web/src/app/vault/vault-filter/shared/models/vault-filter.model.spec.ts b/apps/web/src/app/vault/vault-filter/shared/models/vault-filter.model.spec.ts new file mode 100644 index 0000000000..cbbf21cca5 --- /dev/null +++ b/apps/web/src/app/vault/vault-filter/shared/models/vault-filter.model.spec.ts @@ -0,0 +1,332 @@ +import { CipherType } from "@bitwarden/common/enums/cipherType"; +import { Organization } from "@bitwarden/common/models/domain/organization"; +import { TreeNode } from "@bitwarden/common/models/domain/treeNode"; +import { CipherView } from "@bitwarden/common/models/view/cipherView"; +import { CollectionView } from "@bitwarden/common/models/view/collectionView"; +import { FolderView } from "@bitwarden/common/models/view/folderView"; + +import { VaultFilter } from "./vault-filter.model"; +import { + CipherTypeFilter, + CollectionFilter, + FolderFilter, + OrganizationFilter, +} from "./vault-filter.type"; + +describe("VaultFilter", () => { + describe("filterFunction", () => { + const allCiphersFilter = new TreeNode( + { + id: "AllItems", + name: "allItems", + type: "all", + icon: "", + }, + null + ); + const favoriteCiphersFilter = new TreeNode( + { + id: "favorites", + name: "favorites", + type: "favorites", + icon: "bwi-star", + }, + null + ); + const identityCiphersFilter = new TreeNode( + { + id: "identity", + name: "identity", + type: CipherType.Identity, + icon: "bwi-id-card", + }, + null + ); + const trashFilter = new TreeNode( + { + id: "trash", + name: "trash", + type: "trash", + icon: "bwi-trash", + }, + null + ); + describe("generic cipher", () => { + it("should return true when no filter is applied", () => { + const cipher = createCipher(); + const filterFunction = createFilterFunction({}); + + const result = filterFunction(cipher); + + expect(result).toBe(true); + }); + }); + + describe("given a favorite cipher", () => { + const cipher = createCipher({ favorite: true }); + + it("should return true when filtering for favorites", () => { + const filterFunction = createFilterFunction({ selectedCipherTypeNode: allCiphersFilter }); + + const result = filterFunction(cipher); + + expect(result).toBe(true); + }); + + it("should return false when filtering for trash", () => { + const filterFunction = createFilterFunction({ selectedCipherTypeNode: trashFilter }); + + const result = filterFunction(cipher); + + expect(result).toBe(false); + }); + }); + + describe("given a deleted cipher", () => { + const cipher = createCipher({ deletedDate: new Date() }); + + it("should return true when filtering for trash", () => { + const filterFunction = createFilterFunction({ selectedCipherTypeNode: trashFilter }); + + const result = filterFunction(cipher); + + expect(result).toBe(true); + }); + + it("should return false when filtering for favorites", () => { + const filterFunction = createFilterFunction({ + selectedCipherTypeNode: favoriteCiphersFilter, + }); + + const result = filterFunction(cipher); + + expect(result).toBe(false); + }); + }); + + describe("given a cipher with type", () => { + it("should return true when filter matches cipher type", () => { + const cipher = createCipher({ type: CipherType.Identity }); + const filterFunction = createFilterFunction({ + selectedCipherTypeNode: identityCiphersFilter, + }); + + const result = filterFunction(cipher); + + expect(result).toBe(true); + }); + + it("should return false when filter does not match cipher type", () => { + const cipher = createCipher({ type: CipherType.Card }); + const filterFunction = createFilterFunction({ + selectedCipherTypeNode: identityCiphersFilter, + }); + + const result = filterFunction(cipher); + + expect(result).toBe(false); + }); + }); + + describe("given a cipher with folder id", () => { + it("should return true when filter matches folder id", () => { + const cipher = createCipher({ folderId: "folderId" }); + const filterFunction = createFilterFunction({ + selectedFolderNode: createFolderFilterNode({ id: "folderId" }), + }); + + const result = filterFunction(cipher); + + expect(result).toBe(true); + }); + + it("should return false when filter does not match folder id", () => { + const cipher = createCipher({ folderId: "folderId" }); + const filterFunction = createFilterFunction({ + selectedFolderNode: createFolderFilterNode({ id: "differentFolderId" }), + }); + + const result = filterFunction(cipher); + + expect(result).toBe(false); + }); + }); + + describe("given a cipher without folder", () => { + const cipher = createCipher({ folderId: null }); + + it("should return true when filtering on unassigned folder", () => { + const filterFunction = createFilterFunction({ + selectedFolderNode: createFolderFilterNode({ id: null }), + }); + + const result = filterFunction(cipher); + + expect(result).toBe(true); + }); + }); + + describe("given an organizational cipher (with organization and collections)", () => { + const cipher = createCipher({ + organizationId: "organizationId", + collectionIds: ["collectionId", "anotherId"], + }); + + it("should return true when filter matches collection id", () => { + const filterFunction = createFilterFunction({ + selectedCollectionNode: createCollectionFilterNode({ + id: "collectionId", + organizationId: "organizationId", + }), + }); + + const result = filterFunction(cipher); + + expect(result).toBe(true); + }); + + it("should return false when filter does not match collection id", () => { + const filterFunction = createFilterFunction({ + selectedCollectionNode: createCollectionFilterNode({ + id: "nonMatchingCollectionId", + organizationId: "organizationId", + }), + }); + + const result = filterFunction(cipher); + + expect(result).toBe(false); + }); + + it("should return false when filter does not match organization id", () => { + const filterFunction = createFilterFunction({ + selectedOrganizationNode: createOrganizationFilterNode({ + id: "nonMatchingOrganizationId", + }), + }); + + const result = filterFunction(cipher); + + expect(result).toBe(false); + }); + + it("should return false when filtering for my vault only", () => { + const filterFunction = createFilterFunction({ + selectedOrganizationNode: createOrganizationFilterNode({ id: "MyVault" }), + }); + + const result = filterFunction(cipher); + + expect(result).toBe(false); + }); + + it("should return false when filtering by All Collections", () => { + const filterFunction = createFilterFunction({ + selectedCollectionNode: createCollectionFilterNode({ id: "AllCollections" }), + }); + + const result = filterFunction(cipher); + + expect(result).toBe(false); + }); + }); + + describe("given an unassigned organizational cipher (with organization, without collection)", () => { + const cipher = createCipher({ organizationId: "organizationId", collectionIds: [] }); + + it("should return true when filtering for unassigned collection", () => { + const filterFunction = createFilterFunction({ + selectedCollectionNode: createCollectionFilterNode({ id: null }), + }); + + const result = filterFunction(cipher); + + expect(result).toBe(true); + }); + + it("should return true when filter matches organization id", () => { + const filterFunction = createFilterFunction({ + selectedOrganizationNode: createOrganizationFilterNode({ id: "organizationId" }), + }); + + const result = filterFunction(cipher); + + expect(result).toBe(true); + }); + }); + + describe("given an individual cipher (without organization or collection)", () => { + const cipher = createCipher({ organizationId: null, collectionIds: [] }); + + it("should return false when filtering for unassigned collection", () => { + const filterFunction = createFilterFunction({ + selectedCollectionNode: createCollectionFilterNode({ id: null }), + }); + + const result = filterFunction(cipher); + + expect(result).toBe(false); + }); + + it("should return true when filtering for my vault only", () => { + const cipher = createCipher({ organizationId: null }); + const filterFunction = createFilterFunction({ + selectedOrganizationNode: createOrganizationFilterNode({ id: "MyVault" }), + }); + + const result = filterFunction(cipher); + + expect(result).toBe(true); + }); + }); + }); +}); + +function createFilterFunction(options: Partial = {}) { + return new VaultFilter(options).buildFilter(); +} + +function createOrganizationFilterNode( + options: Partial +): TreeNode { + const org = new Organization() as OrganizationFilter; + org.id = options.id; + org.icon = options.icon ?? ""; + return new TreeNode(org, null); +} + +function createFolderFilterNode(options: Partial): TreeNode { + const folder = new FolderView() as FolderFilter; + folder.id = options.id; + folder.name = options.name; + folder.icon = options.icon ?? ""; + folder.revisionDate = options.revisionDate ?? new Date(); + return new TreeNode(folder, null); +} + +function createCollectionFilterNode( + options: Partial +): TreeNode { + const collection = new CollectionView() as CollectionFilter; + collection.id = options.id; + collection.name = options.name ?? ""; + collection.icon = options.icon ?? ""; + collection.organizationId = options.organizationId; + collection.externalId = options.externalId ?? ""; + collection.readOnly = options.readOnly ?? false; + collection.hidePasswords = options.hidePasswords ?? false; + return new TreeNode(collection, null); +} + +function createCipher(options: Partial = {}) { + const cipher = new CipherView(); + + cipher.favorite = options.favorite ?? false; + cipher.deletedDate = options.deletedDate; + cipher.type = options.type; + cipher.folderId = options.folderId; + cipher.collectionIds = options.collectionIds; + cipher.organizationId = options.organizationId; + + return cipher; +} diff --git a/apps/web/src/app/vault/vault-filter/shared/models/vault-filter.model.ts b/apps/web/src/app/vault/vault-filter/shared/models/vault-filter.model.ts new file mode 100644 index 0000000000..a8f0390268 --- /dev/null +++ b/apps/web/src/app/vault/vault-filter/shared/models/vault-filter.model.ts @@ -0,0 +1,127 @@ +import { CipherType } from "@bitwarden/common/enums/cipherType"; +import { TreeNode } from "@bitwarden/common/models/domain/treeNode"; +import { CipherView } from "@bitwarden/common/models/view/cipherView"; + +import { + CipherStatus, + CipherTypeFilter, + CollectionFilter, + FolderFilter, + OrganizationFilter, +} from "./vault-filter.type"; + +export type VaultFilterFunction = (cipher: CipherView) => boolean; + +// TODO: Replace shared VaultFilter Model with this one and +// refactor browser and desktop code to use this model. +export class VaultFilter { + selectedOrganizationNode: TreeNode; + selectedCipherTypeNode: TreeNode; + selectedFolderNode: TreeNode; + selectedCollectionNode: TreeNode; + + get isFavorites(): boolean { + return this.selectedCipherTypeNode?.node.type === "favorites"; + } + + get isDeleted(): boolean { + return this.selectedCipherTypeNode?.node.type === "trash" ? true : null; + } + + get organizationId(): string { + return this.selectedOrganizationNode?.node.id; + } + + get cipherType(): CipherType { + return this.selectedCipherTypeNode?.node.type in CipherType + ? (this.selectedCipherTypeNode?.node.type as CipherType) + : null; + } + + get cipherStatus(): CipherStatus { + return this.selectedCipherTypeNode?.node.type; + } + + get cipherTypeId(): string { + return this.selectedCipherTypeNode?.node.id; + } + + get folderId(): string { + return this.selectedFolderNode?.node.id; + } + + get collectionId(): string { + return this.selectedCollectionNode?.node.id; + } + + constructor(init?: Partial) { + Object.assign(this, init); + } + + resetFilter() { + this.selectedCipherTypeNode = null; + this.selectedFolderNode = null; + this.selectedCollectionNode = null; + } + + resetOrganization() { + this.selectedOrganizationNode = null; + } + + buildFilter(): VaultFilterFunction { + return (cipher) => { + let cipherPassesFilter = true; + if (this.isFavorites && cipherPassesFilter) { + cipherPassesFilter = cipher.favorite; + } + if (this.isDeleted && cipherPassesFilter) { + cipherPassesFilter = cipher.isDeleted; + } + if (this.cipherType && cipherPassesFilter) { + cipherPassesFilter = cipher.type === this.cipherType; + } + if (this.selectedFolderNode) { + // No folder + if (this.folderId === null && cipherPassesFilter) { + cipherPassesFilter = cipher.folderId === null; + } + // Folder + if (this.folderId !== null && cipherPassesFilter) { + cipherPassesFilter = cipher.folderId === this.folderId; + } + } + if (this.selectedCollectionNode) { + // All Collections + if (this.collectionId === "AllCollections" && cipherPassesFilter) { + cipherPassesFilter = false; + } + // Unassigned + if (this.collectionId === null && cipherPassesFilter) { + cipherPassesFilter = + cipher.organizationId != null && + (cipher.collectionIds == null || cipher.collectionIds.length === 0); + } + // Collection + if ( + this.collectionId !== null && + this.collectionId !== "AllCollections" && + cipherPassesFilter + ) { + cipherPassesFilter = + cipher.collectionIds != null && cipher.collectionIds.includes(this.collectionId); + } + } + if (this.selectedOrganizationNode) { + // My Vault + if (this.organizationId === "MyVault" && cipherPassesFilter) { + cipherPassesFilter = cipher.organizationId === null; + } + // Organization + else if (this.organizationId !== null && cipherPassesFilter) { + cipherPassesFilter = cipher.organizationId === this.organizationId; + } + } + return cipherPassesFilter; + }; + } +} diff --git a/apps/web/src/app/vault/vault-filter/shared/models/vault-filter.type.ts b/apps/web/src/app/vault/vault-filter/shared/models/vault-filter.type.ts new file mode 100644 index 0000000000..26035acf32 --- /dev/null +++ b/apps/web/src/app/vault/vault-filter/shared/models/vault-filter.type.ts @@ -0,0 +1,12 @@ +import { CipherType } from "@bitwarden/common/src/enums/cipherType"; +import { Organization } from "@bitwarden/common/src/models/domain/organization"; +import { ITreeNodeObject } from "@bitwarden/common/src/models/domain/treeNode"; +import { CollectionView } from "@bitwarden/common/src/models/view/collectionView"; +import { FolderView } from "@bitwarden/common/src/models/view/folderView"; + +export type CipherStatus = "all" | "favorites" | "trash" | CipherType; + +export type CipherTypeFilter = ITreeNodeObject & { type: CipherStatus; icon: string }; +export type CollectionFilter = CollectionView & { icon: string }; +export type FolderFilter = FolderView & { icon: string }; +export type OrganizationFilter = Organization & { icon: string; hideOptions?: boolean }; diff --git a/apps/web/src/app/vault/vault-filter/shared/status-filter/status-filter.component.html b/apps/web/src/app/vault/vault-filter/shared/status-filter/status-filter.component.html deleted file mode 100644 index 28c58c70be..0000000000 --- a/apps/web/src/app/vault/vault-filter/shared/status-filter/status-filter.component.html +++ /dev/null @@ -1,33 +0,0 @@ - -
    -
  • - - - -
  • -
  • - - - -
  • -
  • - - - -
  • -
-
diff --git a/apps/web/src/app/vault/vault-filter/shared/status-filter/status-filter.component.ts b/apps/web/src/app/vault/vault-filter/shared/status-filter/status-filter.component.ts deleted file mode 100644 index 5d43fd52d2..0000000000 --- a/apps/web/src/app/vault/vault-filter/shared/status-filter/status-filter.component.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { Component } from "@angular/core"; - -import { StatusFilterComponent as BaseStatusFilterComponent } from "@bitwarden/angular/vault/vault-filter/components/status-filter.component"; - -@Component({ - selector: "app-status-filter", - templateUrl: "status-filter.component.html", -}) -export class StatusFilterComponent extends BaseStatusFilterComponent {} diff --git a/apps/web/src/app/vault/vault-filter/shared/type-filter/type-filter.component.html b/apps/web/src/app/vault/vault-filter/shared/type-filter/type-filter.component.html deleted file mode 100644 index 1149b0e95b..0000000000 --- a/apps/web/src/app/vault/vault-filter/shared/type-filter/type-filter.component.html +++ /dev/null @@ -1,60 +0,0 @@ -
- -

 {{ "types" | i18n }}

-
-
    -
  • - - - -
  • -
  • - - - -
  • -
  • - - - -
  • -
  • - - - -
  • -
diff --git a/apps/web/src/app/vault/vault-filter/shared/type-filter/type-filter.component.ts b/apps/web/src/app/vault/vault-filter/shared/type-filter/type-filter.component.ts deleted file mode 100644 index beb3e8d9bb..0000000000 --- a/apps/web/src/app/vault/vault-filter/shared/type-filter/type-filter.component.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { Component } from "@angular/core"; - -import { TypeFilterComponent as BaseTypeFilterComponent } from "@bitwarden/angular/vault/vault-filter/components/type-filter.component"; - -@Component({ - selector: "app-type-filter", - templateUrl: "type-filter.component.html", -}) -export class TypeFilterComponent extends BaseTypeFilterComponent {} diff --git a/apps/web/src/app/vault/vault-filter/shared/vault-filter-shared.module.ts b/apps/web/src/app/vault/vault-filter/shared/vault-filter-shared.module.ts index b099655d66..04803ae2a7 100644 --- a/apps/web/src/app/vault/vault-filter/shared/vault-filter-shared.module.ts +++ b/apps/web/src/app/vault/vault-filter/shared/vault-filter-shared.module.ts @@ -2,27 +2,11 @@ import { NgModule } from "@angular/core"; import { SharedModule } from "../../../shared"; -import { CollectionFilterComponent } from "./collection-filter/collection-filter.component"; -import { FolderFilterComponent } from "./folder-filter/folder-filter.component"; -import { StatusFilterComponent } from "./status-filter/status-filter.component"; -import { TypeFilterComponent } from "./type-filter/type-filter.component"; -import { VaultFilterService } from "./vault-filter.service"; +import { VaultFilterSectionComponent } from "./components/vault-filter-section.component"; @NgModule({ imports: [SharedModule], - declarations: [ - CollectionFilterComponent, - FolderFilterComponent, - StatusFilterComponent, - TypeFilterComponent, - ], - exports: [ - SharedModule, - CollectionFilterComponent, - FolderFilterComponent, - StatusFilterComponent, - TypeFilterComponent, - ], - providers: [VaultFilterService], + declarations: [VaultFilterSectionComponent], + exports: [SharedModule, VaultFilterSectionComponent], }) export class VaultFilterSharedModule {} diff --git a/apps/web/src/app/vault/vault-filter/shared/vault-filter.service.ts b/apps/web/src/app/vault/vault-filter/shared/vault-filter.service.ts deleted file mode 100644 index d8bb48ce66..0000000000 --- a/apps/web/src/app/vault/vault-filter/shared/vault-filter.service.ts +++ /dev/null @@ -1,85 +0,0 @@ -import { Injectable } from "@angular/core"; -import { BehaviorSubject, Observable } from "rxjs"; - -import { DynamicTreeNode } from "@bitwarden/angular/vault/vault-filter/models/dynamic-tree-node.model"; -import { VaultFilterService as BaseVaultFilterService } from "@bitwarden/angular/vault/vault-filter/services/vault-filter.service"; -import { ApiService } from "@bitwarden/common/abstractions/api.service"; -import { CipherService } from "@bitwarden/common/abstractions/cipher.service"; -import { CollectionService } from "@bitwarden/common/abstractions/collection.service"; -import { FolderService } from "@bitwarden/common/abstractions/folder/folder.service.abstraction"; -import { I18nService } from "@bitwarden/common/abstractions/i18n.service"; -import { OrganizationService } from "@bitwarden/common/abstractions/organization/organization.service.abstraction"; -import { PolicyService } from "@bitwarden/common/abstractions/policy/policy.service.abstraction"; -import { StateService } from "@bitwarden/common/abstractions/state.service"; -import { CollectionData } from "@bitwarden/common/models/data/collectionData"; -import { Collection } from "@bitwarden/common/models/domain/collection"; -import { CollectionDetailsResponse } from "@bitwarden/common/models/response/collectionResponse"; -import { CollectionView } from "@bitwarden/common/models/view/collectionView"; - -@Injectable() -export class VaultFilterService extends BaseVaultFilterService { - private _collapsedFilterNodes = new BehaviorSubject>(null); - collapsedFilterNodes$: Observable> = this._collapsedFilterNodes.asObservable(); - - constructor( - stateService: StateService, - organizationService: OrganizationService, - folderService: FolderService, - cipherService: CipherService, - collectionService: CollectionService, - policyService: PolicyService, - private i18nService: I18nService, - protected apiService: ApiService - ) { - super( - stateService, - organizationService, - folderService, - cipherService, - collectionService, - policyService - ); - } - - async buildCollapsedFilterNodes(): Promise> { - const nodes = await super.buildCollapsedFilterNodes(); - this._collapsedFilterNodes.next(nodes); - return nodes; - } - - async storeCollapsedFilterNodes(collapsedFilterNodes: Set): Promise { - await super.storeCollapsedFilterNodes(collapsedFilterNodes); - this._collapsedFilterNodes.next(collapsedFilterNodes); - } - - async ensureVaultFiltersAreExpanded() { - const collapsedFilterNodes = await super.buildCollapsedFilterNodes(); - if (!collapsedFilterNodes.has("vaults")) { - return; - } - collapsedFilterNodes.delete("vaults"); - await this.storeCollapsedFilterNodes(collapsedFilterNodes); - } - - async buildAdminCollections(organizationId: string) { - let result: CollectionView[] = []; - const collectionResponse = await this.apiService.getCollections(organizationId); - if (collectionResponse?.data != null && collectionResponse.data.length) { - const collectionDomains = collectionResponse.data.map( - (r: CollectionDetailsResponse) => new Collection(new CollectionData(r)) - ); - result = await this.collectionService.decryptMany(collectionDomains); - } - - const noneCollection = new CollectionView(); - noneCollection.name = this.i18nService.t("unassigned"); - noneCollection.organizationId = organizationId; - result.push(noneCollection); - - const nestedCollections = await this.collectionService.getAllNested(result); - return new DynamicTreeNode({ - fullList: result, - nestedList: nestedCollections, - }); - } -} diff --git a/apps/web/src/app/vault/vault-filter/vault-filter.component.html b/apps/web/src/app/vault/vault-filter/vault-filter.component.html deleted file mode 100644 index af77967e64..0000000000 --- a/apps/web/src/app/vault/vault-filter/vault-filter.component.html +++ /dev/null @@ -1,79 +0,0 @@ -
-
- -
-
-
- {{ "filters" | i18n }} - - - -
-
- - -
- -
-
- -
-
- -
-
- -
-
-
-
diff --git a/apps/web/src/app/vault/vault-filter/vault-filter.component.ts b/apps/web/src/app/vault/vault-filter/vault-filter.component.ts deleted file mode 100644 index aaa2c839a9..0000000000 --- a/apps/web/src/app/vault/vault-filter/vault-filter.component.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { Component, EventEmitter, Output } from "@angular/core"; - -import { VaultFilterComponent as BaseVaultFilterComponent } from "@bitwarden/angular/vault/vault-filter/components/vault-filter.component"; - -import { VaultFilterService } from "./shared/vault-filter.service"; - -@Component({ - selector: "./app-vault-filter", - templateUrl: "vault-filter.component.html", -}) -// eslint-disable-next-line rxjs-angular/prefer-takeuntil -export class VaultFilterComponent extends BaseVaultFilterComponent { - @Output() onSearchTextChanged = new EventEmitter(); - - searchPlaceholder: string; - searchText = ""; - - constructor(protected vaultFilterService: VaultFilterService) { - // This empty constructor is required to provide the web vaultFilterService subclass to super() - // TODO: refactor this to use proper dependency injection - super(vaultFilterService); - } - - async ngOnInit() { - await super.ngOnInit(); - // eslint-disable-next-line rxjs-angular/prefer-takeuntil - this.vaultFilterService.collapsedFilterNodes$.subscribe((nodes) => { - this.collapsedFilterNodes = nodes; - }); - } - - searchTextChanged() { - this.onSearchTextChanged.emit(this.searchText); - } -} diff --git a/apps/web/src/app/vault/vault-filter/vault-filter.module.ts b/apps/web/src/app/vault/vault-filter/vault-filter.module.ts index f325392094..7ca4ad3df8 100644 --- a/apps/web/src/app/vault/vault-filter/vault-filter.module.ts +++ b/apps/web/src/app/vault/vault-filter/vault-filter.module.ts @@ -1,19 +1,21 @@ import { NgModule } from "@angular/core"; -import { LinkSsoComponent } from "./organization-filter/link-sso.component"; -import { OrganizationFilterComponent } from "./organization-filter/organization-filter.component"; -import { OrganizationOptionsComponent } from "./organization-filter/organization-options.component"; +import { LinkSsoComponent } from "./components/link-sso.component"; +import { OrganizationOptionsComponent } from "./components/organization-options.component"; +import { VaultFilterComponent } from "./components/vault-filter.component"; +import { VaultFilterService as VaultFilterServiceAbstraction } from "./services/abstractions/vault-filter.service"; +import { VaultFilterService } from "./services/vault-filter.service"; import { VaultFilterSharedModule } from "./shared/vault-filter-shared.module"; -import { VaultFilterComponent } from "./vault-filter.component"; @NgModule({ imports: [VaultFilterSharedModule], - declarations: [ - VaultFilterComponent, - OrganizationFilterComponent, - OrganizationOptionsComponent, - LinkSsoComponent, - ], + declarations: [VaultFilterComponent, OrganizationOptionsComponent, LinkSsoComponent], exports: [VaultFilterComponent], + providers: [ + { + provide: VaultFilterServiceAbstraction, + useClass: VaultFilterService, + }, + ], }) export class VaultFilterModule {} diff --git a/apps/web/src/app/vault/vault.component.html b/apps/web/src/app/vault/vault.component.html index 151354e4dc..705c04efb0 100644 --- a/apps/web/src/app/vault/vault.component.html +++ b/apps/web/src/app/vault/vault.component.html @@ -7,10 +7,10 @@ @@ -34,24 +34,20 @@
- + {{ trashCleanupWarning }} ; + this.filterComponent.filters?.organizationFilter?.action(orgNode); } + addFolder = async (): Promise => { + const [modal] = await this.modalService.openViewRef( + FolderAddEditComponent, + this.folderAddEditModalRef, + (comp) => { + comp.folderId = null; + // eslint-disable-next-line rxjs-angular/prefer-takeuntil, rxjs/no-async-subscribe + comp.onSavedFolder.subscribe(async () => { + modal.close(); + }); + } + ); + }; + + editFolder = async (folder: FolderFilter): Promise => { + const [modal] = await this.modalService.openViewRef( + FolderAddEditComponent, + this.folderAddEditModalRef, + (comp) => { + comp.folderId = folder.id; + // eslint-disable-next-line rxjs-angular/prefer-takeuntil, rxjs/no-async-subscribe + comp.onSavedFolder.subscribe(async () => { + modal.close(); + }); + // eslint-disable-next-line rxjs-angular/prefer-takeuntil, rxjs/no-async-subscribe + comp.onDeletedFolder.subscribe(async () => { + modal.close(); + }); + } + ); + }; + filterSearchText(searchText: string) { this.ciphersComponent.searchText = searchText; this.ciphersComponent.search(200); @@ -208,7 +233,7 @@ export class VaultComponent implements OnInit, OnDestroy { this.messagingService.send("premiumRequired"); return; } else if (cipher.organizationId != null) { - const org = await this.organizationService.get(cipher.organizationId); + const org = this.organizationService.get(cipher.organizationId); if (org != null && (org.maxStorageGb == null || org.maxStorageGb === 0)) { this.messagingService.send("upgradeOrganization", { organizationId: cipher.organizationId, @@ -271,60 +296,20 @@ export class VaultComponent implements OnInit, OnDestroy { ); } - async addFolder() { - const [modal] = await this.modalService.openViewRef( - FolderAddEditComponent, - this.folderAddEditModalRef, - (comp) => { - comp.folderId = null; - // eslint-disable-next-line rxjs-angular/prefer-takeuntil, rxjs/no-async-subscribe - comp.onSavedFolder.subscribe(async () => { - modal.close(); - await this.filterComponent.reloadCollectionsAndFolders(this.activeFilter); - }); - } - ); - } - - async editFolder(folderId: string) { - const [modal] = await this.modalService.openViewRef( - FolderAddEditComponent, - this.folderAddEditModalRef, - (comp) => { - comp.folderId = folderId; - // eslint-disable-next-line rxjs-angular/prefer-takeuntil, rxjs/no-async-subscribe - comp.onSavedFolder.subscribe(async () => { - modal.close(); - await this.filterComponent.reloadCollectionsAndFolders(this.activeFilter); - }); - // eslint-disable-next-line rxjs-angular/prefer-takeuntil, rxjs/no-async-subscribe - comp.onDeletedFolder.subscribe(async () => { - modal.close(); - await this.filterComponent.reloadCollectionsAndFolders(this.activeFilter); - }); - } - ); - } - async addCipher() { const component = await this.editCipher(null); component.type = this.activeFilter.cipherType; - component.folderId = this.folderId === "none" ? null : this.folderId; - if (this.activeFilter.selectedCollectionId != null) { - const collection = this.filterComponent.collections.fullList.filter( - (c) => c.id === this.activeFilter.selectedCollectionId - ); - if (collection.length > 0) { - component.organizationId = collection[0].organizationId; - component.collectionIds = [this.activeFilter.selectedCollectionId]; - } + if (this.activeFilter.organizationId !== "MyVault") { + component.organizationId = this.activeFilter.organizationId; + component.collections = ( + await firstValueFrom(this.vaultFilterService.filteredCollections$) + ).filter((c) => !c.readOnly && c.id != null); } - if (this.activeFilter.selectedFolderId && this.activeFilter.selectedFolder) { - component.folderId = this.activeFilter.selectedFolderId; - } - if (this.activeFilter.selectedOrganizationId) { - component.organizationId = this.activeFilter.selectedOrganizationId; + const selectedColId = this.activeFilter.collectionId; + if (selectedColId !== "AllCollections") { + component.collectionIds = [selectedColId]; } + component.folderId = this.activeFilter.folderId; } async editCipher(cipher: CipherView) { @@ -382,11 +367,11 @@ export class VaultComponent implements OnInit, OnDestroy { private go(queryParams: any = null) { if (queryParams == null) { queryParams = { - favorites: this.activeFilter.status === "favorites" ? true : null, + favorites: this.activeFilter.isFavorites || null, type: this.activeFilter.cipherType, - folderId: this.activeFilter.selectedFolderId, - collectionId: this.activeFilter.selectedCollectionId, - deleted: this.activeFilter.status === "trash" ? true : null, + folderId: this.activeFilter.folderId, + collectionId: this.activeFilter.collectionId, + deleted: this.activeFilter.isDeleted || null, }; } diff --git a/apps/web/src/app/vault/vault.module.ts b/apps/web/src/app/vault/vault.module.ts index 8dc83d613d..cb910efa46 100644 --- a/apps/web/src/app/vault/vault.module.ts +++ b/apps/web/src/app/vault/vault.module.ts @@ -1,13 +1,23 @@ import { NgModule } from "@angular/core"; +import { SharedModule, LooseComponentsModule } from "../shared"; + import { CiphersComponent } from "./ciphers.component"; import { OrganizationBadgeModule } from "./organization-badge/organization-badge.module"; -import { VaultSharedModule } from "./shared/vault-shared.module"; +import { PipesModule } from "./pipes/pipes.module"; +import { VaultFilterModule } from "./vault-filter/vault-filter.module"; import { VaultRoutingModule } from "./vault-routing.module"; import { VaultComponent } from "./vault.component"; @NgModule({ - imports: [VaultSharedModule, VaultRoutingModule, OrganizationBadgeModule], + imports: [ + VaultFilterModule, + VaultRoutingModule, + OrganizationBadgeModule, + PipesModule, + SharedModule, + LooseComponentsModule, + ], declarations: [VaultComponent, CiphersComponent], exports: [VaultComponent], }) diff --git a/apps/web/src/locales/en/messages.json b/apps/web/src/locales/en/messages.json index 306f27609a..ab046e4b6d 100644 --- a/apps/web/src/locales/en/messages.json +++ b/apps/web/src/locales/en/messages.json @@ -277,13 +277,31 @@ "searchFavorites": { "message": "Search Favorites" }, - "searchType": { - "message": "Search Type", - "description": "Search item type" + "searchLogin": { + "message": "Search Logins", + "description": "Search Login type" + }, + "searchCard": { + "message": "Search Cards", + "description": "Search Card type" + }, + "searchIdentity": { + "message": "Search Identities", + "description": "Search Identity type" + }, + "searchSecureNote": { + "message": "Search Secure Notes", + "description": "Search Secure Note type" }, "searchVault": { "message": "Search Vault" }, + "searchMyVault": { + "message": "Search My Vault" + }, + "searchOrganization": { + "message": "Search Organization" + }, "allItems": { "message": "All Items" }, diff --git a/apps/web/src/scss/pages.scss b/apps/web/src/scss/pages.scss index 088be84f50..9a7a68f082 100644 --- a/apps/web/src/scss/pages.scss +++ b/apps/web/src/scss/pages.scss @@ -202,7 +202,7 @@ app-sponsored-families { font-weight: bold; } } - button.org-options { + button.filter-options-icon { background: none; border: none; padding: 0; diff --git a/apps/web/src/scss/vault-filters.scss b/apps/web/src/scss/vault-filters.scss index 9f39164ad7..01c3903c50 100644 --- a/apps/web/src/scss/vault-filters.scss +++ b/apps/web/src/scss/vault-filters.scss @@ -4,7 +4,6 @@ .filter-heading { display: flex; - text-transform: uppercase; align-items: center; * { @@ -26,20 +25,24 @@ } } - button.filter-button { - &:hover, - &:focus, - &.active { - @include themify($themes) { - color: themed("linkColor") !important; + .filter-button { + h3, + button { + &:hover, + &:focus, + &.active { + @include themify($themes) { + color: themed("linkColor") !important; + } + } + &.active { + font-weight: bold; } - } - &.active { - font-weight: bold; } } - button.toggle-button { + button.toggle-button, + button.add-button { &:hover, &:focus { @include themify($themes) { @@ -137,7 +140,7 @@ } } - .org-options { + .filter-options-icon { padding: 0 2px; } } @@ -161,7 +164,6 @@ h3 { font-weight: normal; - text-transform: uppercase; @include themify($themes) { color: themed("textMuted"); } @@ -176,11 +178,6 @@ color: themed("textHeadingColor"); font-weight: themed("linkWeight"); } - - &:hover { - &.text-muted { - } - } } .show-active { display: none; diff --git a/clients.code-workspace b/clients.code-workspace index f458d554c5..f9c7f0cc95 100644 --- a/clients.code-workspace +++ b/clients.code-workspace @@ -57,6 +57,6 @@ "dbaeumer.vscode-eslint", "esbenp.prettier-vscode", "Angular.ng-template" - ], + ] } } diff --git a/libs/angular/src/abstractions/deprecated-vault-filter.service.ts b/libs/angular/src/abstractions/deprecated-vault-filter.service.ts new file mode 100644 index 0000000000..061593b40f --- /dev/null +++ b/libs/angular/src/abstractions/deprecated-vault-filter.service.ts @@ -0,0 +1,20 @@ +import { Observable } from "rxjs"; + +import { Organization } from "@bitwarden/common/models/domain/organization"; +import { CollectionView } from "@bitwarden/common/models/view/collectionView"; +import { FolderView } from "@bitwarden/common/models/view/folderView"; + +import { DynamicTreeNode } from "../vault/vault-filter/models/dynamic-tree-node.model"; + +/** + * @deprecated August 30 2022: Use new VaultFilterService with observables + */ +export abstract class DeprecatedVaultFilterService { + buildOrganizations: () => Promise; + buildNestedFolders: (organizationId?: string) => Observable>; + buildCollections: (organizationId?: string) => Promise>; + buildCollapsedFilterNodes: () => Promise>; + storeCollapsedFilterNodes: (collapsedFilterNodes: Set) => Promise; + checkForSingleOrganizationPolicy: () => Promise; + checkForPersonalOwnershipPolicy: () => Promise; +} diff --git a/libs/angular/src/vault/vault-filter/components/vault-filter.component.ts b/libs/angular/src/vault/vault-filter/components/vault-filter.component.ts index 136be97d6a..43cd46c45d 100644 --- a/libs/angular/src/vault/vault-filter/components/vault-filter.component.ts +++ b/libs/angular/src/vault/vault-filter/components/vault-filter.component.ts @@ -1,6 +1,7 @@ import { Directive, EventEmitter, Input, OnInit, Output } from "@angular/core"; import { firstValueFrom, Observable } from "rxjs"; +import { DeprecatedVaultFilterService } from "@bitwarden/angular/abstractions/deprecated-vault-filter.service"; import { Organization } from "@bitwarden/common/models/domain/organization"; import { ITreeNodeObject } from "@bitwarden/common/models/domain/treeNode"; import { CollectionView } from "@bitwarden/common/models/view/collectionView"; @@ -8,8 +9,9 @@ import { FolderView } from "@bitwarden/common/models/view/folderView"; import { DynamicTreeNode } from "../models/dynamic-tree-node.model"; import { VaultFilter } from "../models/vault-filter.model"; -import { VaultFilterService } from "../services/vault-filter.service"; +// TODO: Replace with refactored web vault filter component +// and refactor desktop/browser vault filters @Directive() export class VaultFilterComponent implements OnInit { @Input() activeFilter: VaultFilter = new VaultFilter(); @@ -31,7 +33,7 @@ export class VaultFilterComponent implements OnInit { collections: DynamicTreeNode; folders$: Observable>; - constructor(protected vaultFilterService: VaultFilterService) {} + constructor(protected vaultFilterService: DeprecatedVaultFilterService) {} get displayCollections() { return this.collections?.fullList != null && this.collections.fullList.length > 0; diff --git a/libs/angular/src/vault/vault-filter/models/dynamic-tree-node.model.ts b/libs/angular/src/vault/vault-filter/models/dynamic-tree-node.model.ts index 31ba21e481..502330005f 100644 --- a/libs/angular/src/vault/vault-filter/models/dynamic-tree-node.model.ts +++ b/libs/angular/src/vault/vault-filter/models/dynamic-tree-node.model.ts @@ -1,8 +1,6 @@ -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 { ITreeNodeObject, TreeNode } from "@bitwarden/common/models/domain/treeNode"; -export class DynamicTreeNode { +export class DynamicTreeNode { fullList: T[]; nestedList: TreeNode[]; diff --git a/libs/angular/src/vault/vault-filter/services/vault-filter.service.ts b/libs/angular/src/vault/vault-filter/services/vault-filter.service.ts index 9155582a61..a2226b2578 100644 --- a/libs/angular/src/vault/vault-filter/services/vault-filter.service.ts +++ b/libs/angular/src/vault/vault-filter/services/vault-filter.service.ts @@ -1,6 +1,7 @@ import { Injectable } from "@angular/core"; -import { firstValueFrom, from, mergeMap, Observable } from "rxjs"; +import { firstValueFrom, mergeMap, Observable, from } from "rxjs"; +import { DeprecatedVaultFilterService as DeprecatedVaultFilterServiceAbstraction } from "@bitwarden/angular/abstractions/deprecated-vault-filter.service"; import { CipherService } from "@bitwarden/common/abstractions/cipher.service"; import { CollectionService } from "@bitwarden/common/abstractions/collection.service"; import { FolderService } from "@bitwarden/common/abstractions/folder/folder.service.abstraction"; @@ -19,7 +20,7 @@ import { DynamicTreeNode } from "../models/dynamic-tree-node.model"; const NestingDelimiter = "/"; @Injectable() -export class VaultFilterService { +export class VaultFilterService implements DeprecatedVaultFilterServiceAbstraction { constructor( protected stateService: StateService, protected organizationService: OrganizationService, @@ -106,6 +107,6 @@ export class VaultFilterService { const folders = await this.getAllFoldersNested( await firstValueFrom(this.folderService.folderViews$) ); - return ServiceUtils.getTreeNodeObject(folders, id) as TreeNode; + return ServiceUtils.getTreeNodeObjectFromList(folders, id) as TreeNode; } } diff --git a/libs/common/src/misc/serviceUtils.ts b/libs/common/src/misc/serviceUtils.ts index b6c0509237..418b8fb541 100644 --- a/libs/common/src/misc/serviceUtils.ts +++ b/libs/common/src/misc/serviceUtils.ts @@ -1,6 +1,15 @@ import { ITreeNodeObject, TreeNode } from "../models/domain/treeNode"; export class ServiceUtils { + /** + * Recursively adds a node to nodeTree + * @param {TreeNode[]} nodeTree - An array of TreeNodes that the node will be added to + * @param {number} partIndex - Index of the `parts` array that is being processed + * @param {string[]} parts - Array of strings that represent the path to the `obj` node + * @param {ITreeNodeObject} obj - The node to be added to the tree + * @param {ITreeNodeObject} parent - The parent node of the `obj` node + * @param {string} delimiter - The delimiter used to split the path string + */ static nestedTraverse( nodeTree: TreeNode[], partIndex: number, @@ -22,7 +31,7 @@ export class ServiceUtils { } if (end && nodeTree[i].node.id !== obj.id) { // Another node with the same name. - nodeTree.push(new TreeNode(obj, partName, parent)); + nodeTree.push(new TreeNode(obj, parent, partName)); return; } ServiceUtils.nestedTraverse( @@ -38,7 +47,7 @@ export class ServiceUtils { if (nodeTree.filter((n) => n.node.name === partName).length === 0) { if (end) { - nodeTree.push(new TreeNode(obj, partName, parent)); + nodeTree.push(new TreeNode(obj, parent, partName)); return; } const newPartName = parts[partIndex] + delimiter + parts[partIndex + 1]; @@ -53,7 +62,37 @@ export class ServiceUtils { } } + /** + * Searches a tree for a node with a matching `id` + * @param {TreeNode} nodeTree - A single TreeNode branch that will be searched + * @param {string} id - The id of the node to be found + * @returns {TreeNode} The node with a matching `id` + */ static getTreeNodeObject( + nodeTree: TreeNode, + id: string + ): TreeNode { + if (nodeTree.node.id === id) { + return nodeTree; + } + for (let i = 0; i < nodeTree.children.length; i++) { + if (nodeTree.children[i].children != null) { + const node = ServiceUtils.getTreeNodeObject(nodeTree.children[i], id); + if (node !== null) { + return node; + } + } + } + return null; + } + + /** + * Searches an array of tree nodes for a node with a matching `id` + * @param {TreeNode} nodeTree - An array of TreeNode branches that will be searched + * @param {string} id - The id of the node to be found + * @returns {TreeNode} The node with a matching `id` + */ + static getTreeNodeObjectFromList( nodeTree: TreeNode[], id: string ): TreeNode { @@ -61,7 +100,7 @@ export class ServiceUtils { if (nodeTree[i].node.id === id) { return nodeTree[i]; } else if (nodeTree[i].children != null) { - const node = ServiceUtils.getTreeNodeObject(nodeTree[i].children, id); + const node = ServiceUtils.getTreeNodeObjectFromList(nodeTree[i].children, id); if (node !== null) { return node; } diff --git a/libs/common/src/models/domain/organization.ts b/libs/common/src/models/domain/organization.ts index 2e55250731..b6bb566742 100644 --- a/libs/common/src/models/domain/organization.ts +++ b/libs/common/src/models/domain/organization.ts @@ -137,6 +137,10 @@ export class Organization { ); } + get canUseAdminCollections() { + return this.canEditAnyCollection; + } + get canDeleteAnyCollection() { return ( this.isAdmin || diff --git a/libs/common/src/models/domain/treeNode.ts b/libs/common/src/models/domain/treeNode.ts index 6af973a595..ea36eeafac 100644 --- a/libs/common/src/models/domain/treeNode.ts +++ b/libs/common/src/models/domain/treeNode.ts @@ -3,10 +3,15 @@ export class TreeNode { node: T; children: TreeNode[] = []; - constructor(node: T, name: string, parent: T) { + constructor(node: T, parent: T, name?: string, id?: string) { this.parent = parent; this.node = node; - this.node.name = name; + if (name) { + this.node.name = name; + } + if (id) { + this.node.id = id; + } } } diff --git a/libs/common/src/services/collection.service.ts b/libs/common/src/services/collection.service.ts index 12054916e0..02a35625da 100644 --- a/libs/common/src/services/collection.service.ts +++ b/libs/common/src/services/collection.service.ts @@ -91,6 +91,10 @@ export class CollectionService implements CollectionServiceAbstraction { return decryptedCollections; } + /** + * @deprecated August 30 2022: Moved to new Vault Filter Service + * Remove when Desktop and Browser are updated + */ async getAllNested(collections: CollectionView[] = null): Promise[]> { if (collections == null) { collections = await this.getAllDecrypted(); @@ -106,9 +110,13 @@ export class CollectionService implements CollectionServiceAbstraction { return nodes; } + /** + * @deprecated August 30 2022: Moved to new Vault Filter Service + * Remove when Desktop and Browser are updated + */ async getNested(id: string): Promise> { const collections = await this.getAllNested(); - return ServiceUtils.getTreeNodeObject(collections, id) as TreeNode; + return ServiceUtils.getTreeNodeObjectFromList(collections, id) as TreeNode; } async upsert(collection: CollectionData | CollectionData[]): Promise {