From bac0874dc0331661a0af194d7937a72509736483 Mon Sep 17 00:00:00 2001 From: Shane Melton Date: Fri, 22 Mar 2024 13:16:29 -0700 Subject: [PATCH] [PM-2383] Bulk collection assignment (#8429) * [PM-2383] Add bulkUpdateCollectionsWithServer method to CipherService * [PM-2383] Introduce bulk-collection-assignment-dialog.component * [PM-2383] Add bulk assign collections option to org vault --- .../vault-items/vault-item-event.ts | 3 +- .../vault-items/vault-items.component.html | 9 + .../vault-items/vault-items.component.ts | 14 ++ ...ollection-assignment-dialog.component.html | 66 ++++++ ...-collection-assignment-dialog.component.ts | 191 ++++++++++++++++++ .../index.ts | 1 + .../app/vault/org-vault/vault.component.html | 1 + .../app/vault/org-vault/vault.component.ts | 42 ++++ apps/web/src/locales/en/messages.json | 37 ++++ libs/common/src/types/guid.ts | 1 + .../src/vault/abstractions/cipher.service.ts | 14 ++ .../cipher-bulk-update-collections.request.ts | 19 ++ .../src/vault/services/cipher.service.ts | 47 ++++- 13 files changed, 443 insertions(+), 2 deletions(-) create mode 100644 apps/web/src/app/vault/org-vault/bulk-collection-assignment-dialog/bulk-collection-assignment-dialog.component.html create mode 100644 apps/web/src/app/vault/org-vault/bulk-collection-assignment-dialog/bulk-collection-assignment-dialog.component.ts create mode 100644 apps/web/src/app/vault/org-vault/bulk-collection-assignment-dialog/index.ts create mode 100644 libs/common/src/vault/models/request/cipher-bulk-update-collections.request.ts 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 b12076e359..f1f5cbc8c0 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 @@ -15,4 +15,5 @@ export type VaultItemEvent = | { type: "delete"; items: VaultItem[] } | { type: "copyField"; item: CipherView; field: "username" | "password" | "totp" } | { type: "moveToFolder"; items: CipherView[] } - | { type: "moveToOrganization"; items: CipherView[] }; + | { type: "moveToOrganization"; items: CipherView[] } + | { type: "assignToCollections"; items: 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 ee284d0517..c63273fabd 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 @@ -46,6 +46,15 @@ {{ "access" | i18n }} + + + + + + {{ "noCollectionsAssigned" | i18n }} + + + + + + + + + + + diff --git a/apps/web/src/app/vault/org-vault/bulk-collection-assignment-dialog/bulk-collection-assignment-dialog.component.ts b/apps/web/src/app/vault/org-vault/bulk-collection-assignment-dialog/bulk-collection-assignment-dialog.component.ts new file mode 100644 index 0000000000..04edce8543 --- /dev/null +++ b/apps/web/src/app/vault/org-vault/bulk-collection-assignment-dialog/bulk-collection-assignment-dialog.component.ts @@ -0,0 +1,191 @@ +import { DIALOG_DATA, DialogConfig, DialogRef } from "@angular/cdk/dialog"; +import { Component, Inject, OnDestroy, OnInit } from "@angular/core"; +import { Subject } from "rxjs"; + +import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; +import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; +import { ConfigServiceAbstraction } from "@bitwarden/common/platform/abstractions/config/config.service.abstraction"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; +import { CipherId, CollectionId, OrganizationId } from "@bitwarden/common/types/guid"; +import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; +import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; +import { CollectionView } from "@bitwarden/common/vault/models/view/collection.view"; +import { DialogService, SelectItemView } from "@bitwarden/components"; + +import { SharedModule } from "../../../shared"; + +export interface BulkCollectionAssignmentDialogParams { + organizationId: OrganizationId; + + /** + * The ciphers to be assigned to the collections selected in the dialog. + */ + ciphers: CipherView[]; + + /** + * The collections available to assign the ciphers to. + */ + availableCollections: CollectionView[]; + + /** + * The currently filtered collection. Selected by default. If the user deselects it in the dialog then it will be + * removed from the ciphers upon submission. + */ + activeCollection?: CollectionView; +} + +export enum BulkCollectionAssignmentDialogResult { + Saved = "saved", + Canceled = "canceled", +} + +@Component({ + imports: [SharedModule], + selector: "app-bulk-collection-assignment-dialog", + templateUrl: "./bulk-collection-assignment-dialog.component.html", + standalone: true, +}) +export class BulkCollectionAssignmentDialogComponent implements OnDestroy, OnInit { + protected totalItemCount: number; + protected editableItemCount: number; + protected readonlyItemCount: number; + protected availableCollections: SelectItemView[] = []; + protected selectedCollections: SelectItemView[] = []; + + private editableItems: CipherView[] = []; + private destroy$ = new Subject(); + + protected pluralize = (count: number, singular: string, plural: string) => + `${count} ${this.i18nService.t(count === 1 ? singular : plural)}`; + + constructor( + @Inject(DIALOG_DATA) private params: BulkCollectionAssignmentDialogParams, + private dialogRef: DialogRef, + private cipherService: CipherService, + private i18nService: I18nService, + private platformUtilsService: PlatformUtilsService, + private configService: ConfigServiceAbstraction, + private organizationService: OrganizationService, + ) {} + + async ngOnInit() { + const v1FCEnabled = await this.configService.getFeatureFlag( + FeatureFlag.FlexibleCollectionsV1, + false, + ); + const org = await this.organizationService.get(this.params.organizationId); + + if (org.canEditAllCiphers(v1FCEnabled)) { + this.editableItems = this.params.ciphers; + } else { + this.editableItems = this.params.ciphers.filter((c) => c.edit); + } + + this.editableItemCount = this.editableItems.length; + + // If no ciphers are editable, close the dialog + if (this.editableItemCount == 0) { + this.platformUtilsService.showToast( + "error", + this.i18nService.t("errorOccurred"), + this.i18nService.t("nothingSelected"), + ); + this.dialogRef.close(BulkCollectionAssignmentDialogResult.Canceled); + } + + this.totalItemCount = this.params.ciphers.length; + this.readonlyItemCount = this.totalItemCount - this.editableItemCount; + + this.availableCollections = this.params.availableCollections.map((c) => ({ + icon: "bwi-collection", + id: c.id, + labelName: c.name, + listName: c.name, + })); + + // If the active collection is set, select it by default + if (this.params.activeCollection) { + this.selectCollections([ + { + icon: "bwi-collection", + id: this.params.activeCollection.id, + labelName: this.params.activeCollection.name, + listName: this.params.activeCollection.name, + }, + ]); + } + } + + private sortItems = (a: SelectItemView, b: SelectItemView) => + this.i18nService.collator.compare(a.labelName, b.labelName); + + selectCollections(items: SelectItemView[]) { + this.selectedCollections = [...this.selectedCollections, ...items].sort(this.sortItems); + + this.availableCollections = this.availableCollections.filter( + (item) => !items.find((i) => i.id === item.id), + ); + } + + unselectCollection(i: number) { + const removed = this.selectedCollections.splice(i, 1); + this.availableCollections = [...this.availableCollections, ...removed].sort(this.sortItems); + } + + get isValid() { + return this.params.activeCollection != null || this.selectedCollections.length > 0; + } + + submit = async () => { + if (!this.isValid) { + return; + } + + const cipherIds = this.editableItems.map((i) => i.id as CipherId); + + if (this.selectedCollections.length > 0) { + await this.cipherService.bulkUpdateCollectionsWithServer( + this.params.organizationId, + cipherIds, + this.selectedCollections.map((i) => i.id as CollectionId), + false, + ); + } + + if ( + this.params.activeCollection != null && + this.selectedCollections.find((c) => c.id === this.params.activeCollection.id) == null + ) { + await this.cipherService.bulkUpdateCollectionsWithServer( + this.params.organizationId, + cipherIds, + [this.params.activeCollection.id as CollectionId], + true, + ); + } + + this.platformUtilsService.showToast( + "success", + null, + this.i18nService.t("successfullyAssignedCollections"), + ); + + this.dialogRef.close(BulkCollectionAssignmentDialogResult.Saved); + }; + + ngOnDestroy(): void { + this.destroy$.next(); + this.destroy$.complete(); + } + + static open( + dialogService: DialogService, + config: DialogConfig, + ) { + return dialogService.open< + BulkCollectionAssignmentDialogResult, + BulkCollectionAssignmentDialogParams + >(BulkCollectionAssignmentDialogComponent, config); + } +} diff --git a/apps/web/src/app/vault/org-vault/bulk-collection-assignment-dialog/index.ts b/apps/web/src/app/vault/org-vault/bulk-collection-assignment-dialog/index.ts new file mode 100644 index 0000000000..44042e3267 --- /dev/null +++ b/apps/web/src/app/vault/org-vault/bulk-collection-assignment-dialog/index.ts @@ -0,0 +1 @@ +export * from "./bulk-collection-assignment-dialog.component"; diff --git a/apps/web/src/app/vault/org-vault/vault.component.html b/apps/web/src/app/vault/org-vault/vault.component.html index 08bf77be37..242a03b995 100644 --- a/apps/web/src/app/vault/org-vault/vault.component.html +++ b/apps/web/src/app/vault/org-vault/vault.component.html @@ -54,6 +54,7 @@ [showBulkEditCollectionAccess]=" (showBulkEditCollectionAccess$ | async) && organization?.flexibleCollections " + [showBulkAddToCollections]="organization?.flexibleCollections" [viewingOrgVault]="true" > 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 ecec349482..6691404b3d 100644 --- a/apps/web/src/app/vault/org-vault/vault.component.ts +++ b/apps/web/src/app/vault/org-vault/vault.component.ts @@ -46,6 +46,7 @@ import { LogService } from "@bitwarden/common/platform/abstractions/log.service" import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { Utils } from "@bitwarden/common/platform/misc/utils"; +import { OrganizationId } from "@bitwarden/common/types/guid"; import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; import { CollectionService } from "@bitwarden/common/vault/abstractions/collection.service"; import { SyncService } from "@bitwarden/common/vault/abstractions/sync/sync.service.abstraction"; @@ -86,6 +87,10 @@ import { getNestedCollectionTree } from "../utils/collection-utils"; import { AddEditComponent } from "./add-edit.component"; import { AttachmentsComponent } from "./attachments.component"; +import { + BulkCollectionAssignmentDialogComponent, + BulkCollectionAssignmentDialogResult, +} from "./bulk-collection-assignment-dialog"; import { BulkCollectionsDialogComponent, BulkCollectionsDialogResult, @@ -631,6 +636,8 @@ export class VaultComponent implements OnInit, OnDestroy { await this.editCollection(event.item, CollectionDialogTabType.Access); } else if (event.type === "bulkEditCollectionAccess") { await this.bulkEditCollectionAccess(event.items); + } else if (event.type === "assignToCollections") { + await this.bulkAssignToCollections(event.items); } else if (event.type === "viewEvents") { await this.viewEvents(event.item); } @@ -1092,6 +1099,41 @@ export class VaultComponent implements OnInit, OnDestroy { } } + async bulkAssignToCollections(items: CipherView[]) { + if (items.length === 0) { + this.platformUtilsService.showToast( + "error", + this.i18nService.t("errorOccurred"), + this.i18nService.t("nothingSelected"), + ); + return; + } + + let availableCollections: CollectionView[]; + + if (this.flexibleCollectionsV1Enabled) { + availableCollections = await firstValueFrom(this.editableCollections$); + } else { + availableCollections = ( + await firstValueFrom(this.vaultFilterService.filteredCollections$) + ).filter((c) => c.id != Unassigned); + } + + const dialog = BulkCollectionAssignmentDialogComponent.open(this.dialogService, { + data: { + ciphers: items, + organizationId: this.organization?.id as OrganizationId, + availableCollections, + activeCollection: this.activeFilter?.selectedCollectionNode?.node, + }, + }); + + const result = await lastValueFrom(dialog.closed); + if (result === BulkCollectionAssignmentDialogResult.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 34e3c5d754..95d1b03e72 100644 --- a/apps/web/src/locales/en/messages.json +++ b/apps/web/src/locales/en/messages.json @@ -7605,5 +7605,42 @@ }, "restrictedCollectionAccess": { "message": "You cannot add yourself to collections." + }, + "assign": { + "message": "Assign" + }, + "assignToCollections": { + "message": "Assign to collections" + }, + "assignToTheseCollections": { + "message": "Assign to these collections" + }, + "bulkCollectionAssignmentDialogDescription": { + "message": "Select the collections that the items will be shared with. Once an item is updated in one collection, it will be reflected in all collections. Only organization members with access to these collections will be able to see the items." + }, + "selectCollectionsToAssign": { + "message": "Select collections to assign" + }, + "noCollectionsAssigned": { + "message": "No collections have been assigned" + }, + "successfullyAssignedCollections": { + "message": "Successfully assigned collections" + }, + "bulkCollectionAssignmentWarning": { + "message": "You have selected $TOTAL_COUNT$ items. You cannot update $READONLY_COUNT$ of the items because you do not have edit permissions.", + "placeholders": { + "total_count": { + "content": "$1", + "example": "10" + }, + "readonly_count": { + "content": "$2", + "example": "3" + } + } + }, + "items": { + "message": "Items" } } diff --git a/libs/common/src/types/guid.ts b/libs/common/src/types/guid.ts index 7f88c82a9e..714f5dffc3 100644 --- a/libs/common/src/types/guid.ts +++ b/libs/common/src/types/guid.ts @@ -7,3 +7,4 @@ export type OrganizationId = Opaque; export type CollectionId = Opaque; export type ProviderId = Opaque; export type PolicyId = Opaque; +export type CipherId = Opaque; diff --git a/libs/common/src/vault/abstractions/cipher.service.ts b/libs/common/src/vault/abstractions/cipher.service.ts index 30b518612d..a8a0a25e9b 100644 --- a/libs/common/src/vault/abstractions/cipher.service.ts +++ b/libs/common/src/vault/abstractions/cipher.service.ts @@ -1,5 +1,6 @@ import { UriMatchStrategySetting } from "../../models/domain/domain-service"; import { SymmetricCryptoKey } from "../../platform/models/domain/symmetric-crypto-key"; +import { CipherId, CollectionId, OrganizationId } from "../../types/guid"; import { CipherType } from "../enums/cipher-type"; import { CipherData } from "../models/data/cipher.data"; import { Cipher } from "../models/domain/cipher"; @@ -63,6 +64,19 @@ export abstract class CipherService { admin?: boolean, ) => Promise; saveCollectionsWithServer: (cipher: Cipher) => Promise; + /** + * Bulk update collections for many ciphers with the server + * @param orgId + * @param cipherIds + * @param collectionIds + * @param removeCollections - If true, the collections will be removed from the ciphers, otherwise they will be added + */ + bulkUpdateCollectionsWithServer: ( + orgId: OrganizationId, + cipherIds: CipherId[], + collectionIds: CollectionId[], + removeCollections: boolean, + ) => Promise; upsert: (cipher: CipherData | CipherData[]) => Promise; replace: (ciphers: { [id: string]: CipherData }) => Promise; clear: (userId: string) => Promise; diff --git a/libs/common/src/vault/models/request/cipher-bulk-update-collections.request.ts b/libs/common/src/vault/models/request/cipher-bulk-update-collections.request.ts new file mode 100644 index 0000000000..1b1a77a48d --- /dev/null +++ b/libs/common/src/vault/models/request/cipher-bulk-update-collections.request.ts @@ -0,0 +1,19 @@ +import { CipherId, CollectionId, OrganizationId } from "../../../types/guid"; + +export class CipherBulkUpdateCollectionsRequest { + organizationId: OrganizationId; + cipherIds: CipherId[]; + collectionIds: CollectionId[]; + removeCollections: boolean; + constructor( + organizationId: OrganizationId, + cipherIds: CipherId[], + collectionIds: CollectionId[], + removeCollections: boolean = false, + ) { + this.organizationId = organizationId; + this.cipherIds = cipherIds; + this.collectionIds = collectionIds; + this.removeCollections = removeCollections; + } +} diff --git a/libs/common/src/vault/services/cipher.service.ts b/libs/common/src/vault/services/cipher.service.ts index 8a86d9aa05..4293e56728 100644 --- a/libs/common/src/vault/services/cipher.service.ts +++ b/libs/common/src/vault/services/cipher.service.ts @@ -21,7 +21,8 @@ import Domain from "../../platform/models/domain/domain-base"; import { EncArrayBuffer } from "../../platform/models/domain/enc-array-buffer"; import { EncString } from "../../platform/models/domain/enc-string"; import { SymmetricCryptoKey } from "../../platform/models/domain/symmetric-crypto-key"; -import { UserKey, OrgKey } from "../../types/key"; +import { CipherId, CollectionId, OrganizationId } from "../../types/guid"; +import { OrgKey, UserKey } from "../../types/key"; import { CipherService as CipherServiceAbstraction } from "../abstractions/cipher.service"; import { CipherFileUploadService } from "../abstractions/file-upload/cipher-file-upload.service"; import { FieldType } from "../enums"; @@ -42,6 +43,7 @@ import { CipherBulkDeleteRequest } from "../models/request/cipher-bulk-delete.re import { CipherBulkMoveRequest } from "../models/request/cipher-bulk-move.request"; import { CipherBulkRestoreRequest } from "../models/request/cipher-bulk-restore.request"; import { CipherBulkShareRequest } from "../models/request/cipher-bulk-share.request"; +import { CipherBulkUpdateCollectionsRequest } from "../models/request/cipher-bulk-update-collections.request"; import { CipherCollectionsRequest } from "../models/request/cipher-collections.request"; import { CipherCreateRequest } from "../models/request/cipher-create.request"; import { CipherPartialRequest } from "../models/request/cipher-partial.request"; @@ -685,6 +687,49 @@ export class CipherService implements CipherServiceAbstraction { await this.upsert(data); } + /** + * Bulk update collections for many ciphers with the server + * @param orgId + * @param cipherIds + * @param collectionIds + * @param removeCollections - If true, the collectionIds will be removed from the ciphers, otherwise they will be added + */ + async bulkUpdateCollectionsWithServer( + orgId: OrganizationId, + cipherIds: CipherId[], + collectionIds: CollectionId[], + removeCollections: boolean = false, + ): Promise { + const request = new CipherBulkUpdateCollectionsRequest( + orgId, + cipherIds, + collectionIds, + removeCollections, + ); + + await this.apiService.send("POST", "/ciphers/bulk-collections", request, true, false); + + // Update the local state + const ciphers = await this.stateService.getEncryptedCiphers(); + + for (const id of cipherIds) { + const cipher = ciphers[id]; + if (cipher) { + if (removeCollections) { + cipher.collectionIds = cipher.collectionIds?.filter( + (cid) => !collectionIds.includes(cid as CollectionId), + ); + } else { + // Append to the collectionIds if it's not already there + cipher.collectionIds = [...new Set([...(cipher.collectionIds ?? []), ...collectionIds])]; + } + } + } + + await this.clearCache(); + await this.stateService.setEncryptedCiphers(ciphers); + } + async upsert(cipher: CipherData | CipherData[]): Promise { let ciphers = await this.stateService.getEncryptedCiphers(); if (ciphers == null) {