From 050f8f4bdcb7a7b8e79a9b061d344aa25ed5bedf Mon Sep 17 00:00:00 2001 From: SmithThe4th Date: Thu, 11 Jul 2024 17:39:49 -0400 Subject: [PATCH] [PM-7624] [PM-7625] Bulk management actions on individual vault (#9507) * fixed issue with clearing search index state * clear user index before account is totally cleaned up * added logout clear on option * removed redundant clear index from logout * added feature flag * added new menu drop down and put behind feature flag * added permanentlyDeleteSelected to the menu * added permanentlyDeleteSelected to the menu * wired up logic to show to hide menu drop down items * modified the bulk collection assignment to work with end user vault * wired up delete and move to folder * merged bulk management actions header into old leveraging the feature flag * added ability to move personal items to an organization and set active collection when user is on a collection * made collection required by default * handled organization cipher share when personal items and org items are selected * moved logic to determine warning text to component class * moved logic to determine warning text to component class * Improved hide or show logic for menu * added bullet point to bulk assignment dialog content * changed description for move to folder * Fixed issue were all collections are retrived instead of only can manage, and added logic to get collections associated with a cipher * added inline assign to collections * added logic to disable three dot to template * Updated logic to retreive shared collection ids between ciphers * Added logic to make attachment view only, show or hide * Only show menu options when there are options available * Comments cleanup * update cipher row to disable menu instead of hide * Put add to folder behind feature flag * ensured old menu behaviour is shown when feature flag is turned off * refactored code base on code review suggestions * fixed bug with available collections * Made assign to collections resuable made pluralize a pipe instead * Utilized the resuable assign to collections component on the web * changed description message for collection assignment * fixed bug with ExpressionChangedAfterItHasBeenCheckedError * Added changedetectorref markForCheck * removed redundant startwith as seed value has been added * made code review suggestions * fixed bug where assign to collections shows up in trash filter * removed bitInput * refactored based on code review comments * added reference ticket * [PM-9341] Cannot assign to collections when filtering by My Vault (#9862) * Add checks for org id myvault * made myvault id a constant * show bulk move is set by individual vault and it is needed so assign to collections does not show up in trash filter (#9876) * Fixed issue where selectedOrgId is null (#9879) * Fix bug introduced with assigning items to a collection (#9897) * [PM-9601] [PM-9602] When collection management setting is turned on view only collections and assign to collections menu option show up (#10047) * Only show collections with edit access on individual vault * remove unused arguments --- .../assign-collections-web.component.html | 35 ++ .../assign-collections-web.component.ts | 39 ++ .../components/assign-collections/index.ts | 1 + .../vault-cipher-row.component.html | 38 +- .../vault-items/vault-cipher-row.component.ts | 53 +++ .../vault-items/vault-items.component.html | 29 +- .../vault-items/vault-items.component.ts | 116 +++++ .../individual-vault/attachments.component.ts | 1 - .../bulk-move-dialog.component.html | 11 +- .../bulk-move-dialog.component.ts | 7 + .../individual-vault/vault.component.html | 2 + .../vault/individual-vault/vault.component.ts | 71 ++- ...ollection-assignment-dialog.component.html | 66 --- ...-collection-assignment-dialog.component.ts | 195 -------- .../index.ts | 1 - .../app/vault/org-vault/vault.component.ts | 11 +- apps/web/src/locales/en/messages.json | 32 +- libs/angular/src/jslib.module.ts | 2 + libs/angular/src/pipes/pluralize.pipe.ts | 11 + .../vault/components/attachments.component.ts | 1 + libs/common/src/enums/feature-flag.enum.ts | 2 + .../assign-collections.component.html | 42 ++ .../assign-collections.component.ts | 443 ++++++++++++++++++ libs/vault/src/index.ts | 5 + 24 files changed, 919 insertions(+), 295 deletions(-) create mode 100644 apps/web/src/app/vault/components/assign-collections/assign-collections-web.component.html create mode 100644 apps/web/src/app/vault/components/assign-collections/assign-collections-web.component.ts create mode 100644 apps/web/src/app/vault/components/assign-collections/index.ts delete mode 100644 apps/web/src/app/vault/org-vault/bulk-collection-assignment-dialog/bulk-collection-assignment-dialog.component.html delete mode 100644 apps/web/src/app/vault/org-vault/bulk-collection-assignment-dialog/bulk-collection-assignment-dialog.component.ts delete mode 100644 apps/web/src/app/vault/org-vault/bulk-collection-assignment-dialog/index.ts create mode 100644 libs/angular/src/pipes/pluralize.pipe.ts create mode 100644 libs/vault/src/components/assign-collections.component.html create mode 100644 libs/vault/src/components/assign-collections.component.ts diff --git a/apps/web/src/app/vault/components/assign-collections/assign-collections-web.component.html b/apps/web/src/app/vault/components/assign-collections/assign-collections-web.component.html new file mode 100644 index 0000000000..f05262832c --- /dev/null +++ b/apps/web/src/app/vault/components/assign-collections/assign-collections-web.component.html @@ -0,0 +1,35 @@ + + + {{ "assignToCollections" | i18n }} + + {{ editableItemCount | pluralize: ("item" | i18n) : ("items" | i18n) }} + + + +
+ +
+ + + + + +
diff --git a/apps/web/src/app/vault/components/assign-collections/assign-collections-web.component.ts b/apps/web/src/app/vault/components/assign-collections/assign-collections-web.component.ts new file mode 100644 index 0000000000..dc7740cc24 --- /dev/null +++ b/apps/web/src/app/vault/components/assign-collections/assign-collections-web.component.ts @@ -0,0 +1,39 @@ +import { DIALOG_DATA, DialogConfig, DialogRef } from "@angular/cdk/dialog"; +import { Component, Inject } from "@angular/core"; + +import { PluralizePipe } from "@bitwarden/angular/pipes/pluralize.pipe"; +import { DialogService } from "@bitwarden/components"; +import { + AssignCollectionsComponent, + CollectionAssignmentParams, + CollectionAssignmentResult, +} from "@bitwarden/vault"; + +import { SharedModule } from "../../../shared"; + +@Component({ + imports: [SharedModule, AssignCollectionsComponent, PluralizePipe], + templateUrl: "./assign-collections-web.component.html", + standalone: true, +}) +export class AssignCollectionsWebComponent { + protected loading = false; + protected disabled = false; + protected editableItemCount: number; + + constructor( + @Inject(DIALOG_DATA) public params: CollectionAssignmentParams, + private dialogRef: DialogRef, + ) {} + + protected async onCollectionAssign(result: CollectionAssignmentResult) { + this.dialogRef.close(result); + } + + static open(dialogService: DialogService, config: DialogConfig) { + return dialogService.open( + AssignCollectionsWebComponent, + config, + ); + } +} diff --git a/apps/web/src/app/vault/components/assign-collections/index.ts b/apps/web/src/app/vault/components/assign-collections/index.ts new file mode 100644 index 0000000000..0c20f95885 --- /dev/null +++ b/apps/web/src/app/vault/components/assign-collections/index.ts @@ -0,0 +1 @@ +export * from "./assign-collections-web.component"; diff --git a/apps/web/src/app/vault/components/vault-items/vault-cipher-row.component.html b/apps/web/src/app/vault/components/vault-items/vault-cipher-row.component.html index af2a8443ed..604dd4acad 100644 --- a/apps/web/src/app/vault/components/vault-items/vault-cipher-row.component.html +++ b/apps/web/src/app/vault/components/vault-items/vault-cipher-row.component.html @@ -69,8 +69,9 @@ - + - + + - + @@ -138,7 +155,12 @@ {{ "restore" | i18n }} - - @@ -125,6 +140,8 @@ [organizations]="allOrganizations" [collections]="allCollections" [checked]="selection.isSelected(item)" + [canEditCipher]="canEditCipher(item.cipher) && vaultBulkManagementActionEnabled" + [vaultBulkManagementActionEnabled]="vaultBulkManagementActionEnabled" (checkedToggled)="selection.toggle(item)" (onEvent)="event($event)" > diff --git a/apps/web/src/app/vault/components/vault-items/vault-items.component.ts b/apps/web/src/app/vault/components/vault-items/vault-items.component.ts index baca403f18..bfb30f3f76 100644 --- a/apps/web/src/app/vault/components/vault-items/vault-items.component.ts +++ b/apps/web/src/app/vault/components/vault-items/vault-items.component.ts @@ -48,6 +48,7 @@ export class VaultItemsComponent { @Input() addAccessStatus: number; @Input() addAccessToggle: boolean; @Input() restrictProviderAccess: boolean; + @Input() vaultBulkManagementActionEnabled = false; private _ciphers?: CipherView[] = []; @Input() get ciphers(): CipherView[] { @@ -93,10 +94,24 @@ export class VaultItemsComponent { ); } + get disableMenu() { + return ( + this.vaultBulkManagementActionEnabled && + !this.bulkMoveAllowed && + !this.showAssignToCollections() && + !this.showDelete() + ); + } + get bulkAssignToCollectionsAllowed() { return this.showBulkAddToCollections && this.ciphers.length > 0; } + // Use new bulk management delete if vaultBulkManagementActionEnabled feature flag is enabled + get deleteAllowed() { + return this.vaultBulkManagementActionEnabled ? this.showDelete() : true; + } + protected canEditCollection(collection: CollectionView): boolean { // Only allow allow deletion if collection editing is enabled and not deleting "Unassigned" if (collection.id === Unassigned) { @@ -192,6 +207,22 @@ export class VaultItemsComponent { return false; } + protected canEditCipher(cipher: CipherView) { + if (cipher.organizationId == null) { + return true; + } + + const organization = this.allOrganizations.find((o) => o.id === cipher.organizationId); + return ( + (organization.canEditAllCiphers( + this.flexibleCollectionsV1Enabled, + this.restrictProviderAccess, + ) && + this.viewingOrgVault) || + cipher.edit + ); + } + private refreshItems() { const collections: VaultItem[] = this.collections.map((collection) => ({ collection })); const ciphers: VaultItem[] = this.ciphers.map((cipher) => ({ cipher })); @@ -235,4 +266,89 @@ export class VaultItemsComponent { .map((item) => item.cipher), }); } + + protected showAssignToCollections(): boolean { + if (!this.showBulkMove) { + return false; + } + + if (this.selection.selected.length === 0) { + return true; + } + + const hasPersonalItems = this.hasPersonalItems(); + const uniqueCipherOrgIds = this.getUniqueOrganizationIds(); + + // Return false if items are from different organizations + if (uniqueCipherOrgIds.size > 1) { + return false; + } + + // If all items are personal, return based on personal items + if (uniqueCipherOrgIds.size === 0) { + return hasPersonalItems; + } + + const [orgId] = uniqueCipherOrgIds; + const organization = this.allOrganizations.find((o) => o.id === orgId); + + const canEditOrManageAllCiphers = + organization?.canEditAllCiphers( + this.flexibleCollectionsV1Enabled, + this.restrictProviderAccess, + ) && this.viewingOrgVault; + + const collectionNotSelected = + this.selection.selected.filter((item) => item.collection).length === 0; + + return (canEditOrManageAllCiphers || this.allCiphersHaveEditAccess()) && collectionNotSelected; + } + + protected showDelete(): boolean { + if (this.selection.selected.length === 0) { + return true; + } + + const hasPersonalItems = this.hasPersonalItems(); + const uniqueCipherOrgIds = this.getUniqueOrganizationIds(); + const organizations = Array.from(uniqueCipherOrgIds, (orgId) => + this.allOrganizations.find((o) => o.id === orgId), + ); + + const canEditOrManageAllCiphers = + organizations.length > 0 && + organizations.every((org) => + org?.canEditAllCiphers(this.flexibleCollectionsV1Enabled, this.restrictProviderAccess), + ); + + const canDeleteCollections = this.selection.selected + .filter((item) => item.collection) + .every((item) => item.collection && this.canDeleteCollection(item.collection)); + + const userCanDeleteAccess = + (canEditOrManageAllCiphers || this.allCiphersHaveEditAccess()) && canDeleteCollections; + + if ( + userCanDeleteAccess || + (hasPersonalItems && (!uniqueCipherOrgIds.size || userCanDeleteAccess)) + ) { + return true; + } + + return false; + } + + private hasPersonalItems(): boolean { + return this.selection.selected.some(({ cipher }) => cipher?.organizationId === null); + } + + private allCiphersHaveEditAccess(): boolean { + return this.selection.selected + .filter(({ cipher }) => cipher) + .every(({ cipher }) => cipher?.edit); + } + + private getUniqueOrganizationIds(): Set { + return new Set(this.selection.selected.flatMap((i) => i.cipher?.organizationId ?? [])); + } } diff --git a/apps/web/src/app/vault/individual-vault/attachments.component.ts b/apps/web/src/app/vault/individual-vault/attachments.component.ts index ae4e8fafab..3bf87ba4e3 100644 --- a/apps/web/src/app/vault/individual-vault/attachments.component.ts +++ b/apps/web/src/app/vault/individual-vault/attachments.component.ts @@ -18,7 +18,6 @@ import { DialogService } from "@bitwarden/components"; templateUrl: "attachments.component.html", }) export class AttachmentsComponent extends BaseAttachmentsComponent { - viewOnly = false; protected override componentName = "app-vault-attachments"; constructor( diff --git a/apps/web/src/app/vault/individual-vault/bulk-action-dialogs/bulk-move-dialog/bulk-move-dialog.component.html b/apps/web/src/app/vault/individual-vault/bulk-action-dialogs/bulk-move-dialog/bulk-move-dialog.component.html index 8843bda2f7..59341a712d 100644 --- a/apps/web/src/app/vault/individual-vault/bulk-action-dialogs/bulk-move-dialog/bulk-move-dialog.component.html +++ b/apps/web/src/app/vault/individual-vault/bulk-action-dialogs/bulk-move-dialog/bulk-move-dialog.component.html @@ -1,15 +1,16 @@
- {{ "moveSelected" | i18n }} + {{ ((vaultBulkManagementActionEnabled$ | async) ? "addToFolder" : "moveSelected") | i18n }}

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

- {{ "folder" | i18n }} - + {{ "selectFolder" | i18n }} + + + +
diff --git a/apps/web/src/app/vault/individual-vault/bulk-action-dialogs/bulk-move-dialog/bulk-move-dialog.component.ts b/apps/web/src/app/vault/individual-vault/bulk-action-dialogs/bulk-move-dialog/bulk-move-dialog.component.ts index cdf45d0669..252cdc7ac5 100644 --- a/apps/web/src/app/vault/individual-vault/bulk-action-dialogs/bulk-move-dialog/bulk-move-dialog.component.ts +++ b/apps/web/src/app/vault/individual-vault/bulk-action-dialogs/bulk-move-dialog/bulk-move-dialog.component.ts @@ -3,6 +3,8 @@ import { Component, Inject, OnInit } from "@angular/core"; import { FormBuilder, Validators } from "@angular/forms"; import { firstValueFrom, Observable } from "rxjs"; +import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; +import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; @@ -45,6 +47,10 @@ export class BulkMoveDialogComponent implements OnInit { }); folders$: Observable; + protected vaultBulkManagementActionEnabled$ = this.configService.getFeatureFlag$( + FeatureFlag.VaultBulkManagementAction, + ); + constructor( @Inject(DIALOG_DATA) params: BulkMoveDialogParams, private dialogRef: DialogRef, @@ -53,6 +59,7 @@ export class BulkMoveDialogComponent implements OnInit { private i18nService: I18nService, private folderService: FolderService, private formBuilder: FormBuilder, + private configService: ConfigService, ) { this.cipherIds = params.cipherIds ?? []; } diff --git a/apps/web/src/app/vault/individual-vault/vault.component.html b/apps/web/src/app/vault/individual-vault/vault.component.html index 780614c330..fe1a97aff1 100644 --- a/apps/web/src/app/vault/individual-vault/vault.component.html +++ b/apps/web/src/app/vault/individual-vault/vault.component.html @@ -50,8 +50,10 @@ [showBulkTrashOptions]="filter.type === 'trash'" [useEvents]="false" [showAdminActions]="false" + [showBulkAddToCollections]="vaultBulkManagementActionEnabled$ | async" (onEvent)="onVaultItemsEvent($event)" [flexibleCollectionsV1Enabled]="flexibleCollectionsV1Enabled$ | async" + [vaultBulkManagementActionEnabled]="vaultBulkManagementActionEnabled$ | async" >
(); private refresh$ = new BehaviorSubject(null); @@ -379,9 +384,7 @@ export class VaultComponent implements OnInit, OnDestroy { (o) => o.canCreateNewCollections && !o.isProviderUser, ); - this.showBulkMove = - filter.type !== "trash" && - (filter.organizationId === undefined || filter.organizationId === Unassigned); + this.showBulkMove = filter.type !== "trash"; this.isEmpty = collections?.length === 0 && ciphers?.length === 0; this.performingInitialLoad = false; @@ -428,6 +431,8 @@ export class VaultComponent implements OnInit, OnDestroy { await this.editCollection(event.item, CollectionDialogTabType.Info); } else if (event.type === "viewCollectionAccess") { await this.editCollection(event.item, CollectionDialogTabType.Access); + } else if (event.type === "assignToCollections") { + await this.bulkAssignToCollections(event.items); } } finally { this.processingEvent = false; @@ -492,12 +497,18 @@ export class VaultComponent implements OnInit, OnDestroy { } } + const canEditAttachments = await this.canEditAttachments(cipher); + const vaultBulkManagementActionEnabled = await firstValueFrom( + this.vaultBulkManagementActionEnabled$, + ); + let madeAttachmentChanges = false; const [modal] = await this.modalService.openViewRef( AttachmentsComponent, this.attachmentsModalRef, (comp) => { comp.cipherId = cipher.id; + comp.viewOnly = !canEditAttachments && vaultBulkManagementActionEnabled; comp.onUploadedAttachment .pipe(takeUntil(this.destroy$)) .subscribe(() => (madeAttachmentChanges = true)); @@ -707,6 +718,47 @@ export class VaultComponent implements OnInit, OnDestroy { } } + async bulkAssignToCollections(ciphers: CipherView[]) { + if (!(await this.repromptCipher(ciphers))) { + return; + } + + if (ciphers.length === 0) { + this.platformUtilsService.showToast( + "error", + this.i18nService.t("errorOccurred"), + this.i18nService.t("nothingSelected"), + ); + return; + } + + let availableCollections: CollectionView[] = []; + const orgId = + this.activeFilter.organizationId || + ciphers.find((c) => c.organizationId !== null)?.organizationId; + + if (orgId && orgId !== "MyVault") { + const organization = this.allOrganizations.find((o) => o.id === orgId); + availableCollections = this.allCollections.filter( + (c) => c.organizationId === organization.id && !c.readOnly, + ); + } + + const dialog = AssignCollectionsWebComponent.open(this.dialogService, { + data: { + ciphers, + organizationId: orgId as OrganizationId, + availableCollections, + activeCollection: this.activeFilter?.selectedCollectionNode?.node, + }, + }); + + const result = await lastValueFrom(dialog.closed); + if (result === CollectionAssignmentResult.Saved) { + this.refresh(); + } + } + async cloneCipher(cipher: CipherView) { if (cipher.login?.hasFido2Credentials) { const confirmed = await this.dialogService.openSimpleDialog({ @@ -984,6 +1036,17 @@ export class VaultComponent implements OnInit, OnDestroy { this.refresh$.next(); } + private async canEditAttachments(cipher: CipherView) { + if (cipher.organizationId == null || cipher.edit) { + return true; + } + + const flexibleCollectionsV1Enabled = await this.flexibleCollectionsV1Enabled(); + + const organization = this.allOrganizations.find((o) => o.id === cipher.organizationId); + return organization.canEditAllCiphers(flexibleCollectionsV1Enabled, false); + } + private go(queryParams: any = null) { if (queryParams == null) { queryParams = { diff --git a/apps/web/src/app/vault/org-vault/bulk-collection-assignment-dialog/bulk-collection-assignment-dialog.component.html b/apps/web/src/app/vault/org-vault/bulk-collection-assignment-dialog/bulk-collection-assignment-dialog.component.html deleted file mode 100644 index 520e807788..0000000000 --- a/apps/web/src/app/vault/org-vault/bulk-collection-assignment-dialog/bulk-collection-assignment-dialog.component.html +++ /dev/null @@ -1,66 +0,0 @@ - - - {{ "assignToCollections" | i18n }} - - {{ pluralize(editableItemCount, "item", "items") }} - - - -
-

{{ "bulkCollectionAssignmentDialogDescription" | i18n }}

- -

- {{ "bulkCollectionAssignmentWarning" | i18n: totalItemCount : readonlyItemCount }} -

- -
- - {{ "selectCollectionsToAssign" | i18n }} - - -
- - - - {{ "assignToTheseCollections" | i18n }} - - - - - - - {{ item.labelName }} - - - - - - - - {{ "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 deleted file mode 100644 index 8998629b66..0000000000 --- a/apps/web/src/app/vault/org-vault/bulk-collection-assignment-dialog/bulk-collection-assignment-dialog.component.ts +++ /dev/null @@ -1,195 +0,0 @@ -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 { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; -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: ConfigService, - private organizationService: OrganizationService, - ) {} - - async ngOnInit() { - // If no ciphers are passed in, close the dialog - if (this.params.ciphers == null || this.params.ciphers.length < 1) { - this.platformUtilsService.showToast("error", null, this.i18nService.t("nothingSelected")); - this.dialogRef.close(BulkCollectionAssignmentDialogResult.Canceled); - return; - } - - const v1FCEnabled = await this.configService.getFeatureFlag(FeatureFlag.FlexibleCollectionsV1); - const restrictProviderAccess = await this.configService.getFeatureFlag( - FeatureFlag.RestrictProviderAccess, - ); - const org = await this.organizationService.get(this.params.organizationId); - - if (org.canEditAllCiphers(v1FCEnabled, restrictProviderAccess)) { - 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", null, this.i18nService.t("missingPermissions")); - this.dialogRef.close(BulkCollectionAssignmentDialogResult.Canceled); - return; - } - - 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 deleted file mode 100644 index 44042e3267..0000000000 --- a/apps/web/src/app/vault/org-vault/bulk-collection-assignment-dialog/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from "./bulk-collection-assignment-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 6622882bf8..07d65656d2 100644 --- a/apps/web/src/app/vault/org-vault/vault.component.ts +++ b/apps/web/src/app/vault/org-vault/vault.component.ts @@ -59,12 +59,13 @@ import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; import { CollectionView } from "@bitwarden/common/vault/models/view/collection.view"; import { ServiceUtils } from "@bitwarden/common/vault/service-utils"; import { DialogService, Icons, ToastService } from "@bitwarden/components"; -import { PasswordRepromptService } from "@bitwarden/vault"; +import { CollectionAssignmentResult, PasswordRepromptService } from "@bitwarden/vault"; import { GroupService, GroupView } from "../../admin-console/organizations/core"; import { openEntityEventsDialog } from "../../admin-console/organizations/manage/entity-events.component"; import { VaultFilterService } from "../../vault/individual-vault/vault-filter/services/abstractions/vault-filter.service"; import { VaultFilter } from "../../vault/individual-vault/vault-filter/shared/models/vault-filter.model"; +import { AssignCollectionsWebComponent } from "../components/assign-collections"; import { CollectionDialogAction, CollectionDialogTabType, @@ -90,10 +91,6 @@ 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, @@ -1327,7 +1324,7 @@ export class VaultComponent implements OnInit, OnDestroy { ).filter((c) => c.id != Unassigned); } - const dialog = BulkCollectionAssignmentDialogComponent.open(this.dialogService, { + const dialog = AssignCollectionsWebComponent.open(this.dialogService, { data: { ciphers: items, organizationId: this.organization?.id as OrganizationId, @@ -1337,7 +1334,7 @@ export class VaultComponent implements OnInit, OnDestroy { }); const result = await lastValueFrom(dialog.closed); - if (result === BulkCollectionAssignmentDialogResult.Saved) { + if (result === CollectionAssignmentResult.Saved) { this.refresh(); } } diff --git a/apps/web/src/locales/en/messages.json b/apps/web/src/locales/en/messages.json index dbbae60cf7..f587560118 100644 --- a/apps/web/src/locales/en/messages.json +++ b/apps/web/src/locales/en/messages.json @@ -1060,7 +1060,7 @@ "message": "Are you sure you want to continue?" }, "moveSelectedItemsDesc": { - "message": "Choose a folder that you would like to move the $COUNT$ selected item(s) to.", + "message": "Choose a folder that you would like to add the $COUNT$ selected item(s) to.", "placeholders": { "count": { "content": "$1", @@ -7883,7 +7883,7 @@ "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." + "message": "Only organization members with access to these collections will be able to see the items." }, "selectCollectionsToAssign": { "message": "Select collections to assign" @@ -8547,5 +8547,33 @@ }, "licenseAndBillingManagementDesc": { "message": "After making updates in the Bitwarden cloud server, upload your license file to apply the most recent changes." + }, + "addToFolder": { + "message": "Add to folder" + }, + "selectFolder": { + "message": "Select folder" + }, + "personalItemsTransferWarning": { + "message": "$PERSONAL_ITEMS_COUNT$ will be permanently transferred to the selected organization. You will no longer own these items.", + "placeholders": { + "personal_items_count": { + "content": "$1", + "example": "2 items" + } + } + }, + "personalItemsWithOrgTransferWarning": { + "message": "$PERSONAL_ITEMS_COUNT$ will be permanently transferred to $ORG$. You will no longer own these items.", + "placeholders": { + "personal_items_count": { + "content": "$1", + "example": "2 items" + }, + "org": { + "content": "$2", + "example": "Organization name" + } + } } } diff --git a/libs/angular/src/jslib.module.ts b/libs/angular/src/jslib.module.ts index 2ec13ea35e..da8a4dd418 100644 --- a/libs/angular/src/jslib.module.ts +++ b/libs/angular/src/jslib.module.ts @@ -46,6 +46,7 @@ import { StopClickDirective } from "./directives/stop-click.directive"; import { StopPropDirective } from "./directives/stop-prop.directive"; import { TrueFalseValueDirective } from "./directives/true-false-value.directive"; import { CreditCardNumberPipe } from "./pipes/credit-card-number.pipe"; +import { PluralizePipe } from "./pipes/pluralize.pipe"; import { SearchCiphersPipe } from "./pipes/search-ciphers.pipe"; import { SearchPipe } from "./pipes/search.pipe"; import { UserNamePipe } from "./pipes/user-name.pipe"; @@ -162,6 +163,7 @@ import { IconComponent } from "./vault/components/icon.component"; UserNamePipe, UserTypePipe, FingerprintPipe, + PluralizePipe, ], }) export class JslibModule {} diff --git a/libs/angular/src/pipes/pluralize.pipe.ts b/libs/angular/src/pipes/pluralize.pipe.ts new file mode 100644 index 0000000000..cc3aa3e0aa --- /dev/null +++ b/libs/angular/src/pipes/pluralize.pipe.ts @@ -0,0 +1,11 @@ +import { Pipe, PipeTransform } from "@angular/core"; + +@Pipe({ + name: "pluralize", + standalone: true, +}) +export class PluralizePipe implements PipeTransform { + transform(count: number, singular: string, plural: string): string { + return `${count} ${count === 1 ? singular : plural}`; + } +} diff --git a/libs/angular/src/vault/components/attachments.component.ts b/libs/angular/src/vault/components/attachments.component.ts index fc86f2f527..68b336a8b0 100644 --- a/libs/angular/src/vault/components/attachments.component.ts +++ b/libs/angular/src/vault/components/attachments.component.ts @@ -20,6 +20,7 @@ import { DialogService } from "@bitwarden/components"; @Directive() export class AttachmentsComponent implements OnInit { @Input() cipherId: string; + @Input() viewOnly: boolean; @Output() onUploadedAttachment = new EventEmitter(); @Output() onDeletedAttachment = new EventEmitter(); @Output() onReuploadedAttachment = new EventEmitter(); diff --git a/libs/common/src/enums/feature-flag.enum.ts b/libs/common/src/enums/feature-flag.enum.ts index de387480f7..3f451e38b1 100644 --- a/libs/common/src/enums/feature-flag.enum.ts +++ b/libs/common/src/enums/feature-flag.enum.ts @@ -23,6 +23,7 @@ export enum FeatureFlag { EnableTimeThreshold = "PM-5864-dollar-threshold", GroupsComponentRefactor = "groups-component-refactor", ProviderClientVaultPrivacyBanner = "ac-2833-provider-client-vault-privacy-banner", + VaultBulkManagementAction = "vault-bulk-management-action", } export type AllowedFeatureFlagTypes = boolean | number | string; @@ -56,6 +57,7 @@ export const DefaultFeatureFlagValue = { [FeatureFlag.EnableTimeThreshold]: FALSE, [FeatureFlag.GroupsComponentRefactor]: FALSE, [FeatureFlag.ProviderClientVaultPrivacyBanner]: FALSE, + [FeatureFlag.VaultBulkManagementAction]: FALSE, } satisfies Record; export type DefaultFeatureFlagValueType = typeof DefaultFeatureFlagValue; diff --git a/libs/vault/src/components/assign-collections.component.html b/libs/vault/src/components/assign-collections.component.html new file mode 100644 index 0000000000..280acae1da --- /dev/null +++ b/libs/vault/src/components/assign-collections.component.html @@ -0,0 +1,42 @@ + +

{{ "bulkCollectionAssignmentDialogDescription" | i18n }}

+ +
    +
  • +

    + {{ "bulkCollectionAssignmentWarning" | i18n: totalItemCount : readonlyItemCount }} +

    +
  • +
  • +

    + {{ transferWarningText(orgName, personalItemsCount) }} +

    +
  • +
+ +
+ + {{ "moveToOrganization" | i18n }} + + + + + +
+ +
+ + {{ "selectCollectionsToAssign" | i18n }} + + +
+ diff --git a/libs/vault/src/components/assign-collections.component.ts b/libs/vault/src/components/assign-collections.component.ts new file mode 100644 index 0000000000..5bbe616c63 --- /dev/null +++ b/libs/vault/src/components/assign-collections.component.ts @@ -0,0 +1,443 @@ +import { CommonModule } from "@angular/common"; +import { Component, EventEmitter, Input, OnInit, Output, ViewChild } from "@angular/core"; +import { FormBuilder, ReactiveFormsModule, Validators } from "@angular/forms"; +import { + Observable, + Subject, + combineLatest, + map, + shareReplay, + switchMap, + takeUntil, + tap, +} from "rxjs"; + +import { JslibModule } from "@bitwarden/angular/jslib.module"; +import { PluralizePipe } from "@bitwarden/angular/pipes/pluralize.pipe"; +import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; +import { OrganizationUserStatusType } from "@bitwarden/common/admin-console/enums"; +import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; +import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; +import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { CipherId, CollectionId, 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 { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; +import { CollectionView } from "@bitwarden/common/vault/models/view/collection.view"; +import { + AsyncActionsModule, + BitSubmitDirective, + ButtonModule, + DialogModule, + FormFieldModule, + MultiSelectModule, + SelectItemView, + SelectModule, + ToastService, +} from "@bitwarden/components"; + +export interface CollectionAssignmentParams { + 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 CollectionAssignmentResult { + Saved = "saved", + Canceled = "canceled", +} + +const MY_VAULT_ID = "MyVault"; + +@Component({ + selector: "assign-collections", + templateUrl: "assign-collections.component.html", + standalone: true, + imports: [ + CommonModule, + JslibModule, + FormFieldModule, + AsyncActionsModule, + MultiSelectModule, + SelectModule, + ReactiveFormsModule, + ButtonModule, + DialogModule, + ], +}) +export class AssignCollectionsComponent implements OnInit { + @ViewChild(BitSubmitDirective) + private bitSubmit: BitSubmitDirective; + + @Input() params: CollectionAssignmentParams; + + @Output() + formLoading = new EventEmitter(); + + @Output() + formDisabled = new EventEmitter(); + + @Output() + editableItemCountChange = new EventEmitter(); + + @Output() onCollectionAssign = new EventEmitter(); + + formGroup = this.formBuilder.group({ + selectedOrg: [null], + collections: [[], [Validators.required]], + }); + + protected totalItemCount: number; + protected editableItemCount: number; + protected readonlyItemCount: number; + protected personalItemsCount: number; + protected availableCollections: SelectItemView[] = []; + protected orgName: string; + protected showOrgSelector: boolean = false; + + protected organizations$: Observable = + this.organizationService.organizations$.pipe( + map((orgs) => + orgs + .filter((o) => o.enabled && o.status === OrganizationUserStatusType.Confirmed) + .sort((a, b) => a.name.localeCompare(b.name)), + ), + tap((orgs) => { + if (orgs.length > 0 && this.showOrgSelector) { + // Using setTimeout to defer the patchValue call until the next event loop cycle + setTimeout(() => { + this.formGroup.patchValue({ selectedOrg: orgs[0].id }); + this.setFormValidators(); + }); + } + }), + ); + + protected transferWarningText = (orgName: string, itemsCount: number) => { + const pluralizedItems = this.pluralizePipe.transform(itemsCount, "item", "items"); + return orgName + ? this.i18nService.t("personalItemsWithOrgTransferWarning", pluralizedItems, orgName) + : this.i18nService.t("personalItemsTransferWarning", pluralizedItems); + }; + + private editableItems: CipherView[] = []; + // Get the selected organization ID. If the user has not selected an organization from the form, + // fallback to use the organization ID from the params. + private get selectedOrgId(): OrganizationId { + return this.formGroup.value.selectedOrg || this.params.organizationId; + } + private destroy$ = new Subject(); + + constructor( + private cipherService: CipherService, + private i18nService: I18nService, + private configService: ConfigService, + private organizationService: OrganizationService, + private collectionService: CollectionService, + private formBuilder: FormBuilder, + private pluralizePipe: PluralizePipe, + private toastService: ToastService, + ) {} + + async ngOnInit() { + const v1FCEnabled = await this.configService.getFeatureFlag(FeatureFlag.FlexibleCollectionsV1); + const restrictProviderAccess = await this.configService.getFeatureFlag( + FeatureFlag.RestrictProviderAccess, + ); + + const onlyPersonalItems = this.params.ciphers.every((c) => c.organizationId == null); + + if (this.selectedOrgId === MY_VAULT_ID || onlyPersonalItems) { + this.showOrgSelector = true; + } + + await this.initializeItems(this.selectedOrgId, v1FCEnabled, restrictProviderAccess); + + if (this.selectedOrgId && this.selectedOrgId !== MY_VAULT_ID) { + await this.handleOrganizationCiphers(); + } + + this.setupFormSubscriptions(); + } + + ngAfterViewInit(): void { + this.bitSubmit.loading$.pipe(takeUntil(this.destroy$)).subscribe((loading) => { + this.formLoading.emit(loading); + }); + + this.bitSubmit.disabled$.pipe(takeUntil(this.destroy$)).subscribe((disabled) => { + this.formDisabled.emit(disabled); + }); + } + + ngOnDestroy(): void { + this.destroy$.next(); + this.destroy$.complete(); + } + + selectCollections(items: SelectItemView[]) { + const currentCollections = this.formGroup.controls.collections.value as SelectItemView[]; + const updatedCollections = [...currentCollections, ...items].sort(this.sortItems); + this.formGroup.patchValue({ collections: updatedCollections }); + } + + submit = async () => { + this.formGroup.markAllAsTouched(); + + if (this.formGroup.invalid) { + return; + } + + // Retrieve ciphers that belong to an organization + const cipherIds = this.editableItems + .filter((i) => i.organizationId) + .map((i) => i.id as CipherId); + + // Move personal items to the organization + if (this.personalItemsCount > 0) { + await this.moveToOrganization( + this.selectedOrgId, + this.params.ciphers.filter((c) => c.organizationId == null), + this.formGroup.controls.collections.value.map((i) => i.id as CollectionId), + ); + } + + if (cipherIds.length > 0) { + const isSingleOrgCipher = cipherIds.length === 1 && this.personalItemsCount === 0; + + // Update assigned collections for single org cipher or bulk update collections for multiple org ciphers + await (isSingleOrgCipher + ? this.updateAssignedCollections(this.editableItems[0]) + : this.bulkUpdateCollections(cipherIds)); + + this.toastService.showToast({ + variant: "success", + title: null, + message: this.i18nService.t("successfullyAssignedCollections"), + }); + } + + this.onCollectionAssign.emit(CollectionAssignmentResult.Saved); + }; + + private sortItems = (a: SelectItemView, b: SelectItemView) => + this.i18nService.collator.compare(a.labelName, b.labelName); + + private async handleOrganizationCiphers() { + // If no ciphers are editable, cancel the operation + if (this.editableItemCount == 0) { + this.toastService.showToast({ + variant: "error", + title: this.i18nService.t("errorOccurred"), + message: this.i18nService.t("nothingSelected"), + }); + this.onCollectionAssign.emit(CollectionAssignmentResult.Canceled); + + return; + } + + this.availableCollections = this.params.availableCollections.map((c) => ({ + icon: "bwi-collection", + id: c.id, + labelName: c.name, + listName: c.name, + })); + + // Select assigned collections for a single cipher. + this.selectCollectionsAssignedToSingleCipher(); + + // 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, + }, + ]); + } + } + + /** + * Selects the collections that are assigned to a single cipher, + * excluding the active collection. + */ + private selectCollectionsAssignedToSingleCipher() { + if (this.params.ciphers.length !== 1) { + return; + } + + const assignedCollectionIds = this.params.ciphers[0].collectionIds; + + // Filter the available collections to select only those that are associated with the ciphers, excluding the active collection + const assignedCollections = this.availableCollections + .filter( + (collection) => + assignedCollectionIds.includes(collection.id) && + collection.id !== this.params.activeCollection?.id, + ) + .map((collection) => ({ + icon: "bwi-collection", + id: collection.id, + labelName: collection.labelName, + listName: collection.listName, + })); + + if (assignedCollections.length > 0) { + this.selectCollections(assignedCollections); + } + } + + private async initializeItems( + organizationId: OrganizationId, + v1FCEnabled: boolean, + restrictProviderAccess: boolean, + ) { + this.totalItemCount = this.params.ciphers.length; + + // If organizationId is not present or organizationId is MyVault, then all ciphers are considered personal items + if (!organizationId || organizationId === MY_VAULT_ID) { + this.editableItems = this.params.ciphers; + this.editableItemCount = this.params.ciphers.length; + this.personalItemsCount = this.params.ciphers.length; + this.editableItemCountChange.emit(this.editableItemCount); + return; + } + + const org = await this.organizationService.get(organizationId); + this.orgName = org.name; + + this.editableItems = org.canEditAllCiphers(v1FCEnabled, restrictProviderAccess) + ? this.params.ciphers + : this.params.ciphers.filter((c) => c.edit); + + this.editableItemCount = this.editableItems.length; + // TODO: https://bitwarden.atlassian.net/browse/PM-9307, + // clean up editableItemCountChange when the org vault is updated to filter editable ciphers + this.editableItemCountChange.emit(this.editableItemCount); + this.personalItemsCount = this.params.ciphers.filter((c) => c.organizationId == null).length; + this.readonlyItemCount = this.totalItemCount - this.editableItemCount; + } + + private setFormValidators() { + const selectedOrgControl = this.formGroup.get("selectedOrg"); + selectedOrgControl?.setValidators([Validators.required]); + selectedOrgControl?.updateValueAndValidity(); + } + + /** + * Sets up form subscriptions for selected organizations. + */ + private setupFormSubscriptions() { + // Listen to changes in selected organization and update collections + this.formGroup.controls.selectedOrg.valueChanges + .pipe( + tap(() => { + this.formGroup.controls.collections.setValue([], { emitEvent: false }); + }), + switchMap((orgId) => { + return this.getCollectionsForOrganization(orgId as OrganizationId); + }), + takeUntil(this.destroy$), + ) + .subscribe((collections) => { + this.availableCollections = collections.map((c) => ({ + icon: "bwi-collection", + id: c.id, + labelName: c.name, + listName: c.name, + })); + }); + } + + /** + * Retrieves the collections for the organization with the given ID. + * @param orgId + * @returns An observable of the collections for the organization. + */ + private getCollectionsForOrganization(orgId: OrganizationId): Observable { + return combineLatest([ + this.collectionService.decryptedCollections$, + this.organizationService.organizations$, + ]).pipe( + map(([collections, organizations]) => { + const org = organizations.find((o) => o.id === orgId); + this.orgName = org.name; + + return collections.filter((c) => { + return c.organizationId === orgId && !c.readOnly; + }); + }), + shareReplay({ refCount: true, bufferSize: 1 }), + ); + } + + private async moveToOrganization( + organizationId: OrganizationId, + shareableCiphers: CipherView[], + selectedCollectionIds: CollectionId[], + ) { + await this.cipherService.shareManyWithServer( + shareableCiphers, + organizationId, + selectedCollectionIds, + ); + + this.toastService.showToast({ + variant: "success", + title: null, + message: this.i18nService.t( + "movedItemsToOrg", + this.orgName ?? this.i18nService.t("organization"), + ), + }); + } + + private async bulkUpdateCollections(cipherIds: CipherId[]) { + if (this.formGroup.controls.collections.value.length > 0) { + await this.cipherService.bulkUpdateCollectionsWithServer( + this.selectedOrgId, + cipherIds, + this.formGroup.controls.collections.value.map((i) => i.id as CollectionId), + false, + ); + } + + if ( + this.params.activeCollection != null && + this.formGroup.controls.collections.value.find( + (c) => c.id === this.params.activeCollection.id, + ) == null + ) { + await this.cipherService.bulkUpdateCollectionsWithServer( + this.selectedOrgId, + cipherIds, + [this.params.activeCollection.id as CollectionId], + true, + ); + } + } + + private async updateAssignedCollections(cipherView: CipherView) { + const { collections } = this.formGroup.getRawValue(); + cipherView.collectionIds = collections.map((i) => i.id as CollectionId); + const cipher = await this.cipherService.encrypt(cipherView); + await this.cipherService.saveCollectionsWithServer(cipher); + } +} diff --git a/libs/vault/src/index.ts b/libs/vault/src/index.ts index e4e17e7aa5..5dee70ea46 100644 --- a/libs/vault/src/index.ts +++ b/libs/vault/src/index.ts @@ -4,3 +4,8 @@ export { CopyCipherFieldDirective } from "./components/copy-cipher-field.directi export * from "./cipher-view"; export * from "./cipher-form"; +export { + AssignCollectionsComponent, + CollectionAssignmentParams, + CollectionAssignmentResult, +} from "./components/assign-collections.component";