1
0
mirror of https://github.com/bitwarden/browser synced 2025-12-12 06:13:38 +00:00

[AC-1174] Bulk collection management (#6133)

* [AC-1174] Add bulk edit collection access event type

* [AC-1174] Add bulk edit collection access menu option

* [AC-1174] Add initial bulk collections access dialog

* [AC-1174] Add logic to open bulk edit collections dialog

* [AC-1174] Move AccessItemView helper methods to access selector model to be shared

* [AC-1174] Add access selector to bulk collections dialog

* [AC-1174] Add bulk assign access method to collection-admin service

* [AC-1174] Introduce strongly typed BulkCollectionAccessRequest model

* [AC-1174] Update vault item event type name

* Update DialogService dependency

---------

Co-authored-by: Thomas Rittson <trittson@bitwarden.com>
This commit is contained in:
Shane Melton
2023-09-26 09:29:34 -07:00
committed by GitHub
parent 07b4204772
commit f4ba92b63c
12 changed files with 292 additions and 31 deletions

View File

@@ -1,10 +1,11 @@
import { OrganizationUserUserDetailsResponse } from "@bitwarden/common/abstractions/organization-user/responses";
import {
OrganizationUserStatusType,
OrganizationUserType,
} from "@bitwarden/common/admin-console/enums";
import { SelectItemView } from "@bitwarden/components";
import { CollectionAccessSelectionView } from "../../../core";
import { CollectionAccessSelectionView, GroupView } from "../../../core";
/**
* Permission options that replace/correspond with manage, readOnly, and hidePassword server fields.
@@ -111,3 +112,29 @@ const readOnly = (perm: CollectionPermission) =>
const hidePassword = (perm: CollectionPermission) =>
[CollectionPermission.ViewExceptPass, CollectionPermission.EditExceptPass].includes(perm);
export function mapGroupToAccessItemView(group: GroupView): AccessItemView {
return {
id: group.id,
type: AccessItemType.Group,
listName: group.name,
labelName: group.name,
accessAllItems: group.accessAll,
readonly: group.accessAll,
};
}
// TODO: Use view when user apis are migrated to a service
export function mapUserToAccessItemView(user: OrganizationUserUserDetailsResponse): AccessItemView {
return {
id: user.id,
type: AccessItemType.Member,
email: user.email,
role: user.type,
listName: user.name?.length > 0 ? `${user.name} (${user.email})` : user.email,
labelName: user.name ?? user.email,
status: user.status,
accessAllItems: user.accessAll,
readonly: user.accessAll,
};
}

View File

@@ -13,7 +13,6 @@ import {
} from "rxjs";
import { OrganizationUserService } from "@bitwarden/common/abstractions/organization-user/organization-user.service";
import { OrganizationUserUserDetailsResponse } from "@bitwarden/common/abstractions/organization-user/responses";
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
@@ -26,11 +25,13 @@ import { DialogService, BitValidators } from "@bitwarden/components";
import { GroupService, GroupView } from "../../../admin-console/organizations/core";
import { PermissionMode } from "../../../admin-console/organizations/shared/components/access-selector/access-selector.component";
import {
AccessItemView,
AccessItemValue,
AccessItemType,
convertToSelectionView,
AccessItemValue,
AccessItemView,
convertToPermission,
convertToSelectionView,
mapGroupToAccessItemView,
mapUserToAccessItemView,
} from "../../../admin-console/organizations/shared/components/access-selector/access-selector.models";
import { CollectionAdminService } from "../../core/collection-admin.service";
import { CollectionAdminView } from "../../core/views/collection-admin.view";
@@ -284,32 +285,6 @@ function parseName(collection: CollectionView) {
return { name, parent };
}
function mapGroupToAccessItemView(group: GroupView): AccessItemView {
return {
id: group.id,
type: AccessItemType.Group,
listName: group.name,
labelName: group.name,
accessAllItems: group.accessAll,
readonly: group.accessAll,
};
}
// TODO: Use view when user apis are migrated to a service
function mapUserToAccessItemView(user: OrganizationUserUserDetailsResponse): AccessItemView {
return {
id: user.id,
type: AccessItemType.Member,
email: user.email,
role: user.type,
listName: user.name?.length > 0 ? `${user.name} (${user.email})` : user.email,
labelName: user.name ?? user.email,
status: user.status,
accessAllItems: user.accessAll,
readonly: user.accessAll,
};
}
function mapToAccessSelections(collectionDetails: CollectionAdminView): AccessItemValue[] {
if (collectionDetails == undefined) {
return [];

View File

@@ -7,6 +7,7 @@ export type VaultItemEvent =
| { type: "viewAttachments"; item: CipherView }
| { type: "viewCollections"; item: CipherView }
| { type: "viewAccess"; item: CollectionView }
| { type: "bulkEditCollectionAccess"; items: CollectionView[] }
| { type: "viewEvents"; item: CipherView }
| { type: "edit"; item: CollectionView }
| { type: "clone"; item: CipherView }

View File

@@ -34,6 +34,15 @@
<i class="bwi bwi-fw bwi-folder" aria-hidden="true"></i>
{{ "moveSelected" | i18n }}
</button>
<button
*ngIf="editableCollections"
type="button"
bitMenuItem
(click)="bulkEditCollectionAccess()"
>
<i class="bwi bwi-fw bwi-users" aria-hidden="true"></i>
{{ "access" | i18n }}
</button>
<button
*ngIf="showBulkMove"
type="button"

View File

@@ -177,4 +177,13 @@ export class VaultItemsComponent {
);
this.dataSource.data = items;
}
protected bulkEditCollectionAccess() {
this.event({
type: "bulkEditCollectionAccess",
items: this.selection.selected
.filter((item) => item.collection !== undefined)
.map((item) => item.collection),
});
}
}

View File

@@ -0,0 +1,7 @@
import { SelectionReadOnlyRequest } from "@bitwarden/common/admin-console/models/request/selection-read-only.request";
export class BulkCollectionAccessRequest {
collectionIds: string[];
users: SelectionReadOnlyRequest[];
groups: SelectionReadOnlyRequest[];
}

View File

@@ -10,6 +10,9 @@ import {
CollectionResponse,
} from "@bitwarden/common/vault/models/response/collection.response";
import { CollectionAccessSelectionView } from "../../admin-console/organizations/core";
import { BulkCollectionAccessRequest } from "./bulk-collection-access.request";
import { CollectionAdminView } from "./views/collection-admin.view";
@Injectable()
@@ -68,6 +71,30 @@ export class CollectionAdminService {
await this.apiService.deleteCollection(organizationId, collectionId);
}
async bulkAssignAccess(
organizationId: string,
collectionIds: string[],
users: CollectionAccessSelectionView[],
groups: CollectionAccessSelectionView[]
): Promise<void> {
const request = new BulkCollectionAccessRequest();
request.collectionIds = collectionIds;
request.users = users.map(
(u) => new SelectionReadOnlyRequest(u.id, u.readOnly, u.hidePasswords, u.manage)
);
request.groups = groups.map(
(g) => new SelectionReadOnlyRequest(g.id, g.readOnly, g.hidePasswords, g.manage)
);
await this.apiService.send(
"POST",
`organizations/${organizationId}/collections/bulk-access`,
request,
true,
false
);
}
private async decryptMany(
organizationId: string,
collections: CollectionResponse[] | CollectionAccessDetailsResponse[]

View File

@@ -0,0 +1,41 @@
<form [formGroup]="formGroup" [bitSubmit]="submit">
<bit-dialog [loading]="loading" dialogSize="large">
<span bitDialogTitle>
{{ "assignCollectionAccess" | i18n }}
<span class="tw-text-sm tw-normal-case tw-text-muted">
{{ numCollections }} {{ (numCollections == 1 ? "collection" : "collections") | i18n }}
</span>
</span>
<div bitDialogContent>
<bit-access-selector
*ngIf="organization?.useGroups"
[permissionMode]="PermissionMode.Edit"
formControlName="access"
[items]="accessItems"
[columnHeader]="'groupAndMemberColumnHeader' | i18n"
[selectorLabelText]="'selectGroupsAndMembers' | i18n"
[selectorHelpText]="'userPermissionOverrideHelper' | i18n"
[emptySelectionText]="'noMembersOrGroupsAdded' | i18n"
></bit-access-selector>
<bit-access-selector
*ngIf="!organization?.useGroups"
[permissionMode]="PermissionMode.Edit"
formControlName="access"
[items]="accessItems"
[columnHeader]="'memberColumnHeader' | i18n"
[selectorLabelText]="'selectMembers' | i18n"
[emptySelectionText]="'noMembersAdded' | i18n"
></bit-access-selector>
</div>
<ng-container bitDialogFooter>
<button type="submit" bitButton bitFormButton buttonType="primary">
{{ "save" | i18n }}
</button>
<button type="button" bitButton bitFormButton buttonType="secondary" bitDialogClose>
{{ "cancel" | i18n }}
</button>
</ng-container>
</bit-dialog>
</form>

View File

@@ -0,0 +1,129 @@
import { DIALOG_DATA, DialogConfig, DialogRef } from "@angular/cdk/dialog";
import { Component, Inject, OnDestroy } from "@angular/core";
import { FormBuilder } from "@angular/forms";
import { combineLatest, of, Subject, switchMap, takeUntil } from "rxjs";
import { OrganizationUserService } from "@bitwarden/common/abstractions/organization-user/organization-user.service";
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { CollectionView } from "@bitwarden/common/vault/models/view/collection.view";
import { DialogService } from "@bitwarden/components";
import { GroupService, GroupView } from "../../../admin-console/organizations/core";
import {
AccessItemType,
AccessItemValue,
AccessItemView,
AccessSelectorModule,
convertToSelectionView,
mapGroupToAccessItemView,
mapUserToAccessItemView,
PermissionMode,
} from "../../../admin-console/organizations/shared/components/access-selector";
import { SharedModule } from "../../../shared";
import { CollectionAdminService } from "../../core/collection-admin.service";
export interface BulkCollectionsDialogParams {
organizationId: string;
collections: CollectionView[];
}
export enum BulkCollectionsDialogResult {
Saved = "saved",
Canceled = "canceled",
}
@Component({
imports: [SharedModule, AccessSelectorModule],
selector: "app-bulk-collections-dialog",
templateUrl: "bulk-collections-dialog.component.html",
standalone: true,
})
export class BulkCollectionsDialogComponent implements OnDestroy {
protected readonly PermissionMode = PermissionMode;
protected formGroup = this.formBuilder.group({
access: [[] as AccessItemValue[]],
});
protected loading = true;
protected organization: Organization;
protected accessItems: AccessItemView[] = [];
protected numCollections: number;
private destroy$ = new Subject<void>();
constructor(
@Inject(DIALOG_DATA) private params: BulkCollectionsDialogParams,
private dialogRef: DialogRef<BulkCollectionsDialogResult>,
private formBuilder: FormBuilder,
private organizationService: OrganizationService,
private groupService: GroupService,
private organizationUserService: OrganizationUserService,
private platformUtilsService: PlatformUtilsService,
private i18nService: I18nService,
private collectionAdminService: CollectionAdminService
) {
this.numCollections = this.params.collections.length;
const organization$ = this.organizationService.get$(this.params.organizationId);
const groups$ = organization$.pipe(
switchMap((organization) => {
if (!organization.useGroups) {
return of([] as GroupView[]);
}
return this.groupService.getAll(organization.id);
})
);
combineLatest([
organization$,
groups$,
this.organizationUserService.getAllUsers(this.params.organizationId),
])
.pipe(takeUntil(this.destroy$))
.subscribe(([organization, groups, users]) => {
this.organization = organization;
this.accessItems = [].concat(
groups.map(mapGroupToAccessItemView),
users.data.map(mapUserToAccessItemView)
);
this.loading = false;
});
}
ngOnDestroy() {
this.destroy$.next();
this.destroy$.complete();
}
submit = async () => {
const users = this.formGroup.controls.access.value
.filter((v) => v.type === AccessItemType.Member)
.map(convertToSelectionView);
const groups = this.formGroup.controls.access.value
.filter((v) => v.type === AccessItemType.Group)
.map(convertToSelectionView);
await this.collectionAdminService.bulkAssignAccess(
this.organization.id,
this.params.collections.map((c) => c.id),
users,
groups
);
this.platformUtilsService.showToast("success", null, this.i18nService.t("editedCollections"));
this.dialogRef.close(BulkCollectionsDialogResult.Saved);
};
static open(dialogService: DialogService, config: DialogConfig<BulkCollectionsDialogParams>) {
return dialogService.open<BulkCollectionsDialogResult, BulkCollectionsDialogParams>(
BulkCollectionsDialogComponent,
config
);
}
}

View File

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

View File

@@ -82,6 +82,10 @@ import { getNestedCollectionTree } from "../utils/collection-utils";
import { AddEditComponent } from "./add-edit.component";
import { AttachmentsComponent } from "./attachments.component";
import {
BulkCollectionsDialogComponent,
BulkCollectionsDialogResult,
} from "./bulk-collections-dialog";
import { CollectionsComponent } from "./collections.component";
import { VaultFilterComponent } from "./vault-filter/vault-filter.component";
@@ -499,6 +503,8 @@ export class VaultComponent implements OnInit, OnDestroy {
await this.editCollection(event.item, CollectionDialogTabType.Info);
} else if (event.type === "viewAccess") {
await this.editCollection(event.item, CollectionDialogTabType.Access);
} else if (event.type === "bulkEditCollectionAccess") {
await this.bulkEditCollectionAccess(event.items);
} else if (event.type === "viewEvents") {
await this.viewEvents(event.item);
}
@@ -878,6 +884,29 @@ export class VaultComponent implements OnInit, OnDestroy {
}
}
async bulkEditCollectionAccess(collections: CollectionView[]): Promise<void> {
if (collections.length === 0) {
this.platformUtilsService.showToast(
"error",
this.i18nService.t("errorOccurred"),
this.i18nService.t("nothingSelected")
);
return;
}
const dialog = BulkCollectionsDialogComponent.open(this.dialogService, {
data: {
collections,
organizationId: this.organization?.id,
},
});
const result = await lastValueFrom(dialog.closed);
if (result === BulkCollectionsDialogResult.Saved) {
this.refresh();
}
}
async viewEvents(cipher: CipherView) {
await openEntityEventsDialog(this.dialogService, {
data: {

View File

@@ -7184,6 +7184,12 @@
"beta": {
"message": "Beta"
},
"assignCollectionAccess": {
"message": "Assign collection access"
},
"editedCollections": {
"message": "Edited collections"
},
"alreadyHaveAccount": {
"message": "Already have an account?"
}