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:
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 },
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {}
|
||||
|
||||
@@ -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";
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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",
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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}`,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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 },
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user