1
0
mirror of https://github.com/bitwarden/browser synced 2026-01-21 11:53:34 +00:00

Desktop/pm 18769/migrate vault filters (#17919)

Migrated vault filters to new v3 vault's navigation

* Decoupled existing vault filtering from vault component by using routed params with routed-vault-filter-bridge
* Converted vault filters to standalone components
* Removed extending filter Base Components from deprecated /libs/angular library and handled logic directly
* Moved shared 'models' and 'services' directories from web-vault into /libs/vault
This commit is contained in:
Leslie Xiong
2026-01-15 10:17:00 -05:00
committed by GitHub
parent 6ef5241c29
commit 44bdaf71b3
142 changed files with 1217 additions and 507 deletions

View File

@@ -1,10 +1,12 @@
import { Observable } from "rxjs";
import { CollectionDetailsResponse } from "@bitwarden/admin-console/common";
import {
CollectionAdminView,
CollectionAccessSelectionView,
CollectionDetailsResponse,
} from "@bitwarden/common/admin-console/models/collections";
import { UserId } from "@bitwarden/common/types/guid";
import { CollectionAccessSelectionView, CollectionAdminView } from "../models";
export abstract class CollectionAdminService {
abstract collectionAdminViews$(
organizationId: string,

View File

@@ -1,11 +1,14 @@
import { Observable } from "rxjs";
import {
CollectionView,
Collection,
CollectionData,
} from "@bitwarden/common/admin-console/models/collections";
import { CollectionId, OrganizationId, UserId } from "@bitwarden/common/types/guid";
import { OrgKey } from "@bitwarden/common/types/key";
import { TreeNode } from "@bitwarden/common/vault/models/domain/tree-node";
import { CollectionData, Collection, CollectionView } from "../models";
export abstract class CollectionService {
abstract encryptedCollections$(userId: UserId): Observable<Collection[] | null>;
abstract decryptedCollections$(userId: UserId): Observable<CollectionView[]>;

View File

@@ -1,4 +1,5 @@
import { Collection } from "./collection";
import { Collection } from "@bitwarden/common/admin-console/models/collections";
import { BaseCollectionRequest } from "./collection.request";
export class CollectionWithIdRequest extends BaseCollectionRequest {

View File

@@ -1,15 +1,17 @@
import { MockProxy, mock } from "jest-mock-extended";
import {
CollectionDetailsResponse,
Collection,
CollectionTypes,
CollectionData,
} from "@bitwarden/common/admin-console/models/collections";
import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service";
import { EncString } from "@bitwarden/common/key-management/crypto/models/enc-string";
import { makeSymmetricCryptoKey } from "@bitwarden/common/spec";
import { CollectionId, OrganizationId } from "@bitwarden/common/types/guid";
import { OrgKey } from "@bitwarden/common/types/key";
import { Collection, CollectionTypes } from "./collection";
import { CollectionData } from "./collection.data";
import { CollectionDetailsResponse } from "./collection.response";
describe("Collection", () => {
let data: CollectionData;
let encService: MockProxy<EncryptService>;

View File

@@ -1,9 +1,3 @@
export * from "./bulk-collection-access.request";
export * from "./collection-access-selection.view";
export * from "./collection-admin.view";
export * from "./collection";
export * from "./collection.data";
export * from "./collection.view";
export * from "./collection.request";
export * from "./collection.response";
export * from "./collection-with-id.request";

View File

@@ -1,5 +1,6 @@
import { Jsonify } from "type-fest";
import { CollectionView, CollectionData } from "@bitwarden/common/admin-console/models/collections";
import {
COLLECTION_DISK,
COLLECTION_MEMORY,
@@ -7,8 +8,6 @@ import {
} from "@bitwarden/common/platform/state";
import { CollectionId } from "@bitwarden/common/types/guid";
import { CollectionData, CollectionView } from "../models";
export const ENCRYPTED_COLLECTION_DATA_KEY = UserKeyDefinition.record<CollectionData, CollectionId>(
COLLECTION_DISK,
"collections",

View File

@@ -5,6 +5,14 @@ import {
getOrganizationById,
OrganizationService,
} from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
import {
CollectionAccessSelectionView,
CollectionAdminView,
CollectionAccessDetailsResponse,
CollectionDetailsResponse,
CollectionResponse,
CollectionData,
} from "@bitwarden/common/admin-console/models/collections";
import { SelectionReadOnlyRequest } from "@bitwarden/common/admin-console/models/request/selection-read-only.request";
import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service";
import { CollectionId, OrganizationId, UserId } from "@bitwarden/common/types/guid";
@@ -13,13 +21,7 @@ import { KeyService } from "@bitwarden/key-management";
import { CollectionAdminService, CollectionService } from "../abstractions";
import {
CollectionData,
CollectionAccessDetailsResponse,
CollectionDetailsResponse,
CollectionResponse,
BulkCollectionAccessRequest,
CollectionAccessSelectionView,
CollectionAdminView,
BaseCollectionRequest,
UpdateCollectionRequest,
CreateCollectionRequest,

View File

@@ -1,6 +1,11 @@
import { mock, MockProxy } from "jest-mock-extended";
import { combineLatest, first, firstValueFrom, of, ReplaySubject, takeWhile } from "rxjs";
import {
CollectionView,
CollectionTypes,
CollectionData,
} from "@bitwarden/common/admin-console/models/collections";
import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service";
import { EncString } from "@bitwarden/common/key-management/crypto/models/enc-string";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
@@ -18,8 +23,6 @@ import { OrgKey } from "@bitwarden/common/types/key";
import { newGuid } from "@bitwarden/guid";
import { KeyService } from "@bitwarden/key-management";
import { CollectionData, CollectionTypes, CollectionView } from "../models";
import { DECRYPTED_COLLECTION_DATA_KEY, ENCRYPTED_COLLECTION_DATA_KEY } from "./collection.state";
import { DefaultCollectionService } from "./default-collection.service";

View File

@@ -12,6 +12,11 @@ import {
switchMap,
} from "rxjs";
import {
CollectionView,
Collection,
CollectionData,
} from "@bitwarden/common/admin-console/models/collections";
import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { Utils } from "@bitwarden/common/platform/misc/utils";
@@ -23,7 +28,6 @@ import { ServiceUtils } from "@bitwarden/common/vault/service-utils";
import { KeyService } from "@bitwarden/key-management";
import { CollectionService } from "../abstractions/collection.service";
import { Collection, CollectionData, CollectionView } from "../models";
import { DECRYPTED_COLLECTION_DATA_KEY, ENCRYPTED_COLLECTION_DATA_KEY } from "./collection.state";

View File

@@ -1,8 +1,6 @@
import { Observable } from "rxjs";
// This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop.
// eslint-disable-next-line no-restricted-imports
import { CollectionView } from "@bitwarden/admin-console/common";
import { CollectionView } from "@bitwarden/common/admin-console/models/collections";
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
import { UserId } from "@bitwarden/common/types/guid";
import { FolderView } from "@bitwarden/common/vault/models/view/folder.view";

View File

@@ -2,9 +2,10 @@
// @ts-strict-ignore
import { Directive, EventEmitter, Input, Output } from "@angular/core";
// This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop.
// eslint-disable-next-line no-restricted-imports
import { CollectionTypes, CollectionView } from "@bitwarden/admin-console/common";
import {
CollectionView,
CollectionTypes,
} from "@bitwarden/common/admin-console/models/collections";
import { ITreeNodeObject } from "@bitwarden/common/vault/models/domain/tree-node";
import { DynamicTreeNode } from "../models/dynamic-tree-node.model";

View File

@@ -3,9 +3,7 @@
import { Directive, EventEmitter, Input, OnInit, Output } from "@angular/core";
import { firstValueFrom, Observable } from "rxjs";
// This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop.
// eslint-disable-next-line no-restricted-imports
import { CollectionView } from "@bitwarden/admin-console/common";
import { CollectionView } from "@bitwarden/common/admin-console/models/collections";
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { getUserId } from "@bitwarden/common/auth/services/account.service";

View File

@@ -3,14 +3,14 @@ import { firstValueFrom, from, map, mergeMap, Observable, switchMap, take } from
// This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop.
// eslint-disable-next-line no-restricted-imports
import {
CollectionService,
CollectionTypes,
CollectionView,
} from "@bitwarden/admin-console/common";
import { CollectionService } from "@bitwarden/admin-console/common";
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
import { PolicyType } from "@bitwarden/common/admin-console/enums";
import {
CollectionView,
CollectionTypes,
} from "@bitwarden/common/admin-console/models/collections";
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { getUserId } from "@bitwarden/common/auth/services/account.service";

View File

@@ -1,12 +1,11 @@
// This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop.
// eslint-disable-next-line no-restricted-imports
import { CreateCollectionRequest, UpdateCollectionRequest } from "@bitwarden/admin-console/common";
import {
CollectionAccessDetailsResponse,
CollectionDetailsResponse,
CollectionResponse,
CreateCollectionRequest,
UpdateCollectionRequest,
} from "@bitwarden/admin-console/common";
} from "@bitwarden/common/admin-console/models/collections";
import { OrganizationConnectionType } from "../admin-console/enums";
import { OrganizationSponsorshipCreateRequest } from "../admin-console/models/request/organization/organization-sponsorship-create.request";

View File

@@ -1,9 +1,9 @@
import { CollectionAccessSelectionView } from "@bitwarden/common/admin-console/models/collections";
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service";
import { EncString } from "@bitwarden/common/key-management/crypto/models/enc-string";
import { OrgKey } from "@bitwarden/common/types/key";
import { CollectionAccessSelectionView } from "./collection-access-selection.view";
import { CollectionAccessDetailsResponse, CollectionResponse } from "./collection.response";
import { CollectionView } from "./collection.view";

View File

@@ -1,10 +1,12 @@
import { Jsonify } from "type-fest";
import {
CollectionDetailsResponse,
CollectionType,
CollectionTypes,
} from "@bitwarden/common/admin-console/models/collections";
import { CollectionId, OrganizationId } from "@bitwarden/common/types/guid";
import { CollectionType, CollectionTypes } from "./collection";
import { CollectionDetailsResponse } from "./collection.response";
export class CollectionData {
id: CollectionId;
organizationId: OrganizationId;

View File

@@ -1,9 +1,11 @@
import {
CollectionType,
CollectionTypes,
} from "@bitwarden/common/admin-console/models/collections";
import { SelectionReadOnlyResponse } from "@bitwarden/common/admin-console/models/response/selection-read-only.response";
import { BaseResponse } from "@bitwarden/common/models/response/base.response";
import { CollectionId, OrganizationId } from "@bitwarden/common/types/guid";
import { CollectionType, CollectionTypes } from "./collection";
export class CollectionResponse extends BaseResponse {
id: CollectionId;
organizationId: OrganizationId;

View File

@@ -1,3 +1,4 @@
import { CollectionView } from "@bitwarden/common/admin-console/models/collections";
import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service";
import { EncString } from "@bitwarden/common/key-management/crypto/models/enc-string";
import Domain from "@bitwarden/common/platform/models/domain/domain-base";
@@ -5,7 +6,6 @@ import { CollectionId, OrganizationId } from "@bitwarden/common/types/guid";
import { OrgKey } from "@bitwarden/common/types/key";
import { CollectionData } from "./collection.data";
import { CollectionView } from "./collection.view";
export const CollectionTypes = {
SharedCollection: 0,

View File

@@ -0,0 +1,6 @@
export * from "./collection-access-selection.view";
export * from "./collection-admin.view";
export * from "./collection.view";
export * from "./collection.response";
export * from "./collection";
export * from "./collection.data";

View File

@@ -1,8 +1,6 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
// This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop.
// eslint-disable-next-line no-restricted-imports
import { CollectionResponse } from "@bitwarden/admin-console/common";
import { CollectionResponse } from "@bitwarden/common/admin-console/models/collections";
import { BaseResponse } from "../../../models/response/base.response";
import { CipherResponse } from "../../../vault/models/response/cipher.response";

View File

@@ -0,0 +1,120 @@
import { CollectionView } from "@bitwarden/common/admin-console/models/collections";
import { CollectionId, OrganizationId } from "@bitwarden/common/types/guid";
import { TreeNode } from "@bitwarden/common/vault/models/domain/tree-node";
import { newGuid } from "@bitwarden/guid";
import { getNestedCollectionTree, getFlatCollectionTree } from "./collection-utils";
describe("CollectionUtils Service", () => {
describe("getNestedCollectionTree", () => {
it("should return collections properly sorted if provided out of order", () => {
// Arrange
const collections: CollectionView[] = [];
const parentCollection = new CollectionView({
name: "Parent",
organizationId: "orgId" as OrganizationId,
id: newGuid() as CollectionId,
});
const childCollection = new CollectionView({
name: "Parent/Child",
organizationId: "orgId" as OrganizationId,
id: newGuid() as CollectionId,
});
collections.push(childCollection);
collections.push(parentCollection);
// Act
const result = getNestedCollectionTree(collections);
// Assert
expect(result[0].node.name).toBe("Parent");
expect(result[0].children[0].node.name).toBe("Child");
});
it("should return an empty array if no collections are provided", () => {
// Arrange
const collections: CollectionView[] = [];
// Act
const result = getNestedCollectionTree(collections);
// Assert
expect(result).toEqual([]);
});
});
describe("getFlatCollectionTree", () => {
it("should flatten a tree node with no children", () => {
// Arrange
const collection = new CollectionView({
name: "Test Collection",
id: "test-id" as CollectionId,
organizationId: "orgId" as OrganizationId,
});
const treeNodes: TreeNode<CollectionView>[] = [
new TreeNode<CollectionView>(collection, {} as TreeNode<CollectionView>),
];
// Act
const result = getFlatCollectionTree(treeNodes);
// Assert
expect(result.length).toBe(1);
expect(result[0]).toBe(collection);
});
it("should flatten a tree node with children", () => {
// Arrange
const parentCollection = new CollectionView({
name: "Parent",
id: "parent-id" as CollectionId,
organizationId: "orgId" as OrganizationId,
});
const child1Collection = new CollectionView({
name: "Child 1",
id: "child1-id" as CollectionId,
organizationId: "orgId" as OrganizationId,
});
const child2Collection = new CollectionView({
name: "Child 2",
id: "child2-id" as CollectionId,
organizationId: "orgId" as OrganizationId,
});
const grandchildCollection = new CollectionView({
name: "Grandchild",
id: "grandchild-id" as CollectionId,
organizationId: "orgId" as OrganizationId,
});
const parentNode = new TreeNode<CollectionView>(
parentCollection,
{} as TreeNode<CollectionView>,
);
const child1Node = new TreeNode<CollectionView>(child1Collection, parentNode);
const child2Node = new TreeNode<CollectionView>(child2Collection, parentNode);
const grandchildNode = new TreeNode<CollectionView>(grandchildCollection, child1Node);
parentNode.children = [child1Node, child2Node];
child1Node.children = [grandchildNode];
const treeNodes: TreeNode<CollectionView>[] = [parentNode];
// Act
const result = getFlatCollectionTree(treeNodes);
// Assert
expect(result.length).toBe(4);
expect(result[0]).toBe(parentCollection);
expect(result).toContain(child1Collection);
expect(result).toContain(child2Collection);
expect(result).toContain(grandchildCollection);
});
});
});

View File

@@ -0,0 +1,87 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import {
CollectionView,
NestingDelimiter,
CollectionAdminView,
} from "@bitwarden/common/admin-console/models/collections";
import { OrganizationId } from "@bitwarden/common/types/guid";
import { TreeNode } from "@bitwarden/common/vault/models/domain/tree-node";
import { ServiceUtils } from "@bitwarden/common/vault/service-utils";
export function getNestedCollectionTree(
collections: CollectionAdminView[],
): TreeNode<CollectionAdminView>[];
export function getNestedCollectionTree(collections: CollectionView[]): TreeNode<CollectionView>[];
export function getNestedCollectionTree(
collections: (CollectionView | CollectionAdminView)[],
): TreeNode<CollectionView | CollectionAdminView>[] {
if (!collections) {
return [];
}
// Collections need to be cloned because ServiceUtils.nestedTraverse actively
// modifies the names of collections.
// These changes risk affecting collections store in StateService.
const clonedCollections: CollectionView[] | CollectionAdminView[] = collections
.sort((a, b) => a.name.localeCompare(b.name))
.map(cloneCollection);
const all: TreeNode<CollectionView | CollectionAdminView>[] = [];
const groupedByOrg = new Map<OrganizationId, (CollectionView | CollectionAdminView)[]>();
clonedCollections.map((c) => {
const key = c.organizationId;
(groupedByOrg.get(key) ?? groupedByOrg.set(key, []).get(key)!).push(c);
});
for (const group of groupedByOrg.values()) {
const nodes: TreeNode<CollectionView | CollectionAdminView>[] = [];
for (const c of group) {
const parts = c.name ? c.name.replace(/^\/+|\/+$/g, "").split(NestingDelimiter) : [];
ServiceUtils.nestedTraverse(nodes, 0, parts, c, undefined, NestingDelimiter);
}
all.push(...nodes);
}
return all;
}
export function cloneCollection(collection: CollectionView): CollectionView;
export function cloneCollection(collection: CollectionAdminView): CollectionAdminView;
export function cloneCollection(
collection: CollectionView | CollectionAdminView,
): CollectionView | CollectionAdminView {
let cloned;
if (collection instanceof CollectionAdminView) {
cloned = Object.assign(
new CollectionAdminView({ ...collection, name: collection.name }),
collection,
);
} else {
cloned = Object.assign(
new CollectionView({ ...collection, name: collection.name }),
collection,
);
}
return cloned;
}
export function getFlatCollectionTree(
nodes: TreeNode<CollectionAdminView>[],
): CollectionAdminView[];
export function getFlatCollectionTree(nodes: TreeNode<CollectionView>[]): CollectionView[];
export function getFlatCollectionTree(
nodes: TreeNode<CollectionView | CollectionAdminView>[],
): (CollectionView | CollectionAdminView)[] {
if (!nodes || nodes.length === 0) {
return [];
}
return nodes.flatMap((node) => {
if (!node.children || node.children.length === 0) {
return [node.node];
}
const children = getFlatCollectionTree(node.children);
return [node.node, ...children];
});
}

View File

@@ -0,0 +1 @@
export * from "./collection-utils";

View File

@@ -1,8 +1,9 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
// This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop.
// eslint-disable-next-line no-restricted-imports
import { Collection as CollectionDomain, CollectionView } from "@bitwarden/admin-console/common";
import {
CollectionView,
Collection as CollectionDomain,
} from "@bitwarden/common/admin-console/models/collections";
import { CollectionId } from "@bitwarden/common/types/guid";
import { CollectionExport } from "./collection.export";

View File

@@ -1,8 +1,9 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
// This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop.
// eslint-disable-next-line no-restricted-imports
import { Collection as CollectionDomain, CollectionView } from "@bitwarden/admin-console/common";
import {
CollectionView,
Collection as CollectionDomain,
} from "@bitwarden/common/admin-console/models/collections";
import { EncString } from "../../key-management/crypto/models/enc-string";
import { CollectionId, emptyGuid, OrganizationId } from "../../types/guid";

View File

@@ -4,11 +4,11 @@ import { firstValueFrom, map } from "rxjs";
// This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop.
// eslint-disable-next-line no-restricted-imports
import { CollectionService } from "@bitwarden/admin-console/common";
import {
CollectionData,
CollectionDetailsResponse,
CollectionService,
} from "@bitwarden/admin-console/common";
CollectionData,
} from "@bitwarden/common/admin-console/models/collections";
import { AccountCryptographicStateService } from "@bitwarden/common/key-management/account-cryptography/account-cryptographic-state.service";
import { SecurityStateService } from "@bitwarden/common/key-management/security-state/abstractions/security-state.service";
// This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop.

View File

@@ -1,6 +1,4 @@
// This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop.
// eslint-disable-next-line no-restricted-imports
import { CollectionDetailsResponse } from "@bitwarden/admin-console/common";
import { CollectionDetailsResponse } from "@bitwarden/common/admin-console/models/collections";
import { PolicyResponse } from "../../admin-console/models/response/policy.response";
import { UserDecryptionResponse } from "../../key-management/models/response/user-decryption.response";

View File

@@ -4,16 +4,15 @@ import { firstValueFrom, map } from "rxjs";
// This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop.
// eslint-disable-next-line no-restricted-imports
import { CreateCollectionRequest, UpdateCollectionRequest } from "@bitwarden/admin-console/common";
// This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop.
// eslint-disable-next-line no-restricted-imports
import { LogoutReason } from "@bitwarden/auth/common";
import {
CollectionAccessDetailsResponse,
CollectionDetailsResponse,
CollectionResponse,
CreateCollectionRequest,
UpdateCollectionRequest,
} from "@bitwarden/admin-console/common";
// This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop.
// eslint-disable-next-line no-restricted-imports
import { LogoutReason } from "@bitwarden/auth/common";
} from "@bitwarden/common/admin-console/models/collections";
import { ApiService as ApiServiceAbstraction } from "../abstractions/api.service";
import { OrganizationConnectionType } from "../admin-console/enums";

View File

@@ -3,8 +3,9 @@ import { Observable, firstValueFrom, of } from "rxjs";
// This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop.
// eslint-disable-next-line no-restricted-imports
import { CollectionService, CollectionView } from "@bitwarden/admin-console/common";
import { CollectionService } from "@bitwarden/admin-console/common";
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
import { CollectionView } from "@bitwarden/common/admin-console/models/collections";
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
import { Utils } from "@bitwarden/common/platform/misc/utils";
import { UserId } from "@bitwarden/common/types/guid";

View File

@@ -29,15 +29,15 @@ import { combineLatestWith, filter, map, switchMap, takeUntil } from "rxjs/opera
// This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop.
// eslint-disable-next-line no-restricted-imports
import {
CollectionService,
CollectionTypes,
CollectionView,
} from "@bitwarden/admin-console/common";
import { CollectionService } from "@bitwarden/admin-console/common";
import { JslibModule } from "@bitwarden/angular/jslib.module";
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
import { PolicyType } from "@bitwarden/common/admin-console/enums";
import {
CollectionView,
CollectionTypes,
} from "@bitwarden/common/admin-console/models/collections";
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { getUserId } from "@bitwarden/common/auth/services/account.service";

View File

@@ -2,9 +2,7 @@
// @ts-strict-ignore
import * as papa from "papaparse";
// This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop.
// eslint-disable-next-line no-restricted-imports
import { Collection, CollectionView } from "@bitwarden/admin-console/common";
import { CollectionView, Collection } from "@bitwarden/common/admin-console/models/collections";
import { normalizeExpiryYearFormat } from "@bitwarden/common/autofill/utils";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { Utils } from "@bitwarden/common/platform/misc/utils";

View File

@@ -2,9 +2,7 @@
// @ts-strict-ignore
import { filter, firstValueFrom } from "rxjs";
// This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop.
// eslint-disable-next-line no-restricted-imports
import { Collection } from "@bitwarden/admin-console/common";
import { Collection } from "@bitwarden/common/admin-console/models/collections";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service";
import { EncString } from "@bitwarden/common/key-management/crypto/models/enc-string";

View File

@@ -1,8 +1,6 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
// This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop.
// eslint-disable-next-line no-restricted-imports
import { CollectionView } from "@bitwarden/admin-console/common";
import { CollectionView } from "@bitwarden/common/admin-console/models/collections";
import { ImportResult } from "../models/import-result";

View File

@@ -1,6 +1,4 @@
// This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop.
// eslint-disable-next-line no-restricted-imports
import { CollectionView } from "@bitwarden/admin-console/common";
import { CollectionView } from "@bitwarden/common/admin-console/models/collections";
import { ImportResult } from "../models/import-result";

View File

@@ -1,6 +1,4 @@
// This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop.
// eslint-disable-next-line no-restricted-imports
import { CollectionView } from "@bitwarden/admin-console/common";
import { CollectionView } from "@bitwarden/common/admin-console/models/collections";
import { OrganizationId } from "@bitwarden/common/types/guid";
import { FieldType, SecureNoteType } from "@bitwarden/common/vault/enums";
import { FolderView } from "@bitwarden/common/vault/models/view/folder.view";

View File

@@ -1,8 +1,6 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
// This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop.
// eslint-disable-next-line no-restricted-imports
import { CollectionView } from "@bitwarden/admin-console/common";
import { CollectionView } from "@bitwarden/common/admin-console/models/collections";
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
import { FolderView } from "@bitwarden/common/vault/models/view/folder.view";

View File

@@ -1,8 +1,6 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
// This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop.
// eslint-disable-next-line no-restricted-imports
import { CollectionView } from "@bitwarden/admin-console/common";
import { CollectionView } from "@bitwarden/common/admin-console/models/collections";
import { UserId } from "@bitwarden/user-core";
export abstract class ImportCollectionServiceAbstraction {

View File

@@ -1,9 +1,6 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
// This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop.
// eslint-disable-next-line no-restricted-imports
import { CollectionView } from "@bitwarden/admin-console/common";
import { CollectionView } from "@bitwarden/common/admin-console/models/collections";
import { FolderView } from "@bitwarden/common/vault/models/view/folder.view";
import { Importer } from "../importers/importer";

View File

@@ -2,11 +2,11 @@ import { mock, MockProxy } from "jest-mock-extended";
// This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop.
// eslint-disable-next-line no-restricted-imports
import { CollectionService } from "@bitwarden/admin-console/common";
import {
CollectionService,
CollectionTypes,
CollectionView,
} from "@bitwarden/admin-console/common";
CollectionTypes,
} from "@bitwarden/common/admin-console/models/collections";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { KeyGenerationService } from "@bitwarden/common/key-management/crypto";
import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service";

View File

@@ -4,12 +4,11 @@ import { firstValueFrom, map } from "rxjs";
// This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop.
// eslint-disable-next-line no-restricted-imports
import { CollectionService, CollectionWithIdRequest } from "@bitwarden/admin-console/common";
import {
CollectionService,
CollectionWithIdRequest,
CollectionView,
CollectionTypes,
} from "@bitwarden/admin-console/common";
} from "@bitwarden/common/admin-console/models/collections";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { getUserId } from "@bitwarden/common/auth/services/account.service";
import { KeyGenerationService } from "@bitwarden/common/key-management/crypto";

View File

@@ -3,13 +3,13 @@
import * as papa from "papaparse";
import { filter, firstValueFrom, map } from "rxjs";
import { CollectionService } from "@bitwarden/admin-console/common";
import {
CollectionService,
CollectionData,
Collection,
CollectionDetailsResponse,
CollectionView,
} from "@bitwarden/admin-console/common";
CollectionDetailsResponse,
Collection,
CollectionData,
} from "@bitwarden/common/admin-console/models/collections";
import { KeyGenerationService } from "@bitwarden/common/key-management/crypto";
import { CryptoFunctionService } from "@bitwarden/common/key-management/crypto/abstractions/crypto-function.service";
import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service";

View File

@@ -0,0 +1,44 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { Observable } from "rxjs";
import {
CollectionAdminView,
CollectionView,
} from "@bitwarden/common/admin-console/models/collections";
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
import { UserId } from "@bitwarden/common/types/guid";
import { TreeNode } from "@bitwarden/common/vault/models/domain/tree-node";
import { FolderView } from "@bitwarden/common/vault/models/view/folder.view";
import {
CipherTypeFilter,
CollectionFilter,
FolderFilter,
OrganizationFilter,
} from "../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>>;
cipherTypeTree$: Observable<TreeNode<CipherTypeFilter>>;
abstract getCollectionNodeFromTree: (id: string) => Promise<TreeNode<CollectionFilter>>;
abstract setCollapsedFilterNodes: (
collapsedFilterNodes: Set<string>,
userId: UserId,
) => Promise<void>;
abstract expandOrgFilter: (userId: UserId) => Promise<void>;
abstract getOrganizationFilter: () => Observable<Organization>;
abstract setOrganizationFilter: (organization: Organization) => void;
abstract buildTypeTree: (
head: CipherTypeFilter,
array: CipherTypeFilter[],
) => Observable<TreeNode<CipherTypeFilter>>;
// TODO: Remove this from org vault when collection admin service adopts state management
abstract reloadCollections?: (collections: CollectionAdminView[]) => void;
abstract clearOrganizationFilter: () => void;
}

View File

@@ -1,6 +1,4 @@
// This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop.
// eslint-disable-next-line no-restricted-imports
import { CollectionView } from "@bitwarden/admin-console/common";
import { CollectionView } from "@bitwarden/common/admin-console/models/collections";
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
import { CipherId, CollectionId, OrganizationId } from "@bitwarden/common/types/guid";
import { CipherType } from "@bitwarden/common/vault/enums";

View File

@@ -12,14 +12,12 @@ import {
import { BehaviorSubject, of } from "rxjs";
import { action } from "storybook/actions";
// This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop.
// eslint-disable-next-line no-restricted-imports
import { CollectionView } from "@bitwarden/admin-console/common";
import { ViewCacheService } from "@bitwarden/angular/platform/view-cache";
import { NudgeStatus, NudgesService } from "@bitwarden/angular/vault";
import { AuditService } from "@bitwarden/common/abstractions/audit.service";
import { EventCollectionService } from "@bitwarden/common/abstractions/event/event-collection.service";
import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
import { CollectionView } from "@bitwarden/common/admin-console/models/collections";
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { AutofillSettingsServiceAbstraction } from "@bitwarden/common/autofill/services/autofill-settings.service";

View File

@@ -5,11 +5,13 @@ import { By } from "@angular/platform-browser";
import { mock, MockProxy } from "jest-mock-extended";
import { BehaviorSubject, of } from "rxjs";
// This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop.
// eslint-disable-next-line no-restricted-imports
import { CollectionType, CollectionTypes, CollectionView } from "@bitwarden/admin-console/common";
import { ClientType } from "@bitwarden/client-type";
import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
import {
CollectionView,
CollectionType,
CollectionTypes,
} from "@bitwarden/common/admin-console/models/collections";
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
import { Policy } from "@bitwarden/common/admin-console/models/domain/policy";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";

View File

@@ -6,13 +6,14 @@ import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
import { FormBuilder, FormControl, ReactiveFormsModule, Validators } from "@angular/forms";
import { concatMap, distinctUntilChanged, firstValueFrom, map } from "rxjs";
// This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop.
// eslint-disable-next-line no-restricted-imports
import { CollectionTypes, CollectionView } from "@bitwarden/admin-console/common";
import { JslibModule } from "@bitwarden/angular/jslib.module";
import { ClientType } from "@bitwarden/client-type";
import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
import { OrganizationUserType, PolicyType } from "@bitwarden/common/admin-console/enums";
import {
CollectionView,
CollectionTypes,
} from "@bitwarden/common/admin-console/models/collections";
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { getUserId } from "@bitwarden/common/auth/services/account.service";

View File

@@ -5,9 +5,10 @@ import { combineLatest, of, switchMap, map, catchError, from, Observable, startW
// This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop.
// eslint-disable-next-line no-restricted-imports
import { CollectionService, CollectionView } from "@bitwarden/admin-console/common";
import { CollectionService } from "@bitwarden/admin-console/common";
import { JslibModule } from "@bitwarden/angular/jslib.module";
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
import { CollectionView } from "@bitwarden/common/admin-console/models/collections";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { getUserId } from "@bitwarden/common/auth/services/account.service";
import { isCardExpired } from "@bitwarden/common/autofill/utils";

View File

@@ -4,9 +4,7 @@ import { By } from "@angular/platform-browser";
import { mock, MockProxy } from "jest-mock-extended";
import { of } from "rxjs";
// This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop.
// eslint-disable-next-line no-restricted-imports
import { CollectionView } from "@bitwarden/admin-console/common";
import { CollectionView } from "@bitwarden/common/admin-console/models/collections";
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
import { DomainSettingsService } from "@bitwarden/common/autofill/services/domain-settings.service";
import { ClientType } from "@bitwarden/common/enums";

View File

@@ -6,10 +6,12 @@ import { Component, computed, input, signal } from "@angular/core";
import { toSignal } from "@angular/core/rxjs-interop";
import { fromEvent, map, startWith } from "rxjs";
// eslint-disable-next-line no-restricted-imports
import { CollectionTypes, CollectionView } from "@bitwarden/admin-console/common";
import { JslibModule } from "@bitwarden/angular/jslib.module";
import { ClientType } from "@bitwarden/client-type";
import {
CollectionView,
CollectionTypes,
} from "@bitwarden/common/admin-console/models/collections";
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";

View File

@@ -5,12 +5,12 @@ import { of } from "rxjs";
// This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop.
// eslint-disable-next-line no-restricted-imports
import {
CollectionService,
CollectionTypes,
CollectionView,
} from "@bitwarden/admin-console/common";
import { CollectionService } from "@bitwarden/admin-console/common";
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
import {
CollectionView,
CollectionTypes,
} from "@bitwarden/common/admin-console/models/collections";
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { ProductTierType } from "@bitwarden/common/billing/enums";

View File

@@ -26,17 +26,17 @@ import {
// This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop.
// eslint-disable-next-line no-restricted-imports
import {
CollectionService,
CollectionTypes,
CollectionView,
} from "@bitwarden/admin-console/common";
import { CollectionService } from "@bitwarden/admin-console/common";
import { JslibModule } from "@bitwarden/angular/jslib.module";
import {
getOrganizationById,
OrganizationService,
} from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
import { OrganizationUserStatusType } from "@bitwarden/common/admin-console/enums";
import {
CollectionView,
CollectionTypes,
} from "@bitwarden/common/admin-console/models/collections";
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { getUserId } from "@bitwarden/common/auth/services/account.service";

View File

@@ -39,3 +39,14 @@ export * from "./abstractions/vault-items-transfer.service";
export * from "./services/default-vault-items-transfer.service";
export * from "./services/default-change-login-password.service";
export * from "./services/archive-cipher-utilities.service";
export * from "./models/vault-filter.type";
export * from "./models/vault-filter.model";
export * from "./models/routed-vault-filter.model";
export * from "./models/routed-vault-filter-bridge.model";
export * from "./models/vault-filter-section.type";
export * from "./models/filter-function";
export { VaultFilterService as VaultFilterServiceAbstraction } from "./abstractions/vault-filter.service";
export * from "./services/vault-filter.service";
export * from "./services/routed-vault-filter.service";
export * from "./services/routed-vault-filter-bridge.service";

View File

@@ -0,0 +1,231 @@
import { Unassigned } from "@bitwarden/common/admin-console/models/collections";
import { CollectionId, OrganizationId } from "@bitwarden/common/types/guid";
import { CipherType } from "@bitwarden/common/vault/enums";
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
import { createFilterFunction } from "./filter-function";
import { All } from "./routed-vault-filter.model";
describe("createFilter", () => {
describe("given a 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({ type: "favorites" });
const result = filterFunction(cipher);
expect(result).toBe(true);
});
it("should return false when filtering for trash", () => {
const filterFunction = createFilterFunction({ type: "trash" });
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({ type: "trash" });
const result = filterFunction(cipher);
expect(result).toBe(true);
});
it("should return false when filtering for favorites", () => {
const filterFunction = createFilterFunction({ type: "favorites" });
const result = filterFunction(cipher);
expect(result).toBe(false);
});
it("should return false when type is not specified in filter", () => {
const filterFunction = createFilterFunction({});
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({ type: "identity" });
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({ type: "favorites" });
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({ folderId: "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({ folderId: "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({ folderId: Unassigned });
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({
collectionId: "collectionId" as CollectionId,
organizationId: "organizationId" as OrganizationId,
});
const result = filterFunction(cipher);
expect(result).toBe(true);
});
it("should return false when filter does not match collection id", () => {
const filterFunction = createFilterFunction({
collectionId: "nonMatchingCollectionId" as CollectionId,
organizationId: "organizationId" as OrganizationId,
});
const result = filterFunction(cipher);
expect(result).toBe(false);
});
it("should return false when filter does not match organization id", () => {
const filterFunction = createFilterFunction({
organizationId: "nonMatchingOrganizationId" as OrganizationId,
});
const result = filterFunction(cipher);
expect(result).toBe(false);
});
it("should return false when filtering for my vault only", () => {
const filterFunction = createFilterFunction({ organizationId: Unassigned });
const result = filterFunction(cipher);
expect(result).toBe(false);
});
it("should return false when filtering by All Collections", () => {
const filterFunction = createFilterFunction({ collectionId: All });
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({ collectionId: Unassigned });
const result = filterFunction(cipher);
expect(result).toBe(true);
});
it("should return true when filter matches organization id", () => {
const filterFunction = createFilterFunction({
organizationId: "organizationId" as 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({ collectionId: Unassigned });
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({ organizationId: Unassigned });
const result = filterFunction(cipher);
expect(result).toBe(true);
});
});
});
function createCipher(options: Partial<CipherView> = {}) {
const cipher = new CipherView();
cipher.favorite = options.favorite ?? false;
cipher.deletedDate = options.deletedDate;
cipher.type = options.type ?? CipherType.Login;
cipher.folderId = options.folderId;
cipher.collectionIds = options.collectionIds;
cipher.organizationId = options.organizationId;
return cipher;
}

View File

@@ -0,0 +1,107 @@
import { Unassigned } from "@bitwarden/common/admin-console/models/collections";
import { CipherType } from "@bitwarden/common/vault/enums";
import {
CipherViewLike,
CipherViewLikeUtils,
} from "@bitwarden/common/vault/utils/cipher-view-like-utils";
import { All, RoutedVaultFilterModel } from "./routed-vault-filter.model";
export type FilterFunction = (cipher: CipherViewLike) => boolean;
export function createFilterFunction(
filter: RoutedVaultFilterModel,
archiveEnabled?: boolean,
): FilterFunction {
return (cipher) => {
const type = CipherViewLikeUtils.getType(cipher);
const isDeleted = CipherViewLikeUtils.isDeleted(cipher);
if (filter.type === "favorites" && !cipher.favorite) {
return false;
}
if (filter.type === "card" && type !== CipherType.Card) {
return false;
}
if (filter.type === "identity" && type !== CipherType.Identity) {
return false;
}
if (filter.type === "login" && type !== CipherType.Login) {
return false;
}
if (filter.type === "note" && type !== CipherType.SecureNote) {
return false;
}
if (filter.type === "sshKey" && type !== CipherType.SshKey) {
return false;
}
if (filter.type === "trash" && !isDeleted) {
return false;
}
// Hide trash unless explicitly selected
if (filter.type !== "trash" && isDeleted) {
return false;
}
// Archive filter logic is only applied if the feature flag is enabled
if (archiveEnabled) {
if (filter.type === "archive" && !CipherViewLikeUtils.isArchived(cipher)) {
return false;
}
if (
filter.type !== "archive" &&
filter.type !== "trash" &&
CipherViewLikeUtils.isArchived(cipher)
) {
return false;
}
}
// No folder
if (filter.folderId === Unassigned && cipher.folderId != null) {
return false;
}
// Folder
if (
filter.folderId !== undefined &&
filter.folderId !== All &&
filter.folderId !== Unassigned &&
cipher.folderId !== filter.folderId
) {
return false;
}
// All collections (top level)
if (filter.collectionId === All) {
return false;
}
// Unassigned
if (
filter.collectionId === Unassigned &&
(cipher.organizationId == null ||
(cipher.collectionIds != null && cipher.collectionIds.length > 0))
) {
return false;
}
// Collection
if (
filter.collectionId !== undefined &&
filter.collectionId !== All &&
filter.collectionId !== Unassigned &&
(cipher.collectionIds == null || !cipher.collectionIds.includes(filter.collectionId as any))
) {
return false;
}
// My Vault
if (filter.organizationId === Unassigned && cipher.organizationId != null) {
return false;
}
// Organization
else if (
filter.organizationId !== undefined &&
filter.organizationId !== Unassigned &&
cipher.organizationId !== filter.organizationId
) {
return false;
}
return true;
};
}

View File

@@ -0,0 +1,172 @@
import { Unassigned } from "@bitwarden/common/admin-console/models/collections";
import { CollectionId } from "@bitwarden/common/types/guid";
import { CipherType } from "@bitwarden/common/vault/enums";
import { TreeNode } from "@bitwarden/common/vault/models/domain/tree-node";
import { RoutedVaultFilterBridgeService } from "../services/routed-vault-filter-bridge.service";
import {
All,
isRoutedVaultFilterItemType,
RoutedVaultFilterItemType,
RoutedVaultFilterModel,
} from "./routed-vault-filter.model";
import { VaultFilter, VaultFilterFunction } from "./vault-filter.model";
import {
OrganizationFilter,
CipherTypeFilter,
FolderFilter,
CollectionFilter,
CipherStatus,
} from "./vault-filter.type";
/**
* This file is part of a layer that is used to temporary bridge between URL filtering and the old state-in-code method.
* This should be removed after we have refactored the {@link VaultItemsComponent} and introduced vertical navigation
* (which will refactor the {@link VaultFilterComponent}).
*
* This model supplies legacy code with the old state-in-code models saved as tree nodes.
* It can also receive requests to select a new tree node by using setters.
* However instead of just replacing the tree node models, it requests a URL navigation,
* thereby bridging between legacy and URL filtering.
*/
export class RoutedVaultFilterBridge implements VaultFilter {
constructor(
private routedFilter: RoutedVaultFilterModel,
private legacyFilter: VaultFilter,
private bridgeService: RoutedVaultFilterBridgeService,
) {}
get collectionBreadcrumbs(): TreeNode<CollectionFilter>[] {
return this.legacyFilter.collectionBreadcrumbs;
}
get isCollectionSelected(): boolean {
return this.legacyFilter.isCollectionSelected;
}
get isUnassignedCollectionSelected(): boolean {
return this.legacyFilter.isUnassignedCollectionSelected;
}
get isMyVaultSelected(): boolean {
return this.legacyFilter.isMyVaultSelected;
}
get selectedOrganizationNode(): TreeNode<OrganizationFilter> {
return this.legacyFilter.selectedOrganizationNode;
}
set selectedOrganizationNode(value: TreeNode<OrganizationFilter>) {
this.bridgeService.navigate({
...this.routedFilter,
organizationId: value?.node.id === "MyVault" ? Unassigned : value?.node.id,
folderId: undefined,
collectionId: undefined,
});
}
get selectedCipherTypeNode(): TreeNode<CipherTypeFilter> {
return this.legacyFilter.selectedCipherTypeNode;
}
set selectedCipherTypeNode(value: TreeNode<CipherTypeFilter>) {
let type: RoutedVaultFilterItemType | undefined;
if (value?.node.id === "AllItems" && this.routedFilter.organizationIdParamType === "path") {
type = All;
} else if (
value?.node.id === "AllItems" &&
this.routedFilter.organizationIdParamType === "query"
) {
type = undefined;
} else if (isRoutedVaultFilterItemType(value?.node.id)) {
type = value?.node.id;
}
this.bridgeService.navigate({
...this.routedFilter,
type,
folderId: undefined,
collectionId: undefined,
});
}
get selectedFolderNode(): TreeNode<FolderFilter> {
return this.legacyFilter.selectedFolderNode;
}
set selectedFolderNode(value: TreeNode<FolderFilter>) {
const folderId = value != null && value.node.id === null ? Unassigned : value?.node.id;
this.bridgeService.navigate({
...this.routedFilter,
folderId,
type: undefined,
collectionId: undefined,
});
}
get selectedCollectionNode(): TreeNode<CollectionFilter> {
return this.legacyFilter.selectedCollectionNode;
}
set selectedCollectionNode(value: TreeNode<CollectionFilter>) {
let collectionId: CollectionId | All | Unassigned | undefined;
if (value != null && value.node.id === null) {
collectionId = Unassigned;
} else if (
value?.node.id === "AllCollections" &&
this.routedFilter.organizationIdParamType === "path"
) {
collectionId = undefined;
} else if (
value?.node.id === "AllCollections" &&
this.routedFilter.organizationIdParamType === "query"
) {
collectionId = All;
} else {
collectionId = value?.node.id;
}
this.bridgeService.navigate({
...this.routedFilter,
collectionId,
type: undefined,
folderId: undefined,
});
}
get isFavorites(): boolean {
return this.legacyFilter.isFavorites;
}
get isDeleted(): boolean {
return this.legacyFilter.isDeleted;
}
get isArchived(): boolean {
return this.legacyFilter.isArchived;
}
get organizationId(): string {
return this.legacyFilter.organizationId;
}
get cipherType(): CipherType {
return this.legacyFilter.cipherType;
}
get cipherStatus(): CipherStatus {
return this.legacyFilter.cipherStatus;
}
get cipherTypeId(): string {
return this.legacyFilter.cipherTypeId;
}
get folderId(): string {
return this.legacyFilter.folderId;
}
get collectionId(): string {
return this.legacyFilter.collectionId;
}
resetFilter(): void {
this.bridgeService.navigate({
...this.routedFilter,
collectionId: undefined,
folderId: undefined,
organizationId:
this.routedFilter.organizationIdParamType === "path"
? this.routedFilter.organizationId
: undefined,
type: undefined,
});
}
resetOrganization(): void {
this.bridgeService.navigate({ ...this.routedFilter, organizationId: undefined });
}
buildFilter(): VaultFilterFunction {
return this.legacyFilter.buildFilter();
}
}

View File

@@ -0,0 +1,36 @@
import { Unassigned } from "@bitwarden/common/admin-console/models/collections";
import { CollectionId, OrganizationId } from "@bitwarden/common/types/guid";
/**
* A constant used to represent viewing "all" of a particular filter.
*/
export const All = "all";
export type All = typeof All;
// TODO: Remove `All` when moving to vertical navigation.
const itemTypes = [
"favorites",
"login",
"card",
"identity",
"note",
"sshKey",
"archive",
"trash",
All,
] as const;
export type RoutedVaultFilterItemType = (typeof itemTypes)[number];
export function isRoutedVaultFilterItemType(value: unknown): value is RoutedVaultFilterItemType {
return itemTypes.includes(value as any);
}
export interface RoutedVaultFilterModel {
collectionId?: CollectionId | All | Unassigned;
folderId?: string;
organizationId?: OrganizationId | Unassigned;
type?: RoutedVaultFilterItemType;
organizationIdParamType?: "path" | "query";
}

View File

@@ -0,0 +1,64 @@
import { Observable } from "rxjs";
import { TreeNode } from "@bitwarden/common/vault/models/domain/tree-node";
import { UnionOfValues } from "@bitwarden/common/vault/types/union-of-values";
import {
CipherTypeFilter,
CollectionFilter,
FolderFilter,
OrganizationFilter,
} from "./vault-filter.type";
export type VaultFilterType =
| OrganizationFilter
| CipherTypeFilter
| FolderFilter
| CollectionFilter;
export const VaultFilterLabel = {
OrganizationFilter: "organizationFilter",
TypeFilter: "typeFilter",
FolderFilter: "folderFilter",
CollectionFilter: "collectionFilter",
ArchiveFilter: "archiveFilter",
TrashFilter: "trashFilter",
} as const;
type VaultFilterLabel = UnionOfValues<typeof VaultFilterLabel>;
export type VaultFilterSection = {
data$: Observable<TreeNode<VaultFilterType>>;
header: {
showHeader: boolean;
isSelectable: boolean;
};
action: (filterNode: TreeNode<VaultFilterType>) => Promise<void>;
edit?: {
filterName: string;
action: (filter: VaultFilterType) => void;
};
add?: {
text: string;
route?: string;
action?: () => void;
};
options?: {
component: any;
};
divider?: boolean;
premiumOptions?: {
/** When true, the premium badge will show on the filter for non-premium users. */
showBadgeForNonPremium?: true;
/**
* Action to be called instead of applying the filter.
* Useful when the user does not have access to a filter (e.g., premium feature)
* and custom behavior is needed when invoking the filter.
*/
blockFilterAction?: () => Promise<void>;
};
};
export type VaultFilterList = {
[key in VaultFilterLabel]?: VaultFilterSection;
};

View File

@@ -0,0 +1,338 @@
import { CollectionView } from "@bitwarden/common/admin-console/models/collections";
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
import { CollectionId, OrganizationId } from "@bitwarden/common/types/guid";
import { CipherType } from "@bitwarden/common/vault/enums";
import { TreeNode } from "@bitwarden/common/vault/models/domain/tree-node";
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
import { FolderView } from "@bitwarden/common/vault/models/view/folder.view";
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" as CollectionId,
organizationId: "organizationId" as 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" as CollectionId,
organizationId: "organizationId" as 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" as OrganizationId,
}),
});
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" as OrganizationId,
}),
});
const result = filterFunction(cipher);
expect(result).toBe(false);
});
it("should return false when filtering by All Collections", () => {
const filterFunction = createFilterFunction({
selectedCollectionNode: createCollectionFilterNode({
id: "AllCollections" as CollectionId,
}),
});
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" as 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" as OrganizationId,
}),
});
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({
name: options.name ?? "Test Name",
id: options.id ?? null,
organizationId: options.organizationId ?? ("Org Id" as OrganizationId),
}) as CollectionFilter;
return new TreeNode<CollectionFilter>(collection, {} as TreeNode<CollectionFilter>);
}
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,178 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { CipherType, isCipherType } from "@bitwarden/common/vault/enums";
import { TreeNode } from "@bitwarden/common/vault/models/domain/tree-node";
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
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>;
/**
* A list of collection filters that form a chain from the organization root to currently selected collection.
* To be used when rendering a breadcrumb UI for navigating the collection hierarchy.
* Begins from the organization root and excludes the currently selected collection.
*/
get collectionBreadcrumbs(): TreeNode<CollectionFilter>[] {
if (!this.isCollectionSelected) {
return [];
}
const collections = [this.selectedCollectionNode];
while (collections[collections.length - 1].parent != undefined) {
collections.push(collections[collections.length - 1].parent);
}
return collections.slice(1).reverse();
}
/**
* The vault is filtered by a specific collection
*/
get isCollectionSelected(): boolean {
return (
this.selectedCollectionNode != null &&
this.selectedCollectionNode.node.id !== "AllCollections"
);
}
/**
* The vault is filtered by the "Unassigned" collection
*/
get isUnassignedCollectionSelected(): boolean {
return this.selectedCollectionNode != null && this.selectedCollectionNode.node.id === null;
}
/**
* The vault is filtered by the users individual vault
*/
get isMyVaultSelected(): boolean {
return this.selectedOrganizationNode?.node.id === "MyVault";
}
get isFavorites(): boolean {
return this.selectedCipherTypeNode?.node.type === "favorites";
}
get isDeleted(): boolean {
return this.selectedCipherTypeNode?.node.type === "trash" ? true : null;
}
get isArchived(): boolean {
return this.selectedCipherTypeNode?.node.type === "archive";
}
get organizationId(): string {
return this.selectedOrganizationNode?.node.id;
}
get cipherType(): CipherType {
return isCipherType(this.selectedCipherTypeNode?.node.type)
? this.selectedCipherTypeNode?.node.type
: 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.isArchived && cipherPassesFilter) {
cipherPassesFilter = cipher.isArchived;
}
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,22 @@
import { CollectionAdminView } from "@bitwarden/common/admin-console/models/collections";
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
import { CipherType } from "@bitwarden/common/vault/enums";
import { ITreeNodeObject } from "@bitwarden/common/vault/models/domain/tree-node";
import { FolderView } from "@bitwarden/common/vault/models/view/folder.view";
export type CipherStatus = "all" | "favorites" | "archive" | "trash" | CipherType;
export type CipherTypeFilter = ITreeNodeObject & { type: CipherStatus; icon: string };
export type CollectionFilter = CollectionAdminView & {
icon: string;
};
export type FolderFilter = FolderView & {
icon: string;
/**
* Full folder name.
*
* Used for when the folder `name` property is be separated into parts.
*/
fullName?: string;
};
export type OrganizationFilter = Organization & { icon: string; hideOptions?: boolean };

