1
0
mirror of https://github.com/bitwarden/browser synced 2026-02-24 08:33:29 +00:00

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

Migrated vault filters to new v3 vault's navigation

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

View File

@@ -2,7 +2,7 @@
<app-side-nav slot="side-nav">
<bit-nav-logo [openIcon]="logo" route="." [label]="'passwordManager' | i18n" />
<bit-nav-item icon="bwi-vault" [text]="'vault' | i18n" route="new-vault" />
<app-vault-filter />
<app-send-filters-nav />
<bit-nav-item icon="bwi-generate" [text]="'generator' | i18n" (click)="openGenerator()" />

View File

@@ -8,6 +8,7 @@ import { FakeGlobalStateProvider } from "@bitwarden/common/spec";
import { DialogService, NavigationModule } from "@bitwarden/components";
import { GlobalStateProvider } from "@bitwarden/state";
import { VaultFilterComponent } from "../../vault/app/vault-v3/vault-filter/vault-filter.component";
import { SendFiltersNavComponent } from "../tools/send-v2/send-filters-nav.component";
import { DesktopLayoutComponent } from "./desktop-layout.component";
@@ -20,6 +21,13 @@ import { DesktopLayoutComponent } from "./desktop-layout.component";
})
class MockSendFiltersNavComponent {}
@Component({
selector: "app-vault-filter",
template: "",
changeDetection: ChangeDetectionStrategy.OnPush,
})
class MockVaultFiltersNavComponent {}
Object.defineProperty(window, "matchMedia", {
writable: true,
value: jest.fn().mockImplementation((query) => ({
@@ -59,8 +67,8 @@ describe("DesktopLayoutComponent", () => {
],
})
.overrideComponent(DesktopLayoutComponent, {
remove: { imports: [SendFiltersNavComponent] },
add: { imports: [MockSendFiltersNavComponent] },
remove: { imports: [SendFiltersNavComponent, VaultFilterComponent] },
add: { imports: [MockSendFiltersNavComponent, MockVaultFiltersNavComponent] },
})
.compileComponents();
@@ -93,4 +101,11 @@ describe("DesktopLayoutComponent", () => {
expect(sendFiltersNav).toBeTruthy();
});
it("renders vault filters navigation component", () => {
const compiled = fixture.nativeElement;
const vaultFiltersNav = compiled.querySelector("app-vault-filter");
expect(vaultFiltersNav).toBeTruthy();
});
});

View File

@@ -5,6 +5,7 @@ import { PasswordManagerLogo } from "@bitwarden/assets/svg";
import { DialogService, LayoutComponent, NavigationModule } from "@bitwarden/components";
import { I18nPipe } from "@bitwarden/ui-common";
import { VaultFilterComponent } from "../../vault/app/vault-v3/vault-filter/vault-filter.component";
import { ExportDesktopComponent } from "../tools/export/export-desktop.component";
import { CredentialGeneratorComponent } from "../tools/generator/credential-generator.component";
import { ImportDesktopComponent } from "../tools/import/import-desktop.component";
@@ -22,6 +23,7 @@ import { DesktopSideNavComponent } from "./desktop-side-nav.component";
LayoutComponent,
NavigationModule,
DesktopSideNavComponent,
VaultFilterComponent,
SendFiltersNavComponent,
],
templateUrl: "./desktop-layout.component.html",

View File

@@ -1,10 +1,10 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { APP_INITIALIZER, NgModule } from "@angular/core";
import { Router } from "@angular/router";
import { ActivatedRoute, Router } from "@angular/router";
import { Subject, merge } from "rxjs";
import { OrganizationUserApiService } from "@bitwarden/admin-console/common";
import { CollectionService, OrganizationUserApiService } from "@bitwarden/admin-console/common";
import { SetInitialPasswordService } from "@bitwarden/angular/auth/password-management/set-initial-password/set-initial-password.service.abstraction";
import { SafeProvider, safeProvider } from "@bitwarden/angular/platform/utils/safe-provider";
import {
@@ -36,6 +36,7 @@ import {
} from "@bitwarden/auth/common";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { OrganizationApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/organization/organization-api.service.abstraction";
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
import {
PolicyService as PolicyServiceAbstraction,
InternalPolicyService,
@@ -107,6 +108,7 @@ import { SystemService } from "@bitwarden/common/platform/services/system.servic
import { GlobalStateProvider, StateProvider } from "@bitwarden/common/platform/state";
import { SyncService } from "@bitwarden/common/platform/sync";
import { CipherService as CipherServiceAbstraction } from "@bitwarden/common/vault/abstractions/cipher.service";
import { FolderService } from "@bitwarden/common/vault/abstractions/folder/folder.service.abstraction";
import { DialogService, ToastService } from "@bitwarden/components";
import { GeneratorServicesModule } from "@bitwarden/generator-components";
import { PasswordGenerationServiceAbstraction } from "@bitwarden/generator-legacy";
@@ -122,7 +124,15 @@ import {
SessionTimeoutSettingsComponentService,
} from "@bitwarden/key-management-ui";
import { SerializedMemoryStorageService } from "@bitwarden/storage-core";
import { DefaultSshImportPromptService, SshImportPromptService } from "@bitwarden/vault";
import {
DefaultSshImportPromptService,
SshImportPromptService,
VaultFilterServiceAbstraction,
VaultFilterService,
RoutedVaultFilterService,
RoutedVaultFilterBridgeService,
VAULT_FILTER_BASE_ROUTE,
} from "@bitwarden/vault";
import { DesktopLoginComponentService } from "../../auth/login/desktop-login-component.service";
import { DesktopAuthRequestAnsweringService } from "../../auth/services/auth-request-answering/desktop-auth-request-answering.service";
@@ -508,6 +518,34 @@ const safeProviders: SafeProvider[] = [
useClass: SessionTimeoutSettingsComponentService,
deps: [I18nServiceAbstraction, SessionTimeoutTypeService, PolicyServiceAbstraction],
}),
safeProvider({
provide: VaultFilterServiceAbstraction,
useClass: VaultFilterService,
deps: [
OrganizationService,
FolderService,
CipherServiceAbstraction,
PolicyServiceAbstraction,
I18nServiceAbstraction,
StateProvider,
CollectionService,
AccountServiceAbstraction,
],
}),
safeProvider({
provide: VAULT_FILTER_BASE_ROUTE,
useValue: "/new-vault",
}),
safeProvider({
provide: RoutedVaultFilterService,
useClass: RoutedVaultFilterService,
deps: [ActivatedRoute],
}),
safeProvider({
provide: RoutedVaultFilterBridgeService,
useClass: RoutedVaultFilterBridgeService,
deps: [Router, RoutedVaultFilterService, VaultFilterServiceAbstraction],
}),
safeProvider({
provide: AuthRequestAnsweringService,
useClass: DesktopAuthRequestAnsweringService,

View File

@@ -0,0 +1,24 @@
@if (collection().children.length) {
<bit-nav-group
[icon]="collection().node.icon"
[text]="displayName()"
variant="tree"
[appA11yTitle]="displayName()"
(click)="applyFilter($event)"
[forceActiveStyles]="isActive()"
[disableToggleOnClick]="true"
>
@for (childCollection of collection().children; track childCollection.node.id) {
<app-collection-filter [collection]="childCollection" [activeFilter]="activeFilter()" />
}
</bit-nav-group>
} @else {
<bit-nav-item
[icon]="collection().node.icon"
[forceActiveStyles]="isActive()"
[text]="displayName()"
variant="tree"
[appA11yTitle]="displayName()"
(click)="applyFilter($event)"
/>
}

View File

@@ -0,0 +1,38 @@
import { Component, input, computed } from "@angular/core";
import { TreeNode } from "@bitwarden/common/vault/models/domain/tree-node";
import { NavigationModule, A11yTitleDirective } from "@bitwarden/components";
import { VaultFilter, CollectionFilter } from "@bitwarden/vault";
// 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",
imports: [A11yTitleDirective, NavigationModule],
})
export class CollectionFilterComponent {
protected readonly collection = input<TreeNode<CollectionFilter>>();
protected readonly activeFilter = input<VaultFilter>();
protected readonly displayName = computed<string>(() => {
return this.collection().node.name;
});
protected readonly isActive = computed<boolean>(() => {
return (
this.collection().node.id === this.activeFilter()?.collectionId &&
!!this.activeFilter()?.selectedCollectionNode
);
});
protected applyFilter(event: Event) {
event.stopPropagation();
const filter = this.activeFilter();
if (filter) {
filter.selectedCollectionNode = this.collection();
}
}
}

View File

@@ -0,0 +1,50 @@
@if (folder().children.length) {
<bit-nav-group
[icon]="folder().node.icon"
[class.active]="isActive()"
[text]="displayName()"
variant="tree"
[appA11yTitle]="displayName()"
(click)="applyFilter($event)"
[forceActiveStyles]="isActive()"
[disableToggleOnClick]="true"
>
@if (folder()?.node.id) {
<button
type="button"
slot="end"
class="edit-button"
bitIconButton="bwi-pencil"
buttonType="nav-contrast"
size="small"
[label]="'editFolder' | i18n"
(click)="editFolder(folder().node)"
></button>
}
@for (childFolder of folder().children; track childFolder.node.id) {
<app-folder-filter [folder]="childFolder" [activeFilter]="activeFilter()" />
}
</bit-nav-group>
} @else {
<bit-nav-item
[icon]="folder().node.icon"
[forceActiveStyles]="isActive()"
[text]="displayName()"
variant="tree"
[appA11yTitle]="displayName()"
(click)="applyFilter($event)"
>
@if (folder()?.node.id) {
<button
type="button"
slot="end"
class="edit-button"
bitIconButton="bwi-pencil"
buttonType="nav-contrast"
size="small"
[label]="'editFolder' | i18n"
(click)="editFolder(folder().node)"
></button>
}
</bit-nav-item>
}

View File

@@ -0,0 +1,43 @@
import { Component, input, computed, output } from "@angular/core";
import { TreeNode } from "@bitwarden/common/vault/models/domain/tree-node";
import { IconButtonModule, NavigationModule, A11yTitleDirective } from "@bitwarden/components";
import { I18nPipe } from "@bitwarden/ui-common";
import { VaultFilter, FolderFilter } from "@bitwarden/vault";
// 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",
imports: [A11yTitleDirective, NavigationModule, IconButtonModule, I18nPipe],
})
export class FolderFilterComponent {
protected readonly folder = input<TreeNode<FolderFilter>>();
protected readonly activeFilter = input<VaultFilter>();
protected onEditFolder = output<FolderFilter>();
protected readonly displayName = computed<string>(() => {
return this.folder().node.name;
});
protected readonly isActive = computed<boolean>(() => {
return (
this.folder().node.id === this.activeFilter()?.folderId &&
!!this.activeFilter()?.selectedFolderNode
);
});
protected applyFilter(event: Event) {
event.stopPropagation();
const filter = this.activeFilter();
if (filter) {
filter.selectedFolderNode = this.folder();
}
}
protected editFolder(folder: FolderFilter) {
this.onEditFolder.emit(folder);
}
}

View File

@@ -0,0 +1,32 @@
@if (show()) {
<bit-nav-group
icon="bwi-filter"
(click)="applyAllVaultsFilter()"
[text]="'allVaults' | i18n"
[attr.aria-pressed]="!activeFilter()?.selectedOrganizationNode"
[appA11yTitle]="'allVaults' | i18n"
variant="tree"
[open]="true"
[forceActiveStyles]="!activeFilter()?.selectedOrganizationNode"
[disableToggleOnClick]="true"
>
@for (organization of organizations().children ?? []; track organization.node.id) {
<bit-nav-item
[icon]="organization.node.icon"
[forceActiveStyles]="organization.node.id === activeFilter()?.organizationId"
[text]="organization.node.name"
[appA11yTitle]="organization.node.name"
(click)="applyFilter($event, organization)"
/>
@if (!organization.node.enabled) {
<span class="tw-ml-auto">
<i
class="bwi bwi-fw bwi-exclamation-triangle text-danger mr-auto"
[attr.aria-label]="'organizationIsDisabled' | i18n"
[appA11yTitle]="'organizationIsDisabled' | i18n"
></i>
</span>
}
}
</bit-nav-group>
}

View File

@@ -0,0 +1,81 @@
import { Component, computed, input, inject } from "@angular/core";
import { DisplayMode } from "@bitwarden/angular/vault/vault-filter/models/display-mode";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { TreeNode } from "@bitwarden/common/vault/models/domain/tree-node";
import { ToastService, NavigationModule, A11yTitleDirective } from "@bitwarden/components";
import { I18nPipe } from "@bitwarden/ui-common";
import { OrganizationFilter, VaultFilter, VaultFilterServiceAbstraction } from "@bitwarden/vault";
// 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",
imports: [A11yTitleDirective, NavigationModule, I18nPipe],
})
export class OrganizationFilterComponent {
private toastService: ToastService = inject(ToastService);
private i18nService: I18nService = inject(I18nService);
private vaultFilterService: VaultFilterServiceAbstraction = inject(VaultFilterServiceAbstraction);
protected readonly hide = input(false);
protected readonly organizations = input<TreeNode<OrganizationFilter>>();
protected readonly activeFilter = input<VaultFilter>();
protected readonly activeOrganizationDataOwnership = input<boolean>(false);
protected readonly activeSingleOrganizationPolicy = input<boolean>(false);
protected readonly show = computed(() => {
const hiddenDisplayModes: DisplayMode[] = [
"singleOrganizationAndOrganizatonDataOwnershipPolicies",
];
return (
!this.hide() &&
this.organizations()?.children.length > 0 &&
hiddenDisplayModes.indexOf(this.displayMode()) === -1
);
});
protected readonly displayMode = computed<DisplayMode>(() => {
let displayMode: DisplayMode = "organizationMember";
if (this.organizations() == null || this.organizations().children.length < 1) {
displayMode = "noOrganizations";
} else if (this.activeOrganizationDataOwnership() && !this.activeSingleOrganizationPolicy()) {
displayMode = "organizationDataOwnershipPolicy";
} else if (!this.activeOrganizationDataOwnership() && this.activeSingleOrganizationPolicy()) {
displayMode = "singleOrganizationPolicy";
} else if (this.activeOrganizationDataOwnership() && this.activeSingleOrganizationPolicy()) {
displayMode = "singleOrganizationAndOrganizatonDataOwnershipPolicies";
}
return displayMode;
});
protected applyFilter(event: Event, organization: TreeNode<OrganizationFilter>) {
event.stopPropagation();
if (!organization.node.enabled) {
this.toastService.showToast({
variant: "error",
title: null,
message: this.i18nService.t("disabledOrganizationFilterError"),
});
return;
}
this.vaultFilterService.setOrganizationFilter(organization.node);
const filter = this.activeFilter();
if (filter) {
filter.selectedOrganizationNode = organization;
}
}
protected applyAllVaultsFilter() {
this.vaultFilterService.clearOrganizationFilter();
const filter = this.activeFilter();
if (filter) {
filter.selectedOrganizationNode = null;
}
}
}

