1
0
mirror of https://github.com/bitwarden/browser.git synced 2024-10-06 05:28:51 +02:00

[PM-6826] Vault filter refresh (#9365)

* add initial type filter

- use `bit-select` while the chip component is being developed

* get cipherTypes$ from service

- integrate with user settings

* initial add of folder selection

* initial add of vault selection

* initial add of collections filter

* update `VaultPopupListFilterService` to `VaultPopupListFiltersService`

* integrate hasFilterApplied$

* intermediate commit of integration to the filters component

* do not return the tree when the value is null

* return null when the updated option is null

* update vault-popup-list to conform to the chip select structure

* integration of bit-chip-select

* move "no folder" option to the end of the list

* show danger icon for deactivated organizations

* show deactivated warning when the filtered org is disabled

* update documentation

* use pascal case for constants

* store filter values as full objects rather than just id

- This allows secondary logic to be applied when filters are selected

* move filter form into service to be the source of truth

* fix tests after adding "jest-preset-angular/setup-jest"

* remove logic to have dynamic cipher type filters

* use ProductType enum

* invert conditional for less nesting

* prefer `decryptedCollections$` over getAllDecrypted

* update comments

* use a `filterFunction$` observable rather than having to pass filters back to the service

* fix children testing

* remove check for no folder

* reset filter form when filter component is destroyed

* add takeUntilDestroyed for organization valueChanges

* allow takeUntilDestroyed to use internal destroy ref

- The associated unit tests needed to be configured with TestBed rather than just `new Service()` for this to work

* use controls object for type safety
This commit is contained in:
Nick Krantz 2024-06-03 09:20:14 -05:00 committed by GitHub
parent 01648e2cc3
commit 748eb00223
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 868 additions and 12 deletions

View File

@ -224,7 +224,7 @@
},
"continueToAuthenticatorPageDesc": {
"message": "Bitwarden Authenticator allows you to store authenticator keys and generate TOTP codes for 2-step verification flows. Learn more on the bitwarden.com website"
},
},
"bitwardenSecretsManager": {
"message": "Bitwarden Secrets Manager"
},
@ -3333,5 +3333,14 @@
"example": "Work"
}
}
},
"itemsWithNoFolder": {
"message": "Items with no folder"
},
"organizationIsDeactivated": {
"message": "Organization is deactivated"
},
"contactYourOrgAdmin": {
"message": "Items in deactivated organizations cannot be accessed. Contact your organization owner for assistance."
}
}

View File

@ -0,0 +1,39 @@
<form [formGroup]="filterForm" class="tw-flex tw-flex-wrap tw-gap-2 tw-mb-6 tw-mt-2">
<ng-container *ngIf="organizations$ | async as organizations">
<bit-chip-select
*ngIf="organizations.length"
formControlName="organization"
placeholderIcon="bwi-vault"
[placeholderText]="'vault' | i18n"
[options]="organizations"
>
</bit-chip-select>
</ng-container>
<ng-container *ngIf="collections$ | async as collections">
<bit-chip-select
*ngIf="collections.length"
formControlName="collection"
placeholderIcon="bwi-collection"
[placeholderText]="'collections' | i18n"
[options]="collections"
>
</bit-chip-select>
</ng-container>
<ng-container *ngIf="folders$ | async as folders">
<bit-chip-select
*ngIf="folders.length"
placeholderIcon="bwi-folder"
formControlName="folder"
[placeholderText]="'folder' | i18n"
[options]="folders"
>
</bit-chip-select>
</ng-container>
<bit-chip-select
formControlName="cipherType"
placeholderIcon="bwi-list"
[placeholderText]="'types' | i18n"
[options]="cipherTypes"
>
</bit-chip-select>
</form>

View File

@ -0,0 +1,28 @@
import { CommonModule } from "@angular/common";
import { Component, OnDestroy } from "@angular/core";
import { ReactiveFormsModule } from "@angular/forms";
import { JslibModule } from "@bitwarden/angular/jslib.module";
import { ChipSelectComponent } from "@bitwarden/components";
import { VaultPopupListFiltersService } from "../../../services/vault-popup-list-filters.service";
@Component({
standalone: true,
selector: "app-vault-list-filters",
templateUrl: "./vault-list-filters.component.html",
imports: [CommonModule, JslibModule, ChipSelectComponent, ReactiveFormsModule],
})
export class VaultListFiltersComponent implements OnDestroy {
protected filterForm = this.vaultPopupListFiltersService.filterForm;
protected organizations$ = this.vaultPopupListFiltersService.organizations$;
protected collections$ = this.vaultPopupListFiltersService.collections$;
protected folders$ = this.vaultPopupListFiltersService.folders$;
protected cipherTypes = this.vaultPopupListFiltersService.cipherTypes;
constructor(private vaultPopupListFiltersService: VaultPopupListFiltersService) {}
ngOnDestroy(): void {
this.vaultPopupListFiltersService.resetFilterForm();
}
}

