From f4ba92b63c63a1c0bc7664482ecb831793e4842b Mon Sep 17 00:00:00 2001 From: Shane Melton Date: Tue, 26 Sep 2023 09:29:34 -0700 Subject: [PATCH] [AC-1174] Bulk collection management (#6133) * [AC-1174] Add bulk edit collection access event type * [AC-1174] Add bulk edit collection access menu option * [AC-1174] Add initial bulk collections access dialog * [AC-1174] Add logic to open bulk edit collections dialog * [AC-1174] Move AccessItemView helper methods to access selector model to be shared * [AC-1174] Add access selector to bulk collections dialog * [AC-1174] Add bulk assign access method to collection-admin service * [AC-1174] Introduce strongly typed BulkCollectionAccessRequest model * [AC-1174] Update vault item event type name * Update DialogService dependency --------- Co-authored-by: Thomas Rittson --- .../access-selector/access-selector.models.ts | 29 +++- .../collection-dialog.component.ts | 35 +---- .../vault-items/vault-item-event.ts | 1 + .../vault-items/vault-items.component.html | 9 ++ .../vault-items/vault-items.component.ts | 9 ++ .../core/bulk-collection-access.request.ts | 7 + .../vault/core/collection-admin.service.ts | 27 ++++ .../bulk-collections-dialog.component.html | 41 ++++++ .../bulk-collections-dialog.component.ts | 129 ++++++++++++++++++ .../bulk-collections-dialog/index.ts | 1 + .../app/vault/org-vault/vault.component.ts | 29 ++++ apps/web/src/locales/en/messages.json | 6 + 12 files changed, 292 insertions(+), 31 deletions(-) create mode 100644 apps/web/src/app/vault/core/bulk-collection-access.request.ts create mode 100644 apps/web/src/app/vault/org-vault/bulk-collections-dialog/bulk-collections-dialog.component.html create mode 100644 apps/web/src/app/vault/org-vault/bulk-collections-dialog/bulk-collections-dialog.component.ts create mode 100644 apps/web/src/app/vault/org-vault/bulk-collections-dialog/index.ts diff --git a/apps/web/src/app/admin-console/organizations/shared/components/access-selector/access-selector.models.ts b/apps/web/src/app/admin-console/organizations/shared/components/access-selector/access-selector.models.ts index 8e823c675a..06e1298a17 100644 --- a/apps/web/src/app/admin-console/organizations/shared/components/access-selector/access-selector.models.ts +++ b/apps/web/src/app/admin-console/organizations/shared/components/access-selector/access-selector.models.ts @@ -1,10 +1,11 @@ +import { OrganizationUserUserDetailsResponse } from "@bitwarden/common/abstractions/organization-user/responses"; import { OrganizationUserStatusType, OrganizationUserType, } from "@bitwarden/common/admin-console/enums"; import { SelectItemView } from "@bitwarden/components"; -import { CollectionAccessSelectionView } from "../../../core"; +import { CollectionAccessSelectionView, GroupView } from "../../../core"; /** * Permission options that replace/correspond with manage, readOnly, and hidePassword server fields. @@ -111,3 +112,29 @@ const readOnly = (perm: CollectionPermission) => const hidePassword = (perm: CollectionPermission) => [CollectionPermission.ViewExceptPass, CollectionPermission.EditExceptPass].includes(perm); + +export function mapGroupToAccessItemView(group: GroupView): AccessItemView { + return { + id: group.id, + type: AccessItemType.Group, + listName: group.name, + labelName: group.name, + accessAllItems: group.accessAll, + readonly: group.accessAll, + }; +} + +// TODO: Use view when user apis are migrated to a service +export function mapUserToAccessItemView(user: OrganizationUserUserDetailsResponse): AccessItemView { + return { + id: user.id, + type: AccessItemType.Member, + email: user.email, + role: user.type, + listName: user.name?.length > 0 ? `${user.name} (${user.email})` : user.email, + labelName: user.name ?? user.email, + status: user.status, + accessAllItems: user.accessAll, + readonly: user.accessAll, + }; +} diff --git a/apps/web/src/app/vault/components/collection-dialog/collection-dialog.component.ts b/apps/web/src/app/vault/components/collection-dialog/collection-dialog.component.ts index 34829f58b6..5fdc1317da 100644 --- a/apps/web/src/app/vault/components/collection-dialog/collection-dialog.component.ts +++ b/apps/web/src/app/vault/components/collection-dialog/collection-dialog.component.ts @@ -13,7 +13,6 @@ import { } from "rxjs"; import { OrganizationUserService } from "@bitwarden/common/abstractions/organization-user/organization-user.service"; -import { OrganizationUserUserDetailsResponse } from "@bitwarden/common/abstractions/organization-user/responses"; import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; @@ -26,11 +25,13 @@ import { DialogService, BitValidators } from "@bitwarden/components"; import { GroupService, GroupView } from "../../../admin-console/organizations/core"; import { PermissionMode } from "../../../admin-console/organizations/shared/components/access-selector/access-selector.component"; import { - AccessItemView, - AccessItemValue, AccessItemType, - convertToSelectionView, + AccessItemValue, + AccessItemView, convertToPermission, + convertToSelectionView, + mapGroupToAccessItemView, + mapUserToAccessItemView, } from "../../../admin-console/organizations/shared/components/access-selector/access-selector.models"; import { CollectionAdminService } from "../../core/collection-admin.service"; import { CollectionAdminView } from "../../core/views/collection-admin.view"; @@ -284,32 +285,6 @@ function parseName(collection: CollectionView) { return { name, parent }; } -function mapGroupToAccessItemView(group: GroupView): AccessItemView { - return { - id: group.id, - type: AccessItemType.Group, - listName: group.name, - labelName: group.name, - accessAllItems: group.accessAll, - readonly: group.accessAll, - }; -} - -// TODO: Use view when user apis are migrated to a service -function mapUserToAccessItemView(user: OrganizationUserUserDetailsResponse): AccessItemView { - return { - id: user.id, - type: AccessItemType.Member, - email: user.email, - role: user.type, - listName: user.name?.length > 0 ? `${user.name} (${user.email})` : user.email, - labelName: user.name ?? user.email, - status: user.status, - accessAllItems: user.accessAll, - readonly: user.accessAll, - }; -} - function mapToAccessSelections(collectionDetails: CollectionAdminView): AccessItemValue[] { if (collectionDetails == undefined) { return []; diff --git a/apps/web/src/app/vault/components/vault-items/vault-item-event.ts b/apps/web/src/app/vault/components/vault-items/vault-item-event.ts index cacd13829f..c20e85fdea 100644 --- a/apps/web/src/app/vault/components/vault-items/vault-item-event.ts +++ b/apps/web/src/app/vault/components/vault-items/vault-item-event.ts @@ -7,6 +7,7 @@ export type VaultItemEvent = | { type: "viewAttachments"; item: CipherView } | { type: "viewCollections"; item: CipherView } | { type: "viewAccess"; item: CollectionView } + | { type: "bulkEditCollectionAccess"; items: CollectionView[] } | { type: "viewEvents"; item: CipherView } | { type: "edit"; item: CollectionView } | { type: "clone"; item: CipherView } diff --git a/apps/web/src/app/vault/components/vault-items/vault-items.component.html b/apps/web/src/app/vault/components/vault-items/vault-items.component.html index a9be8dbf1e..44dd95f81d 100644 --- a/apps/web/src/app/vault/components/vault-items/vault-items.component.html +++ b/apps/web/src/app/vault/components/vault-items/vault-items.component.html @@ -34,6 +34,15 @@ {{ "moveSelected" | i18n }} + + + + + diff --git a/apps/web/src/app/vault/org-vault/bulk-collections-dialog/bulk-collections-dialog.component.ts b/apps/web/src/app/vault/org-vault/bulk-collections-dialog/bulk-collections-dialog.component.ts new file mode 100644 index 0000000000..b863559aa9 --- /dev/null +++ b/apps/web/src/app/vault/org-vault/bulk-collections-dialog/bulk-collections-dialog.component.ts @@ -0,0 +1,129 @@ +import { DIALOG_DATA, DialogConfig, DialogRef } from "@angular/cdk/dialog"; +import { Component, Inject, OnDestroy } from "@angular/core"; +import { FormBuilder } from "@angular/forms"; +import { combineLatest, of, Subject, switchMap, takeUntil } from "rxjs"; + +import { OrganizationUserService } from "@bitwarden/common/abstractions/organization-user/organization-user.service"; +import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; +import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; +import { CollectionView } from "@bitwarden/common/vault/models/view/collection.view"; +import { DialogService } from "@bitwarden/components"; + +import { GroupService, GroupView } from "../../../admin-console/organizations/core"; +import { + AccessItemType, + AccessItemValue, + AccessItemView, + AccessSelectorModule, + convertToSelectionView, + mapGroupToAccessItemView, + mapUserToAccessItemView, + PermissionMode, +} from "../../../admin-console/organizations/shared/components/access-selector"; +import { SharedModule } from "../../../shared"; +import { CollectionAdminService } from "../../core/collection-admin.service"; + +export interface BulkCollectionsDialogParams { + organizationId: string; + collections: CollectionView[]; +} + +export enum BulkCollectionsDialogResult { + Saved = "saved", + Canceled = "canceled", +} + +@Component({ + imports: [SharedModule, AccessSelectorModule], + selector: "app-bulk-collections-dialog", + templateUrl: "bulk-collections-dialog.component.html", + standalone: true, +}) +export class BulkCollectionsDialogComponent implements OnDestroy { + protected readonly PermissionMode = PermissionMode; + + protected formGroup = this.formBuilder.group({ + access: [[] as AccessItemValue[]], + }); + protected loading = true; + protected organization: Organization; + protected accessItems: AccessItemView[] = []; + protected numCollections: number; + + private destroy$ = new Subject(); + + constructor( + @Inject(DIALOG_DATA) private params: BulkCollectionsDialogParams, + private dialogRef: DialogRef, + private formBuilder: FormBuilder, + private organizationService: OrganizationService, + private groupService: GroupService, + private organizationUserService: OrganizationUserService, + private platformUtilsService: PlatformUtilsService, + private i18nService: I18nService, + private collectionAdminService: CollectionAdminService + ) { + this.numCollections = this.params.collections.length; + const organization$ = this.organizationService.get$(this.params.organizationId); + const groups$ = organization$.pipe( + switchMap((organization) => { + if (!organization.useGroups) { + return of([] as GroupView[]); + } + return this.groupService.getAll(organization.id); + }) + ); + + combineLatest([ + organization$, + groups$, + this.organizationUserService.getAllUsers(this.params.organizationId), + ]) + .pipe(takeUntil(this.destroy$)) + .subscribe(([organization, groups, users]) => { + this.organization = organization; + + this.accessItems = [].concat( + groups.map(mapGroupToAccessItemView), + users.data.map(mapUserToAccessItemView) + ); + + this.loading = false; + }); + } + + ngOnDestroy() { + this.destroy$.next(); + this.destroy$.complete(); + } + + submit = async () => { + const users = this.formGroup.controls.access.value + .filter((v) => v.type === AccessItemType.Member) + .map(convertToSelectionView); + + const groups = this.formGroup.controls.access.value + .filter((v) => v.type === AccessItemType.Group) + .map(convertToSelectionView); + + await this.collectionAdminService.bulkAssignAccess( + this.organization.id, + this.params.collections.map((c) => c.id), + users, + groups + ); + + this.platformUtilsService.showToast("success", null, this.i18nService.t("editedCollections")); + + this.dialogRef.close(BulkCollectionsDialogResult.Saved); + }; + + static open(dialogService: DialogService, config: DialogConfig) { + return dialogService.open( + BulkCollectionsDialogComponent, + config + ); + } +} diff --git a/apps/web/src/app/vault/org-vault/bulk-collections-dialog/index.ts b/apps/web/src/app/vault/org-vault/bulk-collections-dialog/index.ts new file mode 100644 index 0000000000..7f6add5329 --- /dev/null +++ b/apps/web/src/app/vault/org-vault/bulk-collections-dialog/index.ts @@ -0,0 +1 @@ +export * from "./bulk-collections-dialog.component"; diff --git a/apps/web/src/app/vault/org-vault/vault.component.ts b/apps/web/src/app/vault/org-vault/vault.component.ts index 1cb57e188a..87121d99ed 100644 --- a/apps/web/src/app/vault/org-vault/vault.component.ts +++ b/apps/web/src/app/vault/org-vault/vault.component.ts @@ -82,6 +82,10 @@ import { getNestedCollectionTree } from "../utils/collection-utils"; import { AddEditComponent } from "./add-edit.component"; import { AttachmentsComponent } from "./attachments.component"; +import { + BulkCollectionsDialogComponent, + BulkCollectionsDialogResult, +} from "./bulk-collections-dialog"; import { CollectionsComponent } from "./collections.component"; import { VaultFilterComponent } from "./vault-filter/vault-filter.component"; @@ -499,6 +503,8 @@ export class VaultComponent implements OnInit, OnDestroy { await this.editCollection(event.item, CollectionDialogTabType.Info); } else if (event.type === "viewAccess") { await this.editCollection(event.item, CollectionDialogTabType.Access); + } else if (event.type === "bulkEditCollectionAccess") { + await this.bulkEditCollectionAccess(event.items); } else if (event.type === "viewEvents") { await this.viewEvents(event.item); } @@ -878,6 +884,29 @@ export class VaultComponent implements OnInit, OnDestroy { } } + async bulkEditCollectionAccess(collections: CollectionView[]): Promise { + if (collections.length === 0) { + this.platformUtilsService.showToast( + "error", + this.i18nService.t("errorOccurred"), + this.i18nService.t("nothingSelected") + ); + return; + } + + const dialog = BulkCollectionsDialogComponent.open(this.dialogService, { + data: { + collections, + organizationId: this.organization?.id, + }, + }); + + const result = await lastValueFrom(dialog.closed); + if (result === BulkCollectionsDialogResult.Saved) { + this.refresh(); + } + } + async viewEvents(cipher: CipherView) { await openEntityEventsDialog(this.dialogService, { data: { diff --git a/apps/web/src/locales/en/messages.json b/apps/web/src/locales/en/messages.json index 007ed4d6a7..1e5c27e21c 100644 --- a/apps/web/src/locales/en/messages.json +++ b/apps/web/src/locales/en/messages.json @@ -7184,6 +7184,12 @@ "beta": { "message": "Beta" }, + "assignCollectionAccess": { + "message": "Assign collection access" + }, + "editedCollections": { + "message": "Edited collections" + }, "alreadyHaveAccount": { "message": "Already have an account?" }