View File

@@ -0,0 +1,21 @@
@if (!hideArchive()) {
<bit-nav-item
[icon]="archiveFilter.icon"
[forceActiveStyles]="activeFilter()?.isArchived"
(click)="handleArchiveFilter($event)"
[text]="archiveFilter.name | i18n"
[attr.aria-pressed]="activeFilter()?.isArchived"
[appA11yTitle]="archiveFilter.name | i18n"
/>
@if (!(canArchive$ | async)) {
<app-premium-badge />
}
}
<bit-nav-item
[icon]="trashFilter.icon"
[forceActiveStyles]="activeFilter()?.isDeleted"
(click)="applyFilter('trash')"
[text]="trashFilter.name | i18n"
[attr.aria-pressed]="activeFilter()?.isDeleted"
[appA11yTitle]="trashFilter.name | i18n"
/>

View File

@@ -0,0 +1,77 @@
import { CommonModule } from "@angular/common";
import { Component, viewChild, input, inject } from "@angular/core";
import { combineLatest, firstValueFrom, map, switchMap } from "rxjs";
import { PremiumBadgeComponent } from "@bitwarden/angular/billing/components/premium-badge";
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";
import { TreeNode } from "@bitwarden/common/vault/models/domain/tree-node";
import { NavigationModule, A11yTitleDirective } from "@bitwarden/components";
import { I18nPipe } from "@bitwarden/ui-common";
import { VaultFilter, CipherStatus, CipherTypeFilter } from "@bitwarden/vault";
// 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",
imports: [CommonModule, A11yTitleDirective, NavigationModule, PremiumBadgeComponent, I18nPipe],
})
export class StatusFilterComponent {
private accountService: AccountService = inject(AccountService);
private cipherArchiveService: CipherArchiveService = inject(CipherArchiveService);
protected readonly hideArchive = input(false);
protected readonly activeFilter = input<VaultFilter>();
protected readonly archiveFilter: CipherTypeFilter = {
id: "archive",
name: "archiveNoun",
type: "archive",
icon: "bwi-archive",
};
protected readonly trashFilter: CipherTypeFilter = {
id: "trash",
name: "trash",
type: "trash",
icon: "bwi-trash",
};
protected applyFilter(filterType: CipherStatus) {
let filter: CipherTypeFilter = null;
if (filterType === "archive") {
filter = this.archiveFilter;
} else if (filterType === "trash") {
filter = this.trashFilter;
}
if (filter) {
this.activeFilter().selectedCipherTypeNode = new TreeNode<CipherTypeFilter>(filter, null);
}
}
private readonly premiumBadgeComponent = viewChild.required(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)),
),
);
protected async handleArchiveFilter(event: Event) {
const [canArchive, hasArchivedCiphers] = await firstValueFrom(
combineLatest([this.canArchive$, this.hasArchivedCiphers$]),
);
if (canArchive || hasArchivedCiphers) {
this.applyFilter("archive");
} else {
await this.premiumBadgeComponent().promptForPremium(event);
}
}
}

