1
0
mirror of https://github.com/bitwarden/browser synced 2025-12-23 03:33:54 +00:00

[PM-2383] Bulk collection assignment (#8429)

* [PM-2383] Add bulkUpdateCollectionsWithServer method to CipherService

* [PM-2383] Introduce bulk-collection-assignment-dialog.component

* [PM-2383] Add bulk assign collections option to org vault
This commit is contained in:
Shane Melton
2024-03-22 13:16:29 -07:00
committed by GitHub
parent 905d177873
commit bac0874dc0
13 changed files with 443 additions and 2 deletions

View File

@@ -15,4 +15,5 @@ export type VaultItemEvent =
| { type: "delete"; items: VaultItem[] }
| { type: "copyField"; item: CipherView; field: "username" | "password" | "totp" }
| { type: "moveToFolder"; items: CipherView[] }
| { type: "moveToOrganization"; items: CipherView[] };
| { type: "moveToOrganization"; items: CipherView[] }
| { type: "assignToCollections"; items: CipherView[] };

View File

@@ -46,6 +46,15 @@
<i class="bwi bwi-fw bwi-users" aria-hidden="true"></i>
{{ "access" | i18n }}
</button>
<button
*ngIf="showAdminActions && bulkAssignToCollectionsAllowed"
type="button"
bitMenuItem
(click)="assignToCollections()"
>
<i class="bwi bwi-fw bwi-collection" aria-hidden="true"></i>
{{ "assignToCollections" | i18n }}
</button>
<button
*ngIf="bulkMoveAllowed"
type="button"

View File

@@ -42,6 +42,7 @@ export class VaultItemsComponent {
@Input() allCollections: CollectionView[] = [];
@Input() allGroups: GroupView[] = [];
@Input() showBulkEditCollectionAccess = false;
@Input() showBulkAddToCollections = false;
@Input() showPermissionsColumn = false;
@Input() viewingOrgVault: boolean;
@@ -89,6 +90,10 @@ export class VaultItemsComponent {
);
}
get bulkAssignToCollectionsAllowed() {
return this.ciphers.length > 0;
}
protected canEditCollection(collection: CollectionView): boolean {
// Only allow allow deletion if collection editing is enabled and not deleting "Unassigned"
if (collection.id === Unassigned) {
@@ -182,4 +187,13 @@ export class VaultItemsComponent {
.map((item) => item.collection),
});
}
protected assignToCollections() {
this.event({
type: "assignToCollections",
items: this.selection.selected
.filter((item) => item.cipher !== undefined)
.map((item) => item.cipher),
});
}
}

View File

@@ -0,0 +1,66 @@
<bit-dialog dialogSize="large">
<span bitDialogTitle>
{{ "assignToCollections" | i18n }}
<span class="tw-text-sm tw-normal-case tw-text-muted">
{{ pluralize(editableItemCount, "item", "items") }}
</span>
</span>
<div bitDialogContent>
<p>{{ "bulkCollectionAssignmentDialogDescription" | i18n }}</p>
<p *ngIf="readonlyItemCount > 0">
{{ "bulkCollectionAssignmentWarning" | i18n: totalItemCount : readonlyItemCount }}
</p>
<div class="tw-flex">
<bit-form-field class="tw-grow">
<bit-label>{{ "selectCollectionsToAssign" | i18n }}</bit-label>
<bit-multi-select
class="tw-w-full"
[baseItems]="availableCollections"
[removeSelectedItems]="true"
(onItemsConfirmed)="selectCollections($event)"
></bit-multi-select>
</bit-form-field>
</div>
<bit-table>
<ng-container header>
<td bitCell>{{ "assignToTheseCollections" | i18n }}</td>
<td bitCell class="tw-w-20"></td>
</ng-container>
<ng-template body>
<tr bitRow *ngFor="let item of selectedCollections; let i = index">
<td bitCell>
<i class="bwi bwi-collection" aria-hidden="true"></i>
{{ item.labelName }}
</td>
<td bitCell class="tw-text-right">
<button
type="button"
bitIconButton="bwi-close"
buttonType="muted"
appA11yTitle="{{ 'remove' | i18n }} {{ item.labelName }}"
(click)="unselectCollection(i)"
></button>
</td>
</tr>
<tr *ngIf="selectedCollections.length == 0">
<td bitCell>
{{ "noCollectionsAssigned" | i18n }}
</td>
</tr>
</ng-template>
</bit-table>
</div>
<ng-container bitDialogFooter>
<button type="submit" bitButton buttonType="primary" [bitAction]="submit" [disabled]="!isValid">
{{ "assign" | i18n }}
</button>
<button type="button" bitButton buttonType="secondary" bitDialogClose>
{{ "cancel" | i18n }}
</button>
</ng-container>
</bit-dialog>

