diff --git a/apps/desktop/src/vault/app/vault-v3/vault-filter/filters/collection-filter.component.html b/apps/desktop/src/vault/app/vault-v3/vault-filter/filters/collection-filter.component.html new file mode 100644 index 00000000000..f83a2e1c91f --- /dev/null +++ b/apps/desktop/src/vault/app/vault-v3/vault-filter/filters/collection-filter.component.html @@ -0,0 +1,84 @@ + + + + + + {{ collectionsGrouping.name | i18n }} + + + + + + + + + + + + + {{ c.node.name }} + + + + + + + + + + + + diff --git a/apps/desktop/src/vault/app/vault-v3/vault-filter/filters/collection-filter.component.ts b/apps/desktop/src/vault/app/vault-v3/vault-filter/filters/collection-filter.component.ts new file mode 100644 index 00000000000..015b301efdb --- /dev/null +++ b/apps/desktop/src/vault/app/vault-v3/vault-filter/filters/collection-filter.component.ts @@ -0,0 +1,12 @@ +import { Component } from "@angular/core"; + +import { CollectionFilterComponent as BaseCollectionFilterComponent } from "@bitwarden/angular/vault/vault-filter/components/collection-filter.component"; + +// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush +// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection +@Component({ + selector: "app-collection-filter", + templateUrl: "collection-filter.component.html", + standalone: false, +}) +export class CollectionFilterComponent extends BaseCollectionFilterComponent {} diff --git a/apps/desktop/src/vault/app/vault-v3/vault-filter/filters/folder-filter.component.html b/apps/desktop/src/vault/app/vault-v3/vault-filter/filters/folder-filter.component.html new file mode 100644 index 00000000000..a2240b03ff5 --- /dev/null +++ b/apps/desktop/src/vault/app/vault-v3/vault-filter/filters/folder-filter.component.html @@ -0,0 +1,94 @@ + + + + + + {{ foldersGrouping.name | i18n }} + + + + + + + + + + + + + + + + {{ f.node.name }} + + + + + + + + + + + + + + diff --git a/apps/desktop/src/vault/app/vault-v3/vault-filter/filters/folder-filter.component.ts b/apps/desktop/src/vault/app/vault-v3/vault-filter/filters/folder-filter.component.ts new file mode 100644 index 00000000000..f340e4082b8 --- /dev/null +++ b/apps/desktop/src/vault/app/vault-v3/vault-filter/filters/folder-filter.component.ts @@ -0,0 +1,12 @@ +import { Component } from "@angular/core"; + +import { FolderFilterComponent as BaseFolderFilterComponent } from "@bitwarden/angular/vault/vault-filter/components/folder-filter.component"; + +// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush +// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection +@Component({ + selector: "app-folder-filter", + templateUrl: "folder-filter.component.html", + standalone: false, +}) +export class FolderFilterComponent extends BaseFolderFilterComponent {} diff --git a/apps/desktop/src/vault/app/vault-v3/vault-filter/filters/organization-filter.component.html b/apps/desktop/src/vault/app/vault-v3/vault-filter/filters/organization-filter.component.html new file mode 100644 index 00000000000..8c73891dc09 --- /dev/null +++ b/apps/desktop/src/vault/app/vault-v3/vault-filter/filters/organization-filter.component.html @@ -0,0 +1,140 @@ + + + + + + + + + + + {{ organizationGrouping.name | i18n }} + + + + + + + + + {{ organization.name }} + + + + + + + + + + + + + + + + + {{ organizationGrouping.name | i18n }} + + + + + + + + + {{ "myVault" | i18n }} + + + + + + + + {{ organization.name }} + + + + + + + + + + + diff --git a/apps/desktop/src/vault/app/vault-v3/vault-filter/filters/organization-filter.component.ts b/apps/desktop/src/vault/app/vault-v3/vault-filter/filters/organization-filter.component.ts new file mode 100644 index 00000000000..99338ddbb7c --- /dev/null +++ b/apps/desktop/src/vault/app/vault-v3/vault-filter/filters/organization-filter.component.ts @@ -0,0 +1,53 @@ +// FIXME: Update this file to be type safe and remove this and next line +// @ts-strict-ignore +import { Component } from "@angular/core"; + +import { OrganizationFilterComponent as BaseOrganizationFilterComponent } from "@bitwarden/angular/vault/vault-filter/components/organization-filter.component"; +import { DisplayMode } from "@bitwarden/angular/vault/vault-filter/models/display-mode"; +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"; +import { ToastService } from "@bitwarden/components"; + +// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush +// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection +@Component({ + selector: "app-organization-filter", + templateUrl: "organization-filter.component.html", + standalone: false, +}) +export class OrganizationFilterComponent extends BaseOrganizationFilterComponent { + get show() { + const hiddenDisplayModes: DisplayMode[] = [ + "singleOrganizationAndOrganizatonDataOwnershipPolicies", + ]; + return ( + !this.hide && + this.organizations.length > 0 && + hiddenDisplayModes.indexOf(this.displayMode) === -1 + ); + } + + constructor( + private i18nService: I18nService, + private platformUtilsService: PlatformUtilsService, + private toastService: ToastService, + ) { + super(); + } + + async applyOrganizationFilter(organization: Organization) { + if (organization.enabled) { + //proceed with default behaviour for enabled organizations + // 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 + super.applyOrganizationFilter(organization); + } else { + this.toastService.showToast({ + variant: "error", + title: null, + message: this.i18nService.t("disabledOrganizationFilterError"), + }); + } + } +} diff --git a/apps/desktop/src/vault/app/vault-v3/vault-filter/filters/status-filter.component.html b/apps/desktop/src/vault/app/vault-v3/vault-filter/filters/status-filter.component.html new file mode 100644 index 00000000000..8b064778444 --- /dev/null +++ b/apps/desktop/src/vault/app/vault-v3/vault-filter/filters/status-filter.component.html @@ -0,0 +1,68 @@ + + {{ "filters" | i18n }} + + + + + {{ "allItems" | i18n }} + + + + + + + {{ "favorites" | i18n }} + + + + + + + {{ "archiveNoun" | i18n }} + + + @if (!(canArchive$ | async)) { + + } + + + + + {{ "trash" | i18n }} + + + + + diff --git a/apps/desktop/src/vault/app/vault-v3/vault-filter/filters/status-filter.component.spec.ts b/apps/desktop/src/vault/app/vault-v3/vault-filter/filters/status-filter.component.spec.ts new file mode 100644 index 00000000000..ba785310a0a --- /dev/null +++ b/apps/desktop/src/vault/app/vault-v3/vault-filter/filters/status-filter.component.spec.ts @@ -0,0 +1,98 @@ +import { ComponentFixture, TestBed } from "@angular/core/testing"; +import { mock } from "jest-mock-extended"; +import { of } from "rxjs"; + +import { PremiumBadgeComponent } from "@bitwarden/angular/billing/components/premium-badge"; +import { JslibModule } from "@bitwarden/angular/jslib.module"; +import { VaultFilter } from "@bitwarden/angular/vault/vault-filter/models/vault-filter.model"; +import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { Utils } from "@bitwarden/common/platform/misc/utils"; +import { FakeAccountService, mockAccountServiceWith } from "@bitwarden/common/spec"; +import { UserId } from "@bitwarden/common/types/guid"; +import { CipherArchiveService } from "@bitwarden/common/vault/abstractions/cipher-archive.service"; +import { PremiumUpgradePromptService } from "@bitwarden/common/vault/abstractions/premium-upgrade-prompt.service"; +import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; + +import { StatusFilterComponent } from "./status-filter.component"; + +describe("StatusFilterComponent", () => { + let component: StatusFilterComponent; + let fixture: ComponentFixture; + let cipherArchiveService: jest.Mocked; + let accountService: FakeAccountService; + + const mockUserId = Utils.newGuid() as UserId; + const event = new Event("click"); + + beforeEach(async () => { + accountService = mockAccountServiceWith(mockUserId); + cipherArchiveService = mock(); + + await TestBed.configureTestingModule({ + declarations: [StatusFilterComponent], + providers: [ + { provide: AccountService, useValue: accountService }, + { provide: CipherArchiveService, useValue: cipherArchiveService }, + { provide: PremiumUpgradePromptService, useValue: mock() }, + { + provide: BillingAccountProfileStateService, + useValue: mock(), + }, + { provide: I18nService, useValue: { t: (key: string) => key } }, + ], + imports: [JslibModule, PremiumBadgeComponent], + }).compileComponents(); + + fixture = TestBed.createComponent(StatusFilterComponent); + component = fixture.componentInstance; + component.activeFilter = new VaultFilter(); + fixture.detectChanges(); + }); + + describe("handleArchiveFilter", () => { + const applyFilter = jest.fn(); + let promptForPremiumSpy: jest.SpyInstance; + + beforeEach(() => { + applyFilter.mockClear(); + component["applyFilter"] = applyFilter; + + promptForPremiumSpy = jest.spyOn(component["premiumBadgeComponent"]()!, "promptForPremium"); + }); + + it("should apply archive filter when userCanArchive returns true", async () => { + cipherArchiveService.userCanArchive$.mockReturnValue(of(true)); + cipherArchiveService.archivedCiphers$.mockReturnValue(of([])); + + await component["handleArchiveFilter"](event); + + expect(applyFilter).toHaveBeenCalledWith("archive"); + expect(promptForPremiumSpy).not.toHaveBeenCalled(); + }); + + it("should apply archive filter when userCanArchive returns false but hasArchivedCiphers is true", async () => { + const mockCipher = new CipherView(); + mockCipher.id = "test-id"; + + cipherArchiveService.userCanArchive$.mockReturnValue(of(false)); + cipherArchiveService.archivedCiphers$.mockReturnValue(of([mockCipher])); + + await component["handleArchiveFilter"](event); + + expect(applyFilter).toHaveBeenCalledWith("archive"); + expect(promptForPremiumSpy).not.toHaveBeenCalled(); + }); + + it("should prompt for premium when userCanArchive returns false and hasArchivedCiphers is false", async () => { + cipherArchiveService.userCanArchive$.mockReturnValue(of(false)); + cipherArchiveService.archivedCiphers$.mockReturnValue(of([])); + + await component["handleArchiveFilter"](event); + + expect(applyFilter).not.toHaveBeenCalled(); + expect(promptForPremiumSpy).toHaveBeenCalled(); + }); + }); +}); diff --git a/apps/desktop/src/vault/app/vault-v3/vault-filter/filters/status-filter.component.ts b/apps/desktop/src/vault/app/vault-v3/vault-filter/filters/status-filter.component.ts new file mode 100644 index 00000000000..95ffd3f0212 --- /dev/null +++ b/apps/desktop/src/vault/app/vault-v3/vault-filter/filters/status-filter.component.ts @@ -0,0 +1,51 @@ +import { Component, viewChild } from "@angular/core"; +import { combineLatest, firstValueFrom, map, switchMap } from "rxjs"; + +import { PremiumBadgeComponent } from "@bitwarden/angular/billing/components/premium-badge"; +import { StatusFilterComponent as BaseStatusFilterComponent } from "@bitwarden/angular/vault/vault-filter/components/status-filter.component"; +import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { getUserId } from "@bitwarden/common/auth/services/account.service"; +import { CipherArchiveService } from "@bitwarden/common/vault/abstractions/cipher-archive.service"; + +// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush +// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection +@Component({ + selector: "app-status-filter", + templateUrl: "status-filter.component.html", + standalone: false, +}) +export class StatusFilterComponent extends BaseStatusFilterComponent { + private readonly premiumBadgeComponent = viewChild(PremiumBadgeComponent); + + private userId$ = this.accountService.activeAccount$.pipe(getUserId); + protected canArchive$ = this.userId$.pipe( + switchMap((userId) => this.cipherArchiveService.userCanArchive$(userId)), + ); + + protected hasArchivedCiphers$ = this.userId$.pipe( + switchMap((userId) => + this.cipherArchiveService.archivedCiphers$(userId).pipe(map((ciphers) => ciphers.length > 0)), + ), + ); + + constructor( + private accountService: AccountService, + private cipherArchiveService: CipherArchiveService, + ) { + super(); + } + + protected async handleArchiveFilter(event: Event) { + const [canArchive, hasArchivedCiphers] = await firstValueFrom( + combineLatest([this.canArchive$, this.hasArchivedCiphers$]), + ); + + if (canArchive || hasArchivedCiphers) { + this.applyFilter("archive"); + } else if (this.premiumBadgeComponent()) { + // The `premiumBadgeComponent` should always be defined here, adding the + // if to satisfy TypeScript. + await this.premiumBadgeComponent().promptForPremium(event); + } + } +} diff --git a/apps/desktop/src/vault/app/vault-v3/vault-filter/filters/type-filter.component.html b/apps/desktop/src/vault/app/vault-v3/vault-filter/filters/type-filter.component.html new file mode 100644 index 00000000000..f8a83e01266 --- /dev/null +++ b/apps/desktop/src/vault/app/vault-v3/vault-filter/filters/type-filter.component.html @@ -0,0 +1,39 @@ + + + + + {{ typesNode.name | i18n }} + + + + + @for (typeFilter of typeFilters$ | async; track typeFilter) { + + + + {{ + typeFilter.labelKey | i18n + }} + + + + } + diff --git a/apps/desktop/src/vault/app/vault-v3/vault-filter/filters/type-filter.component.ts b/apps/desktop/src/vault/app/vault-v3/vault-filter/filters/type-filter.component.ts new file mode 100644 index 00000000000..fbab7ce4667 --- /dev/null +++ b/apps/desktop/src/vault/app/vault-v3/vault-filter/filters/type-filter.component.ts @@ -0,0 +1,34 @@ +import { Component } from "@angular/core"; +import { map, shareReplay } from "rxjs"; + +import { TypeFilterComponent as BaseTypeFilterComponent } from "@bitwarden/angular/vault/vault-filter/components/type-filter.component"; +import { RestrictedItemTypesService } from "@bitwarden/common/vault/services/restricted-item-types.service"; +import { CIPHER_MENU_ITEMS } from "@bitwarden/common/vault/types/cipher-menu-items"; + +// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush +// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection +@Component({ + selector: "app-type-filter", + templateUrl: "type-filter.component.html", + standalone: false, +}) +export class TypeFilterComponent extends BaseTypeFilterComponent { + protected typeFilters$ = this.restrictedItemTypesService.restricted$.pipe( + map((restrictedItemTypes) => + // Filter out restricted item types from the typeFilters array + CIPHER_MENU_ITEMS.filter( + (typeFilter) => + !restrictedItemTypes.some( + (restrictedType) => + restrictedType.allowViewOrgIds.length === 0 && + restrictedType.cipherType === typeFilter.type, + ), + ), + ), + shareReplay({ bufferSize: 1, refCount: true }), + ); + + constructor(private restrictedItemTypesService: RestrictedItemTypesService) { + super(); + } +} diff --git a/apps/desktop/src/vault/app/vault-v3/vault-filter/vault-filter.component.html b/apps/desktop/src/vault/app/vault-v3/vault-filter/vault-filter.component.html new file mode 100644 index 00000000000..14e72f3bb9d --- /dev/null +++ b/apps/desktop/src/vault/app/vault-v3/vault-filter/vault-filter.component.html @@ -0,0 +1,51 @@ + + + + + + + + + + diff --git a/apps/desktop/src/vault/app/vault-v3/vault-filter/vault-filter.component.ts b/apps/desktop/src/vault/app/vault-v3/vault-filter/vault-filter.component.ts new file mode 100644 index 00000000000..d7c5bafc3a4 --- /dev/null +++ b/apps/desktop/src/vault/app/vault-v3/vault-filter/vault-filter.component.ts @@ -0,0 +1,12 @@ +import { Component } from "@angular/core"; + +import { VaultFilterComponent as BaseVaultFilterComponent } from "@bitwarden/angular/vault/vault-filter/components/vault-filter.component"; + +// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush +// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection +@Component({ + selector: "app-vault-filter", + templateUrl: "vault-filter.component.html", + standalone: false, +}) +export class VaultFilterComponent extends BaseVaultFilterComponent {} diff --git a/apps/desktop/src/vault/app/vault-v3/vault-filter/vault-filter.module.ts b/apps/desktop/src/vault/app/vault-v3/vault-filter/vault-filter.module.ts new file mode 100644 index 00000000000..54a6d33ca6a --- /dev/null +++ b/apps/desktop/src/vault/app/vault-v3/vault-filter/vault-filter.module.ts @@ -0,0 +1,34 @@ +import { CommonModule } from "@angular/common"; +import { NgModule } from "@angular/core"; + +import { PremiumBadgeComponent } from "@bitwarden/angular/billing/components/premium-badge"; +import { JslibModule } from "@bitwarden/angular/jslib.module"; +import { DeprecatedVaultFilterService as DeprecatedVaultFilterServiceAbstraction } from "@bitwarden/angular/vault/abstractions/deprecated-vault-filter.service"; +import { VaultFilterService } from "@bitwarden/angular/vault/vault-filter/services/vault-filter.service"; + +import { CollectionFilterComponent } from "./filters/collection-filter.component"; +import { FolderFilterComponent } from "./filters/folder-filter.component"; +import { OrganizationFilterComponent } from "./filters/organization-filter.component"; +import { StatusFilterComponent } from "./filters/status-filter.component"; +import { TypeFilterComponent } from "./filters/type-filter.component"; +import { VaultFilterComponent } from "./vault-filter.component"; + +@NgModule({ + imports: [CommonModule, JslibModule, PremiumBadgeComponent], + declarations: [ + VaultFilterComponent, + CollectionFilterComponent, + FolderFilterComponent, + OrganizationFilterComponent, + StatusFilterComponent, + TypeFilterComponent, + ], + exports: [VaultFilterComponent], + providers: [ + { + provide: DeprecatedVaultFilterServiceAbstraction, + useClass: VaultFilterService, + }, + ], +}) +export class VaultFilterModule {}