View File

@ -22,13 +22,13 @@
</div>
<ng-container *ngIf="!(showEmptyState$ | async)">
<!-- TODO: Filter/search Section in PM-6824 and PM-6826.-->
<app-vault-v2-search (searchTextChanged)="handleSearchTextChange($event)">
</app-vault-v2-search>
<app-vault-list-filters></app-vault-list-filters>
<div
*ngIf="showNoResultsState$ | async"
*ngIf="(showNoResultsState$ | async) && !(showDeactivatedOrg$ | async)"
class="tw-flex tw-flex-col tw-h-full tw-justify-center"
>
<bit-no-items>
@ -37,7 +37,17 @@
</bit-no-items>
</div>
<ng-container *ngIf="!(showNoResultsState$ | async)">
<div
*ngIf="showDeactivatedOrg$ | async"
class="tw-flex tw-flex-col tw-h-full tw-justify-center"
>
<bit-no-items [icon]="deactivatedIcon">
<ng-container slot="title">{{ "organizationIsDeactivated" | i18n }}</ng-container>
<ng-container slot="description">{{ "contactYourOrgAdmin" | i18n }}</ng-container>
</bit-no-items>
</div>
<ng-container *ngIf="!(showNoResultsState$ | async) && !(showDeactivatedOrg$ | async)">
<app-autofill-vault-list-items></app-autofill-vault-list-items>
<app-vault-list-items-container
[title]="'favorites' | i18n"

View File

