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:
@@ -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[] };
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./bulk-collection-assignment-dialog.component";
|
||||
@@ -54,6 +54,7 @@
|
||||
[showBulkEditCollectionAccess]="
|
||||
(showBulkEditCollectionAccess$ | async) && organization?.flexibleCollections
|
||||
"
|
||||
[showBulkAddToCollections]="organization?.flexibleCollections"
|
||||
[viewingOrgVault]="true"
|
||||
>
|
||||
</app-vault-items>
|
||||
|
||||
@@ -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: {
|
||||
|
||||
Reference in New Issue
Block a user