View File

@@ -2,11 +2,12 @@ import { mock, MockProxy } from "jest-mock-extended";
import { firstValueFrom, of, Subject } from "rxjs";
// eslint-disable-next-line no-restricted-imports
import { CollectionService, CollectionView } from "@bitwarden/admin-console/common";
import { CollectionService } from "@bitwarden/admin-console/common";
import { EventCollectionService } from "@bitwarden/common/abstractions/event/event-collection.service";
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
import { PolicyType } from "@bitwarden/common/admin-console/enums";
import { CollectionView } from "@bitwarden/common/admin-console/models/collections";
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
import { Policy } from "@bitwarden/common/admin-console/models/domain/policy";
import { EventType } from "@bitwarden/common/enums";

View File

@@ -0,0 +1,189 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { Injectable } from "@angular/core";
import { Router } from "@angular/router";
import { combineLatest, map, Observable } from "rxjs";
import { Unassigned } from "@bitwarden/common/admin-console/models/collections";
import { TreeNode } from "@bitwarden/common/vault/models/domain/tree-node";
import { ServiceUtils } from "@bitwarden/common/vault/service-utils";
import {
VaultFilterServiceAbstraction as VaultFilterService,
RoutedVaultFilterService,
RoutedVaultFilterBridge,
RoutedVaultFilterModel,
All,
VaultFilter,
CipherTypeFilter,
CollectionFilter,
FolderFilter,
OrganizationFilter,
} from "@bitwarden/vault";
/**
* This file is part of a layer that is used to temporary bridge between URL filtering and the old state-in-code method.
* This should be removed after we have refactored the {@link VaultItemsComponent} and introduced vertical navigation
* (which will refactor the {@link VaultFilterComponent}).
*
* This class listens to both the new {@link RoutedVaultFilterService} and the old {@link VaultFilterService}.
* When a new filter is emitted the service uses the ids to find the corresponding tree nodes needed for
* the old {@link VaultFilter} model. It then emits a bridge model that contains this information.
*/
@Injectable()
export class RoutedVaultFilterBridgeService {
readonly activeFilter$: Observable<VaultFilter>;
constructor(
private router: Router,
private routedVaultFilterService: RoutedVaultFilterService,
legacyVaultFilterService: VaultFilterService,
) {
this.activeFilter$ = combineLatest([
routedVaultFilterService.filter$,
legacyVaultFilterService.collectionTree$,
legacyVaultFilterService.folderTree$,
legacyVaultFilterService.organizationTree$,
legacyVaultFilterService.cipherTypeTree$,
]).pipe(
map(([filter, collectionTree, folderTree, organizationTree, cipherTypeTree]) => {
const legacyFilter = isAdminConsole(filter)
? createLegacyFilterForAdminConsole(filter, collectionTree, cipherTypeTree)
: createLegacyFilterForEndUser(
filter,
collectionTree,
folderTree,
organizationTree,
cipherTypeTree,
);
return new RoutedVaultFilterBridge(filter, legacyFilter, this);
}),
);
}
navigate(filter: RoutedVaultFilterModel) {
const [commands, extras] = this.routedVaultFilterService.createRoute(filter);
// FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling.
// eslint-disable-next-line @typescript-eslint/no-floating-promises
this.router.navigate(commands, extras);
}
}
/**
* Check if the filtering is being done as part of admin console.
* Admin console can be identified by checking if the `organizationId`
* is part of the path.
*
* @param filter Model to check if origin is admin console
* @returns true if filtering being done as part of admin console
*/
function isAdminConsole(filter: RoutedVaultFilterModel) {
return filter.organizationIdParamType === "path";
}
function createLegacyFilterForAdminConsole(
filter: RoutedVaultFilterModel,
collectionTree: TreeNode<CollectionFilter>,
cipherTypeTree: TreeNode<CipherTypeFilter>,
): VaultFilter {
const legacyFilter = new VaultFilter();
if (filter.collectionId === undefined && filter.type === undefined) {
legacyFilter.selectedCollectionNode = ServiceUtils.getTreeNodeObject(
collectionTree,
"AllCollections",
);
} else if (filter.collectionId !== undefined && filter.collectionId === Unassigned) {
legacyFilter.selectedCollectionNode = ServiceUtils.getTreeNodeObject(collectionTree, null);
} else if (filter.collectionId !== undefined) {
legacyFilter.selectedCollectionNode = ServiceUtils.getTreeNodeObject(
collectionTree,
filter.collectionId,
);
}
if (filter.collectionId === undefined && filter.type === All) {
legacyFilter.selectedCipherTypeNode = ServiceUtils.getTreeNodeObject(
cipherTypeTree,
"AllItems",
);
} else if (filter.type !== undefined && filter.type === "trash") {
legacyFilter.selectedCipherTypeNode = new TreeNode<CipherTypeFilter>(
{ id: "trash", name: "", type: "trash", icon: "" },
null,
);
} else if (filter.type !== undefined && filter.type !== "trash") {
legacyFilter.selectedCipherTypeNode = ServiceUtils.getTreeNodeObject(
cipherTypeTree,
filter.type,
);
}
return legacyFilter;
}
function createLegacyFilterForEndUser(
filter: RoutedVaultFilterModel,
collectionTree: TreeNode<CollectionFilter>,
folderTree: TreeNode<FolderFilter>,
organizationTree: TreeNode<OrganizationFilter>,
cipherTypeTree: TreeNode<CipherTypeFilter>,
): VaultFilter {
const legacyFilter = new VaultFilter();
if (filter.collectionId !== undefined && filter.collectionId === Unassigned) {
legacyFilter.selectedCollectionNode = ServiceUtils.getTreeNodeObject(collectionTree, null);
} else if (filter.collectionId !== undefined && filter.collectionId === All) {
legacyFilter.selectedCollectionNode = ServiceUtils.getTreeNodeObject(
collectionTree,
"AllCollections",
);
} else if (filter.collectionId !== undefined) {
legacyFilter.selectedCollectionNode = ServiceUtils.getTreeNodeObject(
collectionTree,
filter.collectionId,
);
}
if (filter.folderId !== undefined && filter.folderId === Unassigned) {
legacyFilter.selectedFolderNode = ServiceUtils.getTreeNodeObject(folderTree, null);
} else if (filter.folderId !== undefined && filter.folderId !== Unassigned) {
legacyFilter.selectedFolderNode = ServiceUtils.getTreeNodeObject(folderTree, filter.folderId);
}
if (filter.organizationId !== undefined && filter.organizationId === Unassigned) {
legacyFilter.selectedOrganizationNode = ServiceUtils.getTreeNodeObject(
organizationTree,
"MyVault",
);
} else if (filter.organizationId !== undefined && filter.organizationId !== Unassigned) {
legacyFilter.selectedOrganizationNode = ServiceUtils.getTreeNodeObject(
organizationTree,
filter.organizationId,
);
}
if (filter.type === undefined) {
legacyFilter.selectedCipherTypeNode = ServiceUtils.getTreeNodeObject(
cipherTypeTree,
"AllItems",
);
} else if (filter.type !== undefined && filter.type === "trash") {
legacyFilter.selectedCipherTypeNode = new TreeNode<CipherTypeFilter>(
{ id: "trash", name: "", type: "trash", icon: "" },
null,
);
} else if (filter.type !== undefined && filter.type === "archive") {
legacyFilter.selectedCipherTypeNode = new TreeNode<CipherTypeFilter>(
{ id: "archive", name: "", type: "archive", icon: "" },
null,
);
} else if (filter.type !== undefined && filter.type !== "trash") {
legacyFilter.selectedCipherTypeNode = ServiceUtils.getTreeNodeObject(
cipherTypeTree,
filter.type,
);
}
return legacyFilter;
}

