mirror of
https://github.com/bitwarden/browser
synced 2025-12-11 22:03:36 +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 { OrganizationUserView } from "../organizations/core/views/organization-user.view";
|
||||||
import { UserConfirmComponent } from "../organizations/manage/user-confirm.component";
|
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";
|
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.
|
* 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 searchControl = new FormControl("", { nonNullable: true });
|
||||||
protected statusToggle = new BehaviorSubject<StatusType | undefined>(undefined);
|
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 edit(user: UserView, organization?: Organization): void;
|
||||||
abstract getUsers(organization?: Organization): Promise<ListResponse<UserView> | UserView[]>;
|
abstract getUsers(organization?: Organization): Promise<ListResponse<UserView> | UserView[]>;
|
||||||
abstract removeUser(id: string, organization?: Organization): Promise<void>;
|
abstract removeUser(id: string, organization?: Organization): Promise<MemberActionResult>;
|
||||||
abstract reinviteUser(id: string, organization?: Organization): Promise<void>;
|
abstract reinviteUser(id: string, organization?: Organization): Promise<MemberActionResult>;
|
||||||
abstract confirmUser(
|
abstract confirmUser(
|
||||||
user: UserView,
|
user: UserView,
|
||||||
publicKey: Uint8Array,
|
publicKey: Uint8Array,
|
||||||
organization?: Organization,
|
organization?: Organization,
|
||||||
): Promise<void>;
|
): Promise<MemberActionResult>;
|
||||||
abstract invite(organization?: Organization): void;
|
abstract invite(organization?: Organization): void;
|
||||||
|
|
||||||
async load(organization?: Organization) {
|
async load(organization?: Organization) {
|
||||||
@@ -140,12 +141,16 @@ export abstract class BaseMembersComponent<UserView extends UserViewTypes> {
|
|||||||
|
|
||||||
this.actionPromise = this.removeUser(user.id, organization);
|
this.actionPromise = this.removeUser(user.id, organization);
|
||||||
try {
|
try {
|
||||||
await this.actionPromise;
|
const result = await this.actionPromise;
|
||||||
this.toastService.showToast({
|
if (result.success) {
|
||||||
variant: "success",
|
this.toastService.showToast({
|
||||||
message: this.i18nService.t("removedUserId", this.userNamePipe.transform(user)),
|
variant: "success",
|
||||||
});
|
message: this.i18nService.t("removedUserId", this.userNamePipe.transform(user)),
|
||||||
this.dataSource.removeUser(user);
|
});
|
||||||
|
this.dataSource.removeUser(user);
|
||||||
|
} else {
|
||||||
|
throw new Error(result.error);
|
||||||
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
this.validationService.showError(e);
|
this.validationService.showError(e);
|
||||||
}
|
}
|
||||||
@@ -159,11 +164,15 @@ export abstract class BaseMembersComponent<UserView extends UserViewTypes> {
|
|||||||
|
|
||||||
this.actionPromise = this.reinviteUser(user.id, organization);
|
this.actionPromise = this.reinviteUser(user.id, organization);
|
||||||
try {
|
try {
|
||||||
await this.actionPromise;
|
const result = await this.actionPromise;
|
||||||
this.toastService.showToast({
|
if (result.success) {
|
||||||
variant: "success",
|
this.toastService.showToast({
|
||||||
message: this.i18nService.t("hasBeenReinvited", this.userNamePipe.transform(user)),
|
variant: "success",
|
||||||
});
|
message: this.i18nService.t("hasBeenReinvited", this.userNamePipe.transform(user)),
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
throw new Error(result.error);
|
||||||
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
this.validationService.showError(e);
|
this.validationService.showError(e);
|
||||||
}
|
}
|
||||||
@@ -174,14 +183,18 @@ export abstract class BaseMembersComponent<UserView extends UserViewTypes> {
|
|||||||
const confirmUser = async (publicKey: Uint8Array) => {
|
const confirmUser = async (publicKey: Uint8Array) => {
|
||||||
try {
|
try {
|
||||||
this.actionPromise = this.confirmUser(user, publicKey, organization);
|
this.actionPromise = this.confirmUser(user, publicKey, organization);
|
||||||
await this.actionPromise;
|
const result = await this.actionPromise;
|
||||||
user.status = this.userStatusType.Confirmed;
|
if (result.success) {
|
||||||
this.dataSource.replaceUser(user);
|
user.status = this.userStatusType.Confirmed;
|
||||||
|
this.dataSource.replaceUser(user);
|
||||||
|
|
||||||
this.toastService.showToast({
|
this.toastService.showToast({
|
||||||
variant: "success",
|
variant: "success",
|
||||||
message: this.i18nService.t("hasBeenConfirmed", this.userNamePipe.transform(user)),
|
message: this.i18nService.t("hasBeenConfirmed", this.userNamePipe.transform(user)),
|
||||||
});
|
});
|
||||||
|
} else {
|
||||||
|
throw new Error(result.error);
|
||||||
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
this.validationService.showError(e);
|
this.validationService.showError(e);
|
||||||
throw e;
|
throw e;
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
@if (organization) {
|
@if (organization) {
|
||||||
<app-organization-free-trial-warning
|
<app-organization-free-trial-warning
|
||||||
[organization]="organization"
|
[organization]="organization"
|
||||||
(clicked)="navigateToPaymentMethod(organization)"
|
(clicked)="billingConstraint.navigateToPaymentMethod(organization)"
|
||||||
>
|
>
|
||||||
</app-organization-free-trial-warning>
|
</app-organization-free-trial-warning>
|
||||||
<app-header>
|
<app-header>
|
||||||
@@ -339,7 +339,10 @@
|
|||||||
></i>
|
></i>
|
||||||
<span class="tw-sr-only">{{ "userUsingTwoStep" | i18n }}</span>
|
<span class="tw-sr-only">{{ "userUsingTwoStep" | i18n }}</span>
|
||||||
</ng-container>
|
</ng-container>
|
||||||
<ng-container *ngIf="showEnrolledStatus($any(u), organization)">
|
@let resetPasswordPolicyEnabled = resetPasswordPolicyEnabled$ | async;
|
||||||
|
<ng-container
|
||||||
|
*ngIf="showEnrolledStatus($any(u), organization, resetPasswordPolicyEnabled)"
|
||||||
|
>
|
||||||
<i
|
<i
|
||||||
class="bwi bwi-key"
|
class="bwi bwi-key"
|
||||||
title="{{ 'enrolledAccountRecovery' | i18n }}"
|
title="{{ 'enrolledAccountRecovery' | i18n }}"
|
||||||
@@ -422,7 +425,7 @@
|
|||||||
type="button"
|
type="button"
|
||||||
bitMenuItem
|
bitMenuItem
|
||||||
(click)="resetPassword(u, organization)"
|
(click)="resetPassword(u, organization)"
|
||||||
*ngIf="allowResetPassword(u, organization)"
|
*ngIf="allowResetPassword(u, organization, resetPasswordPolicyEnabled)"
|
||||||
>
|
>
|
||||||
<i aria-hidden="true" class="bwi bwi-key"></i> {{ "recoverAccount" | i18n }}
|
<i aria-hidden="true" class="bwi bwi-key"></i> {{ "recoverAccount" | i18n }}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -1,14 +1,12 @@
|
|||||||
import { Component, computed, Signal } from "@angular/core";
|
import { Component, computed, Signal } from "@angular/core";
|
||||||
import { takeUntilDestroyed, toSignal } from "@angular/core/rxjs-interop";
|
import { takeUntilDestroyed, toSignal } from "@angular/core/rxjs-interop";
|
||||||
import { ActivatedRoute, Router } from "@angular/router";
|
import { ActivatedRoute } from "@angular/router";
|
||||||
import {
|
import {
|
||||||
BehaviorSubject,
|
|
||||||
combineLatest,
|
combineLatest,
|
||||||
concatMap,
|
concatMap,
|
||||||
filter,
|
filter,
|
||||||
firstValueFrom,
|
firstValueFrom,
|
||||||
from,
|
from,
|
||||||
lastValueFrom,
|
|
||||||
map,
|
map,
|
||||||
merge,
|
merge,
|
||||||
Observable,
|
Observable,
|
||||||
@@ -17,24 +15,12 @@ import {
|
|||||||
take,
|
take,
|
||||||
} from "rxjs";
|
} from "rxjs";
|
||||||
|
|
||||||
import {
|
import { OrganizationUserUserDetailsResponse } from "@bitwarden/admin-console/common";
|
||||||
OrganizationUserApiService,
|
|
||||||
OrganizationUserConfirmRequest,
|
|
||||||
OrganizationUserUserDetailsResponse,
|
|
||||||
CollectionService,
|
|
||||||
CollectionData,
|
|
||||||
Collection,
|
|
||||||
CollectionDetailsResponse,
|
|
||||||
} from "@bitwarden/admin-console/common";
|
|
||||||
import { UserNamePipe } from "@bitwarden/angular/pipes/user-name.pipe";
|
import { UserNamePipe } from "@bitwarden/angular/pipes/user-name.pipe";
|
||||||
import { ApiService } from "@bitwarden/common/abstractions/api.service";
|
import { ApiService } from "@bitwarden/common/abstractions/api.service";
|
||||||
import { OrganizationApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/organization/organization-api.service.abstraction";
|
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
|
||||||
import {
|
|
||||||
getOrganizationById,
|
|
||||||
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 { 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 { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
|
||||||
import {
|
import {
|
||||||
OrganizationUserStatusType,
|
OrganizationUserStatusType,
|
||||||
@@ -43,53 +29,32 @@ import {
|
|||||||
} from "@bitwarden/common/admin-console/enums";
|
} from "@bitwarden/common/admin-console/enums";
|
||||||
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
|
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
|
||||||
import { Policy } from "@bitwarden/common/admin-console/models/domain/policy";
|
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 { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||||
import { getUserId } from "@bitwarden/common/auth/services/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 { 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 { 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 { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||||
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
|
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
|
||||||
import { ValidationService } from "@bitwarden/common/platform/abstractions/validation.service";
|
import { ValidationService } from "@bitwarden/common/platform/abstractions/validation.service";
|
||||||
import { OrganizationId, UserId } from "@bitwarden/common/types/guid";
|
import { getById } from "@bitwarden/common/platform/misc";
|
||||||
import { SyncService } from "@bitwarden/common/vault/abstractions/sync/sync.service.abstraction";
|
import { DialogService, ToastService } from "@bitwarden/components";
|
||||||
import { DialogService, SimpleDialogOptions, ToastService } from "@bitwarden/components";
|
|
||||||
import { KeyService } from "@bitwarden/key-management";
|
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 { 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 { BaseMembersComponent } from "../../common/base-members.component";
|
||||||
import { PeopleTableDataSource } from "../../common/people-table-data-source";
|
import { PeopleTableDataSource } from "../../common/people-table-data-source";
|
||||||
import { GroupApiService } from "../core";
|
|
||||||
import { OrganizationUserView } from "../core/views/organization-user.view";
|
import { OrganizationUserView } from "../core/views/organization-user.view";
|
||||||
import { openEntityEventsDialog } from "../manage/entity-events.component";
|
|
||||||
|
|
||||||
import {
|
import { AccountRecoveryDialogResultType } from "./components/account-recovery/account-recovery-dialog.component";
|
||||||
AccountRecoveryDialogComponent,
|
import { MemberDialogResult, MemberDialogTab } from "./components/member-dialog";
|
||||||
AccountRecoveryDialogResultType,
|
import { MemberDialogManagerService, OrganizationMembersService } from "./services";
|
||||||
} 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 { DeleteManagedMemberWarningService } from "./services/delete-managed-member/delete-managed-member-warning.service";
|
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> {
|
class MembersTableDataSource extends PeopleTableDataSource<OrganizationUserView> {
|
||||||
protected statusType = OrganizationUserStatusType;
|
protected statusType = OrganizationUserStatusType;
|
||||||
@@ -107,7 +72,10 @@ export class MembersComponent extends BaseMembersComponent<OrganizationUserView>
|
|||||||
|
|
||||||
readonly organization: Signal<Organization | undefined>;
|
readonly organization: Signal<Organization | undefined>;
|
||||||
status: OrganizationUserStatusType | undefined;
|
status: OrganizationUserStatusType | undefined;
|
||||||
orgResetPasswordPolicyEnabled = false;
|
|
||||||
|
private userId$: Observable<UserId> = this.accountService.activeAccount$.pipe(getUserId);
|
||||||
|
|
||||||
|
resetPasswordPolicyEnabled$: Observable<boolean>;
|
||||||
|
|
||||||
protected readonly canUseSecretsManager: Signal<boolean> = computed(
|
protected readonly canUseSecretsManager: Signal<boolean> = computed(
|
||||||
() => this.organization()?.useSecretsManager ?? false,
|
() => this.organization()?.useSecretsManager ?? false,
|
||||||
@@ -115,43 +83,34 @@ export class MembersComponent extends BaseMembersComponent<OrganizationUserView>
|
|||||||
protected readonly showUserManagementControls: Signal<boolean> = computed(
|
protected readonly showUserManagementControls: Signal<boolean> = computed(
|
||||||
() => this.organization()?.canManageUsers ?? false,
|
() => this.organization()?.canManageUsers ?? false,
|
||||||
);
|
);
|
||||||
private refreshBillingMetadata$: BehaviorSubject<null> = new BehaviorSubject(null);
|
|
||||||
protected billingMetadata$: Observable<OrganizationBillingMetadataResponse>;
|
protected billingMetadata$: Observable<OrganizationBillingMetadataResponse>;
|
||||||
|
|
||||||
// Fixed sizes used for cdkVirtualScroll
|
// Fixed sizes used for cdkVirtualScroll
|
||||||
protected rowHeight = 66;
|
protected rowHeight = 66;
|
||||||
protected rowHeightClass = `tw-h-[66px]`;
|
protected rowHeightClass = `tw-h-[66px]`;
|
||||||
|
|
||||||
private userId$: Observable<UserId> = this.accountService.activeAccount$.pipe(getUserId);
|
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
apiService: ApiService,
|
apiService: ApiService,
|
||||||
i18nService: I18nService,
|
i18nService: I18nService,
|
||||||
organizationManagementPreferencesService: OrganizationManagementPreferencesService,
|
organizationManagementPreferencesService: OrganizationManagementPreferencesService,
|
||||||
keyService: KeyService,
|
keyService: KeyService,
|
||||||
private encryptService: EncryptService,
|
|
||||||
validationService: ValidationService,
|
validationService: ValidationService,
|
||||||
logService: LogService,
|
logService: LogService,
|
||||||
userNamePipe: UserNamePipe,
|
userNamePipe: UserNamePipe,
|
||||||
dialogService: DialogService,
|
dialogService: DialogService,
|
||||||
toastService: ToastService,
|
toastService: ToastService,
|
||||||
private policyService: PolicyService,
|
|
||||||
private policyApiService: PolicyApiService,
|
|
||||||
private route: ActivatedRoute,
|
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 organizationService: OrganizationService,
|
||||||
private accountService: AccountService,
|
private accountService: AccountService,
|
||||||
private organizationApiService: OrganizationApiServiceAbstraction,
|
private policyService: PolicyService,
|
||||||
private organizationUserApiService: OrganizationUserApiService,
|
private policyApiService: PolicyApiServiceAbstraction,
|
||||||
private router: Router,
|
|
||||||
private groupService: GroupApiService,
|
|
||||||
private collectionService: CollectionService,
|
|
||||||
private billingApiService: BillingApiServiceAbstraction,
|
|
||||||
private organizationMetadataService: OrganizationMetadataServiceAbstraction,
|
private organizationMetadataService: OrganizationMetadataServiceAbstraction,
|
||||||
protected deleteManagedMemberWarningService: DeleteManagedMemberWarningService,
|
|
||||||
private configService: ConfigService,
|
|
||||||
private organizationUserService: OrganizationUserService,
|
|
||||||
private organizationWarningsService: OrganizationWarningsService,
|
|
||||||
) {
|
) {
|
||||||
super(
|
super(
|
||||||
apiService,
|
apiService,
|
||||||
@@ -169,14 +128,12 @@ export class MembersComponent extends BaseMembersComponent<OrganizationUserView>
|
|||||||
concatMap((params) =>
|
concatMap((params) =>
|
||||||
this.userId$.pipe(
|
this.userId$.pipe(
|
||||||
switchMap((userId) =>
|
switchMap((userId) =>
|
||||||
this.organizationService
|
this.organizationService.organizations$(userId).pipe(getById(params.organizationId)),
|
||||||
.organizations$(userId)
|
|
||||||
.pipe(getOrganizationById(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$);
|
this.organization = toSignal(organization$);
|
||||||
@@ -191,53 +148,26 @@ export class MembersComponent extends BaseMembersComponent<OrganizationUserView>
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
combineLatest([this.route.queryParams, policies$, organization$])
|
this.resetPasswordPolicyEnabled$ = combineLatest([organization$, policies$]).pipe(
|
||||||
.pipe(
|
map(
|
||||||
concatMap(async ([qParams, policies, organization]) => {
|
([organization, policies]) =>
|
||||||
// Backfill pub/priv key if necessary
|
policies
|
||||||
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
|
|
||||||
.filter((policy) => policy.type === PolicyType.ResetPassword)
|
.filter((policy) => policy.type === PolicyType.ResetPassword)
|
||||||
.find((p) => p.organizationId === organization.id);
|
.find((p) => p.organizationId === organization.id)?.enabled ?? false,
|
||||||
this.orgResetPasswordPolicyEnabled = resetPasswordPolicy?.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);
|
this.searchControl.setValue(qParams.search);
|
||||||
|
|
||||||
if (qParams.viewEvents != null) {
|
if (qParams.viewEvents != null) {
|
||||||
const user = this.dataSource.data.filter((u) => u.id === qParams.viewEvents);
|
const user = this.dataSource.data.filter((u) => u.id === qParams.viewEvents);
|
||||||
if (user.length > 0 && user[0].status === OrganizationUserStatusType.Confirmed) {
|
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();
|
.subscribe();
|
||||||
|
|
||||||
this.billingMetadata$ = combineLatest([this.refreshBillingMetadata$, organization$]).pipe(
|
this.billingMetadata$ = organization$.pipe(
|
||||||
switchMap(([_, organization]) =>
|
switchMap((organization) =>
|
||||||
this.organizationMetadataService.getOrganizationMetadata$(organization.id),
|
this.organizationMetadataService.getOrganizationMetadata$(organization.id),
|
||||||
),
|
),
|
||||||
takeUntilDestroyed(),
|
|
||||||
shareReplay({ bufferSize: 1, refCount: false }),
|
shareReplay({ bufferSize: 1, refCount: false }),
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -271,136 +200,35 @@ export class MembersComponent extends BaseMembersComponent<OrganizationUserView>
|
|||||||
}
|
}
|
||||||
|
|
||||||
override async load(organization: Organization) {
|
override async load(organization: Organization) {
|
||||||
this.refreshBillingMetadata$.next(null);
|
|
||||||
await super.load(organization);
|
await super.load(organization);
|
||||||
}
|
}
|
||||||
|
|
||||||
async getUsers(organization: Organization): Promise<OrganizationUserView[]> {
|
async getUsers(organization: Organization): Promise<OrganizationUserView[]> {
|
||||||
let groupsPromise: Promise<Map<string, string>> | undefined;
|
return await this.memberService.loadUsers(organization);
|
||||||
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;
|
|
||||||
}) ?? []
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async getGroupNameMap(organization: Organization): Promise<Map<string, string>> {
|
async removeUser(id: string, organization: Organization): Promise<MemberActionResult> {
|
||||||
const groups = await this.groupService.getAll(organization.id);
|
return await this.memberActionsService.removeUser(organization, id);
|
||||||
const groupNameMap = new Map<string, string>();
|
|
||||||
groups.forEach((g) => groupNameMap.set(g.id, g.name));
|
|
||||||
return groupNameMap;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
async revokeUser(id: string, organization: Organization): Promise<MemberActionResult> {
|
||||||
* Retrieve a map of all collection IDs <-> names for the organization.
|
return await this.memberActionsService.revokeUser(organization, id);
|
||||||
*/
|
|
||||||
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$);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
removeUser(id: string, organization: Organization): Promise<void> {
|
async restoreUser(id: string, organization: Organization): Promise<MemberActionResult> {
|
||||||
return this.organizationUserApiService.removeOrganizationUser(organization.id, id);
|
return await this.memberActionsService.restoreUser(organization, id);
|
||||||
}
|
}
|
||||||
|
|
||||||
revokeUser(id: string, organization: Organization): Promise<void> {
|
async reinviteUser(id: string, organization: Organization): Promise<MemberActionResult> {
|
||||||
return this.organizationUserApiService.revokeOrganizationUser(organization.id, id);
|
return await this.memberActionsService.reinviteUser(organization, 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 confirmUser(
|
async confirmUser(
|
||||||
user: OrganizationUserView,
|
user: OrganizationUserView,
|
||||||
publicKey: Uint8Array,
|
publicKey: Uint8Array,
|
||||||
organization: Organization,
|
organization: Organization,
|
||||||
): Promise<void> {
|
): Promise<MemberActionResult> {
|
||||||
if (
|
return await this.memberActionsService.confirmUser(user, publicKey, organization);
|
||||||
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,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async revoke(user: OrganizationUserView, organization: Organization) {
|
async revoke(user: OrganizationUserView, organization: Organization) {
|
||||||
@@ -412,12 +240,16 @@ export class MembersComponent extends BaseMembersComponent<OrganizationUserView>
|
|||||||
|
|
||||||
this.actionPromise = this.revokeUser(user.id, organization);
|
this.actionPromise = this.revokeUser(user.id, organization);
|
||||||
try {
|
try {
|
||||||
await this.actionPromise;
|
const result = await this.actionPromise;
|
||||||
this.toastService.showToast({
|
if (result.success) {
|
||||||
variant: "success",
|
this.toastService.showToast({
|
||||||
message: this.i18nService.t("revokedUserId", this.userNamePipe.transform(user)),
|
variant: "success",
|
||||||
});
|
message: this.i18nService.t("revokedUserId", this.userNamePipe.transform(user)),
|
||||||
await this.load(organization);
|
});
|
||||||
|
await this.load(organization);
|
||||||
|
} else {
|
||||||
|
throw new Error(result.error);
|
||||||
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
this.validationService.showError(e);
|
this.validationService.showError(e);
|
||||||
}
|
}
|
||||||
@@ -427,198 +259,68 @@ export class MembersComponent extends BaseMembersComponent<OrganizationUserView>
|
|||||||
async restore(user: OrganizationUserView, organization: Organization) {
|
async restore(user: OrganizationUserView, organization: Organization) {
|
||||||
this.actionPromise = this.restoreUser(user.id, organization);
|
this.actionPromise = this.restoreUser(user.id, organization);
|
||||||
try {
|
try {
|
||||||
await this.actionPromise;
|
const result = await this.actionPromise;
|
||||||
this.toastService.showToast({
|
if (result.success) {
|
||||||
variant: "success",
|
this.toastService.showToast({
|
||||||
message: this.i18nService.t("restoredUserId", this.userNamePipe.transform(user)),
|
variant: "success",
|
||||||
});
|
message: this.i18nService.t("restoredUserId", this.userNamePipe.transform(user)),
|
||||||
await this.load(organization);
|
});
|
||||||
|
await this.load(organization);
|
||||||
|
} else {
|
||||||
|
throw new Error(result.error);
|
||||||
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
this.validationService.showError(e);
|
this.validationService.showError(e);
|
||||||
}
|
}
|
||||||
this.actionPromise = undefined;
|
this.actionPromise = undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
allowResetPassword(orgUser: OrganizationUserView, organization: Organization): boolean {
|
allowResetPassword(
|
||||||
let callingUserHasPermission = false;
|
orgUser: OrganizationUserView,
|
||||||
|
organization: Organization,
|
||||||
switch (organization.type) {
|
orgResetPasswordPolicyEnabled: boolean,
|
||||||
case OrganizationUserType.Owner:
|
): boolean {
|
||||||
callingUserHasPermission = true;
|
return this.memberActionsService.allowResetPassword(
|
||||||
break;
|
orgUser,
|
||||||
case OrganizationUserType.Admin:
|
organization,
|
||||||
callingUserHasPermission = orgUser.type !== OrganizationUserType.Owner;
|
orgResetPasswordPolicyEnabled,
|
||||||
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
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
showEnrolledStatus(
|
showEnrolledStatus(
|
||||||
orgUser: OrganizationUserUserDetailsResponse,
|
orgUser: OrganizationUserUserDetailsResponse,
|
||||||
organization: Organization,
|
organization: Organization,
|
||||||
|
orgResetPasswordPolicyEnabled: boolean,
|
||||||
): boolean {
|
): boolean {
|
||||||
return (
|
return (
|
||||||
organization.useResetPassword &&
|
organization.useResetPassword &&
|
||||||
orgUser.resetPasswordEnrolled &&
|
orgUser.resetPasswordEnrolled &&
|
||||||
this.orgResetPasswordPolicyEnabled
|
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))),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async handleInviteDialog(organization: Organization) {
|
private async handleInviteDialog(organization: Organization) {
|
||||||
const billingMetadata = await firstValueFrom(this.billingMetadata$);
|
const billingMetadata = await firstValueFrom(this.billingMetadata$);
|
||||||
const dialog = openUserAddEditDialog(this.dialogService, {
|
const allUserEmails = this.dataSource.data?.map((user) => user.email) ?? [];
|
||||||
data: {
|
|
||||||
kind: "Add",
|
|
||||||
organizationId: organization.id,
|
|
||||||
allOrganizationUserEmails: this.dataSource.data?.map((user) => user.email) ?? [],
|
|
||||||
occupiedSeatCount: billingMetadata?.organizationOccupiedSeats ?? 0,
|
|
||||||
isOnSecretsManagerStandalone: billingMetadata?.isOnSecretsManagerStandalone ?? false,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const result = await lastValueFrom(dialog.closed);
|
const result = await this.memberDialogManager.openInviteDialog(
|
||||||
|
organization,
|
||||||
|
billingMetadata,
|
||||||
|
allUserEmails,
|
||||||
|
);
|
||||||
|
|
||||||
if (result === MemberDialogResult.Saved) {
|
if (result === MemberDialogResult.Saved) {
|
||||||
await this.load(organization);
|
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) {
|
async invite(organization: Organization) {
|
||||||
const billingMetadata = await firstValueFrom(this.billingMetadata$);
|
const billingMetadata = await firstValueFrom(this.billingMetadata$);
|
||||||
if (
|
const seatLimitResult = this.billingConstraint.checkSeatLimit(organization, billingMetadata);
|
||||||
organization.hasReseller &&
|
if (!(await this.billingConstraint.seatLimitReached(seatLimitResult, organization))) {
|
||||||
organization.seats === billingMetadata?.organizationOccupiedSeats
|
await this.handleInviteDialog(organization);
|
||||||
) {
|
this.organizationMetadataService.refreshMetadataCache();
|
||||||
this.toastService.showToast({
|
|
||||||
variant: "error",
|
|
||||||
title: this.i18nService.t("seatLimitReached"),
|
|
||||||
message: this.i18nService.t("contactYourProvider"),
|
|
||||||
});
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (
|
|
||||||
billingMetadata?.organizationOccupiedSeats === organization.seats &&
|
|
||||||
isFixedSeatPlan(organization.productTierType)
|
|
||||||
) {
|
|
||||||
await this.handleSeatLimitForFixedTiers(organization);
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
await this.handleInviteDialog(organization);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async edit(
|
async edit(
|
||||||
@@ -627,20 +329,14 @@ export class MembersComponent extends BaseMembersComponent<OrganizationUserView>
|
|||||||
initialTab: MemberDialogTab = MemberDialogTab.Role,
|
initialTab: MemberDialogTab = MemberDialogTab.Role,
|
||||||
) {
|
) {
|
||||||
const billingMetadata = await firstValueFrom(this.billingMetadata$);
|
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) {
|
switch (result) {
|
||||||
case MemberDialogResult.Deleted:
|
case MemberDialogResult.Deleted:
|
||||||
this.dataSource.removeUser(user);
|
this.dataSource.removeUser(user);
|
||||||
@@ -658,43 +354,23 @@ export class MembersComponent extends BaseMembersComponent<OrganizationUserView>
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const dialogRef = BulkRemoveDialogComponent.open(this.dialogService, {
|
await this.memberDialogManager.openBulkRemoveDialog(
|
||||||
data: {
|
organization,
|
||||||
organizationId: organization.id,
|
this.dataSource.getCheckedUsers(),
|
||||||
users: this.dataSource.getCheckedUsers(),
|
);
|
||||||
},
|
this.organizationMetadataService.refreshMetadataCache();
|
||||||
});
|
|
||||||
await lastValueFrom(dialogRef.closed);
|
|
||||||
await this.load(organization);
|
await this.load(organization);
|
||||||
}
|
}
|
||||||
|
|
||||||
async bulkDelete(organization: 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) {
|
if (this.actionPromise != null) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const dialogRef = BulkDeleteDialogComponent.open(this.dialogService, {
|
await this.memberDialogManager.openBulkDeleteDialog(
|
||||||
data: {
|
organization,
|
||||||
organizationId: organization.id,
|
this.dataSource.getCheckedUsers(),
|
||||||
users: this.dataSource.getCheckedUsers(),
|
);
|
||||||
},
|
|
||||||
});
|
|
||||||
await lastValueFrom(dialogRef.closed);
|
|
||||||
await this.load(organization);
|
await this.load(organization);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -711,13 +387,11 @@ export class MembersComponent extends BaseMembersComponent<OrganizationUserView>
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const ref = BulkRestoreRevokeComponent.open(this.dialogService, {
|
await this.memberDialogManager.openBulkRestoreRevokeDialog(
|
||||||
organizationId: organization.id,
|
organization,
|
||||||
users: this.dataSource.getCheckedUsers(),
|
this.dataSource.getCheckedUsers(),
|
||||||
isRevoking: isRevoking,
|
isRevoking,
|
||||||
});
|
);
|
||||||
|
|
||||||
await firstValueFrom(ref.closed);
|
|
||||||
await this.load(organization);
|
await this.load(organization);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -739,20 +413,22 @@ export class MembersComponent extends BaseMembersComponent<OrganizationUserView>
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = this.organizationUserApiService.postManyOrganizationUserReinvite(
|
const result = await this.memberActionsService.bulkReinvite(
|
||||||
organization.id,
|
organization,
|
||||||
filteredUsers.map((user) => user.id),
|
filteredUsers.map((user) => user.id),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if (!result.successful) {
|
||||||
|
throw new Error();
|
||||||
|
}
|
||||||
|
|
||||||
// Bulk Status component open
|
// Bulk Status component open
|
||||||
const dialogRef = BulkStatusComponent.open(this.dialogService, {
|
await this.memberDialogManager.openBulkStatusDialog(
|
||||||
data: {
|
users,
|
||||||
users: users,
|
filteredUsers,
|
||||||
filteredUsers: filteredUsers,
|
Promise.resolve(result.successful),
|
||||||
request: response,
|
this.i18nService.t("bulkReinviteMessage"),
|
||||||
successfulMessage: this.i18nService.t("bulkReinviteMessage"),
|
);
|
||||||
},
|
|
||||||
});
|
|
||||||
await lastValueFrom(dialogRef.closed);
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
this.validationService.showError(e);
|
this.validationService.showError(e);
|
||||||
}
|
}
|
||||||
@@ -764,49 +440,24 @@ export class MembersComponent extends BaseMembersComponent<OrganizationUserView>
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const dialogRef = BulkConfirmDialogComponent.open(this.dialogService, {
|
await this.memberDialogManager.openBulkConfirmDialog(
|
||||||
data: {
|
organization,
|
||||||
organization: organization,
|
this.dataSource.getCheckedUsers(),
|
||||||
users: this.dataSource.getCheckedUsers(),
|
);
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
await lastValueFrom(dialogRef.closed);
|
|
||||||
await this.load(organization);
|
await this.load(organization);
|
||||||
}
|
}
|
||||||
|
|
||||||
async bulkEnableSM(organization: Organization) {
|
async bulkEnableSM(organization: Organization) {
|
||||||
const users = this.dataSource.getCheckedUsers().filter((ou) => !ou.accessSecretsManager);
|
const users = this.dataSource.getCheckedUsers();
|
||||||
|
|
||||||
if (users.length === 0) {
|
await this.memberDialogManager.openBulkEnableSecretsManagerDialog(organization, users);
|
||||||
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,
|
|
||||||
});
|
|
||||||
|
|
||||||
await lastValueFrom(dialogRef.closed);
|
|
||||||
this.dataSource.uncheckAllUsers();
|
this.dataSource.uncheckAllUsers();
|
||||||
await this.load(organization);
|
await this.load(organization);
|
||||||
}
|
}
|
||||||
|
|
||||||
openEventsDialog(user: OrganizationUserView, organization: Organization) {
|
openEventsDialog(user: OrganizationUserView, organization: Organization) {
|
||||||
openEntityEventsDialog(this.dialogService, {
|
this.memberDialogManager.openEventsDialog(user, organization);
|
||||||
data: {
|
|
||||||
name: this.userNamePipe.transform(user),
|
|
||||||
organizationId: organization.id,
|
|
||||||
entityId: user.id,
|
|
||||||
showUser: false,
|
|
||||||
entity: "user",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async resetPassword(user: OrganizationUserView, organization: Organization) {
|
async resetPassword(user: OrganizationUserView, organization: Organization) {
|
||||||
@@ -821,16 +472,7 @@ export class MembersComponent extends BaseMembersComponent<OrganizationUserView>
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const dialogRef = AccountRecoveryDialogComponent.open(this.dialogService, {
|
const result = await this.memberDialogManager.openAccountRecoveryDialog(user, organization);
|
||||||
data: {
|
|
||||||
name: this.userNamePipe.transform(user),
|
|
||||||
email: user.email,
|
|
||||||
organizationId: organization.id as OrganizationId,
|
|
||||||
organizationUserId: user.id,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const result = await lastValueFrom(dialogRef.closed);
|
|
||||||
if (result === AccountRecoveryDialogResultType.Ok) {
|
if (result === AccountRecoveryDialogResultType.Ok) {
|
||||||
await this.load(organization);
|
await this.load(organization);
|
||||||
}
|
}
|
||||||
@@ -839,91 +481,29 @@ export class MembersComponent extends BaseMembersComponent<OrganizationUserView>
|
|||||||
}
|
}
|
||||||
|
|
||||||
protected async removeUserConfirmationDialog(user: OrganizationUserView) {
|
protected async removeUserConfirmationDialog(user: OrganizationUserView) {
|
||||||
const content = user.usesKeyConnector
|
return await this.memberDialogManager.openRemoveUserConfirmationDialog(user);
|
||||||
? "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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
protected async revokeUserConfirmationDialog(user: OrganizationUserView) {
|
protected async revokeUserConfirmationDialog(user: OrganizationUserView) {
|
||||||
const confirmed = await this.dialogService.openSimpleDialog({
|
return await this.memberDialogManager.openRevokeUserConfirmationDialog(user);
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async deleteUser(user: OrganizationUserView, organization: Organization) {
|
async deleteUser(user: OrganizationUserView, organization: Organization) {
|
||||||
const warningAcknowledged = await firstValueFrom(
|
const confirmed = await this.memberDialogManager.openDeleteUserConfirmationDialog(
|
||||||
this.deleteManagedMemberWarningService.warningAcknowledged(organization.id),
|
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) {
|
if (!confirmed) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.deleteManagedMemberWarningService.acknowledgeWarning(organization.id);
|
this.actionPromise = this.memberActionsService.deleteUser(organization, user.id);
|
||||||
|
|
||||||
this.actionPromise = this.organizationUserApiService.deleteOrganizationUser(
|
|
||||||
organization.id,
|
|
||||||
user.id,
|
|
||||||
);
|
|
||||||
try {
|
try {
|
||||||
await this.actionPromise;
|
const result = await this.actionPromise;
|
||||||
|
if (!result.success) {
|
||||||
|
throw new Error(result.error);
|
||||||
|
}
|
||||||
this.toastService.showToast({
|
this.toastService.showToast({
|
||||||
variant: "success",
|
variant: "success",
|
||||||
message: this.i18nService.t("organizationUserDeleted", this.userNamePipe.transform(user)),
|
message: this.i18nService.t("organizationUserDeleted", this.userNamePipe.transform(user)),
|
||||||
@@ -935,19 +515,6 @@ export class MembersComponent extends BaseMembersComponent<OrganizationUserView>
|
|||||||
this.actionPromise = undefined;
|
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 {
|
get showBulkRestoreUsers(): boolean {
|
||||||
return this.dataSource
|
return this.dataSource
|
||||||
.getCheckedUsers()
|
.getCheckedUsers()
|
||||||
@@ -975,13 +542,4 @@ export class MembersComponent extends BaseMembersComponent<OrganizationUserView>
|
|||||||
.getCheckedUsers()
|
.getCheckedUsers()
|
||||||
.every((member) => member.managedByOrganization && validStatuses.includes(member.status));
|
.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 { PasswordStrengthV2Component } from "@bitwarden/angular/tools/password-strength/password-strength-v2.component";
|
||||||
import { PasswordCalloutComponent } from "@bitwarden/auth/angular";
|
import { PasswordCalloutComponent } from "@bitwarden/auth/angular";
|
||||||
import { ScrollLayoutDirective } from "@bitwarden/components";
|
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 { OrganizationFreeTrialWarningComponent } from "@bitwarden/web-vault/app/billing/organizations/warnings/components";
|
||||||
|
|
||||||
import { HeaderModule } from "../../../layouts/header/header.module";
|
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 { UserDialogModule } from "./components/member-dialog";
|
||||||
import { MembersRoutingModule } from "./members-routing.module";
|
import { MembersRoutingModule } from "./members-routing.module";
|
||||||
import { MembersComponent } from "./members.component";
|
import { MembersComponent } from "./members.component";
|
||||||
|
import {
|
||||||
|
OrganizationMembersService,
|
||||||
|
MemberActionsService,
|
||||||
|
MemberDialogManagerService,
|
||||||
|
} from "./services";
|
||||||
|
|
||||||
@NgModule({
|
@NgModule({
|
||||||
imports: [
|
imports: [
|
||||||
@@ -40,5 +46,11 @@ import { MembersComponent } from "./members.component";
|
|||||||
MembersComponent,
|
MembersComponent,
|
||||||
BulkDeleteDialogComponent,
|
BulkDeleteDialogComponent,
|
||||||
],
|
],
|
||||||
|
providers: [
|
||||||
|
OrganizationMembersService,
|
||||||
|
MemberActionsService,
|
||||||
|
BillingConstraintService,
|
||||||
|
MemberDialogManagerService,
|
||||||
|
],
|
||||||
})
|
})
|
||||||
export class MembersModule {}
|
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 },
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -30,6 +30,7 @@ import {
|
|||||||
} from "@bitwarden/web-vault/app/admin-console/common/people-table-data-source";
|
} from "@bitwarden/web-vault/app/admin-console/common/people-table-data-source";
|
||||||
import { openEntityEventsDialog } from "@bitwarden/web-vault/app/admin-console/organizations/manage/entity-events.component";
|
import { openEntityEventsDialog } from "@bitwarden/web-vault/app/admin-console/organizations/manage/entity-events.component";
|
||||||
import { BulkStatusComponent } from "@bitwarden/web-vault/app/admin-console/organizations/members/components/bulk/bulk-status.component";
|
import { BulkStatusComponent } from "@bitwarden/web-vault/app/admin-console/organizations/members/components/bulk/bulk-status.component";
|
||||||
|
import { MemberActionResult } from "@bitwarden/web-vault/app/admin-console/organizations/members/services/member-actions/member-actions.service";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
AddEditMemberDialogComponent,
|
AddEditMemberDialogComponent,
|
||||||
@@ -199,16 +200,27 @@ export class MembersComponent extends BaseMembersComponent<ProviderUser> {
|
|||||||
await this.load();
|
await this.load();
|
||||||
}
|
}
|
||||||
|
|
||||||
async confirmUser(user: ProviderUser, publicKey: Uint8Array): Promise<void> {
|
async confirmUser(user: ProviderUser, publicKey: Uint8Array): Promise<MemberActionResult> {
|
||||||
const providerKey = await this.keyService.getProviderKey(this.providerId);
|
try {
|
||||||
const key = await this.encryptService.encapsulateKeyUnsigned(providerKey, publicKey);
|
const providerKey = await this.keyService.getProviderKey(this.providerId);
|
||||||
const request = new ProviderUserConfirmRequest();
|
const key = await this.encryptService.encapsulateKeyUnsigned(providerKey, publicKey);
|
||||||
request.key = key.encryptedString;
|
const request = new ProviderUserConfirmRequest();
|
||||||
await this.apiService.postProviderUserConfirm(this.providerId, user.id, request);
|
request.key = key.encryptedString;
|
||||||
|
await this.apiService.postProviderUserConfirm(this.providerId, user.id, request);
|
||||||
|
return { success: true };
|
||||||
|
} catch (error) {
|
||||||
|
return { success: false, error: error.message };
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
removeUser = (id: string): Promise<void> =>
|
removeUser = async (id: string): Promise<MemberActionResult> => {
|
||||||
this.apiService.deleteProviderUser(this.providerId, id);
|
try {
|
||||||
|
await this.apiService.deleteProviderUser(this.providerId, id);
|
||||||
|
return { success: true };
|
||||||
|
} catch (error) {
|
||||||
|
return { success: false, error: error.message };
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
edit = async (user: ProviderUser | null): Promise<void> => {
|
edit = async (user: ProviderUser | null): Promise<void> => {
|
||||||
const data: AddEditMemberDialogParams = {
|
const data: AddEditMemberDialogParams = {
|
||||||
@@ -251,6 +263,12 @@ export class MembersComponent extends BaseMembersComponent<ProviderUser> {
|
|||||||
getUsers = (): Promise<ListResponse<ProviderUser>> =>
|
getUsers = (): Promise<ListResponse<ProviderUser>> =>
|
||||||
this.apiService.getProviderUsers(this.providerId);
|
this.apiService.getProviderUsers(this.providerId);
|
||||||
|
|
||||||
reinviteUser = (id: string): Promise<void> =>
|
reinviteUser = async (id: string): Promise<MemberActionResult> => {
|
||||||
this.apiService.postProviderUserReinvite(this.providerId, id);
|
try {
|
||||||
|
await this.apiService.postProviderUserReinvite(this.providerId, id);
|
||||||
|
return { success: true };
|
||||||
|
} catch (error) {
|
||||||
|
return { success: false, error: error.message };
|
||||||
|
}
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -208,7 +208,7 @@ describe("DefaultOrganizationMetadataService", () => {
|
|||||||
}, 10);
|
}, 10);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("does not trigger refresh when feature flag is disabled", async () => {
|
it("does trigger refresh when feature flag is disabled", async () => {
|
||||||
featureFlagSubject.next(false);
|
featureFlagSubject.next(false);
|
||||||
|
|
||||||
const mockResponse1 = createMockMetadataResponse(false, 10);
|
const mockResponse1 = createMockMetadataResponse(false, 10);
|
||||||
@@ -232,11 +232,10 @@ describe("DefaultOrganizationMetadataService", () => {
|
|||||||
|
|
||||||
service.refreshMetadataCache();
|
service.refreshMetadataCache();
|
||||||
|
|
||||||
// wait to ensure no additional invocations
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||||
|
|
||||||
expect(invocationCount).toBe(1);
|
expect(invocationCount).toBe(2);
|
||||||
expect(billingApiService.getOrganizationBillingMetadata).toHaveBeenCalledTimes(1);
|
expect(billingApiService.getOrganizationBillingMetadata).toHaveBeenCalledTimes(2);
|
||||||
|
|
||||||
subscription.unsubscribe();
|
subscription.unsubscribe();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { filter, from, merge, Observable, shareReplay, Subject, switchMap } from "rxjs";
|
import { BehaviorSubject, combineLatest, from, Observable, shareReplay, switchMap } from "rxjs";
|
||||||
|
|
||||||
import { BillingApiServiceAbstraction } from "@bitwarden/common/billing/abstractions";
|
import { BillingApiServiceAbstraction } from "@bitwarden/common/billing/abstractions";
|
||||||
|
|
||||||
@@ -18,57 +18,56 @@ export class DefaultOrganizationMetadataService implements OrganizationMetadataS
|
|||||||
private billingApiService: BillingApiServiceAbstraction,
|
private billingApiService: BillingApiServiceAbstraction,
|
||||||
private configService: ConfigService,
|
private configService: ConfigService,
|
||||||
) {}
|
) {}
|
||||||
private refreshMetadataTrigger = new Subject<void>();
|
private refreshMetadataTrigger = new BehaviorSubject<void>(undefined);
|
||||||
|
|
||||||
refreshMetadataCache = () => this.refreshMetadataTrigger.next();
|
refreshMetadataCache = () => {
|
||||||
|
this.metadataCache.clear();
|
||||||
|
this.refreshMetadataTrigger.next();
|
||||||
|
};
|
||||||
|
|
||||||
getOrganizationMetadata$ = (
|
getOrganizationMetadata$(orgId: OrganizationId): Observable<OrganizationBillingMetadataResponse> {
|
||||||
organizationId: OrganizationId,
|
return combineLatest([
|
||||||
): Observable<OrganizationBillingMetadataResponse> =>
|
this.refreshMetadataTrigger,
|
||||||
this.configService
|
this.configService.getFeatureFlag$(FeatureFlag.PM25379_UseNewOrganizationMetadataStructure),
|
||||||
.getFeatureFlag$(FeatureFlag.PM25379_UseNewOrganizationMetadataStructure)
|
]).pipe(
|
||||||
.pipe(
|
switchMap(([_, featureFlagEnabled]) =>
|
||||||
switchMap((featureFlagEnabled) => {
|
featureFlagEnabled
|
||||||
return merge(
|
? this.vNextGetOrganizationMetadataInternal$(orgId)
|
||||||
this.getOrganizationMetadataInternal$(organizationId, featureFlagEnabled),
|
: this.getOrganizationMetadataInternal$(orgId),
|
||||||
this.refreshMetadataTrigger.pipe(
|
),
|
||||||
filter(() => featureFlagEnabled),
|
);
|
||||||
switchMap(() =>
|
}
|
||||||
this.getOrganizationMetadataInternal$(organizationId, featureFlagEnabled, true),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
private getOrganizationMetadataInternal$(
|
private vNextGetOrganizationMetadataInternal$(
|
||||||
organizationId: OrganizationId,
|
orgId: OrganizationId,
|
||||||
featureFlagEnabled: boolean,
|
|
||||||
bypassCache: boolean = false,
|
|
||||||
): Observable<OrganizationBillingMetadataResponse> {
|
): Observable<OrganizationBillingMetadataResponse> {
|
||||||
if (!bypassCache && featureFlagEnabled && this.metadataCache.has(organizationId)) {
|
const cacheHit = this.metadataCache.get(orgId);
|
||||||
return this.metadataCache.get(organizationId)!;
|
if (cacheHit) {
|
||||||
|
return cacheHit;
|
||||||
}
|
}
|
||||||
|
|
||||||
const metadata$ = from(this.fetchMetadata(organizationId, featureFlagEnabled)).pipe(
|
const result = from(this.fetchMetadata(orgId, true)).pipe(
|
||||||
shareReplay({ bufferSize: 1, refCount: false }),
|
shareReplay({ bufferSize: 1, refCount: false }),
|
||||||
);
|
);
|
||||||
|
|
||||||
if (featureFlagEnabled) {
|
this.metadataCache.set(orgId, result);
|
||||||
this.metadataCache.set(organizationId, metadata$);
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
return metadata$;
|
private getOrganizationMetadataInternal$(
|
||||||
|
organizationId: OrganizationId,
|
||||||
|
): Observable<OrganizationBillingMetadataResponse> {
|
||||||
|
return from(this.fetchMetadata(organizationId, false)).pipe(
|
||||||
|
shareReplay({ bufferSize: 1, refCount: false }),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async fetchMetadata(
|
private async fetchMetadata(
|
||||||
organizationId: OrganizationId,
|
organizationId: OrganizationId,
|
||||||
featureFlagEnabled: boolean,
|
featureFlagEnabled: boolean,
|
||||||
): Promise<OrganizationBillingMetadataResponse> {
|
): Promise<OrganizationBillingMetadataResponse> {
|
||||||
if (featureFlagEnabled) {
|
return featureFlagEnabled
|
||||||
return await this.billingApiService.getOrganizationBillingMetadataVNext(organizationId);
|
? await this.billingApiService.getOrganizationBillingMetadataVNext(organizationId)
|
||||||
}
|
: await this.billingApiService.getOrganizationBillingMetadata(organizationId);
|
||||||
|
|
||||||
return await this.billingApiService.getOrganizationBillingMetadata(organizationId);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user