View File

@@ -0,0 +1,26 @@
<bit-nav-group
icon="bwi-filter"
(click)="applyAllItemsFilter($event)"
[text]="'allItems' | i18n"
[attr.aria-pressed]="activeFilter()?.selectedCipherTypeNode?.node?.id === 'AllItems'"
[appA11yTitle]="'allItems' | i18n"
variant="tree"
[open]="true"
[forceActiveStyles]="activeFilter()?.selectedCipherTypeNode?.node?.id === 'AllItems'"
[disableToggleOnClick]="true"
>
@for (typeFilter of typeFilters$ | async; track typeFilter) {
<bit-nav-item
[icon]="typeFilter.node.icon"
[forceActiveStyles]="
activeFilter()?.selectedCipherTypeNode.node.type === typeFilter.node.type
"
(click)="applyTypeFilter($event, typeFilter)"
[text]="typeFilter.node.name"
[attr.aria-pressed]="
activeFilter()?.selectedCipherTypeNode.node.type === typeFilter.node.type
"
[appA11yTitle]="typeFilter.node.name"
/>
}
</bit-nav-group>

View File

@@ -0,0 +1,57 @@
import { CommonModule } from "@angular/common";
import { Component, input, inject } from "@angular/core";
import { map, shareReplay } from "rxjs";
import { TreeNode } from "@bitwarden/common/vault/models/domain/tree-node";
import { RestrictedItemTypesService } from "@bitwarden/common/vault/services/restricted-item-types.service";
import { NavigationModule, A11yTitleDirective } from "@bitwarden/components";
import { I18nPipe } from "@bitwarden/ui-common";
import { VaultFilter, CipherTypeFilter } from "@bitwarden/vault";
// 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",
imports: [CommonModule, A11yTitleDirective, NavigationModule, I18nPipe],
})
export class TypeFilterComponent {
private restrictedItemTypesService: RestrictedItemTypesService = inject(
RestrictedItemTypesService,
);
protected readonly cipherTypes = input<TreeNode<CipherTypeFilter>>();
protected readonly activeFilter = input<VaultFilter>();
protected applyTypeFilter(event: Event, cipherType: TreeNode<CipherTypeFilter>) {
event.stopPropagation();
const filter = this.activeFilter();
if (filter) {
filter.selectedCipherTypeNode = cipherType;
}
}
protected applyAllItemsFilter(event: Event) {
const filter = this.activeFilter();
if (filter) {
filter.selectedCipherTypeNode = this.cipherTypes();
}
}
protected typeFilters$ = this.restrictedItemTypesService.restricted$.pipe(
map((restrictedItemTypes) =>
// Filter out restricted item types from the typeFilters array
this.cipherTypes().children.filter(
(type) =>
!restrictedItemTypes.some(
(restrictedType) =>
restrictedType.allowViewOrgIds.length === 0 &&
restrictedType.cipherType === type.node.type,
),
),
),
shareReplay({ bufferSize: 1, refCount: true }),
);
}

