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 b12076e3597..f1f5cbc8c0c 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 ee284d05175..c63273fabd3 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 00000000000..04edce8543f --- /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 00000000000..44042e3267a --- /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 08bf77be37e..242a03b9955 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 ecec349482d..6691404b3db 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 34e3c5d7548..95d1b03e725 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 7f88c82a9e5..714f5dffc39 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 30b518612d9..a8a0a25e9bd 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 00000000000..1b1a77a48d2 --- /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 8a86d9aa050..4293e56728c 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) {