mirror of
https://github.com/bitwarden/browser
synced 2025-12-20 10:13:31 +00:00
[EC-16] Implement new Groups Tab (#3563)
* [EC-16] Cleanup RxJS linting problems * [EC-16] Update Group tab to use table component and show collections. * [EC-16] Extract interface from GroupResponse and use it in the view * [EC-16] Remove heading underline * [EC-16] Cleanup i18n * [EC-16] More i18n cleanup * [EC-16] Fix bulk group request type name * [EC-16] Rename group details type * [EC-86] Clear collectionMap before populating it with new collections * [EC-86] Update initialization/loading logic to make better use of the Observable pattern * [EC-86] Make table cells use a pointer cursor * [EC-86] Use bitIconButton for row menu triggers * [EC-86] Refactor GroupDetailsRow interface to wrap GroupDetailsResponse. Remove response model interfaces. Cleanup GroupsComponent. * [EC-86] Add bit-badge-list component and tweak BadgeModule to support both the component and directive. Update mockI18nService to support templated strings. * [EC-86] Cleanup badge color and bitIconButton classes * [EC-86] Cleanup more styles * [EC-86] Add GroupApiService Add a new GroupApiService to replace Group Api calls in the ApiService. * [EC-86] Revisions for badge-list implementation. - Remove `| null` for maxItems according to ADR-0014 - Remove custom setter for items - Use ngOnChanges to update filteredItems - Fix sr-only tailwind class and show screen reader comma after last item if truncated. * [EC-86] Refactor badge-list module/component - Move the badge list component to its own module. - Extract badge list stories from badge stories. - Cleanup bade stories and module after refactor. * [EC-86] Refactor/rename GroupApiService - Re-name GroupApiService to GroupService as there is no need for a separate Api service (no sync or local data for admin services) - Add GroupView for use in the GroupService instead of raw API models - Update views to use GroupView instead of raw GroupResponse models * [EC-86] Refactor group API request models - Move organizationGroupBulkRequest to group requests folder - Fix relative imports in GroupService * [EC-86] Fix linting errors * Fix tab item text color Tab item text color broke after a merge from master and needs a fix to account for bootstrap styles in Web. * [EC-86] Rename new files using kebab-case * [EC-86] Fix group view file name * [EC-86] Fix group request/response file names * [EC-86] Cleanup badge stories per review suggestions * [EC-86] Use inline-flex for badge list container * [EC-86] Move GroupService and Views to Web org module - Move GroupService and GroupServiceAbstraction to Organization Module - Add GroupService provider to Organization Module - Move collection-add-edit.component, user-groups.component, group-add-edit.component, and groups.component into Organization Module as they now depend on GroupService - Remove moved components from Loose Component module * [EC-86] Fix Group table search Adds the id and name properties to GroupDetailsRow to support using the searchPipe (which cannot access nested values such as details.name for filtering). * [EC-86] Fix badge story controls * [EC-87] Edit Group Dialog (#3651) * [EC-87] Update the edit dialog to use content tabs * [EC-87] WIP FormListSelection abstract controller * [EC-87] WIP FormListSelection for members and collections * [EC-87] More WIP on FormListSelection * [EC-87] WIP Working FormSelectionList with initial value support * [EC-87] WIP SelectionList without FormControls and with i18n support for sorting * [EC-87] Final sorted SelectionList with FormArray support * [EC-87] Extract and document FormSelectionList * [EC-87] Functional edit group modal * [EC-87] Remove button icon padding for bitButton directives * [EC-87] Use new disablePadding attribute for Dialog component * [EC-87] Some more cleanup and finetuning * [EC-87] Move enum declaration to top * [EC-87] Remove inline style from access selector * [EC-87] Move Group components into Organization Module * [EC-87] Add MultiSelectModule to Shared Web module * [EC-87] Integrate AccessSelector component in GroupAddEdit modal - Remove duplicate permission / selection readonly helpers from GroupAddEdit component - Use access item views/values for collection and member lists - Replace access selector HTMl with the AccessSelector component * [EC-87] Update Group collections column to open Collection tab * [EC-87] Remove old FormSelectionList file * [EC-87] Fix missed file import changes after merge * [EC-87] Remove GroupAddEditComponent modal service registration Groups component is now using the DialogService which does not require explicit registration for lazy loaded components. * [EC-87] Use injected DIALOG_DATA for GroupAddEdit component - Add types for the GroupAddEdit dialog params, result, and tab indices - Add strongly typed helper method to open GroupAddEdit dialogs - Remove @Input()/@Output() properties. Replaced with the injected DIALOG_DATA params instead - Use dialogRef.close() and result type instead of event emitters * [EC-87] Rename collection tab type to collections * [EC-87] Refactor postGroup() and putGroup() from ApiService - Move postGroup() and putGroup() methods to GroupService - Remove postGroup() and putGroup() from ApiService - Move GroupResponse and GroupRequest into Web (from lib/common) * [EC-87] Remove required attribute * [EC-87] Use PascalCase for template Enums * [EC-87] Use group modal tab enum in template * [EC-87] Convert dialog result to promise * [EC-87] Refactor dialog positionStrategy - Add .top() to position strategy to allow clicking the backdrop to close the dialog - Move the positionStrategy option into the openGroupAddEditDialog helper * [EC-87] Remove [preserveContent] from tab group * [EC-87] Use new CL async actions - Update handlers to be arrow-functions - Remove old form and delete promises - Use [bitSubmit] directive on form - Use bitFormButton directive and [bitAction] for submit and delete buttons - Remove delete/spinner bwi icons as they are handled by the new async directives * [EC-87] Introduce CollectionAccessSelectionView Use a new view to replace the SelectionReadonlyResponse/Request classes. * [EC-87] Use new access selection view in GroupView - Change the collections type - Add members list to make the view more complete - Update the static fromResponse helper to properly map the GroupDetailsResponse to the new access selection view - Update access selector helpers to use new access selection view instead of response/request models * [EC-87] Update GroupService to have a single save() method that accepts a GroupView - Add save() method that checks for existing group id to determine which API method to use - Make post/put group methods private * [EC-87] Utilize the new save() method in the group modal * [EC-87] Use observables for fetching data - Introduce 3 observables for collections, members, and group details - Combine and subscribe to those observables in ngOnInit - Add destroy$ subject - Inject changeDetectorRef to handle quirk of patching the AccessSelector value before available items are set
This commit is contained in:
@@ -1,4 +1,8 @@
|
||||
import { Component, EventEmitter, Input, OnInit, Output } from "@angular/core";
|
||||
import { DIALOG_DATA, DialogConfig, DialogRef } from "@angular/cdk/dialog";
|
||||
import { Overlay } from "@angular/cdk/overlay";
|
||||
import { ChangeDetectorRef, Component, Inject, OnDestroy, OnInit } from "@angular/core";
|
||||
import { FormBuilder, FormControl, Validators } from "@angular/forms";
|
||||
import { catchError, combineLatest, from, map, of, Subject, switchMap, takeUntil } from "rxjs";
|
||||
|
||||
import { ApiService } from "@bitwarden/common/abstractions/api.service";
|
||||
import { CollectionService } from "@bitwarden/common/abstractions/collection.service";
|
||||
@@ -7,127 +11,267 @@ import { LogService } from "@bitwarden/common/abstractions/log.service";
|
||||
import { PlatformUtilsService } from "@bitwarden/common/abstractions/platformUtils.service";
|
||||
import { CollectionData } from "@bitwarden/common/models/data/collection.data";
|
||||
import { Collection } from "@bitwarden/common/models/domain/collection";
|
||||
import { GroupRequest } from "@bitwarden/common/models/request/group.request";
|
||||
import { SelectionReadOnlyRequest } from "@bitwarden/common/models/request/selection-read-only.request";
|
||||
import { CollectionDetailsResponse } from "@bitwarden/common/models/response/collection.response";
|
||||
import { CollectionView } from "@bitwarden/common/models/view/collection.view";
|
||||
import { ErrorResponse } from "@bitwarden/common/models/response/error.response";
|
||||
import { DialogService } from "@bitwarden/components";
|
||||
|
||||
import {
|
||||
AccessItemType,
|
||||
AccessItemValue,
|
||||
AccessItemView,
|
||||
convertToPermission,
|
||||
convertToSelectionView,
|
||||
PermissionMode,
|
||||
} from "../components/access-selector";
|
||||
import { GroupServiceAbstraction } from "../services/abstractions/group";
|
||||
import { GroupView } from "../views/group.view";
|
||||
|
||||
/**
|
||||
* Indices for the available tabs in the dialog
|
||||
*/
|
||||
export enum GroupAddEditTabType {
|
||||
Info = 0,
|
||||
Members = 1,
|
||||
Collections = 2,
|
||||
}
|
||||
|
||||
export interface GroupAddEditDialogParams {
|
||||
/**
|
||||
* ID of the organization the group belongs to
|
||||
*/
|
||||
organizationId: string;
|
||||
|
||||
/**
|
||||
* Optional ID of the group being modified
|
||||
*/
|
||||
groupId?: string;
|
||||
|
||||
/**
|
||||
* Tab to open when the dialog is open.
|
||||
* Defaults to Group Info
|
||||
*/
|
||||
initialTab?: GroupAddEditTabType;
|
||||
}
|
||||
|
||||
export enum GroupAddEditDialogResultType {
|
||||
Saved = "saved",
|
||||
Canceled = "canceled",
|
||||
Deleted = "deleted",
|
||||
}
|
||||
|
||||
/**
|
||||
* Strongly typed helper to open a groupAddEditDialog
|
||||
* @param dialogService Instance of the dialog service that will be used to open the dialog
|
||||
* @param overlay Instance of the CDK Overlay service
|
||||
* @param config Configuration for the dialog
|
||||
*/
|
||||
export const openGroupAddEditDialog = (
|
||||
dialogService: DialogService,
|
||||
overlay: Overlay,
|
||||
config: DialogConfig<GroupAddEditDialogParams>
|
||||
) => {
|
||||
return dialogService.open<GroupAddEditDialogResultType, GroupAddEditDialogParams>(
|
||||
GroupAddEditComponent,
|
||||
{
|
||||
positionStrategy: overlay.position().global().centerHorizontally().top(),
|
||||
...config,
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
@Component({
|
||||
selector: "app-group-add-edit",
|
||||
templateUrl: "group-add-edit.component.html",
|
||||
})
|
||||
export class GroupAddEditComponent implements OnInit {
|
||||
@Input() groupId: string;
|
||||
@Input() organizationId: string;
|
||||
@Output() onSavedGroup = new EventEmitter();
|
||||
@Output() onDeletedGroup = new EventEmitter();
|
||||
export class GroupAddEditComponent implements OnInit, OnDestroy {
|
||||
protected PermissionMode = PermissionMode;
|
||||
protected ResultType = GroupAddEditDialogResultType;
|
||||
|
||||
tabIndex: GroupAddEditTabType;
|
||||
loading = true;
|
||||
editMode = false;
|
||||
title: string;
|
||||
name: string;
|
||||
externalId: string;
|
||||
access: "all" | "selected" = "selected";
|
||||
collections: CollectionView[] = [];
|
||||
formPromise: Promise<any>;
|
||||
deletePromise: Promise<any>;
|
||||
collections: AccessItemView[] = [];
|
||||
members: AccessItemView[] = [];
|
||||
group: GroupView;
|
||||
|
||||
groupForm = this.formBuilder.group({
|
||||
accessAll: new FormControl(false),
|
||||
name: new FormControl("", Validators.required),
|
||||
externalId: new FormControl(""),
|
||||
members: new FormControl<AccessItemValue[]>([]),
|
||||
collections: new FormControl<AccessItemValue[]>([]),
|
||||
});
|
||||
|
||||
get groupId(): string | undefined {
|
||||
return this.params.groupId;
|
||||
}
|
||||
|
||||
get organizationId(): string {
|
||||
return this.params.organizationId;
|
||||
}
|
||||
|
||||
private destroy$ = new Subject<void>();
|
||||
|
||||
private get orgCollections$() {
|
||||
return from(this.apiService.getCollections(this.organizationId)).pipe(
|
||||
switchMap((response) => {
|
||||
return from(
|
||||
this.collectionService.decryptMany(
|
||||
response.data.map(
|
||||
(r) => new Collection(new CollectionData(r as CollectionDetailsResponse))
|
||||
)
|
||||
)
|
||||
);
|
||||
}),
|
||||
map((collections) =>
|
||||
collections.map<AccessItemView>((c) => ({
|
||||
id: c.id,
|
||||
type: AccessItemType.Collection,
|
||||
labelName: c.name,
|
||||
listName: c.name,
|
||||
}))
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
private get orgMembers$() {
|
||||
return from(this.apiService.getOrganizationUsers(this.organizationId)).pipe(
|
||||
map((response) =>
|
||||
response.data.map((m) => ({
|
||||
id: m.id,
|
||||
type: AccessItemType.Member,
|
||||
email: m.email,
|
||||
role: m.type,
|
||||
listName: m.name?.length > 0 ? `${m.name} (${m.email})` : m.email,
|
||||
labelName: m.name || m.email,
|
||||
status: m.status,
|
||||
}))
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
private get groupDetails$() {
|
||||
if (!this.editMode) {
|
||||
return of(undefined);
|
||||
}
|
||||
|
||||
return combineLatest([
|
||||
this.groupService.get(this.organizationId, this.groupId),
|
||||
this.apiService.getGroupUsers(this.organizationId, this.groupId),
|
||||
]).pipe(
|
||||
map(([groupView, users]) => {
|
||||
groupView.members = users;
|
||||
return groupView;
|
||||
}),
|
||||
catchError((e: unknown) => {
|
||||
if (e instanceof ErrorResponse) {
|
||||
this.logService.error(e.message);
|
||||
} else {
|
||||
this.logService.error(e.toString());
|
||||
}
|
||||
return of(undefined);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
constructor(
|
||||
@Inject(DIALOG_DATA) private params: GroupAddEditDialogParams,
|
||||
private dialogRef: DialogRef<GroupAddEditDialogResultType>,
|
||||
private apiService: ApiService,
|
||||
private groupService: GroupServiceAbstraction,
|
||||
private i18nService: I18nService,
|
||||
private collectionService: CollectionService,
|
||||
private platformUtilsService: PlatformUtilsService,
|
||||
private logService: LogService
|
||||
) {}
|
||||
private logService: LogService,
|
||||
private formBuilder: FormBuilder,
|
||||
private changeDetectorRef: ChangeDetectorRef
|
||||
) {
|
||||
this.tabIndex = params.initialTab ?? GroupAddEditTabType.Info;
|
||||
}
|
||||
|
||||
async ngOnInit() {
|
||||
ngOnInit() {
|
||||
this.editMode = this.loading = this.groupId != null;
|
||||
await this.loadCollections();
|
||||
this.title = this.i18nService.t(this.editMode ? "editGroup" : "addGroup");
|
||||
|
||||
if (this.editMode) {
|
||||
this.editMode = true;
|
||||
this.title = this.i18nService.t("editGroup");
|
||||
try {
|
||||
const group = await this.apiService.getGroupDetails(this.organizationId, this.groupId);
|
||||
this.access = group.accessAll ? "all" : "selected";
|
||||
this.name = group.name;
|
||||
this.externalId = group.externalId;
|
||||
if (group.collections != null && this.collections != null) {
|
||||
group.collections.forEach((s) => {
|
||||
const collection = this.collections.filter((c) => c.id === s.id);
|
||||
if (collection != null && collection.length > 0) {
|
||||
(collection[0] as any).checked = true;
|
||||
collection[0].readOnly = s.readOnly;
|
||||
collection[0].hidePasswords = s.hidePasswords;
|
||||
}
|
||||
combineLatest([this.orgCollections$, this.orgMembers$, this.groupDetails$])
|
||||
.pipe(takeUntil(this.destroy$))
|
||||
.subscribe(([collections, members, group]) => {
|
||||
this.collections = collections;
|
||||
this.members = members;
|
||||
this.group = group;
|
||||
|
||||
if (this.group != undefined) {
|
||||
// Must detect changes so that AccessSelector @Inputs() are aware of the latest
|
||||
// collections/members set above, otherwise no selected values will be patched below
|
||||
this.changeDetectorRef.detectChanges();
|
||||
|
||||
this.groupForm.patchValue({
|
||||
name: this.group.name,
|
||||
externalId: this.group.externalId,
|
||||
accessAll: this.group.accessAll,
|
||||
members: this.group.members.map((m) => ({
|
||||
id: m,
|
||||
type: AccessItemType.Member,
|
||||
})),
|
||||
collections: this.group.collections.map((gc) => ({
|
||||
id: gc.id,
|
||||
type: AccessItemType.Collection,
|
||||
permission: convertToPermission(gc),
|
||||
})),
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
this.logService.error(e);
|
||||
}
|
||||
} else {
|
||||
this.title = this.i18nService.t("addGroup");
|
||||
|
||||
this.loading = false;
|
||||
});
|
||||
}
|
||||
|
||||
ngOnDestroy() {
|
||||
this.destroy$.next();
|
||||
this.destroy$.complete();
|
||||
}
|
||||
|
||||
submit = async () => {
|
||||
if (this.groupForm.invalid) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.loading = false;
|
||||
}
|
||||
const groupView = new GroupView();
|
||||
groupView.id = this.groupId;
|
||||
groupView.organizationId = this.organizationId;
|
||||
|
||||
async loadCollections() {
|
||||
const response = await this.apiService.getCollections(this.organizationId);
|
||||
const collections = response.data.map(
|
||||
(r) => new Collection(new CollectionData(r as CollectionDetailsResponse))
|
||||
);
|
||||
this.collections = await this.collectionService.decryptMany(collections);
|
||||
}
|
||||
const formValue = this.groupForm.value;
|
||||
groupView.name = formValue.name;
|
||||
groupView.externalId = formValue.externalId;
|
||||
groupView.accessAll = formValue.accessAll;
|
||||
groupView.members = formValue.members?.map((m) => m.id) ?? [];
|
||||
|
||||
check(c: CollectionView, select?: boolean) {
|
||||
(c as any).checked = select == null ? !(c as any).checked : select;
|
||||
if (!(c as any).checked) {
|
||||
c.readOnly = false;
|
||||
}
|
||||
}
|
||||
|
||||
selectAll(select: boolean) {
|
||||
this.collections.forEach((c) => this.check(c, select));
|
||||
}
|
||||
|
||||
async submit() {
|
||||
const request = new GroupRequest();
|
||||
request.name = this.name;
|
||||
request.externalId = this.externalId;
|
||||
request.accessAll = this.access === "all";
|
||||
if (!request.accessAll) {
|
||||
request.collections = this.collections
|
||||
.filter((c) => (c as any).checked)
|
||||
.map((c) => new SelectionReadOnlyRequest(c.id, !!c.readOnly, !!c.hidePasswords));
|
||||
if (!groupView.accessAll) {
|
||||
groupView.collections = formValue.collections.map((c) => convertToSelectionView(c));
|
||||
}
|
||||
|
||||
try {
|
||||
if (this.editMode) {
|
||||
this.formPromise = this.apiService.putGroup(this.organizationId, this.groupId, request);
|
||||
} else {
|
||||
this.formPromise = this.apiService.postGroup(this.organizationId, request);
|
||||
}
|
||||
await this.formPromise;
|
||||
await this.groupService.save(groupView);
|
||||
|
||||
this.platformUtilsService.showToast(
|
||||
"success",
|
||||
null,
|
||||
this.i18nService.t(this.editMode ? "editedGroupId" : "createdGroupId", this.name)
|
||||
this.i18nService.t(this.editMode ? "editedGroupId" : "createdGroupId", formValue.name)
|
||||
);
|
||||
this.onSavedGroup.emit();
|
||||
this.dialogRef.close(GroupAddEditDialogResultType.Saved);
|
||||
} catch (e) {
|
||||
this.logService.error(e);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
async delete() {
|
||||
delete = async () => {
|
||||
if (!this.editMode) {
|
||||
return;
|
||||
}
|
||||
|
||||
const confirmed = await this.platformUtilsService.showDialog(
|
||||
this.i18nService.t("deleteGroupConfirmation"),
|
||||
this.name,
|
||||
this.group.name,
|
||||
this.i18nService.t("yes"),
|
||||
this.i18nService.t("no"),
|
||||
"warning"
|
||||
@@ -137,16 +281,16 @@ export class GroupAddEditComponent implements OnInit {
|
||||
}
|
||||
|
||||
try {
|
||||
this.deletePromise = this.apiService.deleteGroup(this.organizationId, this.groupId);
|
||||
await this.deletePromise;
|
||||
await this.groupService.delete(this.organizationId, this.groupId);
|
||||
|
||||
this.platformUtilsService.showToast(
|
||||
"success",
|
||||
null,
|
||||
this.i18nService.t("deletedGroupId", this.name)
|
||||
this.i18nService.t("deletedGroupId", this.group.name)
|
||||
);
|
||||
this.onDeletedGroup.emit();
|
||||
this.dialogRef.close(GroupAddEditDialogResultType.Deleted);
|
||||
} catch (e) {
|
||||
this.logService.error(e);
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user