mirror of
https://github.com/bitwarden/browser
synced 2026-01-21 11:53:34 +00:00
[PM-26463] Refactor members components (#17863)
* refactor WIP * fix type issue * continue refactor * continue refactor, add tests * refactor WIP: Rebase bulk reinvite * fix type issue * continue refactor: rebase bulk reinvite * continue refactor, add tests: Rebase bulk reinvite * fix test * cleanup, address claude feedback * fix race condition * continue refactor * fix provider confirm * refactor providers to use memberActionsService * prevent duplicate member actions * wip * run prettier * separate provider and members actions, improve error handling, add tests * refactor member export * refactor edit, fix export service and tests
This commit is contained in:
@@ -2,18 +2,24 @@
|
||||
// @ts-strict-ignore
|
||||
import { computed, Signal } from "@angular/core";
|
||||
import { toSignal } from "@angular/core/rxjs-interop";
|
||||
import { map } from "rxjs";
|
||||
import { Observable, Subject, map } from "rxjs";
|
||||
|
||||
import {
|
||||
OrganizationUserStatusType,
|
||||
ProviderUserStatusType,
|
||||
} from "@bitwarden/common/admin-console/enums";
|
||||
import { ProviderUserUserDetailsResponse } from "@bitwarden/common/admin-console/models/response/provider/provider-user.response";
|
||||
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
|
||||
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
|
||||
import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service";
|
||||
import { TableDataSource } from "@bitwarden/components";
|
||||
|
||||
import { StatusType, UserViewTypes } from "./base-members.component";
|
||||
import { OrganizationUserView } from "../organizations/core/views/organization-user.view";
|
||||
|
||||
export type StatusType = OrganizationUserStatusType | ProviderUserStatusType;
|
||||
|
||||
export type UserViewTypes = ProviderUser | OrganizationUserView;
|
||||
export type ProviderUser = ProviderUserUserDetailsResponse;
|
||||
|
||||
/**
|
||||
* Default maximum for most bulk operations (confirm, remove, delete, etc.)
|
||||
@@ -100,6 +106,8 @@ export abstract class PeopleTableDataSource<T extends UserViewTypes> extends Tab
|
||||
this.data?.filter((u) => u.status === this.statusType.Confirmed).length ?? 0;
|
||||
this.revokedUserCount =
|
||||
this.data?.filter((u) => u.status === this.statusType.Revoked).length ?? 0;
|
||||
|
||||
this.checkedUsersUpdated$.next();
|
||||
}
|
||||
|
||||
override get data() {
|
||||
@@ -112,6 +120,15 @@ export abstract class PeopleTableDataSource<T extends UserViewTypes> extends Tab
|
||||
* @param select check the user (true), uncheck the user (false), or toggle the current state (null)
|
||||
*/
|
||||
checkUser(user: T, select?: boolean) {
|
||||
this.setUserChecked(user, select);
|
||||
this.checkedUsersUpdated$.next();
|
||||
}
|
||||
|
||||
/**
|
||||
* Internal method to set checked state without triggering emissions.
|
||||
* Use this in bulk operations to avoid excessive emissions.
|
||||
*/
|
||||
private setUserChecked(user: T, select?: boolean) {
|
||||
(user as any).checked = select == null ? !(user as any).checked : select;
|
||||
}
|
||||
|
||||
@@ -119,6 +136,12 @@ export abstract class PeopleTableDataSource<T extends UserViewTypes> extends Tab
|
||||
return this.data.filter((u) => (u as any).checked);
|
||||
}
|
||||
|
||||
private checkedUsersUpdated$ = new Subject<void>();
|
||||
|
||||
usersUpdated(): Observable<T[]> {
|
||||
return this.checkedUsersUpdated$.asObservable().pipe(map(() => this.getCheckedUsers()));
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets checked users in the order they appear in the filtered/sorted table view.
|
||||
* Use this when enforcing limits to ensure visual consistency (top N visible rows stay checked).
|
||||
@@ -147,8 +170,10 @@ export abstract class PeopleTableDataSource<T extends UserViewTypes> extends Tab
|
||||
: Math.min(filteredUsers.length, MaxCheckedCount);
|
||||
|
||||
for (let i = 0; i < selectCount; i++) {
|
||||
this.checkUser(filteredUsers[i], select);
|
||||
this.setUserChecked(filteredUsers[i], select);
|
||||
}
|
||||
|
||||
this.checkedUsersUpdated$.next();
|
||||
}
|
||||
|
||||
uncheckAllUsers() {
|
||||
@@ -190,7 +215,10 @@ export abstract class PeopleTableDataSource<T extends UserViewTypes> extends Tab
|
||||
}
|
||||
|
||||
// Uncheck users beyond the limit
|
||||
users.slice(limit).forEach((user) => this.checkUser(user, false));
|
||||
users.slice(limit).forEach((user) => this.setUserChecked(user, false));
|
||||
|
||||
// Emit once after all unchecking is done
|
||||
this.checkedUsersUpdated$.next();
|
||||
|
||||
return users.slice(0, limit);
|
||||
}
|
||||
@@ -213,3 +241,26 @@ export abstract class PeopleTableDataSource<T extends UserViewTypes> extends Tab
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export class ProvidersTableDataSource extends PeopleTableDataSource<ProviderUser> {
|
||||
protected statusType = ProviderUserStatusType;
|
||||
}
|
||||
|
||||
export class MembersTableDataSource extends PeopleTableDataSource<OrganizationUserView> {
|
||||
protected statusType = OrganizationUserStatusType;
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper function to determine if the confirm users banner should be shown
|
||||
* @params dataSource Either a ProvidersTableDataSource or a MembersTableDataSource
|
||||
*/
|
||||
export function showConfirmBanner(
|
||||
dataSource: ProvidersTableDataSource | MembersTableDataSource,
|
||||
): boolean {
|
||||
return (
|
||||
dataSource.activeUserCount > 1 &&
|
||||
dataSource.confirmedUserCount > 0 &&
|
||||
dataSource.confirmedUserCount < 3 &&
|
||||
dataSource.acceptedUserCount > 0
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,8 +2,11 @@
|
||||
// @ts-strict-ignore
|
||||
import { Component, Inject, OnInit } from "@angular/core";
|
||||
import { FormControl, FormGroup } from "@angular/forms";
|
||||
import { firstValueFrom } from "rxjs";
|
||||
|
||||
import { OrganizationManagementPreferencesService } from "@bitwarden/common/admin-console/abstractions/organization-management-preferences/organization-management-preferences.service";
|
||||
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
|
||||
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
|
||||
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
|
||||
import { DIALOG_DATA, DialogConfig, DialogRef, DialogService } from "@bitwarden/components";
|
||||
import { KeyService } from "@bitwarden/key-management";
|
||||
@@ -14,7 +17,8 @@ export type UserConfirmDialogData = {
|
||||
name: string;
|
||||
userId: string;
|
||||
publicKey: Uint8Array;
|
||||
confirmUser: (publicKey: Uint8Array) => Promise<void>;
|
||||
// @TODO remove this when doing feature flag cleanup for members component refactor.
|
||||
confirmUser?: (publicKey: Uint8Array) => Promise<void>;
|
||||
};
|
||||
|
||||
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
|
||||
@@ -42,6 +46,7 @@ export class UserConfirmComponent implements OnInit {
|
||||
private keyService: KeyService,
|
||||
private logService: LogService,
|
||||
private organizationManagementPreferencesService: OrganizationManagementPreferencesService,
|
||||
private configService: ConfigService,
|
||||
) {
|
||||
this.name = data.name;
|
||||
this.userId = data.userId;
|
||||
@@ -64,16 +69,21 @@ export class UserConfirmComponent implements OnInit {
|
||||
|
||||
submit = async () => {
|
||||
if (this.loading) {
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
|
||||
if (this.formGroup.value.dontAskAgain) {
|
||||
await this.organizationManagementPreferencesService.autoConfirmFingerPrints.set(true);
|
||||
}
|
||||
|
||||
await this.data.confirmUser(this.publicKey);
|
||||
const membersComponentRefactorEnabled = await firstValueFrom(
|
||||
this.configService.getFeatureFlag$(FeatureFlag.MembersComponentRefactor),
|
||||
);
|
||||
if (!membersComponentRefactorEnabled) {
|
||||
await this.data.confirmUser(this.publicKey);
|
||||
}
|
||||
|
||||
this.dialogRef.close();
|
||||
this.dialogRef.close(true);
|
||||
};
|
||||
|
||||
static open(dialogService: DialogService, config: DialogConfig<UserConfirmDialogData>) {
|
||||
|
||||
@@ -36,6 +36,7 @@ type BulkConfirmDialogParams = {
|
||||
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
|
||||
@Component({
|
||||
templateUrl: "bulk-confirm-dialog.component.html",
|
||||
selector: "member-bulk-comfirm-dialog",
|
||||
standalone: false,
|
||||
})
|
||||
export class BulkConfirmDialogComponent extends BaseBulkConfirmComponent {
|
||||
|
||||
@@ -20,6 +20,7 @@ type BulkDeleteDialogParams = {
|
||||
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
|
||||
@Component({
|
||||
templateUrl: "bulk-delete-dialog.component.html",
|
||||
selector: "member-bulk-delete-dialog",
|
||||
standalone: false,
|
||||
})
|
||||
export class BulkDeleteDialogComponent {
|
||||
|
||||
@@ -24,6 +24,7 @@ export type BulkEnableSecretsManagerDialogData = {
|
||||
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
|
||||
@Component({
|
||||
templateUrl: `bulk-enable-sm-dialog.component.html`,
|
||||
selector: "member-bulk-enable-sm-dialog",
|
||||
standalone: false,
|
||||
})
|
||||
export class BulkEnableSecretsManagerDialogComponent implements OnInit {
|
||||
|
||||
@@ -23,6 +23,7 @@ type BulkRemoveDialogParams = {
|
||||
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
|
||||
@Component({
|
||||
templateUrl: "bulk-remove-dialog.component.html",
|
||||
selector: "member-bulk-remove-dialog",
|
||||
standalone: false,
|
||||
})
|
||||
export class BulkRemoveDialogComponent extends BaseBulkRemoveComponent {
|
||||
|
||||
@@ -18,7 +18,7 @@ type BulkRestoreDialogParams = {
|
||||
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
|
||||
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
|
||||
@Component({
|
||||
selector: "app-bulk-restore-revoke",
|
||||
selector: "member-bulk-restore-revoke",
|
||||
templateUrl: "bulk-restore-revoke.component.html",
|
||||
standalone: false,
|
||||
})
|
||||
|
||||
@@ -41,7 +41,7 @@ type BulkStatusDialogData = {
|
||||
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
|
||||
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
|
||||
@Component({
|
||||
selector: "app-bulk-status",
|
||||
selector: "member-bulk-status",
|
||||
templateUrl: "bulk-status.component.html",
|
||||
standalone: false,
|
||||
})
|
||||
|
||||
@@ -0,0 +1,495 @@
|
||||
@let organization = this.organization();
|
||||
@if (organization) {
|
||||
<app-organization-free-trial-warning
|
||||
[organization]="organization"
|
||||
(clicked)="billingConstraint.navigateToPaymentMethod(organization)"
|
||||
>
|
||||
</app-organization-free-trial-warning>
|
||||
<app-header>
|
||||
<bit-search
|
||||
class="tw-grow"
|
||||
[formControl]="searchControl"
|
||||
[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>
|
||||
</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>
|
||||
|
||||
<bit-toggle [value]="userStatusType.Invited">
|
||||
{{ "invited" | i18n }}
|
||||
<span bitBadge variant="info" *ngIf="dataSource.invitedUserCount as invitedCount">{{
|
||||
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.Revoked">
|
||||
{{ "revoked" | i18n }}
|
||||
<span bitBadge variant="info" *ngIf="dataSource.revokedUserCount as revokedCount">{{
|
||||
revokedCount
|
||||
}}</span>
|
||||
</bit-toggle>
|
||||
</bit-toggle-group>
|
||||
</div>
|
||||
<ng-container *ngIf="!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>
|
||||
<!-- 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>
|
||||
<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>
|
||||
<div class="tw-flex tw-flex-row tw-items-center tw-justify-end tw-gap-2">
|
||||
<button
|
||||
type="button"
|
||||
bitIconButton="bwi-download"
|
||||
size="small"
|
||||
[bitAction]="exportMembers"
|
||||
[disabled]="!firstLoaded"
|
||||
label="{{ 'export' | i18n }}"
|
||||
></button>
|
||||
<button
|
||||
[bitMenuTriggerFor]="headerMenu"
|
||||
type="button"
|
||||
bitIconButton="bwi-ellipsis-v"
|
||||
size="small"
|
||||
label="{{ 'options' | i18n }}"
|
||||
*ngIf="showUserManagementControls()"
|
||||
></button>
|
||||
</div>
|
||||
|
||||
<bit-menu #headerMenu>
|
||||
<ng-container *ngIf="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>
|
||||
</bit-menu>
|
||||
</th>
|
||||
</tr>
|
||||
</ng-container>
|
||||
<ng-template body let-rows$>
|
||||
<tr
|
||||
bitRow
|
||||
*cdkVirtualFor="let u of rows$"
|
||||
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">
|
||||
<td bitCell (click)="edit(u, organization)" class="tw-cursor-pointer">
|
||||
<div class="tw-flex tw-items-center">
|
||||
<bit-avatar
|
||||
size="small"
|
||||
[text]="u | userName"
|
||||
[id]="u.userId"
|
||||
[color]="u.avatarColor"
|
||||
class="tw-mr-3"
|
||||
></bit-avatar>
|
||||
<div class="tw-flex tw-flex-col">
|
||||
<div class="tw-flex tw-flex-row tw-gap-2">
|
||||
<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 }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
</ng-container>
|
||||
<ng-template #readOnlyUserInfo>
|
||||
<td bitCell>
|
||||
<div class="tw-flex tw-items-center">
|
||||
<bit-avatar
|
||||
size="small"
|
||||
[text]="u | userName"
|
||||
[id]="u.userId"
|
||||
[color]="u.avatarColor"
|
||||
class="tw-mr-3"
|
||||
></bit-avatar>
|
||||
<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 }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
</ng-template>
|
||||
|
||||
<ng-container *ngIf="showUserManagementControls(); else readOnlyGroupsCell">
|
||||
<td
|
||||
bitCell
|
||||
(click)="
|
||||
edit(
|
||||
u,
|
||||
organization,
|
||||
organization.useGroups ? memberTab.Groups : memberTab.Collections
|
||||
)
|
||||
"
|
||||
class="tw-cursor-pointer"
|
||||
>
|
||||
<bit-badge-list
|
||||
[items]="organization.useGroups ? u.groupNames : u.collectionNames"
|
||||
[maxItems]="3"
|
||||
variant="secondary"
|
||||
></bit-badge-list>
|
||||
</td>
|
||||
</ng-container>
|
||||
<ng-template #readOnlyGroupsCell>
|
||||
<td bitCell>
|
||||
<bit-badge-list
|
||||
[items]="organization.useGroups ? u.groupNames : u.collectionNames"
|
||||
[maxItems]="3"
|
||||
variant="secondary"
|
||||
></bit-badge-list>
|
||||
</td>
|
||||
</ng-template>
|
||||
|
||||
<ng-container *ngIf="showUserManagementControls(); else readOnlyRoleCell">
|
||||
<td
|
||||
bitCell
|
||||
(click)="edit(u, organization, memberTab.Role)"
|
||||
class="tw-cursor-pointer tw-text-sm tw-text-muted"
|
||||
>
|
||||
{{ u.type | userType }}
|
||||
</td>
|
||||
</ng-container>
|
||||
<ng-template #readOnlyRoleCell>
|
||||
<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">
|
||||
<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)"
|
||||
>
|
||||
<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>
|
||||
<div class="tw-flex tw-flex-row tw-items-center tw-justify-end tw-gap-2">
|
||||
<div class="tw-w-[32px]"></div>
|
||||
<button
|
||||
[bitMenuTriggerFor]="rowMenu"
|
||||
type="button"
|
||||
bitIconButton="bwi-ellipsis-v"
|
||||
size="small"
|
||||
label="{{ 'options' | i18n }}"
|
||||
></button>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
<button
|
||||
type="button"
|
||||
bitMenuItem
|
||||
(click)="edit(u, organization, memberTab.Role)"
|
||||
>
|
||||
<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>
|
||||
<button
|
||||
type="button"
|
||||
bitMenuItem
|
||||
(click)="edit(u, organization, memberTab.Collections)"
|
||||
>
|
||||
<i aria-hidden="true" class="bwi bwi-collection-shared"></i>
|
||||
{{ "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>
|
||||
|
||||
<!-- 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>
|
||||
|
||||
<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>
|
||||
</bit-menu>
|
||||
</td>
|
||||
</tr>
|
||||
</ng-template>
|
||||
</bit-table>
|
||||
</cdk-virtual-scroll-viewport>
|
||||
</ng-container>
|
||||
</ng-container>
|
||||
}
|
||||
@@ -0,0 +1,616 @@
|
||||
import { Component, computed, Signal } from "@angular/core";
|
||||
import { takeUntilDestroyed, toSignal } from "@angular/core/rxjs-interop";
|
||||
import { ActivatedRoute } from "@angular/router";
|
||||
import {
|
||||
combineLatest,
|
||||
concatMap,
|
||||
filter,
|
||||
firstValueFrom,
|
||||
from,
|
||||
map,
|
||||
merge,
|
||||
Observable,
|
||||
shareReplay,
|
||||
switchMap,
|
||||
take,
|
||||
} from "rxjs";
|
||||
|
||||
import { OrganizationUserUserDetailsResponse } from "@bitwarden/admin-console/common";
|
||||
import { UserNamePipe } from "@bitwarden/angular/pipes/user-name.pipe";
|
||||
import { ApiService } from "@bitwarden/common/abstractions/api.service";
|
||||
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
|
||||
import { OrganizationManagementPreferencesService } from "@bitwarden/common/admin-console/abstractions/organization-management-preferences/organization-management-preferences.service";
|
||||
import { PolicyApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/policy/policy-api.service.abstraction";
|
||||
import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
|
||||
import {
|
||||
OrganizationUserStatusType,
|
||||
OrganizationUserType,
|
||||
PolicyType,
|
||||
} from "@bitwarden/common/admin-console/enums";
|
||||
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
|
||||
import { Policy } from "@bitwarden/common/admin-console/models/domain/policy";
|
||||
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||
import { getUserId } from "@bitwarden/common/auth/services/account.service";
|
||||
import { OrganizationMetadataServiceAbstraction } from "@bitwarden/common/billing/abstractions/organization-metadata.service.abstraction";
|
||||
import { OrganizationBillingMetadataResponse } from "@bitwarden/common/billing/models/response/organization-billing-metadata.response";
|
||||
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
|
||||
import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service";
|
||||
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 { getById } from "@bitwarden/common/platform/misc";
|
||||
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 {
|
||||
CloudBulkReinviteLimit,
|
||||
MaxCheckedCount,
|
||||
PeopleTableDataSource,
|
||||
} from "../../common/people-table-data-source";
|
||||
import { OrganizationUserView } from "../core/views/organization-user.view";
|
||||
|
||||
import { AccountRecoveryDialogResultType } from "./components/account-recovery/account-recovery-dialog.component";
|
||||
import { MemberDialogResult, MemberDialogTab } from "./components/member-dialog";
|
||||
import {
|
||||
MemberDialogManagerService,
|
||||
MemberExportService,
|
||||
OrganizationMembersService,
|
||||
} from "./services";
|
||||
import { DeleteManagedMemberWarningService } from "./services/delete-managed-member/delete-managed-member-warning.service";
|
||||
import {
|
||||
MemberActionsService,
|
||||
MemberActionResult,
|
||||
} from "./services/member-actions/member-actions.service";
|
||||
|
||||
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: "deprecated_members.component.html",
|
||||
standalone: false,
|
||||
})
|
||||
export class MembersComponent extends BaseMembersComponent<OrganizationUserView> {
|
||||
userType = OrganizationUserType;
|
||||
userStatusType = OrganizationUserStatusType;
|
||||
memberTab = MemberDialogTab;
|
||||
protected dataSource: MembersTableDataSource;
|
||||
|
||||
readonly organization: Signal<Organization | undefined>;
|
||||
status: OrganizationUserStatusType | undefined;
|
||||
|
||||
private userId$: Observable<UserId> = this.accountService.activeAccount$.pipe(getUserId);
|
||||
|
||||
resetPasswordPolicyEnabled$: Observable<boolean>;
|
||||
|
||||
protected readonly canUseSecretsManager: Signal<boolean> = computed(
|
||||
() => this.organization()?.useSecretsManager ?? false,
|
||||
);
|
||||
protected readonly showUserManagementControls: Signal<boolean> = computed(
|
||||
() => this.organization()?.canManageUsers ?? false,
|
||||
);
|
||||
protected billingMetadata$: Observable<OrganizationBillingMetadataResponse>;
|
||||
|
||||
// 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,
|
||||
private memberExportService: MemberExportService,
|
||||
private configService: ConfigService,
|
||||
private environmentService: EnvironmentService,
|
||||
) {
|
||||
super(
|
||||
apiService,
|
||||
i18nService,
|
||||
keyService,
|
||||
validationService,
|
||||
logService,
|
||||
userNamePipe,
|
||||
dialogService,
|
||||
organizationManagementPreferencesService,
|
||||
toastService,
|
||||
);
|
||||
|
||||
this.dataSource = new MembersTableDataSource(this.configService, this.environmentService);
|
||||
|
||||
const organization$ = this.route.params.pipe(
|
||||
concatMap((params) =>
|
||||
this.userId$.pipe(
|
||||
switchMap((userId) =>
|
||||
this.organizationService.organizations$(userId).pipe(getById(params.organizationId)),
|
||||
),
|
||||
filter((organization): organization is Organization => organization != null),
|
||||
shareReplay({ refCount: true, bufferSize: 1 }),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
this.organization = toSignal(organization$);
|
||||
|
||||
const policies$ = combineLatest([this.userId$, organization$]).pipe(
|
||||
switchMap(([userId, organization]) =>
|
||||
organization.isProviderUser
|
||||
? from(this.policyApiService.getPolicies(organization.id)).pipe(
|
||||
map((response) => Policy.fromListResponse(response)),
|
||||
)
|
||||
: this.policyService.policies$(userId),
|
||||
),
|
||||
);
|
||||
|
||||
this.resetPasswordPolicyEnabled$ = combineLatest([organization$, policies$]).pipe(
|
||||
map(
|
||||
([organization, policies]) =>
|
||||
policies
|
||||
.filter((policy) => policy.type === PolicyType.ResetPassword)
|
||||
.find((p) => p.organizationId === organization.id)?.enabled ?? false,
|
||||
),
|
||||
);
|
||||
|
||||
combineLatest([this.route.queryParams, organization$])
|
||||
.pipe(
|
||||
concatMap(async ([qParams, organization]) => {
|
||||
await this.load(organization!);
|
||||
|
||||
this.searchControl.setValue(qParams.search);
|
||||
|
||||
if (qParams.viewEvents != null) {
|
||||
const user = this.dataSource.data.filter((u) => u.id === qParams.viewEvents);
|
||||
if (user.length > 0 && user[0].status === OrganizationUserStatusType.Confirmed) {
|
||||
this.openEventsDialog(user[0], organization!);
|
||||
}
|
||||
}
|
||||
}),
|
||||
takeUntilDestroyed(),
|
||||
)
|
||||
.subscribe();
|
||||
|
||||
organization$
|
||||
.pipe(
|
||||
switchMap((organization) =>
|
||||
merge(
|
||||
this.organizationWarningsService.showInactiveSubscriptionDialog$(organization),
|
||||
this.organizationWarningsService.showSubscribeBeforeFreeTrialEndsDialog$(organization),
|
||||
),
|
||||
),
|
||||
takeUntilDestroyed(),
|
||||
)
|
||||
.subscribe();
|
||||
|
||||
this.billingMetadata$ = organization$.pipe(
|
||||
switchMap((organization) =>
|
||||
this.organizationMetadataService.getOrganizationMetadata$(organization.id),
|
||||
),
|
||||
shareReplay({ bufferSize: 1, refCount: false }),
|
||||
);
|
||||
|
||||
// Stripe is slow, so kick this off in the background but without blocking page load.
|
||||
// Anyone who needs it will still await the first emission.
|
||||
this.billingMetadata$.pipe(take(1), takeUntilDestroyed()).subscribe();
|
||||
}
|
||||
|
||||
override async load(organization: Organization) {
|
||||
await super.load(organization);
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
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);
|
||||
}
|
||||
} 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);
|
||||
}
|
||||
} catch (e) {
|
||||
this.validationService.showError(e);
|
||||
}
|
||||
this.actionPromise = undefined;
|
||||
}
|
||||
|
||||
allowResetPassword(
|
||||
orgUser: OrganizationUserView,
|
||||
organization: Organization,
|
||||
orgResetPasswordPolicyEnabled: boolean,
|
||||
): boolean {
|
||||
return this.memberActionsService.allowResetPassword(
|
||||
orgUser,
|
||||
organization,
|
||||
orgResetPasswordPolicyEnabled,
|
||||
);
|
||||
}
|
||||
|
||||
showEnrolledStatus(
|
||||
orgUser: OrganizationUserUserDetailsResponse,
|
||||
organization: Organization,
|
||||
orgResetPasswordPolicyEnabled: boolean,
|
||||
): boolean {
|
||||
return (
|
||||
organization.useResetPassword &&
|
||||
orgUser.resetPasswordEnrolled &&
|
||||
orgResetPasswordPolicyEnabled
|
||||
);
|
||||
}
|
||||
|
||||
private async handleInviteDialog(organization: Organization) {
|
||||
const billingMetadata = await firstValueFrom(this.billingMetadata$);
|
||||
const allUserEmails = this.dataSource.data?.map((user) => user.email) ?? [];
|
||||
|
||||
const result = await this.memberDialogManager.openInviteDialog(
|
||||
organization,
|
||||
billingMetadata,
|
||||
allUserEmails,
|
||||
);
|
||||
|
||||
if (result === MemberDialogResult.Saved) {
|
||||
await this.load(organization);
|
||||
}
|
||||
}
|
||||
|
||||
async invite(organization: Organization) {
|
||||
const billingMetadata = await firstValueFrom(this.billingMetadata$);
|
||||
const seatLimitResult = this.billingConstraint.checkSeatLimit(organization, billingMetadata);
|
||||
if (!(await this.billingConstraint.seatLimitReached(seatLimitResult, organization))) {
|
||||
await this.handleInviteDialog(organization);
|
||||
this.organizationMetadataService.refreshMetadataCache();
|
||||
}
|
||||
}
|
||||
|
||||
async edit(
|
||||
user: OrganizationUserView,
|
||||
organization: Organization,
|
||||
initialTab: MemberDialogTab = MemberDialogTab.Role,
|
||||
) {
|
||||
const billingMetadata = await firstValueFrom(this.billingMetadata$);
|
||||
|
||||
const result = await this.memberDialogManager.openEditDialog(
|
||||
user,
|
||||
organization,
|
||||
billingMetadata,
|
||||
initialTab,
|
||||
);
|
||||
|
||||
switch (result) {
|
||||
case MemberDialogResult.Deleted:
|
||||
this.dataSource.removeUser(user);
|
||||
break;
|
||||
case MemberDialogResult.Saved:
|
||||
case MemberDialogResult.Revoked:
|
||||
case MemberDialogResult.Restored:
|
||||
await this.load(organization);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
async bulkRemove(organization: Organization) {
|
||||
if (this.actionPromise != null) {
|
||||
return;
|
||||
}
|
||||
|
||||
const users = this.dataSource.getCheckedUsersWithLimit(MaxCheckedCount);
|
||||
|
||||
await this.memberDialogManager.openBulkRemoveDialog(organization, users);
|
||||
this.organizationMetadataService.refreshMetadataCache();
|
||||
await this.load(organization);
|
||||
}
|
||||
|
||||
async bulkDelete(organization: Organization) {
|
||||
if (this.actionPromise != null) {
|
||||
return;
|
||||
}
|
||||
|
||||
const users = this.dataSource.getCheckedUsersWithLimit(MaxCheckedCount);
|
||||
|
||||
await this.memberDialogManager.openBulkDeleteDialog(organization, users);
|
||||
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;
|
||||
}
|
||||
|
||||
const users = this.dataSource.getCheckedUsersWithLimit(MaxCheckedCount);
|
||||
|
||||
await this.memberDialogManager.openBulkRestoreRevokeDialog(organization, users, isRevoking);
|
||||
await this.load(organization);
|
||||
}
|
||||
|
||||
async bulkReinvite(organization: Organization) {
|
||||
if (this.actionPromise != null) {
|
||||
return;
|
||||
}
|
||||
|
||||
let users: OrganizationUserView[];
|
||||
if (this.dataSource.isIncreasedBulkLimitEnabled()) {
|
||||
users = this.dataSource.getCheckedUsersInVisibleOrder();
|
||||
} else {
|
||||
users = this.dataSource.getCheckedUsers();
|
||||
}
|
||||
|
||||
const allInvitedUsers = users.filter((u) => u.status === OrganizationUserStatusType.Invited);
|
||||
|
||||
// Capture the original count BEFORE enforcing the limit
|
||||
const originalInvitedCount = allInvitedUsers.length;
|
||||
|
||||
// When feature flag is enabled, limit invited users and uncheck the excess
|
||||
let filteredUsers: OrganizationUserView[];
|
||||
if (this.dataSource.isIncreasedBulkLimitEnabled()) {
|
||||
filteredUsers = this.dataSource.limitAndUncheckExcess(
|
||||
allInvitedUsers,
|
||||
CloudBulkReinviteLimit,
|
||||
);
|
||||
} else {
|
||||
filteredUsers = allInvitedUsers;
|
||||
}
|
||||
|
||||
if (filteredUsers.length <= 0) {
|
||||
this.toastService.showToast({
|
||||
variant: "error",
|
||||
title: this.i18nService.t("errorOccurred"),
|
||||
message: this.i18nService.t("noSelectedUsersApplicable"),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await this.memberActionsService.bulkReinvite(
|
||||
organization,
|
||||
filteredUsers.map((user) => user.id as UserId),
|
||||
);
|
||||
|
||||
if (!result.successful) {
|
||||
throw new Error();
|
||||
}
|
||||
|
||||
// When feature flag is enabled, show toast instead of dialog
|
||||
if (this.dataSource.isIncreasedBulkLimitEnabled()) {
|
||||
const selectedCount = originalInvitedCount;
|
||||
const invitedCount = filteredUsers.length;
|
||||
|
||||
if (selectedCount > CloudBulkReinviteLimit) {
|
||||
const excludedCount = selectedCount - CloudBulkReinviteLimit;
|
||||
this.toastService.showToast({
|
||||
variant: "success",
|
||||
message: this.i18nService.t(
|
||||
"bulkReinviteLimitedSuccessToast",
|
||||
CloudBulkReinviteLimit.toLocaleString(),
|
||||
selectedCount.toLocaleString(),
|
||||
excludedCount.toLocaleString(),
|
||||
),
|
||||
});
|
||||
} else {
|
||||
this.toastService.showToast({
|
||||
variant: "success",
|
||||
message: this.i18nService.t("bulkReinviteSuccessToast", invitedCount.toString()),
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// Feature flag disabled - show legacy dialog
|
||||
await this.memberDialogManager.openBulkStatusDialog(
|
||||
users,
|
||||
filteredUsers,
|
||||
Promise.resolve(result.successful),
|
||||
this.i18nService.t("bulkReinviteMessage"),
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
this.validationService.showError(e);
|
||||
}
|
||||
this.actionPromise = undefined;
|
||||
}
|
||||
|
||||
async bulkConfirm(organization: Organization) {
|
||||
if (this.actionPromise != null) {
|
||||
return;
|
||||
}
|
||||
|
||||
const users = this.dataSource.getCheckedUsersWithLimit(MaxCheckedCount);
|
||||
|
||||
await this.memberDialogManager.openBulkConfirmDialog(organization, users);
|
||||
await this.load(organization);
|
||||
}
|
||||
|
||||
async bulkEnableSM(organization: Organization) {
|
||||
const users = this.dataSource.getCheckedUsersWithLimit(MaxCheckedCount);
|
||||
|
||||
await this.memberDialogManager.openBulkEnableSecretsManagerDialog(organization, users);
|
||||
|
||||
this.dataSource.uncheckAllUsers();
|
||||
await this.load(organization);
|
||||
}
|
||||
|
||||
openEventsDialog(user: OrganizationUserView, organization: Organization) {
|
||||
this.memberDialogManager.openEventsDialog(user, organization);
|
||||
}
|
||||
|
||||
async resetPassword(user: OrganizationUserView, organization: Organization) {
|
||||
if (!user || !user.email || !user.id) {
|
||||
this.toastService.showToast({
|
||||
variant: "error",
|
||||
title: this.i18nService.t("errorOccurred"),
|
||||
message: this.i18nService.t("orgUserDetailsNotFound"),
|
||||
});
|
||||
this.logService.error("Org user details not found when attempting account recovery");
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await this.memberDialogManager.openAccountRecoveryDialog(user, organization);
|
||||
if (result === AccountRecoveryDialogResultType.Ok) {
|
||||
await this.load(organization);
|
||||
}
|
||||
|
||||
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,
|
||||
organization,
|
||||
);
|
||||
|
||||
if (!confirmed) {
|
||||
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)),
|
||||
});
|
||||
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));
|
||||
}
|
||||
|
||||
exportMembers = () => {
|
||||
const result = this.memberExportService.getMemberExport(this.dataSource.data);
|
||||
if (result.success) {
|
||||
this.toastService.showToast({
|
||||
variant: "success",
|
||||
title: undefined,
|
||||
message: this.i18nService.t("dataExportSuccess"),
|
||||
});
|
||||
}
|
||||
|
||||
if (result.error != null) {
|
||||
this.validationService.showError(result.error.message);
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -1,23 +1,30 @@
|
||||
import { NgModule } from "@angular/core";
|
||||
import { RouterModule, Routes } from "@angular/router";
|
||||
|
||||
import { featureFlaggedRoute } from "@bitwarden/angular/platform/utils/feature-flagged-route";
|
||||
import { canAccessMembersTab } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
|
||||
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
|
||||
|
||||
import { FreeBitwardenFamiliesComponent } from "../../../billing/members/free-bitwarden-families.component";
|
||||
import { organizationPermissionsGuard } from "../guards/org-permissions.guard";
|
||||
|
||||
import { canAccessSponsoredFamilies } from "./../../../billing/guards/can-access-sponsored-families.guard";
|
||||
import { MembersComponent } from "./members.component";
|
||||
import { MembersComponent } from "./deprecated_members.component";
|
||||
import { vNextMembersComponent } from "./members.component";
|
||||
|
||||
const routes: Routes = [
|
||||
{
|
||||
path: "",
|
||||
component: MembersComponent,
|
||||
canActivate: [organizationPermissionsGuard(canAccessMembersTab)],
|
||||
data: {
|
||||
titleId: "members",
|
||||
...featureFlaggedRoute({
|
||||
defaultComponent: MembersComponent,
|
||||
flaggedComponent: vNextMembersComponent,
|
||||
featureFlag: FeatureFlag.MembersComponentRefactor,
|
||||
routeOptions: {
|
||||
path: "",
|
||||
canActivate: [organizationPermissionsGuard(canAccessMembersTab)],
|
||||
data: {
|
||||
titleId: "members",
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
{
|
||||
path: "sponsored-families",
|
||||
component: FreeBitwardenFamiliesComponent,
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
@let organization = this.organization();
|
||||
@if (organization) {
|
||||
@let dataSource = this.dataSource();
|
||||
@let bulkActions = bulkMenuOptions$ | async;
|
||||
@let showConfirmBanner = showConfirmBanner$ | async;
|
||||
@let isProcessing = this.isProcessing();
|
||||
|
||||
@if (organization && dataSource) {
|
||||
<app-organization-free-trial-warning
|
||||
[organization]="organization"
|
||||
(clicked)="billingConstraint.navigateToPaymentMethod(organization)"
|
||||
@@ -12,183 +17,199 @@
|
||||
[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() || !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>
|
||||
</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 (showConfirmBanner) {
|
||||
<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>
|
||||
<div class="tw-flex tw-flex-row tw-items-center tw-justify-end tw-gap-2">
|
||||
<button
|
||||
type="button"
|
||||
bitIconButton="bwi-download"
|
||||
size="small"
|
||||
[bitAction]="exportMembers"
|
||||
[disabled]="!firstLoaded"
|
||||
label="{{ 'export' | i18n }}"
|
||||
></button>
|
||||
<button
|
||||
[bitMenuTriggerFor]="headerMenu"
|
||||
type="button"
|
||||
bitIconButton="bwi-ellipsis-v"
|
||||
size="small"
|
||||
label="{{ 'options' | i18n }}"
|
||||
*ngIf="showUserManagementControls()"
|
||||
></button>
|
||||
</div>
|
||||
<th bitCell class="tw-w-10">
|
||||
@if (showUserManagementControls()) {
|
||||
<th bitCell>
|
||||
<div class="tw-flex tw-flex-row tw-items-center tw-justify-end tw-gap-2">
|
||||
<button
|
||||
type="button"
|
||||
bitIconButton="bwi-download"
|
||||
size="small"
|
||||
[bitAction]="exportMembers"
|
||||
[disabled]="!firstLoaded"
|
||||
label="{{ 'export' | i18n }}"
|
||||
></button>
|
||||
<button
|
||||
[bitMenuTriggerFor]="headerMenu"
|
||||
type="button"
|
||||
bitIconButton="bwi-ellipsis-v"
|
||||
size="small"
|
||||
label="{{ 'options' | i18n }}"
|
||||
></button>
|
||||
</div>
|
||||
</th>
|
||||
}
|
||||
|
||||
<bit-menu #headerMenu>
|
||||
<ng-container *ngIf="canUseSecretsManager()">
|
||||
<button type="button" bitMenuItem (click)="bulkEnableSM(organization)">
|
||||
@if (canUseSecretsManager()) {
|
||||
<button
|
||||
type="button"
|
||||
bitMenuItem
|
||||
(click)="isProcessing ? null : 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)="isProcessing ? null : bulkReinvite(organization)"
|
||||
>
|
||||
<i class="bwi bwi-fw bwi-envelope" aria-hidden="true"></i>
|
||||
{{ "reinviteSelected" | i18n }}
|
||||
</button>
|
||||
}
|
||||
@if (bulkActions.showBulkConfirmUsers) {
|
||||
<button
|
||||
type="button"
|
||||
bitMenuItem
|
||||
(click)="isProcessing ? null : 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)="isProcessing ? null : 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)="isProcessing ? null : 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)="isProcessing ? null : 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)="isProcessing ? null : 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>
|
||||
@@ -200,10 +221,10 @@
|
||||
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)]="u.checked" />
|
||||
</td>
|
||||
<td bitCell (click)="edit(u, organization)" class="tw-cursor-pointer">
|
||||
<div class="tw-flex tw-items-center">
|
||||
<bit-avatar
|
||||
@@ -218,39 +239,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
|
||||
@@ -263,40 +276,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)="
|
||||
@@ -314,8 +320,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"
|
||||
@@ -323,9 +328,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)"
|
||||
@@ -333,33 +338,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(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>
|
||||
<div class="tw-flex tw-flex-row tw-items-center tw-justify-end tw-gap-2">
|
||||
@@ -374,122 +376,131 @@
|
||||
</div>
|
||||
|
||||
<bit-menu #rowMenu>
|
||||
<ng-container *ngIf="showUserManagementControls()">
|
||||
@if (showUserManagementControls()) {
|
||||
@if (u.status === userStatusType.Invited) {
|
||||
<button
|
||||
type="button"
|
||||
bitMenuItem
|
||||
(click)="isProcessing ? null : 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)="isProcessing ? null : 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
|
||||
(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>
|
||||
<button
|
||||
type="button"
|
||||
bitMenuItem
|
||||
(click)="edit(u, organization, memberTab.Role)"
|
||||
(click)="isProcessing ? null : edit(u, organization, memberTab.Role)"
|
||||
>
|
||||
<i aria-hidden="true" class="bwi bwi-user"></i> {{ "memberRole" | i18n }}
|
||||
</button>
|
||||
@if (organization.useGroups) {
|
||||
<button
|
||||
type="button"
|
||||
bitMenuItem
|
||||
(click)="isProcessing ? null : edit(u, organization, memberTab.Groups)"
|
||||
>
|
||||
<i aria-hidden="true" class="bwi bwi-users"></i> {{ "groups" | 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>
|
||||
<button
|
||||
type="button"
|
||||
bitMenuItem
|
||||
(click)="edit(u, organization, memberTab.Collections)"
|
||||
(click)="isProcessing ? null : edit(u, organization, memberTab.Collections)"
|
||||
>
|
||||
<i aria-hidden="true" class="bwi bwi-collection-shared"></i>
|
||||
{{ "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)="isProcessing ? null : 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)="isProcessing ? null : 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)="isProcessing ? null : 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)="isProcessing ? null : revoke(u, organization)"
|
||||
>
|
||||
<i aria-hidden="true" class="bwi bwi-minus-circle"></i>
|
||||
{{ "revokeAccess" | i18n }}
|
||||
</button>
|
||||
}
|
||||
@if (!u.managedByOrganization) {
|
||||
<button
|
||||
type="button"
|
||||
bitMenuItem
|
||||
(click)="isProcessing ? null : remove(u, organization)"
|
||||
>
|
||||
<span class="tw-text-danger">
|
||||
<i aria-hidden="true" class="bwi bwi-close"></i> {{ "remove" | i18n }}
|
||||
</span>
|
||||
</button>
|
||||
} @else {
|
||||
<button
|
||||
type="button"
|
||||
bitMenuItem
|
||||
(click)="isProcessing ? null : 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>
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,696 @@
|
||||
import { NO_ERRORS_SCHEMA } from "@angular/core";
|
||||
import { ComponentFixture, TestBed } from "@angular/core/testing";
|
||||
import { ActivatedRoute } from "@angular/router";
|
||||
import { mock, MockProxy } from "jest-mock-extended";
|
||||
import { BehaviorSubject, of } from "rxjs";
|
||||
|
||||
import { UserNamePipe } from "@bitwarden/angular/pipes/user-name.pipe";
|
||||
import { ApiService } from "@bitwarden/common/abstractions/api.service";
|
||||
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
|
||||
import { OrganizationManagementPreferencesService } from "@bitwarden/common/admin-console/abstractions/organization-management-preferences/organization-management-preferences.service";
|
||||
import { PolicyApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/policy/policy-api.service.abstraction";
|
||||
import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
|
||||
import {
|
||||
OrganizationUserStatusType,
|
||||
OrganizationUserType,
|
||||
} from "@bitwarden/common/admin-console/enums";
|
||||
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
|
||||
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||
import { OrganizationMetadataServiceAbstraction } from "@bitwarden/common/billing/abstractions/organization-metadata.service.abstraction";
|
||||
import { OrganizationBillingMetadataResponse } from "@bitwarden/common/billing/models/response/organization-billing-metadata.response";
|
||||
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
|
||||
import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service";
|
||||
import { FileDownloadService } from "@bitwarden/common/platform/abstractions/file-download/file-download.service";
|
||||
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 { FakeAccountService, mockAccountServiceWith } from "@bitwarden/common/spec";
|
||||
import { OrganizationId, UserId } from "@bitwarden/common/types/guid";
|
||||
import { DialogService, ToastService } from "@bitwarden/components";
|
||||
import { newGuid } from "@bitwarden/guid";
|
||||
import { KeyService } from "@bitwarden/key-management";
|
||||
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 { OrganizationUserView } from "../core/views/organization-user.view";
|
||||
|
||||
import { AccountRecoveryDialogResultType } from "./components/account-recovery/account-recovery-dialog.component";
|
||||
import { MemberDialogResult } from "./components/member-dialog";
|
||||
import { vNextMembersComponent } from "./members.component";
|
||||
import {
|
||||
MemberDialogManagerService,
|
||||
MemberExportService,
|
||||
OrganizationMembersService,
|
||||
} from "./services";
|
||||
import { DeleteManagedMemberWarningService } from "./services/delete-managed-member/delete-managed-member-warning.service";
|
||||
import {
|
||||
MemberActionsService,
|
||||
MemberActionResult,
|
||||
} from "./services/member-actions/member-actions.service";
|
||||
|
||||
describe("vNextMembersComponent", () => {
|
||||
let component: vNextMembersComponent;
|
||||
let fixture: ComponentFixture<vNextMembersComponent>;
|
||||
|
||||
let mockApiService: MockProxy<ApiService>;
|
||||
let mockI18nService: MockProxy<I18nService>;
|
||||
let mockOrganizationManagementPreferencesService: MockProxy<OrganizationManagementPreferencesService>;
|
||||
let mockKeyService: MockProxy<KeyService>;
|
||||
let mockValidationService: MockProxy<ValidationService>;
|
||||
let mockLogService: MockProxy<LogService>;
|
||||
let mockUserNamePipe: MockProxy<UserNamePipe>;
|
||||
let mockDialogService: MockProxy<DialogService>;
|
||||
let mockToastService: MockProxy<ToastService>;
|
||||
let mockActivatedRoute: ActivatedRoute;
|
||||
let mockDeleteManagedMemberWarningService: MockProxy<DeleteManagedMemberWarningService>;
|
||||
let mockOrganizationWarningsService: MockProxy<OrganizationWarningsService>;
|
||||
let mockMemberActionsService: MockProxy<MemberActionsService>;
|
||||
let mockMemberDialogManager: MockProxy<MemberDialogManagerService>;
|
||||
let mockBillingConstraint: MockProxy<BillingConstraintService>;
|
||||
let mockMemberService: MockProxy<OrganizationMembersService>;
|
||||
let mockOrganizationService: MockProxy<OrganizationService>;
|
||||
let mockAccountService: FakeAccountService;
|
||||
let mockPolicyService: MockProxy<PolicyService>;
|
||||
let mockPolicyApiService: MockProxy<PolicyApiServiceAbstraction>;
|
||||
let mockOrganizationMetadataService: MockProxy<OrganizationMetadataServiceAbstraction>;
|
||||
let mockConfigService: MockProxy<ConfigService>;
|
||||
let mockEnvironmentService: MockProxy<EnvironmentService>;
|
||||
let mockMemberExportService: MockProxy<MemberExportService>;
|
||||
let mockFileDownloadService: MockProxy<FileDownloadService>;
|
||||
|
||||
let routeParamsSubject: BehaviorSubject<any>;
|
||||
let queryParamsSubject: BehaviorSubject<any>;
|
||||
|
||||
const mockUserId = newGuid() as UserId;
|
||||
const mockOrgId = newGuid() as OrganizationId;
|
||||
const mockOrg = {
|
||||
id: mockOrgId,
|
||||
name: "Test Organization",
|
||||
enabled: true,
|
||||
canManageUsers: true,
|
||||
useSecretsManager: true,
|
||||
useResetPassword: true,
|
||||
isProviderUser: false,
|
||||
} as Organization;
|
||||
|
||||
const mockUser = {
|
||||
id: newGuid(),
|
||||
userId: newGuid(),
|
||||
type: OrganizationUserType.User,
|
||||
status: OrganizationUserStatusType.Confirmed,
|
||||
email: "test@example.com",
|
||||
name: "Test User",
|
||||
resetPasswordEnrolled: false,
|
||||
accessSecretsManager: false,
|
||||
managedByOrganization: false,
|
||||
twoFactorEnabled: false,
|
||||
usesKeyConnector: false,
|
||||
hasMasterPassword: true,
|
||||
} as OrganizationUserView;
|
||||
|
||||
const mockBillingMetadata = {
|
||||
isSubscriptionUnpaid: false,
|
||||
} as Partial<OrganizationBillingMetadataResponse>;
|
||||
|
||||
beforeEach(async () => {
|
||||
routeParamsSubject = new BehaviorSubject({ organizationId: mockOrgId });
|
||||
queryParamsSubject = new BehaviorSubject({});
|
||||
|
||||
mockActivatedRoute = {
|
||||
params: routeParamsSubject.asObservable(),
|
||||
queryParams: queryParamsSubject.asObservable(),
|
||||
} as any;
|
||||
|
||||
mockApiService = mock<ApiService>();
|
||||
mockI18nService = mock<I18nService>();
|
||||
mockI18nService.t.mockImplementation((key: string) => key);
|
||||
|
||||
mockOrganizationManagementPreferencesService = mock<OrganizationManagementPreferencesService>();
|
||||
mockOrganizationManagementPreferencesService.autoConfirmFingerPrints = {
|
||||
state$: of(false),
|
||||
} as any;
|
||||
|
||||
mockKeyService = mock<KeyService>();
|
||||
mockValidationService = mock<ValidationService>();
|
||||
mockLogService = mock<LogService>();
|
||||
mockUserNamePipe = mock<UserNamePipe>();
|
||||
mockUserNamePipe.transform.mockReturnValue("Test User");
|
||||
|
||||
mockDialogService = mock<DialogService>();
|
||||
mockToastService = mock<ToastService>();
|
||||
mockDeleteManagedMemberWarningService = mock<DeleteManagedMemberWarningService>();
|
||||
mockOrganizationWarningsService = mock<OrganizationWarningsService>();
|
||||
mockMemberActionsService = mock<MemberActionsService>();
|
||||
mockMemberDialogManager = mock<MemberDialogManagerService>();
|
||||
mockBillingConstraint = mock<BillingConstraintService>();
|
||||
|
||||
mockMemberService = mock<OrganizationMembersService>();
|
||||
mockMemberService.loadUsers.mockResolvedValue([mockUser]);
|
||||
|
||||
mockOrganizationService = mock<OrganizationService>();
|
||||
mockOrganizationService.organizations$.mockReturnValue(of([mockOrg]));
|
||||
|
||||
mockAccountService = mockAccountServiceWith(mockUserId);
|
||||
|
||||
mockPolicyService = mock<PolicyService>();
|
||||
|
||||
mockPolicyApiService = mock<PolicyApiServiceAbstraction>();
|
||||
mockOrganizationMetadataService = mock<OrganizationMetadataServiceAbstraction>();
|
||||
mockOrganizationMetadataService.getOrganizationMetadata$.mockReturnValue(
|
||||
of(mockBillingMetadata),
|
||||
);
|
||||
|
||||
mockConfigService = mock<ConfigService>();
|
||||
mockConfigService.getFeatureFlag$.mockReturnValue(of(false));
|
||||
|
||||
mockEnvironmentService = mock<EnvironmentService>();
|
||||
mockEnvironmentService.environment$ = of({
|
||||
isCloud: () => false,
|
||||
} as any);
|
||||
|
||||
mockMemberExportService = mock<MemberExportService>();
|
||||
mockFileDownloadService = mock<FileDownloadService>();
|
||||
|
||||
await TestBed.configureTestingModule({
|
||||
declarations: [vNextMembersComponent],
|
||||
providers: [
|
||||
{ provide: ApiService, useValue: mockApiService },
|
||||
{ provide: I18nService, useValue: mockI18nService },
|
||||
{
|
||||
provide: OrganizationManagementPreferencesService,
|
||||
useValue: mockOrganizationManagementPreferencesService,
|
||||
},
|
||||
{ provide: KeyService, useValue: mockKeyService },
|
||||
{ provide: ValidationService, useValue: mockValidationService },
|
||||
{ provide: LogService, useValue: mockLogService },
|
||||
{ provide: UserNamePipe, useValue: mockUserNamePipe },
|
||||
{ provide: DialogService, useValue: mockDialogService },
|
||||
{ provide: ToastService, useValue: mockToastService },
|
||||
{ provide: ActivatedRoute, useValue: mockActivatedRoute },
|
||||
{
|
||||
provide: DeleteManagedMemberWarningService,
|
||||
useValue: mockDeleteManagedMemberWarningService,
|
||||
},
|
||||
{ provide: OrganizationWarningsService, useValue: mockOrganizationWarningsService },
|
||||
{ provide: MemberActionsService, useValue: mockMemberActionsService },
|
||||
{ provide: MemberDialogManagerService, useValue: mockMemberDialogManager },
|
||||
{ provide: BillingConstraintService, useValue: mockBillingConstraint },
|
||||
{ provide: OrganizationMembersService, useValue: mockMemberService },
|
||||
{ provide: OrganizationService, useValue: mockOrganizationService },
|
||||
{ provide: AccountService, useValue: mockAccountService },
|
||||
{ provide: PolicyService, useValue: mockPolicyService },
|
||||
{ provide: PolicyApiServiceAbstraction, useValue: mockPolicyApiService },
|
||||
{
|
||||
provide: OrganizationMetadataServiceAbstraction,
|
||||
useValue: mockOrganizationMetadataService,
|
||||
},
|
||||
{ provide: ConfigService, useValue: mockConfigService },
|
||||
{ provide: EnvironmentService, useValue: mockEnvironmentService },
|
||||
{ provide: MemberExportService, useValue: mockMemberExportService },
|
||||
{ provide: FileDownloadService, useValue: mockFileDownloadService },
|
||||
],
|
||||
schemas: [NO_ERRORS_SCHEMA],
|
||||
})
|
||||
.overrideComponent(vNextMembersComponent, {
|
||||
remove: { imports: [] },
|
||||
add: { template: "<div></div>" },
|
||||
})
|
||||
.compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(vNextMembersComponent);
|
||||
component = fixture.componentInstance;
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (fixture) {
|
||||
fixture.destroy();
|
||||
}
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("load", () => {
|
||||
it("should load users and set data source", async () => {
|
||||
const users = [mockUser];
|
||||
mockMemberService.loadUsers.mockResolvedValue(users);
|
||||
|
||||
await component.load(mockOrg);
|
||||
|
||||
expect(mockMemberService.loadUsers).toHaveBeenCalledWith(mockOrg);
|
||||
expect(component["dataSource"]().data).toEqual(users);
|
||||
expect(component["firstLoaded"]()).toBe(true);
|
||||
});
|
||||
|
||||
it("should handle empty response", async () => {
|
||||
mockMemberService.loadUsers.mockResolvedValue([]);
|
||||
|
||||
await component.load(mockOrg);
|
||||
|
||||
expect(component["dataSource"]().data).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("remove", () => {
|
||||
it("should remove user when confirmed", async () => {
|
||||
mockMemberDialogManager.openRemoveUserConfirmationDialog.mockResolvedValue(true);
|
||||
mockMemberActionsService.removeUser.mockResolvedValue({ success: true });
|
||||
|
||||
const removeSpy = jest.spyOn(component["dataSource"](), "removeUser");
|
||||
|
||||
await component.remove(mockUser, mockOrg);
|
||||
|
||||
expect(mockMemberDialogManager.openRemoveUserConfirmationDialog).toHaveBeenCalledWith(
|
||||
mockUser,
|
||||
);
|
||||
expect(mockMemberActionsService.removeUser).toHaveBeenCalledWith(mockOrg, mockUser.id);
|
||||
expect(removeSpy).toHaveBeenCalledWith(mockUser);
|
||||
expect(mockToastService.showToast).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should not remove user when not confirmed", async () => {
|
||||
mockMemberDialogManager.openRemoveUserConfirmationDialog.mockResolvedValue(false);
|
||||
|
||||
const result = await component.remove(mockUser, mockOrg);
|
||||
|
||||
expect(result).toBe(false);
|
||||
expect(mockMemberActionsService.removeUser).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should handle errors via handleMemberActionResult", async () => {
|
||||
mockMemberDialogManager.openRemoveUserConfirmationDialog.mockResolvedValue(true);
|
||||
mockMemberActionsService.removeUser.mockResolvedValue({
|
||||
success: false,
|
||||
error: "Remove failed",
|
||||
});
|
||||
|
||||
await component.remove(mockUser, mockOrg);
|
||||
|
||||
expect(mockToastService.showToast).toHaveBeenCalledWith({
|
||||
variant: "error",
|
||||
message: "Remove failed",
|
||||
});
|
||||
expect(mockLogService.error).toHaveBeenCalledWith("Remove failed");
|
||||
});
|
||||
});
|
||||
|
||||
describe("reinvite", () => {
|
||||
it("should reinvite user successfully", async () => {
|
||||
mockMemberActionsService.reinviteUser.mockResolvedValue({ success: true });
|
||||
|
||||
await component.reinvite(mockUser, mockOrg);
|
||||
|
||||
expect(mockMemberActionsService.reinviteUser).toHaveBeenCalledWith(mockOrg, mockUser.id);
|
||||
expect(mockToastService.showToast).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should handle errors via handleMemberActionResult", async () => {
|
||||
mockMemberActionsService.reinviteUser.mockResolvedValue({
|
||||
success: false,
|
||||
error: "Reinvite failed",
|
||||
});
|
||||
|
||||
await component.reinvite(mockUser, mockOrg);
|
||||
|
||||
expect(mockToastService.showToast).toHaveBeenCalledWith({
|
||||
variant: "error",
|
||||
message: "Reinvite failed",
|
||||
});
|
||||
expect(mockLogService.error).toHaveBeenCalledWith("Reinvite failed");
|
||||
});
|
||||
});
|
||||
|
||||
describe("confirm", () => {
|
||||
it("should confirm user with auto-confirm enabled", async () => {
|
||||
mockOrganizationManagementPreferencesService.autoConfirmFingerPrints.state$ = of(true);
|
||||
mockMemberActionsService.confirmUser.mockResolvedValue({ success: true });
|
||||
|
||||
// Mock getPublicKeyForConfirm to return a public key
|
||||
const mockPublicKey = new Uint8Array([1, 2, 3, 4]);
|
||||
mockMemberActionsService.getPublicKeyForConfirm.mockResolvedValue(mockPublicKey);
|
||||
|
||||
const replaceSpy = jest.spyOn(component["dataSource"](), "replaceUser");
|
||||
|
||||
await component.confirm(mockUser, mockOrg);
|
||||
|
||||
expect(mockMemberActionsService.getPublicKeyForConfirm).toHaveBeenCalledWith(mockUser);
|
||||
expect(mockMemberActionsService.confirmUser).toHaveBeenCalledWith(
|
||||
mockUser,
|
||||
mockPublicKey,
|
||||
mockOrg,
|
||||
);
|
||||
expect(replaceSpy).toHaveBeenCalled();
|
||||
expect(mockToastService.showToast).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should handle null user", async () => {
|
||||
mockOrganizationManagementPreferencesService.autoConfirmFingerPrints.state$ = of(true);
|
||||
|
||||
// Mock getPublicKeyForConfirm to return null
|
||||
mockMemberActionsService.getPublicKeyForConfirm.mockResolvedValue(null);
|
||||
|
||||
await component.confirm(mockUser, mockOrg);
|
||||
|
||||
expect(mockMemberActionsService.getPublicKeyForConfirm).toHaveBeenCalled();
|
||||
expect(mockMemberActionsService.confirmUser).not.toHaveBeenCalled();
|
||||
expect(mockLogService.warning).toHaveBeenCalledWith("Public key not found");
|
||||
});
|
||||
|
||||
it("should handle API errors gracefully", async () => {
|
||||
// Mock getPublicKeyForConfirm to return null
|
||||
mockMemberActionsService.getPublicKeyForConfirm.mockResolvedValue(null);
|
||||
|
||||
await component.confirm(mockUser, mockOrg);
|
||||
|
||||
expect(mockMemberActionsService.getPublicKeyForConfirm).toHaveBeenCalled();
|
||||
expect(mockLogService.warning).toHaveBeenCalledWith("Public key not found");
|
||||
});
|
||||
});
|
||||
|
||||
describe("revoke", () => {
|
||||
it("should revoke user when confirmed", async () => {
|
||||
mockMemberDialogManager.openRevokeUserConfirmationDialog.mockResolvedValue(true);
|
||||
mockMemberActionsService.revokeUser.mockResolvedValue({ success: true });
|
||||
mockMemberService.loadUsers.mockResolvedValue([mockUser]);
|
||||
|
||||
await component.revoke(mockUser, mockOrg);
|
||||
|
||||
expect(mockMemberDialogManager.openRevokeUserConfirmationDialog).toHaveBeenCalledWith(
|
||||
mockUser,
|
||||
);
|
||||
expect(mockMemberActionsService.revokeUser).toHaveBeenCalledWith(mockOrg, mockUser.id);
|
||||
expect(mockToastService.showToast).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should not revoke user when not confirmed", async () => {
|
||||
mockMemberDialogManager.openRevokeUserConfirmationDialog.mockResolvedValue(false);
|
||||
|
||||
const result = await component.revoke(mockUser, mockOrg);
|
||||
|
||||
expect(result).toBe(false);
|
||||
expect(mockMemberActionsService.revokeUser).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("restore", () => {
|
||||
it("should restore user successfully", async () => {
|
||||
mockMemberActionsService.restoreUser.mockResolvedValue({ success: true });
|
||||
mockMemberService.loadUsers.mockResolvedValue([mockUser]);
|
||||
|
||||
await component.restore(mockUser, mockOrg);
|
||||
|
||||
expect(mockMemberActionsService.restoreUser).toHaveBeenCalledWith(mockOrg, mockUser.id);
|
||||
expect(mockToastService.showToast).toHaveBeenCalled();
|
||||
expect(mockMemberService.loadUsers).toHaveBeenCalledWith(mockOrg);
|
||||
});
|
||||
|
||||
it("should handle errors via handleMemberActionResult", async () => {
|
||||
mockMemberActionsService.restoreUser.mockResolvedValue({
|
||||
success: false,
|
||||
error: "Restore failed",
|
||||
});
|
||||
|
||||
await component.restore(mockUser, mockOrg);
|
||||
|
||||
expect(mockToastService.showToast).toHaveBeenCalledWith({
|
||||
variant: "error",
|
||||
message: "Restore failed",
|
||||
});
|
||||
expect(mockLogService.error).toHaveBeenCalledWith("Restore failed");
|
||||
});
|
||||
});
|
||||
|
||||
describe("invite", () => {
|
||||
it("should open invite dialog when seat limit not reached", async () => {
|
||||
mockBillingConstraint.seatLimitReached.mockResolvedValue(false);
|
||||
mockMemberDialogManager.openInviteDialog.mockResolvedValue(MemberDialogResult.Saved);
|
||||
|
||||
await component.invite(mockOrg);
|
||||
|
||||
expect(mockBillingConstraint.checkSeatLimit).toHaveBeenCalledWith(
|
||||
mockOrg,
|
||||
mockBillingMetadata,
|
||||
);
|
||||
expect(mockMemberDialogManager.openInviteDialog).toHaveBeenCalledWith(
|
||||
mockOrg,
|
||||
mockBillingMetadata,
|
||||
expect.any(Array),
|
||||
);
|
||||
});
|
||||
|
||||
it("should reload organization and refresh metadata cache after successful invite", async () => {
|
||||
mockBillingConstraint.seatLimitReached.mockResolvedValue(false);
|
||||
mockMemberDialogManager.openInviteDialog.mockResolvedValue(MemberDialogResult.Saved);
|
||||
mockMemberService.loadUsers.mockResolvedValue([mockUser]);
|
||||
|
||||
await component.invite(mockOrg);
|
||||
|
||||
expect(mockMemberService.loadUsers).toHaveBeenCalledWith(mockOrg);
|
||||
expect(mockOrganizationMetadataService.refreshMetadataCache).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should not open dialog when seat limit reached", async () => {
|
||||
mockBillingConstraint.seatLimitReached.mockResolvedValue(true);
|
||||
|
||||
await component.invite(mockOrg);
|
||||
|
||||
expect(mockMemberDialogManager.openInviteDialog).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("bulkRemove", () => {
|
||||
it("should open bulk remove dialog and reload", async () => {
|
||||
const users = [mockUser];
|
||||
jest.spyOn(component["dataSource"](), "getCheckedUsersWithLimit").mockReturnValue(users);
|
||||
mockMemberService.loadUsers.mockResolvedValue([mockUser]);
|
||||
|
||||
await component.bulkRemove(mockOrg);
|
||||
|
||||
expect(mockMemberDialogManager.openBulkRemoveDialog).toHaveBeenCalledWith(mockOrg, users);
|
||||
expect(mockOrganizationMetadataService.refreshMetadataCache).toHaveBeenCalled();
|
||||
expect(mockMemberService.loadUsers).toHaveBeenCalledWith(mockOrg);
|
||||
});
|
||||
});
|
||||
|
||||
describe("bulkDelete", () => {
|
||||
it("should open bulk delete dialog and reload", async () => {
|
||||
const users = [mockUser];
|
||||
jest.spyOn(component["dataSource"](), "getCheckedUsersWithLimit").mockReturnValue(users);
|
||||
mockMemberService.loadUsers.mockResolvedValue([mockUser]);
|
||||
|
||||
await component.bulkDelete(mockOrg);
|
||||
|
||||
expect(mockMemberDialogManager.openBulkDeleteDialog).toHaveBeenCalledWith(mockOrg, users);
|
||||
expect(mockMemberService.loadUsers).toHaveBeenCalledWith(mockOrg);
|
||||
});
|
||||
});
|
||||
|
||||
describe("bulkRevokeOrRestore", () => {
|
||||
it.each([
|
||||
{ isRevoking: true, action: "revoke" },
|
||||
{ isRevoking: false, action: "restore" },
|
||||
])(
|
||||
"should open bulk $action dialog and reload when isRevoking is $isRevoking",
|
||||
async ({ isRevoking }) => {
|
||||
const users = [mockUser];
|
||||
jest.spyOn(component["dataSource"](), "getCheckedUsersWithLimit").mockReturnValue(users);
|
||||
mockMemberService.loadUsers.mockResolvedValue([mockUser]);
|
||||
|
||||
await component.bulkRevokeOrRestore(isRevoking, mockOrg);
|
||||
|
||||
expect(mockMemberDialogManager.openBulkRestoreRevokeDialog).toHaveBeenCalledWith(
|
||||
mockOrg,
|
||||
users,
|
||||
isRevoking,
|
||||
);
|
||||
expect(mockMemberService.loadUsers).toHaveBeenCalledWith(mockOrg);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe("bulkReinvite", () => {
|
||||
it("should reinvite invited users", async () => {
|
||||
const invitedUser = {
|
||||
...mockUser,
|
||||
status: OrganizationUserStatusType.Invited,
|
||||
};
|
||||
jest.spyOn(component["dataSource"](), "isIncreasedBulkLimitEnabled").mockReturnValue(false);
|
||||
jest.spyOn(component["dataSource"](), "getCheckedUsers").mockReturnValue([invitedUser]);
|
||||
mockMemberActionsService.bulkReinvite.mockResolvedValue({ successful: true });
|
||||
|
||||
await component.bulkReinvite(mockOrg);
|
||||
|
||||
expect(mockMemberActionsService.bulkReinvite).toHaveBeenCalledWith(mockOrg, [invitedUser.id]);
|
||||
expect(mockMemberDialogManager.openBulkStatusDialog).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should show error when no invited users selected", async () => {
|
||||
const confirmedUser = {
|
||||
...mockUser,
|
||||
status: OrganizationUserStatusType.Confirmed,
|
||||
};
|
||||
jest.spyOn(component["dataSource"](), "isIncreasedBulkLimitEnabled").mockReturnValue(false);
|
||||
jest.spyOn(component["dataSource"](), "getCheckedUsers").mockReturnValue([confirmedUser]);
|
||||
|
||||
await component.bulkReinvite(mockOrg);
|
||||
|
||||
expect(mockToastService.showToast).toHaveBeenCalledWith({
|
||||
variant: "error",
|
||||
title: "errorOccurred",
|
||||
message: "noSelectedUsersApplicable",
|
||||
});
|
||||
expect(mockMemberActionsService.bulkReinvite).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should handle errors", async () => {
|
||||
const invitedUser = {
|
||||
...mockUser,
|
||||
status: OrganizationUserStatusType.Invited,
|
||||
};
|
||||
jest.spyOn(component["dataSource"](), "isIncreasedBulkLimitEnabled").mockReturnValue(false);
|
||||
jest.spyOn(component["dataSource"](), "getCheckedUsers").mockReturnValue([invitedUser]);
|
||||
const error = new Error("Bulk reinvite failed");
|
||||
mockMemberActionsService.bulkReinvite.mockResolvedValue({ successful: false, failed: error });
|
||||
|
||||
await component.bulkReinvite(mockOrg);
|
||||
|
||||
expect(mockValidationService.showError).toHaveBeenCalledWith(error);
|
||||
});
|
||||
});
|
||||
|
||||
describe("bulkConfirm", () => {
|
||||
it("should open bulk confirm dialog and reload", async () => {
|
||||
const users = [mockUser];
|
||||
jest.spyOn(component["dataSource"](), "getCheckedUsersWithLimit").mockReturnValue(users);
|
||||
mockMemberService.loadUsers.mockResolvedValue([mockUser]);
|
||||
|
||||
await component.bulkConfirm(mockOrg);
|
||||
|
||||
expect(mockMemberDialogManager.openBulkConfirmDialog).toHaveBeenCalledWith(mockOrg, users);
|
||||
expect(mockMemberService.loadUsers).toHaveBeenCalledWith(mockOrg);
|
||||
});
|
||||
});
|
||||
|
||||
describe("bulkEnableSM", () => {
|
||||
it("should open bulk enable SM dialog and reload", async () => {
|
||||
const users = [mockUser];
|
||||
jest.spyOn(component["dataSource"](), "getCheckedUsersWithLimit").mockReturnValue(users);
|
||||
jest.spyOn(component["dataSource"](), "uncheckAllUsers");
|
||||
mockMemberService.loadUsers.mockResolvedValue([mockUser]);
|
||||
|
||||
await component.bulkEnableSM(mockOrg);
|
||||
|
||||
expect(mockMemberDialogManager.openBulkEnableSecretsManagerDialog).toHaveBeenCalledWith(
|
||||
mockOrg,
|
||||
users,
|
||||
);
|
||||
expect(component["dataSource"]().uncheckAllUsers).toHaveBeenCalled();
|
||||
expect(mockMemberService.loadUsers).toHaveBeenCalledWith(mockOrg);
|
||||
});
|
||||
});
|
||||
|
||||
describe("resetPassword", () => {
|
||||
it("should open account recovery dialog", async () => {
|
||||
mockMemberDialogManager.openAccountRecoveryDialog.mockResolvedValue(
|
||||
AccountRecoveryDialogResultType.Ok,
|
||||
);
|
||||
mockMemberService.loadUsers.mockResolvedValue([mockUser]);
|
||||
|
||||
await component.resetPassword(mockUser, mockOrg);
|
||||
|
||||
expect(mockMemberDialogManager.openAccountRecoveryDialog).toHaveBeenCalledWith(
|
||||
mockUser,
|
||||
mockOrg,
|
||||
);
|
||||
expect(mockMemberService.loadUsers).toHaveBeenCalledWith(mockOrg);
|
||||
});
|
||||
});
|
||||
|
||||
describe("deleteUser", () => {
|
||||
it("should delete user when confirmed", async () => {
|
||||
mockMemberDialogManager.openDeleteUserConfirmationDialog.mockResolvedValue(true);
|
||||
mockMemberActionsService.deleteUser.mockResolvedValue({ success: true });
|
||||
const removeSpy = jest.spyOn(component["dataSource"](), "removeUser");
|
||||
|
||||
await component.deleteUser(mockUser, mockOrg);
|
||||
|
||||
expect(mockMemberDialogManager.openDeleteUserConfirmationDialog).toHaveBeenCalledWith(
|
||||
mockUser,
|
||||
mockOrg,
|
||||
);
|
||||
expect(mockMemberActionsService.deleteUser).toHaveBeenCalledWith(mockOrg, mockUser.id);
|
||||
expect(removeSpy).toHaveBeenCalledWith(mockUser);
|
||||
expect(mockToastService.showToast).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should not delete user when not confirmed", async () => {
|
||||
mockMemberDialogManager.openDeleteUserConfirmationDialog.mockResolvedValue(false);
|
||||
|
||||
const result = await component.deleteUser(mockUser, mockOrg);
|
||||
|
||||
expect(result).toBe(false);
|
||||
expect(mockMemberActionsService.deleteUser).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should handle errors via handleMemberActionResult", async () => {
|
||||
mockMemberDialogManager.openDeleteUserConfirmationDialog.mockResolvedValue(true);
|
||||
mockMemberActionsService.deleteUser.mockResolvedValue({
|
||||
success: false,
|
||||
error: "Delete failed",
|
||||
});
|
||||
|
||||
await component.deleteUser(mockUser, mockOrg);
|
||||
|
||||
expect(mockToastService.showToast).toHaveBeenCalledWith({
|
||||
variant: "error",
|
||||
message: "Delete failed",
|
||||
});
|
||||
expect(mockLogService.error).toHaveBeenCalledWith("Delete failed");
|
||||
});
|
||||
});
|
||||
|
||||
describe("handleMemberActionResult", () => {
|
||||
it("should show success toast when result is successful", async () => {
|
||||
const result: MemberActionResult = { success: true };
|
||||
|
||||
await component.handleMemberActionResult(result, "testSuccessKey", mockUser);
|
||||
|
||||
expect(mockToastService.showToast).toHaveBeenCalledWith({
|
||||
variant: "success",
|
||||
message: "testSuccessKey",
|
||||
});
|
||||
});
|
||||
|
||||
it("should execute side effect when provided and successful", async () => {
|
||||
const result: MemberActionResult = { success: true };
|
||||
const sideEffect = jest.fn();
|
||||
|
||||
await component.handleMemberActionResult(result, "testSuccessKey", mockUser, sideEffect);
|
||||
|
||||
expect(sideEffect).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should show error toast when result is not successful", async () => {
|
||||
const result: MemberActionResult = { success: false, error: "Error message" };
|
||||
const sideEffect = jest.fn();
|
||||
|
||||
await component.handleMemberActionResult(result, "testSuccessKey", mockUser, sideEffect);
|
||||
|
||||
expect(mockToastService.showToast).toHaveBeenCalledWith({
|
||||
variant: "error",
|
||||
message: "Error message",
|
||||
});
|
||||
expect(mockLogService.error).toHaveBeenCalledWith("Error message");
|
||||
expect(sideEffect).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should propagate error when side effect throws", async () => {
|
||||
const result: MemberActionResult = { success: true };
|
||||
const error = new Error("Side effect failed");
|
||||
const sideEffect = jest.fn().mockRejectedValue(error);
|
||||
|
||||
await expect(
|
||||
component.handleMemberActionResult(result, "testSuccessKey", mockUser, sideEffect),
|
||||
).rejects.toThrow("Side effect failed");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,9 +1,12 @@
|
||||
import { Component, computed, Signal } from "@angular/core";
|
||||
import { Component, computed, inject, signal, Signal, WritableSignal } 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,
|
||||
@@ -15,11 +18,8 @@ import {
|
||||
take,
|
||||
} from "rxjs";
|
||||
|
||||
import { OrganizationUserUserDetailsResponse } from "@bitwarden/admin-console/common";
|
||||
import { UserNamePipe } from "@bitwarden/angular/pipes/user-name.pipe";
|
||||
import { ApiService } from "@bitwarden/common/abstractions/api.service";
|
||||
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
|
||||
import { OrganizationManagementPreferencesService } from "@bitwarden/common/admin-console/abstractions/organization-management-preferences/organization-management-preferences.service";
|
||||
import { PolicyApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/policy/policy-api.service.abstraction";
|
||||
import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
|
||||
import {
|
||||
@@ -35,22 +35,21 @@ import { OrganizationMetadataServiceAbstraction } from "@bitwarden/common/billin
|
||||
import { OrganizationBillingMetadataResponse } from "@bitwarden/common/billing/models/response/organization-billing-metadata.response";
|
||||
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
|
||||
import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service";
|
||||
import { FileDownloadService } from "@bitwarden/common/platform/abstractions/file-download/file-download.service";
|
||||
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 { getById } from "@bitwarden/common/platform/misc";
|
||||
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 {
|
||||
CloudBulkReinviteLimit,
|
||||
MaxCheckedCount,
|
||||
PeopleTableDataSource,
|
||||
MembersTableDataSource,
|
||||
peopleFilter,
|
||||
showConfirmBanner,
|
||||
} from "../../common/people-table-data-source";
|
||||
import { OrganizationUserView } from "../core/views/organization-user.view";
|
||||
|
||||
@@ -67,8 +66,13 @@ import {
|
||||
MemberActionResult,
|
||||
} from "./services/member-actions/member-actions.service";
|
||||
|
||||
class MembersTableDataSource extends PeopleTableDataSource<OrganizationUserView> {
|
||||
protected statusType = OrganizationUserStatusType;
|
||||
interface BulkMemberFlags {
|
||||
showBulkRestoreUsers: boolean;
|
||||
showBulkRevokeUsers: boolean;
|
||||
showBulkRemoveUsers: boolean;
|
||||
showBulkDeleteUsers: boolean;
|
||||
showBulkConfirmUsers: boolean;
|
||||
showBulkReinviteUsers: boolean;
|
||||
}
|
||||
|
||||
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
|
||||
@@ -77,71 +81,76 @@ class MembersTableDataSource extends PeopleTableDataSource<OrganizationUserView>
|
||||
templateUrl: "members.component.html",
|
||||
standalone: false,
|
||||
})
|
||||
export class MembersComponent extends BaseMembersComponent<OrganizationUserView> {
|
||||
userType = OrganizationUserType;
|
||||
userStatusType = OrganizationUserStatusType;
|
||||
memberTab = MemberDialogTab;
|
||||
protected dataSource: MembersTableDataSource;
|
||||
|
||||
readonly organization: Signal<Organization | undefined>;
|
||||
status: OrganizationUserStatusType | undefined;
|
||||
export class vNextMembersComponent {
|
||||
protected i18nService = inject(I18nService);
|
||||
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 configService = inject(ConfigService);
|
||||
private environmentService = inject(EnvironmentService);
|
||||
private memberExportService = inject(MemberExportService);
|
||||
|
||||
private userId$: Observable<UserId> = this.accountService.activeAccount$.pipe(getUserId);
|
||||
|
||||
resetPasswordPolicyEnabled$: Observable<boolean>;
|
||||
protected userType = OrganizationUserType;
|
||||
protected userStatusType = OrganizationUserStatusType;
|
||||
protected memberTab = MemberDialogTab;
|
||||
|
||||
protected searchControl = new FormControl("", { nonNullable: true });
|
||||
protected statusToggle = new BehaviorSubject<OrganizationUserStatusType | undefined>(undefined);
|
||||
|
||||
protected readonly dataSource: Signal<MembersTableDataSource> = signal(
|
||||
new MembersTableDataSource(this.configService, this.environmentService),
|
||||
);
|
||||
protected readonly organization: Signal<Organization | undefined>;
|
||||
protected readonly firstLoaded: WritableSignal<boolean> = signal(false);
|
||||
|
||||
protected bulkMenuOptions$ = this.dataSource()
|
||||
.usersUpdated()
|
||||
.pipe(map((members) => this.bulkMenuOptions(members)));
|
||||
|
||||
protected showConfirmBanner$ = this.dataSource()
|
||||
.usersUpdated()
|
||||
.pipe(map(() => showConfirmBanner(this.dataSource())));
|
||||
|
||||
protected isProcessing = this.memberActionsService.isProcessing;
|
||||
|
||||
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,
|
||||
private memberExportService: MemberExportService,
|
||||
private fileDownloadService: FileDownloadService,
|
||||
private configService: ConfigService,
|
||||
private environmentService: EnvironmentService,
|
||||
) {
|
||||
super(
|
||||
apiService,
|
||||
i18nService,
|
||||
keyService,
|
||||
validationService,
|
||||
logService,
|
||||
userNamePipe,
|
||||
dialogService,
|
||||
organizationManagementPreferencesService,
|
||||
toastService,
|
||||
);
|
||||
|
||||
this.dataSource = new MembersTableDataSource(this.configService, this.environmentService);
|
||||
constructor() {
|
||||
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) =>
|
||||
@@ -184,7 +193,7 @@ export class MembersComponent extends BaseMembersComponent<OrganizationUserView>
|
||||
this.searchControl.setValue(qParams.search);
|
||||
|
||||
if (qParams.viewEvents != null) {
|
||||
const user = this.dataSource.data.filter((u) => u.id === qParams.viewEvents);
|
||||
const user = this.dataSource().data.filter((u) => u.id === qParams.viewEvents);
|
||||
if (user.length > 0 && user[0].status === OrganizationUserStatusType.Confirmed) {
|
||||
this.openEventsDialog(user[0], organization!);
|
||||
}
|
||||
@@ -218,80 +227,62 @@ 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 = 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);
|
||||
}
|
||||
} catch (e) {
|
||||
this.validationService.showError(e);
|
||||
const result = await this.memberActionsService.removeUser(organization, user.id);
|
||||
const sideEffect = () => this.dataSource().removeUser(user);
|
||||
await this.handleMemberActionResult(result, "removedUserId", user, sideEffect);
|
||||
}
|
||||
|
||||
async reinvite(user: OrganizationUserView, organization: Organization) {
|
||||
const result = await this.memberActionsService.reinviteUser(organization, user.id);
|
||||
await this.handleMemberActionResult(result, "hasBeenReinvited", user);
|
||||
}
|
||||
|
||||
async confirm(user: OrganizationUserView, organization: Organization) {
|
||||
const confirmUserSideEffect = () => {
|
||||
user.status = this.userStatusType.Confirmed;
|
||||
this.dataSource().replaceUser(user);
|
||||
};
|
||||
|
||||
const publicKeyResult = await this.memberActionsService.getPublicKeyForConfirm(user);
|
||||
|
||||
if (publicKeyResult == null) {
|
||||
this.logService.warning("Public key not found");
|
||||
return;
|
||||
}
|
||||
this.actionPromise = undefined;
|
||||
|
||||
const result = await this.memberActionsService.confirmUser(user, publicKeyResult, organization);
|
||||
await this.handleMemberActionResult(result, "hasBeenConfirmed", user, confirmUserSideEffect);
|
||||
}
|
||||
|
||||
async revoke(user: OrganizationUserView, organization: Organization) {
|
||||
const confirmed = await this.memberDialogManager.openRevokeUserConfirmationDialog(user);
|
||||
|
||||
if (!confirmed) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const result = await this.memberActionsService.revokeUser(organization, user.id);
|
||||
const sideEffect = async () => await this.load(organization);
|
||||
await this.handleMemberActionResult(result, "revokedUserId", user, sideEffect);
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
} catch (e) {
|
||||
this.validationService.showError(e);
|
||||
}
|
||||
this.actionPromise = undefined;
|
||||
const result = await this.memberActionsService.restoreUser(organization, user.id);
|
||||
const sideEffect = async () => await this.load(organization);
|
||||
await this.handleMemberActionResult(result, "restoredUserId", user, sideEffect);
|
||||
}
|
||||
|
||||
allowResetPassword(
|
||||
@@ -307,7 +298,7 @@ export class MembersComponent extends BaseMembersComponent<OrganizationUserView>
|
||||
}
|
||||
|
||||
showEnrolledStatus(
|
||||
orgUser: OrganizationUserUserDetailsResponse,
|
||||
orgUser: OrganizationUserView,
|
||||
organization: Organization,
|
||||
orgResetPasswordPolicyEnabled: boolean,
|
||||
): boolean {
|
||||
@@ -318,9 +309,15 @@ export class MembersComponent extends BaseMembersComponent<OrganizationUserView>
|
||||
);
|
||||
}
|
||||
|
||||
private async handleInviteDialog(organization: Organization) {
|
||||
async invite(organization: Organization) {
|
||||
const billingMetadata = await firstValueFrom(this.billingMetadata$);
|
||||
const allUserEmails = this.dataSource.data?.map((user) => user.email) ?? [];
|
||||
const seatLimitResult = this.billingConstraint.checkSeatLimit(organization, billingMetadata);
|
||||
|
||||
if (await this.billingConstraint.seatLimitReached(seatLimitResult, organization)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const allUserEmails = this.dataSource().data?.map((user) => user.email) ?? [];
|
||||
|
||||
const result = await this.memberDialogManager.openInviteDialog(
|
||||
organization,
|
||||
@@ -330,14 +327,6 @@ export class MembersComponent extends BaseMembersComponent<OrganizationUserView>
|
||||
|
||||
if (result === MemberDialogResult.Saved) {
|
||||
await this.load(organization);
|
||||
}
|
||||
}
|
||||
|
||||
async invite(organization: Organization) {
|
||||
const billingMetadata = await firstValueFrom(this.billingMetadata$);
|
||||
const seatLimitResult = this.billingConstraint.checkSeatLimit(organization, billingMetadata);
|
||||
if (!(await this.billingConstraint.seatLimitReached(seatLimitResult, organization))) {
|
||||
await this.handleInviteDialog(organization);
|
||||
this.organizationMetadataService.refreshMetadataCache();
|
||||
}
|
||||
}
|
||||
@@ -358,7 +347,7 @@ export class MembersComponent extends BaseMembersComponent<OrganizationUserView>
|
||||
|
||||
switch (result) {
|
||||
case MemberDialogResult.Deleted:
|
||||
this.dataSource.removeUser(user);
|
||||
this.dataSource().removeUser(user);
|
||||
break;
|
||||
case MemberDialogResult.Saved:
|
||||
case MemberDialogResult.Revoked:
|
||||
@@ -369,57 +358,30 @@ export class MembersComponent extends BaseMembersComponent<OrganizationUserView>
|
||||
}
|
||||
|
||||
async bulkRemove(organization: Organization) {
|
||||
if (this.actionPromise != null) {
|
||||
return;
|
||||
}
|
||||
|
||||
const users = this.dataSource.getCheckedUsersWithLimit(MaxCheckedCount);
|
||||
|
||||
const users = this.dataSource().getCheckedUsersWithLimit(MaxCheckedCount);
|
||||
await this.memberDialogManager.openBulkRemoveDialog(organization, users);
|
||||
this.organizationMetadataService.refreshMetadataCache();
|
||||
await this.load(organization);
|
||||
}
|
||||
|
||||
async bulkDelete(organization: Organization) {
|
||||
if (this.actionPromise != null) {
|
||||
return;
|
||||
}
|
||||
|
||||
const users = this.dataSource.getCheckedUsersWithLimit(MaxCheckedCount);
|
||||
|
||||
const users = this.dataSource().getCheckedUsersWithLimit(MaxCheckedCount);
|
||||
await this.memberDialogManager.openBulkDeleteDialog(organization, users);
|
||||
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;
|
||||
}
|
||||
|
||||
const users = this.dataSource.getCheckedUsersWithLimit(MaxCheckedCount);
|
||||
|
||||
const users = this.dataSource().getCheckedUsersWithLimit(MaxCheckedCount);
|
||||
await this.memberDialogManager.openBulkRestoreRevokeDialog(organization, users, isRevoking);
|
||||
await this.load(organization);
|
||||
}
|
||||
|
||||
async bulkReinvite(organization: Organization) {
|
||||
if (this.actionPromise != null) {
|
||||
return;
|
||||
}
|
||||
|
||||
let users: OrganizationUserView[];
|
||||
if (this.dataSource.isIncreasedBulkLimitEnabled()) {
|
||||
users = this.dataSource.getCheckedUsersInVisibleOrder();
|
||||
if (this.dataSource().isIncreasedBulkLimitEnabled()) {
|
||||
users = this.dataSource().getCheckedUsersInVisibleOrder();
|
||||
} else {
|
||||
users = this.dataSource.getCheckedUsers();
|
||||
users = this.dataSource().getCheckedUsers();
|
||||
}
|
||||
|
||||
const allInvitedUsers = users.filter((u) => u.status === OrganizationUserStatusType.Invited);
|
||||
@@ -429,8 +391,8 @@ export class MembersComponent extends BaseMembersComponent<OrganizationUserView>
|
||||
|
||||
// When feature flag is enabled, limit invited users and uncheck the excess
|
||||
let filteredUsers: OrganizationUserView[];
|
||||
if (this.dataSource.isIncreasedBulkLimitEnabled()) {
|
||||
filteredUsers = this.dataSource.limitAndUncheckExcess(
|
||||
if (this.dataSource().isIncreasedBulkLimitEnabled()) {
|
||||
filteredUsers = this.dataSource().limitAndUncheckExcess(
|
||||
allInvitedUsers,
|
||||
CloudBulkReinviteLimit,
|
||||
);
|
||||
@@ -447,70 +409,59 @@ export class MembersComponent extends BaseMembersComponent<OrganizationUserView>
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await this.memberActionsService.bulkReinvite(
|
||||
organization,
|
||||
filteredUsers.map((user) => user.id as UserId),
|
||||
);
|
||||
const result = await this.memberActionsService.bulkReinvite(
|
||||
organization,
|
||||
filteredUsers.map((user) => user.id as UserId),
|
||||
);
|
||||
|
||||
if (!result.successful) {
|
||||
throw new Error();
|
||||
}
|
||||
|
||||
// When feature flag is enabled, show toast instead of dialog
|
||||
if (this.dataSource.isIncreasedBulkLimitEnabled()) {
|
||||
const selectedCount = originalInvitedCount;
|
||||
const invitedCount = filteredUsers.length;
|
||||
|
||||
if (selectedCount > CloudBulkReinviteLimit) {
|
||||
const excludedCount = selectedCount - CloudBulkReinviteLimit;
|
||||
this.toastService.showToast({
|
||||
variant: "success",
|
||||
message: this.i18nService.t(
|
||||
"bulkReinviteLimitedSuccessToast",
|
||||
CloudBulkReinviteLimit.toLocaleString(),
|
||||
selectedCount.toLocaleString(),
|
||||
excludedCount.toLocaleString(),
|
||||
),
|
||||
});
|
||||
} else {
|
||||
this.toastService.showToast({
|
||||
variant: "success",
|
||||
message: this.i18nService.t("bulkReinviteSuccessToast", invitedCount.toString()),
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// Feature flag disabled - show legacy dialog
|
||||
await this.memberDialogManager.openBulkStatusDialog(
|
||||
users,
|
||||
filteredUsers,
|
||||
Promise.resolve(result.successful),
|
||||
this.i18nService.t("bulkReinviteMessage"),
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
this.validationService.showError(e);
|
||||
if (!result.successful) {
|
||||
this.validationService.showError(result.failed);
|
||||
}
|
||||
|
||||
// When feature flag is enabled, show toast instead of dialog
|
||||
if (this.dataSource().isIncreasedBulkLimitEnabled()) {
|
||||
const selectedCount = originalInvitedCount;
|
||||
const invitedCount = filteredUsers.length;
|
||||
|
||||
if (selectedCount > CloudBulkReinviteLimit) {
|
||||
const excludedCount = selectedCount - CloudBulkReinviteLimit;
|
||||
this.toastService.showToast({
|
||||
variant: "success",
|
||||
message: this.i18nService.t(
|
||||
"bulkReinviteLimitedSuccessToast",
|
||||
CloudBulkReinviteLimit.toLocaleString(),
|
||||
selectedCount.toLocaleString(),
|
||||
excludedCount.toLocaleString(),
|
||||
),
|
||||
});
|
||||
} else {
|
||||
this.toastService.showToast({
|
||||
variant: "success",
|
||||
message: this.i18nService.t("bulkReinviteSuccessToast", invitedCount.toString()),
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// Feature flag disabled - show legacy dialog
|
||||
await this.memberDialogManager.openBulkStatusDialog(
|
||||
users,
|
||||
filteredUsers,
|
||||
Promise.resolve(result.successful),
|
||||
this.i18nService.t("bulkReinviteMessage"),
|
||||
);
|
||||
}
|
||||
this.actionPromise = undefined;
|
||||
}
|
||||
|
||||
async bulkConfirm(organization: Organization) {
|
||||
if (this.actionPromise != null) {
|
||||
return;
|
||||
}
|
||||
|
||||
const users = this.dataSource.getCheckedUsersWithLimit(MaxCheckedCount);
|
||||
|
||||
const users = this.dataSource().getCheckedUsersWithLimit(MaxCheckedCount);
|
||||
await this.memberDialogManager.openBulkConfirmDialog(organization, users);
|
||||
await this.load(organization);
|
||||
}
|
||||
|
||||
async bulkEnableSM(organization: Organization) {
|
||||
const users = this.dataSource.getCheckedUsersWithLimit(MaxCheckedCount);
|
||||
|
||||
const users = this.dataSource().getCheckedUsersWithLimit(MaxCheckedCount);
|
||||
await this.memberDialogManager.openBulkEnableSecretsManagerDialog(organization, users);
|
||||
|
||||
this.dataSource.uncheckAllUsers();
|
||||
this.dataSource().uncheckAllUsers();
|
||||
await this.load(organization);
|
||||
}
|
||||
|
||||
@@ -538,14 +489,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,
|
||||
@@ -556,80 +499,72 @@ 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);
|
||||
}
|
||||
const result = await this.memberActionsService.deleteUser(organization, user.id);
|
||||
await this.handleMemberActionResult(result, "organizationUserDeleted", user, () => {
|
||||
this.dataSource().removeUser(user);
|
||||
});
|
||||
}
|
||||
|
||||
async handleMemberActionResult(
|
||||
result: MemberActionResult,
|
||||
successKey: string,
|
||||
user: OrganizationUserView,
|
||||
sideEffect?: () => void | Promise<void>,
|
||||
) {
|
||||
if (result.error != null) {
|
||||
this.toastService.showToast({
|
||||
variant: "error",
|
||||
message: this.i18nService.t(result.error),
|
||||
});
|
||||
this.logService.error(result.error);
|
||||
return;
|
||||
}
|
||||
|
||||
if (result.success) {
|
||||
this.toastService.showToast({
|
||||
variant: "success",
|
||||
message: this.i18nService.t("organizationUserDeleted", this.userNamePipe.transform(user)),
|
||||
message: this.i18nService.t(successKey, this.userNamePipe.transform(user)),
|
||||
});
|
||||
this.dataSource.removeUser(user);
|
||||
} catch (e) {
|
||||
this.validationService.showError(e);
|
||||
|
||||
if (sideEffect) {
|
||||
await sideEffect();
|
||||
}
|
||||
}
|
||||
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 {
|
||||
private bulkMenuOptions(members: OrganizationUserView[]): BulkMemberFlags {
|
||||
const validStatuses = [
|
||||
this.userStatusType.Accepted,
|
||||
this.userStatusType.Confirmed,
|
||||
this.userStatusType.Revoked,
|
||||
OrganizationUserStatusType.Accepted,
|
||||
OrganizationUserStatusType.Confirmed,
|
||||
OrganizationUserStatusType.Revoked,
|
||||
];
|
||||
|
||||
return this.dataSource
|
||||
.getCheckedUsers()
|
||||
.every((member) => member.managedByOrganization && validStatuses.includes(member.status));
|
||||
const result = {
|
||||
showBulkConfirmUsers: members.every((m) => m.status == OrganizationUserStatusType.Accepted),
|
||||
showBulkReinviteUsers: members.every((m) => m.status == OrganizationUserStatusType.Invited),
|
||||
showBulkRestoreUsers: members.every((m) => m.status == OrganizationUserStatusType.Revoked),
|
||||
showBulkRevokeUsers: members.every((m) => m.status != OrganizationUserStatusType.Revoked),
|
||||
showBulkRemoveUsers: members.every((m) => !m.managedByOrganization),
|
||||
showBulkDeleteUsers: members.every(
|
||||
(m) => m.managedByOrganization && validStatuses.includes(m.status),
|
||||
),
|
||||
};
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
exportMembers = async (): Promise<void> => {
|
||||
try {
|
||||
const members = this.dataSource.data;
|
||||
if (!members || members.length === 0) {
|
||||
this.toastService.showToast({
|
||||
variant: "error",
|
||||
title: this.i18nService.t("errorOccurred"),
|
||||
message: this.i18nService.t("noMembersToExport"),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const csvData = this.memberExportService.getMemberExport(members);
|
||||
const fileName = this.memberExportService.getFileName("org-members");
|
||||
|
||||
this.fileDownloadService.download({
|
||||
fileName: fileName,
|
||||
blobData: csvData,
|
||||
blobOptions: { type: "text/plain" },
|
||||
});
|
||||
|
||||
exportMembers = () => {
|
||||
const result = this.memberExportService.getMemberExport(this.dataSource().data);
|
||||
if (result.success) {
|
||||
this.toastService.showToast({
|
||||
variant: "success",
|
||||
title: undefined,
|
||||
message: this.i18nService.t("dataExportSuccess"),
|
||||
});
|
||||
} catch (e) {
|
||||
this.validationService.showError(e);
|
||||
this.logService.error(`Failed to export members: ${e}`);
|
||||
}
|
||||
|
||||
if (result.error != null) {
|
||||
this.validationService.showError(result.error.message);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -17,8 +17,9 @@ import { BulkRemoveDialogComponent } from "./components/bulk/bulk-remove-dialog.
|
||||
import { BulkRestoreRevokeComponent } from "./components/bulk/bulk-restore-revoke.component";
|
||||
import { BulkStatusComponent } from "./components/bulk/bulk-status.component";
|
||||
import { UserDialogModule } from "./components/member-dialog";
|
||||
import { MembersComponent } from "./deprecated_members.component";
|
||||
import { MembersRoutingModule } from "./members-routing.module";
|
||||
import { MembersComponent } from "./members.component";
|
||||
import { vNextMembersComponent } from "./members.component";
|
||||
import { UserStatusPipe } from "./pipes";
|
||||
import {
|
||||
OrganizationMembersService,
|
||||
@@ -46,6 +47,7 @@ import {
|
||||
BulkRestoreRevokeComponent,
|
||||
BulkStatusComponent,
|
||||
MembersComponent,
|
||||
vNextMembersComponent,
|
||||
BulkDeleteDialogComponent,
|
||||
UserStatusPipe,
|
||||
],
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { TestBed } from "@angular/core/testing";
|
||||
import { MockProxy, mock } from "jest-mock-extended";
|
||||
import { of } from "rxjs";
|
||||
|
||||
@@ -6,6 +7,9 @@ import {
|
||||
OrganizationUserBulkResponse,
|
||||
OrganizationUserService,
|
||||
} from "@bitwarden/admin-console/common";
|
||||
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 {
|
||||
OrganizationUserType,
|
||||
OrganizationUserStatusType,
|
||||
@@ -14,8 +18,11 @@ import { Organization } from "@bitwarden/common/admin-console/models/domain/orga
|
||||
import { OrganizationMetadataServiceAbstraction } from "@bitwarden/common/billing/abstractions/organization-metadata.service.abstraction";
|
||||
import { ListResponse } from "@bitwarden/common/models/response/list.response";
|
||||
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
|
||||
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
|
||||
import { OrganizationId, UserId } from "@bitwarden/common/types/guid";
|
||||
import { DialogService } from "@bitwarden/components";
|
||||
import { newGuid } from "@bitwarden/guid";
|
||||
import { KeyService } from "@bitwarden/key-management";
|
||||
|
||||
import { OrganizationUserView } from "../../../core/views/organization-user.view";
|
||||
|
||||
@@ -56,12 +63,29 @@ describe("MemberActionsService", () => {
|
||||
resetPasswordEnrolled: true,
|
||||
} as OrganizationUserView;
|
||||
|
||||
service = new MemberActionsService(
|
||||
organizationUserApiService,
|
||||
organizationUserService,
|
||||
configService,
|
||||
organizationMetadataService,
|
||||
);
|
||||
TestBed.configureTestingModule({
|
||||
providers: [
|
||||
MemberActionsService,
|
||||
{ provide: OrganizationUserApiService, useValue: organizationUserApiService },
|
||||
{ provide: OrganizationUserService, useValue: organizationUserService },
|
||||
{ provide: ConfigService, useValue: configService },
|
||||
{
|
||||
provide: OrganizationMetadataServiceAbstraction,
|
||||
useValue: organizationMetadataService,
|
||||
},
|
||||
{ provide: ApiService, useValue: mock<ApiService>() },
|
||||
{ provide: DialogService, useValue: mock<DialogService>() },
|
||||
{ provide: KeyService, useValue: mock<KeyService>() },
|
||||
{ provide: LogService, useValue: mock<LogService>() },
|
||||
{
|
||||
provide: OrganizationManagementPreferencesService,
|
||||
useValue: mock<OrganizationManagementPreferencesService>(),
|
||||
},
|
||||
{ provide: UserNamePipe, useValue: mock<UserNamePipe>() },
|
||||
],
|
||||
});
|
||||
|
||||
service = TestBed.inject(MemberActionsService);
|
||||
});
|
||||
|
||||
describe("inviteUser", () => {
|
||||
@@ -660,4 +684,26 @@ describe("MemberActionsService", () => {
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isProcessing signal", () => {
|
||||
it("should be false initially", () => {
|
||||
expect(service.isProcessing()).toBe(false);
|
||||
});
|
||||
|
||||
it("should be false after operation completes successfully", async () => {
|
||||
organizationUserApiService.removeOrganizationUser.mockResolvedValue(undefined);
|
||||
|
||||
await service.removeUser(mockOrganization, userIdToManage);
|
||||
|
||||
expect(service.isProcessing()).toBe(false);
|
||||
});
|
||||
|
||||
it("should be false after operation fails", async () => {
|
||||
organizationUserApiService.removeOrganizationUser.mockRejectedValue(new Error("Failed"));
|
||||
|
||||
await service.removeUser(mockOrganization, userIdToManage);
|
||||
|
||||
expect(service.isProcessing()).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,23 +1,33 @@
|
||||
import { Injectable } from "@angular/core";
|
||||
import { firstValueFrom } from "rxjs";
|
||||
import { inject, Injectable, signal } from "@angular/core";
|
||||
import { lastValueFrom, firstValueFrom } from "rxjs";
|
||||
|
||||
import {
|
||||
OrganizationUserApiService,
|
||||
OrganizationUserBulkResponse,
|
||||
OrganizationUserService,
|
||||
} from "@bitwarden/admin-console/common";
|
||||
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 {
|
||||
OrganizationUserType,
|
||||
OrganizationUserStatusType,
|
||||
} from "@bitwarden/common/admin-console/enums";
|
||||
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
|
||||
import { assertNonNullish } from "@bitwarden/common/auth/utils";
|
||||
import { OrganizationMetadataServiceAbstraction } from "@bitwarden/common/billing/abstractions/organization-metadata.service.abstraction";
|
||||
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
|
||||
import { ListResponse } from "@bitwarden/common/models/response/list.response";
|
||||
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
|
||||
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
|
||||
import { Utils } from "@bitwarden/common/platform/misc/utils";
|
||||
import { DialogService } from "@bitwarden/components";
|
||||
import { KeyService } from "@bitwarden/key-management";
|
||||
import { UserId } from "@bitwarden/user-core";
|
||||
import { ProviderUser } from "@bitwarden/web-vault/app/admin-console/common/people-table-data-source";
|
||||
|
||||
import { OrganizationUserView } from "../../../core/views/organization-user.view";
|
||||
import { UserConfirmComponent } from "../../../manage/user-confirm.component";
|
||||
|
||||
export const REQUESTS_PER_BATCH = 500;
|
||||
|
||||
@@ -33,12 +43,26 @@ export interface BulkActionResult {
|
||||
|
||||
@Injectable()
|
||||
export class MemberActionsService {
|
||||
constructor(
|
||||
private organizationUserApiService: OrganizationUserApiService,
|
||||
private organizationUserService: OrganizationUserService,
|
||||
private configService: ConfigService,
|
||||
private organizationMetadataService: OrganizationMetadataServiceAbstraction,
|
||||
) {}
|
||||
private organizationUserApiService = inject(OrganizationUserApiService);
|
||||
private organizationUserService = inject(OrganizationUserService);
|
||||
private configService = inject(ConfigService);
|
||||
private organizationMetadataService = inject(OrganizationMetadataServiceAbstraction);
|
||||
private apiService = inject(ApiService);
|
||||
private dialogService = inject(DialogService);
|
||||
private keyService = inject(KeyService);
|
||||
private logService = inject(LogService);
|
||||
private orgManagementPrefs = inject(OrganizationManagementPreferencesService);
|
||||
private userNamePipe = inject(UserNamePipe);
|
||||
|
||||
readonly isProcessing = signal(false);
|
||||
|
||||
private startProcessing(): void {
|
||||
this.isProcessing.set(true);
|
||||
}
|
||||
|
||||
private endProcessing(): void {
|
||||
this.isProcessing.set(false);
|
||||
}
|
||||
|
||||
async inviteUser(
|
||||
organization: Organization,
|
||||
@@ -48,6 +72,7 @@ export class MemberActionsService {
|
||||
collections?: any[],
|
||||
groups?: string[],
|
||||
): Promise<MemberActionResult> {
|
||||
this.startProcessing();
|
||||
try {
|
||||
await this.organizationUserApiService.postOrganizationUserInvite(organization.id, {
|
||||
emails: [email],
|
||||
@@ -60,55 +85,72 @@ export class MemberActionsService {
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
return { success: false, error: (error as Error).message ?? String(error) };
|
||||
} finally {
|
||||
this.endProcessing();
|
||||
}
|
||||
}
|
||||
|
||||
async removeUser(organization: Organization, userId: string): Promise<MemberActionResult> {
|
||||
this.startProcessing();
|
||||
try {
|
||||
await this.organizationUserApiService.removeOrganizationUser(organization.id, userId);
|
||||
this.organizationMetadataService.refreshMetadataCache();
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
return { success: false, error: (error as Error).message ?? String(error) };
|
||||
} finally {
|
||||
this.endProcessing();
|
||||
}
|
||||
}
|
||||
|
||||
async revokeUser(organization: Organization, userId: string): Promise<MemberActionResult> {
|
||||
this.startProcessing();
|
||||
try {
|
||||
await this.organizationUserApiService.revokeOrganizationUser(organization.id, userId);
|
||||
this.organizationMetadataService.refreshMetadataCache();
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
return { success: false, error: (error as Error).message ?? String(error) };
|
||||
} finally {
|
||||
this.endProcessing();
|
||||
}
|
||||
}
|
||||
|
||||
async restoreUser(organization: Organization, userId: string): Promise<MemberActionResult> {
|
||||
this.startProcessing();
|
||||
try {
|
||||
await this.organizationUserApiService.restoreOrganizationUser(organization.id, userId);
|
||||
this.organizationMetadataService.refreshMetadataCache();
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
return { success: false, error: (error as Error).message ?? String(error) };
|
||||
} finally {
|
||||
this.endProcessing();
|
||||
}
|
||||
}
|
||||
|
||||
async deleteUser(organization: Organization, userId: string): Promise<MemberActionResult> {
|
||||
this.startProcessing();
|
||||
try {
|
||||
await this.organizationUserApiService.deleteOrganizationUser(organization.id, userId);
|
||||
this.organizationMetadataService.refreshMetadataCache();
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
return { success: false, error: (error as Error).message ?? String(error) };
|
||||
} finally {
|
||||
this.endProcessing();
|
||||
}
|
||||
}
|
||||
|
||||
async reinviteUser(organization: Organization, userId: string): Promise<MemberActionResult> {
|
||||
this.startProcessing();
|
||||
try {
|
||||
await this.organizationUserApiService.postOrganizationUserReinvite(organization.id, userId);
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
return { success: false, error: (error as Error).message ?? String(error) };
|
||||
} finally {
|
||||
this.endProcessing();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -117,6 +159,7 @@ export class MemberActionsService {
|
||||
publicKey: Uint8Array,
|
||||
organization: Organization,
|
||||
): Promise<MemberActionResult> {
|
||||
this.startProcessing();
|
||||
try {
|
||||
await firstValueFrom(
|
||||
this.organizationUserService.confirmUser(organization, user.id, publicKey),
|
||||
@@ -124,27 +167,32 @@ export class MemberActionsService {
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
return { success: false, error: (error as Error).message ?? String(error) };
|
||||
} finally {
|
||||
this.endProcessing();
|
||||
}
|
||||
}
|
||||
|
||||
async bulkReinvite(organization: Organization, userIds: UserId[]): Promise<BulkActionResult> {
|
||||
const increaseBulkReinviteLimitForCloud = await firstValueFrom(
|
||||
this.configService.getFeatureFlag$(FeatureFlag.IncreaseBulkReinviteLimitForCloud),
|
||||
);
|
||||
if (increaseBulkReinviteLimitForCloud) {
|
||||
return await this.vNextBulkReinvite(organization, userIds);
|
||||
} else {
|
||||
try {
|
||||
this.startProcessing();
|
||||
try {
|
||||
const increaseBulkReinviteLimitForCloud = await firstValueFrom(
|
||||
this.configService.getFeatureFlag$(FeatureFlag.IncreaseBulkReinviteLimitForCloud),
|
||||
);
|
||||
if (increaseBulkReinviteLimitForCloud) {
|
||||
return await this.vNextBulkReinvite(organization, userIds);
|
||||
} else {
|
||||
const result = await this.organizationUserApiService.postManyOrganizationUserReinvite(
|
||||
organization.id,
|
||||
userIds,
|
||||
);
|
||||
return { successful: result, failed: [] };
|
||||
} catch (error) {
|
||||
return {
|
||||
failed: userIds.map((id) => ({ id, error: (error as Error).message ?? String(error) })),
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
return {
|
||||
failed: userIds.map((id) => ({ id, error: (error as Error).message ?? String(error) })),
|
||||
};
|
||||
} finally {
|
||||
this.endProcessing();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -236,4 +284,50 @@ export class MemberActionsService {
|
||||
failed: allFailed,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared dialog workflow that returns the public key when the user accepts the selected confirmation
|
||||
* action.
|
||||
*
|
||||
* @param user - The user to confirm (must implement ConfirmableUser interface)
|
||||
* @param userNamePipe - Pipe to transform user names for display
|
||||
* @param orgManagementPrefs - Service providing organization management preferences
|
||||
* @returns Promise containing the pulic key that resolves when the confirm action is accepted
|
||||
* or undefined when cancelled
|
||||
*/
|
||||
async getPublicKeyForConfirm(
|
||||
user: OrganizationUserView | ProviderUser,
|
||||
): Promise<Uint8Array | undefined> {
|
||||
try {
|
||||
assertNonNullish(user, "Cannot confirm null user.");
|
||||
|
||||
const autoConfirmFingerPrint = await firstValueFrom(
|
||||
this.orgManagementPrefs.autoConfirmFingerPrints.state$,
|
||||
);
|
||||
|
||||
const publicKeyResponse = await this.apiService.getUserPublicKey(user.userId);
|
||||
const publicKey = Utils.fromB64ToArray(publicKeyResponse.publicKey);
|
||||
|
||||
if (autoConfirmFingerPrint == null || !autoConfirmFingerPrint) {
|
||||
const fingerprint = await this.keyService.getFingerprint(user.userId, publicKey);
|
||||
this.logService.info(`User's fingerprint: ${fingerprint.join("-")}`);
|
||||
|
||||
const confirmed = UserConfirmComponent.open(this.dialogService, {
|
||||
data: {
|
||||
name: this.userNamePipe.transform(user),
|
||||
userId: user.userId,
|
||||
publicKey: publicKey,
|
||||
},
|
||||
});
|
||||
|
||||
if (!(await lastValueFrom(confirmed.closed))) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
return publicKey;
|
||||
} catch (e) {
|
||||
this.logService.error(`Handled exception: ${e}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,7 +6,9 @@ import {
|
||||
OrganizationUserStatusType,
|
||||
OrganizationUserType,
|
||||
} from "@bitwarden/common/admin-console/enums";
|
||||
import { FileDownloadService } from "@bitwarden/common/platform/abstractions/file-download/file-download.service";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { LogService } from "@bitwarden/logging";
|
||||
|
||||
import { OrganizationUserView } from "../../../core";
|
||||
import { UserStatusPipe } from "../../pipes";
|
||||
@@ -16,9 +18,13 @@ import { MemberExportService } from "./member-export.service";
|
||||
describe("MemberExportService", () => {
|
||||
let service: MemberExportService;
|
||||
let i18nService: MockProxy<I18nService>;
|
||||
let fileDownloadService: MockProxy<FileDownloadService>;
|
||||
let logService: MockProxy<LogService>;
|
||||
|
||||
beforeEach(() => {
|
||||
i18nService = mock<I18nService>();
|
||||
fileDownloadService = mock<FileDownloadService>();
|
||||
logService = mock<LogService>();
|
||||
|
||||
// Setup common i18n translations
|
||||
i18nService.t.mockImplementation((key: string) => {
|
||||
@@ -44,9 +50,12 @@ describe("MemberExportService", () => {
|
||||
custom: "Custom",
|
||||
// Boolean states
|
||||
enabled: "Enabled",
|
||||
optionEnabled: "Enabled",
|
||||
disabled: "Disabled",
|
||||
enrolled: "Enrolled",
|
||||
notEnrolled: "Not Enrolled",
|
||||
// Error messages
|
||||
noMembersToExport: "No members to export",
|
||||
};
|
||||
return translations[key] || key;
|
||||
});
|
||||
@@ -54,6 +63,8 @@ describe("MemberExportService", () => {
|
||||
TestBed.configureTestingModule({
|
||||
providers: [
|
||||
MemberExportService,
|
||||
{ provide: FileDownloadService, useValue: fileDownloadService },
|
||||
{ provide: LogService, useValue: logService },
|
||||
{ provide: I18nService, useValue: i18nService },
|
||||
UserTypePipe,
|
||||
UserStatusPipe,
|
||||
@@ -88,8 +99,18 @@ describe("MemberExportService", () => {
|
||||
} as OrganizationUserView,
|
||||
];
|
||||
|
||||
const csvData = service.getMemberExport(members);
|
||||
const result = service.getMemberExport(members);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.error).toBeUndefined();
|
||||
expect(fileDownloadService.download).toHaveBeenCalledTimes(1);
|
||||
|
||||
const downloadCall = fileDownloadService.download.mock.calls[0][0];
|
||||
expect(downloadCall.fileName).toContain("org-members");
|
||||
expect(downloadCall.fileName).toContain(".csv");
|
||||
expect(downloadCall.blobOptions).toEqual({ type: "text/plain" });
|
||||
|
||||
const csvData = downloadCall.blobData as string;
|
||||
expect(csvData).toContain("Email,Name,Status,Role,Two-step Login,Account Recovery");
|
||||
expect(csvData).toContain("user1@example.com");
|
||||
expect(csvData).toContain("User One");
|
||||
@@ -114,8 +135,12 @@ describe("MemberExportService", () => {
|
||||
} as OrganizationUserView,
|
||||
];
|
||||
|
||||
const csvData = service.getMemberExport(members);
|
||||
const result = service.getMemberExport(members);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(fileDownloadService.download).toHaveBeenCalled();
|
||||
|
||||
const csvData = fileDownloadService.download.mock.calls[0][0].blobData as string;
|
||||
expect(csvData).toContain("user@example.com");
|
||||
// Empty name is represented as an empty field in CSV
|
||||
expect(csvData).toContain("user@example.com,,Confirmed");
|
||||
@@ -135,17 +160,23 @@ describe("MemberExportService", () => {
|
||||
} as OrganizationUserView,
|
||||
];
|
||||
|
||||
const csvData = service.getMemberExport(members);
|
||||
const result = service.getMemberExport(members);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(fileDownloadService.download).toHaveBeenCalled();
|
||||
|
||||
const csvData = fileDownloadService.download.mock.calls[0][0].blobData as string;
|
||||
expect(csvData).toContain("user@example.com");
|
||||
expect(csvData).toBeDefined();
|
||||
});
|
||||
|
||||
it("should handle empty members array", () => {
|
||||
const csvData = service.getMemberExport([]);
|
||||
const result = service.getMemberExport([]);
|
||||
|
||||
// When array is empty, papaparse returns an empty string
|
||||
expect(csvData).toBe("");
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toBeDefined();
|
||||
expect(result.error?.message).toBe("No members to export");
|
||||
expect(fileDownloadService.download).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,7 +2,9 @@ import { inject, Injectable } from "@angular/core";
|
||||
import * as papa from "papaparse";
|
||||
|
||||
import { UserTypePipe } from "@bitwarden/angular/pipes/user-type.pipe";
|
||||
import { FileDownloadService } from "@bitwarden/common/platform/abstractions/file-download/file-download.service";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
|
||||
import { ExportHelper } from "@bitwarden/vault-export-core";
|
||||
|
||||
import { OrganizationUserView } from "../../../core";
|
||||
@@ -10,40 +12,71 @@ import { UserStatusPipe } from "../../pipes";
|
||||
|
||||
import { MemberExport } from "./member.export";
|
||||
|
||||
export interface MemberExportResult {
|
||||
success: boolean;
|
||||
error?: { message: string };
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class MemberExportService {
|
||||
private i18nService = inject(I18nService);
|
||||
private userTypePipe = inject(UserTypePipe);
|
||||
private userStatusPipe = inject(UserStatusPipe);
|
||||
private fileDownloadService = inject(FileDownloadService);
|
||||
private logService = inject(LogService);
|
||||
|
||||
getMemberExport(members: OrganizationUserView[]): string {
|
||||
const exportData = members.map((m) =>
|
||||
MemberExport.fromOrganizationUserView(
|
||||
this.i18nService,
|
||||
this.userTypePipe,
|
||||
this.userStatusPipe,
|
||||
m,
|
||||
),
|
||||
);
|
||||
getMemberExport(data: OrganizationUserView[]): MemberExportResult {
|
||||
try {
|
||||
const members = data;
|
||||
if (!members || members.length === 0) {
|
||||
return { success: false, error: { message: this.i18nService.t("noMembersToExport") } };
|
||||
}
|
||||
|
||||
const headers: string[] = [
|
||||
this.i18nService.t("email"),
|
||||
this.i18nService.t("name"),
|
||||
this.i18nService.t("status"),
|
||||
this.i18nService.t("role"),
|
||||
this.i18nService.t("twoStepLogin"),
|
||||
this.i18nService.t("accountRecovery"),
|
||||
this.i18nService.t("secretsManager"),
|
||||
this.i18nService.t("groups"),
|
||||
];
|
||||
const exportData = members.map((m) =>
|
||||
MemberExport.fromOrganizationUserView(
|
||||
this.i18nService,
|
||||
this.userTypePipe,
|
||||
this.userStatusPipe,
|
||||
m,
|
||||
),
|
||||
);
|
||||
|
||||
return papa.unparse(exportData, {
|
||||
columns: headers,
|
||||
header: true,
|
||||
});
|
||||
const headers: string[] = [
|
||||
this.i18nService.t("email"),
|
||||
this.i18nService.t("name"),
|
||||
this.i18nService.t("status"),
|
||||
this.i18nService.t("role"),
|
||||
this.i18nService.t("twoStepLogin"),
|
||||
this.i18nService.t("accountRecovery"),
|
||||
this.i18nService.t("secretsManager"),
|
||||
this.i18nService.t("groups"),
|
||||
];
|
||||
|
||||
const csvData = papa.unparse(exportData, {
|
||||
columns: headers,
|
||||
header: true,
|
||||
});
|
||||
|
||||
const fileName = this.getFileName("org-members");
|
||||
|
||||
this.fileDownloadService.download({
|
||||
fileName: fileName,
|
||||
blobData: csvData,
|
||||
blobOptions: { type: "text/plain" },
|
||||
});
|
||||
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
this.logService.error(`Failed to export members: ${error}`);
|
||||
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : this.i18nService.t("unexpectedError");
|
||||
|
||||
return { success: false, error: { message: errorMessage } };
|
||||
}
|
||||
}
|
||||
|
||||
getFileName(prefix: string | null = null, extension = "csv"): string {
|
||||
private getFileName(prefix: string | null = null, extension = "csv"): string {
|
||||
return ExportHelper.getFileName(prefix ?? "", extension);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,225 @@
|
||||
<app-header>
|
||||
<bit-search class="tw-grow" [formControl]="searchControl" [placeholder]="'searchMembers' | i18n">
|
||||
</bit-search>
|
||||
<button type="button" bitButton buttonType="primary" (click)="invite()">
|
||||
<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"
|
||||
>
|
||||
<bit-toggle [value]="null">
|
||||
{{ "all" | i18n }}
|
||||
<span bitBadge variant="info" *ngIf="dataSource.activeUserCount as allCount">
|
||||
{{ 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.Accepted">
|
||||
{{ "needsConfirmation" | i18n }}
|
||||
<span bitBadge variant="info" *ngIf="dataSource.acceptedUserCount as acceptedCount">
|
||||
{{ acceptedCount }}
|
||||
</span>
|
||||
</bit-toggle>
|
||||
</bit-toggle-group>
|
||||
</div>
|
||||
|
||||
<ng-container *ngIf="!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"
|
||||
>
|
||||
{{ "providerUsersNeedConfirmed" | i18n }}
|
||||
</bit-callout>
|
||||
<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">
|
||||
<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 bitSortable="type">{{ "role" | i18n }}</th>
|
||||
<th bitCell class="tw-w-10">
|
||||
<button
|
||||
[bitMenuTriggerFor]="headerMenu"
|
||||
type="button"
|
||||
bitIconButton="bwi-ellipsis-v"
|
||||
size="small"
|
||||
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()">
|
||||
<span class="tw-text-danger">
|
||||
<i aria-hidden="true" class="bwi bwi-close"></i>
|
||||
{{ "remove" | i18n }}
|
||||
</span>
|
||||
</button>
|
||||
</bit-menu>
|
||||
</th>
|
||||
</tr>
|
||||
</ng-container>
|
||||
<ng-template body let-rows$>
|
||||
<tr
|
||||
bitRow
|
||||
*cdkVirtualFor="let user of rows$"
|
||||
alignContent="middle"
|
||||
[ngClass]="rowHeightClass"
|
||||
>
|
||||
<td bitCell (click)="dataSource.checkUser(user)">
|
||||
<input type="checkbox" bitCheckbox [(ngModel)]="$any(user).checked" />
|
||||
</td>
|
||||
<td bitCell (click)="edit(user)" class="tw-cursor-pointer">
|
||||
<div class="tw-flex tw-items-center">
|
||||
<bit-avatar
|
||||
size="small"
|
||||
[text]="user | userName"
|
||||
[id]="user.userId"
|
||||
[color]="user.avatarColor"
|
||||
class="tw-mr-3"
|
||||
></bit-avatar>
|
||||
<div class="tw-flex tw-flex-col">
|
||||
<div>
|
||||
<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 }}
|
||||
</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>
|
||||
</td>
|
||||
<td bitCell>
|
||||
<button
|
||||
[bitMenuTriggerFor]="rowMenu"
|
||||
type="button"
|
||||
bitIconButton="bwi-ellipsis-v"
|
||||
size="small"
|
||||
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)">
|
||||
<span class="tw-text-danger">
|
||||
<i class="bwi bwi-fw bwi-close" aria-hidden="true"></i>
|
||||
{{ "remove" | i18n }}
|
||||
</span>
|
||||
</button>
|
||||
</bit-menu>
|
||||
</td>
|
||||
</tr>
|
||||
</ng-template>
|
||||
</bit-table>
|
||||
</cdk-virtual-scroll-viewport>
|
||||
</ng-container>
|
||||
</ng-container>
|
||||
@@ -0,0 +1,338 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
// @ts-strict-ignore
|
||||
import { Component } from "@angular/core";
|
||||
import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
|
||||
import { ActivatedRoute, Router } from "@angular/router";
|
||||
import { combineLatest, firstValueFrom, lastValueFrom, switchMap } from "rxjs";
|
||||
import { first, map } 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 { 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";
|
||||
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||
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 { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
|
||||
import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service";
|
||||
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 { 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 {
|
||||
CloudBulkReinviteLimit,
|
||||
MaxCheckedCount,
|
||||
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 { 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";
|
||||
|
||||
import {
|
||||
AddEditMemberDialogComponent,
|
||||
AddEditMemberDialogParams,
|
||||
AddEditMemberDialogResultType,
|
||||
} from "./dialogs/add-edit-member-dialog.component";
|
||||
import { BulkConfirmDialogComponent } from "./dialogs/bulk-confirm-dialog.component";
|
||||
import { BulkRemoveDialogComponent } from "./dialogs/bulk-remove-dialog.component";
|
||||
|
||||
type ProviderUser = ProviderUserUserDetailsResponse;
|
||||
|
||||
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: "deprecated_members.component.html",
|
||||
standalone: false,
|
||||
})
|
||||
export class MembersComponent extends BaseMembersComponent<ProviderUser> {
|
||||
accessEvents = false;
|
||||
dataSource: MembersTableDataSource;
|
||||
loading = true;
|
||||
providerId: string;
|
||||
rowHeight = 70;
|
||||
rowHeightClass = `tw-h-[70px]`;
|
||||
status: ProviderUserStatusType = null;
|
||||
|
||||
userStatusType = ProviderUserStatusType;
|
||||
userType = ProviderUserType;
|
||||
|
||||
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,
|
||||
private configService: ConfigService,
|
||||
private environmentService: EnvironmentService,
|
||||
) {
|
||||
super(
|
||||
apiService,
|
||||
i18nService,
|
||||
keyService,
|
||||
validationService,
|
||||
logService,
|
||||
userNamePipe,
|
||||
dialogService,
|
||||
organizationManagementPreferencesService,
|
||||
toastService,
|
||||
);
|
||||
|
||||
this.dataSource = new MembersTableDataSource(this.configService, this.environmentService);
|
||||
|
||||
combineLatest([
|
||||
this.activatedRoute.parent.params,
|
||||
this.activatedRoute.queryParams.pipe(first()),
|
||||
])
|
||||
.pipe(
|
||||
switchMap(async ([urlParams, queryParams]) => {
|
||||
this.searchControl.setValue(queryParams.search);
|
||||
this.dataSource.filter = peopleFilter(queryParams.search, null);
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
}),
|
||||
takeUntilDestroyed(),
|
||||
)
|
||||
.subscribe();
|
||||
}
|
||||
|
||||
async bulkConfirm(): Promise<void> {
|
||||
if (this.actionPromise != null) {
|
||||
return;
|
||||
}
|
||||
|
||||
const users = this.dataSource.getCheckedUsersWithLimit(MaxCheckedCount);
|
||||
|
||||
const dialogRef = BulkConfirmDialogComponent.open(this.dialogService, {
|
||||
data: {
|
||||
providerId: this.providerId,
|
||||
users: users,
|
||||
},
|
||||
});
|
||||
|
||||
await lastValueFrom(dialogRef.closed);
|
||||
await this.load();
|
||||
}
|
||||
|
||||
async bulkReinvite(): Promise<void> {
|
||||
if (this.actionPromise != null) {
|
||||
return;
|
||||
}
|
||||
|
||||
let users: ProviderUser[];
|
||||
if (this.dataSource.isIncreasedBulkLimitEnabled()) {
|
||||
users = this.dataSource.getCheckedUsersInVisibleOrder();
|
||||
} else {
|
||||
users = this.dataSource.getCheckedUsers();
|
||||
}
|
||||
|
||||
const allInvitedUsers = users.filter((user) => user.status === ProviderUserStatusType.Invited);
|
||||
|
||||
// Capture the original count BEFORE enforcing the limit
|
||||
const originalInvitedCount = allInvitedUsers.length;
|
||||
|
||||
// When feature flag is enabled, limit invited users and uncheck the excess
|
||||
let checkedInvitedUsers: ProviderUser[];
|
||||
if (this.dataSource.isIncreasedBulkLimitEnabled()) {
|
||||
checkedInvitedUsers = this.dataSource.limitAndUncheckExcess(
|
||||
allInvitedUsers,
|
||||
CloudBulkReinviteLimit,
|
||||
);
|
||||
} else {
|
||||
checkedInvitedUsers = allInvitedUsers;
|
||||
}
|
||||
|
||||
if (checkedInvitedUsers.length <= 0) {
|
||||
this.toastService.showToast({
|
||||
variant: "error",
|
||||
title: this.i18nService.t("errorOccurred"),
|
||||
message: this.i18nService.t("noSelectedUsersApplicable"),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// When feature flag is enabled, show toast instead of dialog
|
||||
if (this.dataSource.isIncreasedBulkLimitEnabled()) {
|
||||
await this.apiService.postManyProviderUserReinvite(
|
||||
this.providerId,
|
||||
new ProviderUserBulkRequest(checkedInvitedUsers.map((user) => user.id)),
|
||||
);
|
||||
|
||||
const selectedCount = originalInvitedCount;
|
||||
const invitedCount = checkedInvitedUsers.length;
|
||||
|
||||
if (selectedCount > CloudBulkReinviteLimit) {
|
||||
const excludedCount = selectedCount - CloudBulkReinviteLimit;
|
||||
this.toastService.showToast({
|
||||
variant: "success",
|
||||
message: this.i18nService.t(
|
||||
"bulkReinviteLimitedSuccessToast",
|
||||
CloudBulkReinviteLimit.toLocaleString(),
|
||||
selectedCount.toLocaleString(),
|
||||
excludedCount.toLocaleString(),
|
||||
),
|
||||
});
|
||||
} else {
|
||||
this.toastService.showToast({
|
||||
variant: "success",
|
||||
message: this.i18nService.t("bulkReinviteSuccessToast", invitedCount.toString()),
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// Feature flag disabled - show legacy dialog
|
||||
const request = this.apiService.postManyProviderUserReinvite(
|
||||
this.providerId,
|
||||
new ProviderUserBulkRequest(checkedInvitedUsers.map((user) => user.id)),
|
||||
);
|
||||
|
||||
const dialogRef = BulkStatusComponent.open(this.dialogService, {
|
||||
data: {
|
||||
users: users,
|
||||
filteredUsers: checkedInvitedUsers,
|
||||
request,
|
||||
successfulMessage: this.i18nService.t("bulkReinviteMessage"),
|
||||
},
|
||||
});
|
||||
await lastValueFrom(dialogRef.closed);
|
||||
}
|
||||
} catch (error) {
|
||||
this.validationService.showError(error);
|
||||
}
|
||||
}
|
||||
|
||||
async invite() {
|
||||
await this.edit(null);
|
||||
}
|
||||
|
||||
async bulkRemove(): Promise<void> {
|
||||
if (this.actionPromise != null) {
|
||||
return;
|
||||
}
|
||||
|
||||
const users = this.dataSource.getCheckedUsersWithLimit(MaxCheckedCount);
|
||||
|
||||
const dialogRef = BulkRemoveDialogComponent.open(this.dialogService, {
|
||||
data: {
|
||||
providerId: this.providerId,
|
||||
users: users,
|
||||
},
|
||||
});
|
||||
|
||||
await lastValueFrom(dialogRef.closed);
|
||||
await this.load();
|
||||
}
|
||||
|
||||
async confirmUser(user: ProviderUser, publicKey: Uint8Array): 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),
|
||||
),
|
||||
);
|
||||
assertNonNullish(providerKey, "Provider key not found");
|
||||
|
||||
const key = await this.encryptService.encapsulateKeyUnsigned(providerKey, publicKey);
|
||||
const request = new ProviderUserConfirmRequest(key.encryptedString);
|
||||
await this.apiService.postProviderUserConfirm(this.providerId, user.id, request);
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
}
|
||||
|
||||
removeUser = async (id: string): Promise<MemberActionResult> => {
|
||||
try {
|
||||
await this.apiService.deleteProviderUser(this.providerId, id);
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
};
|
||||
|
||||
edit = async (user: ProviderUser | null): Promise<void> => {
|
||||
const data: AddEditMemberDialogParams = {
|
||||
providerId: this.providerId,
|
||||
user,
|
||||
};
|
||||
|
||||
const dialogRef = AddEditMemberDialogComponent.open(this.dialogService, {
|
||||
data,
|
||||
});
|
||||
|
||||
const result = await lastValueFrom(dialogRef.closed);
|
||||
|
||||
switch (result) {
|
||||
case AddEditMemberDialogResultType.Saved:
|
||||
case AddEditMemberDialogResultType.Deleted:
|
||||
await this.load();
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
openEventsDialog = (user: ProviderUser): DialogRef<void> =>
|
||||
openEntityEventsDialog(this.dialogService, {
|
||||
data: {
|
||||
name: this.userNamePipe.transform(user),
|
||||
providerId: this.providerId,
|
||||
entityId: user.id,
|
||||
showUser: false,
|
||||
entity: "user",
|
||||
},
|
||||
});
|
||||
|
||||
getUsers = (): Promise<ListResponse<ProviderUser>> =>
|
||||
this.apiService.getProviderUsers(this.providerId);
|
||||
|
||||
reinviteUser = async (id: string): Promise<MemberActionResult> => {
|
||||
try {
|
||||
await this.apiService.postProviderUserReinvite(this.providerId, id);
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -3,6 +3,7 @@
|
||||
import { Component, Inject } from "@angular/core";
|
||||
import { FormControl, FormGroup, Validators } from "@angular/forms";
|
||||
|
||||
import { UserNamePipe } from "@bitwarden/angular/pipes/user-name.pipe";
|
||||
import { ApiService } from "@bitwarden/common/abstractions/api.service";
|
||||
import { ProviderUserType } from "@bitwarden/common/admin-console/enums";
|
||||
import { ProviderUserInviteRequest } from "@bitwarden/common/admin-console/models/request/provider/provider-user-invite.request";
|
||||
@@ -15,14 +16,11 @@ import {
|
||||
DialogService,
|
||||
ToastService,
|
||||
} from "@bitwarden/components";
|
||||
import { ProviderUser } from "@bitwarden/web-vault/app/admin-console/common/people-table-data-source";
|
||||
|
||||
export type AddEditMemberDialogParams = {
|
||||
providerId: string;
|
||||
user?: {
|
||||
id: string;
|
||||
name: string;
|
||||
type: ProviderUserType;
|
||||
};
|
||||
user?: ProviderUser;
|
||||
};
|
||||
|
||||
// FIXME: update to use a const object instead of a typescript enum
|
||||
@@ -59,6 +57,7 @@ export class AddEditMemberDialogComponent {
|
||||
private dialogService: DialogService,
|
||||
private i18nService: I18nService,
|
||||
private toastService: ToastService,
|
||||
private userNamePipe: UserNamePipe,
|
||||
) {
|
||||
this.editing = this.loading = this.dialogParams.user != null;
|
||||
if (this.editing) {
|
||||
@@ -78,8 +77,10 @@ export class AddEditMemberDialogComponent {
|
||||
return;
|
||||
}
|
||||
|
||||
const userName = this.userNamePipe.transform(this.dialogParams.user);
|
||||
|
||||
const confirmed = await this.dialogService.openSimpleDialog({
|
||||
title: this.dialogParams.user.name,
|
||||
title: userName,
|
||||
content: { key: "removeUserConfirmation" },
|
||||
type: "warning",
|
||||
});
|
||||
@@ -96,7 +97,7 @@ export class AddEditMemberDialogComponent {
|
||||
this.toastService.showToast({
|
||||
variant: "success",
|
||||
title: null,
|
||||
message: this.i18nService.t("removedUserId", this.dialogParams.user.name),
|
||||
message: this.i18nService.t("removedUserId", userName),
|
||||
});
|
||||
|
||||
this.dialogRef.close(AddEditMemberDialogResultType.Deleted);
|
||||
@@ -118,13 +119,12 @@ export class AddEditMemberDialogComponent {
|
||||
await this.apiService.postProviderUserInvite(this.dialogParams.providerId, request);
|
||||
}
|
||||
|
||||
const userName = this.editing ? this.userNamePipe.transform(this.dialogParams.user) : undefined;
|
||||
|
||||
this.toastService.showToast({
|
||||
variant: "success",
|
||||
title: null,
|
||||
message: this.i18nService.t(
|
||||
this.editing ? "editedUserId" : "invitedUsers",
|
||||
this.dialogParams.user?.name,
|
||||
),
|
||||
message: this.i18nService.t(this.editing ? "editedUserId" : "invitedUsers", userName),
|
||||
});
|
||||
|
||||
this.dialogRef.close(AddEditMemberDialogResultType.Saved);
|
||||
|
||||
@@ -36,6 +36,7 @@ type BulkConfirmDialogParams = {
|
||||
@Component({
|
||||
templateUrl:
|
||||
"../../../../../../../../apps/web/src/app/admin-console/organizations/members/components/bulk/bulk-confirm-dialog.component.html",
|
||||
selector: "provider-bulk-comfirm-dialog",
|
||||
standalone: false,
|
||||
})
|
||||
export class BulkConfirmDialogComponent extends BaseBulkConfirmComponent {
|
||||
|
||||
@@ -21,6 +21,7 @@ type BulkRemoveDialogParams = {
|
||||
@Component({
|
||||
templateUrl:
|
||||
"../../../../../../../../apps/web/src/app/admin-console/organizations/members/components/bulk/bulk-remove-dialog.component.html",
|
||||
selector: "provider-bulk-remove-dialog",
|
||||
standalone: false,
|
||||
})
|
||||
export class BulkRemoveDialogComponent extends BaseBulkRemoveComponent {
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
@let providerId = providerId$ | async;
|
||||
@let bulkMenuOptions = bulkMenuOptions$ | async;
|
||||
@let showConfirmBanner = showConfirmBanner$ | async;
|
||||
@let dataSource = this.dataSource();
|
||||
@let isProcessing = this.isProcessing();
|
||||
|
||||
<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)="edit(providerId)">
|
||||
<i class="bwi bwi-plus bwi-fw" aria-hidden="true"></i>
|
||||
{{ "inviteMember" | i18n }}
|
||||
</button>
|
||||
@@ -13,28 +19,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 +48,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 (showConfirmBanner) {
|
||||
<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 +85,33 @@
|
||||
label="{{ 'options' | i18n }}"
|
||||
></button>
|
||||
<bit-menu #headerMenu>
|
||||
@if (bulkMenuOptions.showBulkReinviteUsers) {
|
||||
<button
|
||||
type="button"
|
||||
bitMenuItem
|
||||
(click)="isProcessing ? null : bulkReinvite(providerId)"
|
||||
>
|
||||
<i class="bwi bwi-fw bwi-envelope" aria-hidden="true"></i>
|
||||
{{ "reinviteSelected" | i18n }}
|
||||
</button>
|
||||
}
|
||||
@if (bulkMenuOptions.showBulkConfirmUsers) {
|
||||
<button
|
||||
type="button"
|
||||
bitMenuItem
|
||||
(click)="isProcessing ? null : 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)="bulkReinvite()"
|
||||
*ngIf="showBulkReinviteUsers"
|
||||
(click)="isProcessing ? null : bulkRemove(providerId)"
|
||||
>
|
||||
<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()">
|
||||
<span class="tw-text-danger">
|
||||
<i aria-hidden="true" class="bwi bwi-close"></i>
|
||||
{{ "remove" | i18n }}
|
||||
@@ -122,7 +131,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(providerId, user)" class="tw-cursor-pointer">
|
||||
<div class="tw-flex tw-items-center">
|
||||
<bit-avatar
|
||||
size="small"
|
||||
@@ -132,44 +141,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 +186,43 @@
|
||||
label="{{ 'options' | i18n }}"
|
||||
></button>
|
||||
<bit-menu #rowMenu>
|
||||
@if (user.status === userStatusType.Invited) {
|
||||
<button
|
||||
type="button"
|
||||
bitMenuItem
|
||||
(click)="isProcessing ? null : 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)="isProcessing ? null : 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)="isProcessing ? null : openEventsDialog(user, providerId)"
|
||||
>
|
||||
<i class="bwi bwi-fw bwi-file-text" aria-hidden="true"></i>
|
||||
{{ "eventLogs" | i18n }}
|
||||
</button>
|
||||
}
|
||||
<button
|
||||
type="button"
|
||||
bitMenuItem
|
||||
(click)="reinvite(user)"
|
||||
*ngIf="user.status === userStatusType.Invited"
|
||||
(click)="isProcessing ? null : remove(user, providerId)"
|
||||
>
|
||||
<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)">
|
||||
<span class="tw-text-danger">
|
||||
<i class="bwi bwi-fw bwi-close" aria-hidden="true"></i>
|
||||
{{ "remove" | i18n }}
|
||||
@@ -221,5 +234,5 @@
|
||||
</ng-template>
|
||||
</bit-table>
|
||||
</cdk-virtual-scroll-viewport>
|
||||
</ng-container>
|
||||
</ng-container>
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,55 +1,59 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
// @ts-strict-ignore
|
||||
import { Component } from "@angular/core";
|
||||
import { Component, inject, signal, WritableSignal } from "@angular/core";
|
||||
import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
|
||||
import { ActivatedRoute, Router } from "@angular/router";
|
||||
import { combineLatest, firstValueFrom, lastValueFrom, switchMap } from "rxjs";
|
||||
import { FormControl } from "@angular/forms";
|
||||
import { ActivatedRoute } from "@angular/router";
|
||||
import {
|
||||
BehaviorSubject,
|
||||
combineLatest,
|
||||
debounceTime,
|
||||
firstValueFrom,
|
||||
lastValueFrom,
|
||||
Observable,
|
||||
switchMap,
|
||||
} from "rxjs";
|
||||
import { first, map } 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";
|
||||
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||
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 { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
|
||||
import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service";
|
||||
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 { 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 { LogService } from "@bitwarden/logging";
|
||||
import {
|
||||
CloudBulkReinviteLimit,
|
||||
MaxCheckedCount,
|
||||
peopleFilter,
|
||||
PeopleTableDataSource,
|
||||
ProviderUser,
|
||||
ProvidersTableDataSource,
|
||||
showConfirmBanner,
|
||||
} 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 { 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";
|
||||
import { MemberActionsService } from "@bitwarden/web-vault/app/admin-console/organizations/members/services/member-actions/member-actions.service";
|
||||
|
||||
import {
|
||||
AddEditMemberDialogComponent,
|
||||
AddEditMemberDialogParams,
|
||||
AddEditMemberDialogResultType,
|
||||
} from "./dialogs/add-edit-member-dialog.component";
|
||||
import { BulkConfirmDialogComponent } from "./dialogs/bulk-confirm-dialog.component";
|
||||
import { BulkRemoveDialogComponent } from "./dialogs/bulk-remove-dialog.component";
|
||||
import {
|
||||
MemberActionResult,
|
||||
ProviderActionsService,
|
||||
} from "./services/provider-actions/provider-actions.service";
|
||||
|
||||
type ProviderUser = ProviderUserUserDetailsResponse;
|
||||
|
||||
class MembersTableDataSource extends PeopleTableDataSource<ProviderUser> {
|
||||
protected statusType = ProviderUserStatusType;
|
||||
interface BulkProviderFlags {
|
||||
showBulkConfirmUsers: boolean;
|
||||
showBulkReinviteUsers: boolean;
|
||||
}
|
||||
|
||||
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
|
||||
@@ -58,77 +62,80 @@ class MembersTableDataSource extends PeopleTableDataSource<ProviderUser> {
|
||||
templateUrl: "members.component.html",
|
||||
standalone: false,
|
||||
})
|
||||
export class MembersComponent extends BaseMembersComponent<ProviderUser> {
|
||||
accessEvents = false;
|
||||
dataSource: MembersTableDataSource;
|
||||
loading = true;
|
||||
providerId: string;
|
||||
rowHeight = 70;
|
||||
rowHeightClass = `tw-h-[70px]`;
|
||||
status: ProviderUserStatusType = null;
|
||||
export class vNextMembersComponent {
|
||||
protected apiService = inject(ApiService);
|
||||
protected dialogService = inject(DialogService);
|
||||
protected i18nService = inject(I18nService);
|
||||
protected userNamePipe = inject(UserNamePipe);
|
||||
protected validationService = inject(ValidationService);
|
||||
protected toastService = inject(ToastService);
|
||||
private activatedRoute = inject(ActivatedRoute);
|
||||
private providerService = inject(ProviderService);
|
||||
private accountService = inject(AccountService);
|
||||
private configService = inject(ConfigService);
|
||||
private environmentService = inject(EnvironmentService);
|
||||
private providerActionsService = inject(ProviderActionsService);
|
||||
private memberActionsService = inject(MemberActionsService);
|
||||
private logService = inject(LogService);
|
||||
|
||||
userStatusType = ProviderUserStatusType;
|
||||
userType = ProviderUserType;
|
||||
protected accessEvents = false;
|
||||
|
||||
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,
|
||||
private configService: ConfigService,
|
||||
private environmentService: EnvironmentService,
|
||||
) {
|
||||
super(
|
||||
apiService,
|
||||
i18nService,
|
||||
keyService,
|
||||
validationService,
|
||||
logService,
|
||||
userNamePipe,
|
||||
dialogService,
|
||||
organizationManagementPreferencesService,
|
||||
toastService,
|
||||
);
|
||||
protected providerId$: Observable<ProviderId>;
|
||||
protected provider$: Observable<Provider | undefined>;
|
||||
|
||||
this.dataSource = new MembersTableDataSource(this.configService, this.environmentService);
|
||||
protected rowHeight = 70;
|
||||
protected rowHeightClass = `tw-h-[70px]`;
|
||||
protected status: ProviderUserStatusType | undefined;
|
||||
|
||||
combineLatest([
|
||||
this.activatedRoute.parent.params,
|
||||
this.activatedRoute.queryParams.pipe(first()),
|
||||
])
|
||||
protected userStatusType = ProviderUserStatusType;
|
||||
protected userType = ProviderUserType;
|
||||
|
||||
protected searchControl = new FormControl("", { nonNullable: true });
|
||||
protected statusToggle = new BehaviorSubject<ProviderUserStatusType | undefined>(undefined);
|
||||
|
||||
protected readonly dataSource: WritableSignal<ProvidersTableDataSource> = signal(
|
||||
new ProvidersTableDataSource(this.configService, this.environmentService),
|
||||
);
|
||||
protected readonly firstLoaded: WritableSignal<boolean> = signal(false);
|
||||
|
||||
protected bulkMenuOptions$ = this.dataSource()
|
||||
.usersUpdated()
|
||||
.pipe(map((members) => this.bulkMenuOptions(members)));
|
||||
|
||||
protected showConfirmBanner$ = this.dataSource()
|
||||
.usersUpdated()
|
||||
.pipe(map(() => showConfirmBanner(this.dataSource())));
|
||||
|
||||
protected isProcessing = this.providerActionsService.isProcessing;
|
||||
|
||||
constructor() {
|
||||
// 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)));
|
||||
|
||||
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);
|
||||
const user = this.dataSource().data.find((user) => user.id === queryParams.viewEvents);
|
||||
if (user && user.status === ProviderUserStatusType.Confirmed) {
|
||||
this.openEventsDialog(user);
|
||||
this.openEventsDialog(user, providerId);
|
||||
}
|
||||
}
|
||||
}),
|
||||
@@ -137,17 +144,19 @@ export class MembersComponent extends BaseMembersComponent<ProviderUser> {
|
||||
.subscribe();
|
||||
}
|
||||
|
||||
async bulkConfirm(): Promise<void> {
|
||||
if (this.actionPromise != null) {
|
||||
return;
|
||||
}
|
||||
|
||||
const users = this.dataSource.getCheckedUsersWithLimit(MaxCheckedCount);
|
||||
async load() {
|
||||
const providerId = await firstValueFrom(this.providerId$);
|
||||
const response = await this.apiService.getProviderUsers(providerId);
|
||||
this.dataSource().data = response.data;
|
||||
this.firstLoaded.set(true);
|
||||
}
|
||||
|
||||
async bulkConfirm(providerId: ProviderId): Promise<void> {
|
||||
const users = this.dataSource().getCheckedUsersWithLimit(MaxCheckedCount);
|
||||
const dialogRef = BulkConfirmDialogComponent.open(this.dialogService, {
|
||||
data: {
|
||||
providerId: this.providerId,
|
||||
users: users,
|
||||
providerId: providerId,
|
||||
users,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -155,16 +164,12 @@ export class MembersComponent extends BaseMembersComponent<ProviderUser> {
|
||||
await this.load();
|
||||
}
|
||||
|
||||
async bulkReinvite(): Promise<void> {
|
||||
if (this.actionPromise != null) {
|
||||
return;
|
||||
}
|
||||
|
||||
async bulkReinvite(providerId: ProviderId): Promise<void> {
|
||||
let users: ProviderUser[];
|
||||
if (this.dataSource.isIncreasedBulkLimitEnabled()) {
|
||||
users = this.dataSource.getCheckedUsersInVisibleOrder();
|
||||
if (this.dataSource().isIncreasedBulkLimitEnabled()) {
|
||||
users = this.dataSource().getCheckedUsersInVisibleOrder();
|
||||
} else {
|
||||
users = this.dataSource.getCheckedUsers();
|
||||
users = this.dataSource().getCheckedUsers();
|
||||
}
|
||||
|
||||
const allInvitedUsers = users.filter((user) => user.status === ProviderUserStatusType.Invited);
|
||||
@@ -174,8 +179,8 @@ export class MembersComponent extends BaseMembersComponent<ProviderUser> {
|
||||
|
||||
// When feature flag is enabled, limit invited users and uncheck the excess
|
||||
let checkedInvitedUsers: ProviderUser[];
|
||||
if (this.dataSource.isIncreasedBulkLimitEnabled()) {
|
||||
checkedInvitedUsers = this.dataSource.limitAndUncheckExcess(
|
||||
if (this.dataSource().isIncreasedBulkLimitEnabled()) {
|
||||
checkedInvitedUsers = this.dataSource().limitAndUncheckExcess(
|
||||
allInvitedUsers,
|
||||
CloudBulkReinviteLimit,
|
||||
);
|
||||
@@ -194,9 +199,9 @@ export class MembersComponent extends BaseMembersComponent<ProviderUser> {
|
||||
|
||||
try {
|
||||
// When feature flag is enabled, show toast instead of dialog
|
||||
if (this.dataSource.isIncreasedBulkLimitEnabled()) {
|
||||
if (this.dataSource().isIncreasedBulkLimitEnabled()) {
|
||||
await this.apiService.postManyProviderUserReinvite(
|
||||
this.providerId,
|
||||
providerId,
|
||||
new ProviderUserBulkRequest(checkedInvitedUsers.map((user) => user.id)),
|
||||
);
|
||||
|
||||
@@ -223,7 +228,7 @@ export class MembersComponent extends BaseMembersComponent<ProviderUser> {
|
||||
} else {
|
||||
// Feature flag disabled - show legacy dialog
|
||||
const request = this.apiService.postManyProviderUserReinvite(
|
||||
this.providerId,
|
||||
providerId,
|
||||
new ProviderUserBulkRequest(checkedInvitedUsers.map((user) => user.id)),
|
||||
);
|
||||
|
||||
@@ -242,21 +247,12 @@ export class MembersComponent extends BaseMembersComponent<ProviderUser> {
|
||||
}
|
||||
}
|
||||
|
||||
async invite() {
|
||||
await this.edit(null);
|
||||
}
|
||||
|
||||
async bulkRemove(): Promise<void> {
|
||||
if (this.actionPromise != null) {
|
||||
return;
|
||||
}
|
||||
|
||||
const users = this.dataSource.getCheckedUsersWithLimit(MaxCheckedCount);
|
||||
|
||||
async bulkRemove(providerId: ProviderId): Promise<void> {
|
||||
const users = this.dataSource().getCheckedUsersWithLimit(MaxCheckedCount);
|
||||
const dialogRef = BulkRemoveDialogComponent.open(this.dialogService, {
|
||||
data: {
|
||||
providerId: this.providerId,
|
||||
users: users,
|
||||
providerId: providerId,
|
||||
users,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -264,51 +260,58 @@ export class MembersComponent extends BaseMembersComponent<ProviderUser> {
|
||||
await this.load();
|
||||
}
|
||||
|
||||
async confirmUser(user: ProviderUser, publicKey: Uint8Array): 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),
|
||||
),
|
||||
);
|
||||
assertNonNullish(providerKey, "Provider key not found");
|
||||
|
||||
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);
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
private async removeUserConfirmationDialog(user: ProviderUser) {
|
||||
return this.dialogService.openSimpleDialog({
|
||||
title: this.userNamePipe.transform(user),
|
||||
content: { key: "removeUserConfirmation" },
|
||||
type: "warning",
|
||||
});
|
||||
}
|
||||
|
||||
removeUser = async (id: string): Promise<MemberActionResult> => {
|
||||
try {
|
||||
await this.apiService.deleteProviderUser(this.providerId, id);
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
return { success: false, error: error.message };
|
||||
async remove(user: ProviderUser, providerId: ProviderId) {
|
||||
const confirmed = await this.removeUserConfirmationDialog(user);
|
||||
if (!confirmed) {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
edit = async (user: ProviderUser | null): Promise<void> => {
|
||||
const data: AddEditMemberDialogParams = {
|
||||
providerId: this.providerId,
|
||||
const sideEffect = () => this.dataSource().removeUser(user);
|
||||
const result = await this.providerActionsService.deleteProviderUser(providerId, user);
|
||||
|
||||
await this.handleMemberActionResult(result, "success", user, sideEffect);
|
||||
}
|
||||
|
||||
async reinvite(user: ProviderUser, providerId: ProviderId) {
|
||||
const result = await this.providerActionsService.reinviteProvider(providerId, user);
|
||||
await this.handleMemberActionResult(result, "success", user);
|
||||
}
|
||||
|
||||
async confirm(user: ProviderUser, providerId: ProviderId) {
|
||||
const publicKeyResult = await this.memberActionsService.getPublicKeyForConfirm(user);
|
||||
|
||||
if (publicKeyResult == null) {
|
||||
this.logService.warning("Public key not found");
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await this.providerActionsService.confirmProvider(
|
||||
user,
|
||||
providerId,
|
||||
publicKeyResult,
|
||||
);
|
||||
const sideEffect = () => {
|
||||
user.status = this.userStatusType.Confirmed;
|
||||
this.dataSource().replaceUser(user);
|
||||
};
|
||||
|
||||
if (user != null) {
|
||||
data.user = {
|
||||
id: user.id,
|
||||
name: this.userNamePipe.transform(user),
|
||||
type: user.type,
|
||||
};
|
||||
}
|
||||
await this.handleMemberActionResult(result, "success", user, sideEffect);
|
||||
}
|
||||
|
||||
async edit(providerId: ProviderId, user?: ProviderUser): Promise<void> {
|
||||
const dialogRef = AddEditMemberDialogComponent.open(this.dialogService, {
|
||||
data,
|
||||
data: {
|
||||
providerId,
|
||||
user,
|
||||
},
|
||||
});
|
||||
|
||||
const result = await lastValueFrom(dialogRef.closed);
|
||||
@@ -319,28 +322,54 @@ export class MembersComponent extends BaseMembersComponent<ProviderUser> {
|
||||
await this.load();
|
||||
break;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
openEventsDialog = (user: ProviderUser): DialogRef<void> =>
|
||||
openEntityEventsDialog(this.dialogService, {
|
||||
openEventsDialog(user: ProviderUser, providerId: ProviderId): DialogRef<void> {
|
||||
return 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);
|
||||
private bulkMenuOptions(providerMembers: ProviderUser[]): BulkProviderFlags {
|
||||
const result: BulkProviderFlags = {
|
||||
showBulkConfirmUsers: providerMembers.every(
|
||||
(m) => m.status == ProviderUserStatusType.Accepted,
|
||||
),
|
||||
showBulkReinviteUsers: providerMembers.every(
|
||||
(m) => m.status == ProviderUserStatusType.Invited,
|
||||
),
|
||||
};
|
||||
|
||||
reinviteUser = async (id: string): Promise<MemberActionResult> => {
|
||||
try {
|
||||
await this.apiService.postProviderUserReinvite(this.providerId, id);
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
return { success: false, error: error.message };
|
||||
return result;
|
||||
}
|
||||
|
||||
async handleMemberActionResult(
|
||||
result: MemberActionResult,
|
||||
successKey: string,
|
||||
user: ProviderUser,
|
||||
sideEffect?: () => void | Promise<void>,
|
||||
) {
|
||||
if (result.error != null) {
|
||||
this.validationService.showError(result.error);
|
||||
this.logService.error(result.error);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
if (result.success) {
|
||||
this.toastService.showToast({
|
||||
variant: "success",
|
||||
message: this.i18nService.t(successKey, this.userNamePipe.transform(user)),
|
||||
});
|
||||
|
||||
if (sideEffect) {
|
||||
await sideEffect();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
import { TestBed } from "@angular/core/testing";
|
||||
import { MockProxy, mock } from "jest-mock-extended";
|
||||
import { BehaviorSubject, of } from "rxjs";
|
||||
|
||||
import { ApiService } from "@bitwarden/common/abstractions/api.service";
|
||||
import { Account, AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||
import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service";
|
||||
import { EncString } from "@bitwarden/common/key-management/crypto/models/enc-string";
|
||||
import { ProviderId, UserId } from "@bitwarden/common/types/guid";
|
||||
import { newGuid } from "@bitwarden/guid";
|
||||
import { KeyService } from "@bitwarden/key-management";
|
||||
import { ProviderUser } from "@bitwarden/web-vault/app/admin-console/common/people-table-data-source";
|
||||
|
||||
import { ProviderActionsService } from "./provider-actions.service";
|
||||
|
||||
describe("ProviderActionsService", () => {
|
||||
let service: ProviderActionsService;
|
||||
let apiService: MockProxy<ApiService>;
|
||||
let keyService: MockProxy<KeyService>;
|
||||
let accountService: MockProxy<AccountService>;
|
||||
let encryptService: MockProxy<EncryptService>;
|
||||
|
||||
const providerId = newGuid() as ProviderId;
|
||||
const userId = newGuid();
|
||||
const userIdToManage = newGuid();
|
||||
|
||||
let mockProviderUser: ProviderUser;
|
||||
|
||||
beforeEach(() => {
|
||||
apiService = mock<ApiService>();
|
||||
keyService = mock<KeyService>();
|
||||
accountService = mock<AccountService>();
|
||||
encryptService = mock<EncryptService>();
|
||||
|
||||
mockProviderUser = {
|
||||
id: userIdToManage,
|
||||
userId: userIdToManage,
|
||||
email: "test@example.com",
|
||||
name: "Test User",
|
||||
} as ProviderUser;
|
||||
|
||||
TestBed.configureTestingModule({
|
||||
providers: [
|
||||
ProviderActionsService,
|
||||
{ provide: ApiService, useValue: apiService },
|
||||
{ provide: KeyService, useValue: keyService },
|
||||
{ provide: AccountService, useValue: accountService },
|
||||
{ provide: EncryptService, useValue: encryptService },
|
||||
],
|
||||
});
|
||||
|
||||
service = TestBed.inject(ProviderActionsService);
|
||||
});
|
||||
|
||||
describe("deleteProviderUser", () => {
|
||||
it("should return success when deletion succeeds", async () => {
|
||||
apiService.deleteProviderUser.mockResolvedValue(undefined);
|
||||
|
||||
const result = await service.deleteProviderUser(providerId, mockProviderUser);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it("should return error when deletion fails", async () => {
|
||||
apiService.deleteProviderUser.mockRejectedValue(new Error("Delete failed"));
|
||||
|
||||
const result = await service.deleteProviderUser(providerId, mockProviderUser);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("reinviteProvider", () => {
|
||||
it("should return success when reinvite succeeds", async () => {
|
||||
apiService.postProviderUserReinvite.mockResolvedValue(undefined);
|
||||
|
||||
const result = await service.reinviteProvider(providerId, mockProviderUser);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it("should return error when reinvite fails", async () => {
|
||||
apiService.postProviderUserReinvite.mockRejectedValue(new Error("Reinvite failed"));
|
||||
|
||||
const result = await service.reinviteProvider(providerId, mockProviderUser);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("confirmProvider", () => {
|
||||
const publicKey = new Uint8Array([1, 2, 3, 4, 5]);
|
||||
|
||||
it("should return success when confirmation succeeds", async () => {
|
||||
const mockAccount: Account = {
|
||||
id: userId as UserId,
|
||||
email: "test@example.com",
|
||||
emailVerified: true,
|
||||
name: "Test User",
|
||||
creationDate: new Date(),
|
||||
};
|
||||
const activeAccount$ = new BehaviorSubject<Account | null>(mockAccount);
|
||||
accountService.activeAccount$ = activeAccount$;
|
||||
keyService.providerKeys$.mockReturnValue(of({ [providerId]: { key: "mock" } as any }));
|
||||
encryptService.encapsulateKeyUnsigned.mockResolvedValue(new EncString("encrypted"));
|
||||
apiService.postProviderUserConfirm.mockResolvedValue(undefined);
|
||||
|
||||
const result = await service.confirmProvider(mockProviderUser, providerId, publicKey);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it("should return error when confirmation fails", async () => {
|
||||
const mockAccount: Account = {
|
||||
id: userId as UserId,
|
||||
email: "test@example.com",
|
||||
emailVerified: true,
|
||||
name: "Test User",
|
||||
creationDate: new Date(),
|
||||
};
|
||||
const activeAccount$ = new BehaviorSubject<Account | null>(mockAccount);
|
||||
accountService.activeAccount$ = activeAccount$;
|
||||
keyService.providerKeys$.mockReturnValue(of({}));
|
||||
|
||||
const result = await service.confirmProvider(mockProviderUser, providerId, publicKey);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toBeDefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,91 @@
|
||||
import { inject, Injectable, signal } from "@angular/core";
|
||||
import { firstValueFrom, switchMap, map } from "rxjs";
|
||||
|
||||
import { ApiService } from "@bitwarden/common/abstractions/api.service";
|
||||
import { ProviderUserConfirmRequest } from "@bitwarden/common/admin-console/models/request/provider/provider-user-confirm.request";
|
||||
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||
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 { ProviderId } from "@bitwarden/common/types/guid";
|
||||
import { KeyService } from "@bitwarden/key-management";
|
||||
import { ProviderUser } from "@bitwarden/web-vault/app/admin-console/common/people-table-data-source";
|
||||
|
||||
export interface MemberActionResult {
|
||||
success: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class ProviderActionsService {
|
||||
private apiService = inject(ApiService);
|
||||
private keyService = inject(KeyService);
|
||||
private accountService = inject(AccountService);
|
||||
private encryptService = inject(EncryptService);
|
||||
|
||||
readonly isProcessing = signal(false);
|
||||
|
||||
private startProcessing(): void {
|
||||
this.isProcessing.set(true);
|
||||
}
|
||||
|
||||
private endProcessing(): void {
|
||||
this.isProcessing.set(false);
|
||||
}
|
||||
|
||||
async deleteProviderUser(
|
||||
providerId: ProviderId,
|
||||
user: ProviderUser,
|
||||
): Promise<MemberActionResult> {
|
||||
this.startProcessing();
|
||||
try {
|
||||
await this.apiService.deleteProviderUser(providerId, user.id);
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
return { success: false, error: (error as Error).message ?? String(error) };
|
||||
} finally {
|
||||
this.endProcessing();
|
||||
}
|
||||
}
|
||||
|
||||
async reinviteProvider(providerId: ProviderId, user: ProviderUser): Promise<MemberActionResult> {
|
||||
this.startProcessing();
|
||||
try {
|
||||
await this.apiService.postProviderUserReinvite(providerId, user.id);
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
return { success: false, error: (error as Error).message ?? String(error) };
|
||||
} finally {
|
||||
this.endProcessing();
|
||||
}
|
||||
}
|
||||
|
||||
async confirmProvider(
|
||||
user: ProviderUser,
|
||||
providerId: ProviderId,
|
||||
publicKey: Uint8Array,
|
||||
): Promise<MemberActionResult> {
|
||||
this.startProcessing();
|
||||
try {
|
||||
const providerKey = await firstValueFrom(
|
||||
this.accountService.activeAccount$.pipe(
|
||||
getUserId,
|
||||
switchMap((userId) => this.keyService.providerKeys$(userId)),
|
||||
map((providerKeys) => providerKeys?.[providerId] ?? null),
|
||||
),
|
||||
);
|
||||
assertNonNullish(providerKey, "Provider key not found");
|
||||
|
||||
const key = await this.encryptService.encapsulateKeyUnsigned(providerKey, publicKey);
|
||||
assertNonNullish(key.encryptedString, "No key was provided");
|
||||
|
||||
const request = new ProviderUserConfirmRequest(key.encryptedString);
|
||||
await this.apiService.postProviderUserConfirm(providerId, user.id, request);
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
return { success: false, error: (error as Error).message ?? String(error) };
|
||||
} finally {
|
||||
this.endProcessing();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,9 @@ import { NgModule } from "@angular/core";
|
||||
import { RouterModule, Routes } from "@angular/router";
|
||||
|
||||
import { authGuard } from "@bitwarden/angular/auth/guards";
|
||||
import { featureFlaggedRoute } from "@bitwarden/angular/platform/utils/feature-flagged-route";
|
||||
import { Provider } from "@bitwarden/common/admin-console/models/domain/provider";
|
||||
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
|
||||
import { AnonLayoutWrapperComponent } from "@bitwarden/components";
|
||||
import { FrontendLayoutComponent } from "@bitwarden/web-vault/app/layouts/frontend-layout.component";
|
||||
import { UserLayoutComponent } from "@bitwarden/web-vault/app/layouts/user-layout.component";
|
||||
@@ -15,8 +17,9 @@ import { ProviderSubscriptionComponent } from "../../billing/providers/subscript
|
||||
import { ManageClientsComponent } from "./clients/manage-clients.component";
|
||||
import { providerPermissionsGuard } from "./guards/provider-permissions.guard";
|
||||
import { AcceptProviderComponent } from "./manage/accept-provider.component";
|
||||
import { MembersComponent } from "./manage/deprecated_members.component";
|
||||
import { EventsComponent } from "./manage/events.component";
|
||||
import { MembersComponent } from "./manage/members.component";
|
||||
import { vNextMembersComponent } from "./manage/members.component";
|
||||
import { ProvidersLayoutComponent } from "./providers-layout.component";
|
||||
import { ProvidersComponent } from "./providers.component";
|
||||
import { AccountComponent } from "./settings/account.component";
|
||||
@@ -92,16 +95,20 @@ const routes: Routes = [
|
||||
pathMatch: "full",
|
||||
redirectTo: "members",
|
||||
},
|
||||
{
|
||||
path: "members",
|
||||
component: MembersComponent,
|
||||
canActivate: [
|
||||
providerPermissionsGuard((provider: Provider) => provider.canManageUsers),
|
||||
],
|
||||
data: {
|
||||
titleId: "members",
|
||||
...featureFlaggedRoute({
|
||||
defaultComponent: MembersComponent,
|
||||
flaggedComponent: vNextMembersComponent,
|
||||
featureFlag: FeatureFlag.MembersComponentRefactor,
|
||||
routeOptions: {
|
||||
path: "members",
|
||||
canActivate: [
|
||||
providerPermissionsGuard((provider: Provider) => provider.canManageUsers),
|
||||
],
|
||||
data: {
|
||||
titleId: "members",
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
{
|
||||
path: "events",
|
||||
component: EventsComponent,
|
||||
|
||||
@@ -5,6 +5,7 @@ import { FormsModule } from "@angular/forms";
|
||||
|
||||
import { JslibModule } from "@bitwarden/angular/jslib.module";
|
||||
import { CardComponent, ScrollLayoutDirective, SearchModule } from "@bitwarden/components";
|
||||
import { MemberActionsService } from "@bitwarden/web-vault/app/admin-console/organizations/members/services/member-actions/member-actions.service";
|
||||
import { DangerZoneComponent } from "@bitwarden/web-vault/app/auth/settings/account/danger-zone.component";
|
||||
import { OrganizationPlansComponent } from "@bitwarden/web-vault/app/billing";
|
||||
import {
|
||||
@@ -26,11 +27,13 @@ import { CreateClientDialogComponent } from "./clients/create-client-dialog.comp
|
||||
import { ManageClientNameDialogComponent } from "./clients/manage-client-name-dialog.component";
|
||||
import { ManageClientSubscriptionDialogComponent } from "./clients/manage-client-subscription-dialog.component";
|
||||
import { AcceptProviderComponent } from "./manage/accept-provider.component";
|
||||
import { MembersComponent } from "./manage/deprecated_members.component";
|
||||
import { AddEditMemberDialogComponent } from "./manage/dialogs/add-edit-member-dialog.component";
|
||||
import { BulkConfirmDialogComponent } from "./manage/dialogs/bulk-confirm-dialog.component";
|
||||
import { BulkRemoveDialogComponent } from "./manage/dialogs/bulk-remove-dialog.component";
|
||||
import { EventsComponent } from "./manage/events.component";
|
||||
import { MembersComponent } from "./manage/members.component";
|
||||
import { vNextMembersComponent } from "./manage/members.component";
|
||||
import { ProviderActionsService } from "./manage/services/provider-actions/provider-actions.service";
|
||||
import { ProvidersLayoutComponent } from "./providers-layout.component";
|
||||
import { ProvidersRoutingModule } from "./providers-routing.module";
|
||||
import { ProvidersComponent } from "./providers.component";
|
||||
@@ -64,6 +67,7 @@ import { VerifyRecoverDeleteProviderComponent } from "./verify-recover-delete-pr
|
||||
BulkConfirmDialogComponent,
|
||||
BulkRemoveDialogComponent,
|
||||
EventsComponent,
|
||||
vNextMembersComponent,
|
||||
MembersComponent,
|
||||
SetupComponent,
|
||||
SetupProviderComponent,
|
||||
@@ -81,6 +85,6 @@ import { VerifyRecoverDeleteProviderComponent } from "./verify-recover-delete-pr
|
||||
VerifyRecoverDeleteProviderComponent,
|
||||
SetupBusinessUnitComponent,
|
||||
],
|
||||
providers: [WebProviderService],
|
||||
providers: [WebProviderService, ProviderActionsService, MemberActionsService],
|
||||
})
|
||||
export class ProvidersModule {}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
// @ts-strict-ignore
|
||||
export class ProviderUserConfirmRequest {
|
||||
key: string;
|
||||
protected key: string;
|
||||
|
||||
constructor(key: string) {
|
||||
this.key = key;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ export enum FeatureFlag {
|
||||
AutoConfirm = "pm-19934-auto-confirm-organization-users",
|
||||
BlockClaimedDomainAccountCreation = "pm-28297-block-uninvited-claimed-domain-registration",
|
||||
IncreaseBulkReinviteLimitForCloud = "pm-28251-increase-bulk-reinvite-limit-for-cloud",
|
||||
MembersComponentRefactor = "pm-29503-refactor-members-inheritance",
|
||||
|
||||
/* Auth */
|
||||
PM23801_PrefetchPasswordPrelogin = "pm-23801-prefetch-password-prelogin",
|
||||
@@ -100,6 +101,7 @@ export const DefaultFeatureFlagValue = {
|
||||
[FeatureFlag.AutoConfirm]: FALSE,
|
||||
[FeatureFlag.BlockClaimedDomainAccountCreation]: FALSE,
|
||||
[FeatureFlag.IncreaseBulkReinviteLimitForCloud]: FALSE,
|
||||
[FeatureFlag.MembersComponentRefactor]: FALSE,
|
||||
|
||||
/* Autofill */
|
||||
[FeatureFlag.MacOsNativeCredentialSync]: FALSE,
|
||||
|
||||
Reference in New Issue
Block a user