1
0
mirror of https://github.com/bitwarden/browser synced 2025-12-13 06:43:35 +00:00

[AC-2791] Members page - finish component library refactors (#9727)

* Replace PlatformUtilsService with ToastService

* Remove unneeded templates

* Implement table filtering function

* Move member-only methods from base class to subclass

* Move utility functions inside new MemberTableDataSource

* Rename PeopleComponent to MembersComponent
This commit is contained in:
Thomas Rittson
2024-07-01 06:50:42 +10:00
committed by GitHub
parent e12e817d22
commit 8f66d60a7e
6 changed files with 282 additions and 301 deletions

View File

@@ -1,7 +1,7 @@
import { Directive, ViewChild, ViewContainerRef } from "@angular/core"; import { Directive } from "@angular/core";
import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
import { FormControl } from "@angular/forms"; import { FormControl } from "@angular/forms";
import { firstValueFrom, lastValueFrom, debounceTime } from "rxjs"; import { firstValueFrom, lastValueFrom, debounceTime, combineLatest, BehaviorSubject } from "rxjs";
import { UserNamePipe } from "@bitwarden/angular/pipes/user-name.pipe"; import { UserNamePipe } from "@bitwarden/angular/pipes/user-name.pipe";
import { ModalService } from "@bitwarden/angular/services/modal.service"; import { ModalService } from "@bitwarden/angular/services/modal.service";
@@ -18,88 +18,47 @@ import { ListResponse } from "@bitwarden/common/models/response/list.response";
import { CryptoService } from "@bitwarden/common/platform/abstractions/crypto.service"; import { CryptoService } from "@bitwarden/common/platform/abstractions/crypto.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.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 { ValidationService } from "@bitwarden/common/platform/abstractions/validation.service";
import { Utils } from "@bitwarden/common/platform/misc/utils"; import { Utils } from "@bitwarden/common/platform/misc/utils";
import { DialogService, TableDataSource } from "@bitwarden/components"; import { DialogService, ToastService } from "@bitwarden/components";
import { OrganizationUserView } from "../organizations/core/views/organization-user.view"; import { OrganizationUserView } from "../organizations/core/views/organization-user.view";
import { UserConfirmComponent } from "../organizations/manage/user-confirm.component"; import { UserConfirmComponent } from "../organizations/manage/user-confirm.component";
type StatusType = OrganizationUserStatusType | ProviderUserStatusType; import { PeopleTableDataSource, peopleFilter } from "./people-table-data-source";
const MaxCheckedCount = 500; export type StatusType = OrganizationUserStatusType | ProviderUserStatusType;
export type UserViewTypes = ProviderUserUserDetailsResponse | OrganizationUserView;
/** /**
* A refactored copy of BasePeopleComponent, using the component library table and other modern features. * 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. * This will replace BasePeopleComponent once all subclasses have been changed over to use this class.
*/ */
@Directive() @Directive()
export abstract class NewBasePeopleComponent< export abstract class NewBasePeopleComponent<UserView extends UserViewTypes> {
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. * Shows a banner alerting the admin that users need to be confirmed.
*/ */
get showConfirmUsers(): boolean { get showConfirmUsers(): boolean {
return ( return (
this.activeUsers != null && this.dataSource.activeUserCount > 1 &&
this.statusMap != null && this.dataSource.confirmedUserCount > 0 &&
this.activeUsers.length > 1 && this.dataSource.confirmedUserCount < 3 &&
this.confirmedCount > 0 && this.dataSource.acceptedUserCount > 0
this.confirmedCount < 3 &&
this.acceptedCount > 0
); );
} }
get showBulkConfirmUsers(): boolean { get showBulkConfirmUsers(): boolean {
return this.acceptedCount > 0; return this.dataSource.acceptedUserCount > 0;
} }
abstract userType: typeof OrganizationUserType | typeof ProviderUserType; abstract userType: typeof OrganizationUserType | typeof ProviderUserType;
abstract userStatusType: typeof OrganizationUserStatusType | typeof ProviderUserStatusType; abstract userStatusType: typeof OrganizationUserStatusType | typeof ProviderUserStatusType;
protected dataSource = new TableDataSource<UserView>(); protected abstract dataSource: PeopleTableDataSource<UserView>;
firstLoaded: boolean; 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<StatusType, UserView[]>();
/** /**
* The currently selected status filter, or null to show all active users. * The currently selected status filter, or null to show all active users.
*/ */
@@ -110,22 +69,12 @@ export abstract class NewBasePeopleComponent<
*/ */
actionPromise: Promise<void>; actionPromise: Promise<void>;
/**
* 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 }); protected searchControl = new FormControl("", { nonNullable: true });
protected statusToggle = new BehaviorSubject<StatusType | null>(null);
constructor( constructor(
protected apiService: ApiService, protected apiService: ApiService,
protected i18nService: I18nService, protected i18nService: I18nService,
protected platformUtilsService: PlatformUtilsService,
protected cryptoService: CryptoService, protected cryptoService: CryptoService,
protected validationService: ValidationService, protected validationService: ValidationService,
protected modalService: ModalService, protected modalService: ModalService,
@@ -133,18 +82,19 @@ export abstract class NewBasePeopleComponent<
protected userNamePipe: UserNamePipe, protected userNamePipe: UserNamePipe,
protected dialogService: DialogService, protected dialogService: DialogService,
protected organizationManagementPreferencesService: OrganizationManagementPreferencesService, protected organizationManagementPreferencesService: OrganizationManagementPreferencesService,
protected toastService: ToastService,
) { ) {
// Connect the search input to the table dataSource filter input // Connect the search input and status toggles to the table dataSource filter
this.searchControl.valueChanges combineLatest([this.searchControl.valueChanges.pipe(debounceTime(200)), this.statusToggle])
.pipe(debounceTime(200), takeUntilDestroyed()) .pipe(takeUntilDestroyed())
.subscribe((v) => (this.dataSource.filter = v)); .subscribe(
([searchText, status]) => (this.dataSource.filter = peopleFilter(searchText, status)),
);
} }
abstract edit(user: UserView): void; abstract edit(user: UserView): void;
abstract getUsers(): Promise<ListResponse<UserView> | UserView[]>; abstract getUsers(): Promise<ListResponse<UserView> | UserView[]>;
abstract deleteUser(id: string): Promise<void>; abstract deleteUser(id: string): Promise<void>;
abstract revokeUser(id: string): Promise<void>;
abstract restoreUser(id: string): Promise<void>;
abstract reinviteUser(id: string): Promise<void>; abstract reinviteUser(id: string): Promise<void>;
abstract confirmUser(user: UserView, publicKey: Uint8Array): Promise<void>; abstract confirmUser(user: UserView, publicKey: Uint8Array): Promise<void>;
@@ -152,70 +102,16 @@ export abstract class NewBasePeopleComponent<
// Load new users from the server // Load new users from the server
const response = await this.getUsers(); const response = await this.getUsers();
// Reset and repopulate the statusMap // GetUsers can return a ListResponse or an Array
this.statusMap.clear();
this.activeUsers = [];
for (const status of Utils.iterateEnum(this.userStatusType)) {
this.statusMap.set(status, []);
}
if (response instanceof ListResponse) { if (response instanceof ListResponse) {
this.allUsers = response.data != null && response.data.length > 0 ? response.data : []; this.dataSource.data = response.data != null && response.data.length > 0 ? response.data : [];
} else if (Array.isArray(response)) { } else if (Array.isArray(response)) {
this.allUsers = response; this.dataSource.data = 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; 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() { invite() {
this.edit(null); this.edit(null);
} }
@@ -237,59 +133,12 @@ export abstract class NewBasePeopleComponent<
this.actionPromise = this.deleteUser(user.id); this.actionPromise = this.deleteUser(user.id);
try { try {
await this.actionPromise; await this.actionPromise;
this.platformUtilsService.showToast( this.toastService.showToast({
"success", variant: "success",
null, title: null,
this.i18nService.t("removedUserId", this.userNamePipe.transform(user)), message: this.i18nService.t("removedUserId", this.userNamePipe.transform(user)),
); });
this.removeUser(user); this.dataSource.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) { } catch (e) {
this.validationService.showError(e); this.validationService.showError(e);
} }
@@ -304,11 +153,11 @@ export abstract class NewBasePeopleComponent<
this.actionPromise = this.reinviteUser(user.id); this.actionPromise = this.reinviteUser(user.id);
try { try {
await this.actionPromise; await this.actionPromise;
this.platformUtilsService.showToast( this.toastService.showToast({
"success", variant: "success",
null, title: null,
this.i18nService.t("hasBeenReinvited", this.userNamePipe.transform(user)), message: this.i18nService.t("hasBeenReinvited", this.userNamePipe.transform(user)),
); });
} catch (e) { } catch (e) {
this.validationService.showError(e); this.validationService.showError(e);
} }
@@ -316,25 +165,18 @@ export abstract class NewBasePeopleComponent<
} }
async confirm(user: UserView) { async confirm(user: UserView) {
function updateUser(self: NewBasePeopleComponent<UserView>) {
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) => { const confirmUser = async (publicKey: Uint8Array) => {
try { try {
this.actionPromise = this.confirmUser(user, publicKey); this.actionPromise = this.confirmUser(user, publicKey);
await this.actionPromise; await this.actionPromise;
updateUser(this); user.status = this.userStatusType.Confirmed;
this.platformUtilsService.showToast( this.dataSource.replaceUser(user);
"success",
null, this.toastService.showToast({
this.i18nService.t("hasBeenConfirmed", this.userNamePipe.transform(user)), variant: "success",
); title: null,
message: this.i18nService.t("hasBeenConfirmed", this.userNamePipe.transform(user)),
});
} catch (e) { } catch (e) {
this.validationService.showError(e); this.validationService.showError(e);
throw e; throw e;
@@ -379,37 +221,4 @@ export abstract class NewBasePeopleComponent<
this.logService.error(`Handled exception: ${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);
}
}
}
} }

View File

@@ -0,0 +1,133 @@
import {
OrganizationUserStatusType,
ProviderUserStatusType,
} from "@bitwarden/common/admin-console/enums";
import { TableDataSource } from "@bitwarden/components";
import { StatusType, UserViewTypes } from "./new-base.people.component";
const MaxCheckedCount = 500;
/**
* Returns true if the user matches the status, or where the status is `null`, if the user is active (not revoked).
*/
function statusFilter(user: UserViewTypes, status: StatusType) {
if (status == null) {
return user.status != OrganizationUserStatusType.Revoked;
}
return user.status === status;
}
/**
* Returns true if the string matches the user's id, name, or email.
* (The default string search includes all properties, which can return false positives for collection names etc.)
*/
function textFilter(user: UserViewTypes, text: string) {
const normalizedText = text?.toLowerCase();
return (
!normalizedText || // null/empty strings should be ignored, i.e. always return true
user.email.toLowerCase().includes(normalizedText) ||
user.id.toLowerCase().includes(normalizedText) ||
user.name?.toLowerCase().includes(normalizedText)
);
}
export function peopleFilter(searchText: string, status: StatusType) {
return (user: UserViewTypes) => statusFilter(user, status) && textFilter(user, searchText);
}
/**
* An extended TableDataSource class for managing people (organization members and provider users).
* It includes a tally of different statuses, utility methods, and other common functionality.
*/
export abstract class PeopleTableDataSource<T extends UserViewTypes> extends TableDataSource<T> {
protected abstract statusType: typeof OrganizationUserStatusType | typeof ProviderUserStatusType;
/**
* The number of 'active' users, that is, all users who are not in a revoked status.
*/
activeUserCount: number;
invitedUserCount: number;
acceptedUserCount: number;
confirmedUserCount: number;
revokedUserCount: number;
override set data(data: T[]) {
super.data = data;
this.activeUserCount =
this.data?.filter((u) => u.status !== this.statusType.Revoked).length ?? 0;
this.invitedUserCount =
this.data?.filter((u) => u.status === this.statusType.Invited).length ?? 0;
this.acceptedUserCount =
this.data?.filter((u) => u.status === this.statusType.Accepted).length ?? 0;
this.confirmedUserCount =
this.data?.filter((u) => u.status === this.statusType.Confirmed).length ?? 0;
this.revokedUserCount =
this.data?.filter((u) => u.status === this.statusType.Revoked).length ?? 0;
}
override get data() {
// If you override a setter, you must also override the getter
return super.data;
}
/**
* Check or uncheck a user in the table
* @param select check the user (true), uncheck the user (false), or toggle the current state (null)
*/
checkUser(user: T, select?: boolean) {
(user as any).checked = select == null ? !(user as any).checked : select;
}
getCheckedUsers() {
return this.data.filter((u) => (u as any).checked);
}
/**
* Check all filtered users (i.e. those rows that are currently visible)
* @param select check the filtered users (true) or uncheck the filtered users (false)
*/
checkAllFilteredUsers(select: boolean) {
if (select) {
// Reset checkbox selection first so we know nothing else is selected
this.uncheckAllUsers();
}
const filteredUsers = this.filteredData;
const selectCount =
filteredUsers.length > MaxCheckedCount ? MaxCheckedCount : filteredUsers.length;
for (let i = 0; i < selectCount; i++) {
this.checkUser(filteredUsers[i], select);
}
}
uncheckAllUsers() {
this.data.forEach((u) => ((u as any).checked = false));
}
/**
* Remove a user from the data source. Use this to ensure the table is re-rendered after the change.
*/
removeUser(user: T) {
// Note: use immutable functions so that we trigger setters to update the table
this.data = this.data.filter((u) => u != user);
}
/**
* Replace a user in the data source by matching on user.id. Use this to ensure the table is re-rendered after the change.
*/
replaceUser(user: T) {
const index = this.data.findIndex((u) => u.id === user.id);
if (index > -1) {
// Clone the array so that the setter for dataSource.data is triggered to update the table rendering
const updatedData = this.data.slice();
updatedData[index] = user;
this.data = updatedData;
}
}
}

View File

@@ -5,12 +5,12 @@ import { canAccessMembersTab } from "@bitwarden/common/admin-console/abstraction
import { OrganizationPermissionsGuard } from "../guards/org-permissions.guard"; import { OrganizationPermissionsGuard } from "../guards/org-permissions.guard";
import { PeopleComponent } from "./people.component"; import { MembersComponent } from "./members.component";
const routes: Routes = [ const routes: Routes = [
{ {
path: "", path: "",
component: PeopleComponent, component: MembersComponent,
canActivate: [OrganizationPermissionsGuard], canActivate: [OrganizationPermissionsGuard],
data: { data: {
titleId: "members", titleId: "members",

View File

@@ -14,26 +14,35 @@
<div class="tw-mb-4 tw-flex tw-flex-col tw-space-y-4"> <div class="tw-mb-4 tw-flex tw-flex-col tw-space-y-4">
<bit-toggle-group <bit-toggle-group
[selected]="status" [selected]="status"
(selectedChange)="filter($event)" (selectedChange)="statusToggle.next($event)"
[attr.aria-label]="'memberStatusFilter' | i18n" [attr.aria-label]="'memberStatusFilter' | i18n"
> >
<bit-toggle [value]="null"> <bit-toggle [value]="null">
{{ "all" | i18n }} <span bitBadge variant="info" *ngIf="allCount">{{ allCount }}</span> {{ "all" | i18n }}
<span bitBadge variant="info" *ngIf="dataSource.activeUserCount as allCount">{{
allCount
}}</span>
</bit-toggle> </bit-toggle>
<bit-toggle [value]="userStatusType.Invited"> <bit-toggle [value]="userStatusType.Invited">
{{ "invited" | i18n }} {{ "invited" | i18n }}
<span bitBadge variant="info" *ngIf="invitedCount">{{ invitedCount }}</span> <span bitBadge variant="info" *ngIf="dataSource.invitedUserCount as invitedCount">{{
invitedCount
}}</span>
</bit-toggle> </bit-toggle>
<bit-toggle [value]="userStatusType.Accepted"> <bit-toggle [value]="userStatusType.Accepted">
{{ "needsConfirmation" | i18n }} {{ "needsConfirmation" | i18n }}
<span bitBadge variant="info" *ngIf="acceptedCount">{{ acceptedCount }}</span> <span bitBadge variant="info" *ngIf="dataSource.acceptedUserCount as acceptedUserCount">{{
acceptedUserCount
}}</span>
</bit-toggle> </bit-toggle>
<bit-toggle [value]="userStatusType.Revoked"> <bit-toggle [value]="userStatusType.Revoked">
{{ "revoked" | i18n }} {{ "revoked" | i18n }}
<span bitBadge variant="info" *ngIf="revokedCount">{{ revokedCount }}</span> <span bitBadge variant="info" *ngIf="dataSource.revokedUserCount as revokedCount">{{
revokedCount
}}</span>
</bit-toggle> </bit-toggle>
</bit-toggle-group> </bit-toggle-group>
</div> </div>
@@ -67,7 +76,7 @@
type="checkbox" type="checkbox"
bitCheckbox bitCheckbox
class="tw-mr-1" class="tw-mr-1"
(change)="selectAll($any($event.target).checked)" (change)="dataSource.checkAllFilteredUsers($any($event.target).checked)"
id="selectAll" id="selectAll"
/> />
<label class="tw-mb-0 !tw-font-bold !tw-text-muted" for="selectAll">{{ <label class="tw-mb-0 !tw-font-bold !tw-text-muted" for="selectAll">{{
@@ -134,7 +143,7 @@
alignContent="middle" alignContent="middle"
[ngClass]="rowHeightClass" [ngClass]="rowHeightClass"
> >
<td bitCell (click)="checkUser(u)"> <td bitCell (click)="dataSource.checkUser(u)">
<input type="checkbox" bitCheckbox [(ngModel)]="$any(u).checked" /> <input type="checkbox" bitCheckbox [(ngModel)]="$any(u).checked" />
</td> </td>
<td bitCell (click)="edit(u)" class="tw-cursor-pointer"> <td bitCell (click)="edit(u)" class="tw-cursor-pointer">
@@ -314,10 +323,4 @@
</cdk-virtual-scroll-viewport> </cdk-virtual-scroll-viewport>
</ng-container> </ng-container>
</ng-container> </ng-container>
<ng-template #addEdit></ng-template>
<ng-template #groupsTemplate></ng-template>
<ng-template #confirmTemplate></ng-template>
<ng-template #resetPasswordTemplate></ng-template> <ng-template #resetPasswordTemplate></ng-template>
<ng-template #bulkStatusTemplate></ng-template>
<ng-template #bulkConfirmTemplate></ng-template>
<ng-template #bulkRemoveTemplate></ng-template>

View File

@@ -37,19 +37,19 @@ import { ProductTierType } from "@bitwarden/common/billing/enums";
import { CryptoService } from "@bitwarden/common/platform/abstractions/crypto.service"; import { CryptoService } from "@bitwarden/common/platform/abstractions/crypto.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.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 { ValidationService } from "@bitwarden/common/platform/abstractions/validation.service";
import { CollectionService } from "@bitwarden/common/vault/abstractions/collection.service"; import { CollectionService } from "@bitwarden/common/vault/abstractions/collection.service";
import { SyncService } from "@bitwarden/common/vault/abstractions/sync/sync.service.abstraction"; import { SyncService } from "@bitwarden/common/vault/abstractions/sync/sync.service.abstraction";
import { CollectionData } from "@bitwarden/common/vault/models/data/collection.data"; import { CollectionData } from "@bitwarden/common/vault/models/data/collection.data";
import { Collection } from "@bitwarden/common/vault/models/domain/collection"; import { Collection } from "@bitwarden/common/vault/models/domain/collection";
import { CollectionDetailsResponse } from "@bitwarden/common/vault/models/response/collection.response"; import { CollectionDetailsResponse } from "@bitwarden/common/vault/models/response/collection.response";
import { DialogService, SimpleDialogOptions } from "@bitwarden/components"; import { DialogService, SimpleDialogOptions, ToastService } from "@bitwarden/components";
import { openEntityEventsDialog } from "../../../admin-console/organizations/manage/entity-events.component";
import { NewBasePeopleComponent } from "../../common/new-base.people.component"; import { NewBasePeopleComponent } from "../../common/new-base.people.component";
import { PeopleTableDataSource } from "../../common/people-table-data-source";
import { GroupService } from "../core"; import { GroupService } from "../core";
import { OrganizationUserView } from "../core/views/organization-user.view"; import { OrganizationUserView } from "../core/views/organization-user.view";
import { openEntityEventsDialog } from "../manage/entity-events.component";
import { BulkConfirmComponent } from "./components/bulk/bulk-confirm.component"; import { BulkConfirmComponent } from "./components/bulk/bulk-confirm.component";
import { BulkEnableSecretsManagerDialogComponent } from "./components/bulk/bulk-enable-sm-dialog.component"; import { BulkEnableSecretsManagerDialogComponent } from "./components/bulk/bulk-enable-sm-dialog.component";
@@ -63,27 +63,21 @@ import {
} from "./components/member-dialog"; } from "./components/member-dialog";
import { ResetPasswordComponent } from "./components/reset-password.component"; import { ResetPasswordComponent } from "./components/reset-password.component";
class MembersTableDataSource extends PeopleTableDataSource<OrganizationUserView> {
protected statusType = OrganizationUserStatusType;
}
@Component({ @Component({
selector: "app-org-people", templateUrl: "members.component.html",
templateUrl: "people.component.html",
}) })
export class PeopleComponent extends NewBasePeopleComponent<OrganizationUserView> { export class MembersComponent extends NewBasePeopleComponent<OrganizationUserView> {
@ViewChild("groupsTemplate", { read: ViewContainerRef, static: true })
groupsModalRef: ViewContainerRef;
@ViewChild("confirmTemplate", { read: ViewContainerRef, static: true })
confirmModalRef: ViewContainerRef;
@ViewChild("resetPasswordTemplate", { read: ViewContainerRef, static: true }) @ViewChild("resetPasswordTemplate", { read: ViewContainerRef, static: true })
resetPasswordModalRef: ViewContainerRef; resetPasswordModalRef: ViewContainerRef;
@ViewChild("bulkStatusTemplate", { read: ViewContainerRef, static: true })
bulkStatusModalRef: ViewContainerRef;
@ViewChild("bulkConfirmTemplate", { read: ViewContainerRef, static: true })
bulkConfirmModalRef: ViewContainerRef;
@ViewChild("bulkRemoveTemplate", { read: ViewContainerRef, static: true })
bulkRemoveModalRef: ViewContainerRef;
userType = OrganizationUserType; userType = OrganizationUserType;
userStatusType = OrganizationUserStatusType; userStatusType = OrganizationUserStatusType;
memberTab = MemberDialogTab; memberTab = MemberDialogTab;
protected dataSource = new MembersTableDataSource();
organization: Organization; organization: Organization;
status: OrganizationUserStatusType = null; status: OrganizationUserStatusType = null;
@@ -98,31 +92,30 @@ export class PeopleComponent extends NewBasePeopleComponent<OrganizationUserView
constructor( constructor(
apiService: ApiService, apiService: ApiService,
private route: ActivatedRoute,
i18nService: I18nService, i18nService: I18nService,
organizationManagementPreferencesService: OrganizationManagementPreferencesService,
modalService: ModalService, modalService: ModalService,
platformUtilsService: PlatformUtilsService,
cryptoService: CryptoService, cryptoService: CryptoService,
validationService: ValidationService, validationService: ValidationService,
private policyService: PolicyService,
private policyApiService: PolicyApiService,
logService: LogService, logService: LogService,
userNamePipe: UserNamePipe, userNamePipe: UserNamePipe,
dialogService: DialogService,
toastService: ToastService,
private policyService: PolicyService,
private policyApiService: PolicyApiService,
private route: ActivatedRoute,
private syncService: SyncService, private syncService: SyncService,
private organizationService: OrganizationService, private organizationService: OrganizationService,
private organizationApiService: OrganizationApiServiceAbstraction, private organizationApiService: OrganizationApiServiceAbstraction,
private organizationUserService: OrganizationUserService, private organizationUserService: OrganizationUserService,
dialogService: DialogService,
private router: Router, private router: Router,
private groupService: GroupService, private groupService: GroupService,
private collectionService: CollectionService, private collectionService: CollectionService,
organizationManagementPreferencesService: OrganizationManagementPreferencesService,
private billingApiService: BillingApiServiceAbstraction, private billingApiService: BillingApiServiceAbstraction,
) { ) {
super( super(
apiService, apiService,
i18nService, i18nService,
platformUtilsService,
cryptoService, cryptoService,
validationService, validationService,
modalService, modalService,
@@ -130,6 +123,7 @@ export class PeopleComponent extends NewBasePeopleComponent<OrganizationUserView
userNamePipe, userNamePipe,
dialogService, dialogService,
organizationManagementPreferencesService, organizationManagementPreferencesService,
toastService,
); );
const organization$ = this.route.params.pipe( const organization$ = this.route.params.pipe(
@@ -293,6 +287,44 @@ export class PeopleComponent extends NewBasePeopleComponent<OrganizationUserView
); );
} }
async revoke(user: OrganizationUserView) {
const confirmed = await this.revokeUserConfirmationDialog(user);
if (!confirmed) {
return false;
}
this.actionPromise = this.revokeUser(user.id);
try {
await this.actionPromise;
this.toastService.showToast({
variant: "success",
title: null,
message: this.i18nService.t("revokedUserId", this.userNamePipe.transform(user)),
});
await this.load();
} catch (e) {
this.validationService.showError(e);
}
this.actionPromise = null;
}
async restore(user: OrganizationUserView) {
this.actionPromise = this.restoreUser(user.id);
try {
await this.actionPromise;
this.toastService.showToast({
variant: "success",
title: null,
message: this.i18nService.t("restoredUserId", this.userNamePipe.transform(user)),
});
await this.load();
} catch (e) {
this.validationService.showError(e);
}
this.actionPromise = null;
}
allowResetPassword(orgUser: OrganizationUserView): boolean { allowResetPassword(orgUser: OrganizationUserView): boolean {
// Hierarchy check // Hierarchy check
let callingUserHasPermission = false; let callingUserHasPermission = false;
@@ -407,12 +439,16 @@ export class PeopleComponent extends NewBasePeopleComponent<OrganizationUserView
} }
async edit(user: OrganizationUserView, initialTab: MemberDialogTab = MemberDialogTab.Role) { async edit(user: OrganizationUserView, initialTab: MemberDialogTab = MemberDialogTab.Role) {
if (!user && this.organization.hasReseller && this.organization.seats === this.confirmedCount) { if (
this.platformUtilsService.showToast( !user &&
"error", this.organization.hasReseller &&
this.i18nService.t("seatLimitReached"), this.organization.seats === this.dataSource.confirmedUserCount
this.i18nService.t("contactYourProvider"), ) {
); this.toastService.showToast({
variant: "error",
title: this.i18nService.t("seatLimitReached"),
message: this.i18nService.t("contactYourProvider"),
});
return; return;
} }
@@ -422,7 +458,7 @@ export class PeopleComponent extends NewBasePeopleComponent<OrganizationUserView
// User attempting to invite new users in a free org with max users // User attempting to invite new users in a free org with max users
if ( if (
!user && !user &&
this.allUsers.length === this.organization.seats && this.dataSource.data.length === this.organization.seats &&
(this.organization.productTierType === ProductTierType.Free || (this.organization.productTierType === ProductTierType.Free ||
this.organization.productTierType === ProductTierType.TeamsStarter) this.organization.productTierType === ProductTierType.TeamsStarter)
) { ) {
@@ -436,18 +472,18 @@ export class PeopleComponent extends NewBasePeopleComponent<OrganizationUserView
name: this.userNamePipe.transform(user), name: this.userNamePipe.transform(user),
organizationId: this.organization.id, organizationId: this.organization.id,
organizationUserId: user != null ? user.id : null, organizationUserId: user != null ? user.id : null,
allOrganizationUserEmails: this.allUsers?.map((user) => user.email) ?? [], allOrganizationUserEmails: this.dataSource.data?.map((user) => user.email) ?? [],
usesKeyConnector: user?.usesKeyConnector, usesKeyConnector: user?.usesKeyConnector,
isOnSecretsManagerStandalone: this.orgIsOnSecretsManagerStandalone, isOnSecretsManagerStandalone: this.orgIsOnSecretsManagerStandalone,
initialTab: initialTab, initialTab: initialTab,
numConfirmedMembers: this.confirmedCount, numConfirmedMembers: this.dataSource.confirmedUserCount,
}, },
}); });
const result = await lastValueFrom(dialog.closed); const result = await lastValueFrom(dialog.closed);
switch (result) { switch (result) {
case MemberDialogResult.Deleted: case MemberDialogResult.Deleted:
this.removeUser(user); this.dataSource.removeUser(user);
break; break;
case MemberDialogResult.Saved: case MemberDialogResult.Saved:
case MemberDialogResult.Revoked: case MemberDialogResult.Revoked:
@@ -467,7 +503,7 @@ export class PeopleComponent extends NewBasePeopleComponent<OrganizationUserView
const dialogRef = BulkRemoveComponent.open(this.dialogService, { const dialogRef = BulkRemoveComponent.open(this.dialogService, {
data: { data: {
organizationId: this.organization.id, organizationId: this.organization.id,
users: this.getCheckedUsers(), users: this.dataSource.getCheckedUsers(),
}, },
}); });
await lastValueFrom(dialogRef.closed); await lastValueFrom(dialogRef.closed);
@@ -489,7 +525,7 @@ export class PeopleComponent extends NewBasePeopleComponent<OrganizationUserView
const ref = BulkRestoreRevokeComponent.open(this.dialogService, { const ref = BulkRestoreRevokeComponent.open(this.dialogService, {
organizationId: this.organization.id, organizationId: this.organization.id,
users: this.getCheckedUsers(), users: this.dataSource.getCheckedUsers(),
isRevoking: isRevoking, isRevoking: isRevoking,
}); });
@@ -502,15 +538,15 @@ export class PeopleComponent extends NewBasePeopleComponent<OrganizationUserView
return; return;
} }
const users = this.getCheckedUsers(); const users = this.dataSource.getCheckedUsers();
const filteredUsers = users.filter((u) => u.status === OrganizationUserStatusType.Invited); const filteredUsers = users.filter((u) => u.status === OrganizationUserStatusType.Invited);
if (filteredUsers.length <= 0) { if (filteredUsers.length <= 0) {
this.platformUtilsService.showToast( this.toastService.showToast({
"error", variant: "error",
this.i18nService.t("errorOccurred"), title: this.i18nService.t("errorOccurred"),
this.i18nService.t("noSelectedUsersApplicable"), message: this.i18nService.t("noSelectedUsersApplicable"),
); });
return; return;
} }
@@ -546,7 +582,7 @@ export class PeopleComponent extends NewBasePeopleComponent<OrganizationUserView
const dialogRef = BulkConfirmComponent.open(this.dialogService, { const dialogRef = BulkConfirmComponent.open(this.dialogService, {
data: { data: {
organizationId: this.organization.id, organizationId: this.organization.id,
users: this.getCheckedUsers(), users: this.dataSource.getCheckedUsers(),
}, },
}); });
@@ -555,14 +591,14 @@ export class PeopleComponent extends NewBasePeopleComponent<OrganizationUserView
} }
async bulkEnableSM() { async bulkEnableSM() {
const users = this.getCheckedUsers().filter((ou) => !ou.accessSecretsManager); const users = this.dataSource.getCheckedUsers().filter((ou) => !ou.accessSecretsManager);
if (users.length === 0) { if (users.length === 0) {
this.platformUtilsService.showToast( this.toastService.showToast({
"error", variant: "error",
this.i18nService.t("errorOccurred"), title: this.i18nService.t("errorOccurred"),
this.i18nService.t("noSelectedUsersApplicable"), message: this.i18nService.t("noSelectedUsersApplicable"),
); });
return; return;
} }
@@ -572,7 +608,7 @@ export class PeopleComponent extends NewBasePeopleComponent<OrganizationUserView
}); });
await lastValueFrom(dialogRef.closed); await lastValueFrom(dialogRef.closed);
this.selectAll(false); this.dataSource.uncheckAllUsers();
await this.load(); await this.load();
} }
@@ -637,7 +673,7 @@ export class PeopleComponent extends NewBasePeopleComponent<OrganizationUserView
protected async revokeUserConfirmationDialog(user: OrganizationUserView) { protected async revokeUserConfirmationDialog(user: OrganizationUserView) {
const confirmed = await this.dialogService.openSimpleDialog({ const confirmed = await this.dialogService.openSimpleDialog({
title: { key: "revokeAccess", placeholders: [this.userNamePipe.transform(user)] }, title: { key: "revokeAccess", placeholders: [this.userNamePipe.transform(user)] },
content: this.revokeWarningMessage(), content: this.i18nService.t("revokeUserConfirmation"),
acceptButtonText: { key: "revokeAccess" }, acceptButtonText: { key: "revokeAccess" },
type: "warning", type: "warning",
}); });

View File

@@ -14,7 +14,7 @@ import { BulkStatusComponent } from "./components/bulk/bulk-status.component";
import { UserDialogModule } from "./components/member-dialog"; import { UserDialogModule } from "./components/member-dialog";
import { ResetPasswordComponent } from "./components/reset-password.component"; import { ResetPasswordComponent } from "./components/reset-password.component";
import { MembersRoutingModule } from "./members-routing.module"; import { MembersRoutingModule } from "./members-routing.module";
import { PeopleComponent } from "./people.component"; import { MembersComponent } from "./members.component";
@NgModule({ @NgModule({
imports: [ imports: [
@@ -31,7 +31,7 @@ import { PeopleComponent } from "./people.component";
BulkRemoveComponent, BulkRemoveComponent,
BulkRestoreRevokeComponent, BulkRestoreRevokeComponent,
BulkStatusComponent, BulkStatusComponent,
PeopleComponent, MembersComponent,
ResetPasswordComponent, ResetPasswordComponent,
], ],
}) })