From f9a89916a23e5da80c66a729a94993acf8606bfe Mon Sep 17 00:00:00 2001 From: Jake Fink Date: Mon, 19 Dec 2022 12:40:00 -0500 Subject: [PATCH] [EC-14] Part II: Add Collection Rows to Vault List (#3875) * [EC-14] initial refactoring of vault filter * [EC-14] return observable trees for all filters with head node * [EC-14] Remove bindings on callbacks * [EC-14] fix formatting on disabled orgs * [EC-14] hide MyVault if personal org policy * [EC-14] add check for single org policy * [EC-14] add policies to org and change node constructor * [EC-14] don't show options if personal vault policy * [EC-14] default to all vaults * [EC-14] add default selection to filters * [EC-14] finish filter model callbacks * [EC-14] finish filter functionality and begin cleaning up * [EC-14] clean up old components and start on org vault * [EC-14] loop through filters for presentation * [EC-14] refactor VaultFilterService and put filter presentation data back into Vault Filter component. Remove VaultService * [EC-14] begin refactoring org vault * [EC-14] Refactor Vault Filter Service to use observables * [EC-14] finish org vault filter * [EC-14] fix vault model tests * [EC-14] fix org service calls * [EC-14] pull refactor out of shared code * [EC-14] include head node for collections even if collections aren't loaded yet * [EC-14] fix url params for vaults * [EC-14] remove comments * [EC-14] Remove unnecesary getter for org on vault filter * [EC-14] fix linter * [EC-14] fix prettier * [EC-14] add deprecated methods to collection service for desktop and browser * [EC-14] simplify cipher type node check * [EC-14] add getters to vault filter model * [EC-14] refactor how we build the filter list into methods * [EC-14] add getters to build filter method * [EC-14] start adding header and collection rows * [EC-14] remove param ids if false * [EC-14] Make collection rows navigatable * [EC-14] fix collapsing nodes * [EC-14] add specific type to search placeholder * [EC-14] remove extra constructor and comment from org vault filter * [EC-14] extract subscription callback to methods * [EC-14] Remove unecessary await * [EC-14] Remove ternary operators while building org filter * [EC-14] remove unnecessary deps array in vault filter service declaration * [EC-14] consolidate new models into one file * [EC-14] change name of edit collections method * [EC-14] add collection badges to item rows * [EC-14] show groups badge on collection rows * [EC-14] add bulk actions to header menu button * [EC-14] initialize nested observable inside of service Signed-off-by: Jacob Fink * [EC-14] change how we load orgs into the vault filter and select the default filter * [EC-14] remove get from getters name * [EC-14] remove eslint-disable comment * [EC-14] move vault filter service abstraction to angular folder and separate * [EC-14] rename filter types and delete VaultFilterLabel * [EC-14] remove changes to workspace file * [EC-14] remove deprecated service from jslib module * [EC-14] remove any remaining files from common code * [EC-14] consolidate vault filter components into components folder * [EC-14] simplify method call * [EC-14] refactor the vault filter service - orgs now have observable property - BehaviorSubjects have been migrated to ReplaySubjects if they don't need starting value - added unit tests - fix small error when selecting org badge of personal vault - renamed some properties * [EC-14] replace mergeMap with switchMap in vault filter service * [EC-14] early return to prevent nesting * [EC-14] clean up filterCollections method * [EC-14] use isDeleted helper in html * [EC-14] add jsdoc comments to ServiceUtils * [EC-14] fix linter * [EC-14] use array.slice instead of setting length * [EC-14] resolve merge conflicts * [EC-14] remove checkbox from end user vault collection rows * [EC-14] add owner column to collections in end user vault * [EC-14] add a11y titles for vault filters * Update apps/web/src/app/vault/vault-filter/services/vault-filter.service.ts Co-authored-by: Thomas Rittson <31796059+eliykat@users.noreply.github.com> * [EC-14] add missing high level jsdoc description * [EC-14] fix storybook absolute imports * [EC-14] delete vault-shared.module * [EC-14] change search placeholder text to getter and add missing strings * [EC-14] remove two way binding from search text in vault filter * [EC-14] removed all binding from search text and just use input event * [EC-14] remove async from apply vault filter * [EC-14] remove circular observable calls in vault filter service Co-authored-by: Thomas Rittson * [EC-14] move collapsed nodes to vault filter section * [EC-14] deconstruct filter section inside component * [EC-14] fix merge conflicts and introduce refactored organization service to vault filter service * [EC-14] remove mutation from filter builders * [EC-14] fix styling on buildFolderTree * [EC-14] remove leftover folder-filters reference and use ternary for collapse icon * [EC-14] remove unecessary checks * [EC-14] stop rebuilding filters when the organization changes * [EC-14] Move subscription out of setter in vault filter section * [EC-14] remove extra policy service methods from vault filter service * [EC-14] remove new methods from old vault-filter.service * [EC-14] Use vault filter service in vault components * [EC-14] reload collections from vault now that we have vault filter service * [EC-14] remove currentFilterCollections in vault filter component * [EC-14] change VaultFilterType to more specific OrganizationFilter in organization-options * [EC-14] include org check in isNodeSelected * [EC-14] add getters to filter function, fix storybook, and add test for All Collections * [EC-14] Resolve merge conflicts * [EC-14] fix merge conflicts * [EC-14] fix merge conflicts: org service protected and remove absolute path * [EC-14] separate org vault filter service observables * [EC-14] remove folder subject in vault filter service * [EC-14] remove collections subject from vault filter service * [EC-14] change collection api call name - getCollectionsWithDetails to getManyCollectionsWithDetails * [EC-14] add collection functionality - add endpoint to bulk delete collections - add logic to bulk delete both ciphers and collections - refresh ciphers list after making collection changes - stop making api calls from ciphers list each time a filter changes * [EC-14] get collections from vault filter service - for badge, instead of passing through @Input variable * [EC-14] only bulk delete collections if passed * [EC-14] fix deleting ciphers in org vault - reuse same logic from end user vault - call different api endpoints * [EC-14] include collections in MaxCheckedCount * [EC-14] add paging to collections * [EC-14] hide collections if searching * [EC-14] change vault table to new table component - removed a lot of scss classes to use tailwind alternatives - added getters for arrays in component that template can reference - imported and used new bitIconButton for options button * [EC-14] remove cursor pointer when checkbox not available * [EC-14] stop reloading cipher list too early * [EC-14] stop setting cipher component to loaded too early - loaded variable on cipher component hides the loaded indicator - when setting the default filter, we were triggering that variable - instead, we'll just set the active filter and let it grab the filter when ready * [EC-14] check/navigate collection when clicked * [EC-14] rename edit collections callback - used to be onEditCollection - renamed to onEditCipherCollections * [EC-14] remove showOrganizationBadge property - property used to tell template whether it was org vault or end user - replace with check for organization property * [EC-14] replace || with ?? in load function of ciphers * [EC-14] remove nested subscriptions - nested subscriptions = bad - the only dependency any of the subscriptions have is on the organization - use withLatestFrom to verify that the org has been set before firing * [EC-14] add getters and rename method * [EC-14] add null check in bulk delete component - some input variables can be null, so we can't just check the length * [EC-14] add ItemRow type - ItemRow can be either CipherView or CollectionFilter - Consolidated a large portion of selection logic * [EC-14] remove extra applyFilter override - Removed extra applyFIlter, allCiphers has already been filtered by org - Also reordered some of the methods to make more sense * [EC-14] remove extra collections uncheck * [EC-14] transition bulk delete to dialog service * [EC-14] transition bulk restore to dialog service * [EC-14] transition bulk move to dialog service * [EC-14] transition bulk share to dialog service * [EC-14] remove modal references * [EC-14] reload cipher list when changing orgs * [EC-14] add helper method to bulk delete dialog - Gives us built in typing instead of having to redeclare * [EC-14] add helper to open bulk restore dialog - Gives us typing without redeclaring * [EC-14] add open helper to bulk move dialog * [EC-14] add open helper to bulk share dialog - Adds typing to data - also removed the component refs from bulk actions * [EC-14] remove modal service from bulk actions * [EC-14] introduce VaultItemRow to combine cipher and collections * [EC-14] show loading indicator while switching orgs * [EC-14] remove indexing every time filter changes - also reverted back to using setter for changing org * [EC-14] allow searching by function in search pipe - this allows us to search parent properties in objects Co-authored-by: Andreas Coroiu * [EC-14] make collections searchable - used search pipe to filter based on search text * [EC-14] consolidate bulk dialogs in single module * [EC-14] remove form promise from bulk dialogs * [EC-14] stop casting dialog return type - we now have a helper function that gives us typing on result * [EC-14] add length check to array guard * [EC-14] remove extra false assignment * [EC-14] move to sentence case * [EC-14] address pr feedback * [EC-14] add back the default assignment to deleted - we need this default assignment to check for null or undefined values * [EC-14] remove optional chaining - everything is initialized to an empty array so it should never be null * [EC-14] remove manager check to show org vault - this is fixed upstream in a more comprehensive way * [EC-686] add tests and comments to serviceUtils (#4092) * [EC-686] add tests and comments to serviceUtls * [EC-686] whitelist spec filename from linter * [EC-686] fix prettier * [EC-14] use new collection admin service * [EC-14] fix groups searching * [EC-14] use new groups service and models * [EC-14] fix shared module * [EC-14] remove leftover empty vault filter service * [EC-14] remove CollectionGroupDetailsView models * [EC-14] replace GroupDetails with AdminView - Collections in vault filter now use admin view to get access details - Collections shown in cipher list use admin view for access details * [EC-14] add back the dialog to shared module * [EC-14] hide org vault if lacking permissions * [EC-14] add edit collection dialog to vault * [EC-14] add screen reader label to share dialog * [EC-14] moved sync call below subscription - the subscription gives a callback for when we finish a sync - by awaiting the sync before we weren't using the callback to refresh * [EC-14] move cipher params check to switchMap - we want to avoid async subscriptions * [EC-14] clean up subscriptions in org vault - added takeUntil - use combineLatest * [EC-14] clean up vault subscriptions - remove nested subscriptions - use takeUntil * [EC-14] init ciphers component first * [EC-14] fix view vault tab permissions - CanViewAssignedCollections doesn't include CanViewAllCollections - CanViewAssignedCollections does include IsManager * [EC-14] reduce nesting * [EC-14] rename bulk action dialogs selectors * [EC-14] fix permissions for collection management - users with custom admin permissions should be able to edit as well * [EC-14] prettier * [EC-14] use percentages for table columns widths * [EC-14] use GetCollectionAccessDetails in cli - renamed api call Signed-off-by: Jacob Fink Co-authored-by: Thomas Rittson <31796059+eliykat@users.noreply.github.com> Co-authored-by: Thomas Rittson Co-authored-by: Andreas Coroiu --- .github/whitelist-capital-letters.txt | 1 + apps/cli/src/commands/get.command.ts | 2 +- .../src/app/common/base.people.component.ts | 3 +- .../core/services/collection-admin.service.ts | 10 +- .../organizations/core/views/group.view.ts | 2 +- .../organization-layout.component.html | 4 +- .../layouts/organization-layout.component.ts | 5 + .../organizations/manage/groups.component.ts | 7 +- .../members/people.component.html | 6 +- .../organization-routing.module.ts | 37 +- .../collection-dialog.component.html | 2 +- .../collection-dialog.component.ts | 11 +- .../collection-badge.module.ts | 13 + .../collection-name-badge.component.html | 6 + .../collection-name.badge.component.ts | 24 ++ .../vault/group-badge/group-badge.module.ts | 13 + .../group-name-badge.component.html | 6 + .../group-badge/group-name-badge.component.ts | 26 ++ .../vault-filter/vault-filter.component.ts | 4 +- .../vault-filter/vault-filter.service.ts | 92 ++--- .../vault/vault-items.component.ts | 227 +++++++++-- .../vault/vault-routing.module.ts | 7 +- .../organizations/vault/vault.component.html | 5 +- .../organizations/vault/vault.component.ts | 118 +++--- .../app/organizations/vault/vault.module.ts | 4 + .../src/app/shared/loose-components.module.ts | 12 - apps/web/src/app/shared/shared.module.ts | 11 +- .../bulk-delete-dialog.component.html | 25 ++ .../bulk-delete-dialog.component.ts | 134 +++++++ .../bulk-dialogs.module.ts | 25 ++ .../bulk-move-dialog.component.html | 24 ++ .../bulk-move-dialog.component.ts | 85 +++++ .../bulk-restore-dialog.component.html | 14 + .../bulk-restore-dialog.component.ts | 63 ++++ .../bulk-share-dialog.component.html | 73 ++++ .../bulk-share-dialog.component.ts} | 65 +++- .../src/app/vault/bulk-actions.component.html | 5 - .../src/app/vault/bulk-actions.component.ts | 140 ++++--- .../src/app/vault/bulk-delete.component.html | 39 -- .../src/app/vault/bulk-delete.component.ts | 62 --- .../src/app/vault/bulk-move.component.html | 37 -- apps/web/src/app/vault/bulk-move.component.ts | 40 -- .../src/app/vault/bulk-restore.component.html | 36 -- .../src/app/vault/bulk-restore.component.ts | 29 -- .../src/app/vault/bulk-share.component.html | 85 ----- .../vault/pipes/get-collection-name.pipe.ts | 13 + .../app/vault/pipes/get-group-name.pipe.ts | 13 + apps/web/src/app/vault/pipes/pipes.module.ts | 6 +- .../components/vault-filter.component.ts | 5 +- .../abstractions/vault-filter.service.ts | 1 + .../services/vault-filter.service.ts | 74 ++-- .../vault-filter-section.component.html | 12 +- .../shared/models/vault-filter.type.ts | 7 +- .../shared/vault-filter.service.ts | 0 .../src/app/vault/vault-items.component.html | 205 ++++++++-- .../src/app/vault/vault-items.component.ts | 353 +++++++++++++++--- apps/web/src/app/vault/vault.component.html | 4 +- apps/web/src/app/vault/vault.component.ts | 122 +++--- apps/web/src/app/vault/vault.module.ts | 6 + apps/web/src/locales/en/messages.json | 28 +- .../providers/manage/people.component.html | 4 +- .../src/components/vault-items.component.ts | 10 +- libs/angular/src/pipes/search.pipe.ts | 67 ++-- libs/common/src/abstractions/api.service.ts | 13 +- .../organization.service.abstraction.ts | 2 +- libs/common/src/misc/serviceUtils.spec.ts | 84 +++++ libs/common/src/misc/serviceUtils.ts | 18 +- .../request/collection-bulk-delete.request.ts | 9 + libs/common/src/services/api.service.ts | 26 +- libs/components/src/link/link.directive.ts | 2 +- 70 files changed, 1891 insertions(+), 832 deletions(-) create mode 100644 apps/web/src/app/organizations/vault/collection-badge/collection-badge.module.ts create mode 100644 apps/web/src/app/organizations/vault/collection-badge/collection-name-badge.component.html create mode 100644 apps/web/src/app/organizations/vault/collection-badge/collection-name.badge.component.ts create mode 100644 apps/web/src/app/organizations/vault/group-badge/group-badge.module.ts create mode 100644 apps/web/src/app/organizations/vault/group-badge/group-name-badge.component.html create mode 100644 apps/web/src/app/organizations/vault/group-badge/group-name-badge.component.ts create mode 100644 apps/web/src/app/vault/bulk-action-dialogs/bulk-delete-dialog/bulk-delete-dialog.component.html create mode 100644 apps/web/src/app/vault/bulk-action-dialogs/bulk-delete-dialog/bulk-delete-dialog.component.ts create mode 100644 apps/web/src/app/vault/bulk-action-dialogs/bulk-dialogs.module.ts create mode 100644 apps/web/src/app/vault/bulk-action-dialogs/bulk-move-dialog/bulk-move-dialog.component.html create mode 100644 apps/web/src/app/vault/bulk-action-dialogs/bulk-move-dialog/bulk-move-dialog.component.ts create mode 100644 apps/web/src/app/vault/bulk-action-dialogs/bulk-restore-dialog/bulk-restore-dialog.component.html create mode 100644 apps/web/src/app/vault/bulk-action-dialogs/bulk-restore-dialog/bulk-restore-dialog.component.ts create mode 100644 apps/web/src/app/vault/bulk-action-dialogs/bulk-share-dialog/bulk-share-dialog.component.html rename apps/web/src/app/vault/{bulk-share.component.ts => bulk-action-dialogs/bulk-share-dialog/bulk-share-dialog.component.ts} (69%) delete mode 100644 apps/web/src/app/vault/bulk-delete.component.html delete mode 100644 apps/web/src/app/vault/bulk-delete.component.ts delete mode 100644 apps/web/src/app/vault/bulk-move.component.html delete mode 100644 apps/web/src/app/vault/bulk-move.component.ts delete mode 100644 apps/web/src/app/vault/bulk-restore.component.html delete mode 100644 apps/web/src/app/vault/bulk-restore.component.ts delete mode 100644 apps/web/src/app/vault/bulk-share.component.html create mode 100644 apps/web/src/app/vault/pipes/get-collection-name.pipe.ts create mode 100644 apps/web/src/app/vault/pipes/get-group-name.pipe.ts delete mode 100644 apps/web/src/app/vault/vault-filter/shared/vault-filter.service.ts create mode 100644 libs/common/src/misc/serviceUtils.spec.ts create mode 100644 libs/common/src/models/request/collection-bulk-delete.request.ts diff --git a/.github/whitelist-capital-letters.txt b/.github/whitelist-capital-letters.txt index af82f6b811a..20ceb2d900e 100644 --- a/.github/whitelist-capital-letters.txt +++ b/.github/whitelist-capital-letters.txt @@ -60,6 +60,7 @@ ./libs/common/src/misc/nodeUtils.ts ./libs/common/src/misc/linkedFieldOption.decorator.ts ./libs/common/src/misc/serviceUtils.ts +./libs/common/src/misc/serviceUtils.spec.ts ./libs/common/src/types/twoFactorResponse.ts ./libs/common/src/types/authResponse.ts ./libs/common/src/types/syncEventArgs.ts diff --git a/apps/cli/src/commands/get.command.ts b/apps/cli/src/commands/get.command.ts index 6cd57fe7cc1..92d7e31258e 100644 --- a/apps/cli/src/commands/get.command.ts +++ b/apps/cli/src/commands/get.command.ts @@ -416,7 +416,7 @@ export class GetCommand extends DownloadCommand { throw new Error("No encryption key for this organization."); } - const response = await this.apiService.getCollectionDetails(options.organizationId, id); + const response = await this.apiService.getCollectionAccessDetails(options.organizationId, id); const decCollection = new CollectionView(response); decCollection.name = await this.cryptoService.decryptToUtf8( new EncString(response.name), diff --git a/apps/web/src/app/common/base.people.component.ts b/apps/web/src/app/common/base.people.component.ts index fd14e5be162..50150ee8065 100644 --- a/apps/web/src/app/common/base.people.component.ts +++ b/apps/web/src/app/common/base.people.component.ts @@ -17,7 +17,6 @@ import { ProviderUserStatusType } from "@bitwarden/common/enums/providerUserStat import { ProviderUserType } from "@bitwarden/common/enums/providerUserType"; import { Utils } from "@bitwarden/common/misc/utils"; import { ListResponse } from "@bitwarden/common/models/response/list.response"; -import { OrganizationUserUserDetailsResponse } from "@bitwarden/common/models/response/organization-user.response"; import { ProviderUserUserDetailsResponse } from "@bitwarden/common/models/response/provider/provider-user.response"; import { OrganizationUserView } from "../organizations/core/views/organization-user.view"; @@ -182,7 +181,7 @@ export abstract class BasePeopleComponent< this.didScroll = this.pagedUsers.length > this.pageSize; } - checkUser(user: OrganizationUserUserDetailsResponse, select?: boolean) { + checkUser(user: UserType, select?: boolean) { (user as any).checked = select == null ? !(user as any).checked : select; } diff --git a/apps/web/src/app/organizations/core/services/collection-admin.service.ts b/apps/web/src/app/organizations/core/services/collection-admin.service.ts index d0edef52592..1ef0573b872 100644 --- a/apps/web/src/app/organizations/core/services/collection-admin.service.ts +++ b/apps/web/src/app/organizations/core/services/collection-admin.service.ts @@ -9,7 +9,6 @@ import { CollectionAccessDetailsResponse, CollectionResponse, } from "@bitwarden/common/models/response/collection.response"; -import { CollectionView } from "@bitwarden/common/models/view/collection.view"; import { CoreOrganizationModule } from "../core-organization.module"; import { CollectionAdminView } from "../views/collection-admin.view"; @@ -18,8 +17,11 @@ import { CollectionAdminView } from "../views/collection-admin.view"; export class CollectionAdminService { constructor(private apiService: ApiService, private cryptoService: CryptoService) {} - async getAll(organizationId: string): Promise { - const collectionResponse = await this.apiService.getCollections(organizationId); + async getAll(organizationId: string): Promise { + const collectionResponse = await this.apiService.getManyCollectionsWithAccessDetails( + organizationId + ); + if (collectionResponse?.data == null || collectionResponse.data.length === 0) { return []; } @@ -31,7 +33,7 @@ export class CollectionAdminService { organizationId: string, collectionId: string ): Promise { - const collectionResponse = await this.apiService.getCollectionDetails( + const collectionResponse = await this.apiService.getCollectionAccessDetails( organizationId, collectionId ); diff --git a/apps/web/src/app/organizations/core/views/group.view.ts b/apps/web/src/app/organizations/core/views/group.view.ts index 0c0f9559a21..f91c5854760 100644 --- a/apps/web/src/app/organizations/core/views/group.view.ts +++ b/apps/web/src/app/organizations/core/views/group.view.ts @@ -1,4 +1,4 @@ -import { View } from "@bitwarden/common/models/view/view"; +import { View } from "@bitwarden/common/src/models/view/view"; import { GroupDetailsResponse, GroupResponse } from "../services/group/responses/group.response"; diff --git a/apps/web/src/app/organizations/layouts/organization-layout.component.html b/apps/web/src/app/organizations/layouts/organization-layout.component.html index 632c2466d45..0d537a4b2a3 100644 --- a/apps/web/src/app/organizations/layouts/organization-layout.component.html +++ b/apps/web/src/app/organizations/layouts/organization-layout.component.html @@ -7,7 +7,9 @@ [activeOrganization]="organization" > - {{ "vault" | i18n }} + {{ + "vault" | i18n + }} {{ "members" | i18n }} diff --git a/apps/web/src/app/organizations/layouts/organization-layout.component.ts b/apps/web/src/app/organizations/layouts/organization-layout.component.ts index e09612310f6..69a04b68a59 100644 --- a/apps/web/src/app/organizations/layouts/organization-layout.component.ts +++ b/apps/web/src/app/organizations/layouts/organization-layout.component.ts @@ -8,6 +8,7 @@ import { canAccessMembersTab, canAccessReportingTab, canAccessSettingsTab, + canAccessVaultTab, getOrganizationById, OrganizationService, } from "@bitwarden/common/abstractions/organization/organization.service.abstraction"; @@ -44,6 +45,10 @@ export class OrganizationLayoutComponent implements OnInit, OnDestroy { this._destroy.complete(); } + canShowVaultTab(organization: Organization): boolean { + return canAccessVaultTab(organization); + } + canShowSettingsTab(organization: Organization): boolean { return canAccessSettingsTab(organization); } diff --git a/apps/web/src/app/organizations/manage/groups.component.ts b/apps/web/src/app/organizations/manage/groups.component.ts index f205bd1b3f5..f4a60f42b36 100644 --- a/apps/web/src/app/organizations/manage/groups.component.ts +++ b/apps/web/src/app/organizations/manage/groups.component.ts @@ -343,7 +343,12 @@ export class GroupsComponent implements OnInit, OnDestroy { private updateSearchedGroups() { if (this.searchService.isSearchable(this.searchText)) { // Making use of the pipe in the component as we need know which groups where filtered - this.searchedGroups = this.searchPipe.transform(this.groups, this.searchText, "name", "id"); + this.searchedGroups = this.searchPipe.transform( + this.groups, + this.searchText, + (group) => group.details.name, + (group) => group.details.id + ); } } } diff --git a/apps/web/src/app/organizations/members/people.component.html b/apps/web/src/app/organizations/members/people.component.html index 2c847b062b1..c7ea68584a9 100644 --- a/apps/web/src/app/organizations/members/people.component.html +++ b/apps/web/src/app/organizations/members/people.component.html @@ -133,7 +133,7 @@ - +
@@ -195,7 +195,7 @@ > {{ "userUsingTwoStep" | i18n }} - + {{ "groups" | i18n }} diff --git a/apps/web/src/app/organizations/organization-routing.module.ts b/apps/web/src/app/organizations/organization-routing.module.ts index f234d42aec2..2bce40e3acf 100644 --- a/apps/web/src/app/organizations/organization-routing.module.ts +++ b/apps/web/src/app/organizations/organization-routing.module.ts @@ -3,11 +3,17 @@ import { RouterModule, Routes } from "@angular/router"; import { AuthGuard } from "@bitwarden/angular/guards/auth.guard"; import { - canAccessGroupsTab, canAccessOrgAdmin, + canAccessGroupsTab, + canAccessMembersTab, + canAccessVaultTab, + canAccessReportingTab, + canAccessSettingsTab, } from "@bitwarden/common/abstractions/organization/organization.service.abstraction"; +import { Organization } from "@bitwarden/common/models/domain/organization"; import { OrganizationPermissionsGuard } from "./guards/org-permissions.guard"; +import { OrganizationRedirectGuard } from "./guards/org-redirect.guard"; import { OrganizationLayoutComponent } from "./layouts/organization-layout.component"; import { CollectionsComponent } from "./manage/collections.component"; import { GroupsComponent } from "./manage/groups.component"; @@ -23,7 +29,15 @@ const routes: Routes = [ organizationPermissions: canAccessOrgAdmin, }, children: [ - { path: "", pathMatch: "full", redirectTo: "vault" }, + { + path: "", + pathMatch: "full", + canActivate: [OrganizationRedirectGuard], + data: { + autoRedirectCallback: getOrganizationRoute, + }, + children: [], // This is required to make the auto redirect work, }, + }, { path: "vault", loadChildren: () => VaultModule, @@ -74,6 +88,25 @@ const routes: Routes = [ }, ]; +function getOrganizationRoute(organization: Organization): string { + if (canAccessVaultTab(organization)) { + return "vault"; + } + if (canAccessMembersTab(organization)) { + return "members"; + } + if (canAccessGroupsTab(organization)) { + return "groups"; + } + if (canAccessReportingTab(organization)) { + return "reporting"; + } + if (canAccessSettingsTab(organization)) { + return "settings"; + } + return undefined; +} + @NgModule({ imports: [RouterModule.forChild(routes)], exports: [RouterModule], diff --git a/apps/web/src/app/organizations/shared/components/collection-dialog/collection-dialog.component.html b/apps/web/src/app/organizations/shared/components/collection-dialog/collection-dialog.component.html index c3d8d708d39..17d5e8645c2 100644 --- a/apps/web/src/app/organizations/shared/components/collection-dialog/collection-dialog.component.html +++ b/apps/web/src/app/organizations/shared/components/collection-dialog/collection-dialog.component.html @@ -15,7 +15,7 @@ - + {{ "name" | i18n }} diff --git a/apps/web/src/app/organizations/shared/components/collection-dialog/collection-dialog.component.ts b/apps/web/src/app/organizations/shared/components/collection-dialog/collection-dialog.component.ts index 590392b9ba0..2ac0b61d349 100644 --- a/apps/web/src/app/organizations/shared/components/collection-dialog/collection-dialog.component.ts +++ b/apps/web/src/app/organizations/shared/components/collection-dialog/collection-dialog.component.ts @@ -27,9 +27,15 @@ import { PermissionMode, } from "../access-selector"; +export enum CollectionDialogTabType { + Info = 0, + Access = 1, +} + export interface CollectionDialogParams { collectionId?: string; organizationId: string; + initialTab?: CollectionDialogTabType; } export enum CollectionDialogResult { @@ -45,6 +51,7 @@ export enum CollectionDialogResult { export class CollectionDialogComponent implements OnInit, OnDestroy { private destroy$ = new Subject(); + protected tabIndex: CollectionDialogTabType; protected loading = true; protected organization?: Organization; protected collection?: CollectionView; @@ -69,7 +76,9 @@ export class CollectionDialogComponent implements OnInit, OnDestroy { private collectionService: CollectionAdminService, private i18nService: I18nService, private platformUtilsService: PlatformUtilsService - ) {} + ) { + this.tabIndex = params.initialTab ?? CollectionDialogTabType.Info; + } ngOnInit() { const organization$ = of(this.organizationService.get(this.params.organizationId)).pipe( diff --git a/apps/web/src/app/organizations/vault/collection-badge/collection-badge.module.ts b/apps/web/src/app/organizations/vault/collection-badge/collection-badge.module.ts new file mode 100644 index 00000000000..fdaf76bbf96 --- /dev/null +++ b/apps/web/src/app/organizations/vault/collection-badge/collection-badge.module.ts @@ -0,0 +1,13 @@ +import { NgModule } from "@angular/core"; + +import { SharedModule } from "../../../shared"; +import { PipesModule } from "../../../vault/pipes/pipes.module"; + +import { CollectionNameBadgeComponent } from "./collection-name.badge.component"; + +@NgModule({ + imports: [SharedModule, PipesModule], + declarations: [CollectionNameBadgeComponent], + exports: [CollectionNameBadgeComponent], +}) +export class CollectionBadgeModule {} diff --git a/apps/web/src/app/organizations/vault/collection-badge/collection-name-badge.component.html b/apps/web/src/app/organizations/vault/collection-badge/collection-name-badge.component.html new file mode 100644 index 00000000000..881ad4e1fa1 --- /dev/null +++ b/apps/web/src/app/organizations/vault/collection-badge/collection-name-badge.component.html @@ -0,0 +1,6 @@ + + {{ c | collectionNameFromId: collections }} + + + + {{ xMoreCount }} more + diff --git a/apps/web/src/app/organizations/vault/collection-badge/collection-name.badge.component.ts b/apps/web/src/app/organizations/vault/collection-badge/collection-name.badge.component.ts new file mode 100644 index 00000000000..625b3083026 --- /dev/null +++ b/apps/web/src/app/organizations/vault/collection-badge/collection-name.badge.component.ts @@ -0,0 +1,24 @@ +import { Component, Input } from "@angular/core"; + +import { CollectionView } from "@bitwarden/common/models/view/collection.view"; + +@Component({ + selector: "app-collection-badge", + templateUrl: "collection-name-badge.component.html", +}) +export class CollectionNameBadgeComponent { + @Input() collectionIds: string[]; + @Input() collections: CollectionView[]; + + get shownCollections(): string[] { + return this.showXMore ? this.collectionIds.slice(0, 2) : this.collectionIds; + } + + get showXMore(): boolean { + return this.collectionIds.length > 3; + } + + get xMoreCount(): number { + return this.collectionIds.length - 2; + } +} diff --git a/apps/web/src/app/organizations/vault/group-badge/group-badge.module.ts b/apps/web/src/app/organizations/vault/group-badge/group-badge.module.ts new file mode 100644 index 00000000000..9e997c97da8 --- /dev/null +++ b/apps/web/src/app/organizations/vault/group-badge/group-badge.module.ts @@ -0,0 +1,13 @@ +import { NgModule } from "@angular/core"; + +import { SharedModule } from "../../../shared"; +import { PipesModule } from "../../../vault/pipes/pipes.module"; + +import { GroupNameBadgeComponent } from "./group-name-badge.component"; + +@NgModule({ + imports: [SharedModule, PipesModule], + declarations: [GroupNameBadgeComponent], + exports: [GroupNameBadgeComponent], +}) +export class GroupBadgeModule {} diff --git a/apps/web/src/app/organizations/vault/group-badge/group-name-badge.component.html b/apps/web/src/app/organizations/vault/group-badge/group-name-badge.component.html new file mode 100644 index 00000000000..5115cadaad0 --- /dev/null +++ b/apps/web/src/app/organizations/vault/group-badge/group-name-badge.component.html @@ -0,0 +1,6 @@ + + {{ c.id | groupNameFromId: allGroups }} + + + + {{ xMoreCount }} more + diff --git a/apps/web/src/app/organizations/vault/group-badge/group-name-badge.component.ts b/apps/web/src/app/organizations/vault/group-badge/group-name-badge.component.ts new file mode 100644 index 00000000000..480f01aca78 --- /dev/null +++ b/apps/web/src/app/organizations/vault/group-badge/group-name-badge.component.ts @@ -0,0 +1,26 @@ +import { Component, Input } from "@angular/core"; + +import { SelectionReadOnlyRequest } from "@bitwarden/common/models/request/selection-read-only.request"; + +import { GroupView } from "../../core"; + +@Component({ + selector: "app-group-badge", + templateUrl: "group-name-badge.component.html", +}) +export class GroupNameBadgeComponent { + @Input() selectedGroups: SelectionReadOnlyRequest[]; + @Input() allGroups: GroupView[]; + + get shownGroups(): SelectionReadOnlyRequest[] { + return this.showXMore ? this.selectedGroups.slice(0, 2) : this.selectedGroups; + } + + get showXMore(): boolean { + return this.selectedGroups.length > 3; + } + + get xMoreCount(): number { + return this.selectedGroups.length - 2; + } +} diff --git a/apps/web/src/app/organizations/vault/vault-filter/vault-filter.component.ts b/apps/web/src/app/organizations/vault/vault-filter/vault-filter.component.ts index 66c01e96a3d..0ef04fab456 100644 --- a/apps/web/src/app/organizations/vault/vault-filter/vault-filter.component.ts +++ b/apps/web/src/app/organizations/vault/vault-filter/vault-filter.component.ts @@ -29,7 +29,9 @@ export class VaultFilterComponent extends BaseVaultFilterComponent implements On async ngOnInit() { this.filters = await this.buildAllFilters(); if (!this.activeFilter.selectedCipherTypeNode) { - this.applyCollectionFilter((await this.getDefaultFilter()) as TreeNode); + this.activeFilter.resetFilter(); + this.activeFilter.selectedCollectionNode = + (await this.getDefaultFilter()) as TreeNode; } this.isLoaded = true; } diff --git a/apps/web/src/app/organizations/vault/vault-filter/vault-filter.service.ts b/apps/web/src/app/organizations/vault/vault-filter/vault-filter.service.ts index 955354dd3e3..737ac431256 100644 --- a/apps/web/src/app/organizations/vault/vault-filter/vault-filter.service.ts +++ b/apps/web/src/app/organizations/vault/vault-filter/vault-filter.service.ts @@ -1,25 +1,34 @@ -import { Injectable } from "@angular/core"; -import { combineLatestWith, ReplaySubject, switchMap, takeUntil } from "rxjs"; +import { Injectable, OnDestroy } from "@angular/core"; +import { filter, map, Observable, ReplaySubject, Subject, switchMap, takeUntil } from "rxjs"; -import { ApiService } from "@bitwarden/common/abstractions/api.service"; import { CipherService } from "@bitwarden/common/abstractions/cipher.service"; import { CollectionService } from "@bitwarden/common/abstractions/collection.service"; import { FolderService } from "@bitwarden/common/abstractions/folder/folder.service.abstraction"; import { I18nService } from "@bitwarden/common/abstractions/i18n.service"; -import { OrganizationService } from "@bitwarden/common/abstractions/organization/organization.service.abstraction"; +import { + canAccessVaultTab, + OrganizationService, +} from "@bitwarden/common/abstractions/organization/organization.service.abstraction"; import { PolicyService } from "@bitwarden/common/abstractions/policy/policy.service.abstraction"; import { StateService } from "@bitwarden/common/abstractions/state.service"; -import { CollectionData } from "@bitwarden/common/models/data/collection.data"; -import { Collection } from "@bitwarden/common/models/domain/collection"; import { Organization } from "@bitwarden/common/models/domain/organization"; -import { CollectionDetailsResponse } from "@bitwarden/common/models/response/collection.response"; -import { CollectionView } from "@bitwarden/common/models/view/collection.view"; +import { TreeNode } from "@bitwarden/common/models/domain/tree-node"; import { VaultFilterService as BaseVaultFilterService } from "../../../vault/vault-filter/services/vault-filter.service"; +import { CollectionFilter } from "../../../vault/vault-filter/shared/models/vault-filter.type"; +import { CollectionAdminView } from "../../core"; +import { CollectionAdminService } from "../../core/services/collection-admin.service"; @Injectable() -export class VaultFilterService extends BaseVaultFilterService { - protected collectionViews$ = new ReplaySubject(1); +export class VaultFilterService extends BaseVaultFilterService implements OnDestroy { + private destroy$ = new Subject(); + private _collections = new ReplaySubject(1); + + filteredCollections$: Observable = this._collections.asObservable(); + + collectionTree$: Observable> = this.filteredCollections$.pipe( + map((collections) => this.buildCollectionTree(collections)) + ); constructor( stateService: StateService, @@ -28,8 +37,8 @@ export class VaultFilterService extends BaseVaultFilterService { cipherService: CipherService, collectionService: CollectionService, policyService: PolicyService, - protected apiService: ApiService, - i18nService: I18nService + i18nService: I18nService, + protected collectionAdminService: CollectionAdminService ) { super( stateService, @@ -40,67 +49,42 @@ export class VaultFilterService extends BaseVaultFilterService { policyService, i18nService ); + this.loadSubscriptions(); } protected loadSubscriptions() { - this.folderService.folderViews$ - .pipe( - combineLatestWith(this._organizationFilter), - switchMap(async ([folders, org]) => { - return this.filterFolders(folders, org); - }), - takeUntil(this.destroy$) - ) - .subscribe(this._filteredFolders); - this._organizationFilter .pipe( + filter((org) => org != null), switchMap((org) => { return this.loadCollections(org); - }) - ) - .subscribe(this.collectionViews$); - - this.collectionViews$ - .pipe( - combineLatestWith(this._organizationFilter), - switchMap(async ([collections, org]) => { - if (org?.canUseAdminCollections) { - return collections; - } else { - return await this.filterCollections(collections, org); - } }), takeUntil(this.destroy$) ) - .subscribe(this._filteredCollections); + .subscribe((collections) => { + this._collections.next(collections); + }); } - protected async loadCollections(org: Organization) { - if (org?.permissions && org?.canEditAnyCollection) { - return await this.loadAdminCollections(org); - } else { - // TODO: remove when collections is refactored with observables - return await this.collectionService.getAllDecrypted(); - } + async reloadCollections() { + this._collections.next(await this.loadCollections(this._organizationFilter.getValue())); } - async loadAdminCollections(org: Organization): Promise { - let collections: CollectionView[] = []; - if (org?.permissions && org?.canEditAnyCollection) { - const collectionResponse = await this.apiService.getCollections(org.id); - if (collectionResponse?.data != null && collectionResponse.data.length) { - const collectionDomains = collectionResponse.data.map( - (r: CollectionDetailsResponse) => new Collection(new CollectionData(r)) - ); - collections = await this.collectionService.decryptMany(collectionDomains); - } + protected async loadCollections(org: Organization): Promise { + let collections: CollectionAdminView[] = []; + if (canAccessVaultTab(org)) { + collections = await this.collectionAdminService.getAll(org.id); - const noneCollection = new CollectionView(); + const noneCollection = new CollectionAdminView(); noneCollection.name = this.i18nService.t("unassigned"); noneCollection.organizationId = org.id; collections.push(noneCollection); } return collections; } + + ngOnDestroy() { + this.destroy$.next(); + this.destroy$.complete(); + } } diff --git a/apps/web/src/app/organizations/vault/vault-items.component.ts b/apps/web/src/app/organizations/vault/vault-items.component.ts index e7ee2cb28e6..40dce0e8a35 100644 --- a/apps/web/src/app/organizations/vault/vault-items.component.ts +++ b/apps/web/src/app/organizations/vault/vault-items.component.ts @@ -1,5 +1,7 @@ -import { Component, EventEmitter, Output } from "@angular/core"; +import { Component, EventEmitter, Input, OnDestroy, Output } from "@angular/core"; +import { lastValueFrom } from "rxjs"; +import { SearchPipe } from "@bitwarden/angular/pipes/search.pipe"; import { ApiService } from "@bitwarden/common/abstractions/api.service"; import { CipherService } from "@bitwarden/common/abstractions/cipher.service"; import { EventCollectionService } from "@bitwarden/common/abstractions/event/event-collection.service"; @@ -12,15 +14,40 @@ import { SearchService } from "@bitwarden/common/abstractions/search.service"; import { StateService } from "@bitwarden/common/abstractions/state.service"; import { TokenService } from "@bitwarden/common/abstractions/token.service"; import { TotpService } from "@bitwarden/common/abstractions/totp.service"; +import { Organization } from "@bitwarden/common/models/domain/organization"; +import { TreeNode } from "@bitwarden/common/models/domain/tree-node"; import { CipherView } from "@bitwarden/common/models/view/cipher.view"; +import { CollectionView } from "@bitwarden/common/models/view/collection.view"; +import { DialogService } from "@bitwarden/components"; -import { VaultItemsComponent as BaseVaultItemsComponent } from "../../vault/vault-items.component"; +import { + BulkDeleteDialogResult, + openBulkDeleteDialog, +} from "../../vault/bulk-action-dialogs/bulk-delete-dialog/bulk-delete-dialog.component"; +import { VaultFilterService } from "../../vault/vault-filter/services/abstractions/vault-filter.service"; +import { CollectionFilter } from "../../vault/vault-filter/shared/models/vault-filter.type"; +import { + VaultItemsComponent as BaseVaultItemsComponent, + VaultItemRow, +} from "../../vault/vault-items.component"; +import { GroupService } from "../core/services/group/group.service"; +import { + CollectionDialogResult, + CollectionDialogTabType, + openCollectionDialog, +} from "../shared/components/collection-dialog/collection-dialog.component"; + +const MaxCheckedCount = 500; @Component({ selector: "app-org-vault-items", templateUrl: "../../vault/vault-items.component.html", }) -export class VaultItemsComponent extends BaseVaultItemsComponent { +export class VaultItemsComponent extends BaseVaultItemsComponent implements OnDestroy { + @Input() set initOrganization(value: Organization) { + this.organization = value; + this.changeOrganization(); + } @Output() onEventsClicked = new EventEmitter(); protected allCiphers: CipherView[] = []; @@ -30,53 +57,81 @@ export class VaultItemsComponent extends BaseVaultItemsComponent { i18nService: I18nService, platformUtilsService: PlatformUtilsService, cipherService: CipherService, + vaultFilterService: VaultFilterService, eventCollectionService: EventCollectionService, totpService: TotpService, passwordRepromptService: PasswordRepromptService, + dialogService: DialogService, logService: LogService, stateService: StateService, organizationService: OrganizationService, tokenService: TokenService, + searchPipe: SearchPipe, + protected groupService: GroupService, private apiService: ApiService ) { super( searchService, i18nService, platformUtilsService, + vaultFilterService, cipherService, eventCollectionService, totpService, stateService, passwordRepromptService, + dialogService, logService, + searchPipe, organizationService, tokenService ); } - async load(filter: (cipher: CipherView) => boolean = null, deleted = false) { - this.deleted = deleted || false; - if (this.organization.canEditAnyCollection) { - this.accessEvents = this.organization.useEvents; - this.allCiphers = await this.cipherService.getAllFromApiForOrganization(this.organization.id); + ngOnDestroy() { + super.ngOnDestroy(); + } + + async changeOrganization() { + this.groups = await this.groupService.getAll(this.organization?.id); + await this.loadCiphers(); + await this.reload(this.activeFilter.buildFilter()); + } + + async loadCiphers() { + if (this.organization?.canEditAnyCollection) { + this.accessEvents = this.organization?.useEvents; + this.allCiphers = await this.cipherService.getAllFromApiForOrganization( + this.organization?.id + ); } else { this.allCiphers = (await this.cipherService.getAllDecrypted()).filter( - (c) => c.organizationId === this.organization.id + (c) => c.organizationId === this.organization?.id ); } - await this.searchService.indexCiphers(this.organization.id, this.allCiphers); + await this.searchService.indexCiphers(this.organization?.id, this.allCiphers); + } + + async refreshCollections(): Promise { + await this.vaultFilterService.reloadCollections(); + if (this.activeFilter.selectedCollectionNode) { + this.activeFilter.selectedCollectionNode = + await this.vaultFilterService.getCollectionNodeFromTree( + this.activeFilter.selectedCollectionNode.node.id + ); + } + } + + async load(filter: (cipher: CipherView) => boolean = null, deleted = false) { + this.deleted = deleted ?? false; await this.applyFilter(filter); this.loaded = true; } - async applyFilter(filter: (cipher: CipherView) => boolean = null) { - if (this.organization.canViewAllCollections) { - await super.applyFilter(filter); - } else { - const f = (c: CipherView) => - c.organizationId === this.organization.id && (filter == null || filter(c)); - await super.applyFilter(f); - } + async refresh() { + await this.loadCiphers(); + await this.refreshCollections(); + super.refresh(); } async search(timeout: number = null) { @@ -87,16 +142,136 @@ export class VaultItemsComponent extends BaseVaultItemsComponent { this.onEventsClicked.emit(c); } - protected deleteCipher(id: string) { - if (!this.organization.canEditAnyCollection) { - return super.deleteCipher(id, this.deleted); + protected showFixOldAttachments(c: CipherView) { + return this.organization?.canEditAnyCollection && c.hasOldAttachments; + } + + checkAll(select: boolean) { + if (select) { + this.checkAll(false); } - return this.deleted + + const items: VaultItemRow[] = [...this.collections, ...this.ciphers]; + if (!items.length) { + return; + } + + const selectCount = select && items.length > MaxCheckedCount ? MaxCheckedCount : items.length; + for (let i = 0; i < selectCount; i++) { + this.checkRow(items[i], select); + } + } + + selectRow(item: VaultItemRow) { + this.checkRow(item); + } + + checkRow(item: VaultItemRow, select?: boolean) { + if (item instanceof TreeNode && item.node.name == "Unassigned") { + return; + } + item.checked = select ?? !item.checked; + } + + get selectedCollections(): TreeNode[] { + if (!this.collections) { + return []; + } + return this.collections.filter((c) => !!(c as VaultItemRow).checked); + } + + get selectedCollectionIds(): string[] { + return this.selectedCollections.map((c) => c.node.id); + } + + async editCollection(c: CollectionView, tab: "info" | "access"): Promise { + const tabType = tab == "info" ? CollectionDialogTabType.Info : CollectionDialogTabType.Access; + + const dialog = openCollectionDialog(this.dialogService, { + data: { collectionId: c?.id, organizationId: this.organization?.id, initialTab: tabType }, + }); + + const result = await lastValueFrom(dialog.closed); + if (result === CollectionDialogResult.Saved || result === CollectionDialogResult.Deleted) { + this.actionPromise = this.refresh(); + await this.actionPromise; + this.actionPromise = null; + } + } + + async deleteCollection(collection: CollectionView): Promise { + if (!this.organization.canDeleteAssignedCollections) { + this.platformUtilsService.showToast( + "error", + this.i18nService.t("errorOccurred"), + this.i18nService.t("missingPermissions") + ); + return; + } + const confirmed = await this.platformUtilsService.showDialog( + this.i18nService.t("deleteCollectionConfirmation"), + collection.name, + this.i18nService.t("yes"), + this.i18nService.t("no"), + "warning" + ); + if (!confirmed) { + return; + } + try { + this.actionPromise = this.apiService.deleteCollection(this.organization?.id, collection.id); + await this.actionPromise; + this.platformUtilsService.showToast( + "success", + null, + this.i18nService.t("deletedCollectionId", collection.name) + ); + await this.refresh(); + } catch (e) { + this.logService.error(e); + } + } + + async bulkDelete() { + if (!(await this.repromptCipher())) { + return; + } + + const selectedCipherIds = this.selectedCipherIds; + const selectedCollectionIds = this.deleted ? null : this.selectedCollectionIds; + + if (!selectedCipherIds?.length && !selectedCollectionIds?.length) { + this.platformUtilsService.showToast( + "error", + this.i18nService.t("errorOccurred"), + this.i18nService.t("nothingSelected") + ); + return; + } + + const dialog = openBulkDeleteDialog(this.dialogService, { + data: { + permanent: this.deleted, + cipherIds: selectedCipherIds, + collectionIds: selectedCollectionIds, + organization: this.organization, + }, + }); + + const result = await lastValueFrom(dialog.closed); + if (result === BulkDeleteDialogResult.Deleted) { + this.actionPromise = this.refresh(); + await this.actionPromise; + this.actionPromise = null; + } + } + + protected deleteCipherWithServer(id: string, permanent: boolean) { + if (!this.organization?.canEditAnyCollection) { + return super.deleteCipherWithServer(id, this.deleted); + } + return permanent ? this.apiService.deleteCipherAdmin(id) : this.apiService.putDeleteCipherAdmin(id); } - - protected showFixOldAttachments(c: CipherView) { - return this.organization.canEditAnyCollection && c.hasOldAttachments; - } } diff --git a/apps/web/src/app/organizations/vault/vault-routing.module.ts b/apps/web/src/app/organizations/vault/vault-routing.module.ts index bd61dab8b90..b48c3118e75 100644 --- a/apps/web/src/app/organizations/vault/vault-routing.module.ts +++ b/apps/web/src/app/organizations/vault/vault-routing.module.ts @@ -1,12 +1,17 @@ import { NgModule } from "@angular/core"; import { RouterModule, Routes } from "@angular/router"; +import { canAccessVaultTab } from "@bitwarden/common/abstractions/organization/organization.service.abstraction"; + +import { OrganizationPermissionsGuard } from "../guards/org-permissions.guard"; + import { VaultComponent } from "./vault.component"; const routes: Routes = [ { path: "", component: VaultComponent, - data: { titleId: "vaults" }, + canActivate: [OrganizationPermissionsGuard], + data: { titleId: "vaults", organizationPermissions: canAccessVaultTab }, }, ]; @NgModule({ diff --git a/apps/web/src/app/organizations/vault/vault.component.html b/apps/web/src/app/organizations/vault/vault.component.html index aff0437309a..bc63b5b9187 100644 --- a/apps/web/src/app/organizations/vault/vault.component.html +++ b/apps/web/src/app/organizations/vault/vault.component.html @@ -55,10 +55,13 @@ {{ trashCleanupWarning }} diff --git a/apps/web/src/app/organizations/vault/vault.component.ts b/apps/web/src/app/organizations/vault/vault.component.ts index 477c795ccba..dfcd681fcb7 100644 --- a/apps/web/src/app/organizations/vault/vault.component.ts +++ b/apps/web/src/app/organizations/vault/vault.component.ts @@ -8,8 +8,8 @@ import { ViewContainerRef, } from "@angular/core"; import { ActivatedRoute, Params, Router } from "@angular/router"; -import { firstValueFrom } from "rxjs"; -import { first } from "rxjs/operators"; +import { combineLatest, firstValueFrom, Subject } from "rxjs"; +import { first, switchMap, takeUntil } from "rxjs/operators"; import { ModalService } from "@bitwarden/angular/services/modal.service"; import { BroadcasterService } from "@bitwarden/common/abstractions/broadcaster.service"; @@ -55,11 +55,12 @@ export class VaultComponent implements OnInit, OnDestroy { organization: Organization; trashCleanupWarning: string = null; activeFilter: VaultFilter = new VaultFilter(); + private destroy$ = new Subject(); constructor( private route: ActivatedRoute, private organizationService: OrganizationService, - private vaultFilterService: VaultFilterService, + protected vaultFilterService: VaultFilterService, private router: Router, private changeDetectorRef: ChangeDetectorRef, private syncService: SyncService, @@ -73,82 +74,75 @@ export class VaultComponent implements OnInit, OnDestroy { private passwordRepromptService: PasswordRepromptService ) {} - ngOnInit() { + async ngOnInit() { this.trashCleanupWarning = this.i18nService.t( this.platformUtilsService.isSelfHost() ? "trashCleanupWarningSelfHosted" : "trashCleanupWarning" ); - // eslint-disable-next-line rxjs-angular/prefer-takeuntil, rxjs/no-async-subscribe - this.route.parent.params.subscribe(async (params: any) => { + + this.route.parent.params.pipe(takeUntil(this.destroy$)).subscribe((params) => { this.organization = this.organizationService.get(params.organizationId); - this.vaultItemsComponent.organization = this.organization; + }); - /* eslint-disable-next-line rxjs-angular/prefer-takeuntil, rxjs/no-async-subscribe, rxjs/no-nested-subscribe */ - this.route.queryParams.pipe(first()).subscribe(async (qParams) => { - this.vaultItemsComponent.searchText = this.vaultFilterComponent.searchText = qParams.search; - if (!this.organization.canViewAllCollections) { - await this.syncService.fullSync(false); - this.broadcasterService.subscribe(BroadcasterSubscriptionId, (message: any) => { - this.ngZone.run(async () => { - switch (message.command) { - case "syncCompleted": - if (message.successfully) { - await Promise.all([ - this.vaultFilterService.reloadCollections(), - this.vaultItemsComponent.refresh(), - ]); - this.changeDetectorRef.detectChanges(); - } - break; - } - }); - }); - } + this.route.queryParams.pipe(first(), takeUntil(this.destroy$)).subscribe((qParams) => { + this.vaultItemsComponent.searchText = this.vaultFilterComponent.searchText = qParams.search; + }); - await this.vaultItemsComponent.reload( - this.activeFilter.buildFilter(), - this.activeFilter.isDeleted - ); - - if (qParams.viewEvents != null) { - const cipher = this.vaultItemsComponent.ciphers.filter( - (c) => c.id === qParams.viewEvents - ); - if (cipher.length > 0) { - this.viewEvents(cipher[0]); + // verifies that the organization has been set + combineLatest([this.route.queryParams, this.route.parent.params]) + .pipe( + switchMap(async ([qParams, params]) => { + const cipherId = getCipherIdFromParams(qParams); + if (!cipherId) { + return; } - } + if ( + // Handle users with implicit collection access since they use the admin endpoint + this.organization.canUseAdminCollections || + (await this.cipherService.get(cipherId)) != null + ) { + this.editCipherId(cipherId); + } else { + this.platformUtilsService.showToast( + "error", + this.i18nService.t("errorOccurred"), + this.i18nService.t("unknownCipher") + ); + this.router.navigate([], { + queryParams: { cipherId: null, itemId: null }, + queryParamsHandling: "merge", + }); + } + }), + takeUntil(this.destroy$) + ) + .subscribe(); - /* eslint-disable-next-line rxjs-angular/prefer-takeuntil, rxjs/no-async-subscribe, rxjs/no-nested-subscribe */ - this.route.queryParams.subscribe(async (params) => { - const cipherId = getCipherIdFromParams(params); - if (cipherId) { - if ( - // Handle users with implicit collection access since they use the admin endpoint - this.organization.canUseAdminCollections || - (await this.cipherService.get(cipherId)) != null - ) { - this.editCipherId(cipherId); - } else { - this.platformUtilsService.showToast( - "error", - this.i18nService.t("errorOccurred"), - this.i18nService.t("unknownCipher") - ); - this.router.navigate([], { - queryParams: { cipherId: null, itemId: null }, - queryParamsHandling: "merge", - }); - } + if (!this.organization.canUseAdminCollections) { + this.broadcasterService.subscribe(BroadcasterSubscriptionId, (message: any) => { + this.ngZone.run(async () => { + switch (message.command) { + case "syncCompleted": + if (message.successfully) { + await Promise.all([ + this.vaultFilterService.reloadCollections(), + this.vaultItemsComponent.refresh(), + ]); + this.changeDetectorRef.detectChanges(); + } + break; } }); }); - }); + await this.syncService.fullSync(false); + } } ngOnDestroy() { this.broadcasterService.unsubscribe(BroadcasterSubscriptionId); + this.destroy$.next(); + this.destroy$.complete(); } async applyVaultFilter(filter: VaultFilter) { diff --git a/apps/web/src/app/organizations/vault/vault.module.ts b/apps/web/src/app/organizations/vault/vault.module.ts index d0f073a880d..2867e340abe 100644 --- a/apps/web/src/app/organizations/vault/vault.module.ts +++ b/apps/web/src/app/organizations/vault/vault.module.ts @@ -5,6 +5,8 @@ import { SharedModule } from "../../shared/shared.module"; import { OrganizationBadgeModule } from "../../vault/organization-badge/organization-badge.module"; import { PipesModule } from "../../vault/pipes/pipes.module"; +import { CollectionBadgeModule } from "./collection-badge/collection-badge.module"; +import { GroupBadgeModule } from "./group-badge/group-badge.module"; import { VaultFilterModule } from "./vault-filter/vault-filter.module"; import { VaultItemsComponent } from "./vault-items.component"; import { VaultRoutingModule } from "./vault-routing.module"; @@ -16,6 +18,8 @@ import { VaultComponent } from "./vault.component"; VaultFilterModule, SharedModule, LooseComponentsModule, + GroupBadgeModule, + CollectionBadgeModule, OrganizationBadgeModule, PipesModule, ], diff --git a/apps/web/src/app/shared/loose-components.module.ts b/apps/web/src/app/shared/loose-components.module.ts index 66a30146e63..0b90ae9fc39 100644 --- a/apps/web/src/app/shared/loose-components.module.ts +++ b/apps/web/src/app/shared/loose-components.module.ts @@ -104,10 +104,6 @@ import { AddEditCustomFieldsComponent } from "../vault/add-edit-custom-fields.co import { AddEditComponent } from "../vault/add-edit.component"; import { AttachmentsComponent } from "../vault/attachments.component"; import { BulkActionsComponent } from "../vault/bulk-actions.component"; -import { BulkDeleteComponent } from "../vault/bulk-delete.component"; -import { BulkMoveComponent } from "../vault/bulk-move.component"; -import { BulkRestoreComponent } from "../vault/bulk-restore.component"; -import { BulkShareComponent } from "../vault/bulk-share.component"; import { CollectionsComponent } from "../vault/collections.component"; import { FolderAddEditComponent } from "../vault/folder-add-edit.component"; import { ShareComponent } from "../vault/share.component"; @@ -135,10 +131,6 @@ import { SharedModule } from "./shared.module"; AttachmentsComponent, BillingSyncKeyComponent, BulkActionsComponent, - BulkDeleteComponent, - BulkMoveComponent, - BulkRestoreComponent, - BulkShareComponent, ChangeEmailComponent, ChangeKdfComponent, ChangePasswordComponent, @@ -246,10 +238,6 @@ import { SharedModule } from "./shared.module"; ApiKeyComponent, AttachmentsComponent, BulkActionsComponent, - BulkDeleteComponent, - BulkMoveComponent, - BulkRestoreComponent, - BulkShareComponent, ChangeEmailComponent, ChangeKdfComponent, ChangePasswordComponent, diff --git a/apps/web/src/app/shared/shared.module.ts b/apps/web/src/app/shared/shared.module.ts index 3d6ab6160e0..37c842fddc4 100644 --- a/apps/web/src/app/shared/shared.module.ts +++ b/apps/web/src/app/shared/shared.module.ts @@ -13,10 +13,10 @@ import { BadgeListModule, BadgeModule, ButtonModule, + IconButtonModule, CalloutModule, DialogModule, FormFieldModule, - IconButtonModule, IconModule, LinkModule, MenuModule, @@ -56,15 +56,16 @@ import "./locales"; ButtonModule, CalloutModule, DialogModule, - MultiSelectModule, FormFieldModule, IconButtonModule, IconModule, LinkModule, MenuModule, + MultiSelectModule, NavigationModule, TableModule, TabsModule, + ToggleGroupModule, // Web specific ], @@ -86,16 +87,16 @@ import "./locales"; ButtonModule, CalloutModule, DialogModule, - MultiSelectModule, FormFieldModule, IconButtonModule, IconModule, + LinkModule, MenuModule, + MultiSelectModule, NavigationModule, TableModule, - ToggleGroupModule, - LinkModule, TabsModule, + ToggleGroupModule, // Web specific ], diff --git a/apps/web/src/app/vault/bulk-action-dialogs/bulk-delete-dialog/bulk-delete-dialog.component.html b/apps/web/src/app/vault/bulk-action-dialogs/bulk-delete-dialog/bulk-delete-dialog.component.html new file mode 100644 index 00000000000..b0d71fe3b8a --- /dev/null +++ b/apps/web/src/app/vault/bulk-action-dialogs/bulk-delete-dialog/bulk-delete-dialog.component.html @@ -0,0 +1,25 @@ + + + {{ (permanent ? "permanentlyDeleteSelected" : "deleteSelected") | i18n }} + + + + + {{ "deleteSelectedItemsDesc" | i18n: cipherIds.length }} + + + {{ "deleteSelectedCollectionsDesc" | i18n: collectionIds.length }} + + {{ "deleteSelectedConfirmation" | i18n }} + + + {{ "permanentlyDeleteSelectedItemsDesc" | i18n: cipherIds.length }} + + +
+ + +
+
diff --git a/apps/web/src/app/vault/bulk-action-dialogs/bulk-delete-dialog/bulk-delete-dialog.component.ts b/apps/web/src/app/vault/bulk-action-dialogs/bulk-delete-dialog/bulk-delete-dialog.component.ts new file mode 100644 index 00000000000..e4a88b18c68 --- /dev/null +++ b/apps/web/src/app/vault/bulk-action-dialogs/bulk-delete-dialog/bulk-delete-dialog.component.ts @@ -0,0 +1,134 @@ +import { DialogConfig, DialogRef, DIALOG_DATA } from "@angular/cdk/dialog"; +import { Component, Inject } from "@angular/core"; + +import { ApiService } from "@bitwarden/common/abstractions/api.service"; +import { CipherService } from "@bitwarden/common/abstractions/cipher.service"; +import { I18nService } from "@bitwarden/common/abstractions/i18n.service"; +import { PlatformUtilsService } from "@bitwarden/common/abstractions/platformUtils.service"; +import { Organization } from "@bitwarden/common/models/domain/organization"; +import { CipherBulkDeleteRequest } from "@bitwarden/common/models/request/cipher-bulk-delete.request"; +import { CollectionBulkDeleteRequest } from "@bitwarden/common/models/request/collection-bulk-delete.request"; +import { DialogService } from "@bitwarden/components"; + +export interface BulkDeleteDialogParams { + cipherIds?: string[]; + collectionIds?: string[]; + permanent?: boolean; + organization?: Organization; +} + +export enum BulkDeleteDialogResult { + Deleted = "deleted", + Canceled = "canceled", +} + +/** + * Strongly typed helper to open a BulkDeleteDialog + * @param dialogService Instance of the dialog service that will be used to open the dialog + * @param config Configuration for the dialog + */ +export const openBulkDeleteDialog = ( + dialogService: DialogService, + config: DialogConfig +) => { + return dialogService.open( + BulkDeleteDialogComponent, + config + ); +}; + +@Component({ + selector: "vault-bulk-delete-dialog", + templateUrl: "bulk-delete-dialog.component.html", +}) +export class BulkDeleteDialogComponent { + cipherIds: string[]; + collectionIds: string[]; + permanent = false; + organization: Organization; + + constructor( + @Inject(DIALOG_DATA) params: BulkDeleteDialogParams, + private dialogRef: DialogRef, + private cipherService: CipherService, + private platformUtilsService: PlatformUtilsService, + private i18nService: I18nService, + private apiService: ApiService + ) { + this.cipherIds = params.cipherIds ?? []; + this.collectionIds = params.collectionIds ?? []; + this.permanent = params.permanent; + this.organization = params.organization; + } + + protected async cancel() { + this.close(BulkDeleteDialogResult.Canceled); + } + + protected submit = async () => { + const deletePromises: Promise[] = []; + if (this.cipherIds.length) { + if (!this.organization || !this.organization.canEditAnyCollection) { + deletePromises.push(this.deleteCiphers()); + } else { + deletePromises.push(this.deleteCiphersAdmin()); + } + } + + if (this.collectionIds.length && this.organization) { + deletePromises.push(this.deleteCollections()); + } + + await Promise.all(deletePromises); + + if (this.cipherIds.length) { + this.platformUtilsService.showToast( + "success", + null, + this.i18nService.t(this.permanent ? "permanentlyDeletedItems" : "deletedItems") + ); + } + if (this.collectionIds.length) { + this.platformUtilsService.showToast( + "success", + null, + this.i18nService.t("deletedCollections") + ); + } + this.close(BulkDeleteDialogResult.Deleted); + }; + + private async deleteCiphers(): Promise { + if (this.permanent) { + await this.cipherService.deleteManyWithServer(this.cipherIds); + } else { + await this.cipherService.softDeleteManyWithServer(this.cipherIds); + } + } + + private async deleteCiphersAdmin(): Promise { + const deleteRequest = new CipherBulkDeleteRequest(this.cipherIds, this.organization.id); + if (this.permanent) { + return await this.apiService.deleteManyCiphersAdmin(deleteRequest); + } else { + return await this.apiService.putDeleteManyCiphersAdmin(deleteRequest); + } + } + + private async deleteCollections(): Promise { + if (!this.organization.canDeleteAssignedCollections) { + this.platformUtilsService.showToast( + "error", + this.i18nService.t("errorOccurred"), + this.i18nService.t("missingPermissions") + ); + return; + } + const deleteRequest = new CollectionBulkDeleteRequest(this.collectionIds, this.organization.id); + return await this.apiService.deleteManyCollections(deleteRequest); + } + + private close(result: BulkDeleteDialogResult) { + this.dialogRef.close(result); + } +} diff --git a/apps/web/src/app/vault/bulk-action-dialogs/bulk-dialogs.module.ts b/apps/web/src/app/vault/bulk-action-dialogs/bulk-dialogs.module.ts new file mode 100644 index 00000000000..90d49ee58e3 --- /dev/null +++ b/apps/web/src/app/vault/bulk-action-dialogs/bulk-dialogs.module.ts @@ -0,0 +1,25 @@ +import { NgModule } from "@angular/core"; + +import { SharedModule } from "../../shared"; + +import { BulkDeleteDialogComponent } from "./bulk-delete-dialog/bulk-delete-dialog.component"; +import { BulkMoveDialogComponent } from "./bulk-move-dialog/bulk-move-dialog.component"; +import { BulkRestoreDialogComponent } from "./bulk-restore-dialog/bulk-restore-dialog.component"; +import { BulkShareDialogComponent } from "./bulk-share-dialog/bulk-share-dialog.component"; + +@NgModule({ + imports: [SharedModule], + declarations: [ + BulkDeleteDialogComponent, + BulkMoveDialogComponent, + BulkRestoreDialogComponent, + BulkShareDialogComponent, + ], + exports: [ + BulkDeleteDialogComponent, + BulkMoveDialogComponent, + BulkRestoreDialogComponent, + BulkShareDialogComponent, + ], +}) +export class BulkDialogsModule {} diff --git a/apps/web/src/app/vault/bulk-action-dialogs/bulk-move-dialog/bulk-move-dialog.component.html b/apps/web/src/app/vault/bulk-action-dialogs/bulk-move-dialog/bulk-move-dialog.component.html new file mode 100644 index 00000000000..107ed4d4972 --- /dev/null +++ b/apps/web/src/app/vault/bulk-action-dialogs/bulk-move-dialog/bulk-move-dialog.component.html @@ -0,0 +1,24 @@ +
+ + + {{ "moveSelected" | i18n }} + + +

{{ "moveSelectedItemsDesc" | i18n: cipherIds.length }}

+ + {{ "folder" | i18n }} + + +
+
+ + +
+
+
diff --git a/apps/web/src/app/vault/bulk-action-dialogs/bulk-move-dialog/bulk-move-dialog.component.ts b/apps/web/src/app/vault/bulk-action-dialogs/bulk-move-dialog/bulk-move-dialog.component.ts new file mode 100644 index 00000000000..99688fcdf10 --- /dev/null +++ b/apps/web/src/app/vault/bulk-action-dialogs/bulk-move-dialog/bulk-move-dialog.component.ts @@ -0,0 +1,85 @@ +import { DialogConfig, DialogRef, DIALOG_DATA } from "@angular/cdk/dialog"; +import { Component, Inject, OnInit } from "@angular/core"; +import { FormBuilder, Validators } from "@angular/forms"; +import { firstValueFrom, Observable } from "rxjs"; + +import { CipherService } from "@bitwarden/common/abstractions/cipher.service"; +import { FolderService } from "@bitwarden/common/abstractions/folder/folder.service.abstraction"; +import { I18nService } from "@bitwarden/common/abstractions/i18n.service"; +import { PlatformUtilsService } from "@bitwarden/common/abstractions/platformUtils.service"; +import { FolderView } from "@bitwarden/common/models/view/folder.view"; +import { DialogService } from "@bitwarden/components"; + +export interface BulkMoveDialogParams { + cipherIds?: string[]; +} + +export enum BulkMoveDialogResult { + Moved = "moved", + Canceled = "canceled", +} + +/** + * Strongly typed helper to open a BulkMoveDialog + * @param dialogService Instance of the dialog service that will be used to open the dialog + * @param config Configuration for the dialog + */ +export const openBulkMoveDialog = ( + dialogService: DialogService, + config: DialogConfig +) => { + return dialogService.open( + BulkMoveDialogComponent, + config + ); +}; + +@Component({ + selector: "vault-bulk-move-dialog", + templateUrl: "bulk-move-dialog.component.html", +}) +export class BulkMoveDialogComponent implements OnInit { + cipherIds: string[] = []; + + formGroup = this.formBuilder.group({ + folderId: ["", [Validators.required]], + }); + folders$: Observable; + + constructor( + @Inject(DIALOG_DATA) params: BulkMoveDialogParams, + private dialogRef: DialogRef, + private cipherService: CipherService, + private platformUtilsService: PlatformUtilsService, + private i18nService: I18nService, + private folderService: FolderService, + private formBuilder: FormBuilder + ) { + this.cipherIds = params.cipherIds ?? []; + } + + async ngOnInit() { + this.folders$ = this.folderService.folderViews$; + this.formGroup.patchValue({ + folderId: (await firstValueFrom(this.folders$))[0].id, + }); + } + + protected cancel() { + this.close(BulkMoveDialogResult.Canceled); + } + + protected submit = async () => { + if (this.formGroup.invalid) { + return; + } + + await this.cipherService.moveManyWithServer(this.cipherIds, this.formGroup.value.folderId); + this.platformUtilsService.showToast("success", null, this.i18nService.t("movedItems")); + this.close(BulkMoveDialogResult.Moved); + }; + + private close(result: BulkMoveDialogResult) { + this.dialogRef.close(result); + } +} diff --git a/apps/web/src/app/vault/bulk-action-dialogs/bulk-restore-dialog/bulk-restore-dialog.component.html b/apps/web/src/app/vault/bulk-action-dialogs/bulk-restore-dialog/bulk-restore-dialog.component.html new file mode 100644 index 00000000000..c91011ded5c --- /dev/null +++ b/apps/web/src/app/vault/bulk-action-dialogs/bulk-restore-dialog/bulk-restore-dialog.component.html @@ -0,0 +1,14 @@ + + + {{ "restoreSelected" | i18n }} + + + {{ "restoreSelectedItemsDesc" | i18n: cipherIds.length }} + +
+ + +
+
diff --git a/apps/web/src/app/vault/bulk-action-dialogs/bulk-restore-dialog/bulk-restore-dialog.component.ts b/apps/web/src/app/vault/bulk-action-dialogs/bulk-restore-dialog/bulk-restore-dialog.component.ts new file mode 100644 index 00000000000..78a47fc70c9 --- /dev/null +++ b/apps/web/src/app/vault/bulk-action-dialogs/bulk-restore-dialog/bulk-restore-dialog.component.ts @@ -0,0 +1,63 @@ +import { DialogConfig, DialogRef, DIALOG_DATA } from "@angular/cdk/dialog"; +import { Component, Inject } from "@angular/core"; + +import { CipherService } from "@bitwarden/common/abstractions/cipher.service"; +import { I18nService } from "@bitwarden/common/abstractions/i18n.service"; +import { PlatformUtilsService } from "@bitwarden/common/abstractions/platformUtils.service"; +import { DialogService } from "@bitwarden/components"; + +export interface BulkRestoreDialogParams { + cipherIds: string[]; +} + +export enum BulkRestoreDialogResult { + Restored = "restored", + Canceled = "canceled", +} + +/** + * Strongly typed helper to open a BulkRestoreDialog + * @param dialogService Instance of the dialog service that will be used to open the dialog + * @param config Configuration for the dialog + */ +export const openBulkRestoreDialog = ( + dialogService: DialogService, + config: DialogConfig +) => { + return dialogService.open( + BulkRestoreDialogComponent, + config + ); +}; + +@Component({ + selector: "vault-bulk-restore-dialog", + templateUrl: "bulk-restore-dialog.component.html", +}) +export class BulkRestoreDialogComponent { + cipherIds: string[]; + + constructor( + @Inject(DIALOG_DATA) params: BulkRestoreDialogParams, + private dialogRef: DialogRef, + private cipherService: CipherService, + private platformUtilsService: PlatformUtilsService, + private i18nService: I18nService + ) { + this.cipherIds = params.cipherIds ?? []; + } + + submit = async () => { + await this.cipherService.restoreManyWithServer(this.cipherIds); + this.platformUtilsService.showToast("success", null, this.i18nService.t("restoredItems")); + this.close(BulkRestoreDialogResult.Restored); + }; + + protected cancel() { + this.close(BulkRestoreDialogResult.Canceled); + } + + private close(result: BulkRestoreDialogResult) { + this.dialogRef.close(result); + } +} diff --git a/apps/web/src/app/vault/bulk-action-dialogs/bulk-share-dialog/bulk-share-dialog.component.html b/apps/web/src/app/vault/bulk-action-dialogs/bulk-share-dialog/bulk-share-dialog.component.html new file mode 100644 index 00000000000..720e45df946 --- /dev/null +++ b/apps/web/src/app/vault/bulk-action-dialogs/bulk-share-dialog/bulk-share-dialog.component.html @@ -0,0 +1,73 @@ + + + {{ "moveSelectedToOrg" | i18n }} + + +

{{ "moveManyToOrgDesc" | i18n }}

+

+ {{ + "moveSelectedItemsCountDesc" + | i18n: this.ciphers.length:shareableCiphers.length:nonShareableCount + }} +

+ + {{ "organization" | i18n }} + + + +
+ +
+ + +
+
+
+ {{ "noCollectionsInList" | i18n }} +
+ + + + + + + +
+ + + {{ c.name }} +
+
+
+ + +
+
diff --git a/apps/web/src/app/vault/bulk-share.component.ts b/apps/web/src/app/vault/bulk-action-dialogs/bulk-share-dialog/bulk-share-dialog.component.ts similarity index 69% rename from apps/web/src/app/vault/bulk-share.component.ts rename to apps/web/src/app/vault/bulk-action-dialogs/bulk-share-dialog/bulk-share-dialog.component.ts index 74fa1a30435..7cfe8228e72 100644 --- a/apps/web/src/app/vault/bulk-share.component.ts +++ b/apps/web/src/app/vault/bulk-action-dialogs/bulk-share-dialog/bulk-share-dialog.component.ts @@ -1,4 +1,5 @@ -import { Component, EventEmitter, Input, OnInit, Output } from "@angular/core"; +import { DialogConfig, DialogRef, DIALOG_DATA } from "@angular/cdk/dialog"; +import { Component, Inject, OnInit } from "@angular/core"; import { CipherService } from "@bitwarden/common/abstractions/cipher.service"; import { CollectionService } from "@bitwarden/common/abstractions/collection.service"; @@ -10,32 +11,61 @@ import { Organization } from "@bitwarden/common/models/domain/organization"; import { CipherView } from "@bitwarden/common/models/view/cipher.view"; import { CollectionView } from "@bitwarden/common/models/view/collection.view"; import { Checkable, isChecked } from "@bitwarden/common/types/checkable"; +import { DialogService } from "@bitwarden/components"; + +export interface BulkShareDialogParams { + ciphers: CipherView[]; + organizationId?: string; +} + +export enum BulkShareDialogResult { + Shared = "shared", + Canceled = "canceled", +} + +/** + * Strongly typed helper to open a BulkShareDialog + * @param dialogService Instance of the dialog service that will be used to open the dialog + * @param config Configuration for the dialog + */ +export const openBulkShareDialog = ( + dialogService: DialogService, + config: DialogConfig +) => { + return dialogService.open( + BulkShareDialogComponent, + config + ); +}; @Component({ - selector: "app-vault-bulk-share", - templateUrl: "bulk-share.component.html", + selector: "vault-bulk-share-dialog", + templateUrl: "bulk-share-dialog.component.html", }) -export class BulkShareComponent implements OnInit { - @Input() ciphers: CipherView[] = []; - @Input() organizationId: string; - @Output() onShared = new EventEmitter(); +export class BulkShareDialogComponent implements OnInit { + ciphers: CipherView[] = []; + organizationId: string; nonShareableCount = 0; collections: Checkable[] = []; organizations: Organization[] = []; shareableCiphers: CipherView[] = []; - formPromise: Promise; private writeableCollections: CollectionView[] = []; constructor( + @Inject(DIALOG_DATA) params: BulkShareDialogParams, + private dialogRef: DialogRef, private cipherService: CipherService, private platformUtilsService: PlatformUtilsService, private i18nService: I18nService, private collectionService: CollectionService, private organizationService: OrganizationService, private logService: LogService - ) {} + ) { + this.ciphers = params.ciphers ?? []; + this.organizationId = params.organizationId; + } async ngOnInit() { this.shareableCiphers = this.ciphers.filter( @@ -66,16 +96,14 @@ export class BulkShareComponent implements OnInit { } } - async submit() { + submit = async () => { const checkedCollectionIds = this.collections.filter(isChecked).map((c) => c.id); try { - this.formPromise = this.cipherService.shareManyWithServer( + await this.cipherService.shareManyWithServer( this.shareableCiphers, this.organizationId, checkedCollectionIds ); - await this.formPromise; - this.onShared.emit(); const orgName = this.organizations.find((o) => o.id === this.organizationId)?.name ?? this.i18nService.t("organization"); @@ -84,10 +112,11 @@ export class BulkShareComponent implements OnInit { null, this.i18nService.t("movedItemsToOrg", orgName) ); + this.close(BulkShareDialogResult.Shared); } catch (e) { this.logService.error(e); } - } + }; check(c: Checkable, select?: boolean) { c.checked = select == null ? !c.checked : select; @@ -112,4 +141,12 @@ export class BulkShareComponent implements OnInit { } return false; } + + protected cancel() { + this.close(BulkShareDialogResult.Canceled); + } + + private close(result: BulkShareDialogResult) { + this.dialogRef.close(result); + } } diff --git a/apps/web/src/app/vault/bulk-actions.component.html b/apps/web/src/app/vault/bulk-actions.component.html index fafcefdeb4a..67144fc62e6 100644 --- a/apps/web/src/app/vault/bulk-actions.component.html +++ b/apps/web/src/app/vault/bulk-actions.component.html @@ -48,8 +48,3 @@
- - - - - diff --git a/apps/web/src/app/vault/bulk-actions.component.ts b/apps/web/src/app/vault/bulk-actions.component.ts index 98ca1f52554..4976f014c9c 100644 --- a/apps/web/src/app/vault/bulk-actions.component.ts +++ b/apps/web/src/app/vault/bulk-actions.component.ts @@ -1,16 +1,29 @@ -import { Component, Input, ViewChild, ViewContainerRef } from "@angular/core"; +import { Component, Input } from "@angular/core"; +import { lastValueFrom } from "rxjs"; -import { ModalService } from "@bitwarden/angular/services/modal.service"; import { I18nService } from "@bitwarden/common/abstractions/i18n.service"; import { PasswordRepromptService } from "@bitwarden/common/abstractions/passwordReprompt.service"; import { PlatformUtilsService } from "@bitwarden/common/abstractions/platformUtils.service"; import { CipherRepromptType } from "@bitwarden/common/enums/cipherRepromptType"; import { Organization } from "@bitwarden/common/models/domain/organization"; +import { DialogService } from "@bitwarden/components"; -import { BulkDeleteComponent } from "./bulk-delete.component"; -import { BulkMoveComponent } from "./bulk-move.component"; -import { BulkRestoreComponent } from "./bulk-restore.component"; -import { BulkShareComponent } from "./bulk-share.component"; +import { + BulkDeleteDialogResult, + openBulkDeleteDialog, +} from "./bulk-action-dialogs/bulk-delete-dialog/bulk-delete-dialog.component"; +import { + BulkMoveDialogResult, + openBulkMoveDialog, +} from "./bulk-action-dialogs/bulk-move-dialog/bulk-move-dialog.component"; +import { + BulkRestoreDialogResult, + openBulkRestoreDialog, +} from "./bulk-action-dialogs/bulk-restore-dialog/bulk-restore-dialog.component"; +import { + BulkShareDialogResult, + openBulkShareDialog, +} from "./bulk-action-dialogs/bulk-share-dialog/bulk-share-dialog.component"; import { VaultItemsComponent } from "./vault-items.component"; @Component({ @@ -23,19 +36,10 @@ export class BulkActionsComponent { @Input() deleted: boolean; @Input() organization: Organization; - @ViewChild("bulkDeleteTemplate", { read: ViewContainerRef, static: true }) - bulkDeleteModalRef: ViewContainerRef; - @ViewChild("bulkRestoreTemplate", { read: ViewContainerRef, static: true }) - bulkRestoreModalRef: ViewContainerRef; - @ViewChild("bulkMoveTemplate", { read: ViewContainerRef, static: true }) - bulkMoveModalRef: ViewContainerRef; - @ViewChild("bulkShareTemplate", { read: ViewContainerRef, static: true }) - bulkShareModalRef: ViewContainerRef; - constructor( private platformUtilsService: PlatformUtilsService, private i18nService: I18nService, - private modalService: ModalService, + private dialogService: DialogService, private passwordRepromptService: PasswordRepromptService ) {} @@ -44,8 +48,8 @@ export class BulkActionsComponent { return; } - const selectedIds = this.vaultItemsComponent.getSelectedIds(); - if (selectedIds.length === 0) { + const selectedCipherIds = this.vaultItemsComponent.selectedCipherIds; + if (selectedCipherIds.length === 0) { this.platformUtilsService.showToast( "error", this.i18nService.t("errorOccurred"), @@ -54,20 +58,18 @@ export class BulkActionsComponent { return; } - const [modal] = await this.modalService.openViewRef( - BulkDeleteComponent, - this.bulkDeleteModalRef, - (comp) => { - comp.permanent = this.deleted; - comp.cipherIds = selectedIds; - comp.organization = this.organization; - // eslint-disable-next-line rxjs-angular/prefer-takeuntil, rxjs/no-async-subscribe - comp.onDeleted.subscribe(async () => { - modal.close(); - await this.vaultItemsComponent.refresh(); - }); - } - ); + const dialog = openBulkDeleteDialog(this.dialogService, { + data: { + permanent: this.deleted, + cipherIds: selectedCipherIds, + organization: this.organization, + }, + }); + + const result = await lastValueFrom(dialog.closed); + if (result === BulkDeleteDialogResult.Deleted) { + await this.vaultItemsComponent.refresh(); + } } async bulkRestore() { @@ -75,8 +77,8 @@ export class BulkActionsComponent { return; } - const selectedIds = this.vaultItemsComponent.getSelectedIds(); - if (selectedIds.length === 0) { + const selectedCipherIds = this.vaultItemsComponent.selectedCipherIds; + if (selectedCipherIds.length === 0) { this.platformUtilsService.showToast( "error", this.i18nService.t("errorOccurred"), @@ -85,18 +87,16 @@ export class BulkActionsComponent { return; } - const [modal] = await this.modalService.openViewRef( - BulkRestoreComponent, - this.bulkRestoreModalRef, - (comp) => { - comp.cipherIds = selectedIds; - // eslint-disable-next-line rxjs-angular/prefer-takeuntil, rxjs/no-async-subscribe - comp.onRestored.subscribe(async () => { - modal.close(); - await this.vaultItemsComponent.refresh(); - }); - } - ); + const dialog = openBulkRestoreDialog(this.dialogService, { + data: { + cipherIds: selectedCipherIds, + }, + }); + + const result = await lastValueFrom(dialog.closed); + if (result === BulkRestoreDialogResult.Restored) { + this.vaultItemsComponent.refresh(); + } } async bulkShare() { @@ -104,7 +104,7 @@ export class BulkActionsComponent { return; } - const selectedCiphers = this.vaultItemsComponent.getSelected(); + const selectedCiphers = this.vaultItemsComponent.selectedCiphers; if (selectedCiphers.length === 0) { this.platformUtilsService.showToast( "error", @@ -114,18 +114,12 @@ export class BulkActionsComponent { return; } - const [modal] = await this.modalService.openViewRef( - BulkShareComponent, - this.bulkShareModalRef, - (comp) => { - comp.ciphers = selectedCiphers; - // eslint-disable-next-line rxjs-angular/prefer-takeuntil, rxjs/no-async-subscribe - comp.onShared.subscribe(async () => { - modal.close(); - await this.vaultItemsComponent.refresh(); - }); - } - ); + const dialog = openBulkShareDialog(this.dialogService, { data: { ciphers: selectedCiphers } }); + + const result = await lastValueFrom(dialog.closed); + if (result === BulkShareDialogResult.Shared) { + this.vaultItemsComponent.refresh(); + } } async bulkMove() { @@ -133,8 +127,8 @@ export class BulkActionsComponent { return; } - const selectedIds = this.vaultItemsComponent.getSelectedIds(); - if (selectedIds.length === 0) { + const selectedCipherIds = this.vaultItemsComponent.selectedCipherIds; + if (selectedCipherIds.length === 0) { this.platformUtilsService.showToast( "error", this.i18nService.t("errorOccurred"), @@ -143,26 +137,22 @@ export class BulkActionsComponent { return; } - const [modal] = await this.modalService.openViewRef( - BulkMoveComponent, - this.bulkMoveModalRef, - (comp) => { - comp.cipherIds = selectedIds; - // eslint-disable-next-line rxjs-angular/prefer-takeuntil, rxjs/no-async-subscribe - comp.onMoved.subscribe(async () => { - modal.close(); - await this.vaultItemsComponent.refresh(); - }); - } - ); + const dialog = openBulkMoveDialog(this.dialogService, { + data: { cipherIds: selectedCipherIds }, + }); + + const result = await lastValueFrom(dialog.closed); + if (result === BulkMoveDialogResult.Moved) { + this.vaultItemsComponent.refresh(); + } } selectAll(select: boolean) { - this.vaultItemsComponent.selectAll(select); + this.vaultItemsComponent.checkAll(select); } private async promptPassword() { - const selectedCiphers = this.vaultItemsComponent.getSelected(); + const selectedCiphers = this.vaultItemsComponent.selectedCiphers; const notProtected = !selectedCiphers.find( (cipher) => cipher.reprompt !== CipherRepromptType.None ); diff --git a/apps/web/src/app/vault/bulk-delete.component.html b/apps/web/src/app/vault/bulk-delete.component.html deleted file mode 100644 index 43ae9829008..00000000000 --- a/apps/web/src/app/vault/bulk-delete.component.html +++ /dev/null @@ -1,39 +0,0 @@ - diff --git a/apps/web/src/app/vault/bulk-delete.component.ts b/apps/web/src/app/vault/bulk-delete.component.ts deleted file mode 100644 index 55a3cc508c7..00000000000 --- a/apps/web/src/app/vault/bulk-delete.component.ts +++ /dev/null @@ -1,62 +0,0 @@ -import { Component, EventEmitter, Input, Output } from "@angular/core"; - -import { ApiService } from "@bitwarden/common/abstractions/api.service"; -import { CipherService } from "@bitwarden/common/abstractions/cipher.service"; -import { I18nService } from "@bitwarden/common/abstractions/i18n.service"; -import { PlatformUtilsService } from "@bitwarden/common/abstractions/platformUtils.service"; -import { Organization } from "@bitwarden/common/models/domain/organization"; -import { CipherBulkDeleteRequest } from "@bitwarden/common/models/request/cipher-bulk-delete.request"; - -@Component({ - selector: "app-vault-bulk-delete", - templateUrl: "bulk-delete.component.html", -}) -export class BulkDeleteComponent { - @Input() cipherIds: string[] = []; - @Input() permanent = false; - @Input() organization: Organization; - @Output() onDeleted = new EventEmitter(); - - formPromise: Promise; - - constructor( - private cipherService: CipherService, - private platformUtilsService: PlatformUtilsService, - private i18nService: I18nService, - private apiService: ApiService - ) {} - - async submit() { - if (!this.organization || !this.organization.canEditAnyCollection) { - await this.deleteCiphers(); - } else { - await this.deleteCiphersAdmin(); - } - - await this.formPromise; - - this.onDeleted.emit(); - this.platformUtilsService.showToast( - "success", - null, - this.i18nService.t(this.permanent ? "permanentlyDeletedItems" : "deletedItems") - ); - } - - private async deleteCiphers() { - if (this.permanent) { - this.formPromise = await this.cipherService.deleteManyWithServer(this.cipherIds); - } else { - this.formPromise = await this.cipherService.softDeleteManyWithServer(this.cipherIds); - } - } - - private async deleteCiphersAdmin() { - const deleteRequest = new CipherBulkDeleteRequest(this.cipherIds, this.organization.id); - if (this.permanent) { - this.formPromise = await this.apiService.deleteManyCiphersAdmin(deleteRequest); - } else { - this.formPromise = await this.apiService.putDeleteManyCiphersAdmin(deleteRequest); - } - } -} diff --git a/apps/web/src/app/vault/bulk-move.component.html b/apps/web/src/app/vault/bulk-move.component.html deleted file mode 100644 index c82ad83f61b..00000000000 --- a/apps/web/src/app/vault/bulk-move.component.html +++ /dev/null @@ -1,37 +0,0 @@ - diff --git a/apps/web/src/app/vault/bulk-move.component.ts b/apps/web/src/app/vault/bulk-move.component.ts deleted file mode 100644 index b3f56f65e8d..00000000000 --- a/apps/web/src/app/vault/bulk-move.component.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { Component, EventEmitter, Input, OnInit, Output } from "@angular/core"; -import { firstValueFrom, Observable } from "rxjs"; - -import { CipherService } from "@bitwarden/common/abstractions/cipher.service"; -import { FolderService } from "@bitwarden/common/abstractions/folder/folder.service.abstraction"; -import { I18nService } from "@bitwarden/common/abstractions/i18n.service"; -import { PlatformUtilsService } from "@bitwarden/common/abstractions/platformUtils.service"; -import { FolderView } from "@bitwarden/common/models/view/folder.view"; - -@Component({ - selector: "app-vault-bulk-move", - templateUrl: "bulk-move.component.html", -}) -export class BulkMoveComponent implements OnInit { - @Input() cipherIds: string[] = []; - @Output() onMoved = new EventEmitter(); - - folderId: string = null; - folders$: Observable; - formPromise: Promise; - - constructor( - private cipherService: CipherService, - private platformUtilsService: PlatformUtilsService, - private i18nService: I18nService, - private folderService: FolderService - ) {} - - async ngOnInit() { - this.folders$ = this.folderService.folderViews$; - this.folderId = (await firstValueFrom(this.folders$))[0].id; - } - - async submit() { - this.formPromise = this.cipherService.moveManyWithServer(this.cipherIds, this.folderId); - await this.formPromise; - this.onMoved.emit(); - this.platformUtilsService.showToast("success", null, this.i18nService.t("movedItems")); - } -} diff --git a/apps/web/src/app/vault/bulk-restore.component.html b/apps/web/src/app/vault/bulk-restore.component.html deleted file mode 100644 index 577c189fe4c..00000000000 --- a/apps/web/src/app/vault/bulk-restore.component.html +++ /dev/null @@ -1,36 +0,0 @@ - diff --git a/apps/web/src/app/vault/bulk-restore.component.ts b/apps/web/src/app/vault/bulk-restore.component.ts deleted file mode 100644 index b48fb019c9f..00000000000 --- a/apps/web/src/app/vault/bulk-restore.component.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { Component, EventEmitter, Input, Output } from "@angular/core"; - -import { CipherService } from "@bitwarden/common/abstractions/cipher.service"; -import { I18nService } from "@bitwarden/common/abstractions/i18n.service"; -import { PlatformUtilsService } from "@bitwarden/common/abstractions/platformUtils.service"; - -@Component({ - selector: "app-vault-bulk-restore", - templateUrl: "bulk-restore.component.html", -}) -export class BulkRestoreComponent { - @Input() cipherIds: string[] = []; - @Output() onRestored = new EventEmitter(); - - formPromise: Promise; - - constructor( - private cipherService: CipherService, - private platformUtilsService: PlatformUtilsService, - private i18nService: I18nService - ) {} - - async submit() { - this.formPromise = this.cipherService.restoreManyWithServer(this.cipherIds); - await this.formPromise; - this.onRestored.emit(); - this.platformUtilsService.showToast("success", null, this.i18nService.t("restoredItems")); - } -} diff --git a/apps/web/src/app/vault/bulk-share.component.html b/apps/web/src/app/vault/bulk-share.component.html deleted file mode 100644 index fa84eb3f543..00000000000 --- a/apps/web/src/app/vault/bulk-share.component.html +++ /dev/null @@ -1,85 +0,0 @@ - diff --git a/apps/web/src/app/vault/pipes/get-collection-name.pipe.ts b/apps/web/src/app/vault/pipes/get-collection-name.pipe.ts new file mode 100644 index 00000000000..edecf07cce9 --- /dev/null +++ b/apps/web/src/app/vault/pipes/get-collection-name.pipe.ts @@ -0,0 +1,13 @@ +import { Pipe, PipeTransform } from "@angular/core"; + +import { CollectionView } from "@bitwarden/common/src/models/view/collection.view"; + +@Pipe({ + name: "collectionNameFromId", + pure: true, +}) +export class GetCollectionNameFromIdPipe implements PipeTransform { + transform(value: string, collections: CollectionView[]) { + return collections.find((o) => o.id === value)?.name; + } +} diff --git a/apps/web/src/app/vault/pipes/get-group-name.pipe.ts b/apps/web/src/app/vault/pipes/get-group-name.pipe.ts new file mode 100644 index 00000000000..cd553fff355 --- /dev/null +++ b/apps/web/src/app/vault/pipes/get-group-name.pipe.ts @@ -0,0 +1,13 @@ +import { Pipe, PipeTransform } from "@angular/core"; + +import { GroupView } from "../../organizations/core"; + +@Pipe({ + name: "groupNameFromId", + pure: true, +}) +export class GetGroupNameFromIdPipe implements PipeTransform { + transform(value: string, groups: GroupView[]) { + return groups.find((o) => o.id === value)?.name; + } +} diff --git a/apps/web/src/app/vault/pipes/pipes.module.ts b/apps/web/src/app/vault/pipes/pipes.module.ts index 9293d172a2e..c33136b8262 100644 --- a/apps/web/src/app/vault/pipes/pipes.module.ts +++ b/apps/web/src/app/vault/pipes/pipes.module.ts @@ -1,9 +1,11 @@ import { NgModule } from "@angular/core"; +import { GetCollectionNameFromIdPipe } from "./get-collection-name.pipe"; +import { GetGroupNameFromIdPipe } from "./get-group-name.pipe"; import { GetOrgNameFromIdPipe } from "./get-organization-name.pipe"; @NgModule({ - declarations: [GetOrgNameFromIdPipe], - exports: [GetOrgNameFromIdPipe], + declarations: [GetOrgNameFromIdPipe, GetCollectionNameFromIdPipe, GetGroupNameFromIdPipe], + exports: [GetOrgNameFromIdPipe, GetCollectionNameFromIdPipe, GetGroupNameFromIdPipe], }) export class PipesModule {} diff --git a/apps/web/src/app/vault/vault-filter/components/vault-filter.component.ts b/apps/web/src/app/vault/vault-filter/components/vault-filter.component.ts index 4fb791ec4cb..47f4719c4df 100644 --- a/apps/web/src/app/vault/vault-filter/components/vault-filter.component.ts +++ b/apps/web/src/app/vault/vault-filter/components/vault-filter.component.ts @@ -93,9 +93,8 @@ export class VaultFilterComponent implements OnInit, OnDestroy { async ngOnInit(): Promise { this.filters = await this.buildAllFilters(); - await this.applyTypeFilter( - (await firstValueFrom(this.filters?.typeFilter.data$)) as TreeNode - ); + this.activeFilter.selectedCipherTypeNode = + (await this.getDefaultFilter()) as TreeNode; this.isLoaded = true; } diff --git a/apps/web/src/app/vault/vault-filter/services/abstractions/vault-filter.service.ts b/apps/web/src/app/vault/vault-filter/services/abstractions/vault-filter.service.ts index f59ee385cd9..f5d9fd289d5 100644 --- a/apps/web/src/app/vault/vault-filter/services/abstractions/vault-filter.service.ts +++ b/apps/web/src/app/vault/vault-filter/services/abstractions/vault-filter.service.ts @@ -20,6 +20,7 @@ export abstract class VaultFilterService { folderTree$: Observable>; collectionTree$: Observable>; reloadCollections: () => Promise; + getCollectionNodeFromTree: (id: string) => Promise>; setCollapsedFilterNodes: (collapsedFilterNodes: Set) => Promise; expandOrgFilter: () => Promise; setOrganizationFilter: (organization: Organization) => void; diff --git a/apps/web/src/app/vault/vault-filter/services/vault-filter.service.ts b/apps/web/src/app/vault/vault-filter/services/vault-filter.service.ts index 24f9f55404f..e0e433d6ede 100644 --- a/apps/web/src/app/vault/vault-filter/services/vault-filter.service.ts +++ b/apps/web/src/app/vault/vault-filter/services/vault-filter.service.ts @@ -1,4 +1,4 @@ -import { Injectable, OnDestroy } from "@angular/core"; +import { Injectable } from "@angular/core"; import { BehaviorSubject, combineLatestWith, @@ -7,9 +7,7 @@ import { Observable, of, ReplaySubject, - Subject, switchMap, - takeUntil, } from "rxjs"; import { CipherService } from "@bitwarden/common/abstractions/cipher.service"; @@ -26,6 +24,7 @@ import { TreeNode } from "@bitwarden/common/models/domain/tree-node"; import { CollectionView } from "@bitwarden/common/models/view/collection.view"; import { FolderView } from "@bitwarden/common/models/view/folder.view"; +import { CollectionAdminView } from "../../../organizations/core"; import { CipherTypeFilter, CollectionFilter, @@ -38,7 +37,7 @@ import { VaultFilterService as VaultFilterServiceAbstraction } from "./abstracti const NestingDelimiter = "/"; @Injectable() -export class VaultFilterService implements VaultFilterServiceAbstraction, OnDestroy { +export class VaultFilterService implements VaultFilterServiceAbstraction { protected _collapsedFilterNodes = new BehaviorSubject>(null); collapsedFilterNodes$: Observable> = this._collapsedFilterNodes.pipe( switchMap(async (nodes) => nodes ?? (await this.getCollapsedFilterNodes())) @@ -49,24 +48,31 @@ export class VaultFilterService implements VaultFilterServiceAbstraction, OnDest switchMap((orgs) => this.buildOrganizationTree(orgs)) ); - protected _filteredFolders = new ReplaySubject(1); - filteredFolders$: Observable = this._filteredFolders.asObservable(); - protected _filteredCollections = new ReplaySubject(1); - filteredCollections$: Observable = this._filteredCollections.asObservable(); + protected _organizationFilter = new BehaviorSubject(null); + filteredFolders$: Observable = this.folderService.folderViews$.pipe( + combineLatestWith(this._organizationFilter), + switchMap(([folders, org]) => { + return this.filterFolders(folders, org); + }) + ); folderTree$: Observable> = this.filteredFolders$.pipe( map((folders) => this.buildFolderTree(folders)) ); + + // TODO: Remove once collections is refactored with observables + // replace with collection service observable + private collectionViews$ = new ReplaySubject(1); + filteredCollections$: Observable = this.collectionViews$.pipe( + combineLatestWith(this._organizationFilter), + switchMap(([collections, org]) => { + return this.filterCollections(collections, org); + }) + ); collectionTree$: Observable> = this.filteredCollections$.pipe( map((collections) => this.buildCollectionTree(collections)) ); - protected _organizationFilter = new BehaviorSubject(null); - protected destroy$: Subject = new Subject(); - - // TODO: Remove once collections is refactored with observables - protected collectionViews$ = new ReplaySubject(1); - constructor( protected stateService: StateService, protected organizationService: OrganizationService, @@ -75,43 +81,18 @@ export class VaultFilterService implements VaultFilterServiceAbstraction, OnDest protected collectionService: CollectionService, protected policyService: PolicyService, protected i18nService: I18nService - ) { - this.loadSubscriptions(); - } - - protected loadSubscriptions() { - this.folderService.folderViews$ - .pipe( - combineLatestWith(this._organizationFilter), - switchMap(([folders, org]) => { - return this.filterFolders(folders, org); - }), - takeUntil(this.destroy$) - ) - .subscribe(this._filteredFolders); - - // TODO: Use collectionService once collections is refactored - this.collectionViews$ - .pipe( - combineLatestWith(this._organizationFilter), - switchMap(([collections, org]) => { - return this.filterCollections(collections, org); - }), - takeUntil(this.destroy$) - ) - .subscribe(this._filteredCollections); - } - - ngOnDestroy(): void { - this.destroy$.next(); - this.destroy$.complete(); - } + ) {} // TODO: Remove once collections is refactored with observables async reloadCollections() { this.collectionViews$.next(await this.collectionService.getAllDecrypted()); } + async getCollectionNodeFromTree(id: string) { + const collections = await firstValueFrom(this.collectionTree$); + return ServiceUtils.getTreeNodeObject(collections, id) as TreeNode; + } + async setCollapsedFilterNodes(collapsedFilterNodes: Set): Promise { await this.stateService.setCollapsedGroupings(Array.from(collapsedFilterNodes)); this._collapsedFilterNodes.next(collapsedFilterNodes); @@ -208,6 +189,9 @@ export class VaultFilterService implements VaultFilterServiceAbstraction, OnDest collectionCopy.id = c.id; collectionCopy.organizationId = c.organizationId; collectionCopy.icon = "bwi-collection"; + if (c instanceof CollectionAdminView) { + collectionCopy.groups = c.groups; + } const parts = c.name != null ? c.name.replace(/^\/+|\/+$/g, "").split(NestingDelimiter) : []; ServiceUtils.nestedTraverse(nodes, 0, parts, collectionCopy, null, NestingDelimiter); }); diff --git a/apps/web/src/app/vault/vault-filter/shared/components/vault-filter-section.component.html b/apps/web/src/app/vault/vault-filter/shared/components/vault-filter-section.component.html index 856ca38849f..e5dec90a6bf 100644 --- a/apps/web/src/app/vault/vault-filter/shared/components/vault-filter-section.component.html +++ b/apps/web/src/app/vault/vault-filter/shared/components/vault-filter-section.component.html @@ -4,8 +4,8 @@ class="toggle-button" (click)="toggleCollapse(headerNode.node)" [attr.aria-expanded]="!isCollapsed(headerNode.node)" + appA11yTitle="{{ 'toggleCollapse' | i18n }}: {{ headerNode.node.name | i18n }}" aria-controls="sub-filters" - title="{{ 'toggleCollapse' | i18n }}" >