View File

@@ -0,0 +1,95 @@
import { Injectable, OnDestroy, inject } from "@angular/core";
import { ActivatedRoute, NavigationExtras } from "@angular/router";
import { combineLatest, map, Observable, Subject, takeUntil } from "rxjs";
import { CollectionId, OrganizationId } from "@bitwarden/common/types/guid";
import { SafeInjectionToken } from "@bitwarden/ui-common";
import {
isRoutedVaultFilterItemType,
RoutedVaultFilterModel,
} from "../models/routed-vault-filter.model";
/**
* Injection token for the base route path used in vault filter navigation.
*/
export const VAULT_FILTER_BASE_ROUTE = new SafeInjectionToken<string>("VaultFilterBaseRoute");
/**
* This service is an abstraction layer on top of ActivatedRoute that
* encapsulates the logic of how filters are stored in the URL.
*
* The service builds and emits filter models based on URL params and
* also contains a method for generating routes to corresponding to those params.
*/
@Injectable()
export class RoutedVaultFilterService implements OnDestroy {
private onDestroy = new Subject<void>();
private baseRoute: string = inject(VAULT_FILTER_BASE_ROUTE, { optional: true }) ?? "";
/**
* Filter values extracted from the URL.
* To change the values use {@link RoutedVaultFilterService.createRoute}.
*/
filter$: Observable<RoutedVaultFilterModel>;
constructor(activatedRoute: ActivatedRoute) {
this.filter$ = combineLatest([activatedRoute.paramMap, activatedRoute.queryParamMap]).pipe(
map(([params, queryParams]) => {
const unsafeType = queryParams.get("type");
const type = isRoutedVaultFilterItemType(unsafeType) ? unsafeType : undefined;
return {
collectionId: (queryParams.get("collectionId") as CollectionId) ?? undefined,
folderId: queryParams.get("folderId") ?? undefined,
organizationId:
(params.get("organizationId") as OrganizationId) ??
(queryParams.get("organizationId") as OrganizationId) ??
undefined,
organizationIdParamType:
params.get("organizationId") != undefined ? ("path" as const) : ("query" as const),
type,
};
}),
takeUntil(this.onDestroy),
);
}
/**
* Create a route that can be used to modify filters with Router or RouterLink.
* This method is specifically built to leave other query parameters untouched,
* meaning that navigation will only affect filters and not e.g. `cipherId`.
* To subscribe to changes use {@link RoutedVaultFilterService.filter$}.
*
* Note:
* This method currently only supports changing filters that are stored
* in query parameters. This means that {@link RoutedVaultFilterModel.organizationId}
* will be ignored if {@link RoutedVaultFilterModel.organizationIdParamType}
* is set to `path`.
*
* @param filter Filter values that should be applied to the URL.
* @returns route that can be used with Router or RouterLink
*/
createRoute(filter: RoutedVaultFilterModel): [commands: any[], extras?: NavigationExtras] {
const commands: string[] = this.baseRoute ? [this.baseRoute] : [];
const extras: NavigationExtras = {
queryParams: {
collectionId: filter.collectionId ?? null,
folderId: filter.folderId ?? null,
organizationId:
filter.organizationIdParamType === "path" ? null : (filter.organizationId ?? null),
type: filter.type ?? null,
},
queryParamsHandling: "merge",
state: {
focusMainAfterNav: false,
},
};
return [commands, extras];
}
ngOnDestroy(): void {
this.onDestroy.next();
this.onDestroy.complete();
}
}

