From a4ff58120b735680f0d5b3a8823851104cb3d0bc Mon Sep 17 00:00:00 2001 From: Brandon Treston Date: Tue, 10 Feb 2026 16:20:39 -0500 Subject: [PATCH] [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 --- .../bulk/bulk-progress-dialog.component.html | 22 ++ .../bulk/bulk-progress-dialog.component.ts | 46 +++++ ...ulk-reinvite-failure-dialog.component.html | 70 +++++++ .../bulk-reinvite-failure-dialog.component.ts | 62 ++++++ .../members/deprecated_members.component.ts | 10 +- .../members/members.component.html | 37 ++-- .../members/members.component.spec.ts | 2 +- .../members/members.component.ts | 24 ++- .../organizations/members/members.module.ts | 7 +- .../member-actions.service.spec.ts | 195 ++++++++++++++++-- .../member-actions/member-actions.service.ts | 96 ++++++--- .../member-dialog-manager.service.ts | 36 +++- apps/web/src/locales/en/messages.json | 56 +++++ .../manage/deprecated_members.component.ts | 5 +- .../providers/manage/members.component.ts | 5 +- libs/common/src/enums/feature-flag.enum.ts | 2 + 16 files changed, 596 insertions(+), 79 deletions(-) create mode 100644 apps/web/src/app/admin-console/organizations/members/components/bulk/bulk-progress-dialog.component.html create mode 100644 apps/web/src/app/admin-console/organizations/members/components/bulk/bulk-progress-dialog.component.ts create mode 100644 apps/web/src/app/admin-console/organizations/members/components/bulk/bulk-reinvite-failure-dialog.component.html create mode 100644 apps/web/src/app/admin-console/organizations/members/components/bulk/bulk-reinvite-failure-dialog.component.ts diff --git a/apps/web/src/app/admin-console/organizations/members/components/bulk/bulk-progress-dialog.component.html b/apps/web/src/app/admin-console/organizations/members/components/bulk/bulk-progress-dialog.component.html new file mode 100644 index 00000000000..2fbcc8afd86 --- /dev/null +++ b/apps/web/src/app/admin-console/organizations/members/components/bulk/bulk-progress-dialog.component.html @@ -0,0 +1,22 @@ + +
+
+
+ +
+
+
+

+ {{ "bulkReinviteProgressTitle" | i18n: progressCount() : allCount }} +

+ + {{ "bulkReinviteProgressSubtitle" | i18n }} + +
+
+
diff --git a/apps/web/src/app/admin-console/organizations/members/components/bulk/bulk-progress-dialog.component.ts b/apps/web/src/app/admin-console/organizations/members/components/bulk/bulk-progress-dialog.component.ts new file mode 100644 index 00000000000..66582fb4434 --- /dev/null +++ b/apps/web/src/app/admin-console/organizations/members/components/bulk/bulk-progress-dialog.component.ts @@ -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; + 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; + protected readonly progressPercentage: Signal; + 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) { + return dialogService.open(BulkProgressDialogComponent, config); + } +} diff --git a/apps/web/src/app/admin-console/organizations/members/components/bulk/bulk-reinvite-failure-dialog.component.html b/apps/web/src/app/admin-console/organizations/members/components/bulk/bulk-reinvite-failure-dialog.component.html new file mode 100644 index 00000000000..0f216be6e5f --- /dev/null +++ b/apps/web/src/app/admin-console/organizations/members/components/bulk/bulk-reinvite-failure-dialog.component.html @@ -0,0 +1,70 @@ + + @let failCount = dataSource().data.length; +
+ @if (failCount > 1) { + {{ "bulkReinviteFailuresTitle" | i18n: failCount }} + } @else { + {{ "bulkReinviteFailureTitle" | i18n }} + } +
+ +
+ {{ "bulkReinviteFailureDescription" | i18n: failCount : totalCount }} + + + {{ "contactSupportShort" | i18n | lowercase }} + + + +
+ + + + {{ "name" | i18n }} + + + + + @let rows = $any(rows$ | async); + @for (u of rows; track u.id) { + + +
+ +
+
+ +
+ @if (u.name) { +
+ {{ u.email }} +
+ } +
+
+ + + } +
+
+
+
+ + + + + + +
diff --git a/apps/web/src/app/admin-console/organizations/members/components/bulk/bulk-reinvite-failure-dialog.component.ts b/apps/web/src/app/admin-console/organizations/members/components/bulk/bulk-reinvite-failure-dialog.component.ts new file mode 100644 index 00000000000..5cb11708fd0 --- /dev/null +++ b/apps/web/src/app/admin-console/organizations/members/components/bulk/bulk-reinvite-failure-dialog.component.ts @@ -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; + + 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) { + return dialogService.open(BulkReinviteFailureDialogComponent, config); + } +} diff --git a/apps/web/src/app/admin-console/organizations/members/deprecated_members.component.ts b/apps/web/src/app/admin-console/organizations/members/deprecated_members.component.ts index 197c5d3efb5..dae9bafbcfe 100644 --- a/apps/web/src/app/admin-console/organizations/members/deprecated_members.component.ts +++ b/apps/web/src/app/admin-console/organizations/members/deprecated_members.component.ts @@ -444,10 +444,7 @@ export class MembersComponent extends BaseMembersComponent } 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 } 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 { diff --git a/apps/web/src/app/admin-console/organizations/members/members.component.html b/apps/web/src/app/admin-console/organizations/members/members.component.html index 0f074d4481d..75ef503366b 100644 --- a/apps/web/src/app/admin-console/organizations/members/members.component.html +++ b/apps/web/src/app/admin-console/organizations/members/members.component.html @@ -113,25 +113,24 @@ {{ "policies" | i18n }} @if (showUserManagementControls()) { - -
- - -
- +
+ + +
} diff --git a/apps/web/src/app/admin-console/organizations/members/members.component.spec.ts b/apps/web/src/app/admin-console/organizations/members/members.component.spec.ts index 246c3d8a1c0..1cd90989b12 100644 --- a/apps/web/src/app/admin-console/organizations/members/members.component.spec.ts +++ b/apps/web/src/app/admin-console/organizations/members/members.component.spec.ts @@ -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(); }); diff --git a/apps/web/src/app/admin-console/organizations/members/members.component.ts b/apps/web/src/app/admin-console/organizations/members/members.component.ts index 36c207219a0..6139c5f07a5 100644 --- a/apps/web/src/app/admin-console/organizations/members/members.component.ts +++ b/apps/web/src/app/admin-console/organizations/members/members.component.ts @@ -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 = 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; protected resetPasswordPolicyEnabled$: Observable; @@ -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) { diff --git a/apps/web/src/app/admin-console/organizations/members/members.module.ts b/apps/web/src/app/admin-console/organizations/members/members.module.ts index 9fd477b1e29..54e2d1b6373 100644 --- a/apps/web/src/app/admin-console/organizations/members/members.module.ts +++ b/apps/web/src/app/admin-console/organizations/members/members.module.ts @@ -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, diff --git a/apps/web/src/app/admin-console/organizations/members/services/member-actions/member-actions.service.spec.ts b/apps/web/src/app/admin-console/organizations/members/services/member-actions/member-actions.service.spec.ts index 5924c2f7814..688c7ed77ce 100644 --- a/apps/web/src/app/admin-console/organizations/members/services/member-actions/member-actions.service.spec.ts +++ b/apps/web/src/app/admin-console/organizations/members/services/member-actions/member-actions.service.spec.ts @@ -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; let configService: MockProxy; let organizationMetadataService: MockProxy; + let memberDialogManager: MockProxy; const organizationId = newGuid() as OrganizationId; const userIdToManage = newGuid(); @@ -46,6 +49,7 @@ describe("MemberActionsService", () => { organizationUserService = mock(); configService = mock(); organizationMetadataService = mock(); + memberDialogManager = mock(); mockOrganization = { id: organizationId, @@ -82,6 +86,8 @@ describe("MemberActionsService", () => { useValue: mock(), }, { provide: UserNamePipe, useValue: mock() }, + { provide: MemberDialogManagerService, useValue: memberDialogManager }, + { provide: I18nService, useValue: mock() }, ], }); @@ -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; + let mockI18nService: MockProxy; + + beforeEach(() => { + configService.getFeatureFlag$.mockReturnValue(of(true)); + mockDialogService = TestBed.inject(DialogService) as MockProxy; + mockI18nService = TestBed.inject(I18nService) as MockProxy; + 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", () => { diff --git a/apps/web/src/app/admin-console/organizations/members/services/member-actions/member-actions.service.ts b/apps/web/src/app/admin-console/organizations/members/services/member-actions/member-actions.service.ts index 3b0db124a6b..e5f8c0c6673 100644 --- a/apps/web/src/app/admin-console/organizations/members/services/member-actions/member-actions.service.ts +++ b/apps/web/src/app/admin-console/organizations/members/services/member-actions/member-actions.service.ts @@ -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; - 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 = signal(0); + async inviteUser( organization: Organization, email: string, @@ -186,19 +201,42 @@ export class MemberActionsService { } } - async bulkReinvite(organization: Organization, userIds: UserId[]): Promise { - this.startProcessing(); + async bulkReinvite( + organization: Organization, + users: OrganizationUserView[], + ): Promise { + 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>, + processBatch: ( + batch: OrganizationUserView[], + ) => Promise>, ): Promise { 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, }; } diff --git a/apps/web/src/app/admin-console/organizations/members/services/member-dialog-manager/member-dialog-manager.service.ts b/apps/web/src/app/admin-console/organizations/members/services/member-dialog-manager/member-dialog-manager.service.ts index c6ef536af2b..18106031fd0 100644 --- a/apps/web/src/app/admin-console/organizations/members/services/member-dialog-manager/member-dialog-manager.service.ts +++ b/apps/web/src/app/admin-console/organizations/members/services/member-dialog-manager/member-dialog-manager.service.ts @@ -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, allCount: number) { + return this.dialogService.open(BulkProgressDialogComponent, { + disableClose: true, + positionStrategy: new CenterPositionStrategy(), + data: { + progress, + allCount, + }, + }); + } + + openBulkReinviteFailureDialog( + organization: Organization, + users: OrganizationUserView[], + result: BulkActionResult, + ) { + return this.dialogService.open( + BulkReinviteFailureDialogComponent, + { + positionStrategy: new CenterPositionStrategy(), + data: { + organization, + users, + result, + }, + }, + ); + } } diff --git a/apps/web/src/locales/en/messages.json b/apps/web/src/locales/en/messages.json index fc2f463d9e6..1f4e1c98e32 100644 --- a/apps/web/src/locales/en/messages.json +++ b/apps/web/src/locales/en/messages.json @@ -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" }, diff --git a/bitwarden_license/bit-web/src/app/admin-console/providers/manage/deprecated_members.component.ts b/bitwarden_license/bit-web/src/app/admin-console/providers/manage/deprecated_members.component.ts index e581bf458d2..1b1ae25c027 100644 --- a/bitwarden_license/bit-web/src/app/admin-console/providers/manage/deprecated_members.component.ts +++ b/bitwarden_license/bit-web/src/app/admin-console/providers/manage/deprecated_members.component.ts @@ -215,7 +215,10 @@ export class MembersComponent extends BaseMembersComponent { } 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 { diff --git a/bitwarden_license/bit-web/src/app/admin-console/providers/manage/members.component.ts b/bitwarden_license/bit-web/src/app/admin-console/providers/manage/members.component.ts index 3efeee17100..c63bda449c5 100644 --- a/bitwarden_license/bit-web/src/app/admin-console/providers/manage/members.component.ts +++ b/bitwarden_license/bit-web/src/app/admin-console/providers/manage/members.component.ts @@ -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 { diff --git a/libs/common/src/enums/feature-flag.enum.ts b/libs/common/src/enums/feature-flag.enum.ts index 9941e7671f4..7722138b88f 100644 --- a/libs/common/src/enums/feature-flag.enum.ts +++ b/libs/common/src/enums/feature-flag.enum.ts @@ -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,