[EC-14] Refactor vault filter (#3440)

* [EC-14] initial refactoring of vault filter

* [EC-14] return observable trees for all filters with head node

* [EC-14] Remove bindings on callbacks

* [EC-14] fix formatting on disabled orgs

* [EC-14] hide MyVault if personal org policy

* [EC-14] add check for single org policy

* [EC-14] add policies to org and change node constructor

* [EC-14] don't show options if personal vault policy

* [EC-14] default to all vaults

* [EC-14] add default selection to filters

* [EC-14] finish filter model callbacks

* [EC-14] finish filter functionality and begin cleaning up

* [EC-14] clean up old components and start on org vault

* [EC-14] loop through filters for presentation

* [EC-14] refactor VaultFilterService and put filter presentation data back into Vault Filter component. Remove VaultService

* [EC-14] begin refactoring org vault

* [EC-14] Refactor Vault Filter Service to use observables

* [EC-14] finish org vault filter

* [EC-14] fix vault model tests

* [EC-14] fix org service calls

* [EC-14] pull refactor out of shared code

* [EC-14] include head node for collections even if collections aren't loaded yet

* [EC-14] fix url params for vaults

* [EC-14] remove comments

* [EC-14] Remove unnecesary getter for org on vault filter

* [EC-14] fix linter

* [EC-14] fix prettier

* [EC-14] add deprecated methods to collection service for desktop and browser

* [EC-14] simplify cipher type node check

* [EC-14] add getters to vault filter model

* [EC-14] refactor how we build the filter list into methods

* [EC-14] add getters to build filter method

* [EC-14] remove param ids if false

* [EC-14] fix collapsing nodes

* [EC-14] add specific type to search placeholder

* [EC-14] remove extra constructor and comment from org vault filter

* [EC-14] extract subscription callback to methods

* [EC-14] Remove unecessary await

* [EC-14] Remove ternary operators while building org filter

* [EC-14] remove unnecessary deps array in vault filter service declaration

* [EC-14] consolidate new models into one file

* [EC-14] initialize nested observable inside of service

Signed-off-by: Jacob Fink <jfink@bitwarden.com>

* [EC-14] change how we load orgs into the vault filter and select the default filter

* [EC-14] remove get from getters name

* [EC-14] remove eslint-disable comment

* [EC-14] move vault filter service abstraction to angular folder and separate

* [EC-14] rename filter types and delete VaultFilterLabel

* [EC-14] remove changes to workspace file

* [EC-14] remove deprecated service from jslib module

* [EC-14] remove any remaining files from common code

* [EC-14] consolidate vault filter components into components folder

* [EC-14] simplify method call

* [EC-14] refactor the vault filter service
- orgs now have observable property
- BehaviorSubjects have been migrated to ReplaySubjects if they don't need starting value
- added unit tests
- fix small error when selecting org badge of personal vault
- renamed some properties

* [EC-14] replace mergeMap with switchMap in vault filter service

* [EC-14] early return to prevent nesting

* [EC-14] clean up filterCollections method

* [EC-14] use isDeleted helper in html

* [EC-14] add jsdoc comments to ServiceUtils

* [EC-14] fix linter

* [EC-14] use array.slice instead of setting length

* Update apps/web/src/app/vault/vault-filter/services/vault-filter.service.ts

Co-authored-by: Thomas Rittson <31796059+eliykat@users.noreply.github.com>

* [EC-14] add missing high level jsdoc description

* [EC-14] fix storybook absolute imports

* [EC-14] delete vault-shared.module

* [EC-14] change search placeholder text to getter and add missing strings

* [EC-14] remove two way binding from search text in vault filter

* [EC-14] removed all binding from search text and just use input event

* [EC-14] remove async from apply vault filter

* [EC-14] remove circular observable calls in vault filter service

Co-authored-by: Thomas Rittson <eliykat@users.noreply.github.com>

* [EC-14] move collapsed nodes to vault filter section

* [EC-14] deconstruct filter section inside component

* [EC-14] fix merge conflicts and introduce refactored organization service to vault filter service

* [EC-14] remove mutation from filter builders

* [EC-14] fix styling on buildFolderTree

* [EC-14] remove leftover folder-filters reference and use ternary for collapse icon

* [EC-14] remove unecessary checks

* [EC-14] stop rebuilding filters when the organization changes

* [EC-14] Move subscription out of setter in vault filter section

* [EC-14] remove extra policy service methods from vault filter service

* [EC-14] remove new methods from old vault-filter.service

* [EC-14] Use vault filter service in vault components

* [EC-14] reload collections from vault now that we have vault filter service

* [EC-14] remove currentFilterCollections in vault filter component

* [EC-14] change VaultFilterType to more specific OrganizationFilter in organization-options

* [EC-14] include org check in isNodeSelected

* [EC-14] add getters to filter function, fix storybook, and add test for All Collections

* [EC-14] show org options even if there's a personal vault policy

* [EC-14] use !"AllCollections" instead of just !null

* [EC-14] Remove extra org Subject in vault filter service

* [EC-14] remove null check from vault search text

* [EC-14] replace store/build names with set/get. Remove extra call to setOrganizationFilter

* [EC-14] add take(1) to subscribe in test

* [EC-14] move init logic in org vault filter component to ngOnInit

* [EC-14] Fix linter

* [EC-14] revert change to vault filter model

* [EC-14] be specific about ignoring All Collections

* [EC-14] move observable init logic to beforeEach in test

* [EC-14] make buildAllFilters return something to reduce side effects

Signed-off-by: Jacob Fink <jfink@bitwarden.com>
Co-authored-by: Thomas Rittson <31796059+eliykat@users.noreply.github.com>
Co-authored-by: Thomas Rittson <eliykat@users.noreply.github.com>
This commit is contained in:
Jake Fink 2022-10-05 21:21:37 -04:00 committed by GitHub
parent dc0ea9a48f
commit 4d83b81d82
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
57 changed files with 2174 additions and 1000 deletions

View File

@ -1,6 +1,7 @@
import { NgModule } from "@angular/core"; import { NgModule } from "@angular/core";
import { BrowserModule } from "@angular/platform-browser"; 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 { JslibModule } from "@bitwarden/angular/jslib.module";
import { VaultFilterService } from "@bitwarden/angular/vault/vault-filter/services/vault-filter.service"; import { VaultFilterService } from "@bitwarden/angular/vault/vault-filter/services/vault-filter.service";
@ -22,6 +23,11 @@ import { VaultFilterComponent } from "./vault-filter.component";
TypeFilterComponent, TypeFilterComponent,
], ],
exports: [VaultFilterComponent], exports: [VaultFilterComponent],
providers: [VaultFilterService], providers: [
{
provide: DeprecatedVaultFilterServiceAbstraction,
useClass: VaultFilterService,
},
],
}) })
export class VaultFilterModule {} export class VaultFilterModule {}

View File

