1
0
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:
Shane Melton
2022-11-21 08:40:27 -08:00
committed by GitHub
parent 1dd9fbab72
commit 21a9f84956
37 changed files with 1129 additions and 511 deletions

View File

@@ -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);
}
}
};
}