mirror of
https://github.com/bitwarden/browser
synced 2026-02-18 18:33:50 +00:00
Merge branch 'main' into vault/pm-27632/sdk-cipher-ops
This commit is contained in:
44
libs/vault/src/abstractions/vault-filter.service.ts
Normal file
44
libs/vault/src/abstractions/vault-filter.service.ts
Normal 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;
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -153,12 +153,12 @@ describe("UriOptionComponent", () => {
|
||||
component.writeValue({ uri: "https://example.com", matchDetection: UriMatchStrategy.Exact });
|
||||
fixture.detectChanges();
|
||||
expect(getToggleMatchDetectionBtn().getAttribute("aria-label")).toBe(
|
||||
"showMatchDetection https://example.com",
|
||||
"showMatchDetectionNoPlaceholder",
|
||||
);
|
||||
getToggleMatchDetectionBtn().click();
|
||||
fixture.detectChanges();
|
||||
expect(getToggleMatchDetectionBtn().getAttribute("aria-label")).toBe(
|
||||
"hideMatchDetection https://example.com",
|
||||
"hideMatchDetectionNoPlaceholder",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -165,9 +165,11 @@ export class UriOptionComponent implements ControlValueAccessor {
|
||||
}
|
||||
|
||||
protected get toggleTitle() {
|
||||
return this.showMatchDetection
|
||||
? this.i18nService.t("hideMatchDetection", this.uriForm.value.uri)
|
||||
: this.i18nService.t("showMatchDetection", this.uriForm.value.uri);
|
||||
return this.i18nService.t(
|
||||
this.showMatchDetection
|
||||
? "hideMatchDetectionNoPlaceholder"
|
||||
: "showMatchDetectionNoPlaceholder",
|
||||
);
|
||||
}
|
||||
|
||||
// NG_VALUE_ACCESSOR implementation
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
<span slot="secondary" data-testid="file-size">{{ attachment.sizeName }}</span>
|
||||
</bit-item-content>
|
||||
<ng-container slot="end">
|
||||
<bit-item-action class="tw-pr-4">
|
||||
<bit-item-action class="tw-pr-4 [@media(min-width:650px)]:tw-pr-6">
|
||||
<app-download-attachment
|
||||
[admin]="admin"
|
||||
[cipher]="cipher"
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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";
|
||||
|
||||
231
libs/vault/src/models/filter-function.spec.ts
Normal file
231
libs/vault/src/models/filter-function.spec.ts
Normal 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;
|
||||
}
|
||||
107
libs/vault/src/models/filter-function.ts
Normal file
107
libs/vault/src/models/filter-function.ts
Normal 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;
|
||||
};
|
||||
}
|
||||
172
libs/vault/src/models/routed-vault-filter-bridge.model.ts
Normal file
172
libs/vault/src/models/routed-vault-filter-bridge.model.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
36
libs/vault/src/models/routed-vault-filter.model.ts
Normal file
36
libs/vault/src/models/routed-vault-filter.model.ts
Normal 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";
|
||||
}
|
||||
64
libs/vault/src/models/vault-filter-section.type.ts
Normal file
64
libs/vault/src/models/vault-filter-section.type.ts
Normal 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;
|
||||
};
|
||||
338
libs/vault/src/models/vault-filter.model.spec.ts
Normal file
338
libs/vault/src/models/vault-filter.model.spec.ts
Normal 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;
|
||||
}
|
||||
178
libs/vault/src/models/vault-filter.model.ts
Normal file
178
libs/vault/src/models/vault-filter.model.ts
Normal 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;
|
||||
};
|
||||
}
|
||||
}
|
||||
22
libs/vault/src/models/vault-filter.type.ts
Normal file
22
libs/vault/src/models/vault-filter.type.ts
Normal 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 };
|
||||
@@ -120,5 +120,19 @@ describe("ArchiveCipherUtilitiesService", () => {
|
||||
message: "errorOccurred",
|
||||
});
|
||||
});
|
||||
|
||||
it("calls password reprompt check when unarchiving", async () => {
|
||||
await service.unarchiveCipher(mockCipher);
|
||||
|
||||
expect(passwordRepromptService.passwordRepromptCheck).toHaveBeenCalledWith(mockCipher);
|
||||
});
|
||||
|
||||
it("returns early when password reprompt fails on unarchive", async () => {
|
||||
passwordRepromptService.passwordRepromptCheck.mockResolvedValue(false);
|
||||
|
||||
await service.unarchiveCipher(mockCipher);
|
||||
|
||||
expect(cipherArchiveService.unarchiveWithServer).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -41,7 +41,8 @@ export class ArchiveCipherUtilitiesService {
|
||||
|
||||
const confirmed = await this.dialogService.openSimpleDialog({
|
||||
title: { key: "archiveItem" },
|
||||
content: { key: "archiveItemConfirmDesc" },
|
||||
content: { key: "archiveItemDialogContent" },
|
||||
acceptButtonText: { key: "archiveVerb" },
|
||||
type: "info",
|
||||
});
|
||||
|
||||
@@ -73,7 +74,14 @@ export class ArchiveCipherUtilitiesService {
|
||||
* @param cipher The cipher to unarchive
|
||||
* @returns The unarchived cipher on success, or undefined on failure
|
||||
*/
|
||||
async unarchiveCipher(cipher: CipherView) {
|
||||
async unarchiveCipher(cipher: CipherView, skipReprompt = false) {
|
||||
if (!skipReprompt) {
|
||||
const repromptPassed = await this.passwordRepromptService.passwordRepromptCheck(cipher);
|
||||
if (!repromptPassed) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const userId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId));
|
||||
try {
|
||||
const cipherResponse = await this.cipherArchiveService.unarchiveWithServer(
|
||||
|
||||
@@ -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, OrganizationUserApiService } 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";
|
||||
@@ -41,6 +42,7 @@ describe("DefaultVaultItemsTransferService", () => {
|
||||
let mockToastService: MockProxy<ToastService>;
|
||||
let mockEventCollectionService: MockProxy<EventCollectionService>;
|
||||
let mockConfigService: MockProxy<ConfigService>;
|
||||
let mockOrganizationUserApiService: MockProxy<OrganizationUserApiService>;
|
||||
|
||||
const userId = "user-id" as UserId;
|
||||
const organizationId = "org-id" as OrganizationId;
|
||||
@@ -76,6 +78,7 @@ describe("DefaultVaultItemsTransferService", () => {
|
||||
mockToastService = mock<ToastService>();
|
||||
mockEventCollectionService = mock<EventCollectionService>();
|
||||
mockConfigService = mock<ConfigService>();
|
||||
mockOrganizationUserApiService = mock<OrganizationUserApiService>();
|
||||
|
||||
mockI18nService.t.mockImplementation((key) => key);
|
||||
transferInProgressValues = [];
|
||||
@@ -91,6 +94,7 @@ describe("DefaultVaultItemsTransferService", () => {
|
||||
mockToastService,
|
||||
mockEventCollectionService,
|
||||
mockConfigService,
|
||||
mockOrganizationUserApiService,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -631,9 +635,15 @@ describe("DefaultVaultItemsTransferService", () => {
|
||||
mockDialogService.open
|
||||
.mockReturnValueOnce(createMockDialogRef(TransferItemsDialogResult.Declined))
|
||||
.mockReturnValueOnce(createMockDialogRef(LeaveConfirmationDialogResult.Confirmed));
|
||||
mockOrganizationUserApiService.revokeSelf.mockResolvedValue(undefined);
|
||||
|
||||
await service.enforceOrganizationDataOwnership(userId);
|
||||
|
||||
expect(mockOrganizationUserApiService.revokeSelf).toHaveBeenCalledWith(organizationId);
|
||||
expect(mockToastService.showToast).toHaveBeenCalledWith({
|
||||
variant: "success",
|
||||
message: "leftOrganization",
|
||||
});
|
||||
expect(mockCipherService.shareManyWithServer).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
} from "rxjs";
|
||||
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { CollectionService } from "@bitwarden/admin-console/common";
|
||||
import { CollectionService, OrganizationUserApiService } 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";
|
||||
@@ -53,6 +53,7 @@ export class DefaultVaultItemsTransferService implements VaultItemsTransferServi
|
||||
private toastService: ToastService,
|
||||
private eventCollectionService: EventCollectionService,
|
||||
private configService: ConfigService,
|
||||
private organizationUserApiService: OrganizationUserApiService,
|
||||
) {}
|
||||
|
||||
private _transferInProgressSubject = new BehaviorSubject(false);
|
||||
@@ -162,7 +163,12 @@ export class DefaultVaultItemsTransferService implements VaultItemsTransferServi
|
||||
);
|
||||
|
||||
if (!userAcceptedTransfer) {
|
||||
// TODO: Revoke user from organization if they decline migration and show toast PM-29465
|
||||
await this.organizationUserApiService.revokeSelf(migrationInfo.enforcingOrganization.id);
|
||||
|
||||
this.toastService.showToast({
|
||||
variant: "success",
|
||||
message: this.i18nService.t("leftOrganization"),
|
||||
});
|
||||
|
||||
await this.eventCollectionService.collect(
|
||||
EventType.Organization_ItemOrganization_Declined,
|
||||
|
||||
189
libs/vault/src/services/routed-vault-filter-bridge.service.ts
Normal file
189
libs/vault/src/services/routed-vault-filter-bridge.service.ts
Normal 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;
|
||||
}
|
||||
95
libs/vault/src/services/routed-vault-filter.service.ts
Normal file
95
libs/vault/src/services/routed-vault-filter.service.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
405
libs/vault/src/services/vault-filter.service.spec.ts
Normal file
405
libs/vault/src/services/vault-filter.service.spec.ts
Normal 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;
|
||||
}
|
||||
});
|
||||
363
libs/vault/src/services/vault-filter.service.ts
Normal file
363
libs/vault/src/services/vault-filter.service.ts
Normal 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user