diff --git a/apps/web/src/app/admin-console/organizations/manage/groups.component.ts b/apps/web/src/app/admin-console/organizations/manage/groups.component.ts index 9ff596181e..7c86ac2849 100644 --- a/apps/web/src/app/admin-console/organizations/manage/groups.component.ts +++ b/apps/web/src/app/admin-console/organizations/manage/groups.component.ts @@ -71,6 +71,11 @@ type GroupDetailsRow = { collectionNames?: string[]; }; +/** + * @deprecated To be replaced with NewGroupsComponent which significantly refactors this component. + * The GroupsComponentRefactor flag switches between the old and new components; this component will be removed when + * the feature flag is removed. + */ @Component({ selector: "app-org-groups", templateUrl: "groups.component.html", diff --git a/apps/web/src/app/admin-console/organizations/manage/new-groups.component.html b/apps/web/src/app/admin-console/organizations/manage/new-groups.component.html new file mode 100644 index 0000000000..3e659e5b6a --- /dev/null +++ b/apps/web/src/app/admin-console/organizations/manage/new-groups.component.html @@ -0,0 +1,109 @@ + + + + + + + + {{ "loading" | i18n }} + + +

{{ "noGroupsInList" | i18n }}

+ + + + + + + + + + {{ "name" | i18n }} + {{ "collections" | i18n }} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
diff --git a/apps/web/src/app/admin-console/organizations/manage/new-groups.component.ts b/apps/web/src/app/admin-console/organizations/manage/new-groups.component.ts new file mode 100644 index 0000000000..e5e99333e6 --- /dev/null +++ b/apps/web/src/app/admin-console/organizations/manage/new-groups.component.ts @@ -0,0 +1,255 @@ +import { Component } from "@angular/core"; +import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; +import { FormControl } from "@angular/forms"; +import { ActivatedRoute } from "@angular/router"; +import { + BehaviorSubject, + combineLatest, + concatMap, + from, + lastValueFrom, + map, + switchMap, + tap, +} from "rxjs"; +import { debounceTime, first } from "rxjs/operators"; + +import { ApiService } from "@bitwarden/common/abstractions/api.service"; +import { ListResponse } from "@bitwarden/common/models/response/list.response"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; +import { CollectionService } from "@bitwarden/common/vault/abstractions/collection.service"; +import { CollectionData } from "@bitwarden/common/vault/models/data/collection.data"; +import { Collection } from "@bitwarden/common/vault/models/domain/collection"; +import { + CollectionDetailsResponse, + CollectionResponse, +} from "@bitwarden/common/vault/models/response/collection.response"; +import { CollectionView } from "@bitwarden/common/vault/models/view/collection.view"; +import { DialogService, TableDataSource, ToastService } from "@bitwarden/components"; + +import { InternalGroupService as GroupService, GroupView } from "../core"; + +import { + GroupAddEditDialogResultType, + GroupAddEditTabType, + openGroupAddEditDialog, +} from "./group-add-edit.component"; + +type GroupDetailsRow = { + /** + * Details used for displaying group information + */ + details: GroupView; + + /** + * True if the group is selected in the table + */ + checked?: boolean; + + /** + * A list of collection names the group has access to + */ + collectionNames?: string[]; +}; + +/** + * Custom filter predicate that filters the groups table by id and name only. + * This is required because the default implementation searches by all properties, which can unintentionally match + * with members' names (who are assigned to the group) or collection names (which the group has access to). + */ +const groupsFilter = (filter: string) => { + const transformedFilter = filter.trim().toLowerCase(); + return (data: GroupDetailsRow) => { + const group = data.details; + + return ( + group.id.toLowerCase().indexOf(transformedFilter) != -1 || + group.name.toLowerCase().indexOf(transformedFilter) != -1 + ); + }; +}; + +@Component({ + templateUrl: "new-groups.component.html", +}) +export class NewGroupsComponent { + loading = true; + organizationId: string; + + protected dataSource = new TableDataSource(); + protected searchControl = new FormControl(""); + + // Fixed sizes used for cdkVirtualScroll + protected rowHeight = 46; + protected rowHeightClass = `tw-h-[46px]`; + + protected ModalTabType = GroupAddEditTabType; + private refreshGroups$ = new BehaviorSubject(null); + + constructor( + private apiService: ApiService, + private groupService: GroupService, + private route: ActivatedRoute, + private i18nService: I18nService, + private dialogService: DialogService, + private logService: LogService, + private collectionService: CollectionService, + private toastService: ToastService, + ) { + this.route.params + .pipe( + tap((params) => (this.organizationId = params.organizationId)), + switchMap(() => + combineLatest([ + // collectionMap + from(this.apiService.getCollections(this.organizationId)).pipe( + concatMap((response) => this.toCollectionMap(response)), + ), + // groups + this.refreshGroups$.pipe( + switchMap(() => this.groupService.getAll(this.organizationId)), + ), + ]), + ), + map(([collectionMap, groups]) => { + return groups.map((g) => ({ + id: g.id, + name: g.name, + details: g, + checked: false, + collectionNames: g.collections + .map((c) => collectionMap[c.id]?.name) + .sort(this.i18nService.collator?.compare), + })); + }), + takeUntilDestroyed(), + ) + .subscribe((groups) => { + this.dataSource.data = groups; + this.loading = false; + }); + + // Connect the search input to the table dataSource filter input + this.searchControl.valueChanges + .pipe(debounceTime(200), takeUntilDestroyed()) + .subscribe((v) => (this.dataSource.filter = groupsFilter(v))); + + this.route.queryParams.pipe(first(), takeUntilDestroyed()).subscribe((qParams) => { + this.searchControl.setValue(qParams.search); + }); + } + + async edit( + group: GroupDetailsRow, + startingTabIndex: GroupAddEditTabType = GroupAddEditTabType.Info, + ) { + const dialogRef = openGroupAddEditDialog(this.dialogService, { + data: { + initialTab: startingTabIndex, + organizationId: this.organizationId, + groupId: group != null ? group.details.id : null, + }, + }); + + const result = await lastValueFrom(dialogRef.closed); + + if (result == GroupAddEditDialogResultType.Saved) { + this.refreshGroups$.next(); + } else if (result == GroupAddEditDialogResultType.Deleted) { + this.removeGroup(group); + } + } + + async add() { + await this.edit(null); + } + + async delete(groupRow: GroupDetailsRow) { + const confirmed = await this.dialogService.openSimpleDialog({ + title: groupRow.details.name, + content: { key: "deleteGroupConfirmation" }, + type: "warning", + }); + if (!confirmed) { + return false; + } + + try { + await this.groupService.delete(this.organizationId, groupRow.details.id); + this.toastService.showToast({ + variant: "success", + title: null, + message: this.i18nService.t("deletedGroupId", groupRow.details.name), + }); + this.removeGroup(groupRow); + } catch (e) { + this.logService.error(e); + } + } + + async deleteAllSelected() { + const groupsToDelete = this.dataSource.data.filter((g) => g.checked); + + if (groupsToDelete.length == 0) { + return; + } + + const deleteMessage = groupsToDelete.map((g) => g.details.name).join(", "); + const confirmed = await this.dialogService.openSimpleDialog({ + title: { + key: "deleteMultipleGroupsConfirmation", + placeholders: [groupsToDelete.length.toString()], + }, + content: deleteMessage, + type: "warning", + }); + if (!confirmed) { + return false; + } + + try { + await this.groupService.deleteMany( + this.organizationId, + groupsToDelete.map((g) => g.details.id), + ); + this.toastService.showToast({ + variant: "success", + title: null, + message: this.i18nService.t("deletedManyGroups", groupsToDelete.length.toString()), + }); + + groupsToDelete.forEach((g) => this.removeGroup(g)); + } catch (e) { + this.logService.error(e); + } + } + + check(groupRow: GroupDetailsRow) { + groupRow.checked = !groupRow.checked; + } + + toggleAllVisible(event: Event) { + this.dataSource.filteredData.forEach( + (g) => (g.checked = (event.target as HTMLInputElement).checked), + ); + } + + private removeGroup(groupRow: GroupDetailsRow) { + // Assign a new array to dataSource.data to trigger the setters and update the table + this.dataSource.data = this.dataSource.data.filter((g) => g !== groupRow); + } + + private async toCollectionMap(response: ListResponse) { + const collections = response.data.map( + (r) => new Collection(new CollectionData(r as CollectionDetailsResponse)), + ); + const decryptedCollections = await this.collectionService.decryptMany(collections); + + // Convert to an object using collection Ids as keys for faster name lookups + const collectionMap: Record = {}; + decryptedCollections.forEach((c) => (collectionMap[c.id] = c)); + + return collectionMap; + } +} diff --git a/apps/web/src/app/admin-console/organizations/organization-routing.module.ts b/apps/web/src/app/admin-console/organizations/organization-routing.module.ts index 2959601c10..7427bbb481 100644 --- a/apps/web/src/app/admin-console/organizations/organization-routing.module.ts +++ b/apps/web/src/app/admin-console/organizations/organization-routing.module.ts @@ -2,6 +2,7 @@ import { NgModule } from "@angular/core"; import { RouterModule, Routes } from "@angular/router"; import { AuthGuard } from "@bitwarden/angular/auth/guards"; +import { featureFlaggedRoute } from "@bitwarden/angular/platform/utils/feature-flagged-route"; import { canAccessOrgAdmin, canAccessGroupsTab, @@ -11,11 +12,13 @@ import { canAccessSettingsTab, } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; +import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; import { organizationPermissionsGuard } from "../../admin-console/organizations/guards/org-permissions.guard"; import { organizationRedirectGuard } from "../../admin-console/organizations/guards/org-redirect.guard"; import { OrganizationLayoutComponent } from "../../admin-console/organizations/layouts/organization-layout.component"; import { GroupsComponent } from "../../admin-console/organizations/manage/groups.component"; +import { NewGroupsComponent } from "../../admin-console/organizations/manage/new-groups.component"; import { deepLinkGuard } from "../../auth/guards/deep-link.guard"; import { VaultModule } from "../../vault/org-vault/vault.module"; @@ -46,14 +49,18 @@ const routes: Routes = [ path: "members", loadChildren: () => import("./members").then((m) => m.MembersModule), }, - { - path: "groups", - component: GroupsComponent, - canActivate: [organizationPermissionsGuard(canAccessGroupsTab)], - data: { - titleId: "groups", + ...featureFlaggedRoute({ + defaultComponent: GroupsComponent, + flaggedComponent: NewGroupsComponent, + featureFlag: FeatureFlag.GroupsComponentRefactor, + routeOptions: { + path: "groups", + canActivate: [organizationPermissionsGuard(canAccessGroupsTab)], + data: { + titleId: "groups", + }, }, - }, + }), { path: "reporting", loadChildren: () => diff --git a/apps/web/src/app/admin-console/organizations/organization.module.ts b/apps/web/src/app/admin-console/organizations/organization.module.ts index 29a7139231..79f3a8e5f7 100644 --- a/apps/web/src/app/admin-console/organizations/organization.module.ts +++ b/apps/web/src/app/admin-console/organizations/organization.module.ts @@ -1,3 +1,4 @@ +import { ScrollingModule } from "@angular/cdk/scrolling"; import { NgModule } from "@angular/core"; import { LooseComponentsModule } from "../../shared"; @@ -5,6 +6,7 @@ import { LooseComponentsModule } from "../../shared"; import { CoreOrganizationModule } from "./core"; import { GroupAddEditComponent } from "./manage/group-add-edit.component"; import { GroupsComponent } from "./manage/groups.component"; +import { NewGroupsComponent } from "./manage/new-groups.component"; import { OrganizationsRoutingModule } from "./organization-routing.module"; import { SharedOrganizationModule } from "./shared"; import { AccessSelectorModule } from "./shared/components/access-selector"; @@ -16,7 +18,8 @@ import { AccessSelectorModule } from "./shared/components/access-selector"; CoreOrganizationModule, OrganizationsRoutingModule, LooseComponentsModule, + ScrollingModule, ], - declarations: [GroupsComponent, GroupAddEditComponent], + declarations: [GroupsComponent, NewGroupsComponent, GroupAddEditComponent], }) export class OrganizationModule {} diff --git a/libs/common/src/enums/feature-flag.enum.ts b/libs/common/src/enums/feature-flag.enum.ts index 759868eef7..2ed70efb81 100644 --- a/libs/common/src/enums/feature-flag.enum.ts +++ b/libs/common/src/enums/feature-flag.enum.ts @@ -21,6 +21,7 @@ export enum FeatureFlag { InlineMenuFieldQualification = "inline-menu-field-qualification", MemberAccessReport = "ac-2059-member-access-report", EnableTimeThreshold = "PM-5864-dollar-threshold", + GroupsComponentRefactor = "groups-component-refactor", } export type AllowedFeatureFlagTypes = boolean | number | string; @@ -52,6 +53,7 @@ export const DefaultFeatureFlagValue = { [FeatureFlag.InlineMenuFieldQualification]: FALSE, [FeatureFlag.MemberAccessReport]: FALSE, [FeatureFlag.EnableTimeThreshold]: FALSE, + [FeatureFlag.GroupsComponentRefactor]: FALSE, } satisfies Record; export type DefaultFeatureFlagValueType = typeof DefaultFeatureFlagValue;