[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
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
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: {

View File

@ -7605,5 +7605,42 @@
},
"restrictedCollectionAccess": {
"message": "You cannot add yourself to collections."
},
"assign": {
"message": "Assign"
},
"assignToCollections": {
"message": "Assign to collections"
},
"assignToTheseCollections": {
"message": "Assign to these collections"
},
"bulkCollectionAssignmentDialogDescription": {
"message": "Select the collections that the items will be shared with. Once an item is updated in one collection, it will be reflected in all collections. Only organization members with access to these collections will be able to see the items."
},
"selectCollectionsToAssign": {
"message": "Select collections to assign"
},
"noCollectionsAssigned": {
"message": "No collections have been assigned"
},
"successfullyAssignedCollections": {
"message": "Successfully assigned collections"
},
"bulkCollectionAssignmentWarning": {
"message": "You have selected $TOTAL_COUNT$ items. You cannot update $READONLY_COUNT$ of the items because you do not have edit permissions.",
"placeholders": {
"total_count": {
"content": "$1",
"example": "10"
},
"readonly_count": {
"content": "$2",
"example": "3"
}
}
},
"items": {
"message": "Items"
}
}

View File

@ -7,3 +7,4 @@ export type OrganizationId = Opaque<string, "OrganizationId">;
export type CollectionId = Opaque<string, "CollectionId">;
export type ProviderId = Opaque<string, "ProviderId">;
export type PolicyId = Opaque<string, "PolicyId">;
export type CipherId = Opaque<string, "CipherId">;

View File

@ -1,5 +1,6 @@
import { UriMatchStrategySetting } from "../../models/domain/domain-service";
import { SymmetricCryptoKey } from "../../platform/models/domain/symmetric-crypto-key";
import { CipherId, CollectionId, OrganizationId } from "../../types/guid";
import { CipherType } from "../enums/cipher-type";
import { CipherData } from "../models/data/cipher.data";
import { Cipher } from "../models/domain/cipher";
@ -63,6 +64,19 @@ export abstract class CipherService {
admin?: boolean,
) => Promise<Cipher>;
saveCollectionsWithServer: (cipher: Cipher) => Promise<any>;
/**
* Bulk update collections for many ciphers with the server
* @param orgId
* @param cipherIds
* @param collectionIds
* @param removeCollections - If true, the collections will be removed from the ciphers, otherwise they will be added
*/
bulkUpdateCollectionsWithServer: (
orgId: OrganizationId,
cipherIds: CipherId[],
collectionIds: CollectionId[],
removeCollections: boolean,
) => Promise<void>;
upsert: (cipher: CipherData | CipherData[]) => Promise<any>;
replace: (ciphers: { [id: string]: CipherData }) => Promise<any>;
clear: (userId: string) => Promise<any>;

View File

@ -0,0 +1,19 @@
import { CipherId, CollectionId, OrganizationId } from "../../../types/guid";
export class CipherBulkUpdateCollectionsRequest {
organizationId: OrganizationId;
cipherIds: CipherId[];
collectionIds: CollectionId[];
removeCollections: boolean;
constructor(
organizationId: OrganizationId,
cipherIds: CipherId[],
collectionIds: CollectionId[],
removeCollections: boolean = false,
) {
this.organizationId = organizationId;
this.cipherIds = cipherIds;
this.collectionIds = collectionIds;
this.removeCollections = removeCollections;
}
}

View File

@ -21,7 +21,8 @@ import Domain from "../../platform/models/domain/domain-base";
import { EncArrayBuffer } from "../../platform/models/domain/enc-array-buffer";
import { EncString } from "../../platform/models/domain/enc-string";
import { SymmetricCryptoKey } from "../../platform/models/domain/symmetric-crypto-key";
import { UserKey, OrgKey } from "../../types/key";
import { CipherId, CollectionId, OrganizationId } from "../../types/guid";
import { OrgKey, UserKey } from "../../types/key";
import { CipherService as CipherServiceAbstraction } from "../abstractions/cipher.service";
import { CipherFileUploadService } from "../abstractions/file-upload/cipher-file-upload.service";
import { FieldType } from "../enums";
@ -42,6 +43,7 @@ import { CipherBulkDeleteRequest } from "../models/request/cipher-bulk-delete.re
import { CipherBulkMoveRequest } from "../models/request/cipher-bulk-move.request";
import { CipherBulkRestoreRequest } from "../models/request/cipher-bulk-restore.request";
import { CipherBulkShareRequest } from "../models/request/cipher-bulk-share.request";
import { CipherBulkUpdateCollectionsRequest } from "../models/request/cipher-bulk-update-collections.request";
import { CipherCollectionsRequest } from "../models/request/cipher-collections.request";
import { CipherCreateRequest } from "../models/request/cipher-create.request";
import { CipherPartialRequest } from "../models/request/cipher-partial.request";
@ -685,6 +687,49 @@ export class CipherService implements CipherServiceAbstraction {
await this.upsert(data);
}
/**
* Bulk update collections for many ciphers with the server
* @param orgId
* @param cipherIds
* @param collectionIds
* @param removeCollections - If true, the collectionIds will be removed from the ciphers, otherwise they will be added
*/
async bulkUpdateCollectionsWithServer(
orgId: OrganizationId,
cipherIds: CipherId[],
collectionIds: CollectionId[],
removeCollections: boolean = false,
): Promise<void> {
const request = new CipherBulkUpdateCollectionsRequest(
orgId,
cipherIds,
collectionIds,
removeCollections,
);
await this.apiService.send("POST", "/ciphers/bulk-collections", request, true, false);
// Update the local state
const ciphers = await this.stateService.getEncryptedCiphers();
for (const id of cipherIds) {
const cipher = ciphers[id];
if (cipher) {
if (removeCollections) {
cipher.collectionIds = cipher.collectionIds?.filter(
(cid) => !collectionIds.includes(cid as CollectionId),
);
} else {
// Append to the collectionIds if it's not already there
cipher.collectionIds = [...new Set([...(cipher.collectionIds ?? []), ...collectionIds])];
}
}
}
await this.clearCache();
await this.stateService.setEncryptedCiphers(ciphers);
}
async upsert(cipher: CipherData | CipherData[]): Promise<any> {
let ciphers = await this.stateService.getEncryptedCiphers();
if (ciphers == null) {