diff --git a/apps/web/src/app/admin-console/common/new-base.people.component.ts b/apps/web/src/app/admin-console/common/new-base.people.component.ts new file mode 100644 index 0000000000..17f504c74a --- /dev/null +++ b/apps/web/src/app/admin-console/common/new-base.people.component.ts @@ -0,0 +1,415 @@ +import { Directive, ViewChild, ViewContainerRef } from "@angular/core"; +import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; +import { FormControl } from "@angular/forms"; +import { firstValueFrom, lastValueFrom, debounceTime } from "rxjs"; + +import { UserNamePipe } from "@bitwarden/angular/pipes/user-name.pipe"; +import { ModalService } from "@bitwarden/angular/services/modal.service"; +import { ApiService } from "@bitwarden/common/abstractions/api.service"; +import { OrganizationManagementPreferencesService } from "@bitwarden/common/admin-console/abstractions/organization-management-preferences/organization-management-preferences.service"; +import { + OrganizationUserStatusType, + OrganizationUserType, + ProviderUserStatusType, + ProviderUserType, +} from "@bitwarden/common/admin-console/enums"; +import { ProviderUserUserDetailsResponse } from "@bitwarden/common/admin-console/models/response/provider/provider-user.response"; +import { ListResponse } from "@bitwarden/common/models/response/list.response"; +import { CryptoService } from "@bitwarden/common/platform/abstractions/crypto.service"; +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 { ValidationService } from "@bitwarden/common/platform/abstractions/validation.service"; +import { Utils } from "@bitwarden/common/platform/misc/utils"; +import { DialogService, TableDataSource } from "@bitwarden/components"; + +import { OrganizationUserView } from "../organizations/core/views/organization-user.view"; +import { UserConfirmComponent } from "../organizations/manage/user-confirm.component"; + +type StatusType = OrganizationUserStatusType | ProviderUserStatusType; + +const MaxCheckedCount = 500; + +/** + * A refactored copy of BasePeopleComponent, using the component library table and other modern features. + * This will replace BasePeopleComponent once all subclasses have been changed over to use this class. + */ +@Directive() +export abstract class NewBasePeopleComponent< + UserView extends ProviderUserUserDetailsResponse | OrganizationUserView, +> { + @ViewChild("confirmTemplate", { read: ViewContainerRef, static: true }) + confirmModalRef: ViewContainerRef; + + get allCount() { + return this.activeUsers != null ? this.activeUsers.length : 0; + } + + get invitedCount() { + return this.statusMap.has(this.userStatusType.Invited) + ? this.statusMap.get(this.userStatusType.Invited).length + : 0; + } + + get acceptedCount() { + return this.statusMap.has(this.userStatusType.Accepted) + ? this.statusMap.get(this.userStatusType.Accepted).length + : 0; + } + + get confirmedCount() { + return this.statusMap.has(this.userStatusType.Confirmed) + ? this.statusMap.get(this.userStatusType.Confirmed).length + : 0; + } + + get revokedCount() { + return this.statusMap.has(this.userStatusType.Revoked) + ? this.statusMap.get(this.userStatusType.Revoked).length + : 0; + } + + /** + * Shows a banner alerting the admin that users need to be confirmed. + */ + get showConfirmUsers(): boolean { + return ( + this.activeUsers != null && + this.statusMap != null && + this.activeUsers.length > 1 && + this.confirmedCount > 0 && + this.confirmedCount < 3 && + this.acceptedCount > 0 + ); + } + + get showBulkConfirmUsers(): boolean { + return this.acceptedCount > 0; + } + + abstract userType: typeof OrganizationUserType | typeof ProviderUserType; + abstract userStatusType: typeof OrganizationUserStatusType | typeof ProviderUserStatusType; + + protected dataSource = new TableDataSource(); + + firstLoaded: boolean; + + /** + * A hashmap that groups users by their status (invited/accepted/etc). This is used by the toggles to show + * user counts and filter data by user status. + */ + statusMap = new Map(); + + /** + * The currently selected status filter, or null to show all active users. + */ + status: StatusType | null; + + /** + * The currently executing promise - used to avoid multiple user actions executing at once. + */ + actionPromise: Promise; + + /** + * All users, loaded from the server, before any filtering has been applied. + */ + protected allUsers: UserView[] = []; + + /** + * Active users only, that is, users that are not in the revoked status. + */ + protected activeUsers: UserView[] = []; + + protected searchControl = new FormControl("", { nonNullable: true }); + + constructor( + protected apiService: ApiService, + protected i18nService: I18nService, + protected platformUtilsService: PlatformUtilsService, + protected cryptoService: CryptoService, + protected validationService: ValidationService, + protected modalService: ModalService, + private logService: LogService, + protected userNamePipe: UserNamePipe, + protected dialogService: DialogService, + protected organizationManagementPreferencesService: OrganizationManagementPreferencesService, + ) { + // Connect the search input to the table dataSource filter input + this.searchControl.valueChanges + .pipe(debounceTime(200), takeUntilDestroyed()) + .subscribe((v) => (this.dataSource.filter = v)); + } + + abstract edit(user: UserView): void; + abstract getUsers(): Promise | UserView[]>; + abstract deleteUser(id: string): Promise; + abstract revokeUser(id: string): Promise; + abstract restoreUser(id: string): Promise; + abstract reinviteUser(id: string): Promise; + abstract confirmUser(user: UserView, publicKey: Uint8Array): Promise; + + async load() { + // Load new users from the server + const response = await this.getUsers(); + + // Reset and repopulate the statusMap + this.statusMap.clear(); + this.activeUsers = []; + for (const status of Utils.iterateEnum(this.userStatusType)) { + this.statusMap.set(status, []); + } + + if (response instanceof ListResponse) { + this.allUsers = response.data != null && response.data.length > 0 ? response.data : []; + } else if (Array.isArray(response)) { + this.allUsers = response; + } + + this.allUsers.forEach((u) => { + if (!this.statusMap.has(u.status)) { + this.statusMap.set(u.status, [u]); + } else { + this.statusMap.get(u.status).push(u); + } + if (u.status !== this.userStatusType.Revoked) { + this.activeUsers.push(u); + } + }); + + // Filter based on UserStatus - this also populates the table on first load + this.filter(this.status); + + this.firstLoaded = true; + } + + /** + * Filter the data source by user status. + * This overwrites dataSource.data because this filtering needs to apply first, before the search input + */ + filter(status: StatusType | null) { + this.status = status; + if (this.status != null) { + this.dataSource.data = this.statusMap.get(this.status); + } else { + this.dataSource.data = this.activeUsers; + } + // Reset checkbox selection + this.selectAll(false); + } + + checkUser(user: UserView, select?: boolean) { + (user as any).checked = select == null ? !(user as any).checked : select; + } + + selectAll(select: boolean) { + if (select) { + // Reset checkbox selection first so we know nothing else is selected + this.selectAll(false); + } + + const filteredUsers = this.dataSource.filteredData; + + const selectCount = + select && filteredUsers.length > MaxCheckedCount ? MaxCheckedCount : filteredUsers.length; + for (let i = 0; i < selectCount; i++) { + this.checkUser(filteredUsers[i], select); + } + } + + invite() { + this.edit(null); + } + + protected async removeUserConfirmationDialog(user: UserView) { + return this.dialogService.openSimpleDialog({ + title: this.userNamePipe.transform(user), + content: { key: "removeUserConfirmation" }, + type: "warning", + }); + } + + async remove(user: UserView) { + const confirmed = await this.removeUserConfirmationDialog(user); + if (!confirmed) { + return false; + } + + this.actionPromise = this.deleteUser(user.id); + try { + await this.actionPromise; + this.platformUtilsService.showToast( + "success", + null, + this.i18nService.t("removedUserId", this.userNamePipe.transform(user)), + ); + this.removeUser(user); + } catch (e) { + this.validationService.showError(e); + } + this.actionPromise = null; + } + + protected async revokeUserConfirmationDialog(user: UserView) { + return this.dialogService.openSimpleDialog({ + title: { key: "revokeAccess", placeholders: [this.userNamePipe.transform(user)] }, + content: this.revokeWarningMessage(), + acceptButtonText: { key: "revokeAccess" }, + type: "warning", + }); + } + + async revoke(user: UserView) { + const confirmed = await this.revokeUserConfirmationDialog(user); + + if (!confirmed) { + return false; + } + + this.actionPromise = this.revokeUser(user.id); + try { + await this.actionPromise; + this.platformUtilsService.showToast( + "success", + null, + this.i18nService.t("revokedUserId", this.userNamePipe.transform(user)), + ); + await this.load(); + } catch (e) { + this.validationService.showError(e); + } + this.actionPromise = null; + } + + async restore(user: UserView) { + this.actionPromise = this.restoreUser(user.id); + try { + await this.actionPromise; + this.platformUtilsService.showToast( + "success", + null, + this.i18nService.t("restoredUserId", this.userNamePipe.transform(user)), + ); + await this.load(); + } catch (e) { + this.validationService.showError(e); + } + this.actionPromise = null; + } + + async reinvite(user: UserView) { + if (this.actionPromise != null) { + return; + } + + this.actionPromise = this.reinviteUser(user.id); + try { + await this.actionPromise; + this.platformUtilsService.showToast( + "success", + null, + this.i18nService.t("hasBeenReinvited", this.userNamePipe.transform(user)), + ); + } catch (e) { + this.validationService.showError(e); + } + this.actionPromise = null; + } + + async confirm(user: UserView) { + function updateUser(self: NewBasePeopleComponent) { + user.status = self.userStatusType.Confirmed; + const mapIndex = self.statusMap.get(self.userStatusType.Accepted).indexOf(user); + if (mapIndex > -1) { + self.statusMap.get(self.userStatusType.Accepted).splice(mapIndex, 1); + self.statusMap.get(self.userStatusType.Confirmed).push(user); + } + } + + const confirmUser = async (publicKey: Uint8Array) => { + try { + this.actionPromise = this.confirmUser(user, publicKey); + await this.actionPromise; + updateUser(this); + this.platformUtilsService.showToast( + "success", + null, + this.i18nService.t("hasBeenConfirmed", this.userNamePipe.transform(user)), + ); + } catch (e) { + this.validationService.showError(e); + throw e; + } finally { + this.actionPromise = null; + } + }; + + if (this.actionPromise != null) { + return; + } + + try { + const publicKeyResponse = await this.apiService.getUserPublicKey(user.userId); + const publicKey = Utils.fromB64ToArray(publicKeyResponse.publicKey); + + const autoConfirm = await firstValueFrom( + this.organizationManagementPreferencesService.autoConfirmFingerPrints.state$, + ); + if (autoConfirm == null || !autoConfirm) { + const dialogRef = UserConfirmComponent.open(this.dialogService, { + data: { + name: this.userNamePipe.transform(user), + userId: user != null ? user.userId : null, + publicKey: publicKey, + confirmUser: () => confirmUser(publicKey), + }, + }); + await lastValueFrom(dialogRef.closed); + + return; + } + + try { + const fingerprint = await this.cryptoService.getFingerprint(user.userId, publicKey); + this.logService.info(`User's fingerprint: ${fingerprint.join("-")}`); + } catch (e) { + this.logService.error(e); + } + await confirmUser(publicKey); + } catch (e) { + this.logService.error(`Handled exception: ${e}`); + } + } + + protected revokeWarningMessage(): string { + return this.i18nService.t("revokeUserConfirmation"); + } + + protected getCheckedUsers() { + return this.dataSource.data.filter((u) => (u as any).checked); + } + + /** + * Remove a user row from the table and all related data sources + */ + protected removeUser(user: UserView) { + let index = this.dataSource.data.indexOf(user); + if (index > -1) { + // Clone the array so that the setter for dataSource.data is triggered to update the table rendering + const updatedData = [...this.dataSource.data]; + updatedData.splice(index, 1); + this.dataSource.data = updatedData; + } + + index = this.allUsers.indexOf(user); + if (index > -1) { + this.allUsers.splice(index, 1); + } + + if (this.statusMap.has(user.status)) { + index = this.statusMap.get(user.status).indexOf(user); + if (index > -1) { + this.statusMap.get(user.status).splice(index, 1); + } + } + } +} diff --git a/apps/web/src/app/admin-console/organizations/members/members.module.ts b/apps/web/src/app/admin-console/organizations/members/members.module.ts index 39246010d5..5dff43b77b 100644 --- a/apps/web/src/app/admin-console/organizations/members/members.module.ts +++ b/apps/web/src/app/admin-console/organizations/members/members.module.ts @@ -1,3 +1,4 @@ +import { ScrollingModule } from "@angular/cdk/scrolling"; import { NgModule } from "@angular/core"; import { PasswordCalloutComponent } from "@bitwarden/auth/angular"; @@ -22,6 +23,7 @@ import { PeopleComponent } from "./people.component"; MembersRoutingModule, UserDialogModule, PasswordCalloutComponent, + ScrollingModule, ], declarations: [ BulkConfirmComponent, diff --git a/apps/web/src/app/admin-console/organizations/members/people.component.html b/apps/web/src/app/admin-console/organizations/members/people.component.html index 902efeafcd..c6e7bfd070 100644 --- a/apps/web/src/app/admin-console/organizations/members/people.component.html +++ b/apps/web/src/app/admin-console/organizations/members/people.component.html @@ -37,7 +37,7 @@ - + {{ "loading" | i18n }} - -

{{ "noMembersInList" | i18n }}

- + +

{{ "noMembersInList" | i18n }}

+ {{ "usersNeedConfirmed" | i18n }} - - - - - - - - {{ "name" | i18n }} - {{ (organization.useGroups ? "groups" : "collections") | i18n }} - {{ "role" | i18n }} - {{ "policies" | i18n }} - - - - - - - - - + + + + + + + + + + {{ "name" | i18n }} + {{ (organization.useGroups ? "groups" : "collections") | i18n }} + {{ "role" | i18n }} + {{ "policies" | i18n }} + - - - - - - - - - - - - - -
- -
-
- + + + + - {{ "invited" | i18n }} - {{ "needsConfirmation" | i18n }} - {{ "revoked" | i18n }} -
-
- {{ u.email }} + + + + + + + + + + + + + + + + + +
+ +
+
+ + {{ "invited" | i18n }} + {{ "needsConfirmation" | i18n }} + {{ "revoked" | i18n }} +
+
+ {{ u.email }} +
-
- + - - - + + + - - {{ u.type | userType }} - + + {{ u.type | userType }} + - - - - {{ "userUsingTwoStep" | i18n }} - - - - {{ "enrolledAccountRecovery" | i18n }} - - - - + + + + {{ "userUsingTwoStep" | i18n }} + + + + {{ "enrolledAccountRecovery" | i18n }} + + + + - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + diff --git a/apps/web/src/app/admin-console/organizations/members/people.component.ts b/apps/web/src/app/admin-console/organizations/members/people.component.ts index 668526b38c..a47e0acd0c 100644 --- a/apps/web/src/app/admin-console/organizations/members/people.component.ts +++ b/apps/web/src/app/admin-console/organizations/members/people.component.ts @@ -1,4 +1,5 @@ import { Component, ViewChild, ViewContainerRef } from "@angular/core"; +import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; import { ActivatedRoute, Router } from "@angular/router"; import { combineLatest, @@ -9,16 +10,12 @@ import { map, Observable, shareReplay, - Subject, switchMap, - takeUntil, } from "rxjs"; -import { SearchPipe } from "@bitwarden/angular/pipes/search.pipe"; import { UserNamePipe } from "@bitwarden/angular/pipes/user-name.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 { OrganizationApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/organization/organization-api.service.abstraction"; import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; import { OrganizationManagementPreferencesService } from "@bitwarden/common/admin-console/abstractions/organization-management-preferences/organization-management-preferences.service"; @@ -50,7 +47,7 @@ import { CollectionDetailsResponse } from "@bitwarden/common/vault/models/respon import { DialogService, SimpleDialogOptions } from "@bitwarden/components"; import { openEntityEventsDialog } from "../../../admin-console/organizations/manage/entity-events.component"; -import { BasePeopleComponent } from "../../common/base.people.component"; +import { NewBasePeopleComponent } from "../../common/new-base.people.component"; import { GroupService } from "../core"; import { OrganizationUserView } from "../core/views/organization-user.view"; @@ -70,7 +67,7 @@ import { ResetPasswordComponent } from "./components/reset-password.component"; selector: "app-org-people", templateUrl: "people.component.html", }) -export class PeopleComponent extends BasePeopleComponent { +export class PeopleComponent extends NewBasePeopleComponent { @ViewChild("groupsTemplate", { read: ViewContainerRef, static: true }) groupsModalRef: ViewContainerRef; @ViewChild("confirmTemplate", { read: ViewContainerRef, static: true }) @@ -95,7 +92,9 @@ export class PeopleComponent extends BasePeopleComponent { protected canUseSecretsManager$: Observable; - private destroy$ = new Subject(); + // Fixed sizes used for cdkVirtualScroll + protected rowHeight = 62; + protected rowHeightClass = `tw-h-[62px]`; constructor( apiService: ApiService, @@ -104,12 +103,10 @@ export class PeopleComponent extends BasePeopleComponent { modalService: ModalService, platformUtilsService: PlatformUtilsService, cryptoService: CryptoService, - searchService: SearchService, validationService: ValidationService, private policyService: PolicyService, private policyApiService: PolicyApiService, logService: LogService, - searchPipe: SearchPipe, userNamePipe: UserNamePipe, private syncService: SyncService, private organizationService: OrganizationService, @@ -124,21 +121,17 @@ export class PeopleComponent extends BasePeopleComponent { ) { super( apiService, - searchService, i18nService, platformUtilsService, cryptoService, validationService, modalService, logService, - searchPipe, userNamePipe, dialogService, organizationManagementPreferencesService, ); - } - async ngOnInit() { const organization$ = this.route.params.pipe( concatMap((params) => this.organizationService.get$(params.organizationId)), shareReplay({ refCount: true, bufferSize: 1 }), @@ -198,29 +191,19 @@ export class PeopleComponent extends BasePeopleComponent { await this.load(); this.searchControl.setValue(qParams.search); + if (qParams.viewEvents != null) { - const user = this.users.filter((u) => u.id === qParams.viewEvents); + const user = this.dataSource.data.filter((u) => u.id === qParams.viewEvents); if (user.length > 0 && user[0].status === OrganizationUserStatusType.Confirmed) { - // FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling. - // eslint-disable-next-line @typescript-eslint/no-floating-promises - this.events(user[0]); + this.openEventsDialog(user[0]); } } }), - takeUntil(this.destroy$), + takeUntilDestroyed(), ) .subscribe(); } - ngOnDestroy(): void { - this.destroy$.next(); - this.destroy$.complete(); - } - - async load() { - await super.load(); - } - async getUsers(): Promise { let groupsPromise: Promise>; let collectionsPromise: Promise>; @@ -593,8 +576,8 @@ export class PeopleComponent extends BasePeopleComponent { await this.load(); } - async events(user: OrganizationUserView) { - await openEntityEventsDialog(this.dialogService, { + openEventsDialog(user: OrganizationUserView) { + openEntityEventsDialog(this.dialogService, { data: { name: this.userNamePipe.transform(user), organizationId: this.organization.id,