View File

@@ -0,0 +1,44 @@
@if (!isLoaded) {
<div class="container loading-spinner">
<i class="bwi bwi-spinner bwi-spin bwi-3x" aria-hidden="true"></i>
</div>
} @else {
<bit-nav-group icon="bwi-vault" [text]="'vault' | i18n" route="new-vault">
<app-organization-filter
[activeFilter]="activeFilter()"
[organizations]="organizations$ | async"
[activeOrganizationDataOwnership]="activeOrganizationDataOwnershipPolicy"
[activeSingleOrganizationPolicy]="activeSingleOrganizationPolicy"
/>
<app-type-filter [activeFilter]="activeFilter()" [cipherTypes]="cipherTypes$ | async" />
<app-status-filter [hideArchive]="!showArchiveVaultFilter" [activeFilter]="activeFilter()" />
@if (showCollectionsFilter()) {
<bit-nav-group
icon="bwi-collection"
[text]="'collections' | i18n"
variant="tree"
[appA11yTitle]="'collections' | i18n"
[disableToggleOnClick]="true"
>
@for (collection of (collections$ | async)?.children ?? []; track collection.node.id) {
<app-collection-filter [activeFilter]="activeFilter()" [collection]="collection" />
}
</bit-nav-group>
}
<bit-nav-group
icon="bwi-folder"
[text]="'folders' | i18n"
variant="tree"
[appA11yTitle]="'folders' | i18n"
[disableToggleOnClick]="true"
>
@for (folder of (folders$ | async)?.children ?? []; track folder.node.id) {
<app-folder-filter
[activeFilter]="activeFilter()"
[folder]="folder"
(onEditFolder)="editFolder($event)"
/>
}
</bit-nav-group>
</bit-nav-group>
}