View File

@@ -0,0 +1,191 @@
import { DIALOG_DATA, DialogConfig, DialogRef } from "@angular/cdk/dialog";
import { Component, Inject, OnDestroy, OnInit } from "@angular/core";
import { Subject } from "rxjs";
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { ConfigServiceAbstraction } from "@bitwarden/common/platform/abstractions/config/config.service.abstraction";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { CipherId, CollectionId, OrganizationId } from "@bitwarden/common/types/guid";
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
import { CollectionView } from "@bitwarden/common/vault/models/view/collection.view";
import { DialogService, SelectItemView } from "@bitwarden/components";
import { SharedModule } from "../../../shared";
export interface BulkCollectionAssignmentDialogParams {
organizationId: OrganizationId;
/**
* The ciphers to be assigned to the collections selected in the dialog.
*/
ciphers: CipherView[];
/**
* The collections available to assign the ciphers to.
*/
availableCollections: CollectionView[];
/**
* The currently filtered collection. Selected by default. If the user deselects it in the dialog then it will be
* removed from the ciphers upon submission.
*/
activeCollection?: CollectionView;
}
export enum BulkCollectionAssignmentDialogResult {
Saved = "saved",
Canceled = "canceled",
}
@Component({
imports: [SharedModule],
selector: "app-bulk-collection-assignment-dialog",
templateUrl: "./bulk-collection-assignment-dialog.component.html",
standalone: true,
})
export class BulkCollectionAssignmentDialogComponent implements OnDestroy, OnInit {
protected totalItemCount: number;
protected editableItemCount: number;
protected readonlyItemCount: number;
protected availableCollections: SelectItemView[] = [];
protected selectedCollections: SelectItemView[] = [];
private editableItems: CipherView[] = [];
private destroy$ = new Subject<void>();
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<BulkCollectionAssignmentDialogResult>,
private cipherService: CipherService,
private i18nService: I18nService,
private platformUtilsService: PlatformUtilsService,
private configService: ConfigServiceAbstraction,
private organizationService: OrganizationService,
) {}
async ngOnInit() {
const v1FCEnabled = await this.configService.getFeatureFlag(
FeatureFlag.FlexibleCollectionsV1,
false,
);
const org = await this.organizationService.get(this.params.organizationId);
if (org.canEditAllCiphers(v1FCEnabled)) {
this.editableItems = this.params.ciphers;
} else {
this.editableItems = this.params.ciphers.filter((c) => c.edit);
}
this.editableItemCount = this.editableItems.length;
// If no ciphers are editable, close the dialog
if (this.editableItemCount == 0) {
this.platformUtilsService.showToast(
"error",
this.i18nService.t("errorOccurred"),
this.i18nService.t("nothingSelected"),
);
this.dialogRef.close(BulkCollectionAssignmentDialogResult.Canceled);
}
this.totalItemCount = this.params.ciphers.length;
this.readonlyItemCount = this.totalItemCount - this.editableItemCount;
this.availableCollections = this.params.availableCollections.map((c) => ({
icon: "bwi-collection",
id: c.id,
labelName: c.name,
listName: c.name,
}));
// If the active collection is set, select it by default
if (this.params.activeCollection) {
this.selectCollections([
{
icon: "bwi-collection",
id: this.params.activeCollection.id,
labelName: this.params.activeCollection.name,
listName: this.params.activeCollection.name,
},
]);
}
}
private sortItems = (a: SelectItemView, b: SelectItemView) =>
this.i18nService.collator.compare(a.labelName, b.labelName);
selectCollections(items: SelectItemView[]) {
this.selectedCollections = [...this.selectedCollections, ...items].sort(this.sortItems);
this.availableCollections = this.availableCollections.filter(
(item) => !items.find((i) => i.id === item.id),
);
}
unselectCollection(i: number) {
const removed = this.selectedCollections.splice(i, 1);
this.availableCollections = [...this.availableCollections, ...removed].sort(this.sortItems);
}
get isValid() {
return this.params.activeCollection != null || this.selectedCollections.length > 0;
}
submit = async () => {
if (!this.isValid) {
return;
}
const cipherIds = this.editableItems.map((i) => i.id as CipherId);
if (this.selectedCollections.length > 0) {
await this.cipherService.bulkUpdateCollectionsWithServer(
this.params.organizationId,
cipherIds,
this.selectedCollections.map((i) => i.id as CollectionId),
false,
);
}
if (
this.params.activeCollection != null &&
this.selectedCollections.find((c) => c.id === this.params.activeCollection.id) == null
) {
await this.cipherService.bulkUpdateCollectionsWithServer(
this.params.organizationId,
cipherIds,
[this.params.activeCollection.id as CollectionId],
true,
);
}
this.platformUtilsService.showToast(
"success",
null,
this.i18nService.t("successfullyAssignedCollections"),
);
this.dialogRef.close(BulkCollectionAssignmentDialogResult.Saved);
};
ngOnDestroy(): void {
this.destroy$.next();
this.destroy$.complete();
}
static open(
dialogService: DialogService,
config: DialogConfig<BulkCollectionAssignmentDialogParams>,
) {
return dialogService.open<
BulkCollectionAssignmentDialogResult,
BulkCollectionAssignmentDialogParams
>(BulkCollectionAssignmentDialogComponent, config);
}
}