View File

@@ -0,0 +1,405 @@
import {
FakeAccountService,
mockAccountServiceWith,
} from "@bitwarden/common/../spec/fake-account-service";
import { FakeSingleUserState } from "@bitwarden/common/../spec/fake-state";
import { FakeStateProvider } from "@bitwarden/common/../spec/fake-state-provider";
import { mock, MockProxy } from "jest-mock-extended";
import { firstValueFrom, of, ReplaySubject } from "rxjs";
// eslint-disable-next-line no-restricted-imports
import { CollectionService } from "@bitwarden/admin-console/common";
import * as vaultFilterSvc from "@bitwarden/angular/vault/vault-filter/services/vault-filter.service";
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
import { PolicyType } from "@bitwarden/common/admin-console/enums";
import {
CollectionView,
CollectionType,
CollectionTypes,
} from "@bitwarden/common/admin-console/models/collections";
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { Utils } from "@bitwarden/common/platform/misc/utils";
import { CollectionId, OrganizationId, UserId } from "@bitwarden/common/types/guid";
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
import { FolderService } from "@bitwarden/common/vault/abstractions/folder/folder.service.abstraction";
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
import { FolderView } from "@bitwarden/common/vault/models/view/folder.view";
import { COLLAPSED_GROUPINGS } from "@bitwarden/common/vault/services/key-state/collapsed-groupings.state";
import { VaultFilterService } from "./vault-filter.service";
jest.mock("@bitwarden/angular/vault/vault-filter/services/vault-filter.service", () => ({
sortDefaultCollections: jest.fn((): CollectionView[] => []),
}));
describe("vault filter service", () => {
let vaultFilterService: VaultFilterService;
let organizationService: MockProxy<OrganizationService>;
let folderService: MockProxy<FolderService>;
let cipherService: MockProxy<CipherService>;
let policyService: MockProxy<PolicyService>;
let i18nService: MockProxy<I18nService>;
let collectionService: MockProxy<CollectionService>;
let organizations: ReplaySubject<Organization[]>;
let folderViews: ReplaySubject<FolderView[]>;
let collectionViews: ReplaySubject<CollectionView[]>;
let cipherViews: ReplaySubject<CipherView[]>;
let organizationDataOwnershipPolicy: ReplaySubject<boolean>;
let singleOrgPolicy: ReplaySubject<boolean>;
let stateProvider: FakeStateProvider;
let configService: MockProxy<ConfigService>;
const mockUserId = Utils.newGuid() as UserId;
let accountService: FakeAccountService;
let collapsedGroupingsState: FakeSingleUserState<string[]>;
beforeEach(() => {
organizationService = mock<OrganizationService>();
folderService = mock<FolderService>();
cipherService = mock<CipherService>();
policyService = mock<PolicyService>();
i18nService = mock<I18nService>();
accountService = mockAccountServiceWith(mockUserId);
stateProvider = new FakeStateProvider(accountService);
i18nService.collator = new Intl.Collator("en-US");
collectionService = mock<CollectionService>();
configService = mock<ConfigService>();
organizations = new ReplaySubject<Organization[]>(1);
folderViews = new ReplaySubject<FolderView[]>(1);
collectionViews = new ReplaySubject<CollectionView[]>(1);
cipherViews = new ReplaySubject<CipherView[]>(1);
organizationDataOwnershipPolicy = new ReplaySubject<boolean>(1);
singleOrgPolicy = new ReplaySubject<boolean>(1);
configService.getFeatureFlag$.mockReturnValue(of(true));
organizationService.memberOrganizations$.mockReturnValue(organizations);
folderService.folderViews$.mockReturnValue(folderViews);
collectionService.decryptedCollections$.mockReturnValue(collectionViews);
policyService.policyAppliesToUser$
.calledWith(PolicyType.OrganizationDataOwnership, mockUserId)
.mockReturnValue(organizationDataOwnershipPolicy);
policyService.policyAppliesToUser$
.calledWith(PolicyType.SingleOrg, mockUserId)
.mockReturnValue(singleOrgPolicy);
cipherService.cipherListViews$.mockReturnValue(cipherViews);
vaultFilterService = new VaultFilterService(
organizationService,
folderService,
cipherService,
policyService,
i18nService,
stateProvider,
collectionService,
accountService,
);
collapsedGroupingsState = stateProvider.singleUser.getFake(mockUserId, COLLAPSED_GROUPINGS);
organizations.next([]);
});
describe("collapsed filter nodes", () => {
const nodes = new Set(["1", "2"]);
it("should update the collapsedFilterNodes$", async () => {
await vaultFilterService.setCollapsedFilterNodes(nodes, mockUserId);
const collapsedGroupingsState = stateProvider.singleUser.getFake(
mockUserId,
COLLAPSED_GROUPINGS,
);
expect(await firstValueFrom(collapsedGroupingsState.state$)).toEqual(Array.from(nodes));
expect(collapsedGroupingsState.nextMock).toHaveBeenCalledWith(Array.from(nodes));
});
it("loads from state on initialization", async () => {
collapsedGroupingsState.nextState(["1", "2"]);
await expect(firstValueFrom(vaultFilterService.collapsedFilterNodes$)).resolves.toEqual(
nodes,
);
});
});
describe("organizations", () => {
beforeEach(() => {
const storedOrgs = [
createOrganization("1" as OrganizationId, "org1"),
createOrganization("2" as OrganizationId, "org2"),
];
organizations.next(storedOrgs);
organizationDataOwnershipPolicy.next(false);
singleOrgPolicy.next(false);
});
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 organization data ownership policy is enabled", async () => {
organizationDataOwnershipPolicy.next(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 () => {
singleOrgPolicy.next(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 organization data ownership policies are enabled", async () => {
singleOrgPolicy.next(true);
organizationDataOwnershipPolicy.next(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" as OrganizationId, "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"),
];
cipherViews.next(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"),
]);
});
it("returns current organization", () => {
vaultFilterService.getOrganizationFilter().subscribe((org) => {
expect(org.id).toEqual("org test id");
expect(org.identifier).toEqual("Test Org");
});
});
});
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);
cipherViews.next([]);
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" as OrganizationId, "Test Org"),
);
const storedCollections = [
createCollectionView("1", "collection 1", "org test id"),
createCollectionView("2", "collection 2", "non matching org id"),
];
collectionViews.next(storedCollections);
await expect(firstValueFrom(vaultFilterService.filteredCollections$)).resolves.toEqual([
createCollectionView("1", "collection 1", "org test id"),
]);
});
});
describe("collection tree", () => {
it("returns tree with children", async () => {
const storedCollections = [
createCollectionView("id-1", "Collection 1", "org test id"),
createCollectionView("id-2", "Collection 1/Collection 2", "org test id"),
createCollectionView("id-3", "Collection 1/Collection 3", "org test id"),
];
collectionViews.next(storedCollections);
collectionService.groupByOrganization.mockReturnValue(
new Map([["org test id" as OrganizationId, storedCollections]]),
);
const result = await firstValueFrom(vaultFilterService.collectionTree$);
expect(result.children.map((c) => c.node.id)).toEqual(["id-1"]);
expect(result.children[0].children.map((c) => c.node.id)).toEqual(["id-2", "id-3"]);
});
it("returns tree where non-existing collections are excluded from children", async () => {
const storedCollections = [
createCollectionView("id-1", "Collection 1", "org test id"),
createCollectionView("id-3", "Collection 1/Collection 2/Collection 3", "org test id"),
];
collectionViews.next(storedCollections);
collectionService.groupByOrganization.mockReturnValue(
new Map([["org test id" as OrganizationId, storedCollections]]),
);
const result = await firstValueFrom(vaultFilterService.collectionTree$);
expect(result.children.map((c) => c.node.id)).toEqual(["id-1"]);
expect(result.children[0].children.map((c) => c.node.id)).toEqual(["id-3"]);
expect(result.children[0].children[0].node.name).toBe("Collection 2/Collection 3");
});
it("returns tree with parents", async () => {
const storedCollections = [
createCollectionView("id-1", "Collection 1", "org test id"),
createCollectionView("id-2", "Collection 1/Collection 2", "org test id"),
createCollectionView("id-3", "Collection 1/Collection 2/Collection 3", "org test id"),
createCollectionView("id-4", "Collection 1/Collection 4", "org test id"),
];
collectionViews.next(storedCollections);
collectionService.groupByOrganization.mockReturnValue(
new Map([["org test id" as OrganizationId, storedCollections]]),
);
const result = await firstValueFrom(vaultFilterService.collectionTree$);
const c1 = result.children[0];
const c2 = c1.children[0];
const c3 = c2.children[0];
const c4 = c1.children[1];
expect(c2.parent.node.id).toEqual("id-1");
expect(c3.parent.node.id).toEqual("id-2");
expect(c4.parent.node.id).toEqual("id-1");
});
it("returns tree where non-existing collections are excluded from parents", async () => {
const storedCollections = [
createCollectionView("id-1", "Collection 1", "org test id"),
createCollectionView("id-3", "Collection 1/Collection 2/Collection 3", "org test id"),
];
collectionViews.next(storedCollections);
collectionService.groupByOrganization.mockReturnValue(
new Map([["org test id" as OrganizationId, storedCollections]]),
);
const result = await firstValueFrom(vaultFilterService.collectionTree$);
const c1 = result.children[0];
const c3 = c1.children[0];
expect(c3.parent.node.id).toEqual("id-1");
});
it("calls sortDefaultCollections with the correct args", async () => {
const storedOrgs = [
createOrganization("id-defaultOrg1" as OrganizationId, "org1"),
createOrganization("id-defaultOrg2" as OrganizationId, "org2"),
];
organizations.next(storedOrgs);
const storedCollections = [
createCollectionView("id-2", "Collection 2", "org test id"),
createCollectionView("id-1", "Collection 1", "org test id"),
createCollectionView(
"id-3",
"Default User Collection - Org 2",
"id-defaultOrg2",
CollectionTypes.DefaultUserCollection,
),
createCollectionView(
"id-4",
"Default User Collection - Org 1",
"id-defaultOrg1",
CollectionTypes.DefaultUserCollection,
),
];
collectionViews.next(storedCollections);
collectionService.groupByOrganization.mockReturnValue(
new Map([["org test id" as OrganizationId, storedCollections]]),
);
await firstValueFrom(vaultFilterService.collectionTree$);
expect(vaultFilterSvc.sortDefaultCollections).toHaveBeenCalledWith(
storedCollections,
storedOrgs,
i18nService.collator,
);
});
});
});
function createOrganization(id: OrganizationId, name: string) {
const org = new Organization();
org.id = id;
org.name = name;
org.identifier = name;
org.isMember = true;
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,
type?: CollectionType,
): CollectionView {
const collection = new CollectionView({
id: id as CollectionId,
name,
organizationId: orgId as OrganizationId,
});
if (type) {
collection.type = type;
}
return collection;
}
});