@ -11,6 +11,7 @@ import { PopupHeaderComponent } from "../../../../platform/popup/layout/popup-he
import { PopupPageComponent } from "../../../../platform/popup/layout/popup-page.component";
import { VaultPopupItemsService } from "../../services/vault-popup-items.service";
import { AutofillVaultListItemsComponent, VaultListItemsContainerComponent } from "../vault-v2";
import { VaultListFiltersComponent } from "../vault-v2/vault-list-filters/vault-list-filters.component";
import { VaultV2SearchComponent } from "../vault-v2/vault-search/vault-v2-search.component";
@Component({
@ -27,6 +28,7 @@ import { VaultV2SearchComponent } from "../vault-v2/vault-search/vault-v2-search
CommonModule,
AutofillVaultListItemsComponent,
VaultListItemsContainerComponent,
VaultListFiltersComponent,
ButtonModule,
RouterLink,
VaultV2SearchComponent,
@ -38,8 +40,10 @@ export class VaultV2Component implements OnInit, OnDestroy {
protected showEmptyState$ = this.vaultPopupItemsService.emptyVault$;
protected showNoResultsState$ = this.vaultPopupItemsService.noFilteredResults$;
protected showDeactivatedOrg$ = this.vaultPopupItemsService.showDeactivatedOrg$;
protected vaultIcon = Icons.Vault;
protected deactivatedIcon = Icons.DeactivatedOrg;
constructor(
private vaultPopupItemsService: VaultPopupItemsService,

View File

@ -2,6 +2,7 @@ import { mock } from "jest-mock-extended";
import { BehaviorSubject } from "rxjs";
import { SearchService } from "@bitwarden/common/abstractions/search.service";
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
import { CipherId } from "@bitwarden/common/types/guid";
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
import { VaultSettingsService } from "@bitwarden/common/vault/abstractions/vault-settings/vault-settings.service";
@ -12,6 +13,7 @@ import { BrowserApi } from "../../../platform/browser/browser-api";
import BrowserPopupUtils from "../../../platform/popup/browser-popup-utils";
import { VaultPopupItemsService } from "./vault-popup-items.service";
import { VaultPopupListFiltersService } from "./vault-popup-list-filters.service";
describe("VaultPopupItemsService", () => {
let service: VaultPopupItemsService;
@ -20,6 +22,8 @@ describe("VaultPopupItemsService", () => {
const cipherServiceMock = mock<CipherService>();
const vaultSettingsServiceMock = mock<VaultSettingsService>();
const organizationServiceMock = mock<OrganizationService>();
const vaultPopupListFiltersServiceMock = mock<VaultPopupListFiltersService>();
const searchService = mock<SearchService>();
beforeEach(() => {
@ -40,6 +44,18 @@ describe("VaultPopupItemsService", () => {
cipherServiceMock.filterCiphersForUrl.mockImplementation(async () => autoFillCiphers);
vaultSettingsServiceMock.showCardsCurrentTab$ = new BehaviorSubject(false).asObservable();
vaultSettingsServiceMock.showIdentitiesCurrentTab$ = new BehaviorSubject(false).asObservable();
vaultPopupListFiltersServiceMock.filters$ = new BehaviorSubject({
organization: null,
collection: null,
cipherType: null,
folder: null,
});
// Return all ciphers, `filterFunction$` will be tested in `VaultPopupListFiltersService`
vaultPopupListFiltersServiceMock.filterFunction$ = new BehaviorSubject(
(ciphers: CipherView[]) => ciphers,
);
jest.spyOn(BrowserPopupUtils, "inPopout").mockReturnValue(false);
jest
.spyOn(BrowserApi, "getTabFromCurrentWindow")
@ -47,6 +63,8 @@ describe("VaultPopupItemsService", () => {
service = new VaultPopupItemsService(
cipherServiceMock,
vaultSettingsServiceMock,
vaultPopupListFiltersServiceMock,
organizationServiceMock,
searchService,
);
});
@ -55,6 +73,8 @@ describe("VaultPopupItemsService", () => {
service = new VaultPopupItemsService(
cipherServiceMock,
vaultSettingsServiceMock,
vaultPopupListFiltersServiceMock,
organizationServiceMock,
searchService,
);
expect(service).toBeTruthy();
@ -87,6 +107,8 @@ describe("VaultPopupItemsService", () => {
service = new VaultPopupItemsService(
cipherServiceMock,
vaultSettingsServiceMock,
vaultPopupListFiltersServiceMock,
organizationServiceMock,
searchService,
);
@ -117,6 +139,8 @@ describe("VaultPopupItemsService", () => {
service = new VaultPopupItemsService(
cipherServiceMock,
vaultSettingsServiceMock,
vaultPopupListFiltersServiceMock,
organizationServiceMock,
searchService,
);
@ -228,6 +252,8 @@ describe("VaultPopupItemsService", () => {
service = new VaultPopupItemsService(
cipherServiceMock,
vaultSettingsServiceMock,
vaultPopupListFiltersServiceMock,
organizationServiceMock,
searchService,
);
service.emptyVault$.subscribe((empty) => {

View File

@ -2,6 +2,8 @@ import { Injectable } from "@angular/core";
import {
BehaviorSubject,
combineLatest,
distinctUntilKeyChanged,
from,
map,
Observable,
of,
@ -12,6 +14,7 @@ import {
} from "rxjs";
import { SearchService } from "@bitwarden/common/abstractions/search.service";
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
import { VaultSettingsService } from "@bitwarden/common/vault/abstractions/vault-settings/vault-settings.service";
import { CipherType } from "@bitwarden/common/vault/enums";
@ -20,6 +23,8 @@ import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
import { BrowserApi } from "../../../platform/browser/browser-api";
import BrowserPopupUtils from "../../../platform/popup/browser-popup-utils";
import { MY_VAULT_ID, VaultPopupListFiltersService } from "./vault-popup-list-filters.service";
/**
* Service for managing the various item lists on the new Vault tab in the browser popup.
*/
@ -72,7 +77,15 @@ export class VaultPopupItemsService {
shareReplay({ refCount: false, bufferSize: 1 }),
);
private _filteredCipherList$ = combineLatest([this._cipherList$, this.searchText$]).pipe(
private _filteredCipherList$: Observable<CipherView[]> = combineLatest([
this._cipherList$,
this.searchText$,
this.vaultPopupListFiltersService.filterFunction$,
]).pipe(
map(([ciphers, searchText, filterFunction]): [CipherView[], string] => [
filterFunction(ciphers),
searchText,
]),
switchMap(([ciphers, searchText]) =>
this.searchService.searchCiphers(searchText, null, ciphers),
),
@ -137,10 +150,19 @@ export class VaultPopupItemsService {
/**
* Observable that indicates whether a filter is currently applied to the ciphers.
* @todo Implement filter/search functionality in PM-6824 and PM-6826.
*/
hasFilterApplied$: Observable<boolean> = this.searchText$.pipe(
switchMap((text) => this.searchService.isSearchable(text)),
hasFilterApplied$ = combineLatest([
this.searchText$,
this.vaultPopupListFiltersService.filters$,
]).pipe(
switchMap(([searchText, filters]) => {
return from(this.searchService.isSearchable(searchText)).pipe(
map(
(isSearchable) =>
isSearchable || Object.values(filters).some((filter) => filter !== null),
),
);
}),
);
/**
@ -156,15 +178,31 @@ export class VaultPopupItemsService {
/**
* Observable that indicates whether there are no ciphers to show with the current filter.
* @todo Implement filter/search functionality in PM-6824 and PM-6826.
*/
noFilteredResults$: Observable<boolean> = this._filteredCipherList$.pipe(
map((ciphers) => !ciphers.length),
);
/** Observable that indicates when the user should see the deactivated org state */
showDeactivatedOrg$: Observable<boolean> = combineLatest([
this.vaultPopupListFiltersService.filters$.pipe(distinctUntilKeyChanged("organization")),
this.organizationService.organizations$,
]).pipe(
map(([filters, orgs]) => {
if (!filters.organization || filters.organization.id === MY_VAULT_ID) {
return false;
}
const org = orgs.find((o) => o.id === filters.organization.id);
return org ? !org.enabled : false;
}),
);
constructor(
private cipherService: CipherService,
private vaultSettingsService: VaultSettingsService,
private vaultPopupListFiltersService: VaultPopupListFiltersService,
private organizationService: OrganizationService,
private searchService: SearchService,
) {}

View File

@ -0,0 +1,298 @@
import { TestBed } from "@angular/core/testing";
import { FormBuilder } from "@angular/forms";
import { BehaviorSubject, skipWhile } from "rxjs";
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
import { CollectionService } from "@bitwarden/common/vault/abstractions/collection.service";
import { FolderService } from "@bitwarden/common/vault/abstractions/folder/folder.service.abstraction";
import { CipherType } from "@bitwarden/common/vault/enums";
import { Collection } from "@bitwarden/common/vault/models/domain/collection";
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
import { CollectionView } from "@bitwarden/common/vault/models/view/collection.view";
import { FolderView } from "@bitwarden/common/vault/models/view/folder.view";
import { MY_VAULT_ID, VaultPopupListFiltersService } from "./vault-popup-list-filters.service";
describe("VaultPopupListFiltersService", () => {
let service: VaultPopupListFiltersService;
const memberOrganizations$ = new BehaviorSubject<{ name: string; id: string }[]>([]);
const folderViews$ = new BehaviorSubject([]);
const cipherViews$ = new BehaviorSubject({});
const decryptedCollections$ = new BehaviorSubject<CollectionView[]>([]);
const collectionService = {
decryptedCollections$,
getAllNested: () => Promise.resolve([]),
} as unknown as CollectionService;
const folderService = {
folderViews$,
} as unknown as FolderService;
const cipherService = {
cipherViews$,
} as unknown as CipherService;
const organizationService = {
memberOrganizations$,
} as unknown as OrganizationService;
const i18nService = {
t: (key: string) => key,
} as I18nService;
beforeEach(() => {
memberOrganizations$.next([]);
decryptedCollections$.next([]);
collectionService.getAllNested = () => Promise.resolve([]);
TestBed.configureTestingModule({
providers: [
{
provide: FolderService,
useValue: folderService,
},
{
provide: CipherService,
useValue: cipherService,
},
{
provide: OrganizationService,
useValue: organizationService,
},
{
provide: I18nService,
useValue: i18nService,
},
{
provide: CollectionService,
useValue: collectionService,
},
{ provide: FormBuilder, useClass: FormBuilder },
],
});
service = TestBed.inject(VaultPopupListFiltersService);
});
describe("cipherTypes", () => {
it("returns all cipher types", () => {
expect(service.cipherTypes.map((c) => c.value)).toEqual([
CipherType.Login,
CipherType.Card,
CipherType.Identity,
CipherType.SecureNote,
]);
});
});
describe("organizations$", () => {
it('does not add "myVault" to the list of organizations when there are no organizations', (done) => {
memberOrganizations$.next([]);
service.organizations$.subscribe((organizations) => {
expect(organizations.map((o) => o.label)).toEqual([]);
done();
});
});
it('adds "myVault" to the list of organizations when there are other organizations', (done) => {
memberOrganizations$.next([{ name: "bobby's org", id: "1234-3323-23223" }]);
service.organizations$.subscribe((organizations) => {
expect(organizations.map((o) => o.label)).toEqual(["myVault", "bobby's org"]);
done();
});
});
it("sorts organizations by name", (done) => {
memberOrganizations$.next([
{ name: "bobby's org", id: "1234-3323-23223" },
{ name: "alice's org", id: "2223-4343-99888" },
]);
service.organizations$.subscribe((organizations) => {
expect(organizations.map((o) => o.label)).toEqual([
"myVault",
"alice's org",
"bobby's org",
]);
done();
});
});
});
describe("collections$", () => {
const testCollection = {
id: "14cbf8e9-7a2a-4105-9bf6-b15c01203cef",
name: "Test collection",
organizationId: "3f860945-b237-40bc-a51e-b15c01203ccf",
} as CollectionView;
const testCollection2 = {
id: "b15c0120-7a2a-4105-9bf6-b15c01203ceg",
name: "Test collection 2",
organizationId: "1203ccf-2432-123-acdd-b15c01203ccf",
} as CollectionView;
const testCollections = [testCollection, testCollection2];
beforeEach(() => {
decryptedCollections$.next(testCollections);
collectionService.getAllNested = () =>
Promise.resolve(
testCollections.map((c) => ({
children: [],
node: c,
parent: null,
})),
);
});
it("returns all collections", (done) => {
service.collections$.subscribe((collections) => {
expect(collections.map((c) => c.label)).toEqual(["Test collection", "Test collection 2"]);
done();
});
});
it("filters out collections that do not belong to an organization", () => {
service.filterForm.patchValue({
organization: { id: testCollection2.organizationId } as Organization,
});
service.collections$.subscribe((collections) => {
expect(collections.map((c) => c.label)).toEqual(["Test collection 2"]);
});
});
});
describe("folders$", () => {
it('returns no folders when "No Folder" is the only option', (done) => {
folderViews$.next([{ id: null, name: "No Folder" }]);
service.folders$.subscribe((folders) => {
expect(folders).toEqual([]);
done();
});
});
it('moves "No Folder" to the end of the list', (done) => {
folderViews$.next([
{ id: null, name: "No Folder" },
{ id: "2345", name: "Folder 2" },
{ id: "1234", name: "Folder 1" },
]);
service.folders$.subscribe((folders) => {
expect(folders.map((f) => f.label)).toEqual(["Folder 1", "Folder 2", "itemsWithNoFolder"]);
done();
});
});
it("returns all folders when MyVault is selected", (done) => {
service.filterForm.patchValue({
organization: { id: MY_VAULT_ID } as Organization,
});
folderViews$.next([
{ id: "1234", name: "Folder 1" },
{ id: "2345", name: "Folder 2" },
]);
service.folders$.subscribe((folders) => {
expect(folders.map((f) => f.label)).toEqual(["Folder 1", "Folder 2"]);
done();
});
});
it("returns folders that have ciphers within the selected organization", (done) => {
service.folders$.pipe(skipWhile((folders) => folders.length === 2)).subscribe((folders) => {
expect(folders.map((f) => f.label)).toEqual(["Folder 1"]);
done();
});
service.filterForm.patchValue({
organization: { id: "1234" } as Organization,
});
folderViews$.next([
{ id: "1234", name: "Folder 1" },
{ id: "2345", name: "Folder 2" },
]);
cipherViews$.next({
"1": { folderId: "1234", organizationId: "1234" },
"2": { folderId: "2345", organizationId: "56789" },
});
});
});
describe("filterFunction$", () => {
const ciphers = [
{ type: CipherType.Login, collectionIds: [], organizationId: null },
{ type: CipherType.Card, collectionIds: ["1234"], organizationId: "8978" },
{ type: CipherType.Identity, collectionIds: [], folderId: "5432", organizationId: null },
{ type: CipherType.SecureNote, collectionIds: [], organizationId: null },
] as CipherView[];
it("filters by cipherType", (done) => {
service.filterFunction$.subscribe((filterFunction) => {
expect(filterFunction(ciphers)).toEqual([ciphers[0]]);
done();
});
service.filterForm.patchValue({ cipherType: CipherType.Login });
});
it("filters by collection", (done) => {
const collection = { id: "1234" } as Collection;
service.filterFunction$.subscribe((filterFunction) => {
expect(filterFunction(ciphers)).toEqual([ciphers[1]]);
done();
});
service.filterForm.patchValue({ collection });
});
it("filters by folder", (done) => {
const folder = { id: "5432" } as FolderView;
service.filterFunction$.subscribe((filterFunction) => {
expect(filterFunction(ciphers)).toEqual([ciphers[2]]);
done();
});
service.filterForm.patchValue({ folder });
});
describe("organizationId", () => {
it("filters out ciphers that belong to an organization when MyVault is selected", (done) => {
const organization = { id: MY_VAULT_ID } as Organization;
service.filterFunction$.subscribe((filterFunction) => {
expect(filterFunction(ciphers)).toEqual([ciphers[0], ciphers[2], ciphers[3]]);
done();
});
service.filterForm.patchValue({ organization });
});
it("filters out ciphers that do not belong to the selected organization", (done) => {
const organization = { id: "8978" } as Organization;
service.filterFunction$.subscribe((filterFunction) => {
expect(filterFunction(ciphers)).toEqual([ciphers[1]]);
done();
});
service.filterForm.patchValue({ organization });
});
});
});
});

View File

@ -0,0 +1,371 @@
import { Injectable } from "@angular/core";
import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
import { FormBuilder } from "@angular/forms";
import {
Observable,
combineLatest,
distinctUntilChanged,
map,
startWith,
switchMap,
tap,
} from "rxjs";
import { DynamicTreeNode } from "@bitwarden/angular/vault/vault-filter/models/dynamic-tree-node.model";
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
import { ProductType } from "@bitwarden/common/enums";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { Utils } from "@bitwarden/common/platform/misc/utils";
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
import { CollectionService } from "@bitwarden/common/vault/abstractions/collection.service";
import { FolderService } from "@bitwarden/common/vault/abstractions/folder/folder.service.abstraction";
import { CipherType } from "@bitwarden/common/vault/enums";
import { Collection } from "@bitwarden/common/vault/models/domain/collection";
import { ITreeNodeObject, TreeNode } from "@bitwarden/common/vault/models/domain/tree-node";
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
import { CollectionView } from "@bitwarden/common/vault/models/view/collection.view";
import { FolderView } from "@bitwarden/common/vault/models/view/folder.view";
import { ServiceUtils } from "@bitwarden/common/vault/service-utils";
import { ChipSelectOption } from "@bitwarden/components";
/** All available cipher filters */
export type PopupListFilter = {
organization: Organization | null;
collection: Collection | null;
folder: FolderView | null;
cipherType: CipherType | null;
};
/** Delimiter that denotes a level of nesting */
const NESTING_DELIMITER = "/";
/** Id assigned to the "My vault" organization */
export const MY_VAULT_ID = "MyVault";
const INITIAL_FILTERS: PopupListFilter = {
organization: null,
collection: null,
folder: null,
cipherType: null,
};
@Injectable({
providedIn: "root",
})
export class VaultPopupListFiltersService {
/**
* UI form for all filters
*/
filterForm = this.formBuilder.group<PopupListFilter>(INITIAL_FILTERS);
/**
* Observable for `filterForm` value
*/
filters$ = this.filterForm.valueChanges.pipe(
startWith(INITIAL_FILTERS),
) as Observable<PopupListFilter>;
/**
* Static list of ciphers views used in synchronous context
*/
private cipherViews: CipherView[] = [];
/**
* Observable of cipher views
*/
private cipherViews$: Observable<CipherView[]> = this.cipherService.cipherViews$.pipe(
tap((cipherViews) => {
this.cipherViews = Object.values(cipherViews);
}),
map((ciphers) => Object.values(ciphers)),
);
constructor(
private folderService: FolderService,
private cipherService: CipherService,
private organizationService: OrganizationService,
private i18nService: I18nService,
private collectionService: CollectionService,
private formBuilder: FormBuilder,
) {
this.filterForm.controls.organization.valueChanges
.pipe(takeUntilDestroyed())
.subscribe(this.validateOrganizationChange.bind(this));
}
/**
* Observable whose value is a function that filters an array of `CipherView` objects based on the current filters
*/
filterFunction$: Observable<(ciphers: CipherView[]) => CipherView[]> = this.filters$.pipe(
map(
(filters) => (ciphers: CipherView[]) =>
ciphers.filter((cipher) => {
if (filters.cipherType !== null && cipher.type !== filters.cipherType) {
return false;
}
if (
filters.collection !== null &&
!cipher.collectionIds.includes(filters.collection.id)
) {
return false;
}
if (filters.folder !== null && cipher.folderId !== filters.folder.id) {
return false;
}
const isMyVault = filters.organization?.id === MY_VAULT_ID;
if (isMyVault) {
if (cipher.organizationId !== null) {
return false;
}
} else if (filters.organization !== null) {
if (cipher.organizationId !== filters.organization.id) {
return false;
}
}
return true;
}),
),
);
/**
* All available cipher types
*/
readonly cipherTypes: ChipSelectOption<CipherType>[] = [
{
value: CipherType.Login,
label: this.i18nService.t("logins"),
icon: "bwi-globe",
},
{
value: CipherType.Card,
label: this.i18nService.t("cards"),
icon: "bwi-credit-card",
},
{
value: CipherType.Identity,
label: this.i18nService.t("identities"),
icon: "bwi-id-card",
},
{
value: CipherType.SecureNote,
label: this.i18nService.t("notes"),
icon: "bwi-sticky-note",
},
];
/** Resets `filterForm` to the original state */
resetFilterForm(): void {
this.filterForm.reset(INITIAL_FILTERS);
}
/**
* Organization array structured to be directly passed to `ChipSelectComponent`
*/
organizations$: Observable<ChipSelectOption<Organization>[]> =
this.organizationService.memberOrganizations$.pipe(
map((orgs) => orgs.sort(Utils.getSortFunction(this.i18nService, "name"))),
map((orgs) => {
if (!orgs.length) {
return [];
}
return [
// When the user is a member of an organization, make the "My Vault" option available
{
value: { id: MY_VAULT_ID } as Organization,
label: this.i18nService.t("myVault"),
icon: "bwi-user",
},
...orgs.map((org) => {
let icon = "bwi-business";
if (!org.enabled) {
// Show a warning icon if the organization is deactivated
icon = "bwi-exclamation-triangle tw-text-danger";
} else if (org.planProductType === ProductType.Families) {
// Show a family icon if the organization is a family org
icon = "bwi-family";
}
return {
value: org,
label: org.name,
icon,
};
}),
];
}),
);
/**
* Folder array structured to be directly passed to `ChipSelectComponent`
*/
folders$: Observable<ChipSelectOption<string>[]> = combineLatest([
this.filters$.pipe(
distinctUntilChanged(
(previousFilter, currentFilter) =>
// Only update the collections when the organizationId filter changes
previousFilter.organization?.id === currentFilter.organization?.id,
),
),
this.folderService.folderViews$,
this.cipherViews$,
]).pipe(
map(([filters, folders, cipherViews]): [PopupListFilter, FolderView[], CipherView[]] => {
if (folders.length === 1 && folders[0].id === null) {
// Do not display folder selections when only the "no folder" option is available.
return [filters, [], cipherViews];
}
// Sort folders by alphabetic name
folders.sort(Utils.getSortFunction(this.i18nService, "name"));
let arrangedFolders = folders;
const noFolder = folders.find((f) => f.id === null);
if (noFolder) {
// Update `name` of the "no folder" option to "Items with no folder"
noFolder.name = this.i18nService.t("itemsWithNoFolder");
// Move the "no folder" option to the end of the list
arrangedFolders = [...folders.filter((f) => f.id !== null), noFolder];
}
return [filters, arrangedFolders, cipherViews];
}),
map(([filters, folders, cipherViews]) => {
const organizationId = filters.organization?.id ?? null;
// When no org or "My vault" is selected, return all folders
if (organizationId === null || organizationId === MY_VAULT_ID) {
return folders;
}
const orgCiphers = cipherViews.filter((c) => c.organizationId === organizationId);
// Return only the folders that have ciphers within the filtered organization
return folders.filter((f) => orgCiphers.some((oc) => oc.folderId === f.id));
}),
map((folders) => {
const nestedFolders = this.getAllFoldersNested(folders);
return new DynamicTreeNode<FolderView>({
fullList: folders,
nestedList: nestedFolders,
});
}),
map((folders) => folders.nestedList.map(this.convertToChipSelectOption.bind(this))),
);
/**
* Collection array structured to be directly passed to `ChipSelectComponent`
*/
collections$: Observable<ChipSelectOption<string>[]> = combineLatest([
this.filters$.pipe(
distinctUntilChanged(
(previousFilter, currentFilter) =>
// Only update the collections when the organizationId filter changes
previousFilter.organization?.id === currentFilter.organization?.id,
),
),
this.collectionService.decryptedCollections$,
]).pipe(
map(([filters, allCollections]) => {
const organizationId = filters.organization?.id ?? null;
// When the organization filter is selected, filter out collections that do not belong to the selected organization
const collections =
organizationId === null
? allCollections
: allCollections.filter((c) => c.organizationId === organizationId);
return collections;
}),
switchMap(async (collections) => {
const nestedCollections = await this.collectionService.getAllNested(collections);
return new DynamicTreeNode<CollectionView>({
fullList: collections,
nestedList: nestedCollections,
});
}),
map((collections) => collections.nestedList.map(this.convertToChipSelectOption.bind(this))),
);
/**
* Converts the given item into the `ChipSelectOption` structure
*/
private convertToChipSelectOption<T extends ITreeNodeObject>(
item: TreeNode<T>,
): ChipSelectOption<T> {
return {
value: item.node,
label: item.node.name,
icon: "bwi-folder", // Organization & Folder icons are the same
children: item.children
? item.children.map(this.convertToChipSelectOption.bind(this))
: undefined,
};
}
/**
* Returns a nested folder structure based on the input FolderView array
*/
private getAllFoldersNested(folders: FolderView[]): TreeNode<FolderView>[] {
const nodes: TreeNode<FolderView>[] = [];
folders.forEach((f) => {
const folderCopy = new FolderView();
folderCopy.id = f.id;
folderCopy.revisionDate = f.revisionDate;
// Remove "/" from beginning and end of the folder name
// then split the folder name by the delimiter
const parts = f.name != null ? f.name.replace(/^\/+|\/+$/g, "").split(NESTING_DELIMITER) : [];
ServiceUtils.nestedTraverse(nodes, 0, parts, folderCopy, null, NESTING_DELIMITER);
});
return nodes;
}
/**
* Validate collection & folder filters when the organization filter changes
*/
private validateOrganizationChange(organization: Organization | null): void {
if (!organization) {
return;
}
const currentFilters = this.filterForm.getRawValue();
// When the organization filter changes and a collection is already selected,
// reset the collection filter if the collection does not belong to the new organization filter
if (currentFilters.collection && currentFilters.collection.organizationId !== organization.id) {
this.filterForm.get("collection").setValue(null);
}
// When the organization filter changes and a folder is already selected,
// reset the folder filter if the folder does not belong to the new organization filter
if (
currentFilters.folder &&
currentFilters.folder.id !== null &&
organization.id !== MY_VAULT_ID
) {
// Get all ciphers that belong to the new organization
const orgCiphers = this.cipherViews.filter((c) => c.organizationId === organization.id);
// Find any ciphers within the organization that belong to the current folder
const newOrgContainsFolder = orgCiphers.some(
(oc) => oc.folderId === currentFilters.folder.id,
);
// If the new organization does not contain the current folder, reset the folder filter
if (!newOrgContainsFolder) {
this.filterForm.get("folder").setValue(null);
}
}
}
}

View File

@ -108,7 +108,7 @@ export class ChipSelectComponent<T = unknown> implements ControlValueAccessor {
*/
private findOption(tree: ChipSelectOption<T>, value: T): ChipSelectOption<T> | null {
let result = null;
if (tree.value === value) {
if (tree.value !== null && tree.value === value) {
return tree;
}
@ -183,7 +183,7 @@ export class ChipSelectComponent<T = unknown> implements ControlValueAccessor {
return;
}
this.notifyOnChange(option?.value);
this.notifyOnChange(option?.value ?? null);
}
/** Implemented as part of NG_VALUE_ACCESSOR */

View File

@ -0,0 +1,32 @@
import { svgIcon } from "../icon";
export const DeactivatedOrg = svgIcon`
<svg width="138" height="118" viewBox="0 0 138 118" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_2929_17380)">
<path class="tw-stroke-text-headers" d="M80.0852 15.889V11.7504C80.0852 9.75243 78.6181 8.18262 76.7509 8.18262H53.1445C51.2773 8.18262 49.8102 9.75243 49.8102 11.7504V16.0317" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
<path class="tw-stroke-text-headers" d="M73.3568 7.06126V3.568C73.3568 1.75668 71.8648 0.333496 69.9658 0.333496H59.9285C58.0295 0.333496 56.5374 1.75668 56.5374 3.568V7.06126" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
<path class="tw-stroke-text-headers" d="M41.9611 29.8517V20.5736C41.9611 18.658 43.4441 17.1528 45.3315 17.1528H84.5637C86.4511 17.1528 87.9341 18.658 87.9341 20.5736V83.2728" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
<path class="tw-stroke-text-headers" d="M12.8074 103.493V32.9262C12.8074 31.0004 14.3311 29.4873 16.2703 29.4873H56.4389C58.3781 29.4873 59.9018 31.0004 59.9018 32.9262V103.493" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
<path class="tw-stroke-text-headers" d="M36.3545 39.5791V94.5225" stroke-linecap="round" stroke-linejoin="round"/>
<path class="tw-stroke-text-headers" d="M47.5677 39.5791V94.5225" stroke-linecap="round" stroke-linejoin="round"/>
<path class="tw-stroke-text-headers" d="M78.9634 26.1235V37.3365" stroke-linecap="round" stroke-linejoin="round"/>
<path class="tw-stroke-text-headers" d="M78.9634 45.1851V56.398" stroke-linecap="round" stroke-linejoin="round"/>
<path class="tw-stroke-text-headers" d="M78.9634 64.2476V75.4605" stroke-linecap="round" stroke-linejoin="round"/>
<path class="tw-stroke-text-headers" d="M78.9634 83.3091V94.522" stroke-linecap="round" stroke-linejoin="round"/>
<path class="tw-stroke-text-headers" d="M69.9932 26.1235V37.3365" stroke-linecap="round" stroke-linejoin="round"/>
<path class="tw-stroke-text-headers" d="M69.9932 45.1851V56.398" stroke-linecap="round" stroke-linejoin="round"/>
<path class="tw-stroke-text-headers" d="M69.9932 64.2476V75.4605" stroke-linecap="round" stroke-linejoin="round"/>
<path class="tw-stroke-text-headers" d="M69.9932 83.3091V94.522" stroke-linecap="round" stroke-linejoin="round"/>
<path class="tw-stroke-text-headers" d="M24.0202 39.5791V94.5225" stroke-linecap="round" stroke-linejoin="round"/>
<path class="tw-stroke-text-headers" d="M0.473145 104.614H75.3408" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
<path class="tw-fill-danger-600" fill-rule="evenodd" clip-rule="evenodd" d="M121.425 111.921L99.1265 73.2989C98.3006 71.8685 96.236 71.8685 95.4101 73.2989L73.1119 111.921C72.286 113.351 73.3183 115.139 74.97 115.139H119.567C121.218 115.139 122.251 113.351 121.425 111.921ZM101.604 71.8685C99.6771 68.5308 94.8595 68.5308 92.9325 71.8685L70.6343 110.49C68.7073 113.828 71.116 118 74.97 118H119.567C123.421 118 125.829 113.828 123.902 110.49L101.604 71.8685Z"/>
<path class="tw-fill-danger-600" d="M98.2704 84.3848C98.8321 84.3848 99.2836 84.8473 99.2701 85.4088L98.8811 101.584C98.8681 102.127 98.4243 102.56 97.8814 102.56H96.6544C96.1118 102.56 95.6682 102.127 95.6547 101.585L95.254 85.4095C95.24 84.8477 95.6917 84.3848 96.2537 84.3848H98.2704Z" />
<circle class="tw-fill-danger-600" cx="97.2682" cy="106.556" r="2.14565" />
</g>
<defs>
<clipPath id="clip0_2929_17380">
<rect width="138" height="118" class="tw-fill-danger-600"/>
</clipPath>
</defs>
</svg>
`;

View File

@ -1,3 +1,4 @@
export * from "./deactivated-org";
export * from "./search";
export * from "./no-access";
export * from "./vault";