@ -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 { 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({ @Component({
selector: "app-organization-vault-filter", 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 { export class VaultFilterComponent extends BaseVaultFilterComponent implements OnInit, OnDestroy {
hideOrganizations = true; @Input() set organization(value: Organization) {
hideFavorites = true; if (value && value !== this._organization) {
hideFolders = true; this._organization = value;
this.vaultFilterService.setOrganizationFilter(this._organization);
organization: Organization;
async initCollections() {
if (this.organization.canEditAnyCollection) {
return await this.vaultFilterService.buildAdminCollections(this.organization.id);
} }
return await this.vaultFilterService.buildCollections(this.organization.id); }
_organization: Organization;
destroy$: Subject<void>;
async ngOnInit() {
this.filters = await this.buildAllFilters();
if (!this.activeFilter.selectedCipherTypeNode) {
this.applyCollectionFilter((await this.getDefaultFilter()) as TreeNode<CollectionFilter>);
}
this.isLoaded = true;
} }
async reloadCollectionsAndFolders() { ngOnDestroy() {
this.collections = await this.initCollections(); 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<CollectionFilter>;
this.applyVaultFilter(this.activeFilter);
}
}
}
async buildAllFilters(): Promise<VaultFilterList> {
const builderFilter = {} as VaultFilterList;
builderFilter.typeFilter = await this.addTypeFilter();
builderFilter.collectionFilter = await this.addCollectionFilter();
builderFilter.trashFilter = await this.addTrashFilter();
return builderFilter;
}
async getDefaultFilter(): Promise<TreeNode<VaultFilterType>> {
return await firstValueFrom(this.filters?.collectionFilter.data$);
} }
} }

View File

@ -1,12 +1,20 @@
import { NgModule } from "@angular/core"; 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 { VaultFilterSharedModule } from "../../../vault/vault-filter/shared/vault-filter-shared.module";
import { VaultFilterComponent } from "./vault-filter.component"; import { VaultFilterComponent } from "./vault-filter.component";
import { VaultFilterService } from "./vault-filter.service";
@NgModule({ @NgModule({
imports: [VaultFilterSharedModule], imports: [VaultFilterSharedModule],
declarations: [VaultFilterComponent], declarations: [VaultFilterComponent],
exports: [VaultFilterComponent], exports: [VaultFilterComponent],
providers: [
{
provide: VaultFilterServiceAbstraction,
useClass: VaultFilterService,
},
],
}) })
export class VaultFilterModule {} export class VaultFilterModule {}

View File

@ -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<CollectionView[]>(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<CollectionView[]> {
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;
}
}

View File

@ -6,8 +6,9 @@
<div class="inner-content"> <div class="inner-content">
<app-organization-vault-filter <app-organization-vault-filter
#vaultFilter #vaultFilter
[organization]="organization"
[activeFilter]="activeFilter" [activeFilter]="activeFilter"
(onFilterChange)="applyVaultFilter($event)" (activeFilterChanged)="applyVaultFilter($event)"
(onSearchTextChanged)="filterSearchText($event)" (onSearchTextChanged)="filterSearchText($event)"
></app-organization-vault-filter> ></app-organization-vault-filter>
</div> </div>
@ -32,7 +33,7 @@
<div class="ml-auto d-flex"> <div class="ml-auto d-flex">
<app-vault-bulk-actions <app-vault-bulk-actions
[ciphersComponent]="ciphersComponent" [ciphersComponent]="ciphersComponent"
[deleted]="deleted" [deleted]="activeFilter.isDeleted"
[organization]="organization" [organization]="organization"
> >
</app-vault-bulk-actions> </app-vault-bulk-actions>
@ -40,13 +41,17 @@
type="button" type="button"
class="btn btn-outline-primary btn-sm ml-auto" class="btn btn-outline-primary btn-sm ml-auto"
(click)="addCipher()" (click)="addCipher()"
*ngIf="!deleted" *ngIf="!activeFilter.isDeleted"
> >
<i class="bwi bwi-plus bwi-fw" aria-hidden="true"></i>{{ "addItem" | i18n }} <i class="bwi bwi-plus bwi-fw" aria-hidden="true"></i>{{ "addItem" | i18n }}
</button> </button>
</div> </div>
</div> </div>
<app-callout type="warning" *ngIf="deleted" icon="bwi bwi-exclamation-triangle"> <app-callout
type="warning"
*ngIf="activeFilter.isDeleted"
icon="bwi bwi-exclamation-triangle"
>
{{ trashCleanupWarning }} {{ trashCleanupWarning }}
</app-callout> </app-callout>
<app-org-vault-ciphers <app-org-vault-ciphers

View File

@ -8,10 +8,10 @@ import {
ViewContainerRef, ViewContainerRef,
} from "@angular/core"; } from "@angular/core";
import { ActivatedRoute, Params, Router } from "@angular/router"; import { ActivatedRoute, Params, Router } from "@angular/router";
import { firstValueFrom } from "rxjs";
import { first } from "rxjs/operators"; import { first } from "rxjs/operators";
import { ModalService } from "@bitwarden/angular/services/modal.service"; import { ModalService } from "@bitwarden/angular/services/modal.service";
import { VaultFilter } from "@bitwarden/angular/vault/vault-filter/models/vault-filter.model";
import { BroadcasterService } from "@bitwarden/common/abstractions/broadcaster.service"; import { BroadcasterService } from "@bitwarden/common/abstractions/broadcaster.service";
import { CipherService } from "@bitwarden/common/abstractions/cipher.service"; import { CipherService } from "@bitwarden/common/abstractions/cipher.service";
import { I18nService } from "@bitwarden/common/abstractions/i18n.service"; import { I18nService } from "@bitwarden/common/abstractions/i18n.service";
@ -20,11 +20,11 @@ import { OrganizationService } from "@bitwarden/common/abstractions/organization
import { PasswordRepromptService } from "@bitwarden/common/abstractions/passwordReprompt.service"; import { PasswordRepromptService } from "@bitwarden/common/abstractions/passwordReprompt.service";
import { PlatformUtilsService } from "@bitwarden/common/abstractions/platformUtils.service"; import { PlatformUtilsService } from "@bitwarden/common/abstractions/platformUtils.service";
import { SyncService } from "@bitwarden/common/abstractions/sync/sync.service.abstraction"; import { SyncService } from "@bitwarden/common/abstractions/sync/sync.service.abstraction";
import { CipherType } from "@bitwarden/common/enums/cipherType";
import { Organization } from "@bitwarden/common/models/domain/organization"; import { Organization } from "@bitwarden/common/models/domain/organization";
import { CipherView } from "@bitwarden/common/models/view/cipherView"; import { CipherView } from "@bitwarden/common/models/view/cipherView";
import { VaultService } from "../../vault/shared/vault.service"; import { VaultFilterService } from "../../vault/vault-filter/services/abstractions/vault-filter.service";
import { VaultFilter } from "../../vault/vault-filter/shared/models/vault-filter.model";
import { EntityEventsComponent } from "../manage/entity-events.component"; import { EntityEventsComponent } from "../manage/entity-events.component";
import { AddEditComponent } from "./add-edit.component"; import { AddEditComponent } from "./add-edit.component";
@ -53,19 +53,13 @@ export class VaultComponent implements OnInit, OnDestroy {
eventsModalRef: ViewContainerRef; eventsModalRef: ViewContainerRef;
organization: Organization; organization: Organization;
collectionId: string = null;
type: CipherType = null;
trashCleanupWarning: string = null; trashCleanupWarning: string = null;
activeFilter: VaultFilter = new VaultFilter(); activeFilter: VaultFilter = new VaultFilter();
// This is a hack to avoid redundant api calls that fetch OrganizationVaultFilterComponent collections
// When it makes sense to do so we should leverage some other communication method for change events that isn't directly tied to the query param for organizationId
// i.e. exposing the VaultFiltersService to the OrganizationSwitcherComponent to make relevant updates from a change event instead of just depending on the router
firstLoaded = true;
constructor( constructor(
private route: ActivatedRoute, private route: ActivatedRoute,
private organizationService: OrganizationService, private organizationService: OrganizationService,
private vaultFilterService: VaultFilterService,
private router: Router, private router: Router,
private changeDetectorRef: ChangeDetectorRef, private changeDetectorRef: ChangeDetectorRef,
private syncService: SyncService, private syncService: SyncService,
@ -75,7 +69,6 @@ export class VaultComponent implements OnInit, OnDestroy {
private broadcasterService: BroadcasterService, private broadcasterService: BroadcasterService,
private ngZone: NgZone, private ngZone: NgZone,
private platformUtilsService: PlatformUtilsService, private platformUtilsService: PlatformUtilsService,
private vaultService: VaultService,
private cipherService: CipherService, private cipherService: CipherService,
private passwordRepromptService: PasswordRepromptService private passwordRepromptService: PasswordRepromptService
) {} ) {}
@ -88,8 +81,7 @@ export class VaultComponent implements OnInit, OnDestroy {
); );
// eslint-disable-next-line rxjs-angular/prefer-takeuntil, rxjs/no-async-subscribe // eslint-disable-next-line rxjs-angular/prefer-takeuntil, rxjs/no-async-subscribe
this.route.parent.params.subscribe(async (params: any) => { this.route.parent.params.subscribe(async (params: any) => {
this.organization = await this.organizationService.get(params.organizationId); this.organization = this.organizationService.get(params.organizationId);
this.vaultFilterComponent.organization = this.organization;
this.ciphersComponent.organization = this.organization; this.ciphersComponent.organization = this.organization;
/* eslint-disable-next-line rxjs-angular/prefer-takeuntil, rxjs/no-async-subscribe, rxjs/no-nested-subscribe */ /* 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": case "syncCompleted":
if (message.successfully) { if (message.successfully) {
await Promise.all([ await Promise.all([
this.vaultFilterComponent.reloadCollectionsAndFolders(), this.vaultFilterService.reloadCollections(),
this.ciphersComponent.refresh(), this.ciphersComponent.refresh(),
]); ]);
this.changeDetectorRef.detectChanges(); this.changeDetectorRef.detectChanges();
@ -114,12 +106,10 @@ export class VaultComponent implements OnInit, OnDestroy {
}); });
} }
if (this.firstLoaded) { await this.ciphersComponent.reload(
await this.vaultFilterComponent.reloadCollectionsAndFolders(); this.activeFilter.buildFilter(),
} this.activeFilter.isDeleted
this.firstLoaded = true; );
await this.ciphersComponent.reload();
if (qParams.viewEvents != null) { if (qParams.viewEvents != null) {
const cipher = this.ciphersComponent.ciphers.filter((c) => c.id === qParams.viewEvents); const cipher = this.ciphersComponent.ciphers.filter((c) => c.id === qParams.viewEvents);
@ -134,7 +124,7 @@ export class VaultComponent implements OnInit, OnDestroy {
if (cipherId) { if (cipherId) {
if ( if (
// Handle users with implicit collection access since they use the admin endpoint // Handle users with implicit collection access since they use the admin endpoint
this.organization.canEditAnyCollection || this.organization.canUseAdminCollections ||
(await this.cipherService.get(cipherId)) != null (await this.cipherService.get(cipherId)) != null
) { ) {
this.editCipherId(cipherId); this.editCipherId(cipherId);
@ -155,23 +145,17 @@ export class VaultComponent implements OnInit, OnDestroy {
}); });
} }
get deleted(): boolean {
return this.activeFilter.status === "trash";
}
ngOnDestroy() { ngOnDestroy() {
this.broadcasterService.unsubscribe(BroadcasterSubscriptionId); this.broadcasterService.unsubscribe(BroadcasterSubscriptionId);
} }
async applyVaultFilter(vaultFilter: VaultFilter) { async applyVaultFilter(filter: VaultFilter) {
this.ciphersComponent.showAddNew = vaultFilter.status !== "trash"; this.activeFilter = filter;
this.activeFilter = vaultFilter; this.ciphersComponent.showAddNew = !this.activeFilter.isDeleted;
await this.ciphersComponent.reload( await this.ciphersComponent.reload(
this.activeFilter.buildFilter(), this.activeFilter.buildFilter(),
vaultFilter.status === "trash" this.activeFilter.isDeleted
); );
this.vaultFilterComponent.searchPlaceholder =
this.vaultService.calculateSearchBarLocalizationString(this.activeFilter);
this.go(); this.go();
} }
@ -211,16 +195,13 @@ export class VaultComponent implements OnInit, OnDestroy {
} }
async editCipherCollections(cipher: CipherView) { async editCipherCollections(cipher: CipherView) {
const currCollections = await firstValueFrom(this.vaultFilterService.filteredCollections$);
const [modal] = await this.modalService.openViewRef( const [modal] = await this.modalService.openViewRef(
CollectionsComponent, CollectionsComponent,
this.collectionsModalRef, this.collectionsModalRef,
(comp) => { (comp) => {
if (this.organization.canEditAnyCollection) { comp.collectionIds = cipher.collectionIds;
comp.collectionIds = cipher.collectionIds; comp.collections = currCollections.filter((c) => !c.readOnly && c.id != null);
comp.collections = this.vaultFilterComponent.collections.fullList.filter(
(c) => !c.readOnly && c.id != null
);
}
comp.organization = this.organization; comp.organization = this.organization;
comp.cipherId = cipher.id; comp.cipherId = cipher.id;
// eslint-disable-next-line rxjs-angular/prefer-takeuntil, rxjs/no-async-subscribe // eslint-disable-next-line rxjs-angular/prefer-takeuntil, rxjs/no-async-subscribe
@ -235,14 +216,12 @@ export class VaultComponent implements OnInit, OnDestroy {
async addCipher() { async addCipher() {
const component = await this.editCipher(null); const component = await this.editCipher(null);
component.organizationId = this.organization.id; component.organizationId = this.organization.id;
component.type = this.type; component.type = this.activeFilter.cipherType;
if (this.organization.canEditAnyCollection) { component.collections = (
component.collections = this.vaultFilterComponent.collections.fullList.filter( await firstValueFrom(this.vaultFilterService.filteredCollections$)
(c) => !c.readOnly && c.id != null ).filter((c) => !c.readOnly && c.id != null);
); if (this.activeFilter.collectionId) {
} component.collectionIds = [this.activeFilter.collectionId];
if (this.collectionId != null) {
component.collectionIds = [this.collectionId];
} }
} }
@ -294,13 +273,9 @@ export class VaultComponent implements OnInit, OnDestroy {
const component = await this.editCipher(cipher); const component = await this.editCipher(cipher);
component.cloneMode = true; component.cloneMode = true;
component.organizationId = this.organization.id; component.organizationId = this.organization.id;
if (this.organization.canEditAnyCollection) { component.collections = (
component.collections = this.vaultFilterComponent.collections.fullList.filter( await firstValueFrom(this.vaultFilterService.filteredCollections$)
(c) => !c.readOnly && c.id != null ).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.collectionIds = cipher.collectionIds; component.collectionIds = cipher.collectionIds;
} }
@ -318,8 +293,8 @@ export class VaultComponent implements OnInit, OnDestroy {
if (queryParams == null) { if (queryParams == null) {
queryParams = { queryParams = {
type: this.activeFilter.cipherType, type: this.activeFilter.cipherType,
collectionId: this.activeFilter.selectedCollectionId, collectionId: this.activeFilter.collectionId,
deleted: this.deleted ? true : null, deleted: this.activeFilter.isDeleted || null,
}; };
} }

View File

@ -1,6 +1,7 @@
import { NgModule } from "@angular/core"; 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 { CiphersComponent } from "./ciphers.component";
import { VaultFilterModule } from "./vault-filter/vault-filter.module"; import { VaultFilterModule } from "./vault-filter/vault-filter.module";
@ -8,7 +9,7 @@ import { VaultRoutingModule } from "./vault-routing.module";
import { VaultComponent } from "./vault.component"; import { VaultComponent } from "./vault.component";
@NgModule({ @NgModule({
imports: [VaultSharedModule, VaultRoutingModule, VaultFilterModule], imports: [VaultRoutingModule, VaultFilterModule, SharedModule, LooseComponentsModule],
declarations: [VaultComponent, CiphersComponent], declarations: [VaultComponent, CiphersComponent],
exports: [VaultComponent], exports: [VaultComponent],
}) })

View File

@ -126,20 +126,13 @@ import { CollectionsComponent } from "../vault/collections.component";
import { FolderAddEditComponent } from "../vault/folder-add-edit.component"; import { FolderAddEditComponent } from "../vault/folder-add-edit.component";
import { OrganizationBadgeModule } from "../vault/organization-badge/organization-badge.module"; import { OrganizationBadgeModule } from "../vault/organization-badge/organization-badge.module";
import { ShareComponent } from "../vault/share.component"; import { ShareComponent } from "../vault/share.component";
import { VaultFilterModule } from "../vault/vault-filter/vault-filter.module";
import { SharedModule } from "."; 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. // 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. // If you are building new functionality, please create or extend a feature module instead.
@NgModule({ @NgModule({
imports: [ imports: [SharedModule, OrganizationBadgeModule, OrganizationCreateModule, RegisterFormModule],
SharedModule,
VaultFilterModule,
OrganizationBadgeModule,
OrganizationCreateModule,
RegisterFormModule,
],
declarations: [ declarations: [
PremiumBadgeComponent, PremiumBadgeComponent,
AcceptEmergencyComponent, AcceptEmergencyComponent,

View File

@ -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 {}

View File

@ -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";
}
}

View File

@ -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 { ModalService } from "@bitwarden/angular/services/modal.service";
import { ApiService } from "@bitwarden/common/abstractions/api.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 { OrganizationUserResetPasswordEnrollmentRequest } from "@bitwarden/common/models/request/organizationUserResetPasswordEnrollmentRequest";
import { EnrollMasterPasswordReset } from "../../../organizations/users/enroll-master-password-reset.component"; 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({ @Component({
selector: "app-organization-options", selector: "app-organization-options",
@ -24,9 +26,8 @@ export class OrganizationOptionsComponent {
policies: Policy[]; policies: Policy[];
loaded = false; loaded = false;
@Input() organization: Organization;
constructor( constructor(
@Inject(OptionsInput) private organization: OrganizationFilter,
private platformUtilsService: PlatformUtilsService, private platformUtilsService: PlatformUtilsService,
private i18nService: I18nService, private i18nService: I18nService,
private apiService: ApiService, private apiService: ApiService,

View File

@ -0,0 +1,35 @@
<div class="card vault-filters">
<div class="container loading-spinner" *ngIf="!isLoaded">
<i class="bwi bwi-spinner bwi-spin bwi-3x" aria-hidden="true"></i>
</div>
<div *ngIf="isLoaded">
<div class="card-header d-flex">
{{ "filters" | i18n }}
<a
class="ml-auto"
href="https://bitwarden.com/help/searching-vault/"
target="_blank"
rel="noopener"
appA11yTitle="{{ 'learnMore' | i18n }}"
>
<i class="bwi bwi-question-circle" aria-hidden="true"></i>
</a>
</div>
<div class="card-body">
<input
type="search"
placeholder="{{ searchPlaceholder | i18n }}"
id="search"
class="form-control"
(input)="searchTextChanged($event.target.value)"
autocomplete="off"
appAutofocus
/>
<ng-container *ngFor="let f of filtersList">
<div class="filter">
<app-filter-section [activeFilter]="activeFilter" [section]="f"> </app-filter-section>
</div>
</ng-container>
</div>
</div>
</div>

View File

@ -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<VaultFilter>();
@Output() onSearchTextChanged = new EventEmitter<string>();
@Output() onAddFolder = new EventEmitter<never>();
@Output() onEditFolder = new EventEmitter<FolderView>();
isLoaded = false;
searchText = "";
protected destroy$: Subject<void> = new Subject<void>();
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<void> {
this.filters = await this.buildAllFilters();
await this.applyTypeFilter(
(await firstValueFrom(this.filters?.typeFilter.data$)) as TreeNode<CipherTypeFilter>
);
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<OrganizationFilter>): Promise<void> => {
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<CipherTypeFilter>): Promise<void> => {
const filter = this.activeFilter;
filter.resetFilter();
filter.selectedCipherTypeNode = filterNode;
this.applyVaultFilter(filter);
};
applyFolderFilter = async (folderNode: TreeNode<FolderFilter>): Promise<void> => {
const filter = this.activeFilter;
filter.resetFilter();
filter.selectedFolderNode = folderNode;
this.applyVaultFilter(filter);
};
applyCollectionFilter = async (collectionNode: TreeNode<CollectionFilter>): Promise<void> => {
const filter = this.activeFilter;
filter.resetFilter();
filter.selectedCollectionNode = collectionNode;
this.applyVaultFilter(filter);
};
addFolder = async (): Promise<void> => {
this.onAddFolder.emit();
};
editFolder = async (folder: FolderFilter): Promise<void> => {
this.onEditFolder.emit(folder);
};
async getDefaultFilter(): Promise<TreeNode<VaultFilterType>> {
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<CipherTypeFilter>;
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<CipherTypeFilter>;
this.applyVaultFilter(filter);
}
}
}
async buildAllFilters(): Promise<VaultFilterList> {
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<VaultFilterSection> {
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<VaultFilterSection> {
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<VaultFilterSection> {
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<VaultFilterSection> {
const collectionFilterSection: VaultFilterSection = {
data$: this.vaultFilterService.collectionTree$,
header: {
showHeader: true,
isSelectable: true,
},
action: this.applyCollectionFilter,
};
return collectionFilterSection;
}
protected async addTrashFilter(): Promise<VaultFilterSection> {
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;
}
}

View File

@ -1,186 +0,0 @@
<ng-container *ngIf="!hide">
<ng-container [ngSwitch]="displayMode">
<ng-container *ngSwitchCase="'noOrganizations'">
<ul class="filter-options">
<li class="filter-option active">
<span class="filter-buttons">
<button
class="filter-button"
[attr.aria-pressed]="activeFilter.myVaultOnly"
appA11yTitle="{{ 'vault' | i18n }}: {{ 'myVault' | i18n }}"
>
<i class="bwi bwi-fw bwi-user" aria-hidden="true"></i>
{{ "myVault" | i18n }}
</button>
</span>
</li>
<li class="filter-option">
<span class="filter-buttons">
<a href="#" routerLink="/create-organization" class="filter-button">
<i class="bwi bwi-plus bwi-fw" aria-hidden="true"></i>
&nbsp;{{ "newOrganization" | i18n }}
</a>
</span>
</li>
</ul>
</ng-container>
<ng-container *ngSwitchCase="'personalOwnershipPolicy'">
<div class="filter-heading">
<button
(click)="toggleCollapse()"
title="{{ 'toggleCollapse' | i18n }}"
class="toggle-button"
[attr.aria-expanded]="!isCollapsed"
aria-controls="organization-filters"
>
<i
class="bwi bwi-fw"
aria-hidden="true"
[ngClass]="{
'bwi-angle-right': isCollapsed,
'bwi-angle-down': !isCollapsed
}"
></i>
</button>
<button
class="filter-button"
(click)="clearFilter()"
[ngClass]="{ active: !hasActiveFilter }"
appA11yTitle="{{ 'vault' | i18n }}: {{ organizationGrouping.name | i18n }}"
>
&nbsp;{{ organizationGrouping.name | i18n }}
</button>
</div>
<ul id="organization-filters" *ngIf="!isCollapsed" class="filter-options">
<li
class="filter-option"
*ngFor="let organization of organizations"
[ngClass]="{ active: organization.id === activeFilter.selectedOrganizationId }"
>
<span class="filter-buttons">
<button
class="filter-button"
(click)="applyOrganizationFilter(organization)"
[attr.aria-pressed]="activeFilter.selectedOrganizationId === organization.id"
appA11yTitle="{{ 'vault' | i18n }}: {{ organization.name }}"
>
<i class="bwi bwi-fw bwi-business" aria-hidden="true"></i>
{{ organization.name }}
</button>
<ng-container *ngIf="organization.id === activeFilter.selectedOrganizationId">
<button [bitMenuTriggerFor]="orgMenu" class="org-options ml-auto">
<i class="bwi bwi-ellipsis-v" aria-hidden="true"></i>
</button>
<bit-menu class="filter-organization-options" #orgMenu>
<app-organization-options [organization]="organization"></app-organization-options>
</bit-menu>
</ng-container>
</span>
</li>
<li class="filter-option">
<span class="filter-buttons">
<a href="#" routerLink="/create-organization" class="filter-button">
<i class="bwi bwi-plus bwi-fw" aria-hidden="true"></i>
&nbsp;{{ "newOrganization" | i18n }}
</a>
</span>
</li>
</ul>
</ng-container>
<ng-container *ngSwitchCase="'singleOrganizationAndPersonalOwnershipPolicies'">
<div class="filter-heading">
<button
class="filter-button active"
[attr.aria-pressed]="activeFilter.selectedOrganizationId === organizations[0].id"
appA11yTitle="{{ 'vault' | i18n }}: {{ organizations[0].name }}"
>
<i class="bwi bwi-fw bwi-business" aria-hidden="true"></i>
{{ organizations[0].name }}
</button>
</div>
</ng-container>
<ng-container *ngSwitchDefault>
<div class="filter-heading">
<button
class="toggle-button"
title="{{ 'toggleCollapse' | i18n }}"
(click)="toggleCollapse()"
[attr.aria-expanded]="!isCollapsed"
aria-controls="organization-filters"
>
<i
class="bwi bwi-fw"
aria-hidden="true"
[ngClass]="{
'bwi-angle-right': isCollapsed,
'bwi-angle-down': !isCollapsed
}"
></i>
</button>
<button
class="filter-button"
(click)="clearFilter()"
[ngClass]="{ active: !hasActiveFilter }"
appA11yTitle="{{ 'vault' | i18n }}: {{ organizationGrouping.name | i18n }}"
>
&nbsp;{{ organizationGrouping.name | i18n }}
</button>
</div>
<ul id="organization-filters" *ngIf="!isCollapsed" class="filter-options">
<li class="filter-option" [ngClass]="{ active: activeFilter.myVaultOnly }">
<span class="filter-buttons">
<button
class="filter-button"
(click)="applyMyVaultFilter()"
appA11yTitle="{{ 'vault' | i18n }}: {{ 'myVault' | i18n }}"
[attr.aria-pressed]="activeFilter.myVaultOnly"
>
<i class="bwi bwi-fw bwi-user" aria-hidden="true"></i>
{{ "myVault" | i18n }}
</button>
</span>
</li>
<li
class="filter-option"
*ngFor="let organization of organizations"
[ngClass]="{ active: organization.id === activeFilter.selectedOrganizationId }"
>
<span class="filter-buttons">
<button
class="filter-button"
[ngClass]="{ 'disabled-organization': !organization.enabled }"
(click)="applyOrganizationFilter(organization)"
appA11yTitle="{{ 'vault' | i18n }}: {{ organization.name }}"
>
<i class="bwi bwi-fw bwi-business" aria-hidden="true"></i>
{{ organization.name }}
</button>
<span class="ml-auto">
<i
*ngIf="!organization.enabled"
class="org-options bwi bwi-fw bwi-exclamation-triangle text-danger"
aria-label="{{ 'organizationIsDisabled' | i18n }}"
appA11yTitle="{{ 'organizationIsDisabled' | i18n }}"
></i
><button [bitMenuTriggerFor]="orgMenu" class="org-options">
<i class="bwi bwi-ellipsis-v" aria-hidden="true"></i>
</button>
<bit-menu class="filter-organization-options" #orgMenu>
<app-organization-options [organization]="organization"></app-organization-options>
</bit-menu>
</span>
</span>
</li>
<li class="filter-option" *ngIf="!(displayMode === 'singleOrganizationPolicy')">
<span class="filter-buttons">
<a href="#" routerLink="/create-organization" class="filter-button">
<i class="bwi bwi-plus bwi-fw" aria-hidden="true"></i>
&nbsp;{{ "newOrganization" | i18n }}
</a>
</span>
</li>
</ul>
</ng-container>
</ng-container>
<hr />
</ng-container>

View File

@ -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")
);
}
}
}

View File

@ -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<Set<string>>;
filteredFolders$: Observable<FolderView[]>;
filteredCollections$: Observable<CollectionView[]>;
organizationTree$: Observable<TreeNode<OrganizationFilter>>;
folderTree$: Observable<TreeNode<FolderFilter>>;
collectionTree$: Observable<TreeNode<CollectionFilter>>;
reloadCollections: () => Promise<void>;
setCollapsedFilterNodes: (collapsedFilterNodes: Set<string>) => Promise<void>;
expandOrgFilter: () => Promise<void>;
setOrganizationFilter: (organization: Organization) => void;
buildTypeTree: (
head: CipherTypeFilter,
array: CipherTypeFilter[]
) => Observable<TreeNode<CipherTypeFilter>>;
}

View File

@ -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<StateService>;
let organizationService: MockProxy<OrganizationService>;
let folderService: MockProxy<FolderService>;
let cipherService: MockProxy<CipherService>;
let collectionService: MockProxy<CollectionService>;
let policyService: MockProxy<PolicyService>;
let i18nService: MockProxy<I18nService>;
let organizations: ReplaySubject<Organization[]>;
let folderViews: ReplaySubject<FolderView[]>;
beforeEach(() => {
stateService = mock<StateService>();
organizationService = mock<OrganizationService>();
folderService = mock<FolderService>();
cipherService = mock<CipherService>();
collectionService = mock<CollectionService>();
policyService = mock<PolicyService>();
i18nService = mock<I18nService>();
organizations = new ReplaySubject<Organization[]>(1);
folderViews = new ReplaySubject<FolderView[]>(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;
}
});

View File

@ -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<Set<string>>(null);
collapsedFilterNodes$: Observable<Set<string>> = this._collapsedFilterNodes.pipe(
switchMap(async (nodes) => nodes ?? (await this.getCollapsedFilterNodes()))
);
organizationTree$: Observable<TreeNode<OrganizationFilter>> =
this.organizationService.organizations$.pipe(
switchMap((orgs) => this.buildOrganizationTree(orgs))
);
protected _filteredFolders = new ReplaySubject<FolderView[]>(1);
filteredFolders$: Observable<FolderView[]> = this._filteredFolders.asObservable();
protected _filteredCollections = new ReplaySubject<CollectionView[]>(1);
filteredCollections$: Observable<CollectionView[]> = this._filteredCollections.asObservable();
folderTree$: Observable<TreeNode<FolderFilter>> = this.filteredFolders$.pipe(
map((folders) => this.buildFolderTree(folders))
);
collectionTree$: Observable<TreeNode<CollectionFilter>> = this.filteredCollections$.pipe(
map((collections) => this.buildCollectionTree(collections))
);
protected _organizationFilter = new BehaviorSubject<Organization>(null);
protected destroy$: Subject<void> = new Subject<void>();
// TODO: Remove once collections is refactored with observables
protected collectionViews$ = new ReplaySubject<CollectionView[]>(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<string>): Promise<void> {
await this.stateService.setCollapsedGroupings(Array.from(collapsedFilterNodes));
this._collapsedFilterNodes.next(collapsedFilterNodes);
}
protected async getCollapsedFilterNodes(): Promise<Set<string>> {
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<TreeNode<OrganizationFilter>> {
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<OrganizationFilter>(orgCopy, headNode.node, orgCopy.name);
headNode.children.push(node);
});
}
return headNode;
}
protected getOrganizationFilterHead(): TreeNode<OrganizationFilter> {
const head = new Organization() as OrganizationFilter;
head.enabled = true;
return new TreeNode<OrganizationFilter>(head, null, "allVaults", "AllVaults");
}
protected getOrganizationFilterMyVault(): TreeNode<OrganizationFilter> {
const myVault = new Organization() as OrganizationFilter;
myVault.id = "MyVault";
myVault.icon = "bwi-user";
myVault.enabled = true;
myVault.hideOptions = true;
return new TreeNode<OrganizationFilter>(myVault, null, this.i18nService.t("myVault"));
}
buildTypeTree(
head: CipherTypeFilter,
array?: CipherTypeFilter[]
): Observable<TreeNode<CipherTypeFilter>> {
const headNode = new TreeNode<CipherTypeFilter>(head, null);
array?.forEach((filter) => {
const node = new TreeNode<CipherTypeFilter>(filter, head, filter.name);
headNode.children.push(node);
});
return of(headNode);
}
protected async filterCollections(
storedCollections: CollectionView[],
org?: Organization
): Promise<CollectionView[]> {
return org?.id != null
? storedCollections.filter((c) => c.organizationId === org.id)
: storedCollections;
}
protected buildCollectionTree(collections?: CollectionView[]): TreeNode<CollectionFilter> {
const headNode = this.getCollectionFilterHead();
if (!collections) {
return headNode;
}
const nodes: TreeNode<CollectionFilter>[] = [];
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<CollectionFilter> {
const head = new CollectionView() as CollectionFilter;
return new TreeNode<CollectionFilter>(head, null, "collections", "AllCollections");
}
protected async filterFolders(
storedFolders: FolderView[],
org?: Organization
): Promise<FolderView[]> {
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<FolderFilter> {
const headNode = this.getFolderFilterHead();
if (!folders) {
return headNode;
}
const nodes: TreeNode<FolderFilter>[] = [];
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<FolderFilter> {
const head = new FolderView() as FolderFilter;
return new TreeNode<FolderFilter>(head, null, "folders", "AllFolders");
}
}

View File

@ -1,74 +0,0 @@
<ng-container *ngIf="show">
<div class="filter-heading">
<button
(click)="toggleCollapse(collectionsGrouping)"
[attr.aria-expanded]="!isCollapsed(collectionsGrouping)"
aria-controls="collection-filters"
title="{{ 'toggleCollapse' | i18n }}"
class="toggle-button"
>
<i
class="bwi bwi-fw"
[ngClass]="{
'bwi-angle-right': isCollapsed(collectionsGrouping),
'bwi-angle-down': !isCollapsed(collectionsGrouping)
}"
aria-hidden="true"
></i>
</button>
<h3 class="filter-title">&nbsp;{{ collectionsGrouping.name | i18n }}</h3>
</div>
<ul id="collection-filters" *ngIf="!isCollapsed(collectionsGrouping)" class="filter-options">
<ng-template #recursiveCollections let-collections>
<li
*ngFor="let c of collections"
[ngClass]="{
active: c.node.id === activeFilter.selectedCollectionId && activeFilter.selectedCollection
}"
class="filter-option"
>
<span class="filter-buttons">
<button
class="toggle-button"
*ngIf="c.children.length"
(click)="toggleCollapse(c.node)"
title="{{ 'toggleCollapse' | i18n }}"
[attr.aria-expanded]="!isCollapsed(c.node)"
[attr.aria-controls]="c.node.name + '_children'"
>
<i
class="bwi bwi-fw"
[ngClass]="{
'bwi-angle-right': isCollapsed(c.node),
'bwi-angle-down': !isCollapsed(c.node)
}"
aria-hidden="true"
></i>
</button>
<button class="filter-button" (click)="applyFilter(c.node)">
<i
*ngIf="c.children.length === 0"
class="bwi bwi-collection bwi-fw"
aria-hidden="true"
></i
>&nbsp;{{ c.node.name }}
</button>
</span>
<ul
[id]="c.node.name + '_children'"
class="nested-filter-options"
*ngIf="c.children.length && !isCollapsed(c.node)"
>
<ng-container
*ngTemplateOutlet="recursiveCollections; context: { $implicit: c.children }"
>
</ng-container>
</ul>
</li>
</ng-template>
<ng-container
*ngTemplateOutlet="recursiveCollections; context: { $implicit: nestedCollections }"
>
</ng-container>
</ul>
</ng-container>

View File

@ -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 {}

View File

@ -0,0 +1,137 @@
<ng-container *ngIf="filters && filters.length">
<div *ngIf="headerInfo.showHeader" class="filter-heading">
<button
class="toggle-button"
(click)="toggleCollapse(headerNode.node)"
[attr.aria-expanded]="!isCollapsed(headerNode.node)"
aria-controls="sub-filters"
title="{{ 'toggleCollapse' | i18n }}"
>
<i
class="bwi bwi-fw"
aria-hidden="true"
[ngClass]="isCollapsed(headerNode.node) ? 'bwi-angle-right' : 'bwi-angle-down'"
></i>
</button>
<button
*ngIf="headerInfo.isSelectable"
class="filter-button"
(click)="onFilterSelect(headerNode)"
>
<h3
[ngClass]="{
active: isAllVaultsSelected || isNodeSelected(headerNode)
}"
>
&nbsp;{{ headerNode.node.name | i18n }}
</h3>
</button>
<h3 *ngIf="!headerInfo.isSelectable" class="filter-title">
&nbsp;{{ headerNode.node.name | i18n }}
</h3>
<button
*ngIf="showAddButton"
(click)="onAdd()"
class="text-muted ml-auto add-button"
appA11yTitle="{{ addInfo.text | i18n }}"
>
<i class="bwi bwi-plus bwi-fw" aria-hidden="true"></i>
</button>
</div>
<ul
id="{{ headerNode.node.name }}-filters"
*ngIf="!isCollapsed(headerNode.node)"
class="filter-options"
>
<ng-template #recursiveFilters let-filters>
<li
*ngFor="let f of filters"
[ngClass]="{
active: isNodeSelected(f)
}"
class="filter-option"
>
<span class="filter-buttons">
<button
*ngIf="f.children.length"
title="{{ 'toggleCollapse' | i18n }}"
appA11yTitle="{{ 'toggleCollapse' | i18n }} {{ f.node.name | i18n }}"
(click)="toggleCollapse(f.node)"
[attr.aria-expanded]="!isCollapsed(f.node)"
[attr.aria-controls]="f.node.name + '_children'"
class="toggle-button"
>
<i
class="bwi bwi-fw"
[ngClass]="{
'bwi-angle-right': isCollapsed(f.node),
'bwi-angle-down': !isCollapsed(f.node)
}"
aria-hidden="true"
></i>
</button>
<button
class="filter-button"
appA11yTitle="{{ 'vault' | i18n }}: {{ f.node.name | i18n }}"
[ngClass]="{ 'disabled-organization': isOrganization && !f.node.enabled }"
(click)="onFilterSelect(f)"
>
<i
*ngIf="f.children.length === 0"
class="bwi bwi-fw {{ f.node.icon }}"
aria-hidden="true"
></i>
&nbsp;{{ f.node.name }}
</button>
<span class="ml-auto">
<button
*ngIf="editInfo && f.node.id"
class="edit-button"
(click)="onEdit(f)"
appA11yTitle="{{ editInfo.text | i18n }}"
>
<i class="bwi bwi-pencil bwi-fw" aria-hidden="true"></i>
</button>
<i
*ngIf="isOrganizationFilter && !f.node.enabled"
class="org-options bwi bwi-fw bwi-exclamation-triangle text-danger"
aria-label="{{ 'organizationIsDisabled' | i18n }}"
appA11yTitle="{{ 'organizationIsDisabled' | i18n }}"
></i
><ng-container *ngIf="optionsInfo && !f.node.hideOptions"
><button [bitMenuTriggerFor]="optionsMenu" class="filter-options-icon">
<i class="bwi bwi-ellipsis-v" aria-hidden="true"></i>
</button>
<bit-menu class="filter-organization-options" #optionsMenu>
<ng-container
*ngComponentOutlet="optionsInfo.component; injector: createInjector(f.node)"
></ng-container>
</bit-menu>
</ng-container>
</span>
</span>
<ul
[id]="f.node.name + '_children'"
class="nested-filter-options"
*ngIf="f.children.length && !isCollapsed(f.node)"
>
<ng-container *ngTemplateOutlet="recursiveFilters; context: { $implicit: f.children }">
</ng-container>
</ul>
</li>
</ng-template>
<ng-container
*ngTemplateOutlet="recursiveFilters; context: { $implicit: filters }"
></ng-container>
<li class="filter-option" *ngIf="showAddLink">
<span class="filter-buttons">
<a href="#" routerLink="{{ addInfo.route }}" class="filter-button">
<i class="bwi bwi-plus bwi-fw" aria-hidden="true"></i>
&nbsp;{{ addInfo.text | i18n }}
</a>
</span>
</li>
</ul>
<hr *ngIf="divider" />
</ng-container>

View File

@ -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<void>();
@Input() activeFilter: VaultFilter;
@Input() section: VaultFilterSection;
data: TreeNode<VaultFilterType>;
collapsedFilterNodes: Set<string> = new Set();
private injectors = new Map<string, Injector>();
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<VaultFilterType>) {
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<VaultFilterType>) {
await this.section?.action(filterNode);
}
get editInfo() {
return this.section?.edit;
}
onEdit(filterNode: TreeNode<VaultFilterType>) {
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<VaultFilterType>("OptionsInput");

View File

@ -1,82 +0,0 @@
<ng-container *ngIf="!hide">
<div class="filter-heading">
<button
class="toggle-button"
(click)="toggleCollapse(foldersGrouping)"
[attr.aria-expanded]="!isCollapsed(foldersGrouping)"
aria-controls="folder-filters"
title="{{ 'toggleCollapse' | i18n }}"
>
<i
class="bwi bwi-fw"
aria-hidden="true"
[ngClass]="{
'bwi-angle-right': isCollapsed(foldersGrouping),
'bwi-angle-down': !isCollapsed(foldersGrouping)
}"
></i>
</button>
<h3 class="filter-title">&nbsp;{{ "folders" | i18n }}</h3>
<button
class="text-muted ml-auto add-button"
(click)="addFolder()"
appA11yTitle="{{ 'addFolder' | i18n }}"
>
<i class="bwi bwi-plus bwi-fw" aria-hidden="true"></i>
</button>
</div>
<ul id="folder-filters" *ngIf="!isCollapsed(foldersGrouping)" class="filter-options">
<ng-template #recursiveFolders let-folders>
<li
*ngFor="let f of folders"
[ngClass]="{
active: f.node.id === activeFilter.selectedFolderId && activeFilter.selectedFolder
}"
class="filter-option"
>
<span class="filter-buttons">
<button
*ngIf="f.children.length"
title="{{ 'toggleCollapse' | i18n }}"
(click)="toggleCollapse(f.node)"
[attr.aria-expanded]="!isCollapsed(f.node)"
[attr.aria-controls]="f.node.name + '_children'"
class="toggle-button"
>
<i
class="bwi bwi-fw"
[ngClass]="{
'bwi-angle-right': isCollapsed(f.node),
'bwi-angle-down': !isCollapsed(f.node)
}"
aria-hidden="true"
></i>
</button>
<button class="filter-button" (click)="applyFilter(f.node)">
<i *ngIf="f.children.length === 0" class="bwi bwi-fw bwi-folder" aria-hidden="true"></i
>&nbsp;{{ f.node.name }}
</button>
<button
class="edit-button"
(click)="editFolder(f.node)"
appA11yTitle="{{ 'editFolder' | i18n }}"
*ngIf="f.node.id"
>
<i class="bwi bwi-pencil bwi-fw" aria-hidden="true"></i>
</button>
</span>
<ul
[id]="f.node.name + '_children'"
class="nested-filter-options"
*ngIf="f.children.length && !isCollapsed(f.node)"
>
<ng-container *ngTemplateOutlet="recursiveFolders; context: { $implicit: f.children }">
</ng-container>
</ul>
</li>
</ng-template>
<ng-container
*ngTemplateOutlet="recursiveFolders; context: { $implicit: nestedFolders }"
></ng-container>
</ul>
</ng-container>

View File

@ -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 {}

View File

@ -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<TreeNode<VaultFilterType>>;
header: {
showHeader: boolean;
isSelectable: boolean;
};
action: (filterNode: TreeNode<VaultFilterType>) => Promise<void>;
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;
};

View File

@ -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<CipherTypeFilter>(
{
id: "AllItems",
name: "allItems",
type: "all",
icon: "",
},
null
);
const favoriteCiphersFilter = new TreeNode<CipherTypeFilter>(
{
id: "favorites",
name: "favorites",
type: "favorites",
icon: "bwi-star",
},
null
);
const identityCiphersFilter = new TreeNode<CipherTypeFilter>(
{
id: "identity",
name: "identity",
type: CipherType.Identity,
icon: "bwi-id-card",
},
null
);
const trashFilter = new TreeNode<CipherTypeFilter>(
{
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<VaultFilter> = {}) {
return new VaultFilter(options).buildFilter();
}
function createOrganizationFilterNode(
options: Partial<OrganizationFilter>
): TreeNode<OrganizationFilter> {
const org = new Organization() as OrganizationFilter;
org.id = options.id;
org.icon = options.icon ?? "";
return new TreeNode<OrganizationFilter>(org, null);
}
function createFolderFilterNode(options: Partial<FolderFilter>): TreeNode<FolderFilter> {
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<FolderFilter>(folder, null);
}
function createCollectionFilterNode(
options: Partial<CollectionFilter>
): TreeNode<CollectionFilter> {
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<CollectionFilter>(collection, null);
}
function createCipher(options: Partial<CipherView> = {}) {
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;
}

View File

@ -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<OrganizationFilter>;
selectedCipherTypeNode: TreeNode<CipherTypeFilter>;
selectedFolderNode: TreeNode<FolderFilter>;
selectedCollectionNode: TreeNode<CollectionFilter>;
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<VaultFilter>) {
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;
};
}
}

View File

@ -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 };

View File

@ -1,33 +0,0 @@
<ng-container *ngIf="show">
<ul class="filter-options">
<li class="filter-option" [ngClass]="{ active: activeFilter.status === 'all' }">
<span class="filter-buttons">
<button class="filter-button" (click)="applyFilter('all')">
<i class="bwi bwi-fw bwi-filter" aria-hidden="true"></i>&nbsp;{{ "allItems" | i18n }}
</button>
</span>
</li>
<li
*ngIf="!hideFavorites"
class="filter-option"
[ngClass]="{ active: activeFilter.status === 'favorites' }"
>
<span class="filter-buttons">
<button class="filter-button" (click)="applyFilter('favorites')">
<i class="bwi bwi-fw bwi-star" aria-hidden="true"></i>&nbsp;{{ "favorites" | i18n }}
</button>
</span>
</li>
<li
*ngIf="!hideTrash"
class="filter-option"
[ngClass]="{ active: activeFilter.status === 'trash' }"
>
<span class="filter-buttons">
<button class="filter-button" (click)="applyFilter('trash')">
<i class="bwi bwi-fw bwi-trash" aria-hidden="true"></i>&nbsp;{{ "trash" | i18n }}
</button>
</span>
</li>
</ul>
</ng-container>

View File

@ -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 {}

View File

@ -1,60 +0,0 @@
<div class="filter-heading">
<button
class="toggle-button"
[attr.aria-expanded]="!isCollapsed"
aria-controls="type-filters"
(click)="toggleCollapse()"
title="{{ 'toggleCollapse' | i18n }}"
>
<i
class="bwi bwi-fw"
aria-hidden="true"
[ngClass]="{
'bwi-angle-right': isCollapsed,
'bwi-angle-down': !isCollapsed
}"
></i>
</button>
<h3>&nbsp;{{ "types" | i18n }}</h3>
</div>
<ul id="type-filters" *ngIf="!isCollapsed" class="filter-options">
<li
class="filter-option"
[ngClass]="{ active: activeFilter.cipherType === cipherTypeEnum.Login }"
>
<span class="filter-buttons">
<button class="filter-button" (click)="applyFilter(cipherTypeEnum.Login)">
<i class="bwi bwi-fw bwi-globe" aria-hidden="true"></i>&nbsp;{{ "typeLogin" | i18n }}
</button>
</span>
</li>
<li class="filter-option" [ngClass]="{ active: activeFilter.cipherType === cipherTypeEnum.Card }">
<span class="filter-buttons">
<button class="filter-button" (click)="applyFilter(cipherTypeEnum.Card)">
<i class="bwi bwi-fw bwi-credit-card" aria-hidden="true"></i>&nbsp;{{ "typeCard" | i18n }}
</button>
</span>
</li>
<li
class="filter-option"
[ngClass]="{ active: activeFilter.cipherType === cipherTypeEnum.Identity }"
>
<span class="filter-buttons">
<button class="filter-button" (click)="applyFilter(cipherTypeEnum.Identity)">
<i class="bwi bwi-fw bwi-id-card" aria-hidden="true"></i>&nbsp;{{ "typeIdentity" | i18n }}
</button>
</span>
</li>
<li
class="filter-option"
[ngClass]="{ active: activeFilter.cipherType === cipherTypeEnum.SecureNote }"
>
<span class="filter-buttons">
<button class="filter-button" (click)="applyFilter(cipherTypeEnum.SecureNote)">
<i class="bwi bwi-fw bwi-sticky-note" aria-hidden="true"></i>&nbsp;{{
"typeSecureNote" | i18n
}}
</button>
</span>
</li>
</ul>

View File

@ -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 {}

View File

@ -2,27 +2,11 @@ import { NgModule } from "@angular/core";
import { SharedModule } from "../../../shared"; import { SharedModule } from "../../../shared";
import { CollectionFilterComponent } from "./collection-filter/collection-filter.component"; import { VaultFilterSectionComponent } from "./components/vault-filter-section.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";
@NgModule({ @NgModule({
imports: [SharedModule], imports: [SharedModule],
declarations: [ declarations: [VaultFilterSectionComponent],
CollectionFilterComponent, exports: [SharedModule, VaultFilterSectionComponent],
FolderFilterComponent,
StatusFilterComponent,
TypeFilterComponent,
],
exports: [
SharedModule,
CollectionFilterComponent,
FolderFilterComponent,
StatusFilterComponent,
TypeFilterComponent,
],
providers: [VaultFilterService],
}) })
export class VaultFilterSharedModule {} export class VaultFilterSharedModule {}

View File

@ -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<Set<string>>(null);
collapsedFilterNodes$: Observable<Set<string>> = 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<Set<string>> {
const nodes = await super.buildCollapsedFilterNodes();
this._collapsedFilterNodes.next(nodes);
return nodes;
}
async storeCollapsedFilterNodes(collapsedFilterNodes: Set<string>): Promise<void> {
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<CollectionView>({
fullList: result,
nestedList: nestedCollections,
});
}
}

View File

@ -1,79 +0,0 @@
<div class="card vault-filters">
<div class="container loading-spinner" *ngIf="!isLoaded">
<i class="bwi bwi-spinner bwi-spin bwi-3x" aria-hidden="true"></i>
</div>
<div *ngIf="isLoaded">
<div class="card-header d-flex">
{{ "filters" | i18n }}
<a
class="ml-auto"
href="https://bitwarden.com/help/searching-vault/"
target="_blank"
rel="noopener"
appA11yTitle="{{ 'learnMore' | i18n }}"
>
<i class="bwi bwi-question-circle" aria-hidden="true"></i>
</a>
</div>
<div class="card-body">
<input
type="search"
placeholder="{{ (searchPlaceholder | i18n) || ('searchVault' | i18n) }}"
id="search"
class="form-control"
[(ngModel)]="searchText"
(input)="searchTextChanged()"
autocomplete="off"
appAutofocus
/>
<app-organization-filter
[hide]="hideOrganizations"
[activeFilter]="activeFilter"
[collapsedFilterNodes]="collapsedFilterNodes"
[organizations]="organizations"
[activePersonalOwnershipPolicy]="activePersonalOwnershipPolicy"
[activeSingleOrganizationPolicy]="activeSingleOrganizationPolicy"
(onNodeCollapseStateChange)="toggleFilterNodeCollapseState($event)"
(onFilterChange)="applyFilter($event)"
></app-organization-filter>
<div class="filter">
<app-status-filter
[hideFavorites]="hideFavorites"
[hideTrash]="hideTrash"
[activeFilter]="activeFilter"
(onFilterChange)="applyFilter($event)"
></app-status-filter>
</div>
<div class="filter">
<app-type-filter
[activeFilter]="activeFilter"
[collapsedFilterNodes]="collapsedFilterNodes"
(onNodeCollapseStateChange)="toggleFilterNodeCollapseState($event)"
(onFilterChange)="applyFilter($event)"
></app-type-filter>
</div>
<div class="filter">
<app-folder-filter
[hide]="hideFolders"
[activeFilter]="activeFilter"
[collapsedFilterNodes]="collapsedFilterNodes"
[folderNodes]="folders$ | async"
(onNodeCollapseStateChange)="toggleFilterNodeCollapseState($event)"
(onFilterChange)="applyFilter($event)"
(onAddFolder)="addFolder()"
(onEditFolder)="editFolder($event)"
></app-folder-filter>
</div>
<div class="filter">
<app-collection-filter
[hide]="hideCollections"
[activeFilter]="activeFilter"
[collapsedFilterNodes]="collapsedFilterNodes"
[collectionNodes]="collections"
(onNodeCollapseStateChange)="toggleFilterNodeCollapseState($event)"
(onFilterChange)="applyFilter($event)"
></app-collection-filter>
</div>
</div>
</div>
</div>

View File

@ -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<string>();
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);
}
}

View File

@ -1,19 +1,21 @@
import { NgModule } from "@angular/core"; import { NgModule } from "@angular/core";
import { LinkSsoComponent } from "./organization-filter/link-sso.component"; import { LinkSsoComponent } from "./components/link-sso.component";
import { OrganizationFilterComponent } from "./organization-filter/organization-filter.component"; import { OrganizationOptionsComponent } from "./components/organization-options.component";
import { OrganizationOptionsComponent } from "./organization-filter/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 { VaultFilterSharedModule } from "./shared/vault-filter-shared.module";
import { VaultFilterComponent } from "./vault-filter.component";
@NgModule({ @NgModule({
imports: [VaultFilterSharedModule], imports: [VaultFilterSharedModule],
declarations: [ declarations: [VaultFilterComponent, OrganizationOptionsComponent, LinkSsoComponent],
VaultFilterComponent,
OrganizationFilterComponent,
OrganizationOptionsComponent,
LinkSsoComponent,
],
exports: [VaultFilterComponent], exports: [VaultFilterComponent],
providers: [
{
provide: VaultFilterServiceAbstraction,
useClass: VaultFilterService,
},
],
}) })
export class VaultFilterModule {} export class VaultFilterModule {}

View File

@ -7,10 +7,10 @@
<app-vault-filter <app-vault-filter
#vaultFilter #vaultFilter
[activeFilter]="activeFilter" [activeFilter]="activeFilter"
(onFilterChange)="applyVaultFilter($event)" (activeFilterChanged)="applyVaultFilter($event)"
(onAddFolder)="addFolder()"
(onEditFolder)="editFolder($event.id)"
(onSearchTextChanged)="filterSearchText($event)" (onSearchTextChanged)="filterSearchText($event)"
(onAddFolder)="addFolder()"
(onEditFolder)="editFolder($event)"
></app-vault-filter> ></app-vault-filter>
</div> </div>
</div> </div>
@ -34,24 +34,20 @@
<div class="ml-auto d-flex"> <div class="ml-auto d-flex">
<app-vault-bulk-actions <app-vault-bulk-actions
[ciphersComponent]="ciphersComponent" [ciphersComponent]="ciphersComponent"
[deleted]="activeFilter.status === 'trash'" [deleted]="activeFilter.isDeleted"
> >
</app-vault-bulk-actions> </app-vault-bulk-actions>
<button <button
type="button" type="button"
class="btn btn-outline-primary btn-sm" class="btn btn-outline-primary btn-sm"
(click)="addCipher()" (click)="addCipher()"
*ngIf="activeFilter.status !== 'trash'" *ngIf="!activeFilter.isDeleted"
> >
<i class="bwi bwi-plus bwi-fw" aria-hidden="true"></i>{{ "addItem" | i18n }} <i class="bwi bwi-plus bwi-fw" aria-hidden="true"></i>{{ "addItem" | i18n }}
</button> </button>
</div> </div>
</div> </div>
<app-callout <app-callout type="warning" *ngIf="activeFilter.isDeleted" icon="bwi-exclamation-triangle">
type="warning"
*ngIf="activeFilter.status === 'trash'"
icon="bwi-exclamation-triangle"
>
{{ trashCleanupWarning }} {{ trashCleanupWarning }}
</app-callout> </app-callout>
<app-vault-ciphers <app-vault-ciphers

View File

@ -8,10 +8,10 @@ import {
ViewContainerRef, ViewContainerRef,
} from "@angular/core"; } from "@angular/core";
import { ActivatedRoute, Params, Router } from "@angular/router"; import { ActivatedRoute, Params, Router } from "@angular/router";
import { firstValueFrom } from "rxjs";
import { first } from "rxjs/operators"; import { first } from "rxjs/operators";
import { ModalService } from "@bitwarden/angular/services/modal.service"; import { ModalService } from "@bitwarden/angular/services/modal.service";
import { VaultFilter } from "@bitwarden/angular/vault/vault-filter/models/vault-filter.model";
import { BroadcasterService } from "@bitwarden/common/abstractions/broadcaster.service"; import { BroadcasterService } from "@bitwarden/common/abstractions/broadcaster.service";
import { CipherService } from "@bitwarden/common/abstractions/cipher.service"; import { CipherService } from "@bitwarden/common/abstractions/cipher.service";
import { CryptoService } from "@bitwarden/common/abstractions/crypto.service"; import { CryptoService } from "@bitwarden/common/abstractions/crypto.service";
@ -23,6 +23,8 @@ import { PlatformUtilsService } from "@bitwarden/common/abstractions/platformUti
import { StateService } from "@bitwarden/common/abstractions/state.service"; import { StateService } from "@bitwarden/common/abstractions/state.service";
import { SyncService } from "@bitwarden/common/abstractions/sync/sync.service.abstraction"; import { SyncService } from "@bitwarden/common/abstractions/sync/sync.service.abstraction";
import { TokenService } from "@bitwarden/common/abstractions/token.service"; import { TokenService } from "@bitwarden/common/abstractions/token.service";
import { ServiceUtils } from "@bitwarden/common/misc/serviceUtils";
import { TreeNode } from "@bitwarden/common/models/domain/treeNode";
import { CipherView } from "@bitwarden/common/models/view/cipherView"; import { CipherView } from "@bitwarden/common/models/view/cipherView";
import { UpdateKeyComponent } from "../settings/update-key.component"; import { UpdateKeyComponent } from "../settings/update-key.component";
@ -33,9 +35,10 @@ import { CiphersComponent } from "./ciphers.component";
import { CollectionsComponent } from "./collections.component"; import { CollectionsComponent } from "./collections.component";
import { FolderAddEditComponent } from "./folder-add-edit.component"; import { FolderAddEditComponent } from "./folder-add-edit.component";
import { ShareComponent } from "./share.component"; import { ShareComponent } from "./share.component";
import { VaultService } from "./shared/vault.service"; import { VaultFilterComponent } from "./vault-filter/components/vault-filter.component";
import { VaultFilterService } from "./vault-filter/shared/vault-filter.service"; import { VaultFilterService } from "./vault-filter/services/abstractions/vault-filter.service";
import { VaultFilterComponent } from "./vault-filter/vault-filter.component"; import { VaultFilter } from "./vault-filter/shared/models/vault-filter.model";
import { FolderFilter, OrganizationFilter } from "./vault-filter/shared/models/vault-filter.type";
const BroadcasterSubscriptionId = "VaultComponent"; const BroadcasterSubscriptionId = "VaultComponent";
@ -58,8 +61,6 @@ export class VaultComponent implements OnInit, OnDestroy {
@ViewChild("updateKeyTemplate", { read: ViewContainerRef, static: true }) @ViewChild("updateKeyTemplate", { read: ViewContainerRef, static: true })
updateKeyModalRef: ViewContainerRef; updateKeyModalRef: ViewContainerRef;
folderId: string = null;
myVaultOnly = false;
showVerifyEmail = false; showVerifyEmail = false;
showBrowserOutdated = false; showBrowserOutdated = false;
showUpdateKey = false; showUpdateKey = false;
@ -82,10 +83,9 @@ export class VaultComponent implements OnInit, OnDestroy {
private ngZone: NgZone, private ngZone: NgZone,
private stateService: StateService, private stateService: StateService,
private organizationService: OrganizationService, private organizationService: OrganizationService,
private vaultService: VaultService, private vaultFilterService: VaultFilterService,
private cipherService: CipherService, private cipherService: CipherService,
private passwordRepromptService: PasswordRepromptService, private passwordRepromptService: PasswordRepromptService
private vaultFilterService: VaultFilterService
) {} ) {}
async ngOnInit() { async ngOnInit() {
@ -104,8 +104,7 @@ export class VaultComponent implements OnInit, OnDestroy {
this.showPremiumCallout = this.showPremiumCallout =
!this.showVerifyEmail && !canAccessPremium && !this.platformUtilsService.isSelfHost(); !this.showVerifyEmail && !canAccessPremium && !this.platformUtilsService.isSelfHost();
this.filterComponent.reloadCollectionsAndFolders(this.activeFilter); await this.vaultFilterService.reloadCollections();
this.filterComponent.reloadOrganizations();
this.showUpdateKey = !(await this.cryptoService.hasEncKey()); this.showUpdateKey = !(await this.cryptoService.hasEncKey());
const cipherId = getCipherIdFromParams(params); const cipherId = getCipherIdFromParams(params);
@ -147,8 +146,7 @@ export class VaultComponent implements OnInit, OnDestroy {
case "syncCompleted": case "syncCompleted":
if (message.successfully) { if (message.successfully) {
await Promise.all([ await Promise.all([
this.filterComponent.reloadCollectionsAndFolders(this.activeFilter), this.vaultFilterService.reloadCollections(),
this.filterComponent.reloadOrganizations(),
this.ciphersComponent.load(this.ciphersComponent.filter), this.ciphersComponent.load(this.ciphersComponent.filter),
]); ]);
this.changeDetectorRef.detectChanges(); this.changeDetectorRef.detectChanges();
@ -173,30 +171,57 @@ export class VaultComponent implements OnInit, OnDestroy {
this.broadcasterService.unsubscribe(BroadcasterSubscriptionId); this.broadcasterService.unsubscribe(BroadcasterSubscriptionId);
} }
async applyVaultFilter(vaultFilter: VaultFilter) { async applyVaultFilter(filter: VaultFilter) {
this.ciphersComponent.showAddNew = vaultFilter.status !== "trash"; this.activeFilter = filter;
this.activeFilter = vaultFilter; this.ciphersComponent.showAddNew = !this.activeFilter.isDeleted;
await this.ciphersComponent.reload( await this.ciphersComponent.reload(
this.activeFilter.buildFilter(), this.activeFilter.buildFilter(),
vaultFilter.status === "trash" this.activeFilter.isDeleted
);
this.filterComponent.searchPlaceholder = this.vaultService.calculateSearchBarLocalizationString(
this.activeFilter
); );
this.go(); this.go();
} }
async applyOrganizationFilter(orgId: string) { async applyOrganizationFilter(orgId: string) {
if (orgId == null) { if (orgId == null) {
this.activeFilter.resetOrganization(); orgId = "MyVault";
this.activeFilter.myVaultOnly = true;
} else {
this.activeFilter.selectedOrganizationId = orgId;
} }
await this.vaultFilterService.ensureVaultFiltersAreExpanded(); const orgs = await firstValueFrom(this.filterComponent.filters.organizationFilter.data$);
await this.applyVaultFilter(this.activeFilter); const orgNode = ServiceUtils.getTreeNodeObject(orgs, orgId) as TreeNode<OrganizationFilter>;
this.filterComponent.filters?.organizationFilter?.action(orgNode);
} }
addFolder = async (): Promise<void> => {
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<void> => {
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) { filterSearchText(searchText: string) {
this.ciphersComponent.searchText = searchText; this.ciphersComponent.searchText = searchText;
this.ciphersComponent.search(200); this.ciphersComponent.search(200);
@ -208,7 +233,7 @@ export class VaultComponent implements OnInit, OnDestroy {
this.messagingService.send("premiumRequired"); this.messagingService.send("premiumRequired");
return; return;
} else if (cipher.organizationId != null) { } 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)) { if (org != null && (org.maxStorageGb == null || org.maxStorageGb === 0)) {
this.messagingService.send("upgradeOrganization", { this.messagingService.send("upgradeOrganization", {
organizationId: cipher.organizationId, 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() { async addCipher() {
const component = await this.editCipher(null); const component = await this.editCipher(null);
component.type = this.activeFilter.cipherType; component.type = this.activeFilter.cipherType;
component.folderId = this.folderId === "none" ? null : this.folderId; if (this.activeFilter.organizationId !== "MyVault") {
if (this.activeFilter.selectedCollectionId != null) { component.organizationId = this.activeFilter.organizationId;
const collection = this.filterComponent.collections.fullList.filter( component.collections = (
(c) => c.id === this.activeFilter.selectedCollectionId await firstValueFrom(this.vaultFilterService.filteredCollections$)
); ).filter((c) => !c.readOnly && c.id != null);
if (collection.length > 0) {
component.organizationId = collection[0].organizationId;
component.collectionIds = [this.activeFilter.selectedCollectionId];
}
} }
if (this.activeFilter.selectedFolderId && this.activeFilter.selectedFolder) { const selectedColId = this.activeFilter.collectionId;
component.folderId = this.activeFilter.selectedFolderId; if (selectedColId !== "AllCollections") {
} component.collectionIds = [selectedColId];
if (this.activeFilter.selectedOrganizationId) {
component.organizationId = this.activeFilter.selectedOrganizationId;
} }
component.folderId = this.activeFilter.folderId;
} }
async editCipher(cipher: CipherView) { async editCipher(cipher: CipherView) {
@ -382,11 +367,11 @@ export class VaultComponent implements OnInit, OnDestroy {
private go(queryParams: any = null) { private go(queryParams: any = null) {
if (queryParams == null) { if (queryParams == null) {
queryParams = { queryParams = {
favorites: this.activeFilter.status === "favorites" ? true : null, favorites: this.activeFilter.isFavorites || null,
type: this.activeFilter.cipherType, type: this.activeFilter.cipherType,
folderId: this.activeFilter.selectedFolderId, folderId: this.activeFilter.folderId,
collectionId: this.activeFilter.selectedCollectionId, collectionId: this.activeFilter.collectionId,
deleted: this.activeFilter.status === "trash" ? true : null, deleted: this.activeFilter.isDeleted || null,
}; };
} }

View File

@ -1,13 +1,23 @@
import { NgModule } from "@angular/core"; import { NgModule } from "@angular/core";
import { SharedModule, LooseComponentsModule } from "../shared";
import { CiphersComponent } from "./ciphers.component"; import { CiphersComponent } from "./ciphers.component";
import { OrganizationBadgeModule } from "./organization-badge/organization-badge.module"; 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 { VaultRoutingModule } from "./vault-routing.module";
import { VaultComponent } from "./vault.component"; import { VaultComponent } from "./vault.component";
@NgModule({ @NgModule({
imports: [VaultSharedModule, VaultRoutingModule, OrganizationBadgeModule], imports: [
VaultFilterModule,
VaultRoutingModule,
OrganizationBadgeModule,
PipesModule,
SharedModule,
LooseComponentsModule,
],
declarations: [VaultComponent, CiphersComponent], declarations: [VaultComponent, CiphersComponent],
exports: [VaultComponent], exports: [VaultComponent],
}) })

View File

@ -277,13 +277,31 @@
"searchFavorites": { "searchFavorites": {
"message": "Search Favorites" "message": "Search Favorites"
}, },
"searchType": { "searchLogin": {
"message": "Search Type", "message": "Search Logins",
"description": "Search item type" "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": { "searchVault": {
"message": "Search Vault" "message": "Search Vault"
}, },
"searchMyVault": {
"message": "Search My Vault"
},
"searchOrganization": {
"message": "Search Organization"
},
"allItems": { "allItems": {
"message": "All Items" "message": "All Items"
}, },

View File

@ -202,7 +202,7 @@ app-sponsored-families {
font-weight: bold; font-weight: bold;
} }
} }
button.org-options { button.filter-options-icon {
background: none; background: none;
border: none; border: none;
padding: 0; padding: 0;

View File

@ -4,7 +4,6 @@
.filter-heading { .filter-heading {
display: flex; display: flex;
text-transform: uppercase;
align-items: center; align-items: center;
* { * {
@ -26,20 +25,24 @@
} }
} }
button.filter-button { .filter-button {
&:hover, h3,
&:focus, button {
&.active { &:hover,
@include themify($themes) { &:focus,
color: themed("linkColor") !important; &.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, &:hover,
&:focus { &:focus {
@include themify($themes) { @include themify($themes) {
@ -137,7 +140,7 @@
} }
} }
.org-options { .filter-options-icon {
padding: 0 2px; padding: 0 2px;
} }
} }
@ -161,7 +164,6 @@
h3 { h3 {
font-weight: normal; font-weight: normal;
text-transform: uppercase;
@include themify($themes) { @include themify($themes) {
color: themed("textMuted"); color: themed("textMuted");
} }
@ -176,11 +178,6 @@
color: themed("textHeadingColor"); color: themed("textHeadingColor");
font-weight: themed("linkWeight"); font-weight: themed("linkWeight");
} }
&:hover {
&.text-muted {
}
}
} }
.show-active { .show-active {
display: none; display: none;

View File

@ -57,6 +57,6 @@
"dbaeumer.vscode-eslint", "dbaeumer.vscode-eslint",
"esbenp.prettier-vscode", "esbenp.prettier-vscode",
"Angular.ng-template" "Angular.ng-template"
], ]
} }
} }

View File

@ -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<Organization[]>;
buildNestedFolders: (organizationId?: string) => Observable<DynamicTreeNode<FolderView>>;
buildCollections: (organizationId?: string) => Promise<DynamicTreeNode<CollectionView>>;
buildCollapsedFilterNodes: () => Promise<Set<string>>;
storeCollapsedFilterNodes: (collapsedFilterNodes: Set<string>) => Promise<void>;
checkForSingleOrganizationPolicy: () => Promise<boolean>;
checkForPersonalOwnershipPolicy: () => Promise<boolean>;
}

View File

@ -1,6 +1,7 @@
import { Directive, EventEmitter, Input, OnInit, Output } from "@angular/core"; import { Directive, EventEmitter, Input, OnInit, Output } from "@angular/core";
import { firstValueFrom, Observable } from "rxjs"; import { firstValueFrom, Observable } from "rxjs";
import { DeprecatedVaultFilterService } from "@bitwarden/angular/abstractions/deprecated-vault-filter.service";
import { Organization } from "@bitwarden/common/models/domain/organization"; import { Organization } from "@bitwarden/common/models/domain/organization";
import { ITreeNodeObject } from "@bitwarden/common/models/domain/treeNode"; import { ITreeNodeObject } from "@bitwarden/common/models/domain/treeNode";
import { CollectionView } from "@bitwarden/common/models/view/collectionView"; 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 { DynamicTreeNode } from "../models/dynamic-tree-node.model";
import { VaultFilter } from "../models/vault-filter.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() @Directive()
export class VaultFilterComponent implements OnInit { export class VaultFilterComponent implements OnInit {
@Input() activeFilter: VaultFilter = new VaultFilter(); @Input() activeFilter: VaultFilter = new VaultFilter();
@ -31,7 +33,7 @@ export class VaultFilterComponent implements OnInit {
collections: DynamicTreeNode<CollectionView>; collections: DynamicTreeNode<CollectionView>;
folders$: Observable<DynamicTreeNode<FolderView>>; folders$: Observable<DynamicTreeNode<FolderView>>;
constructor(protected vaultFilterService: VaultFilterService) {} constructor(protected vaultFilterService: DeprecatedVaultFilterService) {}
get displayCollections() { get displayCollections() {
return this.collections?.fullList != null && this.collections.fullList.length > 0; return this.collections?.fullList != null && this.collections.fullList.length > 0;

View File

@ -1,8 +1,6 @@
import { TreeNode } from "@bitwarden/common/models/domain/treeNode"; import { ITreeNodeObject, TreeNode } from "@bitwarden/common/models/domain/treeNode";
import { CollectionView } from "@bitwarden/common/models/view/collectionView";
import { FolderView } from "@bitwarden/common/models/view/folderView";
export class DynamicTreeNode<T extends CollectionView | FolderView> { export class DynamicTreeNode<T extends ITreeNodeObject> {
fullList: T[]; fullList: T[];
nestedList: TreeNode<T>[]; nestedList: TreeNode<T>[];

View File

@ -1,6 +1,7 @@
import { Injectable } from "@angular/core"; 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 { CipherService } from "@bitwarden/common/abstractions/cipher.service";
import { CollectionService } from "@bitwarden/common/abstractions/collection.service"; import { CollectionService } from "@bitwarden/common/abstractions/collection.service";
import { FolderService } from "@bitwarden/common/abstractions/folder/folder.service.abstraction"; import { FolderService } from "@bitwarden/common/abstractions/folder/folder.service.abstraction";
@ -19,7 +20,7 @@ import { DynamicTreeNode } from "../models/dynamic-tree-node.model";
const NestingDelimiter = "/"; const NestingDelimiter = "/";
@Injectable() @Injectable()
export class VaultFilterService { export class VaultFilterService implements DeprecatedVaultFilterServiceAbstraction {
constructor( constructor(
protected stateService: StateService, protected stateService: StateService,
protected organizationService: OrganizationService, protected organizationService: OrganizationService,
@ -106,6 +107,6 @@ export class VaultFilterService {
const folders = await this.getAllFoldersNested( const folders = await this.getAllFoldersNested(
await firstValueFrom(this.folderService.folderViews$) await firstValueFrom(this.folderService.folderViews$)
); );
return ServiceUtils.getTreeNodeObject(folders, id) as TreeNode<FolderView>; return ServiceUtils.getTreeNodeObjectFromList(folders, id) as TreeNode<FolderView>;
} }
} }

View File

@ -1,6 +1,15 @@
import { ITreeNodeObject, TreeNode } from "../models/domain/treeNode"; import { ITreeNodeObject, TreeNode } from "../models/domain/treeNode";
export class ServiceUtils { export class ServiceUtils {
/**
* Recursively adds a node to nodeTree
* @param {TreeNode<ITreeNodeObject>[]} 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( static nestedTraverse(
nodeTree: TreeNode<ITreeNodeObject>[], nodeTree: TreeNode<ITreeNodeObject>[],
partIndex: number, partIndex: number,
@ -22,7 +31,7 @@ export class ServiceUtils {
} }
if (end && nodeTree[i].node.id !== obj.id) { if (end && nodeTree[i].node.id !== obj.id) {
// Another node with the same name. // Another node with the same name.
nodeTree.push(new TreeNode(obj, partName, parent)); nodeTree.push(new TreeNode(obj, parent, partName));
return; return;
} }
ServiceUtils.nestedTraverse( ServiceUtils.nestedTraverse(
@ -38,7 +47,7 @@ export class ServiceUtils {
if (nodeTree.filter((n) => n.node.name === partName).length === 0) { if (nodeTree.filter((n) => n.node.name === partName).length === 0) {
if (end) { if (end) {
nodeTree.push(new TreeNode(obj, partName, parent)); nodeTree.push(new TreeNode(obj, parent, partName));
return; return;
} }
const newPartName = parts[partIndex] + delimiter + parts[partIndex + 1]; 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<ITreeNodeObject>} nodeTree - A single TreeNode branch that will be searched
* @param {string} id - The id of the node to be found
* @returns {TreeNode<ITreeNodeObject>} The node with a matching `id`
*/
static getTreeNodeObject( static getTreeNodeObject(
nodeTree: TreeNode<ITreeNodeObject>,
id: string
): TreeNode<ITreeNodeObject> {
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<ITreeNodeObject>} nodeTree - An array of TreeNode branches that will be searched
* @param {string} id - The id of the node to be found
* @returns {TreeNode<ITreeNodeObject>} The node with a matching `id`
*/
static getTreeNodeObjectFromList(
nodeTree: TreeNode<ITreeNodeObject>[], nodeTree: TreeNode<ITreeNodeObject>[],
id: string id: string
): TreeNode<ITreeNodeObject> { ): TreeNode<ITreeNodeObject> {
@ -61,7 +100,7 @@ export class ServiceUtils {
if (nodeTree[i].node.id === id) { if (nodeTree[i].node.id === id) {
return nodeTree[i]; return nodeTree[i];
} else if (nodeTree[i].children != null) { } 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) { if (node !== null) {
return node; return node;
} }

View File

@ -137,6 +137,10 @@ export class Organization {
); );
} }
get canUseAdminCollections() {
return this.canEditAnyCollection;
}
get canDeleteAnyCollection() { get canDeleteAnyCollection() {
return ( return (
this.isAdmin || this.isAdmin ||

View File

@ -3,10 +3,15 @@ export class TreeNode<T extends ITreeNodeObject> {
node: T; node: T;
children: TreeNode<T>[] = []; children: TreeNode<T>[] = [];
constructor(node: T, name: string, parent: T) { constructor(node: T, parent: T, name?: string, id?: string) {
this.parent = parent; this.parent = parent;
this.node = node; this.node = node;
this.node.name = name; if (name) {
this.node.name = name;
}
if (id) {
this.node.id = id;
}
} }
} }

View File

@ -91,6 +91,10 @@ export class CollectionService implements CollectionServiceAbstraction {
return decryptedCollections; 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<TreeNode<CollectionView>[]> { async getAllNested(collections: CollectionView[] = null): Promise<TreeNode<CollectionView>[]> {
if (collections == null) { if (collections == null) {
collections = await this.getAllDecrypted(); collections = await this.getAllDecrypted();
@ -106,9 +110,13 @@ export class CollectionService implements CollectionServiceAbstraction {
return nodes; return nodes;
} }
/**
* @deprecated August 30 2022: Moved to new Vault Filter Service
* Remove when Desktop and Browser are updated
*/
async getNested(id: string): Promise<TreeNode<CollectionView>> { async getNested(id: string): Promise<TreeNode<CollectionView>> {
const collections = await this.getAllNested(); const collections = await this.getAllNested();
return ServiceUtils.getTreeNodeObject(collections, id) as TreeNode<CollectionView>; return ServiceUtils.getTreeNodeObjectFromList(collections, id) as TreeNode<CollectionView>;
} }
async upsert(collection: CollectionData | CollectionData[]): Promise<any> { async upsert(collection: CollectionData | CollectionData[]): Promise<any> {