import { Component, OnDestroy, OnInit, ViewChild, ViewContainerRef } from "@angular/core"; import { ActivatedRoute } from "@angular/router"; import { BehaviorSubject, combineLatest, concatMap, from, lastValueFrom, map, Subject, switchMap, takeUntil, tap, } from "rxjs"; import { first } from "rxjs/operators"; import { SearchPipe } from "@bitwarden/angular/pipes/search.pipe"; import { ModalService } from "@bitwarden/angular/services/modal.service"; import { ApiService } from "@bitwarden/common/abstractions/api.service"; import { SearchService } from "@bitwarden/common/abstractions/search.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 { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { Utils } from "@bitwarden/common/platform/misc/utils"; 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 } from "@bitwarden/components"; import { InternalGroupService as GroupService, GroupView } from "../core"; import { GroupAddEditDialogResultType, GroupAddEditTabType, openGroupAddEditDialog, } from "./group-add-edit.component"; type CollectionViewMap = { [id: string]: CollectionView; }; type GroupDetailsRow = { /** * Group Id (used for searching) */ id: string; /** * Group name (used for searching) */ name: string; /** * 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[]; }; @Component({ selector: "app-org-groups", templateUrl: "groups.component.html", }) export class GroupsComponent implements OnInit, OnDestroy { @ViewChild("addEdit", { read: ViewContainerRef, static: true }) addEditModalRef: ViewContainerRef; @ViewChild("usersTemplate", { read: ViewContainerRef, static: true }) usersModalRef: ViewContainerRef; loading = true; organizationId: string; groups: GroupDetailsRow[]; protected didScroll = false; protected pageSize = 100; protected ModalTabType = GroupAddEditTabType; private pagedGroupsCount = 0; private pagedGroups: GroupDetailsRow[]; private searchedGroups: GroupDetailsRow[]; private _searchText: string; private destroy$ = new Subject(); private refreshGroups$ = new BehaviorSubject(null); get searchText() { return this._searchText; } set searchText(value: string) { this._searchText = value; // Manually update as we are not using the search pipe in the template this.updateSearchedGroups(); } /** * The list of groups that should be visible in the table. * This is needed as there are two modes (paging/searching) and * we need a reference to the currently visible groups for * the Select All checkbox */ get visibleGroups(): GroupDetailsRow[] { if (this.isPaging()) { return this.pagedGroups; } if (this.isSearching()) { return this.searchedGroups; } return this.groups; } constructor( private apiService: ApiService, private groupService: GroupService, private route: ActivatedRoute, private i18nService: I18nService, private modalService: ModalService, private dialogService: DialogService, private platformUtilsService: PlatformUtilsService, private searchService: SearchService, private logService: LogService, private collectionService: CollectionService, private searchPipe: SearchPipe ) {} async ngOnInit() { 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 .sort(Utils.getSortFunction(this.i18nService, "name")) .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), })); }), takeUntil(this.destroy$) ) .subscribe((groups) => { this.groups = groups; this.resetPaging(); this.updateSearchedGroups(); this.loading = false; }); this.route.queryParams .pipe( first(), concatMap(async (qParams) => { this.searchText = qParams.search; }), takeUntil(this.destroy$) ) .subscribe(); } ngOnDestroy() { this.destroy$.next(); this.destroy$.complete(); } loadMore() { if (!this.groups || this.groups.length <= this.pageSize) { return; } const pagedLength = this.pagedGroups.length; let pagedSize = this.pageSize; if (pagedLength === 0 && this.pagedGroupsCount > this.pageSize) { pagedSize = this.pagedGroupsCount; } if (this.groups.length > pagedLength) { this.pagedGroups = this.pagedGroups.concat( this.groups.slice(pagedLength, pagedLength + pagedSize) ); } this.pagedGroupsCount = this.pagedGroups.length; this.didScroll = this.pagedGroups.length > this.pageSize; } 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.details.id); } } add() { 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.platformUtilsService.showToast( "success", null, this.i18nService.t("deletedGroupId", groupRow.details.name) ); this.removeGroup(groupRow.details.id); } catch (e) { this.logService.error(e); } } async deleteAllSelected() { const groupsToDelete = this.groups.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.platformUtilsService.showToast( "success", null, this.i18nService.t("deletedManyGroups", groupsToDelete.length.toString()) ); groupsToDelete.forEach((g) => this.removeGroup(g.details.id)); } catch (e) { this.logService.error(e); } } resetPaging() { this.pagedGroups = []; this.loadMore(); } isSearching() { return this.searchService.isSearchable(this.searchText); } check(groupRow: GroupDetailsRow) { groupRow.checked = !groupRow.checked; } toggleAllVisible(event: Event) { this.visibleGroups.forEach((g) => (g.checked = (event.target as HTMLInputElement).checked)); } isPaging() { const searching = this.isSearching(); if (searching && this.didScroll) { this.resetPaging(); } return !searching && this.groups && this.groups.length > this.pageSize; } private removeGroup(id: string) { const index = this.groups.findIndex((g) => g.details.id === id); if (index > -1) { this.groups.splice(index, 1); this.resetPaging(); this.updateSearchedGroups(); } } 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: CollectionViewMap = {}; decryptedCollections.forEach((c) => (collectionMap[c.id] = c)); return collectionMap; } private updateSearchedGroups() { if (this.searchService.isSearchable(this.searchText)) { // Making use of the pipe in the component as we need know which groups where filtered this.searchedGroups = this.searchPipe.transform( this.groups, this.searchText, (group) => group.details.name, (group) => group.details.id ); } } }