1
0
mirror of https://github.com/bitwarden/browser synced 2025-12-10 13:23:34 +00:00

[PM-23133] refactor members component (#16703)

* WIP: added new services, refactor members to use billing service and member action service

* replace dialog logic and user logic with service implementations

* WIP

* wip add tests

* add tests, continue refactoring

* clean up

* move BillingConstraintService to billing ownership

* fix import

* fix seat count not updating if feature flag is disabled

* refactor billingMetadata, clean up
This commit is contained in:
Brandon Treston
2025-10-23 11:16:17 -04:00
committed by GitHub
parent 7321e3132b
commit 0691583b50
16 changed files with 2999 additions and 666 deletions

View File

@@ -24,6 +24,7 @@ import { KeyService } from "@bitwarden/key-management";
import { OrganizationUserView } from "../organizations/core/views/organization-user.view";
import { UserConfirmComponent } from "../organizations/manage/user-confirm.component";
import { MemberActionResult } from "../organizations/members/services/member-actions/member-actions.service";
import { PeopleTableDataSource, peopleFilter } from "./people-table-data-source";
@@ -75,7 +76,7 @@ export abstract class BaseMembersComponent<UserView extends UserViewTypes> {
/**
* The currently executing promise - used to avoid multiple user actions executing at once.
*/
actionPromise?: Promise<void>;
actionPromise?: Promise<MemberActionResult>;
protected searchControl = new FormControl("", { nonNullable: true });
protected statusToggle = new BehaviorSubject<StatusType | undefined>(undefined);
@@ -101,13 +102,13 @@ export abstract class BaseMembersComponent<UserView extends UserViewTypes> {
abstract edit(user: UserView, organization?: Organization): void;
abstract getUsers(organization?: Organization): Promise<ListResponse<UserView> | UserView[]>;
abstract removeUser(id: string, organization?: Organization): Promise<void>;
abstract reinviteUser(id: string, organization?: Organization): Promise<void>;
abstract removeUser(id: string, organization?: Organization): Promise<MemberActionResult>;
abstract reinviteUser(id: string, organization?: Organization): Promise<MemberActionResult>;
abstract confirmUser(
user: UserView,
publicKey: Uint8Array,
organization?: Organization,
): Promise<void>;
): Promise<MemberActionResult>;
abstract invite(organization?: Organization): void;
async load(organization?: Organization) {
@@ -140,12 +141,16 @@ export abstract class BaseMembersComponent<UserView extends UserViewTypes> {
this.actionPromise = this.removeUser(user.id, organization);
try {
await this.actionPromise;
this.toastService.showToast({
variant: "success",
message: this.i18nService.t("removedUserId", this.userNamePipe.transform(user)),
});
this.dataSource.removeUser(user);
const result = await this.actionPromise;
if (result.success) {
this.toastService.showToast({
variant: "success",
message: this.i18nService.t("removedUserId", this.userNamePipe.transform(user)),
});
this.dataSource.removeUser(user);
} else {
throw new Error(result.error);
}
} catch (e) {
this.validationService.showError(e);
}
@@ -159,11 +164,15 @@ export abstract class BaseMembersComponent<UserView extends UserViewTypes> {
this.actionPromise = this.reinviteUser(user.id, organization);
try {
await this.actionPromise;
this.toastService.showToast({
variant: "success",
message: this.i18nService.t("hasBeenReinvited", this.userNamePipe.transform(user)),
});
const result = await this.actionPromise;
if (result.success) {
this.toastService.showToast({
variant: "success",
message: this.i18nService.t("hasBeenReinvited", this.userNamePipe.transform(user)),
});
} else {
throw new Error(result.error);
}
} catch (e) {
this.validationService.showError(e);
}
@@ -174,14 +183,18 @@ export abstract class BaseMembersComponent<UserView extends UserViewTypes> {
const confirmUser = async (publicKey: Uint8Array) => {
try {
this.actionPromise = this.confirmUser(user, publicKey, organization);
await this.actionPromise;
user.status = this.userStatusType.Confirmed;
this.dataSource.replaceUser(user);
const result = await this.actionPromise;
if (result.success) {
user.status = this.userStatusType.Confirmed;
this.dataSource.replaceUser(user);
this.toastService.showToast({
variant: "success",
message: this.i18nService.t("hasBeenConfirmed", this.userNamePipe.transform(user)),
});
this.toastService.showToast({
variant: "success",
message: this.i18nService.t("hasBeenConfirmed", this.userNamePipe.transform(user)),
});
} else {
throw new Error(result.error);
}
} catch (e) {
this.validationService.showError(e);
throw e;

View File

@@ -2,7 +2,7 @@
@if (organization) {
<app-organization-free-trial-warning
[organization]="organization"
(clicked)="navigateToPaymentMethod(organization)"
(clicked)="billingConstraint.navigateToPaymentMethod(organization)"
>
</app-organization-free-trial-warning>
<app-header>
@@ -339,7 +339,10 @@
></i>
<span class="tw-sr-only">{{ "userUsingTwoStep" | i18n }}</span>
</ng-container>
<ng-container *ngIf="showEnrolledStatus($any(u), organization)">
@let resetPasswordPolicyEnabled = resetPasswordPolicyEnabled$ | async;
<ng-container
*ngIf="showEnrolledStatus($any(u), organization, resetPasswordPolicyEnabled)"
>
<i
class="bwi bwi-key"
title="{{ 'enrolledAccountRecovery' | i18n }}"
@@ -422,7 +425,7 @@
type="button"
bitMenuItem
(click)="resetPassword(u, organization)"
*ngIf="allowResetPassword(u, organization)"
*ngIf="allowResetPassword(u, organization, resetPasswordPolicyEnabled)"
>
<i aria-hidden="true" class="bwi bwi-key"></i> {{ "recoverAccount" | i18n }}
</button>

View File

@@ -1,14 +1,12 @@
import { Component, computed, Signal } from "@angular/core";
import { takeUntilDestroyed, toSignal } from "@angular/core/rxjs-interop";
import { ActivatedRoute, Router } from "@angular/router";
import { ActivatedRoute } from "@angular/router";
import {
BehaviorSubject,
combineLatest,
concatMap,
filter,
firstValueFrom,
from,
lastValueFrom,
map,
merge,
Observable,
@@ -17,24 +15,12 @@ import {
take,
} from "rxjs";
import {
OrganizationUserApiService,
OrganizationUserConfirmRequest,
OrganizationUserUserDetailsResponse,
CollectionService,
CollectionData,
Collection,
CollectionDetailsResponse,
} from "@bitwarden/admin-console/common";
import { OrganizationUserUserDetailsResponse } from "@bitwarden/admin-console/common";
import { UserNamePipe } from "@bitwarden/angular/pipes/user-name.pipe";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { OrganizationApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/organization/organization-api.service.abstraction";
import {
getOrganizationById,
OrganizationService,
} from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
import { OrganizationManagementPreferencesService } from "@bitwarden/common/admin-console/abstractions/organization-management-preferences/organization-management-preferences.service";
import { PolicyApiServiceAbstraction as PolicyApiService } from "@bitwarden/common/admin-console/abstractions/policy/policy-api.service.abstraction";
import { PolicyApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/policy/policy-api.service.abstraction";
import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
import {
OrganizationUserStatusType,
@@ -43,53 +29,32 @@ import {
} from "@bitwarden/common/admin-console/enums";
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
import { Policy } from "@bitwarden/common/admin-console/models/domain/policy";
import { OrganizationKeysRequest } from "@bitwarden/common/admin-console/models/request/organization-keys.request";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { getUserId } from "@bitwarden/common/auth/services/account.service";
import { BillingApiServiceAbstraction } from "@bitwarden/common/billing/abstractions/billing-api.service.abstraction";
import { OrganizationMetadataServiceAbstraction } from "@bitwarden/common/billing/abstractions/organization-metadata.service.abstraction";
import { isNotSelfUpgradable, ProductTierType } from "@bitwarden/common/billing/enums";
import { OrganizationBillingMetadataResponse } from "@bitwarden/common/billing/models/response/organization-billing-metadata.response";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service";
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 { ValidationService } from "@bitwarden/common/platform/abstractions/validation.service";
import { OrganizationId, UserId } from "@bitwarden/common/types/guid";
import { SyncService } from "@bitwarden/common/vault/abstractions/sync/sync.service.abstraction";
import { DialogService, SimpleDialogOptions, ToastService } from "@bitwarden/components";
import { getById } from "@bitwarden/common/platform/misc";
import { DialogService, ToastService } from "@bitwarden/components";
import { KeyService } from "@bitwarden/key-management";
import { UserId } from "@bitwarden/user-core";
import { BillingConstraintService } from "@bitwarden/web-vault/app/billing/members/billing-constraint/billing-constraint.service";
import { OrganizationWarningsService } from "@bitwarden/web-vault/app/billing/organizations/warnings/services";
import {
ChangePlanDialogResultType,
openChangePlanDialog,
} from "../../../billing/organizations/change-plan-dialog.component";
import { BaseMembersComponent } from "../../common/base-members.component";
import { PeopleTableDataSource } from "../../common/people-table-data-source";
import { GroupApiService } from "../core";
import { OrganizationUserView } from "../core/views/organization-user.view";
import { openEntityEventsDialog } from "../manage/entity-events.component";
import {
AccountRecoveryDialogComponent,
AccountRecoveryDialogResultType,
} from "./components/account-recovery/account-recovery-dialog.component";
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 { 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";
import {
MemberDialogResult,
MemberDialogTab,
openUserAddEditDialog,
} from "./components/member-dialog";
import { isFixedSeatPlan } from "./components/member-dialog/validators/org-seat-limit-reached.validator";
import { AccountRecoveryDialogResultType } from "./components/account-recovery/account-recovery-dialog.component";
import { MemberDialogResult, MemberDialogTab } from "./components/member-dialog";
import { MemberDialogManagerService, OrganizationMembersService } from "./services";
import { DeleteManagedMemberWarningService } from "./services/delete-managed-member/delete-managed-member-warning.service";
import { OrganizationUserService } from "./services/organization-user/organization-user.service";
import {
MemberActionsService,
MemberActionResult,
} from "./services/member-actions/member-actions.service";
class MembersTableDataSource extends PeopleTableDataSource<OrganizationUserView> {
protected statusType = OrganizationUserStatusType;
@@ -107,7 +72,10 @@ export class MembersComponent extends BaseMembersComponent<OrganizationUserView>
readonly organization: Signal<Organization | undefined>;
status: OrganizationUserStatusType | undefined;
orgResetPasswordPolicyEnabled = false;
private userId$: Observable<UserId> = this.accountService.activeAccount$.pipe(getUserId);
resetPasswordPolicyEnabled$: Observable<boolean>;
protected readonly canUseSecretsManager: Signal<boolean> = computed(
() => this.organization()?.useSecretsManager ?? false,
@@ -115,43 +83,34 @@ export class MembersComponent extends BaseMembersComponent<OrganizationUserView>
protected readonly showUserManagementControls: Signal<boolean> = computed(
() => this.organization()?.canManageUsers ?? false,
);
private refreshBillingMetadata$: BehaviorSubject<null> = new BehaviorSubject(null);
protected billingMetadata$: Observable<OrganizationBillingMetadataResponse>;
// Fixed sizes used for cdkVirtualScroll
protected rowHeight = 66;
protected rowHeightClass = `tw-h-[66px]`;
private userId$: Observable<UserId> = this.accountService.activeAccount$.pipe(getUserId);
constructor(
apiService: ApiService,
i18nService: I18nService,
organizationManagementPreferencesService: OrganizationManagementPreferencesService,
keyService: KeyService,
private encryptService: EncryptService,
validationService: ValidationService,
logService: LogService,
userNamePipe: UserNamePipe,
dialogService: DialogService,
toastService: ToastService,
private policyService: PolicyService,
private policyApiService: PolicyApiService,
private route: ActivatedRoute,
private syncService: SyncService,
protected deleteManagedMemberWarningService: DeleteManagedMemberWarningService,
private organizationWarningsService: OrganizationWarningsService,
private memberActionsService: MemberActionsService,
private memberDialogManager: MemberDialogManagerService,
protected billingConstraint: BillingConstraintService,
protected memberService: OrganizationMembersService,
private organizationService: OrganizationService,
private accountService: AccountService,
private organizationApiService: OrganizationApiServiceAbstraction,
private organizationUserApiService: OrganizationUserApiService,
private router: Router,
private groupService: GroupApiService,
private collectionService: CollectionService,
private billingApiService: BillingApiServiceAbstraction,
private policyService: PolicyService,
private policyApiService: PolicyApiServiceAbstraction,
private organizationMetadataService: OrganizationMetadataServiceAbstraction,
protected deleteManagedMemberWarningService: DeleteManagedMemberWarningService,
private configService: ConfigService,
private organizationUserService: OrganizationUserService,
private organizationWarningsService: OrganizationWarningsService,
) {
super(
apiService,
@@ -169,14 +128,12 @@ export class MembersComponent extends BaseMembersComponent<OrganizationUserView>
concatMap((params) =>
this.userId$.pipe(
switchMap((userId) =>
this.organizationService
.organizations$(userId)
.pipe(getOrganizationById(params.organizationId)),
this.organizationService.organizations$(userId).pipe(getById(params.organizationId)),
),
filter((organization): organization is Organization => organization != null),
shareReplay({ refCount: true, bufferSize: 1 }),
),
),
filter((organization): organization is Organization => organization != null),
shareReplay({ refCount: true, bufferSize: 1 }),
);
this.organization = toSignal(organization$);
@@ -191,53 +148,26 @@ export class MembersComponent extends BaseMembersComponent<OrganizationUserView>
),
);
combineLatest([this.route.queryParams, policies$, organization$])
.pipe(
concatMap(async ([qParams, policies, organization]) => {
// Backfill pub/priv key if necessary
if (organization.canManageUsersPassword && !organization.hasPublicAndPrivateKeys) {
const orgShareKey = await firstValueFrom(
this.userId$.pipe(
switchMap((userId) => this.keyService.orgKeys$(userId)),
map((orgKeys) => {
if (orgKeys == null || orgKeys[organization.id] == null) {
throw new Error("Organization keys not found for provided User.");
}
return orgKeys[organization.id];
}),
),
);
const [orgPublicKey, encryptedOrgPrivateKey] =
await this.keyService.makeKeyPair(orgShareKey);
if (encryptedOrgPrivateKey.encryptedString == null) {
throw new Error("Encrypted private key is null.");
}
const request = new OrganizationKeysRequest(
orgPublicKey,
encryptedOrgPrivateKey.encryptedString,
);
const response = await this.organizationApiService.updateKeys(organization.id, request);
if (response != null) {
await this.syncService.fullSync(true); // Replace organizations with new data
} else {
throw new Error(this.i18nService.t("resetPasswordOrgKeysError"));
}
}
const resetPasswordPolicy = policies
this.resetPasswordPolicyEnabled$ = combineLatest([organization$, policies$]).pipe(
map(
([organization, policies]) =>
policies
.filter((policy) => policy.type === PolicyType.ResetPassword)
.find((p) => p.organizationId === organization.id);
this.orgResetPasswordPolicyEnabled = resetPasswordPolicy?.enabled ?? false;
.find((p) => p.organizationId === organization.id)?.enabled ?? false,
),
);
await this.load(organization);
combineLatest([this.route.queryParams, organization$])
.pipe(
concatMap(async ([qParams, organization]) => {
await this.load(organization!);
this.searchControl.setValue(qParams.search);
if (qParams.viewEvents != null) {
const user = this.dataSource.data.filter((u) => u.id === qParams.viewEvents);
if (user.length > 0 && user[0].status === OrganizationUserStatusType.Confirmed) {
this.openEventsDialog(user[0], organization);
this.openEventsDialog(user[0], organization!);
}
}
}),
@@ -257,11 +187,10 @@ export class MembersComponent extends BaseMembersComponent<OrganizationUserView>
)
.subscribe();
this.billingMetadata$ = combineLatest([this.refreshBillingMetadata$, organization$]).pipe(
switchMap(([_, organization]) =>
this.billingMetadata$ = organization$.pipe(
switchMap((organization) =>
this.organizationMetadataService.getOrganizationMetadata$(organization.id),
),
takeUntilDestroyed(),
shareReplay({ bufferSize: 1, refCount: false }),
);
@@ -271,136 +200,35 @@ export class MembersComponent extends BaseMembersComponent<OrganizationUserView>
}
override async load(organization: Organization) {
this.refreshBillingMetadata$.next(null);
await super.load(organization);
}
async getUsers(organization: Organization): Promise<OrganizationUserView[]> {
let groupsPromise: Promise<Map<string, string>> | undefined;
let collectionsPromise: Promise<Map<string, string>> | undefined;
// We don't need both groups and collections for the table, so only load one
const userPromise = this.organizationUserApiService.getAllUsers(organization.id, {
includeGroups: organization.useGroups,
includeCollections: !organization.useGroups,
});
// Depending on which column is displayed, we need to load the group/collection names
if (organization.useGroups) {
groupsPromise = this.getGroupNameMap(organization);
} else {
collectionsPromise = this.getCollectionNameMap(organization);
}
const [usersResponse, groupNamesMap, collectionNamesMap] = await Promise.all([
userPromise,
groupsPromise,
collectionsPromise,
]);
return (
usersResponse.data?.map<OrganizationUserView>((r) => {
const userView = OrganizationUserView.fromResponse(r);
userView.groupNames = userView.groups
.map((g) => groupNamesMap?.get(g))
.filter((name): name is string => name != null)
.sort(this.i18nService.collator?.compare);
userView.collectionNames = userView.collections
.map((c) => collectionNamesMap?.get(c.id))
.filter((name): name is string => name != null)
.sort(this.i18nService.collator?.compare);
return userView;
}) ?? []
);
return await this.memberService.loadUsers(organization);
}
async getGroupNameMap(organization: Organization): Promise<Map<string, string>> {
const groups = await this.groupService.getAll(organization.id);
const groupNameMap = new Map<string, string>();
groups.forEach((g) => groupNameMap.set(g.id, g.name));
return groupNameMap;
async removeUser(id: string, organization: Organization): Promise<MemberActionResult> {
return await this.memberActionsService.removeUser(organization, id);
}
/**
* Retrieve a map of all collection IDs <-> names for the organization.
*/
async getCollectionNameMap(organization: Organization) {
const response = from(this.apiService.getCollections(organization.id)).pipe(
map((res) =>
res.data.map((r) =>
Collection.fromCollectionData(new CollectionData(r as CollectionDetailsResponse)),
),
),
);
const decryptedCollections$ = combineLatest([
this.userId$.pipe(
switchMap((userId) => this.keyService.orgKeys$(userId)),
filter((orgKeys) => orgKeys != null),
),
response,
]).pipe(
switchMap(([orgKeys, collections]) =>
this.collectionService.decryptMany$(collections, orgKeys),
),
map((collections) => {
const collectionMap = new Map<string, string>();
collections.forEach((c) => collectionMap.set(c.id, c.name));
return collectionMap;
}),
);
return await firstValueFrom(decryptedCollections$);
async revokeUser(id: string, organization: Organization): Promise<MemberActionResult> {
return await this.memberActionsService.revokeUser(organization, id);
}
removeUser(id: string, organization: Organization): Promise<void> {
return this.organizationUserApiService.removeOrganizationUser(organization.id, id);
async restoreUser(id: string, organization: Organization): Promise<MemberActionResult> {
return await this.memberActionsService.restoreUser(organization, id);
}
revokeUser(id: string, organization: Organization): Promise<void> {
return this.organizationUserApiService.revokeOrganizationUser(organization.id, id);
}
restoreUser(id: string, organization: Organization): Promise<void> {
return this.organizationUserApiService.restoreOrganizationUser(organization.id, id);
}
reinviteUser(id: string, organization: Organization): Promise<void> {
return this.organizationUserApiService.postOrganizationUserReinvite(organization.id, id);
async reinviteUser(id: string, organization: Organization): Promise<MemberActionResult> {
return await this.memberActionsService.reinviteUser(organization, id);
}
async confirmUser(
user: OrganizationUserView,
publicKey: Uint8Array,
organization: Organization,
): Promise<void> {
if (
await firstValueFrom(this.configService.getFeatureFlag$(FeatureFlag.CreateDefaultLocation))
) {
await firstValueFrom(this.organizationUserService.confirmUser(organization, user, publicKey));
} else {
const request = await firstValueFrom(
this.userId$.pipe(
switchMap((userId) => this.keyService.orgKeys$(userId)),
filter((orgKeys) => orgKeys != null),
map((orgKeys) => orgKeys[organization.id]),
switchMap((orgKey) => this.encryptService.encapsulateKeyUnsigned(orgKey, publicKey)),
map((encKey) => {
const req = new OrganizationUserConfirmRequest();
req.key = encKey.encryptedString;
return req;
}),
),
);
await this.organizationUserApiService.postOrganizationUserConfirm(
organization.id,
user.id,
request,
);
}
): Promise<MemberActionResult> {
return await this.memberActionsService.confirmUser(user, publicKey, organization);
}
async revoke(user: OrganizationUserView, organization: Organization) {
@@ -412,12 +240,16 @@ export class MembersComponent extends BaseMembersComponent<OrganizationUserView>
this.actionPromise = this.revokeUser(user.id, organization);
try {
await this.actionPromise;
this.toastService.showToast({
variant: "success",
message: this.i18nService.t("revokedUserId", this.userNamePipe.transform(user)),
});
await this.load(organization);
const result = await this.actionPromise;
if (result.success) {
this.toastService.showToast({
variant: "success",
message: this.i18nService.t("revokedUserId", this.userNamePipe.transform(user)),
});
await this.load(organization);
} else {
throw new Error(result.error);
}
} catch (e) {
this.validationService.showError(e);
}
@@ -427,198 +259,68 @@ export class MembersComponent extends BaseMembersComponent<OrganizationUserView>
async restore(user: OrganizationUserView, organization: Organization) {
this.actionPromise = this.restoreUser(user.id, organization);
try {
await this.actionPromise;
this.toastService.showToast({
variant: "success",
message: this.i18nService.t("restoredUserId", this.userNamePipe.transform(user)),
});
await this.load(organization);
const result = await this.actionPromise;
if (result.success) {
this.toastService.showToast({
variant: "success",
message: this.i18nService.t("restoredUserId", this.userNamePipe.transform(user)),
});
await this.load(organization);
} else {
throw new Error(result.error);
}
} catch (e) {
this.validationService.showError(e);
}
this.actionPromise = undefined;
}
allowResetPassword(orgUser: OrganizationUserView, organization: Organization): boolean {
let callingUserHasPermission = false;
switch (organization.type) {
case OrganizationUserType.Owner:
callingUserHasPermission = true;
break;
case OrganizationUserType.Admin:
callingUserHasPermission = orgUser.type !== OrganizationUserType.Owner;
break;
case OrganizationUserType.Custom:
callingUserHasPermission =
orgUser.type !== OrganizationUserType.Owner &&
orgUser.type !== OrganizationUserType.Admin;
break;
}
return (
organization.canManageUsersPassword &&
callingUserHasPermission &&
organization.useResetPassword &&
organization.hasPublicAndPrivateKeys &&
orgUser.resetPasswordEnrolled &&
this.orgResetPasswordPolicyEnabled &&
orgUser.status === OrganizationUserStatusType.Confirmed
allowResetPassword(
orgUser: OrganizationUserView,
organization: Organization,
orgResetPasswordPolicyEnabled: boolean,
): boolean {
return this.memberActionsService.allowResetPassword(
orgUser,
organization,
orgResetPasswordPolicyEnabled,
);
}
showEnrolledStatus(
orgUser: OrganizationUserUserDetailsResponse,
organization: Organization,
orgResetPasswordPolicyEnabled: boolean,
): boolean {
return (
organization.useResetPassword &&
orgUser.resetPasswordEnrolled &&
this.orgResetPasswordPolicyEnabled
);
}
private getManageBillingText(organization: Organization): string {
return organization.canEditSubscription ? "ManageBilling" : "NoManageBilling";
}
private getProductKey(organization: Organization): string {
let product = "";
switch (organization.productTierType) {
case ProductTierType.Free:
product = "freeOrg";
break;
case ProductTierType.TeamsStarter:
product = "teamsStarterPlan";
break;
case ProductTierType.Families:
product = "familiesPlan";
break;
default:
throw new Error(`Unsupported product type: ${organization.productTierType}`);
}
return `${product}InvLimitReached${this.getManageBillingText(organization)}`;
}
private getDialogContent(organization: Organization): string {
return this.i18nService.t(this.getProductKey(organization), organization.seats);
}
private getAcceptButtonText(organization: Organization): string {
if (!organization.canEditSubscription) {
return this.i18nService.t("ok");
}
const productType = organization.productTierType;
if (isNotSelfUpgradable(productType)) {
throw new Error(`Unsupported product type: ${productType}`);
}
return this.i18nService.t("upgrade");
}
private async handleDialogClose(
result: boolean | undefined,
organization: Organization,
): Promise<void> {
if (!result || !organization.canEditSubscription) {
return;
}
const productType = organization.productTierType;
if (isNotSelfUpgradable(productType)) {
throw new Error(`Unsupported product type: ${organization.productTierType}`);
}
await this.router.navigate(["/organizations", organization.id, "billing", "subscription"], {
queryParams: { upgrade: true },
});
}
private async showSeatLimitReachedDialog(organization: Organization): Promise<void> {
const orgUpgradeSimpleDialogOpts: SimpleDialogOptions = {
title: this.i18nService.t("upgradeOrganization"),
content: this.getDialogContent(organization),
type: "primary",
acceptButtonText: this.getAcceptButtonText(organization),
};
if (!organization.canEditSubscription) {
orgUpgradeSimpleDialogOpts.cancelButtonText = null;
}
const simpleDialog = this.dialogService.openSimpleDialogRef(orgUpgradeSimpleDialogOpts);
await lastValueFrom(
simpleDialog.closed.pipe(map((closed) => this.handleDialogClose(closed, organization))),
orgResetPasswordPolicyEnabled
);
}
private async handleInviteDialog(organization: Organization) {
const billingMetadata = await firstValueFrom(this.billingMetadata$);
const dialog = openUserAddEditDialog(this.dialogService, {
data: {
kind: "Add",
organizationId: organization.id,
allOrganizationUserEmails: this.dataSource.data?.map((user) => user.email) ?? [],
occupiedSeatCount: billingMetadata?.organizationOccupiedSeats ?? 0,
isOnSecretsManagerStandalone: billingMetadata?.isOnSecretsManagerStandalone ?? false,
},
});
const allUserEmails = this.dataSource.data?.map((user) => user.email) ?? [];
const result = await lastValueFrom(dialog.closed);
const result = await this.memberDialogManager.openInviteDialog(
organization,
billingMetadata,
allUserEmails,
);
if (result === MemberDialogResult.Saved) {
await this.load(organization);
}
}
private async handleSeatLimitForFixedTiers(organization: Organization) {
if (!organization.canEditSubscription) {
await this.showSeatLimitReachedDialog(organization);
return;
}
const reference = openChangePlanDialog(this.dialogService, {
data: {
organizationId: organization.id,
productTierType: organization.productTierType,
},
});
const result = await lastValueFrom(reference.closed);
if (result === ChangePlanDialogResultType.Submitted) {
await this.load(organization);
}
}
async invite(organization: Organization) {
const billingMetadata = await firstValueFrom(this.billingMetadata$);
if (
organization.hasReseller &&
organization.seats === billingMetadata?.organizationOccupiedSeats
) {
this.toastService.showToast({
variant: "error",
title: this.i18nService.t("seatLimitReached"),
message: this.i18nService.t("contactYourProvider"),
});
return;
const seatLimitResult = this.billingConstraint.checkSeatLimit(organization, billingMetadata);
if (!(await this.billingConstraint.seatLimitReached(seatLimitResult, organization))) {
await this.handleInviteDialog(organization);
this.organizationMetadataService.refreshMetadataCache();
}
if (
billingMetadata?.organizationOccupiedSeats === organization.seats &&
isFixedSeatPlan(organization.productTierType)
) {
await this.handleSeatLimitForFixedTiers(organization);
return;
}
await this.handleInviteDialog(organization);
}
async edit(
@@ -627,20 +329,14 @@ export class MembersComponent extends BaseMembersComponent<OrganizationUserView>
initialTab: MemberDialogTab = MemberDialogTab.Role,
) {
const billingMetadata = await firstValueFrom(this.billingMetadata$);
const dialog = openUserAddEditDialog(this.dialogService, {
data: {
kind: "Edit",
name: this.userNamePipe.transform(user),
organizationId: organization.id,
organizationUserId: user.id,
usesKeyConnector: user.usesKeyConnector,
isOnSecretsManagerStandalone: billingMetadata?.isOnSecretsManagerStandalone ?? false,
initialTab: initialTab,
managedByOrganization: user.managedByOrganization,
},
});
const result = await lastValueFrom(dialog.closed);
const result = await this.memberDialogManager.openEditDialog(
user,
organization,
billingMetadata,
initialTab,
);
switch (result) {
case MemberDialogResult.Deleted:
this.dataSource.removeUser(user);
@@ -658,43 +354,23 @@ export class MembersComponent extends BaseMembersComponent<OrganizationUserView>
return;
}
const dialogRef = BulkRemoveDialogComponent.open(this.dialogService, {
data: {
organizationId: organization.id,
users: this.dataSource.getCheckedUsers(),
},
});
await lastValueFrom(dialogRef.closed);
await this.memberDialogManager.openBulkRemoveDialog(
organization,
this.dataSource.getCheckedUsers(),
);
this.organizationMetadataService.refreshMetadataCache();
await this.load(organization);
}
async bulkDelete(organization: Organization) {
const warningAcknowledged = await firstValueFrom(
this.deleteManagedMemberWarningService.warningAcknowledged(organization.id),
);
if (
!warningAcknowledged &&
organization.canManageUsers &&
organization.productTierType === ProductTierType.Enterprise
) {
const acknowledged = await this.deleteManagedMemberWarningService.showWarning();
if (!acknowledged) {
return;
}
}
if (this.actionPromise != null) {
return;
}
const dialogRef = BulkDeleteDialogComponent.open(this.dialogService, {
data: {
organizationId: organization.id,
users: this.dataSource.getCheckedUsers(),
},
});
await lastValueFrom(dialogRef.closed);
await this.memberDialogManager.openBulkDeleteDialog(
organization,
this.dataSource.getCheckedUsers(),
);
await this.load(organization);
}
@@ -711,13 +387,11 @@ export class MembersComponent extends BaseMembersComponent<OrganizationUserView>
return;
}
const ref = BulkRestoreRevokeComponent.open(this.dialogService, {
organizationId: organization.id,
users: this.dataSource.getCheckedUsers(),
isRevoking: isRevoking,
});
await firstValueFrom(ref.closed);
await this.memberDialogManager.openBulkRestoreRevokeDialog(
organization,
this.dataSource.getCheckedUsers(),
isRevoking,
);
await this.load(organization);
}
@@ -739,20 +413,22 @@ export class MembersComponent extends BaseMembersComponent<OrganizationUserView>
}
try {
const response = this.organizationUserApiService.postManyOrganizationUserReinvite(
organization.id,
const result = await this.memberActionsService.bulkReinvite(
organization,
filteredUsers.map((user) => user.id),
);
if (!result.successful) {
throw new Error();
}
// Bulk Status component open
const dialogRef = BulkStatusComponent.open(this.dialogService, {
data: {
users: users,
filteredUsers: filteredUsers,
request: response,
successfulMessage: this.i18nService.t("bulkReinviteMessage"),
},
});
await lastValueFrom(dialogRef.closed);
await this.memberDialogManager.openBulkStatusDialog(
users,
filteredUsers,
Promise.resolve(result.successful),
this.i18nService.t("bulkReinviteMessage"),
);
} catch (e) {
this.validationService.showError(e);
}
@@ -764,49 +440,24 @@ export class MembersComponent extends BaseMembersComponent<OrganizationUserView>
return;
}
const dialogRef = BulkConfirmDialogComponent.open(this.dialogService, {
data: {
organization: organization,
users: this.dataSource.getCheckedUsers(),
},
});
await lastValueFrom(dialogRef.closed);
await this.memberDialogManager.openBulkConfirmDialog(
organization,
this.dataSource.getCheckedUsers(),
);
await this.load(organization);
}
async bulkEnableSM(organization: Organization) {
const users = this.dataSource.getCheckedUsers().filter((ou) => !ou.accessSecretsManager);
const users = this.dataSource.getCheckedUsers();
if (users.length === 0) {
this.toastService.showToast({
variant: "error",
title: this.i18nService.t("errorOccurred"),
message: this.i18nService.t("noSelectedUsersApplicable"),
});
return;
}
await this.memberDialogManager.openBulkEnableSecretsManagerDialog(organization, users);
const dialogRef = BulkEnableSecretsManagerDialogComponent.open(this.dialogService, {
orgId: organization.id,
users,
});
await lastValueFrom(dialogRef.closed);
this.dataSource.uncheckAllUsers();
await this.load(organization);
}
openEventsDialog(user: OrganizationUserView, organization: Organization) {
openEntityEventsDialog(this.dialogService, {
data: {
name: this.userNamePipe.transform(user),
organizationId: organization.id,
entityId: user.id,
showUser: false,
entity: "user",
},
});
this.memberDialogManager.openEventsDialog(user, organization);
}
async resetPassword(user: OrganizationUserView, organization: Organization) {
@@ -821,16 +472,7 @@ export class MembersComponent extends BaseMembersComponent<OrganizationUserView>
return;
}
const dialogRef = AccountRecoveryDialogComponent.open(this.dialogService, {
data: {
name: this.userNamePipe.transform(user),
email: user.email,
organizationId: organization.id as OrganizationId,
organizationUserId: user.id,
},
});
const result = await lastValueFrom(dialogRef.closed);
const result = await this.memberDialogManager.openAccountRecoveryDialog(user, organization);
if (result === AccountRecoveryDialogResultType.Ok) {
await this.load(organization);
}
@@ -839,91 +481,29 @@ export class MembersComponent extends BaseMembersComponent<OrganizationUserView>
}
protected async removeUserConfirmationDialog(user: OrganizationUserView) {
const content = user.usesKeyConnector
? "removeUserConfirmationKeyConnector"
: "removeOrgUserConfirmation";
const confirmed = await this.dialogService.openSimpleDialog({
title: {
key: "removeUserIdAccess",
placeholders: [this.userNamePipe.transform(user)],
},
content: { key: content },
type: "warning",
});
if (!confirmed) {
return false;
}
if (user.status > OrganizationUserStatusType.Invited && user.hasMasterPassword === false) {
return await this.noMasterPasswordConfirmationDialog(user);
}
return true;
return await this.memberDialogManager.openRemoveUserConfirmationDialog(user);
}
protected async revokeUserConfirmationDialog(user: OrganizationUserView) {
const confirmed = await this.dialogService.openSimpleDialog({
title: { key: "revokeAccess", placeholders: [this.userNamePipe.transform(user)] },
content: this.i18nService.t("revokeUserConfirmation"),
acceptButtonText: { key: "revokeAccess" },
type: "warning",
});
if (!confirmed) {
return false;
}
if (user.status > OrganizationUserStatusType.Invited && user.hasMasterPassword === false) {
return await this.noMasterPasswordConfirmationDialog(user);
}
return true;
return await this.memberDialogManager.openRevokeUserConfirmationDialog(user);
}
async deleteUser(user: OrganizationUserView, organization: Organization) {
const warningAcknowledged = await firstValueFrom(
this.deleteManagedMemberWarningService.warningAcknowledged(organization.id),
const confirmed = await this.memberDialogManager.openDeleteUserConfirmationDialog(
user,
organization,
);
if (
!warningAcknowledged &&
organization.canManageUsers &&
organization.productTierType === ProductTierType.Enterprise
) {
const acknowledged = await this.deleteManagedMemberWarningService.showWarning();
if (!acknowledged) {
return false;
}
}
const confirmed = await this.dialogService.openSimpleDialog({
title: {
key: "deleteOrganizationUser",
placeholders: [this.userNamePipe.transform(user)],
},
content: {
key: "deleteOrganizationUserWarningDesc",
placeholders: [this.userNamePipe.transform(user)],
},
type: "warning",
acceptButtonText: { key: "delete" },
cancelButtonText: { key: "cancel" },
});
if (!confirmed) {
return false;
}
await this.deleteManagedMemberWarningService.acknowledgeWarning(organization.id);
this.actionPromise = this.organizationUserApiService.deleteOrganizationUser(
organization.id,
user.id,
);
this.actionPromise = this.memberActionsService.deleteUser(organization, user.id);
try {
await this.actionPromise;
const result = await this.actionPromise;
if (!result.success) {
throw new Error(result.error);
}
this.toastService.showToast({
variant: "success",
message: this.i18nService.t("organizationUserDeleted", this.userNamePipe.transform(user)),
@@ -935,19 +515,6 @@ export class MembersComponent extends BaseMembersComponent<OrganizationUserView>
this.actionPromise = undefined;
}
private async noMasterPasswordConfirmationDialog(user: OrganizationUserView) {
return this.dialogService.openSimpleDialog({
title: {
key: "removeOrgUserNoMasterPasswordTitle",
},
content: {
key: "removeOrgUserNoMasterPasswordDesc",
placeholders: [this.userNamePipe.transform(user)],
},
type: "warning",
});
}
get showBulkRestoreUsers(): boolean {
return this.dataSource
.getCheckedUsers()
@@ -975,13 +542,4 @@ export class MembersComponent extends BaseMembersComponent<OrganizationUserView>
.getCheckedUsers()
.every((member) => member.managedByOrganization && validStatuses.includes(member.status));
}
async navigateToPaymentMethod(organization: Organization) {
await this.router.navigate(
["organizations", `${organization.id}`, "billing", "payment-details"],
{
state: { launchPaymentModalAutomatically: true },
},
);
}
}

View File

@@ -4,6 +4,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 { BillingConstraintService } from "@bitwarden/web-vault/app/billing/members/billing-constraint/billing-constraint.service";
import { OrganizationFreeTrialWarningComponent } from "@bitwarden/web-vault/app/billing/organizations/warnings/components";
import { HeaderModule } from "../../../layouts/header/header.module";
@@ -18,6 +19,11 @@ import { BulkStatusComponent } from "./components/bulk/bulk-status.component";
import { UserDialogModule } from "./components/member-dialog";
import { MembersRoutingModule } from "./members-routing.module";
import { MembersComponent } from "./members.component";
import {
OrganizationMembersService,
MemberActionsService,
MemberDialogManagerService,
} from "./services";
@NgModule({
imports: [
@@ -40,5 +46,11 @@ import { MembersComponent } from "./members.component";
MembersComponent,
BulkDeleteDialogComponent,
],
providers: [
OrganizationMembersService,
MemberActionsService,
BillingConstraintService,
MemberDialogManagerService,
],
})
export class MembersModule {}

View File

@@ -0,0 +1,5 @@
export { OrganizationMembersService } from "./organization-members-service/organization-members.service";
export { MemberActionsService } from "./member-actions/member-actions.service";
export { MemberDialogManagerService } from "./member-dialog-manager/member-dialog-manager.service";
export { DeleteManagedMemberWarningService } from "./delete-managed-member/delete-managed-member-warning.service";
export { OrganizationUserService } from "./organization-user/organization-user.service";

View File

@@ -0,0 +1,463 @@
import { MockProxy, mock } from "jest-mock-extended";
import { of } from "rxjs";
import {
OrganizationUserApiService,
OrganizationUserBulkResponse,
} from "@bitwarden/admin-console/common";
import {
OrganizationUserType,
OrganizationUserStatusType,
} from "@bitwarden/common/admin-console/enums";
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service";
import { EncString } from "@bitwarden/common/key-management/crypto/models/enc-string";
import { ListResponse } from "@bitwarden/common/models/response/list.response";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { FakeAccountService, mockAccountServiceWith } from "@bitwarden/common/spec";
import { OrganizationId, UserId } from "@bitwarden/common/types/guid";
import { OrgKey } from "@bitwarden/common/types/key";
import { newGuid } from "@bitwarden/guid";
import { KeyService } from "@bitwarden/key-management";
import { BillingConstraintService } from "../../../../../billing/members/billing-constraint/billing-constraint.service";
import { OrganizationUserView } from "../../../core/views/organization-user.view";
import { OrganizationUserService } from "../organization-user/organization-user.service";
import { MemberActionsService } from "./member-actions.service";
describe("MemberActionsService", () => {
let service: MemberActionsService;
let organizationUserApiService: MockProxy<OrganizationUserApiService>;
let organizationUserService: MockProxy<OrganizationUserService>;
let keyService: MockProxy<KeyService>;
let encryptService: MockProxy<EncryptService>;
let configService: MockProxy<ConfigService>;
let accountService: FakeAccountService;
let billingConstraintService: MockProxy<BillingConstraintService>;
const userId = newGuid() as UserId;
const organizationId = newGuid() as OrganizationId;
const userIdToManage = newGuid();
let mockOrganization: Organization;
let mockOrgUser: OrganizationUserView;
beforeEach(() => {
organizationUserApiService = mock<OrganizationUserApiService>();
organizationUserService = mock<OrganizationUserService>();
keyService = mock<KeyService>();
encryptService = mock<EncryptService>();
configService = mock<ConfigService>();
accountService = mockAccountServiceWith(userId);
billingConstraintService = mock<BillingConstraintService>();
mockOrganization = {
id: organizationId,
type: OrganizationUserType.Owner,
canManageUsersPassword: true,
hasPublicAndPrivateKeys: true,
useResetPassword: true,
} as Organization;
mockOrgUser = {
id: userIdToManage,
userId: userIdToManage,
type: OrganizationUserType.User,
status: OrganizationUserStatusType.Confirmed,
resetPasswordEnrolled: true,
} as OrganizationUserView;
service = new MemberActionsService(
organizationUserApiService,
organizationUserService,
keyService,
encryptService,
configService,
accountService,
billingConstraintService,
);
});
describe("inviteUser", () => {
it("should successfully invite a user", async () => {
organizationUserApiService.postOrganizationUserInvite.mockResolvedValue(undefined);
const result = await service.inviteUser(
mockOrganization,
"test@example.com",
OrganizationUserType.User,
{},
[],
[],
);
expect(result).toEqual({ success: true });
expect(organizationUserApiService.postOrganizationUserInvite).toHaveBeenCalledWith(
organizationId,
{
emails: ["test@example.com"],
type: OrganizationUserType.User,
accessSecretsManager: false,
collections: [],
groups: [],
permissions: {},
},
);
});
it("should handle invite errors", async () => {
const errorMessage = "Invitation failed";
organizationUserApiService.postOrganizationUserInvite.mockRejectedValue(
new Error(errorMessage),
);
const result = await service.inviteUser(
mockOrganization,
"test@example.com",
OrganizationUserType.User,
);
expect(result).toEqual({ success: false, error: errorMessage });
});
});
describe("removeUser", () => {
it("should successfully remove a user", async () => {
organizationUserApiService.removeOrganizationUser.mockResolvedValue(undefined);
const result = await service.removeUser(mockOrganization, userIdToManage);
expect(result).toEqual({ success: true });
expect(organizationUserApiService.removeOrganizationUser).toHaveBeenCalledWith(
organizationId,
userIdToManage,
);
});
it("should handle remove errors", async () => {
const errorMessage = "Remove failed";
organizationUserApiService.removeOrganizationUser.mockRejectedValue(new Error(errorMessage));
const result = await service.removeUser(mockOrganization, userIdToManage);
expect(result).toEqual({ success: false, error: errorMessage });
});
});
describe("revokeUser", () => {
it("should successfully revoke a user", async () => {
organizationUserApiService.revokeOrganizationUser.mockResolvedValue(undefined);
const result = await service.revokeUser(mockOrganization, userIdToManage);
expect(result).toEqual({ success: true });
expect(organizationUserApiService.revokeOrganizationUser).toHaveBeenCalledWith(
organizationId,
userIdToManage,
);
});
it("should handle revoke errors", async () => {
const errorMessage = "Revoke failed";
organizationUserApiService.revokeOrganizationUser.mockRejectedValue(new Error(errorMessage));
const result = await service.revokeUser(mockOrganization, userIdToManage);
expect(result).toEqual({ success: false, error: errorMessage });
});
});
describe("restoreUser", () => {
it("should successfully restore a user", async () => {
organizationUserApiService.restoreOrganizationUser.mockResolvedValue(undefined);
const result = await service.restoreUser(mockOrganization, userIdToManage);
expect(result).toEqual({ success: true });
expect(organizationUserApiService.restoreOrganizationUser).toHaveBeenCalledWith(
organizationId,
userIdToManage,
);
});
it("should handle restore errors", async () => {
const errorMessage = "Restore failed";
organizationUserApiService.restoreOrganizationUser.mockRejectedValue(new Error(errorMessage));
const result = await service.restoreUser(mockOrganization, userIdToManage);
expect(result).toEqual({ success: false, error: errorMessage });
});
});
describe("deleteUser", () => {
it("should successfully delete a user", async () => {
organizationUserApiService.deleteOrganizationUser.mockResolvedValue(undefined);
const result = await service.deleteUser(mockOrganization, userIdToManage);
expect(result).toEqual({ success: true });
expect(organizationUserApiService.deleteOrganizationUser).toHaveBeenCalledWith(
organizationId,
userIdToManage,
);
});
it("should handle delete errors", async () => {
const errorMessage = "Delete failed";
organizationUserApiService.deleteOrganizationUser.mockRejectedValue(new Error(errorMessage));
const result = await service.deleteUser(mockOrganization, userIdToManage);
expect(result).toEqual({ success: false, error: errorMessage });
});
});
describe("reinviteUser", () => {
it("should successfully reinvite a user", async () => {
organizationUserApiService.postOrganizationUserReinvite.mockResolvedValue(undefined);
const result = await service.reinviteUser(mockOrganization, userIdToManage);
expect(result).toEqual({ success: true });
expect(organizationUserApiService.postOrganizationUserReinvite).toHaveBeenCalledWith(
organizationId,
userIdToManage,
);
});
it("should handle reinvite errors", async () => {
const errorMessage = "Reinvite failed";
organizationUserApiService.postOrganizationUserReinvite.mockRejectedValue(
new Error(errorMessage),
);
const result = await service.reinviteUser(mockOrganization, userIdToManage);
expect(result).toEqual({ success: false, error: errorMessage });
});
});
describe("confirmUser", () => {
const publicKey = new Uint8Array([1, 2, 3, 4, 5]);
it("should confirm user using new flow when feature flag is enabled", async () => {
configService.getFeatureFlag$.mockReturnValue(of(true));
organizationUserService.confirmUser.mockReturnValue(of(undefined));
const result = await service.confirmUser(mockOrgUser, publicKey, mockOrganization);
expect(result).toEqual({ success: true });
expect(organizationUserService.confirmUser).toHaveBeenCalledWith(
mockOrganization,
mockOrgUser,
publicKey,
);
expect(organizationUserApiService.postOrganizationUserConfirm).not.toHaveBeenCalled();
});
it("should confirm user using exising flow when feature flag is disabled", async () => {
configService.getFeatureFlag$.mockReturnValue(of(false));
const mockOrgKey = mock<OrgKey>();
const mockOrgKeys = { [organizationId]: mockOrgKey };
keyService.orgKeys$.mockReturnValue(of(mockOrgKeys));
const mockEncryptedKey = new EncString("encrypted-key-data");
encryptService.encapsulateKeyUnsigned.mockResolvedValue(mockEncryptedKey);
organizationUserApiService.postOrganizationUserConfirm.mockResolvedValue(undefined);
const result = await service.confirmUser(mockOrgUser, publicKey, mockOrganization);
expect(result).toEqual({ success: true });
expect(keyService.orgKeys$).toHaveBeenCalledWith(userId);
expect(encryptService.encapsulateKeyUnsigned).toHaveBeenCalledWith(mockOrgKey, publicKey);
expect(organizationUserApiService.postOrganizationUserConfirm).toHaveBeenCalledWith(
organizationId,
userIdToManage,
expect.objectContaining({
key: "encrypted-key-data",
}),
);
});
it("should handle missing organization keys", async () => {
configService.getFeatureFlag$.mockReturnValue(of(false));
keyService.orgKeys$.mockReturnValue(of({}));
const result = await service.confirmUser(mockOrgUser, publicKey, mockOrganization);
expect(result.success).toBe(false);
expect(result.error).toContain("Organization keys not found");
});
it("should handle confirm errors", async () => {
configService.getFeatureFlag$.mockReturnValue(of(true));
const errorMessage = "Confirm failed";
organizationUserService.confirmUser.mockImplementation(() => {
throw new Error(errorMessage);
});
const result = await service.confirmUser(mockOrgUser, publicKey, mockOrganization);
expect(result.success).toBe(false);
expect(result.error).toContain(errorMessage);
});
});
describe("bulkReinvite", () => {
const userIds = [newGuid(), newGuid(), newGuid()];
it("should successfully reinvite multiple users", async () => {
const mockResponse = {
data: userIds.map((id) => ({
id,
error: null,
})),
continuationToken: null,
} as ListResponse<OrganizationUserBulkResponse>;
organizationUserApiService.postManyOrganizationUserReinvite.mockResolvedValue(mockResponse);
const result = await service.bulkReinvite(mockOrganization, userIds);
expect(result).toEqual({
successful: mockResponse,
failed: [],
});
expect(organizationUserApiService.postManyOrganizationUserReinvite).toHaveBeenCalledWith(
organizationId,
userIds,
);
});
it("should handle bulk reinvite errors", async () => {
const errorMessage = "Bulk reinvite failed";
organizationUserApiService.postManyOrganizationUserReinvite.mockRejectedValue(
new Error(errorMessage),
);
const result = await service.bulkReinvite(mockOrganization, userIds);
expect(result.successful).toBeUndefined();
expect(result.failed).toHaveLength(3);
expect(result.failed[0]).toEqual({ id: userIds[0], error: errorMessage });
});
});
describe("allowResetPassword", () => {
const resetPasswordEnabled = true;
it("should allow reset password for Owner over User", () => {
const result = service.allowResetPassword(
mockOrgUser,
mockOrganization,
resetPasswordEnabled,
);
expect(result).toBe(true);
});
it("should allow reset password for Admin over User", () => {
const adminOrg = { ...mockOrganization, type: OrganizationUserType.Admin } as Organization;
const result = service.allowResetPassword(mockOrgUser, adminOrg, resetPasswordEnabled);
expect(result).toBe(true);
});
it("should not allow reset password for Admin over Owner", () => {
const adminOrg = { ...mockOrganization, type: OrganizationUserType.Admin } as Organization;
const ownerUser = {
...mockOrgUser,
type: OrganizationUserType.Owner,
} as OrganizationUserView;
const result = service.allowResetPassword(ownerUser, adminOrg, resetPasswordEnabled);
expect(result).toBe(false);
});
it("should allow reset password for Custom over User", () => {
const customOrg = { ...mockOrganization, type: OrganizationUserType.Custom } as Organization;
const result = service.allowResetPassword(mockOrgUser, customOrg, resetPasswordEnabled);
expect(result).toBe(true);
});
it("should not allow reset password for Custom over Admin", () => {
const customOrg = { ...mockOrganization, type: OrganizationUserType.Custom } as Organization;
const adminUser = {
...mockOrgUser,
type: OrganizationUserType.Admin,
} as OrganizationUserView;
const result = service.allowResetPassword(adminUser, customOrg, resetPasswordEnabled);
expect(result).toBe(false);
});
it("should not allow reset password for Custom over Owner", () => {
const customOrg = { ...mockOrganization, type: OrganizationUserType.Custom } as Organization;
const ownerUser = {
...mockOrgUser,
type: OrganizationUserType.Owner,
} as OrganizationUserView;
const result = service.allowResetPassword(ownerUser, customOrg, resetPasswordEnabled);
expect(result).toBe(false);
});
it("should not allow reset password when organization cannot manage users password", () => {
const org = { ...mockOrganization, canManageUsersPassword: false } as Organization;
const result = service.allowResetPassword(mockOrgUser, org, resetPasswordEnabled);
expect(result).toBe(false);
});
it("should not allow reset password when organization does not use reset password", () => {
const org = { ...mockOrganization, useResetPassword: false } as Organization;
const result = service.allowResetPassword(mockOrgUser, org, resetPasswordEnabled);
expect(result).toBe(false);
});
it("should not allow reset password when organization lacks public and private keys", () => {
const org = { ...mockOrganization, hasPublicAndPrivateKeys: false } as Organization;
const result = service.allowResetPassword(mockOrgUser, org, resetPasswordEnabled);
expect(result).toBe(false);
});
it("should not allow reset password when user is not enrolled in reset password", () => {
const user = { ...mockOrgUser, resetPasswordEnrolled: false } as OrganizationUserView;
const result = service.allowResetPassword(user, mockOrganization, resetPasswordEnabled);
expect(result).toBe(false);
});
it("should not allow reset password when reset password is disabled", () => {
const result = service.allowResetPassword(mockOrgUser, mockOrganization, false);
expect(result).toBe(false);
});
it("should not allow reset password when user status is not confirmed", () => {
const user = {
...mockOrgUser,
status: OrganizationUserStatusType.Invited,
} as OrganizationUserView;
const result = service.allowResetPassword(user, mockOrganization, resetPasswordEnabled);
expect(result).toBe(false);
});
});
});

View File

@@ -0,0 +1,210 @@
import { Injectable } from "@angular/core";
import { firstValueFrom, switchMap, map } from "rxjs";
import {
OrganizationUserApiService,
OrganizationUserBulkResponse,
OrganizationUserConfirmRequest,
} from "@bitwarden/admin-console/common";
import {
OrganizationUserType,
OrganizationUserStatusType,
} from "@bitwarden/common/admin-console/enums";
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { getUserId } from "@bitwarden/common/auth/services/account.service";
import { OrganizationMetadataServiceAbstraction } from "@bitwarden/common/billing/abstractions/organization-metadata.service.abstraction";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service";
import { ListResponse } from "@bitwarden/common/models/response/list.response";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { KeyService } from "@bitwarden/key-management";
import { OrganizationUserView } from "../../../core/views/organization-user.view";
import { OrganizationUserService } from "../organization-user/organization-user.service";
export interface MemberActionResult {
success: boolean;
error?: string;
}
export interface BulkActionResult {
successful?: ListResponse<OrganizationUserBulkResponse>;
failed: { id: string; error: string }[];
}
@Injectable()
export class MemberActionsService {
private userId$ = this.accountService.activeAccount$.pipe(getUserId);
constructor(
private organizationUserApiService: OrganizationUserApiService,
private organizationUserService: OrganizationUserService,
private keyService: KeyService,
private encryptService: EncryptService,
private configService: ConfigService,
private accountService: AccountService,
private organizationMetadataService: OrganizationMetadataServiceAbstraction,
) {}
async inviteUser(
organization: Organization,
email: string,
type: OrganizationUserType,
permissions?: any,
collections?: any[],
groups?: string[],
): Promise<MemberActionResult> {
try {
await this.organizationUserApiService.postOrganizationUserInvite(organization.id, {
emails: [email],
type,
accessSecretsManager: false,
collections: collections ?? [],
groups: groups ?? [],
permissions,
});
return { success: true };
} catch (error) {
return { success: false, error: (error as Error).message ?? String(error) };
}
}
async removeUser(organization: Organization, userId: string): Promise<MemberActionResult> {
try {
await this.organizationUserApiService.removeOrganizationUser(organization.id, userId);
this.organizationMetadataService.refreshMetadataCache();
return { success: true };
} catch (error) {
return { success: false, error: (error as Error).message ?? String(error) };
}
}
async revokeUser(organization: Organization, userId: string): Promise<MemberActionResult> {
try {
await this.organizationUserApiService.revokeOrganizationUser(organization.id, userId);
this.organizationMetadataService.refreshMetadataCache();
return { success: true };
} catch (error) {
return { success: false, error: (error as Error).message ?? String(error) };
}
}
async restoreUser(organization: Organization, userId: string): Promise<MemberActionResult> {
try {
await this.organizationUserApiService.restoreOrganizationUser(organization.id, userId);
this.organizationMetadataService.refreshMetadataCache();
return { success: true };
} catch (error) {
return { success: false, error: (error as Error).message ?? String(error) };
}
}
async deleteUser(organization: Organization, userId: string): Promise<MemberActionResult> {
try {
await this.organizationUserApiService.deleteOrganizationUser(organization.id, userId);
this.organizationMetadataService.refreshMetadataCache();
return { success: true };
} catch (error) {
return { success: false, error: (error as Error).message ?? String(error) };
}
}
async reinviteUser(organization: Organization, userId: string): Promise<MemberActionResult> {
try {
await this.organizationUserApiService.postOrganizationUserReinvite(organization.id, userId);
return { success: true };
} catch (error) {
return { success: false, error: (error as Error).message ?? String(error) };
}
}
async confirmUser(
user: OrganizationUserView,
publicKey: Uint8Array,
organization: Organization,
): Promise<MemberActionResult> {
try {
if (
await firstValueFrom(this.configService.getFeatureFlag$(FeatureFlag.CreateDefaultLocation))
) {
await firstValueFrom(
this.organizationUserService.confirmUser(organization, user, publicKey),
);
} else {
const request = await firstValueFrom(
this.userId$.pipe(
switchMap((userId) => this.keyService.orgKeys$(userId)),
map((orgKeys) => {
if (orgKeys == null || orgKeys[organization.id] == null) {
throw new Error("Organization keys not found for provided User.");
}
return orgKeys[organization.id];
}),
switchMap((orgKey) => this.encryptService.encapsulateKeyUnsigned(orgKey, publicKey)),
map((encKey) => {
const req = new OrganizationUserConfirmRequest();
req.key = encKey.encryptedString;
return req;
}),
),
);
await this.organizationUserApiService.postOrganizationUserConfirm(
organization.id,
user.id,
request,
);
}
return { success: true };
} catch (error) {
return { success: false, error: (error as Error).message ?? String(error) };
}
}
async bulkReinvite(organization: Organization, userIds: string[]): Promise<BulkActionResult> {
try {
const result = await this.organizationUserApiService.postManyOrganizationUserReinvite(
organization.id,
userIds,
);
return { successful: result, failed: [] };
} catch (error) {
return {
failed: userIds.map((id) => ({ id, error: (error as Error).message ?? String(error) })),
};
}
}
allowResetPassword(
orgUser: OrganizationUserView,
organization: Organization,
resetPasswordEnabled: boolean,
): boolean {
let callingUserHasPermission = false;
switch (organization.type) {
case OrganizationUserType.Owner:
callingUserHasPermission = true;
break;
case OrganizationUserType.Admin:
callingUserHasPermission = orgUser.type !== OrganizationUserType.Owner;
break;
case OrganizationUserType.Custom:
callingUserHasPermission =
orgUser.type !== OrganizationUserType.Owner &&
orgUser.type !== OrganizationUserType.Admin;
break;
}
return (
organization.canManageUsersPassword &&
callingUserHasPermission &&
organization.useResetPassword &&
organization.hasPublicAndPrivateKeys &&
orgUser.resetPasswordEnrolled &&
resetPasswordEnabled &&
orgUser.status === OrganizationUserStatusType.Confirmed
);
}
}

View File

@@ -0,0 +1,640 @@
import { mock, MockProxy } from "jest-mock-extended";
import { of } from "rxjs";
import { UserNamePipe } from "@bitwarden/angular/pipes/user-name.pipe";
import { OrganizationUserStatusType } from "@bitwarden/common/admin-console/enums";
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
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 { DialogService, ToastService } from "@bitwarden/components";
import { OrganizationUserView } from "../../../core/views/organization-user.view";
import { EntityEventsComponent } from "../../../manage/entity-events.component";
import { AccountRecoveryDialogComponent } from "../../components/account-recovery/account-recovery-dialog.component";
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 { 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";
import {
MemberDialogComponent,
MemberDialogResult,
MemberDialogTab,
} from "../../components/member-dialog";
import { DeleteManagedMemberWarningService } from "../delete-managed-member/delete-managed-member-warning.service";
import { MemberDialogManagerService } from "./member-dialog-manager.service";
describe("MemberDialogManagerService", () => {
let service: MemberDialogManagerService;
let dialogService: MockProxy<DialogService>;
let i18nService: MockProxy<I18nService>;
let toastService: MockProxy<ToastService>;
let userNamePipe: MockProxy<UserNamePipe>;
let deleteManagedMemberWarningService: MockProxy<DeleteManagedMemberWarningService>;
let mockOrganization: Organization;
let mockUser: OrganizationUserView;
let mockBillingMetadata: OrganizationBillingMetadataResponse;
beforeEach(() => {
dialogService = mock<DialogService>();
i18nService = mock<I18nService>();
toastService = mock<ToastService>();
userNamePipe = mock<UserNamePipe>();
deleteManagedMemberWarningService = mock<DeleteManagedMemberWarningService>();
service = new MemberDialogManagerService(
dialogService,
i18nService,
toastService,
userNamePipe,
deleteManagedMemberWarningService,
);
// Setup mock data
mockOrganization = {
id: "org-id",
canManageUsers: true,
productTierType: ProductTierType.Enterprise,
} as Organization;
mockUser = {
id: "user-id",
email: "test@example.com",
name: "Test User",
usesKeyConnector: false,
status: OrganizationUserStatusType.Confirmed,
hasMasterPassword: true,
accessSecretsManager: false,
managedByOrganization: false,
} as OrganizationUserView;
mockBillingMetadata = {
organizationOccupiedSeats: 10,
isOnSecretsManagerStandalone: false,
} as OrganizationBillingMetadataResponse;
userNamePipe.transform.mockReturnValue("Test User");
});
describe("openInviteDialog", () => {
it("should open the invite dialog with correct parameters", async () => {
const mockDialogRef = { closed: of(MemberDialogResult.Saved) };
dialogService.open.mockReturnValue(mockDialogRef as any);
const allUserEmails = ["user1@example.com", "user2@example.com"];
const result = await service.openInviteDialog(
mockOrganization,
mockBillingMetadata,
allUserEmails,
);
expect(dialogService.open).toHaveBeenCalledWith(
MemberDialogComponent,
expect.objectContaining({
data: {
kind: "Add",
organizationId: mockOrganization.id,
allOrganizationUserEmails: allUserEmails,
occupiedSeatCount: 10,
isOnSecretsManagerStandalone: false,
},
}),
);
expect(result).toBe(MemberDialogResult.Saved);
});
it("should return Canceled when dialog is closed without result", async () => {
const mockDialogRef = { closed: of(null) };
dialogService.open.mockReturnValue(mockDialogRef as any);
const result = await service.openInviteDialog(mockOrganization, mockBillingMetadata, []);
expect(result).toBe(MemberDialogResult.Canceled);
});
it("should handle null billing metadata", async () => {
const mockDialogRef = { closed: of(MemberDialogResult.Saved) };
dialogService.open.mockReturnValue(mockDialogRef as any);
await service.openInviteDialog(mockOrganization, null, []);
expect(dialogService.open).toHaveBeenCalledWith(
MemberDialogComponent,
expect.objectContaining({
data: expect.objectContaining({
occupiedSeatCount: 0,
isOnSecretsManagerStandalone: false,
}),
}),
);
});
});
describe("openEditDialog", () => {
it("should open the edit dialog with correct parameters", async () => {
const mockDialogRef = { closed: of(MemberDialogResult.Saved) };
dialogService.open.mockReturnValue(mockDialogRef as any);
const result = await service.openEditDialog(mockUser, mockOrganization, mockBillingMetadata);
expect(dialogService.open).toHaveBeenCalledWith(
MemberDialogComponent,
expect.objectContaining({
data: {
kind: "Edit",
name: "Test User",
organizationId: mockOrganization.id,
organizationUserId: mockUser.id,
usesKeyConnector: false,
isOnSecretsManagerStandalone: false,
initialTab: MemberDialogTab.Role,
managedByOrganization: false,
},
}),
);
expect(result).toBe(MemberDialogResult.Saved);
});
it("should use custom initial tab when provided", async () => {
const mockDialogRef = { closed: of(MemberDialogResult.Saved) };
dialogService.open.mockReturnValue(mockDialogRef as any);
await service.openEditDialog(
mockUser,
mockOrganization,
mockBillingMetadata,
MemberDialogTab.AccountRecovery,
);
expect(dialogService.open).toHaveBeenCalledWith(
MemberDialogComponent,
expect.objectContaining({
data: expect.objectContaining({
initialTab: 0, // MemberDialogTab.AccountRecovery is 0
}),
}),
);
});
it("should return Canceled when dialog is closed without result", async () => {
const mockDialogRef = { closed: of(null) };
dialogService.open.mockReturnValue(mockDialogRef as any);
const result = await service.openEditDialog(mockUser, mockOrganization, mockBillingMetadata);
expect(result).toBe(MemberDialogResult.Canceled);
});
});
describe("openAccountRecoveryDialog", () => {
it("should open account recovery dialog with correct parameters", async () => {
const mockDialogRef = { closed: of("recovered") };
dialogService.open.mockReturnValue(mockDialogRef as any);
const result = await service.openAccountRecoveryDialog(mockUser, mockOrganization);
expect(dialogService.open).toHaveBeenCalledWith(
AccountRecoveryDialogComponent,
expect.objectContaining({
data: {
name: "Test User",
email: mockUser.email,
organizationId: mockOrganization.id,
organizationUserId: mockUser.id,
},
}),
);
expect(result).toBe("recovered");
});
it("should return Ok when dialog is closed without result", async () => {
const mockDialogRef = { closed: of(null) };
dialogService.open.mockReturnValue(mockDialogRef as any);
const result = await service.openAccountRecoveryDialog(mockUser, mockOrganization);
expect(result).toBe("ok");
});
});
describe("openBulkConfirmDialog", () => {
it("should open bulk confirm dialog with correct parameters", async () => {
const mockDialogRef = { closed: of(undefined) };
dialogService.open.mockReturnValue(mockDialogRef as any);
const users = [mockUser];
await service.openBulkConfirmDialog(mockOrganization, users);
expect(dialogService.open).toHaveBeenCalledWith(
BulkConfirmDialogComponent,
expect.objectContaining({
data: {
organization: mockOrganization,
users: users,
},
}),
);
});
});
describe("openBulkRemoveDialog", () => {
it("should open bulk remove dialog with correct parameters", async () => {
const mockDialogRef = { closed: of(undefined) };
dialogService.open.mockReturnValue(mockDialogRef as any);
const users = [mockUser];
await service.openBulkRemoveDialog(mockOrganization, users);
expect(dialogService.open).toHaveBeenCalledWith(
BulkRemoveDialogComponent,
expect.objectContaining({
data: {
organizationId: mockOrganization.id,
users: users,
},
}),
);
});
});
describe("openBulkDeleteDialog", () => {
it("should open bulk delete dialog when warning already acknowledged", async () => {
deleteManagedMemberWarningService.warningAcknowledged.mockReturnValue(of(true));
const mockDialogRef = { closed: of(undefined) };
dialogService.open.mockReturnValue(mockDialogRef as any);
const users = [mockUser];
await service.openBulkDeleteDialog(mockOrganization, users);
expect(dialogService.open).toHaveBeenCalledWith(
BulkDeleteDialogComponent,
expect.objectContaining({
data: {
organizationId: mockOrganization.id,
users: users,
},
}),
);
expect(deleteManagedMemberWarningService.showWarning).not.toHaveBeenCalled();
});
it("should show warning before opening dialog for enterprise organizations", async () => {
deleteManagedMemberWarningService.warningAcknowledged.mockReturnValue(of(false));
deleteManagedMemberWarningService.showWarning.mockResolvedValue(true);
const mockDialogRef = { closed: of(undefined) };
dialogService.open.mockReturnValue(mockDialogRef as any);
const users = [mockUser];
await service.openBulkDeleteDialog(mockOrganization, users);
expect(deleteManagedMemberWarningService.showWarning).toHaveBeenCalled();
expect(dialogService.open).toHaveBeenCalled();
});
it("should not open dialog if warning is not acknowledged", async () => {
deleteManagedMemberWarningService.warningAcknowledged.mockReturnValue(of(false));
deleteManagedMemberWarningService.showWarning.mockResolvedValue(false);
const users = [mockUser];
await service.openBulkDeleteDialog(mockOrganization, users);
expect(deleteManagedMemberWarningService.showWarning).toHaveBeenCalled();
expect(dialogService.open).not.toHaveBeenCalled();
});
it("should skip warning for non-enterprise organizations", async () => {
const nonEnterpriseOrg = {
...mockOrganization,
productTierType: ProductTierType.Free,
} as Organization;
deleteManagedMemberWarningService.warningAcknowledged.mockReturnValue(of(false));
const mockDialogRef = { closed: of(undefined) };
dialogService.open.mockReturnValue(mockDialogRef as any);
const users = [mockUser];
await service.openBulkDeleteDialog(nonEnterpriseOrg, users);
expect(deleteManagedMemberWarningService.showWarning).not.toHaveBeenCalled();
expect(dialogService.open).toHaveBeenCalled();
});
});
describe("openBulkRestoreRevokeDialog", () => {
it("should open bulk restore revoke dialog with correct parameters for revoking", async () => {
const mockDialogRef = { closed: of(undefined) };
dialogService.open.mockReturnValue(mockDialogRef as any);
const users = [mockUser];
await service.openBulkRestoreRevokeDialog(mockOrganization, users, true);
expect(dialogService.open).toHaveBeenCalledWith(
BulkRestoreRevokeComponent,
expect.objectContaining({
data: expect.objectContaining({
organizationId: mockOrganization.id,
users: users,
isRevoking: true,
}),
}),
);
});
it("should open bulk restore revoke dialog with correct parameters for restoring", async () => {
const mockDialogRef = { closed: of(undefined) };
dialogService.open.mockReturnValue(mockDialogRef as any);
const users = [mockUser];
await service.openBulkRestoreRevokeDialog(mockOrganization, users, false);
expect(dialogService.open).toHaveBeenCalledWith(
BulkRestoreRevokeComponent,
expect.objectContaining({
data: expect.objectContaining({
organizationId: mockOrganization.id,
users: users,
isRevoking: false,
}),
}),
);
});
});
describe("openBulkEnableSecretsManagerDialog", () => {
it("should open dialog with eligible users only", async () => {
const mockDialogRef = { closed: of(undefined) };
dialogService.open.mockReturnValue(mockDialogRef as any);
const user1 = { ...mockUser, accessSecretsManager: false } as OrganizationUserView;
const user2 = {
...mockUser,
id: "user-2",
accessSecretsManager: true,
} as OrganizationUserView;
const users = [user1, user2];
await service.openBulkEnableSecretsManagerDialog(mockOrganization, users);
expect(dialogService.open).toHaveBeenCalledWith(
BulkEnableSecretsManagerDialogComponent,
expect.objectContaining({
data: expect.objectContaining({
orgId: mockOrganization.id,
users: [user1],
}),
}),
);
});
it("should show error toast when no eligible users", async () => {
i18nService.t.mockImplementation((key) => key);
const user1 = { ...mockUser, accessSecretsManager: true } as OrganizationUserView;
const users = [user1];
await service.openBulkEnableSecretsManagerDialog(mockOrganization, users);
expect(toastService.showToast).toHaveBeenCalledWith({
variant: "error",
title: "errorOccurred",
message: "noSelectedUsersApplicable",
});
expect(dialogService.open).not.toHaveBeenCalled();
});
});
describe("openBulkStatusDialog", () => {
it("should open bulk status dialog with correct parameters", async () => {
const mockDialogRef = { closed: of(undefined) };
dialogService.open.mockReturnValue(mockDialogRef as any);
const users = [mockUser];
const filteredUsers = [mockUser];
const request = Promise.resolve();
const successMessage = "Success!";
await service.openBulkStatusDialog(users, filteredUsers, request, successMessage);
expect(dialogService.open).toHaveBeenCalledWith(
BulkStatusComponent,
expect.objectContaining({
data: {
users: users,
filteredUsers: filteredUsers,
request: request,
successfulMessage: successMessage,
},
}),
);
});
});
describe("openEventsDialog", () => {
it("should open events dialog with correct parameters", () => {
service.openEventsDialog(mockUser, mockOrganization);
expect(dialogService.open).toHaveBeenCalledWith(
EntityEventsComponent,
expect.objectContaining({
data: {
name: "Test User",
organizationId: mockOrganization.id,
entityId: mockUser.id,
showUser: false,
entity: "user",
},
}),
);
});
});
describe("openRemoveUserConfirmationDialog", () => {
it("should return true when user confirms removal", async () => {
dialogService.openSimpleDialog.mockResolvedValue(true);
const result = await service.openRemoveUserConfirmationDialog(mockUser);
expect(dialogService.openSimpleDialog).toHaveBeenCalledWith({
title: {
key: "removeUserIdAccess",
placeholders: ["Test User"],
},
content: { key: "removeOrgUserConfirmation" },
type: "warning",
});
expect(result).toBe(true);
});
it("should show key connector warning when user uses key connector", async () => {
const keyConnectorUser = { ...mockUser, usesKeyConnector: true } as OrganizationUserView;
dialogService.openSimpleDialog.mockResolvedValue(true);
await service.openRemoveUserConfirmationDialog(keyConnectorUser);
expect(dialogService.openSimpleDialog).toHaveBeenCalledWith(
expect.objectContaining({
content: { key: "removeUserConfirmationKeyConnector" },
}),
);
});
it("should return false when user cancels confirmation", async () => {
dialogService.openSimpleDialog.mockResolvedValue(false);
const result = await service.openRemoveUserConfirmationDialog(mockUser);
expect(result).toBe(false);
});
it("should show no master password warning for confirmed users without master password", async () => {
const noMpUser = {
...mockUser,
status: OrganizationUserStatusType.Confirmed,
hasMasterPassword: false,
} as OrganizationUserView;
dialogService.openSimpleDialog.mockResolvedValueOnce(true).mockResolvedValueOnce(true);
const result = await service.openRemoveUserConfirmationDialog(noMpUser);
expect(dialogService.openSimpleDialog).toHaveBeenCalledTimes(2);
expect(dialogService.openSimpleDialog).toHaveBeenLastCalledWith({
title: {
key: "removeOrgUserNoMasterPasswordTitle",
},
content: {
key: "removeOrgUserNoMasterPasswordDesc",
placeholders: ["Test User"],
},
type: "warning",
});
expect(result).toBe(true);
});
});
describe("openRevokeUserConfirmationDialog", () => {
it("should return true when user confirms revocation", async () => {
i18nService.t.mockReturnValue("Revoke user confirmation");
dialogService.openSimpleDialog.mockResolvedValue(true);
const result = await service.openRevokeUserConfirmationDialog(mockUser);
expect(dialogService.openSimpleDialog).toHaveBeenCalledWith({
title: { key: "revokeAccess", placeholders: ["Test User"] },
content: "Revoke user confirmation",
acceptButtonText: { key: "revokeAccess" },
type: "warning",
});
expect(result).toBe(true);
});
it("should return false when user cancels confirmation", async () => {
i18nService.t.mockReturnValue("Revoke user confirmation");
dialogService.openSimpleDialog.mockResolvedValue(false);
const result = await service.openRevokeUserConfirmationDialog(mockUser);
expect(result).toBe(false);
});
it("should show no master password warning for confirmed users without master password", async () => {
const noMpUser = {
...mockUser,
status: OrganizationUserStatusType.Confirmed,
hasMasterPassword: false,
} as OrganizationUserView;
i18nService.t.mockReturnValue("Revoke user confirmation");
dialogService.openSimpleDialog.mockResolvedValueOnce(true).mockResolvedValueOnce(true);
const result = await service.openRevokeUserConfirmationDialog(noMpUser);
expect(dialogService.openSimpleDialog).toHaveBeenCalledTimes(2);
expect(result).toBe(true);
});
});
describe("openDeleteUserConfirmationDialog", () => {
it("should return true when user confirms deletion", async () => {
deleteManagedMemberWarningService.warningAcknowledged.mockReturnValue(of(true));
dialogService.openSimpleDialog.mockResolvedValue(true);
const result = await service.openDeleteUserConfirmationDialog(mockUser, mockOrganization);
expect(dialogService.openSimpleDialog).toHaveBeenCalledWith({
title: {
key: "deleteOrganizationUser",
placeholders: ["Test User"],
},
content: {
key: "deleteOrganizationUserWarningDesc",
placeholders: ["Test User"],
},
type: "warning",
acceptButtonText: { key: "delete" },
cancelButtonText: { key: "cancel" },
});
expect(deleteManagedMemberWarningService.acknowledgeWarning).toHaveBeenCalledWith(
mockOrganization.id,
);
expect(result).toBe(true);
});
it("should show warning before deletion for enterprise organizations", async () => {
deleteManagedMemberWarningService.warningAcknowledged.mockReturnValue(of(false));
deleteManagedMemberWarningService.showWarning.mockResolvedValue(true);
dialogService.openSimpleDialog.mockResolvedValue(true);
const result = await service.openDeleteUserConfirmationDialog(mockUser, mockOrganization);
expect(deleteManagedMemberWarningService.showWarning).toHaveBeenCalled();
expect(dialogService.openSimpleDialog).toHaveBeenCalled();
expect(result).toBe(true);
});
it("should return false if warning is not acknowledged", async () => {
deleteManagedMemberWarningService.warningAcknowledged.mockReturnValue(of(false));
deleteManagedMemberWarningService.showWarning.mockResolvedValue(false);
const result = await service.openDeleteUserConfirmationDialog(mockUser, mockOrganization);
expect(deleteManagedMemberWarningService.showWarning).toHaveBeenCalled();
expect(dialogService.openSimpleDialog).not.toHaveBeenCalled();
expect(result).toBe(false);
});
it("should skip warning for non-enterprise organizations", async () => {
const nonEnterpriseOrg = {
...mockOrganization,
productTierType: ProductTierType.Free,
} as Organization;
deleteManagedMemberWarningService.warningAcknowledged.mockReturnValue(of(false));
dialogService.openSimpleDialog.mockResolvedValue(true);
const result = await service.openDeleteUserConfirmationDialog(mockUser, nonEnterpriseOrg);
expect(deleteManagedMemberWarningService.showWarning).not.toHaveBeenCalled();
expect(dialogService.openSimpleDialog).toHaveBeenCalled();
expect(result).toBe(true);
});
it("should return false when user cancels confirmation", async () => {
deleteManagedMemberWarningService.warningAcknowledged.mockReturnValue(of(true));
dialogService.openSimpleDialog.mockResolvedValue(false);
const result = await service.openDeleteUserConfirmationDialog(mockUser, mockOrganization);
expect(result).toBe(false);
expect(deleteManagedMemberWarningService.acknowledgeWarning).not.toHaveBeenCalled();
});
});
});

View File

@@ -0,0 +1,322 @@
import { Injectable } from "@angular/core";
import { firstValueFrom, lastValueFrom } from "rxjs";
import { UserNamePipe } from "@bitwarden/angular/pipes/user-name.pipe";
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
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 { OrganizationUserView } from "../../../core/views/organization-user.view";
import { openEntityEventsDialog } from "../../../manage/entity-events.component";
import {
AccountRecoveryDialogComponent,
AccountRecoveryDialogResultType,
} from "../../components/account-recovery/account-recovery-dialog.component";
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 { 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";
import {
MemberDialogResult,
MemberDialogTab,
openUserAddEditDialog,
} from "../../components/member-dialog";
import { DeleteManagedMemberWarningService } from "../delete-managed-member/delete-managed-member-warning.service";
@Injectable()
export class MemberDialogManagerService {
constructor(
private dialogService: DialogService,
private i18nService: I18nService,
private toastService: ToastService,
private userNamePipe: UserNamePipe,
private deleteManagedMemberWarningService: DeleteManagedMemberWarningService,
) {}
async openInviteDialog(
organization: Organization,
billingMetadata: OrganizationBillingMetadataResponse,
allUserEmails: string[],
): Promise<MemberDialogResult> {
const dialog = openUserAddEditDialog(this.dialogService, {
data: {
kind: "Add",
organizationId: organization.id,
allOrganizationUserEmails: allUserEmails,
occupiedSeatCount: billingMetadata?.organizationOccupiedSeats ?? 0,
isOnSecretsManagerStandalone: billingMetadata?.isOnSecretsManagerStandalone ?? false,
},
});
const result = await lastValueFrom(dialog.closed);
return result ?? MemberDialogResult.Canceled;
}
async openEditDialog(
user: OrganizationUserView,
organization: Organization,
billingMetadata: OrganizationBillingMetadataResponse,
initialTab: MemberDialogTab = MemberDialogTab.Role,
): Promise<MemberDialogResult> {
const dialog = openUserAddEditDialog(this.dialogService, {
data: {
kind: "Edit",
name: this.userNamePipe.transform(user),
organizationId: organization.id,
organizationUserId: user.id,
usesKeyConnector: user.usesKeyConnector,
isOnSecretsManagerStandalone: billingMetadata?.isOnSecretsManagerStandalone ?? false,
initialTab: initialTab,
managedByOrganization: user.managedByOrganization,
},
});
const result = await lastValueFrom(dialog.closed);
return result ?? MemberDialogResult.Canceled;
}
async openAccountRecoveryDialog(
user: OrganizationUserView,
organization: Organization,
): Promise<AccountRecoveryDialogResultType> {
const dialogRef = AccountRecoveryDialogComponent.open(this.dialogService, {
data: {
name: this.userNamePipe.transform(user),
email: user.email,
organizationId: organization.id as OrganizationId,
organizationUserId: user.id,
},
});
const result = await lastValueFrom(dialogRef.closed);
return result ?? AccountRecoveryDialogResultType.Ok;
}
async openBulkConfirmDialog(
organization: Organization,
users: OrganizationUserView[],
): Promise<void> {
const dialogRef = BulkConfirmDialogComponent.open(this.dialogService, {
data: {
organization: organization,
users: users,
},
});
await lastValueFrom(dialogRef.closed);
}
async openBulkRemoveDialog(
organization: Organization,
users: OrganizationUserView[],
): Promise<void> {
const dialogRef = BulkRemoveDialogComponent.open(this.dialogService, {
data: {
organizationId: organization.id,
users: users,
},
});
await lastValueFrom(dialogRef.closed);
}
async openBulkDeleteDialog(
organization: Organization,
users: OrganizationUserView[],
): Promise<void> {
const warningAcknowledged = await firstValueFrom(
this.deleteManagedMemberWarningService.warningAcknowledged(organization.id),
);
if (
!warningAcknowledged &&
organization.canManageUsers &&
organization.productTierType === ProductTierType.Enterprise
) {
const acknowledged = await this.deleteManagedMemberWarningService.showWarning();
if (!acknowledged) {
return;
}
}
const dialogRef = BulkDeleteDialogComponent.open(this.dialogService, {
data: {
organizationId: organization.id,
users: users,
},
});
await lastValueFrom(dialogRef.closed);
}
async openBulkRestoreRevokeDialog(
organization: Organization,
users: OrganizationUserView[],
isRevoking: boolean,
): Promise<void> {
const ref = BulkRestoreRevokeComponent.open(this.dialogService, {
organizationId: organization.id,
users: users,
isRevoking: isRevoking,
});
await firstValueFrom(ref.closed);
}
async openBulkEnableSecretsManagerDialog(
organization: Organization,
users: OrganizationUserView[],
): Promise<void> {
const eligibleUsers = users.filter((ou) => !ou.accessSecretsManager);
if (eligibleUsers.length === 0) {
this.toastService.showToast({
variant: "error",
title: this.i18nService.t("errorOccurred"),
message: this.i18nService.t("noSelectedUsersApplicable"),
});
return;
}
const dialogRef = BulkEnableSecretsManagerDialogComponent.open(this.dialogService, {
orgId: organization.id,
users: eligibleUsers,
});
await lastValueFrom(dialogRef.closed);
}
async openBulkStatusDialog(
users: OrganizationUserView[],
filteredUsers: OrganizationUserView[],
request: Promise<any>,
successMessage: string,
): Promise<void> {
const dialogRef = BulkStatusComponent.open(this.dialogService, {
data: {
users: users,
filteredUsers: filteredUsers,
request: request,
successfulMessage: successMessage,
},
});
await lastValueFrom(dialogRef.closed);
}
openEventsDialog(user: OrganizationUserView, organization: Organization): void {
openEntityEventsDialog(this.dialogService, {
data: {
name: this.userNamePipe.transform(user),
organizationId: organization.id,
entityId: user.id,
showUser: false,
entity: "user",
},
});
}
async openRemoveUserConfirmationDialog(user: OrganizationUserView): Promise<boolean> {
const content = user.usesKeyConnector
? "removeUserConfirmationKeyConnector"
: "removeOrgUserConfirmation";
const confirmed = await this.dialogService.openSimpleDialog({
title: {
key: "removeUserIdAccess",
placeholders: [this.userNamePipe.transform(user)],
},
content: { key: content },
type: "warning",
});
if (!confirmed) {
return false;
}
if (user.status > 0 && user.hasMasterPassword === false) {
return await this.openNoMasterPasswordConfirmationDialog(user);
}
return true;
}
async openRevokeUserConfirmationDialog(user: OrganizationUserView): Promise<boolean> {
const confirmed = await this.dialogService.openSimpleDialog({
title: { key: "revokeAccess", placeholders: [this.userNamePipe.transform(user)] },
content: this.i18nService.t("revokeUserConfirmation"),
acceptButtonText: { key: "revokeAccess" },
type: "warning",
});
if (!confirmed) {
return false;
}
if (user.status > 0 && user.hasMasterPassword === false) {
return await this.openNoMasterPasswordConfirmationDialog(user);
}
return true;
}
async openDeleteUserConfirmationDialog(
user: OrganizationUserView,
organization: Organization,
): Promise<boolean> {
const warningAcknowledged = await firstValueFrom(
this.deleteManagedMemberWarningService.warningAcknowledged(organization.id),
);
if (
!warningAcknowledged &&
organization.canManageUsers &&
organization.productTierType === ProductTierType.Enterprise
) {
const acknowledged = await this.deleteManagedMemberWarningService.showWarning();
if (!acknowledged) {
return false;
}
}
const confirmed = await this.dialogService.openSimpleDialog({
title: {
key: "deleteOrganizationUser",
placeholders: [this.userNamePipe.transform(user)],
},
content: {
key: "deleteOrganizationUserWarningDesc",
placeholders: [this.userNamePipe.transform(user)],
},
type: "warning",
acceptButtonText: { key: "delete" },
cancelButtonText: { key: "cancel" },
});
if (confirmed) {
await this.deleteManagedMemberWarningService.acknowledgeWarning(organization.id);
}
return confirmed;
}
private async openNoMasterPasswordConfirmationDialog(
user: OrganizationUserView,
): Promise<boolean> {
return this.dialogService.openSimpleDialog({
title: {
key: "removeOrgUserNoMasterPasswordTitle",
},
content: {
key: "removeOrgUserNoMasterPasswordDesc",
placeholders: [this.userNamePipe.transform(user)],
},
type: "warning",
});
}
}

View File

@@ -0,0 +1,362 @@
import { TestBed } from "@angular/core/testing";
import {
OrganizationUserApiService,
OrganizationUserUserDetailsResponse,
} from "@bitwarden/admin-console/common";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
import { ListResponse } from "@bitwarden/common/models/response/list.response";
import { OrganizationId } from "@bitwarden/common/types/guid";
import { GroupApiService } from "../../../core";
import { OrganizationMembersService } from "./organization-members.service";
describe("OrganizationMembersService", () => {
let service: OrganizationMembersService;
let organizationUserApiService: jest.Mocked<OrganizationUserApiService>;
let groupService: jest.Mocked<GroupApiService>;
let apiService: jest.Mocked<ApiService>;
const mockOrganizationId = "org-123" as OrganizationId;
const createMockOrganization = (overrides: Partial<Organization> = {}): Organization => {
const org = new Organization();
org.id = mockOrganizationId;
org.useGroups = false;
return Object.assign(org, overrides);
};
const createMockUserResponse = (
overrides: Partial<OrganizationUserUserDetailsResponse> = {},
): OrganizationUserUserDetailsResponse => {
return {
id: "user-1",
userId: "user-id-1",
email: "test@example.com",
name: "Test User",
collections: [],
groups: [],
...overrides,
} as OrganizationUserUserDetailsResponse;
};
const createMockGroup = (id: string, name: string) => ({
id,
name,
});
const createMockCollection = (id: string, name: string) => ({
id,
name,
});
beforeEach(() => {
organizationUserApiService = {
getAllUsers: jest.fn(),
} as any;
groupService = {
getAll: jest.fn(),
} as any;
apiService = {
getCollections: jest.fn(),
} as any;
TestBed.configureTestingModule({
providers: [
OrganizationMembersService,
{ provide: OrganizationUserApiService, useValue: organizationUserApiService },
{ provide: GroupApiService, useValue: groupService },
{ provide: ApiService, useValue: apiService },
],
});
service = TestBed.inject(OrganizationMembersService);
});
describe("loadUsers", () => {
it("should load users with collections when organization does not use groups", async () => {
const organization = createMockOrganization({ useGroups: false });
const mockUser = createMockUserResponse({
collections: [{ id: "col-1" } as any],
});
const mockUsersResponse: ListResponse<OrganizationUserUserDetailsResponse> = {
data: [mockUser],
} as any;
const mockCollections = [createMockCollection("col-1", "Collection 1")];
organizationUserApiService.getAllUsers.mockResolvedValue(mockUsersResponse);
apiService.getCollections.mockResolvedValue({
data: mockCollections,
} as any);
const result = await service.loadUsers(organization);
expect(organizationUserApiService.getAllUsers).toHaveBeenCalledWith(mockOrganizationId, {
includeGroups: false,
includeCollections: true,
});
expect(apiService.getCollections).toHaveBeenCalledWith(mockOrganizationId);
expect(groupService.getAll).not.toHaveBeenCalled();
expect(result).toHaveLength(1);
expect(result[0].collectionNames).toEqual(["Collection 1"]);
expect(result[0].groupNames).toEqual([]);
});
it("should load users with groups when organization uses groups", async () => {
const organization = createMockOrganization({ useGroups: true });
const mockUser = createMockUserResponse({
groups: ["group-1", "group-2"],
});
const mockUsersResponse: ListResponse<OrganizationUserUserDetailsResponse> = {
data: [mockUser],
} as any;
const mockGroups = [
createMockGroup("group-1", "Group 1"),
createMockGroup("group-2", "Group 2"),
];
organizationUserApiService.getAllUsers.mockResolvedValue(mockUsersResponse);
groupService.getAll.mockResolvedValue(mockGroups as any);
const result = await service.loadUsers(organization);
expect(organizationUserApiService.getAllUsers).toHaveBeenCalledWith(mockOrganizationId, {
includeGroups: true,
includeCollections: false,
});
expect(groupService.getAll).toHaveBeenCalledWith(mockOrganizationId);
expect(apiService.getCollections).not.toHaveBeenCalled();
expect(result).toHaveLength(1);
expect(result[0].groupNames).toEqual(["Group 1", "Group 2"]);
expect(result[0].collectionNames).toEqual([]);
});
it("should sort group names alphabetically", async () => {
const organization = createMockOrganization({ useGroups: true });
const mockUser = createMockUserResponse({
groups: ["group-1", "group-2", "group-3"],
});
const mockUsersResponse: ListResponse<OrganizationUserUserDetailsResponse> = {
data: [mockUser],
} as any;
const mockGroups = [
createMockGroup("group-1", "Zebra Group"),
createMockGroup("group-2", "Alpha Group"),
createMockGroup("group-3", "Beta Group"),
];
organizationUserApiService.getAllUsers.mockResolvedValue(mockUsersResponse);
groupService.getAll.mockResolvedValue(mockGroups as any);
const result = await service.loadUsers(organization);
expect(result[0].groupNames).toEqual(["Alpha Group", "Beta Group", "Zebra Group"]);
});
it("should sort collection names alphabetically", async () => {
const organization = createMockOrganization({ useGroups: false });
const mockUser = createMockUserResponse({
collections: [{ id: "col-1" } as any, { id: "col-2" } as any, { id: "col-3" } as any],
});
const mockUsersResponse: ListResponse<OrganizationUserUserDetailsResponse> = {
data: [mockUser],
} as any;
const mockCollections = [
createMockCollection("col-1", "Zebra Collection"),
createMockCollection("col-2", "Alpha Collection"),
createMockCollection("col-3", "Beta Collection"),
];
organizationUserApiService.getAllUsers.mockResolvedValue(mockUsersResponse);
apiService.getCollections.mockResolvedValue({
data: mockCollections,
} as any);
const result = await service.loadUsers(organization);
expect(result[0].collectionNames).toEqual([
"Alpha Collection",
"Beta Collection",
"Zebra Collection",
]);
});
it("should filter out null or undefined group names", async () => {
const organization = createMockOrganization({ useGroups: true });
const mockUser = createMockUserResponse({
groups: ["group-1", "group-2", "group-3"],
});
const mockUsersResponse: ListResponse<OrganizationUserUserDetailsResponse> = {
data: [mockUser],
} as any;
const mockGroups = [
createMockGroup("group-1", "Group 1"),
// group-2 is missing - should be filtered out
createMockGroup("group-3", "Group 3"),
];
organizationUserApiService.getAllUsers.mockResolvedValue(mockUsersResponse);
groupService.getAll.mockResolvedValue(mockGroups as any);
const result = await service.loadUsers(organization);
expect(result[0].groupNames).toEqual(["Group 1", "Group 3"]);
expect(result[0].groupNames).not.toContain(undefined);
expect(result[0].groupNames).not.toContain(null);
});
it("should filter out null or undefined collection names", async () => {
const organization = createMockOrganization({ useGroups: false });
const mockUser = createMockUserResponse({
collections: [{ id: "col-1" } as any, { id: "col-2" } as any, { id: "col-3" } as any],
});
const mockUsersResponse: ListResponse<OrganizationUserUserDetailsResponse> = {
data: [mockUser],
} as any;
const mockCollections = [
createMockCollection("col-1", "Collection 1"),
// col-2 is missing - should be filtered out
createMockCollection("col-3", "Collection 3"),
];
organizationUserApiService.getAllUsers.mockResolvedValue(mockUsersResponse);
apiService.getCollections.mockResolvedValue({
data: mockCollections,
} as any);
const result = await service.loadUsers(organization);
expect(result[0].collectionNames).toEqual(["Collection 1", "Collection 3"]);
expect(result[0].collectionNames).not.toContain(undefined);
expect(result[0].collectionNames).not.toContain(null);
});
it("should handle multiple users", async () => {
const organization = createMockOrganization({ useGroups: true });
const mockUser1 = createMockUserResponse({
id: "user-1",
groups: ["group-1"],
});
const mockUser2 = createMockUserResponse({
id: "user-2",
groups: ["group-2"],
});
const mockUsersResponse: ListResponse<OrganizationUserUserDetailsResponse> = {
data: [mockUser1, mockUser2],
} as any;
const mockGroups = [
createMockGroup("group-1", "Group 1"),
createMockGroup("group-2", "Group 2"),
];
organizationUserApiService.getAllUsers.mockResolvedValue(mockUsersResponse);
groupService.getAll.mockResolvedValue(mockGroups as any);
const result = await service.loadUsers(organization);
expect(result).toHaveLength(2);
expect(result[0].groupNames).toEqual(["Group 1"]);
expect(result[1].groupNames).toEqual(["Group 2"]);
});
it("should return empty array when usersResponse.data is null", async () => {
const organization = createMockOrganization({ useGroups: false });
const mockUsersResponse: ListResponse<OrganizationUserUserDetailsResponse> = {
data: null as any,
} as any;
organizationUserApiService.getAllUsers.mockResolvedValue(mockUsersResponse);
apiService.getCollections.mockResolvedValue({
data: [],
} as any);
const result = await service.loadUsers(organization);
expect(result).toEqual([]);
});
it("should return empty array when usersResponse.data is undefined", async () => {
const organization = createMockOrganization({ useGroups: false });
const mockUsersResponse: ListResponse<OrganizationUserUserDetailsResponse> = {
data: undefined as any,
} as any;
organizationUserApiService.getAllUsers.mockResolvedValue(mockUsersResponse);
apiService.getCollections.mockResolvedValue({
data: [],
} as any);
const result = await service.loadUsers(organization);
expect(result).toEqual([]);
});
it("should handle empty groups array", async () => {
const organization = createMockOrganization({ useGroups: true });
const mockUser = createMockUserResponse({
groups: [],
});
const mockUsersResponse: ListResponse<OrganizationUserUserDetailsResponse> = {
data: [mockUser],
} as any;
organizationUserApiService.getAllUsers.mockResolvedValue(mockUsersResponse);
groupService.getAll.mockResolvedValue([]);
const result = await service.loadUsers(organization);
expect(result).toHaveLength(1);
expect(result[0].groupNames).toEqual([]);
});
it("should handle empty collections array", async () => {
const organization = createMockOrganization({ useGroups: false });
const mockUser = createMockUserResponse({
collections: [],
});
const mockUsersResponse: ListResponse<OrganizationUserUserDetailsResponse> = {
data: [mockUser],
} as any;
organizationUserApiService.getAllUsers.mockResolvedValue(mockUsersResponse);
apiService.getCollections.mockResolvedValue({
data: [],
} as any);
const result = await service.loadUsers(organization);
expect(result).toHaveLength(1);
expect(result[0].collectionNames).toEqual([]);
});
it("should fetch data in parallel using Promise.all", async () => {
const organization = createMockOrganization({ useGroups: true });
const mockUsersResponse: ListResponse<OrganizationUserUserDetailsResponse> = {
data: [],
} as any;
let getUsersCallTime: number;
let getGroupsCallTime: number;
organizationUserApiService.getAllUsers.mockImplementation(async () => {
getUsersCallTime = Date.now();
return mockUsersResponse;
});
groupService.getAll.mockImplementation(async () => {
getGroupsCallTime = Date.now();
return [];
});
await service.loadUsers(organization);
// Both calls should have been initiated at roughly the same time (within 50ms)
expect(Math.abs(getUsersCallTime - getGroupsCallTime)).toBeLessThan(50);
});
});
});

View File

@@ -0,0 +1,76 @@
import { Injectable } from "@angular/core";
import { OrganizationUserApiService } from "@bitwarden/admin-console/common";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
import { GroupApiService } from "../../../core";
import { OrganizationUserView } from "../../../core/views/organization-user.view";
@Injectable()
export class OrganizationMembersService {
constructor(
private organizationUserApiService: OrganizationUserApiService,
private groupService: GroupApiService,
private apiService: ApiService,
) {}
async loadUsers(organization: Organization): Promise<OrganizationUserView[]> {
let groupsPromise: Promise<Map<string, string>> | undefined;
let collectionsPromise: Promise<Map<string, string>> | undefined;
const userPromise = this.organizationUserApiService.getAllUsers(organization.id, {
includeGroups: organization.useGroups,
includeCollections: !organization.useGroups,
});
if (organization.useGroups) {
groupsPromise = this.getGroupNameMap(organization);
} else {
collectionsPromise = this.getCollectionNameMap(organization);
}
const [usersResponse, groupNamesMap, collectionNamesMap] = await Promise.all([
userPromise,
groupsPromise,
collectionsPromise,
]);
return (
usersResponse.data?.map<OrganizationUserView>((r) => {
const userView = OrganizationUserView.fromResponse(r);
userView.groupNames = userView.groups
.map((g: string) => groupNamesMap?.get(g))
.filter((name): name is string => name != null)
.sort();
userView.collectionNames = userView.collections
.map((c: { id: string }) => collectionNamesMap?.get(c.id))
.filter((name): name is string => name != null)
.sort();
return userView;
}) ?? []
);
}
private async getGroupNameMap(organization: Organization): Promise<Map<string, string>> {
const groups = await this.groupService.getAll(organization.id);
const groupNameMap = new Map<string, string>();
groups.forEach((g: { id: string; name: string }) => groupNameMap.set(g.id, g.name));
return groupNameMap;
}
private async getCollectionNameMap(organization: Organization): Promise<Map<string, string>> {
const response = this.apiService
.getCollections(organization.id)
.then((res) =>
res.data.map((r: { id: string; name: string }) => ({ id: r.id, name: r.name })),
);
const collections = await response;
const collectionMap = new Map<string, string>();
collections.forEach((c: { id: string; name: string }) => collectionMap.set(c.id, c.name));
return collectionMap;
}
}

View File

@@ -0,0 +1,461 @@
import { TestBed } from "@angular/core/testing";
import { Router } from "@angular/router";
import { of } from "rxjs";
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
import { OrganizationMetadataServiceAbstraction } from "@bitwarden/common/billing/abstractions/organization-metadata.service.abstraction";
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 {
ChangePlanDialogResultType,
openChangePlanDialog,
} from "../../organizations/change-plan-dialog.component";
import { BillingConstraintService, SeatLimitResult } from "./billing-constraint.service";
jest.mock("../../organizations/change-plan-dialog.component");
describe("BillingConstraintService", () => {
let service: BillingConstraintService;
let i18nService: jest.Mocked<I18nService>;
let dialogService: jest.Mocked<DialogService>;
let toastService: jest.Mocked<ToastService>;
let router: jest.Mocked<Router>;
let organizationMetadataService: jest.Mocked<OrganizationMetadataServiceAbstraction>;
const mockOrganizationId = "org-123" as OrganizationId;
const createMockOrganization = (overrides: Partial<Organization> = {}): Organization => {
const org = new Organization();
org.id = mockOrganizationId;
org.seats = 10;
org.productTierType = ProductTierType.Teams;
Object.defineProperty(org, "hasReseller", {
value: false,
writable: true,
configurable: true,
});
Object.defineProperty(org, "canEditSubscription", {
value: true,
writable: true,
configurable: true,
});
return Object.assign(org, overrides);
};
const createMockBillingMetadata = (
overrides: Partial<OrganizationBillingMetadataResponse> = {},
): OrganizationBillingMetadataResponse => {
return {
organizationOccupiedSeats: 5,
...overrides,
} as OrganizationBillingMetadataResponse;
};
beforeEach(() => {
const mockDialogRef = {
closed: of(true),
};
const mockSimpleDialogRef = {
closed: of(true),
};
i18nService = {
t: jest.fn().mockReturnValue("translated-text"),
} as any;
dialogService = {
openSimpleDialogRef: jest.fn().mockReturnValue(mockSimpleDialogRef),
} as any;
toastService = {
showToast: jest.fn(),
} as any;
router = {
navigate: jest.fn().mockResolvedValue(true),
} as any;
organizationMetadataService = {
getOrganizationMetadata$: jest.fn(),
refreshMetadataCache: jest.fn(),
} as any;
(openChangePlanDialog as jest.Mock).mockReturnValue(mockDialogRef);
TestBed.configureTestingModule({
providers: [
BillingConstraintService,
{ provide: I18nService, useValue: i18nService },
{ provide: DialogService, useValue: dialogService },
{ provide: ToastService, useValue: toastService },
{ provide: Router, useValue: router },
{ provide: OrganizationMetadataServiceAbstraction, useValue: organizationMetadataService },
],
});
service = TestBed.inject(BillingConstraintService);
});
describe("checkSeatLimit", () => {
it("should allow users when occupied seats are less than total seats", () => {
const organization = createMockOrganization({ seats: 10 });
const billingMetadata = createMockBillingMetadata({ organizationOccupiedSeats: 5 });
const result = service.checkSeatLimit(organization, billingMetadata);
expect(result).toEqual({ canAddUsers: true });
});
it("should allow users when occupied seats equal total seats for non-fixed seat plans", () => {
const organization = createMockOrganization({
seats: 10,
productTierType: ProductTierType.Teams,
});
const billingMetadata = createMockBillingMetadata({ organizationOccupiedSeats: 10 });
const result = service.checkSeatLimit(organization, billingMetadata);
expect(result).toEqual({ canAddUsers: true });
});
it("should block users with reseller-limit reason when organization has reseller", () => {
const organization = createMockOrganization({
seats: 10,
hasReseller: true,
});
const billingMetadata = createMockBillingMetadata({ organizationOccupiedSeats: 10 });
const result = service.checkSeatLimit(organization, billingMetadata);
expect(result).toEqual({
canAddUsers: false,
reason: "reseller-limit",
});
});
it("should block users with fixed-seat-limit reason for fixed seat plans", () => {
const organization = createMockOrganization({
seats: 10,
productTierType: ProductTierType.Free,
canEditSubscription: true,
});
const billingMetadata = createMockBillingMetadata({ organizationOccupiedSeats: 10 });
const result = service.checkSeatLimit(organization, billingMetadata);
expect(result).toEqual({
canAddUsers: false,
reason: "fixed-seat-limit",
shouldShowUpgradeDialog: true,
});
});
it("should not show upgrade dialog when organization cannot edit subscription", () => {
const organization = createMockOrganization({
seats: 10,
productTierType: ProductTierType.TeamsStarter,
canEditSubscription: false,
});
const billingMetadata = createMockBillingMetadata({ organizationOccupiedSeats: 10 });
const result = service.checkSeatLimit(organization, billingMetadata);
expect(result).toEqual({
canAddUsers: false,
reason: "fixed-seat-limit",
shouldShowUpgradeDialog: false,
});
});
it("shoud throw if missing billingMetadata", () => {
const organization = createMockOrganization({ seats: 10 });
const billingMetadata = createMockBillingMetadata({
organizationOccupiedSeats: undefined as any,
});
const err = () => service.checkSeatLimit(organization, billingMetadata);
expect(err).toThrow("Cannot check seat limit: billingMetadata is null or undefined.");
});
});
describe("seatLimitReached", () => {
it("should return false when canAddUsers is true", async () => {
const result: SeatLimitResult = { canAddUsers: true };
const organization = createMockOrganization();
const seatLimitReached = await service.seatLimitReached(result, organization);
expect(seatLimitReached).toBe(false);
});
it("should show toast and return true for reseller-limit", async () => {
const result: SeatLimitResult = { canAddUsers: false, reason: "reseller-limit" };
const organization = createMockOrganization();
const seatLimitReached = await service.seatLimitReached(result, organization);
expect(toastService.showToast).toHaveBeenCalledWith({
variant: "error",
title: "translated-text",
message: "translated-text",
});
expect(i18nService.t).toHaveBeenCalledWith("seatLimitReached");
expect(i18nService.t).toHaveBeenCalledWith("contactYourProvider");
expect(seatLimitReached).toBe(true);
});
it("should return true when upgrade dialog is cancelled", async () => {
const result: SeatLimitResult = {
canAddUsers: false,
reason: "fixed-seat-limit",
shouldShowUpgradeDialog: true,
};
const organization = createMockOrganization();
const mockDialogRef = { closed: of(ChangePlanDialogResultType.Closed) };
(openChangePlanDialog as jest.Mock).mockReturnValue(mockDialogRef);
const seatLimitReached = await service.seatLimitReached(result, organization);
expect(openChangePlanDialog).toHaveBeenCalledWith(dialogService, {
data: {
organizationId: organization.id,
productTierType: organization.productTierType,
},
});
expect(seatLimitReached).toBe(true);
});
it("should return false when upgrade dialog is submitted", async () => {
const result: SeatLimitResult = {
canAddUsers: false,
reason: "fixed-seat-limit",
shouldShowUpgradeDialog: true,
};
const organization = createMockOrganization();
const mockDialogRef = { closed: of(ChangePlanDialogResultType.Submitted) };
(openChangePlanDialog as jest.Mock).mockReturnValue(mockDialogRef);
const seatLimitReached = await service.seatLimitReached(result, organization);
expect(seatLimitReached).toBe(false);
});
it("should show seat limit dialog when shouldShowUpgradeDialog is false", async () => {
const result: SeatLimitResult = {
canAddUsers: false,
reason: "fixed-seat-limit",
shouldShowUpgradeDialog: false,
};
const organization = createMockOrganization({
canEditSubscription: false,
productTierType: ProductTierType.Free,
});
const seatLimitReached = await service.seatLimitReached(result, organization);
expect(dialogService.openSimpleDialogRef).toHaveBeenCalled();
expect(seatLimitReached).toBe(true);
});
it("should return true for unknown reasons", async () => {
const result: SeatLimitResult = { canAddUsers: false };
const organization = createMockOrganization();
const seatLimitReached = await service.seatLimitReached(result, organization);
expect(seatLimitReached).toBe(true);
});
});
describe("navigateToPaymentMethod", () => {
it("should navigate to payment method with correct parameters", async () => {
const organization = createMockOrganization();
await service.navigateToPaymentMethod(organization);
expect(router.navigate).toHaveBeenCalledWith(
["organizations", organization.id, "billing", "payment-method"],
{
state: { launchPaymentModalAutomatically: true },
},
);
});
});
describe("private methods through public method coverage", () => {
describe("getDialogContent via showSeatLimitReachedDialog", () => {
it("should get correct dialog content for Free organization", async () => {
const result: SeatLimitResult = {
canAddUsers: false,
reason: "fixed-seat-limit",
shouldShowUpgradeDialog: false,
};
const organization = createMockOrganization({
productTierType: ProductTierType.Free,
canEditSubscription: false,
seats: 5,
});
await service.seatLimitReached(result, organization);
expect(i18nService.t).toHaveBeenCalledWith("freeOrgInvLimitReachedNoManageBilling", 5);
});
it("should get correct dialog content for TeamsStarter organization", async () => {
const result: SeatLimitResult = {
canAddUsers: false,
reason: "fixed-seat-limit",
shouldShowUpgradeDialog: false,
};
const organization = createMockOrganization({
productTierType: ProductTierType.TeamsStarter,
canEditSubscription: false,
seats: 3,
});
await service.seatLimitReached(result, organization);
expect(i18nService.t).toHaveBeenCalledWith(
"teamsStarterPlanInvLimitReachedNoManageBilling",
3,
);
});
it("should get correct dialog content for Families organization", async () => {
const result: SeatLimitResult = {
canAddUsers: false,
reason: "fixed-seat-limit",
shouldShowUpgradeDialog: false,
};
const organization = createMockOrganization({
productTierType: ProductTierType.Families,
canEditSubscription: false,
seats: 6,
});
await service.seatLimitReached(result, organization);
expect(i18nService.t).toHaveBeenCalledWith("familiesPlanInvLimitReachedNoManageBilling", 6);
});
it("should throw error for unsupported product type in getProductKey", async () => {
const result: SeatLimitResult = {
canAddUsers: false,
reason: "fixed-seat-limit",
shouldShowUpgradeDialog: false,
};
const organization = createMockOrganization({
productTierType: ProductTierType.Enterprise,
canEditSubscription: false,
});
await expect(service.seatLimitReached(result, organization)).rejects.toThrow(
`Unsupported product type: ${ProductTierType.Enterprise}`,
);
});
});
describe("getAcceptButtonText via showSeatLimitReachedDialog", () => {
it("should return 'ok' when organization cannot edit subscription", async () => {
const result: SeatLimitResult = {
canAddUsers: false,
reason: "fixed-seat-limit",
shouldShowUpgradeDialog: false,
};
const organization = createMockOrganization({
canEditSubscription: false,
productTierType: ProductTierType.Free,
});
await service.seatLimitReached(result, organization);
expect(i18nService.t).toHaveBeenCalledWith("ok");
});
it("should return 'upgrade' when organization can edit subscription", async () => {
const result: SeatLimitResult = {
canAddUsers: false,
reason: "fixed-seat-limit",
shouldShowUpgradeDialog: false,
};
const organization = createMockOrganization({
canEditSubscription: true,
productTierType: ProductTierType.Free,
});
const mockSimpleDialogRef = { closed: of(false) };
dialogService.openSimpleDialogRef.mockReturnValue(mockSimpleDialogRef);
await service.seatLimitReached(result, organization);
expect(i18nService.t).toHaveBeenCalledWith("upgrade");
});
it("should throw error for unsupported product type in getAcceptButtonText", async () => {
const result: SeatLimitResult = {
canAddUsers: false,
reason: "fixed-seat-limit",
shouldShowUpgradeDialog: false,
};
const organization = createMockOrganization({
canEditSubscription: true,
productTierType: ProductTierType.Enterprise,
});
await expect(service.seatLimitReached(result, organization)).rejects.toThrow(
`Unsupported product type: ${ProductTierType.Enterprise}`,
);
});
});
describe("handleUpgradeNavigation", () => {
it("should navigate to billing subscription with upgrade query param", async () => {
const result: SeatLimitResult = {
canAddUsers: false,
reason: "fixed-seat-limit",
shouldShowUpgradeDialog: false,
};
const organization = createMockOrganization({
canEditSubscription: true,
productTierType: ProductTierType.Free,
});
const mockSimpleDialogRef = { closed: of(true) };
dialogService.openSimpleDialogRef.mockReturnValue(mockSimpleDialogRef);
await service.seatLimitReached(result, organization);
expect(router.navigate).toHaveBeenCalledWith(
["/organizations", organization.id, "billing", "subscription"],
{ queryParams: { upgrade: true } },
);
});
it("should throw error for non-self-upgradable product type", async () => {
const result: SeatLimitResult = {
canAddUsers: false,
reason: "fixed-seat-limit",
shouldShowUpgradeDialog: false,
};
const organization = createMockOrganization({
canEditSubscription: true,
productTierType: ProductTierType.Enterprise,
});
const mockSimpleDialogRef = { closed: of(true) };
dialogService.openSimpleDialogRef.mockReturnValue(mockSimpleDialogRef);
await expect(service.seatLimitReached(result, organization)).rejects.toThrow(
`Unsupported product type: ${ProductTierType.Enterprise}`,
);
});
});
});
});

View File

@@ -0,0 +1,192 @@
import { Injectable } from "@angular/core";
import { Router } from "@angular/router";
import { lastValueFrom } from "rxjs";
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
import { isNotSelfUpgradable, 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 { DialogService, ToastService } from "@bitwarden/components";
import { isFixedSeatPlan } from "../../../admin-console/organizations/members/components/member-dialog/validators/org-seat-limit-reached.validator";
import {
ChangePlanDialogResultType,
openChangePlanDialog,
} from "../../organizations/change-plan-dialog.component";
export interface SeatLimitResult {
canAddUsers: boolean;
reason?: "reseller-limit" | "fixed-seat-limit" | "no-billing-permission";
shouldShowUpgradeDialog?: boolean;
}
@Injectable()
export class BillingConstraintService {
constructor(
private i18nService: I18nService,
private dialogService: DialogService,
private toastService: ToastService,
private router: Router,
) {}
checkSeatLimit(
organization: Organization,
billingMetadata: OrganizationBillingMetadataResponse,
): SeatLimitResult {
const occupiedSeats = billingMetadata?.organizationOccupiedSeats;
if (occupiedSeats == null) {
throw new Error("Cannot check seat limit: billingMetadata is null or undefined.");
}
const totalSeats = organization.seats;
if (occupiedSeats < totalSeats) {
return { canAddUsers: true };
}
if (organization.hasReseller) {
return {
canAddUsers: false,
reason: "reseller-limit",
};
}
if (isFixedSeatPlan(organization.productTierType)) {
return {
canAddUsers: false,
reason: "fixed-seat-limit",
shouldShowUpgradeDialog: organization.canEditSubscription,
};
}
return { canAddUsers: true };
}
async seatLimitReached(result: SeatLimitResult, organization: Organization): Promise<boolean> {
if (result.canAddUsers) {
return false;
}
switch (result.reason) {
case "reseller-limit":
this.toastService.showToast({
variant: "error",
title: this.i18nService.t("seatLimitReached"),
message: this.i18nService.t("contactYourProvider"),
});
return true;
case "fixed-seat-limit":
if (result.shouldShowUpgradeDialog) {
const dialogResult = await this.showChangePlanDialog(organization);
// If the plan was successfully changed, the seat limit is no longer blocking
return dialogResult !== ChangePlanDialogResultType.Submitted;
} else {
await this.showSeatLimitReachedDialog(organization);
return true;
}
default:
return true;
}
}
private async showChangePlanDialog(
organization: Organization,
): Promise<ChangePlanDialogResultType> {
const reference = openChangePlanDialog(this.dialogService, {
data: {
organizationId: organization.id,
productTierType: organization.productTierType,
},
});
const result = await lastValueFrom(reference.closed);
if (result == null) {
throw new Error("ChangePlanDialog result is null or undefined.");
}
return result;
}
private async showSeatLimitReachedDialog(organization: Organization): Promise<void> {
const dialogContent = this.getSeatLimitReachedDialogContent(organization);
const acceptButtonText = this.getSeatLimitReachedDialogAcceptButtonText(organization);
const orgUpgradeSimpleDialogOpts = {
title: this.i18nService.t("upgradeOrganization"),
content: dialogContent,
type: "primary" as const,
acceptButtonText,
cancelButtonText: organization.canEditSubscription ? undefined : (null as string | null),
};
const simpleDialog = this.dialogService.openSimpleDialogRef(orgUpgradeSimpleDialogOpts);
const result = await lastValueFrom(simpleDialog.closed);
if (result && organization.canEditSubscription) {
await this.handleUpgradeNavigation(organization);
}
}
private async handleUpgradeNavigation(organization: Organization): Promise<void> {
const productType = organization.productTierType;
if (isNotSelfUpgradable(productType)) {
throw new Error(`Unsupported product type: ${organization.productTierType}`);
}
await this.router.navigate(["/organizations", organization.id, "billing", "subscription"], {
queryParams: { upgrade: true },
});
}
private getSeatLimitReachedDialogContent(organization: Organization): string {
const productKey = this.getProductKey(organization);
return this.i18nService.t(productKey, organization.seats);
}
private getSeatLimitReachedDialogAcceptButtonText(organization: Organization): string {
if (!organization.canEditSubscription) {
return this.i18nService.t("ok");
}
const productType = organization.productTierType;
if (isNotSelfUpgradable(productType)) {
throw new Error(`Unsupported product type: ${productType}`);
}
return this.i18nService.t("upgrade");
}
private getProductKey(organization: Organization): string {
const manageBillingText = organization.canEditSubscription
? "ManageBilling"
: "NoManageBilling";
let product = "";
switch (organization.productTierType) {
case ProductTierType.Free:
product = "freeOrg";
break;
case ProductTierType.TeamsStarter:
product = "teamsStarterPlan";
break;
case ProductTierType.Families:
product = "familiesPlan";
break;
default:
throw new Error(`Unsupported product type: ${organization.productTierType}`);
}
return `${product}InvLimitReached${manageBillingText}`;
}
async navigateToPaymentMethod(organization: Organization): Promise<void> {
await this.router.navigate(
["organizations", `${organization.id}`, "billing", "payment-method"],
{
state: { launchPaymentModalAutomatically: true },
},
);
}
}