1
0
mirror of https://github.com/bitwarden/browser synced 2026-02-19 10:54:00 +00:00

[PM-28262] Bulk re-invite UI improvements (#18754)

* implement dilogs and logic

* clean up, add tests

* add feature flag key

* product requested changes

* more product changes

* edit error message
This commit is contained in:
Brandon Treston
2026-02-10 16:20:39 -05:00
committed by jaasen-livefront
parent 541686dfc8
commit a4ff58120b
16 changed files with 596 additions and 79 deletions

View File

@@ -0,0 +1,22 @@
<bit-simple-dialog hideIcon>
<div bitDialogContent>
<div class="tw-flex tw-justify-center">
<div class="tw-mt-1 tw-w-[273px]">
<bit-progress
[showText]="false"
size="default"
bgColor="primary"
[barWidth]="progressPercentage()"
></bit-progress>
</div>
</div>
<div class="tw-flex tw-flex-col tw-gap-2 tw-mt-6">
<h3 class="tw-font-semibold">
{{ "bulkReinviteProgressTitle" | i18n: progressCount() : allCount }}
</h3>
<span class="tw-text-sm">
{{ "bulkReinviteProgressSubtitle" | i18n }}
</span>
</div>
</div>
</bit-simple-dialog>

View File

@@ -0,0 +1,46 @@
import { DialogConfig, DialogRef } from "@angular/cdk/dialog";
import {
ChangeDetectionStrategy,
Component,
computed,
effect,
Inject,
Signal,
} from "@angular/core";
import { DIALOG_DATA, DialogService } from "@bitwarden/components";
export interface BulkProgressDialogParams {
progress: Signal<number>;
allCount: number;
}
@Component({
templateUrl: "bulk-progress-dialog.component.html",
selector: "member-bulk-progress-dialog",
changeDetection: ChangeDetectionStrategy.OnPush,
standalone: false,
})
export class BulkProgressDialogComponent {
protected allCount: string;
protected readonly progressCount: Signal<string>;
protected readonly progressPercentage: Signal<number>;
private readonly progressEffect = effect(() => {
if (this.progressPercentage() >= 100) {
this.dialogRef.close();
}
});
constructor(
public dialogRef: DialogRef,
@Inject(DIALOG_DATA) data: BulkProgressDialogParams,
) {
this.progressCount = computed(() => data.progress().toLocaleString());
this.allCount = data.allCount.toLocaleString();
this.progressPercentage = computed(() => (data.progress() / data.allCount) * 100);
}
static open(dialogService: DialogService, config: DialogConfig<BulkProgressDialogParams>) {
return dialogService.open(BulkProgressDialogComponent, config);
}
}

View File

@@ -0,0 +1,70 @@
<bit-dialog dialogSize="large">
@let failCount = dataSource().data.length;
<div bitDialogTitle>
@if (failCount > 1) {
{{ "bulkReinviteFailuresTitle" | i18n: failCount }}
} @else {
{{ "bulkReinviteFailureTitle" | i18n }}
}
</div>
<div bitDialogContent>
{{ "bulkReinviteFailureDescription" | i18n: failCount : totalCount }}
<a bitLink href="https://bitwarden.com/contact/" target="_blank" rel="noopener noreferrer">
{{ "contactSupportShort" | i18n | lowercase }}
<bit-icon name="bwi-external-link"></bit-icon>
</a>
<div class="tw-max-h-[304px] tw-overflow-auto tw-mt-4">
<bit-table [dataSource]="dataSource()">
<ng-container header>
<tr>
<th bitCell bitSortable="name">{{ "name" | i18n }}</th>
</tr>
</ng-container>
<ng-template body let-rows$>
@let rows = $any(rows$ | async);
@for (u of rows; track u.id) {
<tr bitRow class="tw-h-16">
<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">
<button type="button" bitLink>
{{ u.name ?? u.email }}
</button>
</div>
@if (u.name) {
<div class="tw-text-sm tw-text-muted">
{{ u.email }}
</div>
}
</div>
</div>
</td>
</tr>
}
</ng-template>
</bit-table>
</div>
</div>
<ng-container bitDialogFooter>
<button bitButton buttonType="primary" type="button" (click)="resendInvitations()">
{{ "bulkResendInvitations" | i18n }}
</button>
<button bitButton buttonType="secondary" type="button" (click)="cancel()">
{{ "cancel" | i18n }}
</button>
</ng-container>
</bit-dialog>

View File

@@ -0,0 +1,62 @@
import { DIALOG_DATA, DialogConfig, DialogRef } from "@angular/cdk/dialog";
import { ChangeDetectionStrategy, Component, Inject, signal, WritableSignal } from "@angular/core";
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service";
import { DialogService } from "@bitwarden/components";
import { MembersTableDataSource } from "@bitwarden/web-vault/app/admin-console/common/people-table-data-source";
import { OrganizationUserView } from "../../../core";
import {
BulkActionResult,
MemberActionsService,
} from "../../services/member-actions/member-actions.service";
export interface BulkReinviteFailureDialogParams {
result: BulkActionResult;
users: OrganizationUserView[];
organization: Organization;
}
@Component({
templateUrl: "bulk-reinvite-failure-dialog.component.html",
selector: "member-bulk-reinvite-failure-dialog",
changeDetection: ChangeDetectionStrategy.OnPush,
standalone: false,
})
export class BulkReinviteFailureDialogComponent {
private organization: Organization;
protected totalCount: string;
protected readonly dataSource: WritableSignal<MembersTableDataSource>;
constructor(
public dialogRef: DialogRef,
private memberActionsService: MemberActionsService,
@Inject(DIALOG_DATA) data: BulkReinviteFailureDialogParams,
environmentService: EnvironmentService,
) {
this.organization = data.organization;
this.totalCount = (data.users.length ?? 0).toLocaleString();
this.dataSource = signal(new MembersTableDataSource(environmentService));
this.dataSource().data = data.result.failed.map((failedUser) => {
const user = data.users.find((u) => u.id === failedUser.id);
if (user == null) {
throw new Error("Member not found");
}
return user;
});
}
async resendInvitations() {
await this.memberActionsService.bulkReinvite(this.organization, this.dataSource().data);
this.dialogRef.close();
}
async cancel() {
this.dialogRef.close();
}
static open(dialogService: DialogService, config: DialogConfig<BulkReinviteFailureDialogParams>) {
return dialogService.open(BulkReinviteFailureDialogComponent, config);
}
}

View File

@@ -444,10 +444,7 @@ export class MembersComponent extends BaseMembersComponent<OrganizationUserView>
}
try {
const result = await this.memberActionsService.bulkReinvite(
organization,
filteredUsers.map((user) => user.id as UserId),
);
const result = await this.memberActionsService.bulkReinvite(organization, filteredUsers);
if (!result.successful) {
throw new Error();
@@ -472,7 +469,10 @@ export class MembersComponent extends BaseMembersComponent<OrganizationUserView>
} else {
this.toastService.showToast({
variant: "success",
message: this.i18nService.t("bulkReinviteSuccessToast", invitedCount.toString()),
message:
invitedCount === 1
? this.i18nService.t("reinviteSuccessToast")
: this.i18nService.t("bulkReinviteSentToast", invitedCount.toString()),
});
}
} else {

View File

@@ -113,25 +113,24 @@
<th bitCell>{{ "policies" | i18n }}</th>
<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>
<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>

View File

@@ -519,7 +519,7 @@ describe("vNextMembersComponent", () => {
await component.bulkReinvite(mockOrg);
expect(mockMemberActionsService.bulkReinvite).toHaveBeenCalledWith(mockOrg, [invitedUser.id]);
expect(mockMemberActionsService.bulkReinvite).toHaveBeenCalledWith(mockOrg, [invitedUser]);
expect(mockMemberDialogManager.openBulkStatusDialog).toHaveBeenCalled();
});

View File

@@ -33,6 +33,8 @@ import { AccountService } from "@bitwarden/common/auth/abstractions/account.serv
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 { 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 { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
@@ -101,6 +103,7 @@ export class vNextMembersComponent {
private organizationMetadataService = inject(OrganizationMetadataServiceAbstraction);
private environmentService = inject(EnvironmentService);
private memberExportService = inject(MemberExportService);
private configService = inject(ConfigService);
private userId$: Observable<UserId> = this.accountService.activeAccount$.pipe(getUserId);
@@ -145,6 +148,10 @@ export class vNextMembersComponent {
() => this.organization()?.canManageUsers ?? false,
);
protected readonly bulkReinviteUIEnabled = toSignal(
this.configService.getFeatureFlag$(FeatureFlag.BulkReinviteUI),
);
protected billingMetadata$: Observable<OrganizationBillingMetadataResponse>;
protected resetPasswordPolicyEnabled$: Observable<boolean>;
@@ -399,7 +406,7 @@ export class vNextMembersComponent {
// In cloud environments, limit invited users and uncheck the excess
let filteredUsers: OrganizationUserView[];
if (this.dataSource().isIncreasedBulkLimitEnabled()) {
if (this.dataSource().isIncreasedBulkLimitEnabled() && !this.bulkReinviteUIEnabled()) {
filteredUsers = this.dataSource().limitAndUncheckExcess(
allInvitedUsers,
CloudBulkReinviteLimit,
@@ -417,10 +424,7 @@ export class vNextMembersComponent {
return;
}
const result = await this.memberActionsService.bulkReinvite(
organization,
filteredUsers.map((user) => user.id as UserId),
);
const result = await this.memberActionsService.bulkReinvite(organization, filteredUsers);
if (!result.successful) {
this.validationService.showError(result.failed);
@@ -431,7 +435,8 @@ export class vNextMembersComponent {
const selectedCount = originalInvitedCount;
const invitedCount = filteredUsers.length;
if (selectedCount > CloudBulkReinviteLimit) {
// Only show limited toast if feature flag is disabled and limit was applied
if (!this.bulkReinviteUIEnabled() && selectedCount > CloudBulkReinviteLimit) {
const excludedCount = selectedCount - CloudBulkReinviteLimit;
this.toastService.showToast({
variant: "success",
@@ -445,7 +450,10 @@ export class vNextMembersComponent {
} else {
this.toastService.showToast({
variant: "success",
message: this.i18nService.t("bulkReinviteSuccessToast", invitedCount.toString()),
message:
invitedCount === 1
? this.i18nService.t("reinviteSuccessToast")
: this.i18nService.t("bulkReinviteSentToast", invitedCount.toString()),
});
}
} else {
@@ -457,6 +465,8 @@ export class vNextMembersComponent {
this.i18nService.t("bulkReinviteMessage"),
);
}
this.dataSource().uncheckAllUsers();
}
async bulkConfirm(organization: Organization) {

View File

@@ -3,7 +3,7 @@ import { NgModule } from "@angular/core";
import { PasswordStrengthV2Component } from "@bitwarden/angular/tools/password-strength/password-strength-v2.component";
import { PasswordCalloutComponent } from "@bitwarden/auth/angular";
import { ScrollLayoutDirective } from "@bitwarden/components";
import { IconModule, ScrollLayoutDirective } from "@bitwarden/components";
import { BillingConstraintService } from "@bitwarden/web-vault/app/billing/members/billing-constraint/billing-constraint.service";
import { OrganizationFreeTrialWarningComponent } from "@bitwarden/web-vault/app/billing/organizations/warnings/components";
@@ -13,6 +13,8 @@ import { SharedOrganizationModule } from "../shared";
import { BulkConfirmDialogComponent } from "./components/bulk/bulk-confirm-dialog.component";
import { BulkDeleteDialogComponent } from "./components/bulk/bulk-delete-dialog.component";
import { BulkEnableSecretsManagerDialogComponent } from "./components/bulk/bulk-enable-sm-dialog.component";
import { BulkProgressDialogComponent } from "./components/bulk/bulk-progress-dialog.component";
import { BulkReinviteFailureDialogComponent } from "./components/bulk/bulk-reinvite-failure-dialog.component";
import { BulkRemoveDialogComponent } from "./components/bulk/bulk-remove-dialog.component";
import { BulkRestoreRevokeComponent } from "./components/bulk/bulk-restore-revoke.component";
import { BulkStatusComponent } from "./components/bulk/bulk-status.component";
@@ -39,6 +41,7 @@ import {
PasswordStrengthV2Component,
ScrollLayoutDirective,
OrganizationFreeTrialWarningComponent,
IconModule,
],
declarations: [
BulkConfirmDialogComponent,
@@ -46,6 +49,8 @@ import {
BulkRemoveDialogComponent,
BulkRestoreRevokeComponent,
BulkStatusComponent,
BulkProgressDialogComponent,
BulkReinviteFailureDialogComponent,
MembersComponent,
vNextMembersComponent,
BulkDeleteDialogComponent,

View File

@@ -18,6 +18,7 @@ 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 { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { OrganizationId, UserId } from "@bitwarden/common/types/guid";
import { DialogService } from "@bitwarden/components";
@@ -25,6 +26,7 @@ import { newGuid } from "@bitwarden/guid";
import { KeyService } from "@bitwarden/key-management";
import { OrganizationUserView } from "../../../core/views/organization-user.view";
import { MemberDialogManagerService } from "../member-dialog-manager/member-dialog-manager.service";
import { REQUESTS_PER_BATCH, MemberActionsService } from "./member-actions.service";
@@ -34,6 +36,7 @@ describe("MemberActionsService", () => {
let organizationUserService: MockProxy<OrganizationUserService>;
let configService: MockProxy<ConfigService>;
let organizationMetadataService: MockProxy<OrganizationMetadataServiceAbstraction>;
let memberDialogManager: MockProxy<MemberDialogManagerService>;
const organizationId = newGuid() as OrganizationId;
const userIdToManage = newGuid();
@@ -46,6 +49,7 @@ describe("MemberActionsService", () => {
organizationUserService = mock<OrganizationUserService>();
configService = mock<ConfigService>();
organizationMetadataService = mock<OrganizationMetadataServiceAbstraction>();
memberDialogManager = mock<MemberDialogManagerService>();
mockOrganization = {
id: organizationId,
@@ -82,6 +86,8 @@ describe("MemberActionsService", () => {
useValue: mock<OrganizationManagementPreferencesService>(),
},
{ provide: UserNamePipe, useValue: mock<UserNamePipe>() },
{ provide: MemberDialogManagerService, useValue: memberDialogManager },
{ provide: I18nService, useValue: mock<I18nService>() },
],
});
@@ -318,8 +324,13 @@ describe("MemberActionsService", () => {
});
describe("bulkReinvite", () => {
beforeEach(() => {
configService.getFeatureFlag$.mockReturnValue(of(false));
});
it("should process users in a single batch when count equals REQUESTS_PER_BATCH", async () => {
const userIdsBatch = Array.from({ length: REQUESTS_PER_BATCH }, () => newGuid() as UserId);
const users = userIdsBatch.map((id) => ({ id }) as OrganizationUserView);
const mockResponse = new ListResponse(
{
data: userIdsBatch.map((id) => ({
@@ -333,10 +344,10 @@ describe("MemberActionsService", () => {
organizationUserApiService.postManyOrganizationUserReinvite.mockResolvedValue(mockResponse);
const result = await service.bulkReinvite(mockOrganization, userIdsBatch);
const result = await service.bulkReinvite(mockOrganization, users);
expect(result.successful).toBeDefined();
expect(result.successful?.response).toHaveLength(REQUESTS_PER_BATCH);
expect(result.successful).toHaveLength(REQUESTS_PER_BATCH);
expect(result.failed).toHaveLength(0);
expect(organizationUserApiService.postManyOrganizationUserReinvite).toHaveBeenCalledTimes(1);
expect(organizationUserApiService.postManyOrganizationUserReinvite).toHaveBeenCalledWith(
@@ -348,6 +359,7 @@ describe("MemberActionsService", () => {
it("should process users in multiple batches when count exceeds REQUESTS_PER_BATCH", async () => {
const totalUsers = REQUESTS_PER_BATCH + 100;
const userIdsBatch = Array.from({ length: totalUsers }, () => newGuid() as UserId);
const users = userIdsBatch.map((id) => ({ id }) as OrganizationUserView);
const mockResponse1 = new ListResponse(
{
@@ -375,10 +387,10 @@ describe("MemberActionsService", () => {
.mockResolvedValueOnce(mockResponse1)
.mockResolvedValueOnce(mockResponse2);
const result = await service.bulkReinvite(mockOrganization, userIdsBatch);
const result = await service.bulkReinvite(mockOrganization, users);
expect(result.successful).toBeDefined();
expect(result.successful?.response).toHaveLength(totalUsers);
expect(result.successful).toHaveLength(totalUsers);
expect(result.failed).toHaveLength(0);
expect(organizationUserApiService.postManyOrganizationUserReinvite).toHaveBeenCalledTimes(2);
expect(organizationUserApiService.postManyOrganizationUserReinvite).toHaveBeenNthCalledWith(
@@ -396,6 +408,7 @@ describe("MemberActionsService", () => {
it("should aggregate results across multiple successful batches", async () => {
const totalUsers = REQUESTS_PER_BATCH + 50;
const userIdsBatch = Array.from({ length: totalUsers }, () => newGuid() as UserId);
const users = userIdsBatch.map((id) => ({ id }) as OrganizationUserView);
const mockResponse1 = new ListResponse(
{
@@ -423,18 +436,19 @@ describe("MemberActionsService", () => {
.mockResolvedValueOnce(mockResponse1)
.mockResolvedValueOnce(mockResponse2);
const result = await service.bulkReinvite(mockOrganization, userIdsBatch);
const result = await service.bulkReinvite(mockOrganization, users);
expect(result.successful).toBeDefined();
expect(result.successful?.response).toHaveLength(totalUsers);
expect(result.successful?.response.slice(0, REQUESTS_PER_BATCH)).toEqual(mockResponse1.data);
expect(result.successful?.response.slice(REQUESTS_PER_BATCH)).toEqual(mockResponse2.data);
expect(result.successful).toHaveLength(totalUsers);
expect(result.successful!.slice(0, REQUESTS_PER_BATCH)).toEqual(mockResponse1.data);
expect(result.successful!.slice(REQUESTS_PER_BATCH)).toEqual(mockResponse2.data);
expect(result.failed).toHaveLength(0);
});
it("should handle mixed individual errors across multiple batches", async () => {
const totalUsers = REQUESTS_PER_BATCH + 4;
const userIdsBatch = Array.from({ length: totalUsers }, () => newGuid() as UserId);
const users = userIdsBatch.map((id) => ({ id }) as OrganizationUserView);
const mockResponse1 = new ListResponse(
{
@@ -464,7 +478,7 @@ describe("MemberActionsService", () => {
.mockResolvedValueOnce(mockResponse1)
.mockResolvedValueOnce(mockResponse2);
const result = await service.bulkReinvite(mockOrganization, userIdsBatch);
const result = await service.bulkReinvite(mockOrganization, users);
// Count expected failures: every 10th index (0, 10, 20, ..., 490) in first batch + 2 explicit in second batch
// Indices 0 to REQUESTS_PER_BATCH-1 where index % 10 === 0: that's floor((BATCH_SIZE-1)/10) + 1 values
@@ -474,7 +488,7 @@ describe("MemberActionsService", () => {
const expectedSuccesses = totalUsers - expectedTotalFailures;
expect(result.successful).toBeDefined();
expect(result.successful?.response).toHaveLength(expectedSuccesses);
expect(result.successful).toHaveLength(expectedSuccesses);
expect(result.failed).toHaveLength(expectedTotalFailures);
expect(result.failed.some((f) => f.error === "Rate limit exceeded")).toBe(true);
expect(result.failed.some((f) => f.error === "Invalid email")).toBe(true);
@@ -484,13 +498,14 @@ describe("MemberActionsService", () => {
it("should aggregate all failures when all batches fail", async () => {
const totalUsers = REQUESTS_PER_BATCH + 100;
const userIdsBatch = Array.from({ length: totalUsers }, () => newGuid() as UserId);
const users = userIdsBatch.map((id) => ({ id }) as OrganizationUserView);
const errorMessage = "All batches failed";
organizationUserApiService.postManyOrganizationUserReinvite.mockRejectedValue(
new Error(errorMessage),
);
const result = await service.bulkReinvite(mockOrganization, userIdsBatch);
const result = await service.bulkReinvite(mockOrganization, users);
expect(result.successful).toBeUndefined();
expect(result.failed).toHaveLength(totalUsers);
@@ -501,6 +516,7 @@ describe("MemberActionsService", () => {
it("should handle empty data in batch response", async () => {
const totalUsers = REQUESTS_PER_BATCH + 50;
const userIdsBatch = Array.from({ length: totalUsers }, () => newGuid() as UserId);
const users = userIdsBatch.map((id) => ({ id }) as OrganizationUserView);
const mockResponse1 = new ListResponse(
{
@@ -525,16 +541,17 @@ describe("MemberActionsService", () => {
.mockResolvedValueOnce(mockResponse1)
.mockResolvedValueOnce(mockResponse2);
const result = await service.bulkReinvite(mockOrganization, userIdsBatch);
const result = await service.bulkReinvite(mockOrganization, users);
expect(result.successful).toBeDefined();
expect(result.successful?.response).toHaveLength(REQUESTS_PER_BATCH);
expect(result.successful).toHaveLength(REQUESTS_PER_BATCH);
expect(result.failed).toHaveLength(0);
});
it("should process batches sequentially in order", async () => {
const totalUsers = REQUESTS_PER_BATCH * 2;
const userIdsBatch = Array.from({ length: totalUsers }, () => newGuid() as UserId);
const users = userIdsBatch.map((id) => ({ id }) as OrganizationUserView);
const callOrder: number[] = [];
organizationUserApiService.postManyOrganizationUserReinvite.mockImplementation(
@@ -555,11 +572,161 @@ describe("MemberActionsService", () => {
},
);
await service.bulkReinvite(mockOrganization, userIdsBatch);
await service.bulkReinvite(mockOrganization, users);
expect(callOrder).toEqual([1, 2]);
expect(organizationUserApiService.postManyOrganizationUserReinvite).toHaveBeenCalledTimes(2);
});
describe("with BulkReinviteUI feature flag enabled", () => {
let mockDialogService: MockProxy<DialogService>;
let mockI18nService: MockProxy<I18nService>;
beforeEach(() => {
configService.getFeatureFlag$.mockReturnValue(of(true));
mockDialogService = TestBed.inject(DialogService) as MockProxy<DialogService>;
mockI18nService = TestBed.inject(I18nService) as MockProxy<I18nService>;
mockI18nService.t.mockImplementation((key: string) => key);
});
it("should open progress dialog when user count exceeds REQUESTS_PER_BATCH", async () => {
const totalUsers = REQUESTS_PER_BATCH + 100;
const userIdsBatch = Array.from({ length: totalUsers }, () => newGuid() as UserId);
const users = userIdsBatch.map((id) => ({ id }) as OrganizationUserView);
mockDialogService.openSimpleDialog.mockResolvedValue(true);
const mockDialogRef = { closed: of(undefined) };
memberDialogManager.openBulkProgressDialog.mockReturnValue(mockDialogRef as any);
const mockResponse1 = new ListResponse(
{
data: userIdsBatch.slice(0, REQUESTS_PER_BATCH).map((id) => ({
id,
error: null,
})),
continuationToken: null,
},
OrganizationUserBulkResponse,
);
const mockResponse2 = new ListResponse(
{
data: userIdsBatch.slice(REQUESTS_PER_BATCH).map((id) => ({
id,
error: null,
})),
continuationToken: null,
},
OrganizationUserBulkResponse,
);
organizationUserApiService.postManyOrganizationUserReinvite
.mockResolvedValueOnce(mockResponse1)
.mockResolvedValueOnce(mockResponse2);
await service.bulkReinvite(mockOrganization, users);
expect(memberDialogManager.openBulkReinviteFailureDialog).not.toHaveBeenCalled();
expect(memberDialogManager.openBulkProgressDialog).toHaveBeenCalledWith(
expect.anything(),
totalUsers,
);
});
it("should not open progress dialog when user count is or below REQUESTS_PER_BATCH", async () => {
const totalUsers = REQUESTS_PER_BATCH;
const userIdsBatch = Array.from({ length: totalUsers }, () => newGuid() as UserId);
const users = userIdsBatch.map((id) => ({ id }) as OrganizationUserView);
const mockResponse = new ListResponse(
{
data: userIdsBatch.map((id) => ({
id,
error: null,
})),
continuationToken: null,
},
OrganizationUserBulkResponse,
);
organizationUserApiService.postManyOrganizationUserReinvite.mockResolvedValue(mockResponse);
await service.bulkReinvite(mockOrganization, users);
expect(memberDialogManager.openBulkReinviteFailureDialog).not.toHaveBeenCalled();
expect(memberDialogManager.openBulkProgressDialog).not.toHaveBeenCalled();
});
it("should open failure dialog when there are failures", async () => {
const totalUsers = 10;
const userIdsBatch = Array.from({ length: totalUsers }, () => newGuid() as UserId);
const users = userIdsBatch.map((id) => ({ id }) as OrganizationUserView);
const mockResponse = new ListResponse(
{
data: userIdsBatch.map((id) => ({
id,
error: "error",
})),
continuationToken: null,
},
OrganizationUserBulkResponse,
);
organizationUserApiService.postManyOrganizationUserReinvite.mockResolvedValue(mockResponse);
const result = await service.bulkReinvite(mockOrganization, users);
expect(memberDialogManager.openBulkReinviteFailureDialog).toHaveBeenCalledWith(
mockOrganization,
users,
result,
);
expect(result.failed.length).toBeGreaterThan(0);
});
it("should process batches when exceeding REQUESTS_PER_BATCH", async () => {
const totalUsers = REQUESTS_PER_BATCH + 100;
const userIdsBatch = Array.from({ length: totalUsers }, () => newGuid() as UserId);
const users = userIdsBatch.map((id) => ({ id }) as OrganizationUserView);
const mockDialogRef = { closed: of(undefined) };
memberDialogManager.openBulkProgressDialog.mockReturnValue(mockDialogRef as any);
const mockResponse1 = new ListResponse(
{
data: userIdsBatch.slice(0, REQUESTS_PER_BATCH).map((id) => ({
id,
error: null,
})),
continuationToken: null,
},
OrganizationUserBulkResponse,
);
const mockResponse2 = new ListResponse(
{
data: userIdsBatch.slice(REQUESTS_PER_BATCH).map((id) => ({
id,
error: null,
})),
continuationToken: null,
},
OrganizationUserBulkResponse,
);
organizationUserApiService.postManyOrganizationUserReinvite
.mockResolvedValueOnce(mockResponse1)
.mockResolvedValueOnce(mockResponse2);
await service.bulkReinvite(mockOrganization, users);
expect(organizationUserApiService.postManyOrganizationUserReinvite).toHaveBeenCalledTimes(
2,
);
});
});
});
describe("allowResetPassword", () => {

View File

@@ -1,5 +1,5 @@
import { inject, Injectable, signal } from "@angular/core";
import { lastValueFrom, firstValueFrom, switchMap } from "rxjs";
import { inject, Injectable, signal, WritableSignal } from "@angular/core";
import { lastValueFrom, firstValueFrom, switchMap, take } from "rxjs";
import {
OrganizationUserApiService,
@@ -23,11 +23,11 @@ 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";
import { MemberDialogManagerService } from "../member-dialog-manager/member-dialog-manager.service";
export const REQUESTS_PER_BATCH = 500;
@@ -36,9 +36,13 @@ export interface MemberActionResult {
error?: string;
}
export interface BulkActionResult {
successful?: ListResponse<OrganizationUserBulkResponse>;
failed: { id: string; error: string }[];
export class BulkActionResult {
constructor() {
this.failed = [];
}
successful?: OrganizationUserBulkResponse[];
failed: { id: string; error: string }[] = [];
}
@Injectable()
@@ -53,17 +57,28 @@ export class MemberActionsService {
private logService = inject(LogService);
private orgManagementPrefs = inject(OrganizationManagementPreferencesService);
private userNamePipe = inject(UserNamePipe);
private memberDialogManager = inject(MemberDialogManagerService);
readonly isProcessing = signal(false);
private startProcessing(): void {
private startProcessing(length?: number): void {
this.isProcessing.set(true);
if (length != null && length > REQUESTS_PER_BATCH) {
this.memberDialogManager
.openBulkProgressDialog(this.progressCount, length)
.closed.pipe(take(1))
.subscribe(() => {
this.progressCount.set(0);
});
}
}
private endProcessing(): void {
this.isProcessing.set(false);
}
private readonly progressCount: WritableSignal<number> = signal(0);
async inviteUser(
organization: Organization,
email: string,
@@ -186,19 +201,42 @@ export class MemberActionsService {
}
}
async bulkReinvite(organization: Organization, userIds: UserId[]): Promise<BulkActionResult> {
this.startProcessing();
async bulkReinvite(
organization: Organization,
users: OrganizationUserView[],
): Promise<BulkActionResult> {
let result = new BulkActionResult();
const bulkReinviteUIEnabled = await firstValueFrom(
this.configService.getFeatureFlag$(FeatureFlag.BulkReinviteUI),
);
if (bulkReinviteUIEnabled) {
this.startProcessing(users.length);
} else {
this.startProcessing();
}
try {
return this.processBatchedOperation(userIds, REQUESTS_PER_BATCH, (batch) =>
this.organizationUserApiService.postManyOrganizationUserReinvite(organization.id, batch),
);
result = await this.processBatchedOperation(users, REQUESTS_PER_BATCH, (userBatch) => {
const userIds = userBatch.map((u) => u.id);
return this.organizationUserApiService.postManyOrganizationUserReinvite(
organization.id,
userIds,
);
});
if (bulkReinviteUIEnabled && result.failed.length > 0) {
this.memberDialogManager.openBulkReinviteFailureDialog(organization, users, result);
}
} catch (error) {
return {
failed: userIds.map((id) => ({ id, error: (error as Error).message ?? String(error) })),
};
result.failed = users.map((user) => ({
id: user.id,
error: (error as Error).message ?? String(error),
}));
} finally {
this.endProcessing();
}
return result;
}
allowResetPassword(
@@ -235,21 +273,23 @@ export class MemberActionsService {
/**
* Processes user IDs in sequential batches and aggregates results.
* @param userIds - Array of user IDs to process
* @param users - Array of users to process
* @param batchSize - Number of IDs to process per batch
* @param processBatch - Async function that processes a single batch and returns the result
* @param processBatch - Async function that processes a single batch from the provided param `users` and returns the result.
* @returns Aggregated bulk action result
*/
private async processBatchedOperation(
userIds: UserId[],
users: OrganizationUserView[],
batchSize: number,
processBatch: (batch: string[]) => Promise<ListResponse<OrganizationUserBulkResponse>>,
processBatch: (
batch: OrganizationUserView[],
) => Promise<ListResponse<OrganizationUserBulkResponse>>,
): Promise<BulkActionResult> {
const allSuccessful: OrganizationUserBulkResponse[] = [];
const allFailed: { id: string; error: string }[] = [];
for (let i = 0; i < userIds.length; i += batchSize) {
const batch = userIds.slice(i, i + batchSize);
for (let i = 0; i < users.length; i += batchSize) {
const batch = users.slice(i, i + batchSize);
try {
const result = await processBatch(batch);
@@ -265,18 +305,18 @@ export class MemberActionsService {
}
} catch (error) {
allFailed.push(
...batch.map((id) => ({ id, error: (error as Error).message ?? String(error) })),
...batch.map((user) => ({
id: user.id,
error: (error as Error).message ?? String(error),
})),
);
}
this.progressCount.update((value) => value + batch.length);
}
const successful =
allSuccessful.length > 0
? new ListResponse(allSuccessful, OrganizationUserBulkResponse)
: undefined;
return {
successful,
successful: allSuccessful.length > 0 ? allSuccessful : undefined,
failed: allFailed,
};
}

View File

@@ -1,4 +1,4 @@
import { Injectable } from "@angular/core";
import { Injectable, WritableSignal } from "@angular/core";
import { firstValueFrom, lastValueFrom } from "rxjs";
import { UserNamePipe } from "@bitwarden/angular/pipes/user-name.pipe";
@@ -7,7 +7,7 @@ import { ProductTierType } from "@bitwarden/common/billing/enums";
import { OrganizationBillingMetadataResponse } from "@bitwarden/common/billing/models/response/organization-billing-metadata.response";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { OrganizationId } from "@bitwarden/common/types/guid";
import { DialogService, ToastService } from "@bitwarden/components";
import { CenterPositionStrategy, DialogService, ToastService } from "@bitwarden/components";
import { OrganizationUserView } from "../../../core/views/organization-user.view";
import { openEntityEventsDialog } from "../../../manage/entity-events.component";
@@ -18,6 +18,8 @@ import {
import { BulkConfirmDialogComponent } from "../../components/bulk/bulk-confirm-dialog.component";
import { BulkDeleteDialogComponent } from "../../components/bulk/bulk-delete-dialog.component";
import { BulkEnableSecretsManagerDialogComponent } from "../../components/bulk/bulk-enable-sm-dialog.component";
import { BulkProgressDialogComponent } from "../../components/bulk/bulk-progress-dialog.component";
import { BulkReinviteFailureDialogComponent } from "../../components/bulk/bulk-reinvite-failure-dialog.component";
import { BulkRemoveDialogComponent } from "../../components/bulk/bulk-remove-dialog.component";
import { BulkRestoreRevokeComponent } from "../../components/bulk/bulk-restore-revoke.component";
import { BulkStatusComponent } from "../../components/bulk/bulk-status.component";
@@ -27,6 +29,7 @@ import {
openUserAddEditDialog,
} from "../../components/member-dialog";
import { DeleteManagedMemberWarningService } from "../delete-managed-member/delete-managed-member-warning.service";
import { BulkActionResult } from "../member-actions/member-actions.service";
@Injectable()
export class MemberDialogManagerService {
@@ -319,4 +322,33 @@ export class MemberDialogManagerService {
type: "warning",
});
}
openBulkProgressDialog(progress: WritableSignal<number>, allCount: number) {
return this.dialogService.open<BulkProgressDialogComponent>(BulkProgressDialogComponent, {
disableClose: true,
positionStrategy: new CenterPositionStrategy(),
data: {
progress,
allCount,
},
});
}
openBulkReinviteFailureDialog(
organization: Organization,
users: OrganizationUserView[],
result: BulkActionResult,
) {
return this.dialogService.open<BulkReinviteFailureDialogComponent>(
BulkReinviteFailureDialogComponent,
{
positionStrategy: new CenterPositionStrategy(),
data: {
organization,
users,
result,
},
},
);
}
}

View File

@@ -6637,6 +6637,18 @@
}
}
},
"reinviteSuccessToast":{
"message": "1 invitation sent"
},
"bulkReinviteSentToast": {
"message": "$COUNT$ invitations sent",
"placeholders": {
"count": {
"content": "$1",
"example": "12"
}
}
},
"bulkReinviteLimitedSuccessToast": {
"message": "$LIMIT$ of $SELECTEDCOUNT$ users re-invited. $EXCLUDEDCOUNT$ were not invited due to the $LIMIT$ invite limit.",
"placeholders": {
@@ -6654,6 +6666,50 @@
}
}
},
"bulkReinviteProgressTitle": {
"message": "$COUNT$ of $TOTAL$ invitations sent...",
"placeholders": {
"count": {
"content": "$1",
"example": "1,000"
},
"total": {
"content": "$2",
"example": "2,000"
}
}
},
"bulkReinviteProgressSubtitle": {
"message": "Keep this page open until all are sent."
},
"bulkReinviteFailuresTitle": {
"message": "$COUNT$ invitations didn't send",
"placeholders": {
"count": {
"content": "$1",
"example": "1,000"
}
}
},
"bulkReinviteFailureTitle": {
"message": "1 invitation didn't send"
},
"bulkReinviteFailureDescription":{
"message": "An error occurred while sending invitations to $COUNT$ of $TOTAL$ members. Try sending again, and if the problem continues,",
"placeholders": {
"count": {
"content": "$1",
"example": "1,000"
},
"total": {
"content": "$2",
"example": "2,000"
}
}
},
"bulkResendInvitations": {
"message": "Try sending again"
},
"bulkRemovedMessage": {
"message": "Removed successfully"
},

View File

@@ -215,7 +215,10 @@ export class MembersComponent extends BaseMembersComponent<ProviderUser> {
} else {
this.toastService.showToast({
variant: "success",
message: this.i18nService.t("bulkReinviteSuccessToast", invitedCount.toString()),
message:
invitedCount === 1
? this.i18nService.t("reinviteSuccessToast")
: this.i18nService.t("bulkReinviteSentToast", invitedCount.toString()),
});
}
} else {

View File

@@ -228,7 +228,10 @@ export class vNextMembersComponent {
} else {
this.toastService.showToast({
variant: "success",
message: this.i18nService.t("bulkReinviteSuccessToast", invitedCount.toString()),
message:
invitedCount === 1
? this.i18nService.t("reinviteSuccessToast")
: this.i18nService.t("bulkReinviteSentToast", invitedCount.toString()),
});
}
} else {

View File

@@ -15,6 +15,7 @@ export enum FeatureFlag {
BlockClaimedDomainAccountCreation = "pm-28297-block-uninvited-claimed-domain-registration",
DefaultUserCollectionRestore = "pm-30883-my-items-restored-users",
MembersComponentRefactor = "pm-29503-refactor-members-inheritance",
BulkReinviteUI = "pm-28416-bulk-reinvite-ux-improvements",
/* Auth */
PM23801_PrefetchPasswordPrelogin = "pm-23801-prefetch-password-prelogin",
@@ -109,6 +110,7 @@ export const DefaultFeatureFlagValue = {
[FeatureFlag.BlockClaimedDomainAccountCreation]: FALSE,
[FeatureFlag.DefaultUserCollectionRestore]: FALSE,
[FeatureFlag.MembersComponentRefactor]: FALSE,
[FeatureFlag.BulkReinviteUI]: FALSE,
/* Autofill */
[FeatureFlag.UseUndeterminedCipherScenarioTriggeringLogic]: FALSE,