diff --git a/apps/web/src/app/admin-console/organizations/collections/vault-filter/vault-filter.component.ts b/apps/web/src/app/admin-console/organizations/collections/vault-filter/vault-filter.component.ts index f7d7acfdc2d..ff6ec9af0af 100644 --- a/apps/web/src/app/admin-console/organizations/collections/vault-filter/vault-filter.component.ts +++ b/apps/web/src/app/admin-console/organizations/collections/vault-filter/vault-filter.component.ts @@ -12,6 +12,7 @@ import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.servic import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { TreeNode } from "@bitwarden/common/vault/models/domain/tree-node"; import { DialogService, ToastService } from "@bitwarden/components"; +import { RestrictedItemTypesService } from "@bitwarden/vault"; import { VaultFilterComponent as BaseVaultFilterComponent } from "../../../../vault/individual-vault/vault-filter/components/vault-filter.component"; import { VaultFilterService } from "../../../../vault/individual-vault/vault-filter/services/abstractions/vault-filter.service"; @@ -51,6 +52,7 @@ export class VaultFilterComponent protected dialogService: DialogService, protected configService: ConfigService, protected accountService: AccountService, + protected restrictedItemTypesService: RestrictedItemTypesService, ) { super( vaultFilterService, @@ -62,6 +64,7 @@ export class VaultFilterComponent dialogService, configService, accountService, + restrictedItemTypesService, ); } diff --git a/apps/web/src/app/admin-console/organizations/policies/restricted-item-types.component.ts b/apps/web/src/app/admin-console/organizations/policies/restricted-item-types.component.ts index 8dd8720a220..406014973f0 100644 --- a/apps/web/src/app/admin-console/organizations/policies/restricted-item-types.component.ts +++ b/apps/web/src/app/admin-console/organizations/policies/restricted-item-types.component.ts @@ -7,7 +7,7 @@ import { BasePolicy, BasePolicyComponent } from "./base-policy.component"; export class RestrictedItemTypesPolicy extends BasePolicy { name = "restrictedItemTypesPolicy"; description = "restrictedItemTypesPolicyDesc"; - type = PolicyType.RestrictedItemTypesPolicy; + type = PolicyType.RestrictedItemTypes; component = RestrictedItemTypesPolicyComponent; } diff --git a/apps/web/src/app/vault/components/vault-items/vault-items.component.ts b/apps/web/src/app/vault/components/vault-items/vault-items.component.ts index 9679f0879b9..9d94fb044b5 100644 --- a/apps/web/src/app/vault/components/vault-items/vault-items.component.ts +++ b/apps/web/src/app/vault/components/vault-items/vault-items.component.ts @@ -342,8 +342,6 @@ export class VaultItemsComponent { const ciphers: VaultItem[] = this.ciphers.map((cipher) => ({ cipher })); const items: VaultItem[] = [].concat(collections).concat(ciphers); - this.selection.clear(); - // All ciphers are selectable, collections only if they can be edited or deleted this.editableItems = items.filter( (item) => diff --git a/apps/web/src/app/vault/components/vault-items/vault-items.stories.ts b/apps/web/src/app/vault/components/vault-items/vault-items.stories.ts index 55807ed855f..e2c6f204d72 100644 --- a/apps/web/src/app/vault/components/vault-items/vault-items.stories.ts +++ b/apps/web/src/app/vault/components/vault-items/vault-items.stories.ts @@ -29,6 +29,7 @@ import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; import { LoginUriView } from "@bitwarden/common/vault/models/view/login-uri.view"; import { LoginView } from "@bitwarden/common/vault/models/view/login.view"; import { CipherAuthorizationService } from "@bitwarden/common/vault/services/cipher-authorization.service"; +import { RestrictedItemTypesService } from "@bitwarden/vault"; import { GroupView } from "../../../admin-console/organizations/core"; import { PreloadedEnglishI18nModule } from "../../../core/tests"; @@ -125,6 +126,12 @@ export default { }, }, }, + { + provide: RestrictedItemTypesService, + useValue: { + restricted$: of([]), // No restricted item types for this story + }, + }, ], }), applicationConfig({ diff --git a/apps/web/src/app/vault/individual-vault/vault-filter/components/vault-filter.component.ts b/apps/web/src/app/vault/individual-vault/vault-filter/components/vault-filter.component.ts index 6b974296f21..d21896e26fe 100644 --- a/apps/web/src/app/vault/individual-vault/vault-filter/components/vault-filter.component.ts +++ b/apps/web/src/app/vault/individual-vault/vault-filter/components/vault-filter.component.ts @@ -1,8 +1,15 @@ -// FIXME: Update this file to be type safe and remove this and next line -// @ts-strict-ignore import { Component, EventEmitter, inject, Input, OnDestroy, OnInit, Output } from "@angular/core"; import { Router } from "@angular/router"; -import { firstValueFrom, merge, Subject, switchMap, takeUntil } from "rxjs"; +import { + distinctUntilChanged, + firstValueFrom, + map, + merge, + shareReplay, + Subject, + switchMap, + takeUntil, +} from "rxjs"; import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; import { PolicyType } from "@bitwarden/common/admin-console/enums"; @@ -16,6 +23,7 @@ import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/pl import { CipherType } from "@bitwarden/common/vault/enums"; import { TreeNode } from "@bitwarden/common/vault/models/domain/tree-node"; import { DialogService, ToastService } from "@bitwarden/components"; +import { RestrictedItemTypesService } from "@bitwarden/vault"; import { TrialFlowService } from "../../../../billing/services/trial-flow.service"; import { VaultFilterService } from "../services/abstractions/vault-filter.service"; @@ -56,6 +64,45 @@ export class VaultFilterComponent implements OnInit, OnDestroy { return this.filters ? Object.values(this.filters) : []; } + 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("note"), + type: CipherType.SecureNote, + icon: "bwi-sticky-note", + }, + { + id: "sshKey", + name: this.i18nService.t("typeSshKey"), + type: CipherType.SshKey, + icon: "bwi-key", + }, + ]; + get searchPlaceholder() { if (this.activeFilter.isFavorites) { return "searchFavorites"; @@ -107,12 +154,17 @@ export class VaultFilterComponent implements OnInit, OnDestroy { protected dialogService: DialogService, protected configService: ConfigService, protected accountService: AccountService, + protected restrictedItemTypesService: RestrictedItemTypesService, ) {} async ngOnInit(): Promise { this.filters = await this.buildAllFilters(); - this.activeFilter.selectedCipherTypeNode = - (await this.getDefaultFilter()) as TreeNode; + if (this.filters?.typeFilter?.data$) { + this.activeFilter.selectedCipherTypeNode = (await firstValueFrom( + this.filters?.typeFilter.data$, + )) as TreeNode; + } + this.isLoaded = true; // Without refactoring the entire component, we need to manually update the organization filter whenever the policies update @@ -133,6 +185,9 @@ export class VaultFilterComponent implements OnInit, OnDestroy { takeUntil(this.destroy$), ) .subscribe((orgFilters) => { + if (!this.filters) { + return; + } this.filters.organizationFilter = orgFilters; }); } @@ -151,7 +206,6 @@ export class VaultFilterComponent implements OnInit, OnDestroy { if (!orgNode?.node.enabled) { this.toastService.showToast({ variant: "error", - title: null, message: this.i18nService.t("disabledOrganizationFilterError"), }); const metadata = await this.billingApiService.getOrganizationBillingMetadata(orgNode.node.id); @@ -190,10 +244,6 @@ export class VaultFilterComponent implements OnInit, OnDestroy { this.onEditFolder.emit(folder); }; - async getDefaultFilter(): Promise> { - return await firstValueFrom(this.filters?.typeFilter.data$); - } - async buildAllFilters(): Promise { const builderFilter = {} as VaultFilterList; builderFilter.organizationFilter = await this.addOrganizationFilter(); @@ -225,7 +275,7 @@ export class VaultFilterComponent implements OnInit, OnDestroy { const addAction = !singleOrgPolicy ? { text: "newOrganization", route: "/create-organization" } - : null; + : undefined; const orgFilterSection: VaultFilterSection = { data$: this.vaultFilterService.organizationTree$, @@ -233,7 +283,7 @@ export class VaultFilterComponent implements OnInit, OnDestroy { showHeader: !(singleOrgPolicy && personalVaultPolicy), isSelectable: true, }, - action: this.applyOrganizationFilter, + action: this.applyOrganizationFilter as (orgNode: TreeNode) => Promise, options: { component: OrganizationOptionsComponent }, add: addAction, divider: true, @@ -243,55 +293,31 @@ export class VaultFilterComponent implements OnInit, OnDestroy { } protected async addTypeFilter(excludeTypes: CipherStatus[] = []): Promise { - 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("note"), - type: CipherType.SecureNote, - icon: "bwi-sticky-note", - }, - { - id: "sshKey", - name: this.i18nService.t("typeSshKey"), - type: CipherType.SshKey, - icon: "bwi-key", - }, - ]; + const allFilter: CipherTypeFilter = { id: "AllItems", name: "allItems", type: "all", icon: "" }; + + const data$ = this.restrictedItemTypesService.restricted$.pipe( + map((restricted) => { + // List of types restricted by all orgs + const restrictedByAll = restricted + .filter((r) => r.allowViewOrgIds.length === 0) + .map((r) => r.cipherType); + const toExclude = [...excludeTypes, ...restrictedByAll]; + return this.allTypeFilters.filter( + (f) => typeof f.type !== "string" && !toExclude.includes(f.type), + ); + }), + switchMap((allowed) => this.vaultFilterService.buildTypeTree(allFilter, allowed)), + distinctUntilChanged(), + shareReplay({ bufferSize: 1, refCount: true }), + ); const typeFilterSection: VaultFilterSection = { - data$: this.vaultFilterService.buildTypeTree( - { id: "AllItems", name: "allItems", type: "all", icon: "" }, - allTypeFilters.filter((f) => !excludeTypes.includes(f.type)), - ), + data$, header: { showHeader: true, isSelectable: true, }, - action: this.applyTypeFilter, + action: this.applyTypeFilter as (filterNode: TreeNode) => Promise, }; return typeFilterSection; } @@ -303,10 +329,10 @@ export class VaultFilterComponent implements OnInit, OnDestroy { showHeader: true, isSelectable: false, }, - action: this.applyFolderFilter, + action: this.applyFolderFilter as (filterNode: TreeNode) => Promise, edit: { filterName: this.i18nService.t("folder"), - action: this.editFolder, + action: this.editFolder as (filter: VaultFilterType) => void, }, }; return folderFilterSection; @@ -319,7 +345,9 @@ export class VaultFilterComponent implements OnInit, OnDestroy { showHeader: true, isSelectable: true, }, - action: this.applyCollectionFilter, + action: this.applyCollectionFilter as ( + filterNode: TreeNode, + ) => Promise, }; return collectionFilterSection; } @@ -346,7 +374,7 @@ export class VaultFilterComponent implements OnInit, OnDestroy { showHeader: false, isSelectable: true, }, - action: this.applyTypeFilter, + action: this.applyTypeFilter as (filterNode: TreeNode) => Promise, }; return trashFilterSection; } diff --git a/apps/web/src/app/vault/individual-vault/vault-filter/shared/models/filter-function.spec.ts b/apps/web/src/app/vault/individual-vault/vault-filter/shared/models/filter-function.spec.ts index 3082d7cb809..660aeb293a4 100644 --- a/apps/web/src/app/vault/individual-vault/vault-filter/shared/models/filter-function.spec.ts +++ b/apps/web/src/app/vault/individual-vault/vault-filter/shared/models/filter-function.spec.ts @@ -3,6 +3,7 @@ import { Unassigned } from "@bitwarden/admin-console/common"; import { CipherType } from "@bitwarden/common/vault/enums"; import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; +import { RestrictedCipherType } from "@bitwarden/vault"; import { createFilterFunction } from "./filter-function"; import { All } from "./routed-vault-filter.model"; @@ -214,6 +215,46 @@ describe("createFilter", () => { expect(result).toBe(true); }); }); + + describe("given restricted types", () => { + const restrictedTypes: RestrictedCipherType[] = [ + { cipherType: CipherType.Login, allowViewOrgIds: [] }, + ]; + + it("should filter out a cipher whose type is fully restricted", () => { + const cipher = createCipher({ type: CipherType.Login }); + const filterFunction = createFilterFunction({}, restrictedTypes); + + expect(filterFunction(cipher)).toBe(false); + }); + + it("should allow a cipher when the cipher's organization allows it", () => { + const cipher = createCipher({ type: CipherType.Login, organizationId: "org1" }); + const restricted: RestrictedCipherType[] = [ + { cipherType: CipherType.Login, allowViewOrgIds: ["org1"] }, + ]; + const filterFunction2 = createFilterFunction({}, restricted); + + expect(filterFunction2(cipher)).toBe(true); + }); + + it("should filter out a personal vault cipher when the owning orgs does not allow it", () => { + const cipher = createCipher({ type: CipherType.Card, organizationId: "org1" }); + const restricted2: RestrictedCipherType[] = [ + { cipherType: CipherType.Card, allowViewOrgIds: [] }, + ]; + const filterFunction3 = createFilterFunction({}, restricted2); + + expect(filterFunction3(cipher)).toBe(false); + }); + + it("should not filter a cipher if there are no restricted types", () => { + const cipher = createCipher({ type: CipherType.Login }); + const filterFunction = createFilterFunction({}, []); + + expect(filterFunction(cipher)).toBe(true); + }); + }); }); function createCipher(options: Partial = {}) { diff --git a/apps/web/src/app/vault/individual-vault/vault-filter/shared/models/filter-function.ts b/apps/web/src/app/vault/individual-vault/vault-filter/shared/models/filter-function.ts index a39918df4a7..61305fa5e49 100644 --- a/apps/web/src/app/vault/individual-vault/vault-filter/shared/models/filter-function.ts +++ b/apps/web/src/app/vault/individual-vault/vault-filter/shared/models/filter-function.ts @@ -1,12 +1,16 @@ import { Unassigned } from "@bitwarden/admin-console/common"; import { CipherType } from "@bitwarden/common/vault/enums"; import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; +import { RestrictedCipherType } from "@bitwarden/vault"; import { All, RoutedVaultFilterModel } from "./routed-vault-filter.model"; export type FilterFunction = (cipher: CipherView) => boolean; -export function createFilterFunction(filter: RoutedVaultFilterModel): FilterFunction { +export function createFilterFunction( + filter: RoutedVaultFilterModel, + restrictedTypes?: RestrictedCipherType[], +): FilterFunction { return (cipher) => { if (filter.type === "favorites" && !cipher.favorite) { return false; @@ -80,6 +84,24 @@ export function createFilterFunction(filter: RoutedVaultFilterModel): FilterFunc return false; } + // Restricted types + if (restrictedTypes && restrictedTypes.length > 0) { + // Filter the cipher if that type is restricted unless + // - The cipher belongs to an organization and that organization allows viewing the cipher type + // OR + // - The cipher belongs to the user's personal vault and at least one other organization does not restrict that type + if ( + restrictedTypes.some( + (restrictedType) => + restrictedType.cipherType === cipher.type && + (cipher.organizationId + ? !restrictedType.allowViewOrgIds.includes(cipher.organizationId) + : restrictedType.allowViewOrgIds.length === 0), + ) + ) { + return false; + } + } return true; }; } diff --git a/apps/web/src/app/vault/individual-vault/vault-header/vault-header.component.html b/apps/web/src/app/vault/individual-vault/vault-header/vault-header.component.html index af95a71ba8d..4ef8204cdfc 100644 --- a/apps/web/src/app/vault/individual-vault/vault-header/vault-header.component.html +++ b/apps/web/src/app/vault/individual-vault/vault-header/vault-header.component.html @@ -81,26 +81,12 @@ {{ "new" | i18n }} - - - - - + @for (item of cipherMenuItems$ | async; track item.type) { + + }