View File

@@ -0,0 +1,363 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { Injectable } from "@angular/core";
import {
BehaviorSubject,
combineLatest,
filter,
firstValueFrom,
map,
Observable,
of,
switchMap,
} from "rxjs";
// eslint-disable-next-line no-restricted-imports
import { CollectionService } from "@bitwarden/admin-console/common";
import { sortDefaultCollections } from "@bitwarden/angular/vault/vault-filter/services/vault-filter.service";
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
import { PolicyType } from "@bitwarden/common/admin-console/enums";
import {
CollectionView,
CollectionTypes,
} from "@bitwarden/common/admin-console/models/collections";
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
import { cloneCollection } from "@bitwarden/common/admin-console/utils/collection-utils";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { getUserId } from "@bitwarden/common/auth/services/account.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { SingleUserState, StateProvider } from "@bitwarden/common/platform/state";
import { OrganizationId, UserId } from "@bitwarden/common/types/guid";
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
import { FolderService } from "@bitwarden/common/vault/abstractions/folder/folder.service.abstraction";
import { CipherType } from "@bitwarden/common/vault/enums";
import { TreeNode } from "@bitwarden/common/vault/models/domain/tree-node";
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
import { FolderView } from "@bitwarden/common/vault/models/view/folder.view";
import { ServiceUtils } from "@bitwarden/common/vault/service-utils";
import { COLLAPSED_GROUPINGS } from "@bitwarden/common/vault/services/key-state/collapsed-groupings.state";
import { CipherListView } from "@bitwarden/sdk-internal";
import {
VaultFilterServiceAbstraction,
CipherTypeFilter,
CollectionFilter,
FolderFilter,
OrganizationFilter,
} from "@bitwarden/vault";
const NestingDelimiter = "/";
@Injectable()
export class VaultFilterService implements VaultFilterServiceAbstraction {
protected activeUserId$ = this.accountService.activeAccount$.pipe(getUserId);
memberOrganizations$ = this.activeUserId$.pipe(
switchMap((id) => this.organizationService.memberOrganizations$(id)),
);
collapsedFilterNodes$ = this.activeUserId$.pipe(
switchMap((id) => this.collapsedGroupingsState(id).state$),
map((state) => new Set(state)),
);
organizationTree$: Observable<TreeNode<OrganizationFilter>> = combineLatest([
this.memberOrganizations$,
this.activeUserId$.pipe(
switchMap((userId) => this.policyService.policyAppliesToUser$(PolicyType.SingleOrg, userId)),
),
this.activeUserId$.pipe(
switchMap((userId) =>
this.policyService.policyAppliesToUser$(PolicyType.OrganizationDataOwnership, userId),
),
),
]).pipe(
switchMap(([orgs, singleOrgPolicy, organizationDataOwnershipPolicy]) =>
this.buildOrganizationTree(orgs, singleOrgPolicy, organizationDataOwnershipPolicy),
),
);
protected _organizationFilter = new BehaviorSubject<Organization>(null);
filteredFolders$: Observable<FolderView[]> = this.activeUserId$.pipe(
switchMap((userId) =>
combineLatest([
this.folderService.folderViews$(userId),
this.cipherService.cipherListViews$(userId),
this._organizationFilter,
]),
),
filter(([folders, ciphers, org]) => !!ciphers), // ciphers may be null, meaning decryption is in progress. Ignore this emission
switchMap(([folders, ciphers, org]) => {
return this.filterFolders(folders, ciphers, org);
}),
);
folderTree$: Observable<TreeNode<FolderFilter>> = this.filteredFolders$.pipe(
map((folders) => this.buildFolderTree(folders)),
);
filteredCollections$: Observable<CollectionView[]> = combineLatest([
this.accountService.activeAccount$.pipe(
getUserId,
switchMap((userId) => this.collectionService.decryptedCollections$(userId)),
),
this._organizationFilter,
]).pipe(switchMap(([collections, org]) => this.filterCollections(collections, org)));
collectionTree$: Observable<TreeNode<CollectionFilter>> = combineLatest([
this.filteredCollections$,
this.memberOrganizations$,
]).pipe(
map(([collections, organizations]) => this.buildCollectionTree(collections, organizations)),
);
cipherTypeTree$: Observable<TreeNode<CipherTypeFilter>> = this.buildCipherTypeTree();
private collapsedGroupingsState(userId: UserId): SingleUserState<string[]> {
return this.stateProvider.getUser(userId, COLLAPSED_GROUPINGS);
}
constructor(
protected organizationService: OrganizationService,
protected folderService: FolderService,
protected cipherService: CipherService,
protected policyService: PolicyService,
protected i18nService: I18nService,
protected stateProvider: StateProvider,
protected collectionService: CollectionService,
protected accountService: AccountService,
) {}
async getCollectionNodeFromTree(id: string) {
const collections = await firstValueFrom(this.collectionTree$);
return ServiceUtils.getTreeNodeObject(collections, id) as TreeNode<CollectionFilter>;
}
async setCollapsedFilterNodes(collapsedFilterNodes: Set<string>, userId: UserId): Promise<void> {
await this.collapsedGroupingsState(userId).update(() => Array.from(collapsedFilterNodes));
}
protected async getCollapsedFilterNodes(): Promise<Set<string>> {
return await firstValueFrom(this.collapsedFilterNodes$);
}
getOrganizationFilter() {
return this._organizationFilter;
}
clearOrganizationFilter() {
this._organizationFilter.next(null);
}
setOrganizationFilter(organization: Organization) {
if (organization?.id != "AllVaults") {
this._organizationFilter.next(organization);
} else {
this._organizationFilter.next(null);
}
}
async expandOrgFilter(userId: UserId) {
const collapsedFilterNodes = await firstValueFrom(this.collapsedFilterNodes$);
if (!collapsedFilterNodes.has("AllVaults")) {
return;
}
collapsedFilterNodes.delete("AllVaults");
await this.setCollapsedFilterNodes(collapsedFilterNodes, userId);
}
protected async buildOrganizationTree(
orgs: Organization[],
singleOrgPolicy: boolean,
organizationDataOwnershipPolicy: boolean,
): Promise<TreeNode<OrganizationFilter>> {
const headNode = this.getOrganizationFilterHead();
if (!organizationDataOwnershipPolicy) {
const myVaultNode = this.getOrganizationFilterMyVault();
headNode.children.push(myVaultNode);
}
if (singleOrgPolicy) {
orgs = orgs.slice(0, 1);
}
if (orgs) {
const orgNodes: TreeNode<OrganizationFilter>[] = [];
orgs.forEach((org) => {
const orgCopy = org as OrganizationFilter;
orgCopy.icon = "bwi-business";
const node = new TreeNode<OrganizationFilter>(orgCopy, headNode, orgCopy.name);
orgNodes.push(node);
});
// Sort organization nodes, then add them to the list after 'My Vault' and 'All Vaults' if present
orgNodes.sort((a, b) => a.node.name.localeCompare(b.node.name));
headNode.children.push(...orgNodes);
}
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" as OrganizationId;
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, headNode, 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[],
orgs?: Organization[],
): TreeNode<CollectionFilter> {
const headNode = this.getCollectionFilterHead();
if (!collections) {
return headNode;
}
const all: TreeNode<CollectionFilter>[] = [];
collections = sortDefaultCollections(collections, orgs, this.i18nService.collator);
const groupedByOrg = this.collectionService.groupByOrganization(collections);
for (const group of groupedByOrg.values()) {
const nodes: TreeNode<CollectionFilter>[] = [];
for (const c of group) {
const collectionCopy = cloneCollection(
new CollectionView({ ...c, name: c.name }),
) as CollectionFilter;
collectionCopy.icon =
c.type === CollectionTypes.DefaultUserCollection ? "bwi-user" : "bwi-collection-shared";
const parts = c.name ? c.name.replace(/^\/+|\/+$/g, "").split(NestingDelimiter) : [];
ServiceUtils.nestedTraverse(nodes, 0, parts, collectionCopy, undefined, NestingDelimiter);
}
all.push(...nodes);
}
all.forEach((n) => {
n.parent = headNode;
headNode.children.push(n);
});
return headNode;
}
protected getCollectionFilterHead(): TreeNode<CollectionFilter> {
const head = CollectionView.vaultFilterHead() as CollectionFilter;
return new TreeNode<CollectionFilter>(head, null, "collections", "AllCollections");
}
protected async filterFolders(
storedFolders: FolderView[],
ciphers: CipherView[] | CipherListView[],
org?: Organization,
): Promise<FolderView[]> {
// If no org or "My Vault" is selected, show all folders
if (org?.id == null || org?.id == "MyVault") {
return storedFolders;
}
// Otherwise, show only folders that have ciphers from the selected org and the "no folder" folder
const orgCiphers = ciphers.filter((c) => c.organizationId == org?.id);
return storedFolders.filter(
(f) => orgCiphers.some((oc) => oc.folderId == f.id) || f.id == null,
);
}
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";
folderCopy.fullName = f.name; // save full folder name before separating it into parts
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;
headNode.children.push(n);
});
return headNode;
}
protected getFolderFilterHead(): TreeNode<FolderFilter> {
const head = new FolderView() as FolderFilter;
return new TreeNode<FolderFilter>(head, null, "folders", "AllFolders");
}
protected buildCipherTypeTree(): Observable<TreeNode<CipherTypeFilter>> {
const allTypeFilters: CipherTypeFilter[] = [
{
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",
},
{
id: "sshKey",
name: this.i18nService.t("typeSshKey"),
type: CipherType.SshKey,
icon: "bwi-key",
},
];
return this.buildTypeTree(
{ id: "AllItems", name: "allItems", type: "all", icon: "" },
allTypeFilters,
);
}
}