View File

@@ -0,0 +1,143 @@
import { CommonModule } from "@angular/common";
import { Component, inject, OnInit, output, computed, signal } from "@angular/core";
import { firstValueFrom, Observable, Subject, takeUntil } from "rxjs";
import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
import { PolicyType } from "@bitwarden/common/admin-console/enums";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { getUserId } from "@bitwarden/common/auth/services/account.service";
import { UserId } from "@bitwarden/common/types/guid";
import { CipherArchiveService } from "@bitwarden/common/vault/abstractions/cipher-archive.service";
import { FolderService } from "@bitwarden/common/vault/abstractions/folder/folder.service.abstraction";
import { PremiumUpgradePromptService } from "@bitwarden/common/vault/abstractions/premium-upgrade-prompt.service";
import { TreeNode } from "@bitwarden/common/vault/models/domain/tree-node";
import { NavigationModule, DialogService, A11yTitleDirective } from "@bitwarden/components";
import { I18nPipe } from "@bitwarden/ui-common";
import {
OrganizationFilter,
CipherTypeFilter,
CollectionFilter,
FolderFilter,
VaultFilter,
VaultFilterServiceAbstraction as VaultFilterService,
AddEditFolderDialogComponent,
RoutedVaultFilterBridgeService,
} from "@bitwarden/vault";
import { DesktopPremiumUpgradePromptService } from "../../../../services/desktop-premium-upgrade-prompt.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";
// 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",
imports: [
I18nPipe,
NavigationModule,
CommonModule,
OrganizationFilterComponent,
StatusFilterComponent,
TypeFilterComponent,
CollectionFilterComponent,
FolderFilterComponent,
A11yTitleDirective,
],
providers: [
{
provide: PremiumUpgradePromptService,
useClass: DesktopPremiumUpgradePromptService,
},
],
})
export class VaultFilterComponent implements OnInit {
private routedVaultFilterBridgeService = inject(RoutedVaultFilterBridgeService);
private vaultFilterService: VaultFilterService = inject(VaultFilterService);
private accountService: AccountService = inject(AccountService);
private cipherArchiveService: CipherArchiveService = inject(CipherArchiveService);
private folderService: FolderService = inject(FolderService);
private policyService: PolicyService = inject(PolicyService);
private dialogService: DialogService = inject(DialogService);
private componentIsDestroyed$ = new Subject<boolean>();
protected readonly activeFilter = signal<VaultFilter | null>(null);
protected onFilterChange = output<VaultFilter>();
private activeUserId: UserId;
protected isLoaded = false;
protected showArchiveVaultFilter = false;
protected activeOrganizationDataOwnershipPolicy: boolean;
protected activeSingleOrganizationPolicy: boolean;
protected organizations$: Observable<TreeNode<OrganizationFilter>>;
protected collections$: Observable<TreeNode<CollectionFilter>>;
protected folders$: Observable<TreeNode<FolderFilter>>;
protected cipherTypes$: Observable<TreeNode<CipherTypeFilter>>;
protected readonly showCollectionsFilter = computed<boolean>(() => {
return this.organizations$ != null && !this.activeFilter()?.isMyVaultSelected;
});
private async setActivePolicies() {
this.activeOrganizationDataOwnershipPolicy = await firstValueFrom(
this.policyService.policyAppliesToUser$(
PolicyType.OrganizationDataOwnership,
this.activeUserId,
),
);
this.activeSingleOrganizationPolicy = await firstValueFrom(
this.policyService.policyAppliesToUser$(PolicyType.SingleOrg, this.activeUserId),
);
}
async ngOnInit(): Promise<void> {
this.activeUserId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId));
this.organizations$ = this.vaultFilterService.organizationTree$;
if (
this.organizations$ != null &&
(await firstValueFrom(this.organizations$)).children.length > 0
) {
await this.setActivePolicies();
}
this.cipherTypes$ = this.vaultFilterService.cipherTypeTree$;
this.folders$ = this.vaultFilterService.folderTree$;
this.collections$ = this.vaultFilterService.collectionTree$;
this.showArchiveVaultFilter = await firstValueFrom(
this.cipherArchiveService.hasArchiveFlagEnabled$,
);
this.routedVaultFilterBridgeService.activeFilter$
.pipe(takeUntil(this.componentIsDestroyed$))
.subscribe((filter) => {
this.activeFilter.set(filter);
});
this.isLoaded = true;
}
protected async editFolder(folder: FolderFilter) {
if (!this.activeUserId) {
return;
}
const folderView = await firstValueFrom(
this.folderService.getDecrypted$(folder.id, this.activeUserId),
);
if (!folderView) {
return;
}
AddEditFolderDialogComponent.open(this.dialogService, {
editFolderConfig: {
folder: {
...folderView,
},
},
});
}
}

