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:
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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 [];
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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[];
|
||||
}
|
||||
@@ -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[]
|
||||
|
||||
@@ -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>
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./bulk-collections-dialog.component";
|
||||
@@ -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: {
|
||||
|
||||
@@ -7184,6 +7184,12 @@
|
||||
"beta": {
|
||||
"message": "Beta"
|
||||
},
|
||||
"assignCollectionAccess": {
|
||||
"message": "Assign collection access"
|
||||
},
|
||||
"editedCollections": {
|
||||
"message": "Edited collections"
|
||||
},
|
||||
"alreadyHaveAccount": {
|
||||
"message": "Already have an account?"
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user