refactor WIP

This commit is contained in:
Brandon
2025-12-05 16:45:56 -05:00
parent 17ebae11d7
commit ca2ec3bb45
5 changed files with 871 additions and 694 deletions

View File

@@ -1,5 +1,7 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { Observable, Subject } from "rxjs";
import {
OrganizationUserStatusType,
ProviderUserStatusType,
@@ -83,12 +85,19 @@ export abstract class PeopleTableDataSource<T extends UserViewTypes> extends Tab
*/
checkUser(user: T, select?: boolean) {
(user as any).checked = select == null ? !(user as any).checked : select;
this.checkedUsersUpdated$.next();
}
getCheckedUsers() {
return this.data.filter((u) => (u as any).checked);
}
private checkedUsersUpdated$ = new Subject<void>();
usersUpdated(): Observable<void> {
return this.checkedUsersUpdated$.asObservable();
}
/**
* 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)

View File

@@ -1,4 +1,15 @@
@let organization = this.organization();
@let bulkActions = bulkFlags$ | async;
@if (!organization || !dataSource) {
<i
class="bwi bwi-spinner bwi-spin tw-text-muted"
title="{{ 'loading' | i18n }}"
aria-hidden="true"
></i>
<span class="tw-sr-only">{{ "loading" | i18n }}</span>
}
@if (organization) {
<app-organization-free-trial-warning
[organization]="organization"
@@ -12,173 +23,167 @@
[placeholder]="'searchMembers' | i18n"
></bit-search>
<button
type="button"
bitButton
buttonType="primary"
(click)="invite(organization)"
[disabled]="!firstLoaded"
*ngIf="showUserManagementControls()"
>
<i class="bwi bwi-plus bwi-fw" aria-hidden="true"></i>
{{ "inviteMember" | i18n }}
</button>
@if (showUserManagementControls()) {
<button
type="button"
bitButton
buttonType="primary"
(click)="invite(organization)"
[disabled]="!firstLoaded()"
>
<i class="bwi bwi-plus bwi-fw" aria-hidden="true"></i>
{{ "inviteMember" | i18n }}
</button>
}
</app-header>
<div class="tw-mb-4 tw-flex tw-flex-col tw-space-y-4">
<bit-toggle-group
[selected]="status"
(selectedChange)="statusToggle.next($event)"
[attr.aria-label]="'memberStatusFilter' | i18n"
*ngIf="showUserManagementControls()"
>
<bit-toggle [value]="null">
{{ "all" | i18n }}
<span bitBadge variant="info" *ngIf="dataSource.activeUserCount as allCount">{{
allCount
}}</span>
</bit-toggle>
@if (showUserManagementControls()) {
<bit-toggle-group
[selected]="statusToggle | async"
(selectedChange)="statusToggle.next($event)"
[attr.aria-label]="'memberStatusFilter' | i18n"
>
<bit-toggle [value]="undefined">
{{ "all" | i18n }}
@if (dataSource.activeUserCount; as allCount) {
<span bitBadge variant="info">{{ allCount }}</span>
}
</bit-toggle>
<bit-toggle [value]="userStatusType.Invited">
{{ "invited" | i18n }}
<span bitBadge variant="info" *ngIf="dataSource.invitedUserCount as invitedCount">{{
invitedCount
}}</span>
</bit-toggle>
<bit-toggle [value]="userStatusType.Invited">
{{ "invited" | i18n }}
@if (dataSource.invitedUserCount; as invitedCount) {
<span bitBadge variant="info">{{ invitedCount }}</span>
}
</bit-toggle>
<bit-toggle [value]="userStatusType.Accepted">
{{ "needsConfirmation" | i18n }}
<span bitBadge variant="info" *ngIf="dataSource.acceptedUserCount as acceptedUserCount">{{
acceptedUserCount
}}</span>
</bit-toggle>
<bit-toggle [value]="userStatusType.Accepted">
{{ "needsConfirmation" | i18n }}
@if (dataSource.acceptedUserCount; as acceptedUserCount) {
<span bitBadge variant="info">{{ acceptedUserCount }}</span>
}
</bit-toggle>
<bit-toggle [value]="userStatusType.Revoked">
{{ "revoked" | i18n }}
<span bitBadge variant="info" *ngIf="dataSource.revokedUserCount as revokedCount">{{
revokedCount
}}</span>
</bit-toggle>
</bit-toggle-group>
<bit-toggle [value]="userStatusType.Revoked">
{{ "revoked" | i18n }}
@if (dataSource.revokedUserCount; as revokedCount) {
<span bitBadge variant="info">{{ revokedCount }}</span>
}
</bit-toggle>
</bit-toggle-group>
}
</div>
<ng-container *ngIf="!firstLoaded">
@if (!firstLoaded()) {
<i
class="bwi bwi-spinner bwi-spin tw-text-muted"
title="{{ 'loading' | i18n }}"
aria-hidden="true"
></i>
<span class="tw-sr-only">{{ "loading" | i18n }}</span>
</ng-container>
<ng-container *ngIf="firstLoaded">
<p *ngIf="!dataSource.filteredData.length">{{ "noMembersInList" | i18n }}</p>
<ng-container *ngIf="dataSource.filteredData.length">
<bit-callout
type="info"
title="{{ 'confirmUsers' | i18n }}"
icon="bwi-check-circle"
*ngIf="showConfirmUsers"
>
{{ "usersNeedConfirmed" | i18n }}
</bit-callout>
} @else {
@if (!dataSource.filteredData?.length) {
<p>{{ "noMembersInList" | i18n }}</p>
}
@if (dataSource.filteredData?.length) {
@if (bulkActions.showConfirmUsers) {
<bit-callout type="info" title="{{ 'confirmUsers' | i18n }}" icon="bwi-check-circle">
{{ "usersNeedConfirmed" | i18n }}
</bit-callout>
}
<!-- The padding on the bottom of the cdk-virtual-scroll-viewport element is required to prevent table row content
from overflowing the <main> element. -->
<cdk-virtual-scroll-viewport bitScrollLayout [itemSize]="rowHeight" class="tw-pb-8">
<bit-table [dataSource]="dataSource">
<ng-container header>
<tr>
<th bitCell class="tw-w-20" *ngIf="showUserManagementControls()">
<input
type="checkbox"
bitCheckbox
class="tw-mr-1"
(change)="dataSource.checkAllFilteredUsers($any($event.target).checked)"
id="selectAll"
/>
<label class="tw-mb-0 !tw-font-medium !tw-text-muted" for="selectAll">{{
"all" | i18n
}}</label>
</th>
@if (showUserManagementControls()) {
<th bitCell class="tw-w-20">
<input
type="checkbox"
bitCheckbox
class="tw-mr-1"
(change)="dataSource.checkAllFilteredUsers($any($event.target).checked)"
id="selectAll"
/>
<label class="tw-mb-0 !tw-font-medium !tw-text-muted" for="selectAll">{{
"all" | i18n
}}</label>
</th>
}
<th bitCell bitSortable="email" default>{{ "name" | i18n }}</th>
<th bitCell>{{ (organization.useGroups ? "groups" : "collections") | i18n }}</th>
<th bitCell bitSortable="type">{{ "role" | i18n }}</th>
<th bitCell>{{ "policies" | i18n }}</th>
<th bitCell class="tw-w-10">
<button
[bitMenuTriggerFor]="headerMenu"
type="button"
bitIconButton="bwi-ellipsis-v"
size="small"
label="{{ 'options' | i18n }}"
*ngIf="showUserManagementControls()"
></button>
@if (showUserManagementControls()) {
<button
[bitMenuTriggerFor]="headerMenu"
type="button"
bitIconButton="bwi-ellipsis-v"
size="small"
label="{{ 'options' | i18n }}"
></button>
}
<bit-menu #headerMenu>
<ng-container *ngIf="canUseSecretsManager()">
@if (canUseSecretsManager()) {
<button type="button" bitMenuItem (click)="bulkEnableSM(organization)">
{{ "activateSecretsManager" | i18n }}
</button>
<bit-menu-divider></bit-menu-divider>
</ng-container>
<button
type="button"
bitMenuItem
(click)="bulkReinvite(organization)"
*ngIf="showBulkReinviteUsers"
>
<i class="bwi bwi-fw bwi-envelope" aria-hidden="true"></i>
{{ "reinviteSelected" | i18n }}
</button>
<button
type="button"
bitMenuItem
(click)="bulkConfirm(organization)"
*ngIf="showBulkConfirmUsers"
>
<span class="tw-text-success">
<i class="bwi bwi-fw bwi-check" aria-hidden="true"></i>
{{ "confirmSelected" | i18n }}
</span>
</button>
<button
type="button"
bitMenuItem
(click)="bulkRestore(organization)"
*ngIf="showBulkRestoreUsers"
>
<i class="bwi bwi-fw bwi-plus-circle" aria-hidden="true"></i>
{{ "restoreAccess" | i18n }}
</button>
<button
type="button"
bitMenuItem
(click)="bulkRevoke(organization)"
*ngIf="showBulkRevokeUsers"
>
<i class="bwi bwi-fw bwi-minus-circle" aria-hidden="true"></i>
{{ "revokeAccess" | i18n }}
</button>
<button
type="button"
bitMenuItem
(click)="bulkRemove(organization)"
*ngIf="showBulkRemoveUsers"
>
<span class="tw-text-danger">
<i aria-hidden="true" class="bwi bwi-fw bwi-close"></i>
{{ "remove" | i18n }}
</span>
</button>
<button
type="button"
bitMenuItem
(click)="bulkDelete(organization)"
*ngIf="showBulkDeleteUsers"
>
<span class="tw-text-danger">
<i aria-hidden="true" class="bwi bwi-fw bwi-trash"></i>
{{ "delete" | i18n }}
</span>
</button>
}
@if (bulkActions.showBulkReinviteUsers) {
<button type="button" bitMenuItem (click)="bulkReinvite(organization)">
<i class="bwi bwi-fw bwi-envelope" aria-hidden="true"></i>
{{ "reinviteSelected" | i18n }}
</button>
}
@if (bulkActions.showBulkConfirmUsers) {
<button type="button" bitMenuItem (click)="bulkConfirm(organization)">
<span class="tw-text-success">
<i class="bwi bwi-fw bwi-check" aria-hidden="true"></i>
{{ "confirmSelected" | i18n }}
</span>
</button>
}
@if (bulkActions.showBulkRestoreUsers) {
<button
type="button"
bitMenuItem
(click)="bulkRevokeOrRestore(false, organization)"
>
<i class="bwi bwi-fw bwi-plus-circle" aria-hidden="true"></i>
{{ "restoreAccess" | i18n }}
</button>
}
@if (bulkActions.showBulkRevokeUsers) {
<button
type="button"
bitMenuItem
(click)="bulkRevokeOrRestore(true, organization)"
>
<i class="bwi bwi-fw bwi-minus-circle" aria-hidden="true"></i>
{{ "revokeAccess" | i18n }}
</button>
}
@if (bulkActions.showBulkRemoveUsers) {
<button type="button" bitMenuItem (click)="bulkRemove(organization)">
<span class="tw-text-danger">
<i aria-hidden="true" class="bwi bwi-fw bwi-close"></i>
{{ "remove" | i18n }}
</span>
</button>
}
@if (bulkActions.showBulkDeleteUsers) {
<button type="button" bitMenuItem (click)="bulkDelete(organization)">
<span class="tw-text-danger">
<i aria-hidden="true" class="bwi bwi-fw bwi-trash"></i>
{{ "delete" | i18n }}
</span>
</button>
}
</bit-menu>
</th>
</tr>
@@ -190,10 +195,12 @@
alignContent="middle"
[ngClass]="rowHeightClass"
>
<td bitCell (click)="dataSource.checkUser(u)" *ngIf="showUserManagementControls()">
<input type="checkbox" bitCheckbox [(ngModel)]="$any(u).checked" />
</td>
<ng-container *ngIf="showUserManagementControls(); else readOnlyUserInfo">
@if (showUserManagementControls()) {
<td bitCell (click)="dataSource.checkUser(u)">
<input type="checkbox" bitCheckbox [(ngModel)]="$any(u).checked" />
</td>
}
@if (showUserManagementControls()) {
<td bitCell (click)="edit(u, organization)" class="tw-cursor-pointer">
<div class="tw-flex tw-items-center">
<bit-avatar
@@ -208,39 +215,31 @@
<button type="button" bitLink>
{{ u.name ?? u.email }}
</button>
<span
bitBadge
class="tw-text-xs"
variant="secondary"
*ngIf="u.status === userStatusType.Invited"
>
{{ "invited" | i18n }}
</span>
<span
bitBadge
class="tw-text-xs"
variant="warning"
*ngIf="u.status === userStatusType.Accepted"
>
{{ "needsConfirmation" | i18n }}
</span>
<span
bitBadge
class="tw-text-xs"
variant="secondary"
*ngIf="u.status === userStatusType.Revoked"
>
{{ "revoked" | i18n }}
</span>
</div>
<div class="tw-text-sm tw-text-muted" *ngIf="u.name">
{{ u.email }}
@if (u.status === userStatusType.Invited) {
<span bitBadge class="tw-text-xs" variant="secondary">
{{ "invited" | i18n }}
</span>
}
@if (u.status === userStatusType.Accepted) {
<span bitBadge class="tw-text-xs" variant="warning">
{{ "needsConfirmation" | i18n }}
</span>
}
@if (u.status === userStatusType.Revoked) {
<span bitBadge class="tw-text-xs" variant="secondary">
{{ "revoked" | i18n }}
</span>
}
</div>
@if (u.name) {
<div class="tw-text-sm tw-text-muted">
{{ u.email }}
</div>
}
</div>
</div>
</td>
</ng-container>
<ng-template #readOnlyUserInfo>
} @else {
<td bitCell>
<div class="tw-flex tw-items-center">
<bit-avatar
@@ -253,40 +252,33 @@
<div class="tw-flex tw-flex-col">
<div class="tw-flex tw-flex-row tw-gap-2">
<span>{{ u.name ?? u.email }}</span>
<span
bitBadge
class="tw-text-xs"
variant="secondary"
*ngIf="u.status === userStatusType.Invited"
>
{{ "invited" | i18n }}
</span>
<span
bitBadge
class="tw-text-xs"
variant="warning"
*ngIf="u.status === userStatusType.Accepted"
>
{{ "needsConfirmation" | i18n }}
</span>
<span
bitBadge
class="tw-text-xs"
variant="secondary"
*ngIf="u.status === userStatusType.Revoked"
>
{{ "revoked" | i18n }}
</span>
</div>
<div class="tw-text-sm tw-text-muted" *ngIf="u.name">
{{ u.email }}
@if (u.status === userStatusType.Invited) {
<span bitBadge class="tw-text-xs" variant="secondary">
{{ "invited" | i18n }}
</span>
}
@if (u.status === userStatusType.Accepted) {
<span bitBadge class="tw-text-xs" variant="warning">
{{ "needsConfirmation" | i18n }}
</span>
}
@if (u.status === userStatusType.Revoked) {
<span bitBadge class="tw-text-xs" variant="secondary">
{{ "revoked" | i18n }}
</span>
}
</div>
@if (u.name) {
<div class="tw-text-sm tw-text-muted">
{{ u.email }}
</div>
}
</div>
</div>
</td>
</ng-template>
}
<ng-container *ngIf="showUserManagementControls(); else readOnlyGroupsCell">
@if (showUserManagementControls()) {
<td
bitCell
(click)="
@@ -304,8 +296,7 @@
variant="secondary"
></bit-badge-list>
</td>
</ng-container>
<ng-template #readOnlyGroupsCell>
} @else {
<td bitCell>
<bit-badge-list
[items]="organization.useGroups ? u.groupNames : u.collectionNames"
@@ -313,9 +304,9 @@
variant="secondary"
></bit-badge-list>
</td>
</ng-template>
}
<ng-container *ngIf="showUserManagementControls(); else readOnlyRoleCell">
@if (showUserManagementControls()) {
<td
bitCell
(click)="edit(u, organization, memberTab.Role)"
@@ -323,33 +314,30 @@
>
{{ u.type | userType }}
</td>
</ng-container>
<ng-template #readOnlyRoleCell>
} @else {
<td bitCell class="tw-text-sm tw-text-muted">
{{ u.type | userType }}
</td>
</ng-template>
}
<td bitCell class="tw-text-muted">
<ng-container *ngIf="u.twoFactorEnabled">
@if (u.twoFactorEnabled) {
<i
class="bwi bwi-lock"
title="{{ 'userUsingTwoStep' | i18n }}"
aria-hidden="true"
></i>
<span class="tw-sr-only">{{ "userUsingTwoStep" | i18n }}</span>
</ng-container>
}
@let resetPasswordPolicyEnabled = resetPasswordPolicyEnabled$ | async;
<ng-container
*ngIf="showEnrolledStatus($any(u), organization, resetPasswordPolicyEnabled)"
>
@if (showEnrolledStatus($any(u), organization, resetPasswordPolicyEnabled)) {
<i
class="bwi bwi-key"
title="{{ 'enrolledAccountRecovery' | i18n }}"
aria-hidden="true"
></i>
<span class="tw-sr-only">{{ "enrolledAccountRecovery" | i18n }}</span>
</ng-container>
}
</td>
<td bitCell>
<button
@@ -361,31 +349,25 @@
></button>
<bit-menu #rowMenu>
<ng-container *ngIf="showUserManagementControls()">
<button
type="button"
bitMenuItem
(click)="reinvite(u, organization)"
*ngIf="u.status === userStatusType.Invited"
>
<i aria-hidden="true" class="bwi bwi-envelope"></i>
{{ "resendInvitation" | i18n }}
</button>
<button
type="button"
bitMenuItem
(click)="confirm(u, organization)"
*ngIf="u.status === userStatusType.Accepted"
>
<span class="tw-text-success">
<i aria-hidden="true" class="bwi bwi-check"></i> {{ "confirm" | i18n }}
</span>
</button>
<bit-menu-divider
*ngIf="
u.status === userStatusType.Accepted || u.status === userStatusType.Invited
"
></bit-menu-divider>
@if (showUserManagementControls()) {
@if (u.status === userStatusType.Invited) {
<button type="button" bitMenuItem (click)="reinvite(u, organization)">
<i aria-hidden="true" class="bwi bwi-envelope"></i>
{{ "resendInvitation" | i18n }}
</button>
}
@if (u.status === userStatusType.Accepted) {
<button type="button" bitMenuItem (click)="confirm(u, organization)">
<span class="tw-text-success">
<i aria-hidden="true" class="bwi bwi-check"></i> {{ "confirm" | i18n }}
</span>
</button>
}
@if (
u.status === userStatusType.Accepted || u.status === userStatusType.Invited
) {
<bit-menu-divider></bit-menu-divider>
}
<button
type="button"
bitMenuItem
@@ -393,14 +375,15 @@
>
<i aria-hidden="true" class="bwi bwi-user"></i> {{ "memberRole" | i18n }}
</button>
<button
type="button"
bitMenuItem
(click)="edit(u, organization, memberTab.Groups)"
*ngIf="organization.useGroups"
>
<i aria-hidden="true" class="bwi bwi-users"></i> {{ "groups" | i18n }}
</button>
@if (organization.useGroups) {
<button
type="button"
bitMenuItem
(click)="edit(u, organization, memberTab.Groups)"
>
<i aria-hidden="true" class="bwi bwi-users"></i> {{ "groups" | i18n }}
</button>
}
<button
type="button"
bitMenuItem
@@ -410,73 +393,56 @@
{{ "collections" | i18n }}
</button>
<bit-menu-divider></bit-menu-divider>
<button
type="button"
bitMenuItem
(click)="openEventsDialog(u, organization)"
*ngIf="organization.useEvents && u.status === userStatusType.Confirmed"
>
<i aria-hidden="true" class="bwi bwi-file-text"></i> {{ "eventLogs" | i18n }}
</button>
</ng-container>
@if (organization.useEvents && u.status === userStatusType.Confirmed) {
<button type="button" bitMenuItem (click)="openEventsDialog(u, organization)">
<i aria-hidden="true" class="bwi bwi-file-text"></i>
{{ "eventLogs" | i18n }}
</button>
}
}
<!-- Account recovery is available to all users with appropriate permissions -->
<button
type="button"
bitMenuItem
(click)="resetPassword(u, organization)"
*ngIf="allowResetPassword(u, organization, resetPasswordPolicyEnabled)"
>
<i aria-hidden="true" class="bwi bwi-key"></i> {{ "recoverAccount" | i18n }}
</button>
@if (allowResetPassword(u, organization, resetPasswordPolicyEnabled)) {
<button type="button" bitMenuItem (click)="resetPassword(u, organization)">
<i aria-hidden="true" class="bwi bwi-key"></i> {{ "recoverAccount" | i18n }}
</button>
}
<ng-container *ngIf="showUserManagementControls()">
<button
type="button"
bitMenuItem
(click)="restore(u, organization)"
*ngIf="u.status === userStatusType.Revoked"
>
<i aria-hidden="true" class="bwi bwi-plus-circle"></i>
{{ "restoreAccess" | i18n }}
</button>
<button
type="button"
bitMenuItem
(click)="revoke(u, organization)"
*ngIf="u.status !== userStatusType.Revoked"
>
<i aria-hidden="true" class="bwi bwi-minus-circle"></i>
{{ "revokeAccess" | i18n }}
</button>
<button
*ngIf="!u.managedByOrganization"
type="button"
bitMenuItem
(click)="remove(u, organization)"
>
<span class="tw-text-danger">
<i aria-hidden="true" class="bwi bwi-close"></i> {{ "remove" | i18n }}
</span>
</button>
<button
*ngIf="u.managedByOrganization"
type="button"
bitMenuItem
(click)="deleteUser(u, organization)"
>
<span class="tw-text-danger">
<i class="bwi bwi-trash" aria-hidden="true"></i>
{{ "delete" | i18n }}
</span>
</button>
</ng-container>
@if (showUserManagementControls()) {
@if (u.status === userStatusType.Revoked) {
<button type="button" bitMenuItem (click)="restore(u, organization)">
<i aria-hidden="true" class="bwi bwi-plus-circle"></i>
{{ "restoreAccess" | i18n }}
</button>
}
@if (u.status !== userStatusType.Revoked) {
<button type="button" bitMenuItem (click)="revoke(u, organization)">
<i aria-hidden="true" class="bwi bwi-minus-circle"></i>
{{ "revokeAccess" | i18n }}
</button>
}
@if (!u.managedByOrganization) {
<button type="button" bitMenuItem (click)="remove(u, organization)">
<span class="tw-text-danger">
<i aria-hidden="true" class="bwi bwi-close"></i> {{ "remove" | i18n }}
</span>
</button>
}
@if (u.managedByOrganization) {
<button type="button" bitMenuItem (click)="deleteUser(u, organization)">
<span class="tw-text-danger">
<i class="bwi bwi-trash" aria-hidden="true"></i>
{{ "delete" | i18n }}
</span>
</button>
}
}
</bit-menu>
</td>
</tr>
</ng-template>
</bit-table>
</cdk-virtual-scroll-viewport>
</ng-container>
</ng-container>
}
}
}

View File

@@ -1,16 +1,29 @@
import { Component, computed, Signal } from "@angular/core";
import {
ChangeDetectionStrategy,
ChangeDetectorRef,
Component,
computed,
inject,
signal,
Signal,
} from "@angular/core";
import { takeUntilDestroyed, toSignal } from "@angular/core/rxjs-interop";
import { FormControl } from "@angular/forms";
import { ActivatedRoute } from "@angular/router";
import {
BehaviorSubject,
combineLatest,
concatMap,
debounceTime,
filter,
firstValueFrom,
from,
lastValueFrom,
map,
merge,
Observable,
shareReplay,
startWith,
switchMap,
take,
} from "rxjs";
@@ -37,15 +50,16 @@ import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.servic
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { ValidationService } from "@bitwarden/common/platform/abstractions/validation.service";
import { getById } from "@bitwarden/common/platform/misc";
import { Utils } from "@bitwarden/common/platform/misc/utils";
import { DialogService, ToastService } from "@bitwarden/components";
import { KeyService } from "@bitwarden/key-management";
import { UserId } from "@bitwarden/user-core";
import { BillingConstraintService } from "@bitwarden/web-vault/app/billing/members/billing-constraint/billing-constraint.service";
import { OrganizationWarningsService } from "@bitwarden/web-vault/app/billing/organizations/warnings/services";
import { BaseMembersComponent } from "../../common/base-members.component";
import { PeopleTableDataSource } from "../../common/people-table-data-source";
import { PeopleTableDataSource, peopleFilter } from "../../common/people-table-data-source";
import { OrganizationUserView } from "../core/views/organization-user.view";
import { UserConfirmComponent } from "../manage/user-confirm.component";
import { AccountRecoveryDialogResultType } from "./components/account-recovery/account-recovery-dialog.component";
import { MemberDialogResult, MemberDialogTab } from "./components/member-dialog";
@@ -56,75 +70,145 @@ import {
MemberActionResult,
} from "./services/member-actions/member-actions.service";
interface BulkFlags {
showConfirmUsers: boolean;
showBulkConfirmUsers: boolean;
showBulkReinviteUsers: boolean;
showBulkRestoreUsers: boolean;
showBulkRevokeUsers: boolean;
showBulkRemoveUsers: boolean;
showBulkDeleteUsers: boolean;
}
class MembersTableDataSource extends PeopleTableDataSource<OrganizationUserView> {
protected statusType = OrganizationUserStatusType;
}
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
@Component({
templateUrl: "members.component.html",
standalone: false,
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class MembersComponent extends BaseMembersComponent<OrganizationUserView> {
userType = OrganizationUserType;
userStatusType = OrganizationUserStatusType;
memberTab = MemberDialogTab;
protected dataSource = new MembersTableDataSource();
readonly organization: Signal<Organization | undefined>;
status: OrganizationUserStatusType | undefined;
export class MembersComponent {
protected apiService = inject(ApiService);
protected i18nService = inject(I18nService);
protected organizationManagementPreferencesService = inject(
OrganizationManagementPreferencesService,
);
protected keyService = inject(KeyService);
protected validationService = inject(ValidationService);
protected logService = inject(LogService);
protected userNamePipe = inject(UserNamePipe);
protected dialogService = inject(DialogService);
protected toastService = inject(ToastService);
private route = inject(ActivatedRoute);
protected deleteManagedMemberWarningService = inject(DeleteManagedMemberWarningService);
private organizationWarningsService = inject(OrganizationWarningsService);
private memberActionsService = inject(MemberActionsService);
private memberDialogManager = inject(MemberDialogManagerService);
protected billingConstraint = inject(BillingConstraintService);
protected memberService = inject(OrganizationMembersService);
private organizationService = inject(OrganizationService);
private accountService = inject(AccountService);
private policyService = inject(PolicyService);
private policyApiService = inject(PolicyApiServiceAbstraction);
private organizationMetadataService = inject(OrganizationMetadataServiceAbstraction);
private changeDetectionRef = inject(ChangeDetectorRef);
private userId$: Observable<UserId> = this.accountService.activeAccount$.pipe(getUserId);
resetPasswordPolicyEnabled$: Observable<boolean>;
protected userType = OrganizationUserType;
protected userStatusType = OrganizationUserStatusType;
protected memberTab = MemberDialogTab;
protected dataSource = new MembersTableDataSource();
protected searchControl = new FormControl("", { nonNullable: true });
protected statusToggle = new BehaviorSubject<OrganizationUserStatusType | undefined>(undefined);
protected readonly organization: Signal<Organization | undefined>;
protected readonly firstLoaded = signal(false);
bulkFlags$: Observable<BulkFlags> = this.dataSource.usersUpdated().pipe(
startWith(null), // initial emission to kick off reactive member options menu actions
map(() => {
const checkedUsers = this.dataSource.getCheckedUsers();
const result = {
showConfirmUsers: true,
showBulkConfirmUsers: true,
showBulkReinviteUsers: true,
showBulkRestoreUsers: true,
showBulkRevokeUsers: true,
showBulkRemoveUsers: true,
showBulkDeleteUsers: true,
};
if (checkedUsers.length) {
checkedUsers.forEach((member) => {
if (member.status !== this.userStatusType.Accepted) {
result.showBulkConfirmUsers = false;
}
if (member.status !== this.userStatusType.Invited) {
result.showBulkReinviteUsers = false;
}
if (member.status !== this.userStatusType.Revoked) {
result.showBulkRestoreUsers = false;
}
if (member.status == this.userStatusType.Revoked) {
result.showBulkRevokeUsers = false;
}
result.showBulkRemoveUsers = !member.managedByOrganization;
const validStatuses = [
this.userStatusType.Accepted,
this.userStatusType.Confirmed,
this.userStatusType.Revoked,
];
result.showBulkDeleteUsers =
member.managedByOrganization && validStatuses.includes(member.status);
});
}
result.showConfirmUsers =
this.dataSource.activeUserCount > 1 &&
this.dataSource.confirmedUserCount > 0 &&
this.dataSource.confirmedUserCount < 3 &&
this.dataSource.acceptedUserCount > 0;
return result;
}),
);
protected readonly canUseSecretsManager: Signal<boolean> = computed(
() => this.organization()?.useSecretsManager ?? false,
);
protected readonly showUserManagementControls: Signal<boolean> = computed(
() => this.organization()?.canManageUsers ?? false,
);
protected billingMetadata$: Observable<OrganizationBillingMetadataResponse>;
protected resetPasswordPolicyEnabled$: Observable<boolean>;
// Fixed sizes used for cdkVirtualScroll
protected rowHeight = 66;
protected rowHeightClass = `tw-h-[66px]`;
constructor(
apiService: ApiService,
i18nService: I18nService,
organizationManagementPreferencesService: OrganizationManagementPreferencesService,
keyService: KeyService,
validationService: ValidationService,
logService: LogService,
userNamePipe: UserNamePipe,
dialogService: DialogService,
toastService: ToastService,
private route: ActivatedRoute,
protected deleteManagedMemberWarningService: DeleteManagedMemberWarningService,
private organizationWarningsService: OrganizationWarningsService,
private memberActionsService: MemberActionsService,
private memberDialogManager: MemberDialogManagerService,
protected billingConstraint: BillingConstraintService,
protected memberService: OrganizationMembersService,
private organizationService: OrganizationService,
private accountService: AccountService,
private policyService: PolicyService,
private policyApiService: PolicyApiServiceAbstraction,
private organizationMetadataService: OrganizationMetadataServiceAbstraction,
) {
super(
apiService,
i18nService,
keyService,
validationService,
logService,
userNamePipe,
dialogService,
organizationManagementPreferencesService,
toastService,
);
constructor() {
this.dataSource
.connect()
.pipe(takeUntilDestroyed())
.subscribe(() => {
this.changeDetectionRef.markForCheck();
});
combineLatest([this.searchControl.valueChanges.pipe(debounceTime(200)), this.statusToggle])
.pipe(takeUntilDestroyed())
.subscribe(
([searchText, status]) => (this.dataSource.filter = peopleFilter(searchText, status)),
);
const organization$ = this.route.params.pipe(
concatMap((params) =>
@@ -201,80 +285,118 @@ export class MembersComponent extends BaseMembersComponent<OrganizationUserView>
this.billingMetadata$.pipe(take(1), takeUntilDestroyed()).subscribe();
}
override async load(organization: Organization) {
await super.load(organization);
async load(organization: Organization) {
const response = await this.memberService.loadUsers(organization);
this.dataSource.data = Array.isArray(response) ? response : [];
this.firstLoaded.set(true);
}
async getUsers(organization: Organization): Promise<OrganizationUserView[]> {
return await this.memberService.loadUsers(organization);
}
async removeUser(id: string, organization: Organization): Promise<MemberActionResult> {
return await this.memberActionsService.removeUser(organization, id);
}
async revokeUser(id: string, organization: Organization): Promise<MemberActionResult> {
return await this.memberActionsService.revokeUser(organization, id);
}
async restoreUser(id: string, organization: Organization): Promise<MemberActionResult> {
return await this.memberActionsService.restoreUser(organization, id);
}
async reinviteUser(id: string, organization: Organization): Promise<MemberActionResult> {
return await this.memberActionsService.reinviteUser(organization, id);
}
async confirmUser(
user: OrganizationUserView,
publicKey: Uint8Array,
organization: Organization,
): Promise<MemberActionResult> {
return await this.memberActionsService.confirmUser(user, publicKey, organization);
}
async revoke(user: OrganizationUserView, organization: Organization) {
const confirmed = await this.revokeUserConfirmationDialog(user);
async remove(user: OrganizationUserView, organization: Organization) {
const confirmed = await this.memberDialogManager.openRemoveUserConfirmationDialog(user);
if (!confirmed) {
return false;
}
this.actionPromise = this.revokeUser(user.id, organization);
try {
const result = await this.actionPromise;
if (result.success) {
this.toastService.showToast({
variant: "success",
message: this.i18nService.t("revokedUserId", this.userNamePipe.transform(user)),
});
await this.load(organization);
} else {
throw new Error(result.error);
}
const result = await this.memberActionsService.removeUser(organization, user.id);
const sideEffect = () => this.dataSource.removeUser(user);
await this.handleMemberActionResult(result, "removedUserId", user, sideEffect);
} catch (e) {
this.validationService.showError(e);
}
}
async reinvite(user: OrganizationUserView, organization: Organization) {
try {
const result = await this.memberActionsService.reinviteUser(organization, user.id);
await this.handleMemberActionResult(result, "hasBeenReinvited", user);
} catch (e) {
this.validationService.showError(e);
}
}
async confirm(user: OrganizationUserView, organization: Organization) {
const confirmUserSideEffect = () => {
user.status = this.userStatusType.Confirmed;
this.dataSource.replaceUser(user);
};
const confirmUser = async (publicKey: Uint8Array) => {
try {
const result = await this.memberActionsService.confirmUser(user, publicKey, organization);
await this.handleMemberActionResult(
result,
"hasBeenConfirmed",
user,
confirmUserSideEffect,
);
} catch (e) {
this.validationService.showError(e);
throw e;
}
};
try {
const publicKeyResponse = await this.apiService.getUserPublicKey(user.userId);
const publicKey = Utils.fromB64ToArray(publicKeyResponse.publicKey);
const autoConfirm = await firstValueFrom(
this.organizationManagementPreferencesService.autoConfirmFingerPrints.state$,
);
if (user == null) {
throw new Error("Cannot confirm null user.");
}
if (autoConfirm == null || !autoConfirm) {
const dialogRef = UserConfirmComponent.open(this.dialogService, {
data: {
name: this.userNamePipe.transform(user),
userId: user.userId,
publicKey: publicKey,
confirmUser: () => confirmUser(publicKey),
},
});
await lastValueFrom(dialogRef.closed);
return;
}
try {
const fingerprint = await this.keyService.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}`);
}
}
async revoke(user: OrganizationUserView, organization: Organization) {
const confirmed = await this.memberDialogManager.openRevokeUserConfirmationDialog(user);
if (!confirmed) {
return false;
}
try {
const result = await this.memberActionsService.revokeUser(organization, user.id);
const sideEffect = async () => await this.load(organization);
await this.handleMemberActionResult(result, "revokedUserId", user, sideEffect);
} catch (e) {
this.validationService.showError(e);
}
this.actionPromise = undefined;
}
async restore(user: OrganizationUserView, organization: Organization) {
this.actionPromise = this.restoreUser(user.id, organization);
try {
const result = await this.actionPromise;
if (result.success) {
this.toastService.showToast({
variant: "success",
message: this.i18nService.t("restoredUserId", this.userNamePipe.transform(user)),
});
await this.load(organization);
} else {
throw new Error(result.error);
}
const result = await this.memberActionsService.restoreUser(organization, user.id);
const sideEffect = async () => await this.load(organization);
await this.handleMemberActionResult(result, "restoredUserId", user, sideEffect);
} catch (e) {
this.validationService.showError(e);
}
this.actionPromise = undefined;
}
allowResetPassword(
@@ -352,10 +474,6 @@ export class MembersComponent extends BaseMembersComponent<OrganizationUserView>
}
async bulkRemove(organization: Organization) {
if (this.actionPromise != null) {
return;
}
await this.memberDialogManager.openBulkRemoveDialog(
organization,
this.dataSource.getCheckedUsers(),
@@ -365,10 +483,6 @@ export class MembersComponent extends BaseMembersComponent<OrganizationUserView>
}
async bulkDelete(organization: Organization) {
if (this.actionPromise != null) {
return;
}
await this.memberDialogManager.openBulkDeleteDialog(
organization,
this.dataSource.getCheckedUsers(),
@@ -376,19 +490,7 @@ export class MembersComponent extends BaseMembersComponent<OrganizationUserView>
await this.load(organization);
}
async bulkRevoke(organization: Organization) {
await this.bulkRevokeOrRestore(true, organization);
}
async bulkRestore(organization: Organization) {
await this.bulkRevokeOrRestore(false, organization);
}
async bulkRevokeOrRestore(isRevoking: boolean, organization: Organization) {
if (this.actionPromise != null) {
return;
}
await this.memberDialogManager.openBulkRestoreRevokeDialog(
organization,
this.dataSource.getCheckedUsers(),
@@ -398,10 +500,6 @@ export class MembersComponent extends BaseMembersComponent<OrganizationUserView>
}
async bulkReinvite(organization: Organization) {
if (this.actionPromise != null) {
return;
}
const users = this.dataSource.getCheckedUsers();
const filteredUsers = users.filter((u) => u.status === OrganizationUserStatusType.Invited);
@@ -434,14 +532,9 @@ export class MembersComponent extends BaseMembersComponent<OrganizationUserView>
} catch (e) {
this.validationService.showError(e);
}
this.actionPromise = undefined;
}
async bulkConfirm(organization: Organization) {
if (this.actionPromise != null) {
return;
}
await this.memberDialogManager.openBulkConfirmDialog(
organization,
this.dataSource.getCheckedUsers(),
@@ -451,7 +544,6 @@ export class MembersComponent extends BaseMembersComponent<OrganizationUserView>
async bulkEnableSM(organization: Organization) {
const users = this.dataSource.getCheckedUsers();
await this.memberDialogManager.openBulkEnableSecretsManagerDialog(organization, users);
this.dataSource.uncheckAllUsers();
@@ -482,14 +574,6 @@ export class MembersComponent extends BaseMembersComponent<OrganizationUserView>
return;
}
protected async removeUserConfirmationDialog(user: OrganizationUserView) {
return await this.memberDialogManager.openRemoveUserConfirmationDialog(user);
}
protected async revokeUserConfirmationDialog(user: OrganizationUserView) {
return await this.memberDialogManager.openRevokeUserConfirmationDialog(user);
}
async deleteUser(user: OrganizationUserView, organization: Organization) {
const confirmed = await this.memberDialogManager.openDeleteUserConfirmationDialog(
user,
@@ -500,48 +584,32 @@ export class MembersComponent extends BaseMembersComponent<OrganizationUserView>
return false;
}
this.actionPromise = this.memberActionsService.deleteUser(organization, user.id);
try {
const result = await this.actionPromise;
if (!result.success) {
throw new Error(result.error);
}
this.toastService.showToast({
variant: "success",
message: this.i18nService.t("organizationUserDeleted", this.userNamePipe.transform(user)),
const result = await this.memberActionsService.deleteUser(organization, user.id);
await this.handleMemberActionResult(result, "organizationUserDeleted", user, () => {
this.dataSource.removeUser(user);
});
this.dataSource.removeUser(user);
} catch (e) {
this.validationService.showError(e);
}
this.actionPromise = undefined;
}
get showBulkRestoreUsers(): boolean {
return this.dataSource
.getCheckedUsers()
.every((member) => member.status == this.userStatusType.Revoked);
}
get showBulkRevokeUsers(): boolean {
return this.dataSource
.getCheckedUsers()
.every((member) => member.status != this.userStatusType.Revoked);
}
get showBulkRemoveUsers(): boolean {
return this.dataSource.getCheckedUsers().every((member) => !member.managedByOrganization);
}
get showBulkDeleteUsers(): boolean {
const validStatuses = [
this.userStatusType.Accepted,
this.userStatusType.Confirmed,
this.userStatusType.Revoked,
];
return this.dataSource
.getCheckedUsers()
.every((member) => member.managedByOrganization && validStatuses.includes(member.status));
async handleMemberActionResult(
result: MemberActionResult,
successKey: string,
user: OrganizationUserView,
sideEffect?: () => void | Promise<void>,
) {
if (result.success) {
this.toastService.showToast({
variant: "success",
message: this.i18nService.t(successKey, this.userNamePipe.transform(user)),
});
if (sideEffect) {
await sideEffect();
}
} else {
throw new Error(result.error);
}
}
}

View File

@@ -1,7 +1,8 @@
@let providerId = providerId$ | async;
<app-header>
<bit-search class="tw-grow" [formControl]="searchControl" [placeholder]="'searchMembers' | i18n">
</bit-search>
<button type="button" bitButton buttonType="primary" (click)="invite()">
<button type="button" bitButton buttonType="primary" (click)="invite(providerId)">
<i class="bwi bwi-plus bwi-fw" aria-hidden="true"></i>
{{ "inviteMember" | i18n }}
</button>
@@ -13,28 +14,28 @@
(selectedChange)="statusToggle.next($event)"
[attr.aria-label]="'memberStatusFilter' | i18n"
>
<bit-toggle [value]="null">
<bit-toggle [value]="undefined">
{{ "all" | i18n }}
<span bitBadge variant="info" *ngIf="dataSource.activeUserCount as allCount">
{{ allCount }}
</span>
@if (dataSource.activeUserCount; as allCount) {
<span bitBadge variant="info">{{ allCount }}</span>
}
</bit-toggle>
<bit-toggle [value]="userStatusType.Invited">
{{ "invited" | i18n }}
<span bitBadge variant="info" *ngIf="dataSource.invitedUserCount as invitedCount">
{{ invitedCount }}
</span>
@if (dataSource.invitedUserCount; as invitedCount) {
<span bitBadge variant="info">{{ invitedCount }}</span>
}
</bit-toggle>
<bit-toggle [value]="userStatusType.Accepted">
{{ "needsConfirmation" | i18n }}
<span bitBadge variant="info" *ngIf="dataSource.acceptedUserCount as acceptedCount">
{{ acceptedCount }}
</span>
@if (dataSource.acceptedUserCount; as acceptedCount) {
<span bitBadge variant="info">{{ acceptedCount }}</span>
}
</bit-toggle>
</bit-toggle-group>
</div>
<ng-container *ngIf="!firstLoaded">
@if (!firstLoaded()) {
<i
class="bwi bwi-spinner bwi-spin tw-text-muted"
title="{{ 'loading' | i18n }}"
@@ -42,19 +43,16 @@
>
</i>
<span class="tw-sr-only">{{ "loading" | i18n }}</span>
</ng-container>
<ng-container *ngIf="firstLoaded">
<p *ngIf="!dataSource.filteredData.length">{{ "noMembersInList" | i18n }}</p>
<ng-container *ngIf="dataSource.filteredData.length">
<bit-callout
type="info"
title="{{ 'confirmUsers' | i18n }}"
icon="bwi-check-circle"
*ngIf="showConfirmUsers"
>
{{ "providerUsersNeedConfirmed" | i18n }}
</bit-callout>
} @else {
@if (!dataSource.filteredData?.length) {
<p>{{ "noMembersInList" | i18n }}</p>
}
@if (dataSource.filteredData?.length) {
@if (showConfirmUsers) {
<bit-callout type="info" title="{{ 'confirmUsers' | i18n }}" icon="bwi-check-circle">
{{ "providerUsersNeedConfirmed" | i18n }}
</bit-callout>
}
<cdk-virtual-scroll-viewport bitScrollLayout [itemSize]="rowHeight" class="tw-pb-8">
<bit-table [dataSource]="dataSource">
<ng-container header>
@@ -82,27 +80,21 @@
label="{{ 'options' | i18n }}"
></button>
<bit-menu #headerMenu>
<button
type="button"
bitMenuItem
(click)="bulkReinvite()"
*ngIf="showBulkReinviteUsers"
>
<i class="bwi bwi-fw bwi-envelope" aria-hidden="true"></i>
{{ "reinviteSelected" | i18n }}
</button>
<button
type="button"
bitMenuItem
(click)="bulkConfirm()"
*ngIf="showBulkConfirmUsers"
>
<span class="tw-text-success">
<i class="bwi bwi-fw bwi-check" aria-hidden="true"></i>
{{ "confirmSelected" | i18n }}
</span>
</button>
<button type="button" bitMenuItem (click)="bulkRemove()">
@if (showBulkReinviteUsers) {
<button type="button" bitMenuItem (click)="bulkReinvite(providerId)">
<i class="bwi bwi-fw bwi-envelope" aria-hidden="true"></i>
{{ "reinviteSelected" | i18n }}
</button>
}
@if (showBulkConfirmUsers) {
<button type="button" bitMenuItem (click)="bulkConfirm(providerId)">
<span class="tw-text-success">
<i class="bwi bwi-fw bwi-check" aria-hidden="true"></i>
{{ "confirmSelected" | i18n }}
</span>
</button>
}
<button type="button" bitMenuItem (click)="bulkRemove(providerId)">
<span class="tw-text-danger">
<i aria-hidden="true" class="bwi bwi-close"></i>
{{ "remove" | i18n }}
@@ -122,7 +114,7 @@
<td bitCell (click)="dataSource.checkUser(user)">
<input type="checkbox" bitCheckbox [(ngModel)]="$any(user).checked" />
</td>
<td bitCell (click)="edit(user)" class="tw-cursor-pointer">
<td bitCell (click)="edit(user, providerId)" class="tw-cursor-pointer">
<div class="tw-flex tw-items-center">
<bit-avatar
size="small"
@@ -132,44 +124,41 @@
class="tw-mr-3"
></bit-avatar>
<div class="tw-flex tw-flex-col">
<div>
<div class="tw-flex tw-flex-row tw-gap-2">
<button type="button" bitLink>
{{ user.name ?? user.email }}
</button>
<span
bitBadge
class="tw-text-xs"
variant="secondary"
*ngIf="user.status === userStatusType.Invited"
>
{{ "invited" | i18n }}
</span>
<span
bitBadge
class="tw-text-xs"
variant="warning"
*ngIf="user.status === userStatusType.Accepted"
>
{{ "needsConfirmation" | i18n }}
</span>
<span
bitBadge
class="tw-text-xs"
variant="secondary"
*ngIf="user.status === userStatusType.Revoked"
>
{{ "revoked" | i18n }}
</span>
</div>
<div class="tw-text-sm tw-text-muted" *ngIf="user.name">
{{ user.email }}
@if (user.status === userStatusType.Invited) {
<span bitBadge class="tw-text-xs" variant="secondary">
{{ "invited" | i18n }}
</span>
}
@if (user.status === userStatusType.Accepted) {
<span bitBadge class="tw-text-xs" variant="warning">
{{ "needsConfirmation" | i18n }}
</span>
}
@if (user.status === userStatusType.Revoked) {
<span bitBadge class="tw-text-xs" variant="secondary">
{{ "revoked" | i18n }}
</span>
}
</div>
@if (user.name) {
<div class="tw-text-sm tw-text-muted">
{{ user.email }}
</div>
}
</div>
</div>
</td>
<td bitCell class="tw-text-muted">
<span *ngIf="user.type === userType.ProviderAdmin">{{ "providerAdmin" | i18n }}</span>
<span *ngIf="user.type === userType.ServiceUser">{{ "serviceUser" | i18n }}</span>
@if (user.type === userType.ProviderAdmin) {
<span>{{ "providerAdmin" | i18n }}</span>
}
@if (user.type === userType.ServiceUser) {
<span>{{ "serviceUser" | i18n }}</span>
}
</td>
<td bitCell>
<button
@@ -180,36 +169,27 @@
label="{{ 'options' | i18n }}"
></button>
<bit-menu #rowMenu>
<button
type="button"
bitMenuItem
(click)="reinvite(user)"
*ngIf="user.status === userStatusType.Invited"
>
<i aria-hidden="true" class="bwi bwi-envelope"></i>
{{ "resendInvitation" | i18n }}
</button>
<button
type="button"
bitMenuItem
(click)="confirm(user)"
*ngIf="user.status === userStatusType.Accepted"
>
<span class="tw-text-success">
<i class="bwi bwi-fw bwi-check" aria-hidden="true"></i>
{{ "confirm" | i18n }}
</span>
</button>
<button
type="button"
bitMenuItem
(click)="openEventsDialog(user)"
*ngIf="user.status === userStatusType.Confirmed"
>
<i class="bwi bwi-fw bwi-file-text" aria-hidden="true"></i>
{{ "eventLogs" | i18n }}
</button>
<button type="button" bitMenuItem (click)="remove(user)">
@if (user.status === userStatusType.Invited) {
<button type="button" bitMenuItem (click)="reinvite(user, providerId)">
<i aria-hidden="true" class="bwi bwi-envelope"></i>
{{ "resendInvitation" | i18n }}
</button>
}
@if (user.status === userStatusType.Accepted) {
<button type="button" bitMenuItem (click)="confirm(user, providerId)">
<span class="tw-text-success">
<i class="bwi bwi-fw bwi-check" aria-hidden="true"></i>
{{ "confirm" | i18n }}
</span>
</button>
}
@if (accessEvents && user.status === userStatusType.Confirmed) {
<button type="button" bitMenuItem (click)="openEventsDialog(user, providerId)">
<i class="bwi bwi-fw bwi-file-text" aria-hidden="true"></i>
{{ "eventLogs" | i18n }}
</button>
}
<button type="button" bitMenuItem (click)="remove(user, providerId)">
<span class="tw-text-danger">
<i class="bwi bwi-fw bwi-close" aria-hidden="true"></i>
{{ "remove" | i18n }}
@@ -221,5 +201,5 @@
</ng-template>
</bit-table>
</cdk-virtual-scroll-viewport>
</ng-container>
</ng-container>
}
}

View File

@@ -1,16 +1,31 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { Component } from "@angular/core";
import {
ChangeDetectionStrategy,
ChangeDetectorRef,
Component,
inject,
signal,
WritableSignal,
} from "@angular/core";
import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
import { FormControl } from "@angular/forms";
import { ActivatedRoute, Router } from "@angular/router";
import { combineLatest, firstValueFrom, lastValueFrom, switchMap } from "rxjs";
import { first, map } from "rxjs/operators";
import {
BehaviorSubject,
combineLatest,
debounceTime,
firstValueFrom,
lastValueFrom,
Observable,
switchMap,
} from "rxjs";
import { first, map, tap } from "rxjs/operators";
import { UserNamePipe } from "@bitwarden/angular/pipes/user-name.pipe";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { OrganizationManagementPreferencesService } from "@bitwarden/common/admin-console/abstractions/organization-management-preferences/organization-management-preferences.service";
import { ProviderService } from "@bitwarden/common/admin-console/abstractions/provider.service";
import { ProviderUserStatusType, ProviderUserType } from "@bitwarden/common/admin-console/enums";
import { Provider } from "@bitwarden/common/admin-console/models/domain/provider";
import { ProviderUserBulkRequest } from "@bitwarden/common/admin-console/models/request/provider/provider-user-bulk.request";
import { ProviderUserConfirmRequest } from "@bitwarden/common/admin-console/models/request/provider/provider-user-confirm.request";
import { ProviderUserUserDetailsResponse } from "@bitwarden/common/admin-console/models/response/provider/provider-user.response";
@@ -18,19 +33,19 @@ import { AccountService } from "@bitwarden/common/auth/abstractions/account.serv
import { getUserId } from "@bitwarden/common/auth/services/account.service";
import { assertNonNullish } from "@bitwarden/common/auth/utils";
import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.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 { ValidationService } from "@bitwarden/common/platform/abstractions/validation.service";
import { Utils } from "@bitwarden/common/platform/misc/utils";
import { ProviderId } from "@bitwarden/common/types/guid";
import { DialogRef, DialogService, ToastService } from "@bitwarden/components";
import { KeyService } from "@bitwarden/key-management";
import { BaseMembersComponent } from "@bitwarden/web-vault/app/admin-console/common/base-members.component";
import {
peopleFilter,
PeopleTableDataSource,
} from "@bitwarden/web-vault/app/admin-console/common/people-table-data-source";
import { openEntityEventsDialog } from "@bitwarden/web-vault/app/admin-console/organizations/manage/entity-events.component";
import { UserConfirmComponent } from "@bitwarden/web-vault/app/admin-console/organizations/manage/user-confirm.component";
import { BulkStatusComponent } from "@bitwarden/web-vault/app/admin-console/organizations/members/components/bulk/bulk-status.component";
import { MemberActionResult } from "@bitwarden/web-vault/app/admin-console/organizations/members/services/member-actions/member-actions.service";
@@ -48,79 +63,113 @@ class MembersTableDataSource extends PeopleTableDataSource<ProviderUser> {
protected statusType = ProviderUserStatusType;
}
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
@Component({
templateUrl: "members.component.html",
standalone: false,
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class MembersComponent extends BaseMembersComponent<ProviderUser> {
accessEvents = false;
dataSource = new MembersTableDataSource();
loading = true;
providerId: string;
rowHeight = 70;
rowHeightClass = `tw-h-[70px]`;
status: ProviderUserStatusType = null;
export class MembersComponent {
protected apiService = inject(ApiService);
protected i18nService = inject(I18nService);
protected keyService = inject(KeyService);
protected validationService = inject(ValidationService);
protected logService = inject(LogService);
protected userNamePipe = inject(UserNamePipe);
protected dialogService = inject(DialogService);
protected organizationManagementPreferencesService = inject(
OrganizationManagementPreferencesService,
);
protected toastService = inject(ToastService);
private encryptService = inject(EncryptService);
private activatedRoute = inject(ActivatedRoute);
private providerService = inject(ProviderService);
private router = inject(Router);
private accountService = inject(AccountService);
private changeDetectorRef = inject(ChangeDetectorRef);
userStatusType = ProviderUserStatusType;
userType = ProviderUserType;
protected accessEvents = false;
protected dataSource = new MembersTableDataSource();
constructor(
apiService: ApiService,
keyService: KeyService,
dialogService: DialogService,
i18nService: I18nService,
logService: LogService,
organizationManagementPreferencesService: OrganizationManagementPreferencesService,
toastService: ToastService,
userNamePipe: UserNamePipe,
validationService: ValidationService,
private encryptService: EncryptService,
private activatedRoute: ActivatedRoute,
private providerService: ProviderService,
private router: Router,
private accountService: AccountService,
) {
super(
apiService,
i18nService,
keyService,
validationService,
logService,
userNamePipe,
dialogService,
organizationManagementPreferencesService,
toastService,
protected providerId$: Observable<ProviderId>;
protected provider$: Observable<Provider | undefined>;
protected rowHeight = 70;
protected rowHeightClass = `tw-h-[70px]`;
protected status: ProviderUserStatusType | undefined;
protected userStatusType = ProviderUserStatusType;
protected userType = ProviderUserType;
protected searchControl = new FormControl("", { nonNullable: true });
protected statusToggle = new BehaviorSubject<ProviderUserStatusType | undefined>(undefined);
protected readonly firstLoaded: WritableSignal<boolean> = signal(false);
/**
* Shows a banner alerting the admin that users need to be confirmed.
*/
get showConfirmUsers(): boolean {
return (
this.dataSource.activeUserCount > 1 &&
this.dataSource.confirmedUserCount > 0 &&
this.dataSource.confirmedUserCount < 3 &&
this.dataSource.acceptedUserCount > 0
);
}
get showBulkConfirmUsers(): boolean {
return this.dataSource
.getCheckedUsers()
.every((member) => member.status == this.userStatusType.Accepted);
}
get showBulkReinviteUsers(): boolean {
return this.dataSource
.getCheckedUsers()
.every((member) => member.status == this.userStatusType.Invited);
}
constructor() {
this.dataSource
.connect()
.pipe(takeUntilDestroyed())
.subscribe(() => {
this.changeDetectorRef.markForCheck();
});
// Connect the search input and status toggles to the table dataSource filter
combineLatest([this.searchControl.valueChanges.pipe(debounceTime(200)), this.statusToggle])
.pipe(takeUntilDestroyed())
.subscribe(
([searchText, status]) => (this.dataSource.filter = peopleFilter(searchText, status)),
);
this.providerId$ = this.activatedRoute.params.pipe(map((params) => params.providerId));
this.provider$ = combineLatest([
this.providerId$,
this.accountService.activeAccount$.pipe(getUserId),
]).pipe(
switchMap(([providerId, userId]) => this.providerService.get$(providerId, userId)),
tap(async (provider) => {
if (!provider || !provider.canManageUsers) {
return await this.router.navigate(["../"], { relativeTo: this.activatedRoute });
}
}),
);
combineLatest([
this.activatedRoute.parent.params,
this.activatedRoute.queryParams.pipe(first()),
])
combineLatest([this.activatedRoute.queryParams, this.providerId$])
.pipe(
switchMap(async ([urlParams, queryParams]) => {
first(),
switchMap(async ([queryParams, providerId]) => {
this.searchControl.setValue(queryParams.search);
this.dataSource.filter = peopleFilter(queryParams.search, null);
this.dataSource.filter = peopleFilter(queryParams.search, undefined);
this.providerId = urlParams.providerId;
const provider = await firstValueFrom(
this.accountService.activeAccount$.pipe(
getUserId,
switchMap((userId) => this.providerService.get$(this.providerId, userId)),
),
);
if (!provider || !provider.canManageUsers) {
return await this.router.navigate(["../"], { relativeTo: this.activatedRoute });
}
this.accessEvents = provider.useEvents;
await this.load();
if (queryParams.viewEvents != null) {
const user = this.dataSource.data.find((user) => user.id === queryParams.viewEvents);
if (user && user.status === ProviderUserStatusType.Confirmed) {
this.openEventsDialog(user);
this.openEventsDialog(user, providerId);
}
}
}),
@@ -129,14 +178,17 @@ export class MembersComponent extends BaseMembersComponent<ProviderUser> {
.subscribe();
}
async bulkConfirm(): Promise<void> {
if (this.actionPromise != null) {
return;
}
async load() {
const providerId = await firstValueFrom(this.providerId$);
const response = await this.apiService.getProviderUsers(providerId);
this.dataSource.data = response.data != null && response.data.length > 0 ? response.data : [];
this.firstLoaded.set(true);
}
async bulkConfirm(providerId: ProviderId): Promise<void> {
const dialogRef = BulkConfirmDialogComponent.open(this.dialogService, {
data: {
providerId: this.providerId,
providerId: providerId,
users: this.dataSource.getCheckedUsers(),
},
});
@@ -145,11 +197,7 @@ export class MembersComponent extends BaseMembersComponent<ProviderUser> {
await this.load();
}
async bulkReinvite(): Promise<void> {
if (this.actionPromise != null) {
return;
}
async bulkReinvite(providerId: ProviderId): Promise<void> {
const checkedUsers = this.dataSource.getCheckedUsers();
const checkedInvitedUsers = checkedUsers.filter(
(user) => user.status === ProviderUserStatusType.Invited,
@@ -166,7 +214,7 @@ export class MembersComponent extends BaseMembersComponent<ProviderUser> {
try {
const request = this.apiService.postManyProviderUserReinvite(
this.providerId,
providerId,
new ProviderUserBulkRequest(checkedInvitedUsers.map((user) => user.id)),
);
@@ -184,18 +232,14 @@ export class MembersComponent extends BaseMembersComponent<ProviderUser> {
}
}
async invite() {
await this.edit(null);
async invite(providerId: ProviderId) {
await this.edit(null, providerId);
}
async bulkRemove(): Promise<void> {
if (this.actionPromise != null) {
return;
}
async bulkRemove(providerId: ProviderId): Promise<void> {
const dialogRef = BulkRemoveDialogComponent.open(this.dialogService, {
data: {
providerId: this.providerId,
providerId: providerId,
users: this.dataSource.getCheckedUsers(),
},
});
@@ -204,13 +248,120 @@ export class MembersComponent extends BaseMembersComponent<ProviderUser> {
await this.load();
}
async confirmUser(user: ProviderUser, publicKey: Uint8Array): Promise<MemberActionResult> {
private async removeUserConfirmationDialog(user: ProviderUser) {
return this.dialogService.openSimpleDialog({
title: this.userNamePipe.transform(user),
content: { key: "removeUserConfirmation" },
type: "warning",
});
}
async remove(user: ProviderUser, providerId: ProviderId) {
const confirmed = await this.removeUserConfirmationDialog(user);
if (!confirmed) {
return false;
}
try {
const result = await this.removeUserInternal(user.id, providerId);
if (result.success) {
this.toastService.showToast({
variant: "success",
message: this.i18nService.t("removedUserId", this.userNamePipe.transform(user)),
});
this.dataSource.removeUser(user);
} else {
throw new Error(result.error);
}
} catch (e) {
this.validationService.showError(e);
}
}
async reinvite(user: ProviderUser, providerId: ProviderId) {
try {
const result = await this.reinviteUserInternal(user.id, providerId);
if (result.success) {
this.toastService.showToast({
variant: "success",
message: this.i18nService.t("hasBeenReinvited", this.userNamePipe.transform(user)),
});
} else {
throw new Error(result.error);
}
} catch (e) {
this.validationService.showError(e);
}
}
async confirm(user: ProviderUser, providerId: ProviderId) {
const confirmUser = async (publicKey: Uint8Array) => {
try {
const result = await this.confirmUserInternal(user, publicKey, providerId);
if (result.success) {
user.status = this.userStatusType.Confirmed;
this.dataSource.replaceUser(user);
this.toastService.showToast({
variant: "success",
message: this.i18nService.t("hasBeenConfirmed", this.userNamePipe.transform(user)),
});
} else {
throw new Error(result.error);
}
} catch (e) {
this.validationService.showError(e);
throw e;
}
};
try {
const publicKeyResponse = await this.apiService.getUserPublicKey(user.userId);
const publicKey = Utils.fromB64ToArray(publicKeyResponse.publicKey);
const autoConfirm = await firstValueFrom(
this.organizationManagementPreferencesService.autoConfirmFingerPrints.state$,
);
if (user == null) {
throw new Error("Cannot confirm null user.");
}
if (autoConfirm == null || !autoConfirm) {
const dialogRef = UserConfirmComponent.open(this.dialogService, {
data: {
name: this.userNamePipe.transform(user),
userId: user.userId,
publicKey: publicKey,
confirmUser: () => confirmUser(publicKey),
},
});
await lastValueFrom(dialogRef.closed);
return;
}
try {
const fingerprint = await this.keyService.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}`);
}
}
private async confirmUserInternal(
user: ProviderUser,
publicKey: Uint8Array,
providerId: ProviderId,
): Promise<MemberActionResult> {
try {
const providerKey = await firstValueFrom(
this.accountService.activeAccount$.pipe(
getUserId,
switchMap((userId) => this.keyService.providerKeys$(userId)),
map((providerKeys) => providerKeys?.[this.providerId as ProviderId] ?? null),
map((providerKeys) => providerKeys?.[providerId as ProviderId] ?? null),
),
);
assertNonNullish(providerKey, "Provider key not found");
@@ -218,25 +369,28 @@ export class MembersComponent extends BaseMembersComponent<ProviderUser> {
const key = await this.encryptService.encapsulateKeyUnsigned(providerKey, publicKey);
const request = new ProviderUserConfirmRequest();
request.key = key.encryptedString;
await this.apiService.postProviderUserConfirm(this.providerId, user.id, request);
await this.apiService.postProviderUserConfirm(providerId, user.id, request);
return { success: true };
} catch (error) {
} catch (error: any) {
return { success: false, error: error.message };
}
}
removeUser = async (id: string): Promise<MemberActionResult> => {
private async removeUserInternal(
id: string,
providerId: ProviderId,
): Promise<MemberActionResult> {
try {
await this.apiService.deleteProviderUser(this.providerId, id);
await this.apiService.deleteProviderUser(providerId, id);
return { success: true };
} catch (error) {
} catch (error: any) {
return { success: false, error: error.message };
}
};
}
edit = async (user: ProviderUser | null): Promise<void> => {
edit = async (user: ProviderUser | null, providerId: ProviderId): Promise<void> => {
const data: AddEditMemberDialogParams = {
providerId: this.providerId,
providerId: providerId,
};
if (user != null) {
@@ -261,26 +415,26 @@ export class MembersComponent extends BaseMembersComponent<ProviderUser> {
}
};
openEventsDialog = (user: ProviderUser): DialogRef<void> =>
openEventsDialog = (user: ProviderUser, providerId: ProviderId): DialogRef<void> =>
openEntityEventsDialog(this.dialogService, {
data: {
name: this.userNamePipe.transform(user),
providerId: this.providerId,
providerId: providerId,
entityId: user.id,
showUser: false,
entity: "user",
},
});
getUsers = (): Promise<ListResponse<ProviderUser>> =>
this.apiService.getProviderUsers(this.providerId);
reinviteUser = async (id: string): Promise<MemberActionResult> => {
private async reinviteUserInternal(
id: string,
providerId: ProviderId,
): Promise<MemberActionResult> {
try {
await this.apiService.postProviderUserReinvite(this.providerId, id);
await this.apiService.postProviderUserReinvite(providerId, id);
return { success: true };
} catch (error) {
} catch (error: any) {
return { success: false, error: error.message };
}
};
}
}