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 00000000000..f05262832c7 --- /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 00000000000..dc7740cc240 --- /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 00000000000..0c20f958850 --- /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 af2a8443edf..604dd4acadf 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 baca403f181..bfb30f3f769 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 ae4e8fafabe..3bf87ba4e3c 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 8843bda2f7b..59341a712d5 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 cdf45d0669c..252cdc7ac54 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 780614c3303..fe1a97aff1d 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 520e8077880..00000000000 --- 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 8998629b665..00000000000 --- 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 44042e3267a..00000000000 --- 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 6622882bf86..07d65656d2f 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 dbbae60cf70..f5875601189 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 2ec13ea35e6..da8a4dd4181 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 00000000000..cc3aa3e0aa7 --- /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 fc86f2f5277..68b336a8b06 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 de387480f7e..3f451e38b19 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 00000000000..280acae1daa --- /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 00000000000..5bbe616c639 --- /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 e4e17e7aa5a..5dee70ea46f 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";