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:
committed by
jaasen-livefront
parent
541686dfc8
commit
a4ff58120b
@@ -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>
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user