View File

@@ -0,0 +1 @@
export * from "./bulk-collection-assignment-dialog.component";

View File

@@ -54,6 +54,7 @@
[showBulkEditCollectionAccess]="
(showBulkEditCollectionAccess$ | async) && organization?.flexibleCollections
"
[showBulkAddToCollections]="organization?.flexibleCollections"
[viewingOrgVault]="true"
>
</app-vault-items>

View File

@@ -46,6 +46,7 @@ import { LogService } from "@bitwarden/common/platform/abstractions/log.service"
import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { Utils } from "@bitwarden/common/platform/misc/utils";
import { OrganizationId } from "@bitwarden/common/types/guid";
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
import { CollectionService } from "@bitwarden/common/vault/abstractions/collection.service";
import { SyncService } from "@bitwarden/common/vault/abstractions/sync/sync.service.abstraction";
@@ -86,6 +87,10 @@ import { getNestedCollectionTree } from "../utils/collection-utils";
import { AddEditComponent } from "./add-edit.component";
import { AttachmentsComponent } from "./attachments.component";
import {
BulkCollectionAssignmentDialogComponent,
BulkCollectionAssignmentDialogResult,
} from "./bulk-collection-assignment-dialog";
import {
BulkCollectionsDialogComponent,
BulkCollectionsDialogResult,
@@ -631,6 +636,8 @@ export class VaultComponent implements OnInit, OnDestroy {
await this.editCollection(event.item, CollectionDialogTabType.Access);
} else if (event.type === "bulkEditCollectionAccess") {
await this.bulkEditCollectionAccess(event.items);
} else if (event.type === "assignToCollections") {
await this.bulkAssignToCollections(event.items);
} else if (event.type === "viewEvents") {
await this.viewEvents(event.item);
}
@@ -1092,6 +1099,41 @@ export class VaultComponent implements OnInit, OnDestroy {
}
}
async bulkAssignToCollections(items: CipherView[]) {
if (items.length === 0) {
this.platformUtilsService.showToast(
"error",
this.i18nService.t("errorOccurred"),
this.i18nService.t("nothingSelected"),
);
return;
}
let availableCollections: CollectionView[];
if (this.flexibleCollectionsV1Enabled) {
availableCollections = await firstValueFrom(this.editableCollections$);
} else {
availableCollections = (
await firstValueFrom(this.vaultFilterService.filteredCollections$)
).filter((c) => c.id != Unassigned);
}
const dialog = BulkCollectionAssignmentDialogComponent.open(this.dialogService, {
data: {
ciphers: items,
organizationId: this.organization?.id as OrganizationId,
availableCollections,
activeCollection: this.activeFilter?.selectedCollectionNode?.node,
},
});
const result = await lastValueFrom(dialog.closed);
if (result === BulkCollectionAssignmentDialogResult.Saved) {
this.refresh();
}
}
async viewEvents(cipher: CipherView) {
await openEntityEventsDialog(this.dialogService, {
data: {