View File

@@ -1,34 +1,32 @@
import { CommonModule } from "@angular/common";
import {
ChangeDetectorRef,
Component,
NgZone,
OnDestroy,
OnInit,
ViewChild,
ViewContainerRef,
} from "@angular/core";
import { ChangeDetectorRef, Component, NgZone, OnDestroy, OnInit, ViewChild } from "@angular/core";
import { ActivatedRoute, Router } from "@angular/router";
import { firstValueFrom, Subject, takeUntil, switchMap, lastValueFrom, Observable } from "rxjs";
import {
firstValueFrom,
Subject,
takeUntil,
switchMap,
lastValueFrom,
Observable,
from,
} from "rxjs";
import { filter, map, take } from "rxjs/operators";
import { CollectionService, CollectionView } from "@bitwarden/admin-console/common";
import { CollectionService } from "@bitwarden/admin-console/common";
import { PremiumBadgeComponent } from "@bitwarden/angular/billing/components/premium-badge";
import { VaultViewPasswordHistoryService } from "@bitwarden/angular/services/view-password-history.service";
import { VaultFilter } from "@bitwarden/angular/vault/vault-filter/models/vault-filter.model";
import { AuthRequestServiceAbstraction } from "@bitwarden/auth/common";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
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 { Account, AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { getUserId } from "@bitwarden/common/auth/services/account.service";
import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service";
import { EventType } from "@bitwarden/common/enums";
import { BroadcasterService } from "@bitwarden/common/platform/abstractions/broadcaster.service";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
@@ -60,8 +58,6 @@ import {
} from "@bitwarden/components";
import { I18nPipe } from "@bitwarden/ui-common";
import {
AddEditFolderDialogComponent,
AddEditFolderDialogResult,
AttachmentDialogResult,
AttachmentsV2Component,
ChangeLoginPasswordService,
@@ -78,6 +74,9 @@ import {
PasswordRepromptService,
CipherFormComponent,
ArchiveCipherUtilitiesService,
VaultFilter,
VaultFilterServiceAbstraction as VaultFilterService,
RoutedVaultFilterBridgeService,
} from "@bitwarden/vault";
import { SearchBarService } from "../../../app/layout/search/search-bar.service";
@@ -86,8 +85,6 @@ import { DesktopPremiumUpgradePromptService } from "../../../services/desktop-pr
import { invokeMenu, RendererMenuItem } from "../../../utils";
import { AssignCollectionsDesktopComponent } from "../vault/assign-collections";
import { ItemFooterComponent } from "../vault/item-footer.component";
import { VaultFilterComponent } from "../vault/vault-filter/vault-filter.component";
import { VaultFilterModule } from "../vault/vault-filter/vault-filter.module";
import { VaultItemsV2Component } from "../vault/vault-items-v2.component";
const BroadcasterSubscriptionId = "VaultComponent";
@@ -107,7 +104,6 @@ const BroadcasterSubscriptionId = "VaultComponent";
ItemModule,
ButtonModule,
PremiumBadgeComponent,
VaultFilterModule,
VaultItemsV2Component,
],
providers: [
@@ -134,21 +130,11 @@ const BroadcasterSubscriptionId = "VaultComponent";
},
],
})
export class VaultComponent<C extends CipherViewLike>
implements OnInit, OnDestroy, CopyClickListener
{
export class VaultComponent implements OnInit, OnDestroy, CopyClickListener {
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
// eslint-disable-next-line @angular-eslint/prefer-signals
@ViewChild(VaultItemsV2Component, { static: true })
vaultItemsComponent: VaultItemsV2Component<C> | null = null;
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
// eslint-disable-next-line @angular-eslint/prefer-signals
@ViewChild(VaultFilterComponent, { static: true })
vaultFilterComponent: VaultFilterComponent | null = null;
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
// eslint-disable-next-line @angular-eslint/prefer-signals
@ViewChild("folderAddEdit", { read: ViewContainerRef, static: true })
folderAddEditModalRef: ViewContainerRef | null = null;
vaultItemsComponent: VaultItemsV2Component<CipherView> | null = null;
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
// eslint-disable-next-line @angular-eslint/prefer-signals
@ViewChild(CipherFormComponent)
@@ -194,6 +180,7 @@ export class VaultComponent<C extends CipherViewLike>
private componentIsDestroyed$ = new Subject<boolean>();
private allOrganizations: Organization[] = [];
private allCollections: CollectionView[] = [];
private filteredCollections: CollectionView[] = [];
constructor(
private route: ActivatedRoute,
@@ -209,7 +196,6 @@ export class VaultComponent<C extends CipherViewLike>
private totpService: TotpService,
private passwordRepromptService: PasswordRepromptService,
private searchBarService: SearchBarService,
private apiService: ApiService,
private dialogService: DialogService,
private billingAccountProfileStateService: BillingAccountProfileStateService,
private toastService: ToastService,
@@ -220,11 +206,12 @@ export class VaultComponent<C extends CipherViewLike>
private collectionService: CollectionService,
private organizationService: OrganizationService,
private folderService: FolderService,
private configService: ConfigService,
private authRequestService: AuthRequestServiceAbstraction,
private cipherArchiveService: CipherArchiveService,
private policyService: PolicyService,
private archiveCipherUtilitiesService: ArchiveCipherUtilitiesService,
private routedVaultFilterBridgeService: RoutedVaultFilterBridgeService,
private vaultFilterService: VaultFilterService,
) {}
async ngOnInit() {
@@ -240,6 +227,14 @@ export class VaultComponent<C extends CipherViewLike>
this.userHasPremiumAccess = canAccessPremium;
});
// Subscribe to filter changes from router params via the bridge service
this.routedVaultFilterBridgeService.activeFilter$
.pipe(
switchMap((vaultFilter: VaultFilter) => from(this.applyVaultFilter(vaultFilter))),
takeUntil(this.componentIsDestroyed$),
)
.subscribe();
this.broadcasterService.subscribe(BroadcasterSubscriptionId, (message: any) => {
this.ngZone
.run(async () => {
@@ -267,15 +262,7 @@ export class VaultComponent<C extends CipherViewLike>
break;
case "syncCompleted":
if (this.vaultItemsComponent) {
await this.vaultItemsComponent
.reload(this.activeFilter.buildFilter())
.catch(() => {});
}
if (this.vaultFilterComponent) {
await this.vaultFilterComponent
.reloadCollectionsAndFolders(this.activeFilter)
.catch(() => {});
await this.vaultFilterComponent.reloadOrganizations().catch(() => {});
await this.vaultItemsComponent.refresh().catch(() => {});
}
break;
case "modalShown":
@@ -377,6 +364,12 @@ export class VaultComponent<C extends CipherViewLike>
.subscribe((collections) => {
this.allCollections = collections;
});
this.vaultFilterService.filteredCollections$
.pipe(takeUntil(this.componentIsDestroyed$))
.subscribe((collections) => {
this.filteredCollections = collections;
});
}
ngOnDestroy() {
@@ -403,19 +396,6 @@ export class VaultComponent<C extends CipherViewLike>
this.addType = paramCipherAddType;
await this.addCipher(this.addType).catch(() => {});
}
const paramCipherType = toCipherType(params.type);
this.activeFilter = new VaultFilter({
status: params.deleted ? "trash" : params.favorites ? "favorites" : "all",
cipherType: params.action === "add" || paramCipherType == null ? undefined : paramCipherType,
selectedFolderId: params.folderId,
selectedCollectionId: params.selectedCollectionId,
selectedOrganizationId: params.selectedOrganizationId,
myVaultOnly: params.myVaultOnly ?? false,
});
if (this.vaultItemsComponent) {
await this.vaultItemsComponent.reload(this.activeFilter.buildFilter()).catch(() => {});
}
}
/**
@@ -439,9 +419,7 @@ export class VaultComponent<C extends CipherViewLike>
this.cipherId = cipher.id;
this.cipher = cipher;
this.collections =
this.vaultFilterComponent?.collections?.fullList.filter((c) =>
cipher.collectionIds.includes(c.id),
) ?? null;
this.filteredCollections?.filter((c) => cipher.collectionIds.includes(c.id)) ?? null;
this.action = "view";
await this.go().catch(() => {});
@@ -798,19 +776,45 @@ export class VaultComponent<C extends CipherViewLike>
await this.go().catch(() => {});
}
/**
* Wraps a filter function to handle CipherListView objects.
* CipherListView has a different type structure where type can be a string or object.
* This wrapper converts it to CipherView-compatible structure before filtering.
*/
private wrapFilterForCipherListView(
filterFn: (cipher: CipherView) => boolean,
): (cipher: CipherViewLike) => boolean {
return (cipher: CipherViewLike) => {
// For CipherListView, create a proxy object with the correct type property
if (CipherViewLikeUtils.isCipherListView(cipher)) {
const proxyCipher = {
...cipher,
type: CipherViewLikeUtils.getType(cipher),
// Normalize undefined organizationId to null for filter compatibility
organizationId: cipher.organizationId ?? null,
// Explicitly include isDeleted and isArchived since they might be getters
isDeleted: CipherViewLikeUtils.isDeleted(cipher),
isArchived: CipherViewLikeUtils.isArchived(cipher),
};
return filterFn(proxyCipher as any);
}
};
}
async applyVaultFilter(vaultFilter: VaultFilter) {
this.searchBarService.setPlaceholderText(
this.i18nService.t(this.calculateSearchBarLocalizationString(vaultFilter)),
);
this.activeFilter = vaultFilter;
await this.vaultItemsComponent
?.reload(
this.activeFilter.buildFilter(),
vaultFilter.status === "trash",
vaultFilter.status === "archive",
)
.catch(() => {});
await this.go().catch(() => {});
const originalFilterFn = this.activeFilter.buildFilter();
const wrappedFilterFn = this.wrapFilterForCipherListView(originalFilterFn);
await this.vaultItemsComponent?.reload(
wrappedFilterFn,
vaultFilter.isDeleted,
vaultFilter.isArchived,
);
}
private getAvailableCollections(cipher: CipherView): CollectionView[] {
@@ -824,25 +828,25 @@ export class VaultComponent<C extends CipherViewLike>
}
private calculateSearchBarLocalizationString(vaultFilter: VaultFilter): string {
if (vaultFilter.status === "favorites") {
if (vaultFilter.isFavorites) {
return "searchFavorites";
}
if (vaultFilter.status === "trash") {
if (vaultFilter.isDeleted) {
return "searchTrash";
}
if (vaultFilter.cipherType != null) {
return "searchType";
}
if (vaultFilter.selectedFolderId != null && vaultFilter.selectedFolderId !== "none") {
if (vaultFilter.folderId != null && vaultFilter.folderId !== "none") {
return "searchFolder";
}
if (vaultFilter.selectedCollectionId != null) {
if (vaultFilter.collectionId != null) {
return "searchCollection";
}
if (vaultFilter.selectedOrganizationId != null) {
if (vaultFilter.organizationId != null) {
return "searchOrganization";
}
if (vaultFilter.myVaultOnly) {
if (vaultFilter.isMyVaultSelected) {
return "searchMyVault";
}
return "searchVault";
@@ -863,23 +867,6 @@ export class VaultComponent<C extends CipherViewLike>
if (!folderView) {
return;
}
const dialogRef = AddEditFolderDialogComponent.open(this.dialogService, {
editFolderConfig: {
folder: {
...folderView,
},
},
});
const result = await lastValueFrom(dialogRef.closed);
if (
result === AddEditFolderDialogResult.Deleted ||
result === AddEditFolderDialogResult.Created
) {
await this.vaultFilterComponent?.reloadCollectionsAndFolders(this.activeFilter);
}
}
/** Refresh the current cipher object */
@@ -919,19 +906,13 @@ export class VaultComponent<C extends CipherViewLike>
queryParams = {
action: this.action,
cipherId: this.cipherId,
favorites: this.favorites ? true : null,
type: this.type,
folderId: this.folderId,
collectionId: this.collectionId,
deleted: this.deleted ? true : null,
organizationId: this.organizationId,
myVaultOnly: this.myVaultOnly,
};
}
this.router
.navigate([], {
relativeTo: this.route,
queryParams: queryParams,
queryParamsHandling: "merge",
replaceUrl: true,
})
.catch(() => {});
@@ -966,21 +947,23 @@ export class VaultComponent<C extends CipherViewLike>
}
private prefillCipherFromFilter() {
if (this.activeFilter.selectedCollectionId != null && this.vaultFilterComponent != null) {
const collections = this.vaultFilterComponent.collections?.fullList.filter(
(c) => c.id === this.activeFilter.selectedCollectionId,
if (this.activeFilter.collectionId != null) {
const collections = this.filteredCollections?.filter(
(c) => c.id === this.activeFilter.collectionId,
);
if (collections.length > 0) {
if (collections?.length > 0) {
this.addOrganizationId = collections[0].organizationId;
this.addCollectionIds = [this.activeFilter.selectedCollectionId];
this.addCollectionIds = [this.activeFilter.collectionId];
}
} else if (this.activeFilter.selectedOrganizationId) {
this.addOrganizationId = this.activeFilter.selectedOrganizationId;
} else if (this.activeFilter.organizationId) {
this.addOrganizationId = this.activeFilter.organizationId;
} else {
// clear out organizationId when the user switches to a personal vault filter
this.addOrganizationId = null;
}
this.folderId = this.activeFilter.selectedFolderId;
if (this.activeFilter.folderId && this.activeFilter.selectedFolderNode) {
this.folderId = this.activeFilter.folderId;
}
if (this.config == null) {
return;

View File

@@ -23,7 +23,7 @@ import {
} from "rxjs";
import { filter, map, take } from "rxjs/operators";
import { CollectionService, CollectionView } from "@bitwarden/admin-console/common";
import { CollectionService } from "@bitwarden/admin-console/common";
import { PremiumBadgeComponent } from "@bitwarden/angular/billing/components/premium-badge";
import { VaultViewPasswordHistoryService } from "@bitwarden/angular/services/view-password-history.service";
import { VaultFilter } from "@bitwarden/angular/vault/vault-filter/models/vault-filter.model";
@@ -32,6 +32,7 @@ import { EventCollectionService } from "@bitwarden/common/abstractions/event/eve
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 { Account, AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { ForceSetPasswordReason } from "@bitwarden/common/auth/models/domain/force-set-password-reason";