From 0691583b5059bc473065720a9930572b95f886f8 Mon Sep 17 00:00:00 2001 From: Brandon Treston Date: Thu, 23 Oct 2025 11:16:17 -0400 Subject: [PATCH] [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 --- .../common/base-members.component.ts | 57 +- .../members/members.component.html | 9 +- .../members/members.component.ts | 738 ++++-------------- .../organizations/members/members.module.ts | 12 + .../organizations/members/services/index.ts | 5 + .../member-actions.service.spec.ts | 463 +++++++++++ .../member-actions/member-actions.service.ts | 210 +++++ .../member-dialog-manager.service.spec.ts | 640 +++++++++++++++ .../member-dialog-manager.service.ts | 322 ++++++++ .../organization-members.service.spec.ts | 362 +++++++++ .../organization-members.service.ts | 76 ++ .../billing-constraint.service.spec.ts | 461 +++++++++++ .../billing-constraint.service.ts | 192 +++++ .../providers/manage/members.component.ts | 38 +- .../organization-metadata.service.spec.ts | 7 +- .../organization-metadata.service.ts | 73 +- 16 files changed, 2999 insertions(+), 666 deletions(-) create mode 100644 apps/web/src/app/admin-console/organizations/members/services/index.ts create mode 100644 apps/web/src/app/admin-console/organizations/members/services/member-actions/member-actions.service.spec.ts create mode 100644 apps/web/src/app/admin-console/organizations/members/services/member-actions/member-actions.service.ts create mode 100644 apps/web/src/app/admin-console/organizations/members/services/member-dialog-manager/member-dialog-manager.service.spec.ts create mode 100644 apps/web/src/app/admin-console/organizations/members/services/member-dialog-manager/member-dialog-manager.service.ts create mode 100644 apps/web/src/app/admin-console/organizations/members/services/organization-members-service/organization-members.service.spec.ts create mode 100644 apps/web/src/app/admin-console/organizations/members/services/organization-members-service/organization-members.service.ts create mode 100644 apps/web/src/app/billing/members/billing-constraint/billing-constraint.service.spec.ts create mode 100644 apps/web/src/app/billing/members/billing-constraint/billing-constraint.service.ts diff --git a/apps/web/src/app/admin-console/common/base-members.component.ts b/apps/web/src/app/admin-console/common/base-members.component.ts index 21c52949254..5ecf4269a1a 100644 --- a/apps/web/src/app/admin-console/common/base-members.component.ts +++ b/apps/web/src/app/admin-console/common/base-members.component.ts @@ -24,6 +24,7 @@ import { KeyService } from "@bitwarden/key-management"; import { OrganizationUserView } from "../organizations/core/views/organization-user.view"; import { UserConfirmComponent } from "../organizations/manage/user-confirm.component"; +import { MemberActionResult } from "../organizations/members/services/member-actions/member-actions.service"; import { PeopleTableDataSource, peopleFilter } from "./people-table-data-source"; @@ -75,7 +76,7 @@ export abstract class BaseMembersComponent { /** * The currently executing promise - used to avoid multiple user actions executing at once. */ - actionPromise?: Promise; + actionPromise?: Promise; protected searchControl = new FormControl("", { nonNullable: true }); protected statusToggle = new BehaviorSubject(undefined); @@ -101,13 +102,13 @@ export abstract class BaseMembersComponent { abstract edit(user: UserView, organization?: Organization): void; abstract getUsers(organization?: Organization): Promise | UserView[]>; - abstract removeUser(id: string, organization?: Organization): Promise; - abstract reinviteUser(id: string, organization?: Organization): Promise; + abstract removeUser(id: string, organization?: Organization): Promise; + abstract reinviteUser(id: string, organization?: Organization): Promise; abstract confirmUser( user: UserView, publicKey: Uint8Array, organization?: Organization, - ): Promise; + ): Promise; abstract invite(organization?: Organization): void; async load(organization?: Organization) { @@ -140,12 +141,16 @@ export abstract class BaseMembersComponent { this.actionPromise = this.removeUser(user.id, organization); try { - await this.actionPromise; - this.toastService.showToast({ - variant: "success", - message: this.i18nService.t("removedUserId", this.userNamePipe.transform(user)), - }); - this.dataSource.removeUser(user); + const result = await this.actionPromise; + if (result.success) { + this.toastService.showToast({ + variant: "success", + message: this.i18nService.t("removedUserId", this.userNamePipe.transform(user)), + }); + this.dataSource.removeUser(user); + } else { + throw new Error(result.error); + } } catch (e) { this.validationService.showError(e); } @@ -159,11 +164,15 @@ export abstract class BaseMembersComponent { this.actionPromise = this.reinviteUser(user.id, organization); try { - await this.actionPromise; - this.toastService.showToast({ - variant: "success", - message: this.i18nService.t("hasBeenReinvited", this.userNamePipe.transform(user)), - }); + const result = await this.actionPromise; + if (result.success) { + this.toastService.showToast({ + variant: "success", + message: this.i18nService.t("hasBeenReinvited", this.userNamePipe.transform(user)), + }); + } else { + throw new Error(result.error); + } } catch (e) { this.validationService.showError(e); } @@ -174,14 +183,18 @@ export abstract class BaseMembersComponent { const confirmUser = async (publicKey: Uint8Array) => { try { this.actionPromise = this.confirmUser(user, publicKey, organization); - await this.actionPromise; - user.status = this.userStatusType.Confirmed; - this.dataSource.replaceUser(user); + const result = await this.actionPromise; + if (result.success) { + user.status = this.userStatusType.Confirmed; + this.dataSource.replaceUser(user); - this.toastService.showToast({ - variant: "success", - message: this.i18nService.t("hasBeenConfirmed", this.userNamePipe.transform(user)), - }); + this.toastService.showToast({ + variant: "success", + message: this.i18nService.t("hasBeenConfirmed", this.userNamePipe.transform(user)), + }); + } else { + throw new Error(result.error); + } } catch (e) { this.validationService.showError(e); throw e; diff --git a/apps/web/src/app/admin-console/organizations/members/members.component.html b/apps/web/src/app/admin-console/organizations/members/members.component.html index 282291eb60e..9401a88ab76 100644 --- a/apps/web/src/app/admin-console/organizations/members/members.component.html +++ b/apps/web/src/app/admin-console/organizations/members/members.component.html @@ -2,7 +2,7 @@ @if (organization) { @@ -339,7 +339,10 @@ > {{ "userUsingTwoStep" | i18n }} - + @let resetPasswordPolicyEnabled = resetPasswordPolicyEnabled$ | async; + {{ "recoverAccount" | i18n }} diff --git a/apps/web/src/app/admin-console/organizations/members/members.component.ts b/apps/web/src/app/admin-console/organizations/members/members.component.ts index 3841f6d5b4b..324452499dc 100644 --- a/apps/web/src/app/admin-console/organizations/members/members.component.ts +++ b/apps/web/src/app/admin-console/organizations/members/members.component.ts @@ -1,14 +1,12 @@ import { Component, computed, Signal } from "@angular/core"; import { takeUntilDestroyed, toSignal } from "@angular/core/rxjs-interop"; -import { ActivatedRoute, Router } from "@angular/router"; +import { ActivatedRoute } from "@angular/router"; import { - BehaviorSubject, combineLatest, concatMap, filter, firstValueFrom, from, - lastValueFrom, map, merge, Observable, @@ -17,24 +15,12 @@ import { take, } from "rxjs"; -import { - OrganizationUserApiService, - OrganizationUserConfirmRequest, - OrganizationUserUserDetailsResponse, - CollectionService, - CollectionData, - Collection, - CollectionDetailsResponse, -} from "@bitwarden/admin-console/common"; +import { OrganizationUserUserDetailsResponse } from "@bitwarden/admin-console/common"; import { UserNamePipe } from "@bitwarden/angular/pipes/user-name.pipe"; import { ApiService } from "@bitwarden/common/abstractions/api.service"; -import { OrganizationApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/organization/organization-api.service.abstraction"; -import { - getOrganizationById, - OrganizationService, -} from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; +import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; import { OrganizationManagementPreferencesService } from "@bitwarden/common/admin-console/abstractions/organization-management-preferences/organization-management-preferences.service"; -import { PolicyApiServiceAbstraction as PolicyApiService } from "@bitwarden/common/admin-console/abstractions/policy/policy-api.service.abstraction"; +import { PolicyApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/policy/policy-api.service.abstraction"; import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; import { OrganizationUserStatusType, @@ -43,53 +29,32 @@ import { } from "@bitwarden/common/admin-console/enums"; import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; import { Policy } from "@bitwarden/common/admin-console/models/domain/policy"; -import { OrganizationKeysRequest } from "@bitwarden/common/admin-console/models/request/organization-keys.request"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { getUserId } from "@bitwarden/common/auth/services/account.service"; -import { BillingApiServiceAbstraction } from "@bitwarden/common/billing/abstractions/billing-api.service.abstraction"; import { OrganizationMetadataServiceAbstraction } from "@bitwarden/common/billing/abstractions/organization-metadata.service.abstraction"; -import { isNotSelfUpgradable, ProductTierType } from "@bitwarden/common/billing/enums"; import { OrganizationBillingMetadataResponse } from "@bitwarden/common/billing/models/response/organization-billing-metadata.response"; -import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; -import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service"; -import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; import { ValidationService } from "@bitwarden/common/platform/abstractions/validation.service"; -import { OrganizationId, UserId } from "@bitwarden/common/types/guid"; -import { SyncService } from "@bitwarden/common/vault/abstractions/sync/sync.service.abstraction"; -import { DialogService, SimpleDialogOptions, ToastService } from "@bitwarden/components"; +import { getById } from "@bitwarden/common/platform/misc"; +import { DialogService, ToastService } from "@bitwarden/components"; import { KeyService } from "@bitwarden/key-management"; +import { UserId } from "@bitwarden/user-core"; +import { BillingConstraintService } from "@bitwarden/web-vault/app/billing/members/billing-constraint/billing-constraint.service"; import { OrganizationWarningsService } from "@bitwarden/web-vault/app/billing/organizations/warnings/services"; -import { - ChangePlanDialogResultType, - openChangePlanDialog, -} from "../../../billing/organizations/change-plan-dialog.component"; import { BaseMembersComponent } from "../../common/base-members.component"; import { PeopleTableDataSource } from "../../common/people-table-data-source"; -import { GroupApiService } from "../core"; import { OrganizationUserView } from "../core/views/organization-user.view"; -import { openEntityEventsDialog } from "../manage/entity-events.component"; -import { - AccountRecoveryDialogComponent, - AccountRecoveryDialogResultType, -} from "./components/account-recovery/account-recovery-dialog.component"; -import { BulkConfirmDialogComponent } from "./components/bulk/bulk-confirm-dialog.component"; -import { BulkDeleteDialogComponent } from "./components/bulk/bulk-delete-dialog.component"; -import { BulkEnableSecretsManagerDialogComponent } from "./components/bulk/bulk-enable-sm-dialog.component"; -import { BulkRemoveDialogComponent } from "./components/bulk/bulk-remove-dialog.component"; -import { BulkRestoreRevokeComponent } from "./components/bulk/bulk-restore-revoke.component"; -import { BulkStatusComponent } from "./components/bulk/bulk-status.component"; -import { - MemberDialogResult, - MemberDialogTab, - openUserAddEditDialog, -} from "./components/member-dialog"; -import { isFixedSeatPlan } from "./components/member-dialog/validators/org-seat-limit-reached.validator"; +import { AccountRecoveryDialogResultType } from "./components/account-recovery/account-recovery-dialog.component"; +import { MemberDialogResult, MemberDialogTab } from "./components/member-dialog"; +import { MemberDialogManagerService, OrganizationMembersService } from "./services"; import { DeleteManagedMemberWarningService } from "./services/delete-managed-member/delete-managed-member-warning.service"; -import { OrganizationUserService } from "./services/organization-user/organization-user.service"; +import { + MemberActionsService, + MemberActionResult, +} from "./services/member-actions/member-actions.service"; class MembersTableDataSource extends PeopleTableDataSource { protected statusType = OrganizationUserStatusType; @@ -107,7 +72,10 @@ export class MembersComponent extends BaseMembersComponent readonly organization: Signal; status: OrganizationUserStatusType | undefined; - orgResetPasswordPolicyEnabled = false; + + private userId$: Observable = this.accountService.activeAccount$.pipe(getUserId); + + resetPasswordPolicyEnabled$: Observable; protected readonly canUseSecretsManager: Signal = computed( () => this.organization()?.useSecretsManager ?? false, @@ -115,43 +83,34 @@ export class MembersComponent extends BaseMembersComponent protected readonly showUserManagementControls: Signal = computed( () => this.organization()?.canManageUsers ?? false, ); - private refreshBillingMetadata$: BehaviorSubject = new BehaviorSubject(null); protected billingMetadata$: Observable; // Fixed sizes used for cdkVirtualScroll protected rowHeight = 66; protected rowHeightClass = `tw-h-[66px]`; - private userId$: Observable = this.accountService.activeAccount$.pipe(getUserId); - constructor( apiService: ApiService, i18nService: I18nService, organizationManagementPreferencesService: OrganizationManagementPreferencesService, keyService: KeyService, - private encryptService: EncryptService, validationService: ValidationService, logService: LogService, userNamePipe: UserNamePipe, dialogService: DialogService, toastService: ToastService, - private policyService: PolicyService, - private policyApiService: PolicyApiService, private route: ActivatedRoute, - private syncService: SyncService, + protected deleteManagedMemberWarningService: DeleteManagedMemberWarningService, + private organizationWarningsService: OrganizationWarningsService, + private memberActionsService: MemberActionsService, + private memberDialogManager: MemberDialogManagerService, + protected billingConstraint: BillingConstraintService, + protected memberService: OrganizationMembersService, private organizationService: OrganizationService, private accountService: AccountService, - private organizationApiService: OrganizationApiServiceAbstraction, - private organizationUserApiService: OrganizationUserApiService, - private router: Router, - private groupService: GroupApiService, - private collectionService: CollectionService, - private billingApiService: BillingApiServiceAbstraction, + private policyService: PolicyService, + private policyApiService: PolicyApiServiceAbstraction, private organizationMetadataService: OrganizationMetadataServiceAbstraction, - protected deleteManagedMemberWarningService: DeleteManagedMemberWarningService, - private configService: ConfigService, - private organizationUserService: OrganizationUserService, - private organizationWarningsService: OrganizationWarningsService, ) { super( apiService, @@ -169,14 +128,12 @@ export class MembersComponent extends BaseMembersComponent concatMap((params) => this.userId$.pipe( switchMap((userId) => - this.organizationService - .organizations$(userId) - .pipe(getOrganizationById(params.organizationId)), + this.organizationService.organizations$(userId).pipe(getById(params.organizationId)), ), + filter((organization): organization is Organization => organization != null), + shareReplay({ refCount: true, bufferSize: 1 }), ), ), - filter((organization): organization is Organization => organization != null), - shareReplay({ refCount: true, bufferSize: 1 }), ); this.organization = toSignal(organization$); @@ -191,53 +148,26 @@ export class MembersComponent extends BaseMembersComponent ), ); - combineLatest([this.route.queryParams, policies$, organization$]) - .pipe( - concatMap(async ([qParams, policies, organization]) => { - // Backfill pub/priv key if necessary - if (organization.canManageUsersPassword && !organization.hasPublicAndPrivateKeys) { - const orgShareKey = await firstValueFrom( - this.userId$.pipe( - switchMap((userId) => this.keyService.orgKeys$(userId)), - map((orgKeys) => { - if (orgKeys == null || orgKeys[organization.id] == null) { - throw new Error("Organization keys not found for provided User."); - } - return orgKeys[organization.id]; - }), - ), - ); - - const [orgPublicKey, encryptedOrgPrivateKey] = - await this.keyService.makeKeyPair(orgShareKey); - if (encryptedOrgPrivateKey.encryptedString == null) { - throw new Error("Encrypted private key is null."); - } - const request = new OrganizationKeysRequest( - orgPublicKey, - encryptedOrgPrivateKey.encryptedString, - ); - const response = await this.organizationApiService.updateKeys(organization.id, request); - if (response != null) { - await this.syncService.fullSync(true); // Replace organizations with new data - } else { - throw new Error(this.i18nService.t("resetPasswordOrgKeysError")); - } - } - - const resetPasswordPolicy = policies + this.resetPasswordPolicyEnabled$ = combineLatest([organization$, policies$]).pipe( + map( + ([organization, policies]) => + policies .filter((policy) => policy.type === PolicyType.ResetPassword) - .find((p) => p.organizationId === organization.id); - this.orgResetPasswordPolicyEnabled = resetPasswordPolicy?.enabled ?? false; + .find((p) => p.organizationId === organization.id)?.enabled ?? false, + ), + ); - await this.load(organization); + combineLatest([this.route.queryParams, organization$]) + .pipe( + concatMap(async ([qParams, organization]) => { + await this.load(organization!); this.searchControl.setValue(qParams.search); if (qParams.viewEvents != null) { const user = this.dataSource.data.filter((u) => u.id === qParams.viewEvents); if (user.length > 0 && user[0].status === OrganizationUserStatusType.Confirmed) { - this.openEventsDialog(user[0], organization); + this.openEventsDialog(user[0], organization!); } } }), @@ -257,11 +187,10 @@ export class MembersComponent extends BaseMembersComponent ) .subscribe(); - this.billingMetadata$ = combineLatest([this.refreshBillingMetadata$, organization$]).pipe( - switchMap(([_, organization]) => + this.billingMetadata$ = organization$.pipe( + switchMap((organization) => this.organizationMetadataService.getOrganizationMetadata$(organization.id), ), - takeUntilDestroyed(), shareReplay({ bufferSize: 1, refCount: false }), ); @@ -271,136 +200,35 @@ export class MembersComponent extends BaseMembersComponent } override async load(organization: Organization) { - this.refreshBillingMetadata$.next(null); await super.load(organization); } async getUsers(organization: Organization): Promise { - let groupsPromise: Promise> | undefined; - let collectionsPromise: Promise> | 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((r) => { - const userView = OrganizationUserView.fromResponse(r); - - userView.groupNames = userView.groups - .map((g) => groupNamesMap?.get(g)) - .filter((name): name is string => name != null) - .sort(this.i18nService.collator?.compare); - userView.collectionNames = userView.collections - .map((c) => collectionNamesMap?.get(c.id)) - .filter((name): name is string => name != null) - .sort(this.i18nService.collator?.compare); - - return userView; - }) ?? [] - ); + return await this.memberService.loadUsers(organization); } - async getGroupNameMap(organization: Organization): Promise> { - const groups = await this.groupService.getAll(organization.id); - const groupNameMap = new Map(); - groups.forEach((g) => groupNameMap.set(g.id, g.name)); - return groupNameMap; + async removeUser(id: string, organization: Organization): Promise { + return await this.memberActionsService.removeUser(organization, id); } - /** - * Retrieve a map of all collection IDs <-> names for the organization. - */ - async getCollectionNameMap(organization: Organization) { - const response = from(this.apiService.getCollections(organization.id)).pipe( - map((res) => - res.data.map((r) => - Collection.fromCollectionData(new CollectionData(r as CollectionDetailsResponse)), - ), - ), - ); - - const decryptedCollections$ = combineLatest([ - this.userId$.pipe( - switchMap((userId) => this.keyService.orgKeys$(userId)), - filter((orgKeys) => orgKeys != null), - ), - response, - ]).pipe( - switchMap(([orgKeys, collections]) => - this.collectionService.decryptMany$(collections, orgKeys), - ), - map((collections) => { - const collectionMap = new Map(); - collections.forEach((c) => collectionMap.set(c.id, c.name)); - return collectionMap; - }), - ); - - return await firstValueFrom(decryptedCollections$); + async revokeUser(id: string, organization: Organization): Promise { + return await this.memberActionsService.revokeUser(organization, id); } - removeUser(id: string, organization: Organization): Promise { - return this.organizationUserApiService.removeOrganizationUser(organization.id, id); + async restoreUser(id: string, organization: Organization): Promise { + return await this.memberActionsService.restoreUser(organization, id); } - revokeUser(id: string, organization: Organization): Promise { - return this.organizationUserApiService.revokeOrganizationUser(organization.id, id); - } - - restoreUser(id: string, organization: Organization): Promise { - return this.organizationUserApiService.restoreOrganizationUser(organization.id, id); - } - - reinviteUser(id: string, organization: Organization): Promise { - return this.organizationUserApiService.postOrganizationUserReinvite(organization.id, id); + async reinviteUser(id: string, organization: Organization): Promise { + return await this.memberActionsService.reinviteUser(organization, id); } async confirmUser( user: OrganizationUserView, publicKey: Uint8Array, organization: Organization, - ): Promise { - if ( - await firstValueFrom(this.configService.getFeatureFlag$(FeatureFlag.CreateDefaultLocation)) - ) { - await firstValueFrom(this.organizationUserService.confirmUser(organization, user, publicKey)); - } else { - const request = await firstValueFrom( - this.userId$.pipe( - switchMap((userId) => this.keyService.orgKeys$(userId)), - filter((orgKeys) => orgKeys != null), - map((orgKeys) => orgKeys[organization.id]), - switchMap((orgKey) => this.encryptService.encapsulateKeyUnsigned(orgKey, publicKey)), - map((encKey) => { - const req = new OrganizationUserConfirmRequest(); - req.key = encKey.encryptedString; - return req; - }), - ), - ); - - await this.organizationUserApiService.postOrganizationUserConfirm( - organization.id, - user.id, - request, - ); - } + ): Promise { + return await this.memberActionsService.confirmUser(user, publicKey, organization); } async revoke(user: OrganizationUserView, organization: Organization) { @@ -412,12 +240,16 @@ export class MembersComponent extends BaseMembersComponent this.actionPromise = this.revokeUser(user.id, organization); try { - await this.actionPromise; - this.toastService.showToast({ - variant: "success", - message: this.i18nService.t("revokedUserId", this.userNamePipe.transform(user)), - }); - await this.load(organization); + const result = await this.actionPromise; + if (result.success) { + this.toastService.showToast({ + variant: "success", + message: this.i18nService.t("revokedUserId", this.userNamePipe.transform(user)), + }); + await this.load(organization); + } else { + throw new Error(result.error); + } } catch (e) { this.validationService.showError(e); } @@ -427,198 +259,68 @@ export class MembersComponent extends BaseMembersComponent async restore(user: OrganizationUserView, organization: Organization) { this.actionPromise = this.restoreUser(user.id, organization); try { - await this.actionPromise; - this.toastService.showToast({ - variant: "success", - message: this.i18nService.t("restoredUserId", this.userNamePipe.transform(user)), - }); - await this.load(organization); + const result = await this.actionPromise; + if (result.success) { + this.toastService.showToast({ + variant: "success", + message: this.i18nService.t("restoredUserId", this.userNamePipe.transform(user)), + }); + await this.load(organization); + } else { + throw new Error(result.error); + } } catch (e) { this.validationService.showError(e); } this.actionPromise = undefined; } - allowResetPassword(orgUser: OrganizationUserView, organization: Organization): boolean { - let callingUserHasPermission = false; - - switch (organization.type) { - case OrganizationUserType.Owner: - callingUserHasPermission = true; - break; - case OrganizationUserType.Admin: - callingUserHasPermission = orgUser.type !== OrganizationUserType.Owner; - break; - case OrganizationUserType.Custom: - callingUserHasPermission = - orgUser.type !== OrganizationUserType.Owner && - orgUser.type !== OrganizationUserType.Admin; - break; - } - - return ( - organization.canManageUsersPassword && - callingUserHasPermission && - organization.useResetPassword && - organization.hasPublicAndPrivateKeys && - orgUser.resetPasswordEnrolled && - this.orgResetPasswordPolicyEnabled && - orgUser.status === OrganizationUserStatusType.Confirmed + allowResetPassword( + orgUser: OrganizationUserView, + organization: Organization, + orgResetPasswordPolicyEnabled: boolean, + ): boolean { + return this.memberActionsService.allowResetPassword( + orgUser, + organization, + orgResetPasswordPolicyEnabled, ); } showEnrolledStatus( orgUser: OrganizationUserUserDetailsResponse, organization: Organization, + orgResetPasswordPolicyEnabled: boolean, ): boolean { return ( organization.useResetPassword && orgUser.resetPasswordEnrolled && - this.orgResetPasswordPolicyEnabled - ); - } - - private getManageBillingText(organization: Organization): string { - return organization.canEditSubscription ? "ManageBilling" : "NoManageBilling"; - } - - private getProductKey(organization: Organization): string { - let product = ""; - switch (organization.productTierType) { - case ProductTierType.Free: - product = "freeOrg"; - break; - case ProductTierType.TeamsStarter: - product = "teamsStarterPlan"; - break; - case ProductTierType.Families: - product = "familiesPlan"; - break; - default: - throw new Error(`Unsupported product type: ${organization.productTierType}`); - } - return `${product}InvLimitReached${this.getManageBillingText(organization)}`; - } - - private getDialogContent(organization: Organization): string { - return this.i18nService.t(this.getProductKey(organization), organization.seats); - } - - private getAcceptButtonText(organization: Organization): string { - if (!organization.canEditSubscription) { - return this.i18nService.t("ok"); - } - - const productType = organization.productTierType; - - if (isNotSelfUpgradable(productType)) { - throw new Error(`Unsupported product type: ${productType}`); - } - - return this.i18nService.t("upgrade"); - } - - private async handleDialogClose( - result: boolean | undefined, - organization: Organization, - ): Promise { - 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 { - const orgUpgradeSimpleDialogOpts: SimpleDialogOptions = { - title: this.i18nService.t("upgradeOrganization"), - content: this.getDialogContent(organization), - type: "primary", - acceptButtonText: this.getAcceptButtonText(organization), - }; - - if (!organization.canEditSubscription) { - orgUpgradeSimpleDialogOpts.cancelButtonText = null; - } - - const simpleDialog = this.dialogService.openSimpleDialogRef(orgUpgradeSimpleDialogOpts); - await lastValueFrom( - simpleDialog.closed.pipe(map((closed) => this.handleDialogClose(closed, organization))), + orgResetPasswordPolicyEnabled ); } private async handleInviteDialog(organization: Organization) { const billingMetadata = await firstValueFrom(this.billingMetadata$); - const dialog = openUserAddEditDialog(this.dialogService, { - data: { - kind: "Add", - organizationId: organization.id, - allOrganizationUserEmails: this.dataSource.data?.map((user) => user.email) ?? [], - occupiedSeatCount: billingMetadata?.organizationOccupiedSeats ?? 0, - isOnSecretsManagerStandalone: billingMetadata?.isOnSecretsManagerStandalone ?? false, - }, - }); + const allUserEmails = this.dataSource.data?.map((user) => user.email) ?? []; - const result = await lastValueFrom(dialog.closed); + const result = await this.memberDialogManager.openInviteDialog( + organization, + billingMetadata, + allUserEmails, + ); if (result === MemberDialogResult.Saved) { await this.load(organization); } } - private async handleSeatLimitForFixedTiers(organization: Organization) { - if (!organization.canEditSubscription) { - await this.showSeatLimitReachedDialog(organization); - return; - } - - const reference = openChangePlanDialog(this.dialogService, { - data: { - organizationId: organization.id, - productTierType: organization.productTierType, - }, - }); - - const result = await lastValueFrom(reference.closed); - - if (result === ChangePlanDialogResultType.Submitted) { - await this.load(organization); - } - } - async invite(organization: Organization) { const billingMetadata = await firstValueFrom(this.billingMetadata$); - if ( - organization.hasReseller && - organization.seats === billingMetadata?.organizationOccupiedSeats - ) { - this.toastService.showToast({ - variant: "error", - title: this.i18nService.t("seatLimitReached"), - message: this.i18nService.t("contactYourProvider"), - }); - - return; + const seatLimitResult = this.billingConstraint.checkSeatLimit(organization, billingMetadata); + if (!(await this.billingConstraint.seatLimitReached(seatLimitResult, organization))) { + await this.handleInviteDialog(organization); + this.organizationMetadataService.refreshMetadataCache(); } - - if ( - billingMetadata?.organizationOccupiedSeats === organization.seats && - isFixedSeatPlan(organization.productTierType) - ) { - await this.handleSeatLimitForFixedTiers(organization); - - return; - } - - await this.handleInviteDialog(organization); } async edit( @@ -627,20 +329,14 @@ export class MembersComponent extends BaseMembersComponent initialTab: MemberDialogTab = MemberDialogTab.Role, ) { const billingMetadata = await firstValueFrom(this.billingMetadata$); - const dialog = openUserAddEditDialog(this.dialogService, { - data: { - kind: "Edit", - name: this.userNamePipe.transform(user), - organizationId: organization.id, - organizationUserId: user.id, - usesKeyConnector: user.usesKeyConnector, - isOnSecretsManagerStandalone: billingMetadata?.isOnSecretsManagerStandalone ?? false, - initialTab: initialTab, - managedByOrganization: user.managedByOrganization, - }, - }); - const result = await lastValueFrom(dialog.closed); + const result = await this.memberDialogManager.openEditDialog( + user, + organization, + billingMetadata, + initialTab, + ); + switch (result) { case MemberDialogResult.Deleted: this.dataSource.removeUser(user); @@ -658,43 +354,23 @@ export class MembersComponent extends BaseMembersComponent return; } - const dialogRef = BulkRemoveDialogComponent.open(this.dialogService, { - data: { - organizationId: organization.id, - users: this.dataSource.getCheckedUsers(), - }, - }); - await lastValueFrom(dialogRef.closed); + await this.memberDialogManager.openBulkRemoveDialog( + organization, + this.dataSource.getCheckedUsers(), + ); + this.organizationMetadataService.refreshMetadataCache(); await this.load(organization); } async bulkDelete(organization: Organization) { - const warningAcknowledged = await firstValueFrom( - this.deleteManagedMemberWarningService.warningAcknowledged(organization.id), - ); - - if ( - !warningAcknowledged && - organization.canManageUsers && - organization.productTierType === ProductTierType.Enterprise - ) { - const acknowledged = await this.deleteManagedMemberWarningService.showWarning(); - if (!acknowledged) { - return; - } - } - if (this.actionPromise != null) { return; } - const dialogRef = BulkDeleteDialogComponent.open(this.dialogService, { - data: { - organizationId: organization.id, - users: this.dataSource.getCheckedUsers(), - }, - }); - await lastValueFrom(dialogRef.closed); + await this.memberDialogManager.openBulkDeleteDialog( + organization, + this.dataSource.getCheckedUsers(), + ); await this.load(organization); } @@ -711,13 +387,11 @@ export class MembersComponent extends BaseMembersComponent return; } - const ref = BulkRestoreRevokeComponent.open(this.dialogService, { - organizationId: organization.id, - users: this.dataSource.getCheckedUsers(), - isRevoking: isRevoking, - }); - - await firstValueFrom(ref.closed); + await this.memberDialogManager.openBulkRestoreRevokeDialog( + organization, + this.dataSource.getCheckedUsers(), + isRevoking, + ); await this.load(organization); } @@ -739,20 +413,22 @@ export class MembersComponent extends BaseMembersComponent } try { - const response = this.organizationUserApiService.postManyOrganizationUserReinvite( - organization.id, + const result = await this.memberActionsService.bulkReinvite( + organization, filteredUsers.map((user) => user.id), ); + + if (!result.successful) { + throw new Error(); + } + // Bulk Status component open - const dialogRef = BulkStatusComponent.open(this.dialogService, { - data: { - users: users, - filteredUsers: filteredUsers, - request: response, - successfulMessage: this.i18nService.t("bulkReinviteMessage"), - }, - }); - await lastValueFrom(dialogRef.closed); + await this.memberDialogManager.openBulkStatusDialog( + users, + filteredUsers, + Promise.resolve(result.successful), + this.i18nService.t("bulkReinviteMessage"), + ); } catch (e) { this.validationService.showError(e); } @@ -764,49 +440,24 @@ export class MembersComponent extends BaseMembersComponent return; } - const dialogRef = BulkConfirmDialogComponent.open(this.dialogService, { - data: { - organization: organization, - users: this.dataSource.getCheckedUsers(), - }, - }); - - await lastValueFrom(dialogRef.closed); + await this.memberDialogManager.openBulkConfirmDialog( + organization, + this.dataSource.getCheckedUsers(), + ); await this.load(organization); } async bulkEnableSM(organization: Organization) { - const users = this.dataSource.getCheckedUsers().filter((ou) => !ou.accessSecretsManager); + const users = this.dataSource.getCheckedUsers(); - if (users.length === 0) { - this.toastService.showToast({ - variant: "error", - title: this.i18nService.t("errorOccurred"), - message: this.i18nService.t("noSelectedUsersApplicable"), - }); - return; - } + await this.memberDialogManager.openBulkEnableSecretsManagerDialog(organization, users); - const dialogRef = BulkEnableSecretsManagerDialogComponent.open(this.dialogService, { - orgId: organization.id, - users, - }); - - await lastValueFrom(dialogRef.closed); this.dataSource.uncheckAllUsers(); await this.load(organization); } openEventsDialog(user: OrganizationUserView, organization: Organization) { - openEntityEventsDialog(this.dialogService, { - data: { - name: this.userNamePipe.transform(user), - organizationId: organization.id, - entityId: user.id, - showUser: false, - entity: "user", - }, - }); + this.memberDialogManager.openEventsDialog(user, organization); } async resetPassword(user: OrganizationUserView, organization: Organization) { @@ -821,16 +472,7 @@ export class MembersComponent extends BaseMembersComponent return; } - const dialogRef = AccountRecoveryDialogComponent.open(this.dialogService, { - data: { - name: this.userNamePipe.transform(user), - email: user.email, - organizationId: organization.id as OrganizationId, - organizationUserId: user.id, - }, - }); - - const result = await lastValueFrom(dialogRef.closed); + const result = await this.memberDialogManager.openAccountRecoveryDialog(user, organization); if (result === AccountRecoveryDialogResultType.Ok) { await this.load(organization); } @@ -839,91 +481,29 @@ export class MembersComponent extends BaseMembersComponent } protected async removeUserConfirmationDialog(user: OrganizationUserView) { - const content = user.usesKeyConnector - ? "removeUserConfirmationKeyConnector" - : "removeOrgUserConfirmation"; - - const confirmed = await this.dialogService.openSimpleDialog({ - title: { - key: "removeUserIdAccess", - placeholders: [this.userNamePipe.transform(user)], - }, - content: { key: content }, - type: "warning", - }); - - if (!confirmed) { - return false; - } - - if (user.status > OrganizationUserStatusType.Invited && user.hasMasterPassword === false) { - return await this.noMasterPasswordConfirmationDialog(user); - } - - return true; + return await this.memberDialogManager.openRemoveUserConfirmationDialog(user); } protected async revokeUserConfirmationDialog(user: OrganizationUserView) { - const confirmed = await this.dialogService.openSimpleDialog({ - title: { key: "revokeAccess", placeholders: [this.userNamePipe.transform(user)] }, - content: this.i18nService.t("revokeUserConfirmation"), - acceptButtonText: { key: "revokeAccess" }, - type: "warning", - }); - - if (!confirmed) { - return false; - } - - if (user.status > OrganizationUserStatusType.Invited && user.hasMasterPassword === false) { - return await this.noMasterPasswordConfirmationDialog(user); - } - - return true; + return await this.memberDialogManager.openRevokeUserConfirmationDialog(user); } async deleteUser(user: OrganizationUserView, organization: Organization) { - const warningAcknowledged = await firstValueFrom( - this.deleteManagedMemberWarningService.warningAcknowledged(organization.id), + const confirmed = await this.memberDialogManager.openDeleteUserConfirmationDialog( + user, + organization, ); - if ( - !warningAcknowledged && - organization.canManageUsers && - organization.productTierType === ProductTierType.Enterprise - ) { - const acknowledged = await this.deleteManagedMemberWarningService.showWarning(); - if (!acknowledged) { - return false; - } - } - - const confirmed = await this.dialogService.openSimpleDialog({ - title: { - key: "deleteOrganizationUser", - placeholders: [this.userNamePipe.transform(user)], - }, - content: { - key: "deleteOrganizationUserWarningDesc", - placeholders: [this.userNamePipe.transform(user)], - }, - type: "warning", - acceptButtonText: { key: "delete" }, - cancelButtonText: { key: "cancel" }, - }); - if (!confirmed) { return false; } - await this.deleteManagedMemberWarningService.acknowledgeWarning(organization.id); - - this.actionPromise = this.organizationUserApiService.deleteOrganizationUser( - organization.id, - user.id, - ); + this.actionPromise = this.memberActionsService.deleteUser(organization, user.id); try { - await this.actionPromise; + const result = await this.actionPromise; + if (!result.success) { + throw new Error(result.error); + } this.toastService.showToast({ variant: "success", message: this.i18nService.t("organizationUserDeleted", this.userNamePipe.transform(user)), @@ -935,19 +515,6 @@ export class MembersComponent extends BaseMembersComponent this.actionPromise = undefined; } - private async noMasterPasswordConfirmationDialog(user: OrganizationUserView) { - return this.dialogService.openSimpleDialog({ - title: { - key: "removeOrgUserNoMasterPasswordTitle", - }, - content: { - key: "removeOrgUserNoMasterPasswordDesc", - placeholders: [this.userNamePipe.transform(user)], - }, - type: "warning", - }); - } - get showBulkRestoreUsers(): boolean { return this.dataSource .getCheckedUsers() @@ -975,13 +542,4 @@ export class MembersComponent extends BaseMembersComponent .getCheckedUsers() .every((member) => member.managedByOrganization && validStatuses.includes(member.status)); } - - async navigateToPaymentMethod(organization: Organization) { - await this.router.navigate( - ["organizations", `${organization.id}`, "billing", "payment-details"], - { - state: { launchPaymentModalAutomatically: true }, - }, - ); - } } diff --git a/apps/web/src/app/admin-console/organizations/members/members.module.ts b/apps/web/src/app/admin-console/organizations/members/members.module.ts index e5bc5f29a3b..3b233932ed3 100644 --- a/apps/web/src/app/admin-console/organizations/members/members.module.ts +++ b/apps/web/src/app/admin-console/organizations/members/members.module.ts @@ -4,6 +4,7 @@ import { NgModule } from "@angular/core"; import { PasswordStrengthV2Component } from "@bitwarden/angular/tools/password-strength/password-strength-v2.component"; import { PasswordCalloutComponent } from "@bitwarden/auth/angular"; import { ScrollLayoutDirective } from "@bitwarden/components"; +import { BillingConstraintService } from "@bitwarden/web-vault/app/billing/members/billing-constraint/billing-constraint.service"; import { OrganizationFreeTrialWarningComponent } from "@bitwarden/web-vault/app/billing/organizations/warnings/components"; import { HeaderModule } from "../../../layouts/header/header.module"; @@ -18,6 +19,11 @@ import { BulkStatusComponent } from "./components/bulk/bulk-status.component"; import { UserDialogModule } from "./components/member-dialog"; import { MembersRoutingModule } from "./members-routing.module"; import { MembersComponent } from "./members.component"; +import { + OrganizationMembersService, + MemberActionsService, + MemberDialogManagerService, +} from "./services"; @NgModule({ imports: [ @@ -40,5 +46,11 @@ import { MembersComponent } from "./members.component"; MembersComponent, BulkDeleteDialogComponent, ], + providers: [ + OrganizationMembersService, + MemberActionsService, + BillingConstraintService, + MemberDialogManagerService, + ], }) export class MembersModule {} diff --git a/apps/web/src/app/admin-console/organizations/members/services/index.ts b/apps/web/src/app/admin-console/organizations/members/services/index.ts new file mode 100644 index 00000000000..2ac2d31cd69 --- /dev/null +++ b/apps/web/src/app/admin-console/organizations/members/services/index.ts @@ -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"; diff --git a/apps/web/src/app/admin-console/organizations/members/services/member-actions/member-actions.service.spec.ts b/apps/web/src/app/admin-console/organizations/members/services/member-actions/member-actions.service.spec.ts new file mode 100644 index 00000000000..6fd7de7b292 --- /dev/null +++ b/apps/web/src/app/admin-console/organizations/members/services/member-actions/member-actions.service.spec.ts @@ -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; + let organizationUserService: MockProxy; + let keyService: MockProxy; + let encryptService: MockProxy; + let configService: MockProxy; + let accountService: FakeAccountService; + let billingConstraintService: MockProxy; + + const userId = newGuid() as UserId; + const organizationId = newGuid() as OrganizationId; + const userIdToManage = newGuid(); + + let mockOrganization: Organization; + let mockOrgUser: OrganizationUserView; + + beforeEach(() => { + organizationUserApiService = mock(); + organizationUserService = mock(); + keyService = mock(); + encryptService = mock(); + configService = mock(); + accountService = mockAccountServiceWith(userId); + billingConstraintService = mock(); + + 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(); + 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; + 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); + }); + }); +}); diff --git a/apps/web/src/app/admin-console/organizations/members/services/member-actions/member-actions.service.ts b/apps/web/src/app/admin-console/organizations/members/services/member-actions/member-actions.service.ts new file mode 100644 index 00000000000..3697aba94ff --- /dev/null +++ b/apps/web/src/app/admin-console/organizations/members/services/member-actions/member-actions.service.ts @@ -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; + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 + ); + } +} diff --git a/apps/web/src/app/admin-console/organizations/members/services/member-dialog-manager/member-dialog-manager.service.spec.ts b/apps/web/src/app/admin-console/organizations/members/services/member-dialog-manager/member-dialog-manager.service.spec.ts new file mode 100644 index 00000000000..e478f8bbb41 --- /dev/null +++ b/apps/web/src/app/admin-console/organizations/members/services/member-dialog-manager/member-dialog-manager.service.spec.ts @@ -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; + let i18nService: MockProxy; + let toastService: MockProxy; + let userNamePipe: MockProxy; + let deleteManagedMemberWarningService: MockProxy; + + let mockOrganization: Organization; + let mockUser: OrganizationUserView; + let mockBillingMetadata: OrganizationBillingMetadataResponse; + + beforeEach(() => { + dialogService = mock(); + i18nService = mock(); + toastService = mock(); + userNamePipe = mock(); + deleteManagedMemberWarningService = mock(); + + 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(); + }); + }); +}); diff --git a/apps/web/src/app/admin-console/organizations/members/services/member-dialog-manager/member-dialog-manager.service.ts b/apps/web/src/app/admin-console/organizations/members/services/member-dialog-manager/member-dialog-manager.service.ts new file mode 100644 index 00000000000..c6ef536af2b --- /dev/null +++ b/apps/web/src/app/admin-console/organizations/members/services/member-dialog-manager/member-dialog-manager.service.ts @@ -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 { + 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 { + 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 { + 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 { + const dialogRef = BulkConfirmDialogComponent.open(this.dialogService, { + data: { + organization: organization, + users: users, + }, + }); + + await lastValueFrom(dialogRef.closed); + } + + async openBulkRemoveDialog( + organization: Organization, + users: OrganizationUserView[], + ): Promise { + const dialogRef = BulkRemoveDialogComponent.open(this.dialogService, { + data: { + organizationId: organization.id, + users: users, + }, + }); + + await lastValueFrom(dialogRef.closed); + } + + async openBulkDeleteDialog( + organization: Organization, + users: OrganizationUserView[], + ): Promise { + 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 { + const ref = BulkRestoreRevokeComponent.open(this.dialogService, { + organizationId: organization.id, + users: users, + isRevoking: isRevoking, + }); + + await firstValueFrom(ref.closed); + } + + async openBulkEnableSecretsManagerDialog( + organization: Organization, + users: OrganizationUserView[], + ): Promise { + 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, + successMessage: string, + ): Promise { + 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 { + 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 { + 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 { + 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 { + return this.dialogService.openSimpleDialog({ + title: { + key: "removeOrgUserNoMasterPasswordTitle", + }, + content: { + key: "removeOrgUserNoMasterPasswordDesc", + placeholders: [this.userNamePipe.transform(user)], + }, + type: "warning", + }); + } +} diff --git a/apps/web/src/app/admin-console/organizations/members/services/organization-members-service/organization-members.service.spec.ts b/apps/web/src/app/admin-console/organizations/members/services/organization-members-service/organization-members.service.spec.ts new file mode 100644 index 00000000000..615d2ece463 --- /dev/null +++ b/apps/web/src/app/admin-console/organizations/members/services/organization-members-service/organization-members.service.spec.ts @@ -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; + let groupService: jest.Mocked; + let apiService: jest.Mocked; + + const mockOrganizationId = "org-123" as OrganizationId; + + const createMockOrganization = (overrides: Partial = {}): Organization => { + const org = new Organization(); + org.id = mockOrganizationId; + org.useGroups = false; + + return Object.assign(org, overrides); + }; + + const createMockUserResponse = ( + overrides: Partial = {}, + ): 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 = { + 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 = { + 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 = { + 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 = { + 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 = { + 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 = { + 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 = { + 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 = { + 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 = { + 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 = { + 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 = { + 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 = { + 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); + }); + }); +}); diff --git a/apps/web/src/app/admin-console/organizations/members/services/organization-members-service/organization-members.service.ts b/apps/web/src/app/admin-console/organizations/members/services/organization-members-service/organization-members.service.ts new file mode 100644 index 00000000000..613c7c1b9c0 --- /dev/null +++ b/apps/web/src/app/admin-console/organizations/members/services/organization-members-service/organization-members.service.ts @@ -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 { + let groupsPromise: Promise> | undefined; + let collectionsPromise: Promise> | 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((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> { + const groups = await this.groupService.getAll(organization.id); + const groupNameMap = new Map(); + groups.forEach((g: { id: string; name: string }) => groupNameMap.set(g.id, g.name)); + return groupNameMap; + } + + private async getCollectionNameMap(organization: Organization): Promise> { + 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(); + collections.forEach((c: { id: string; name: string }) => collectionMap.set(c.id, c.name)); + return collectionMap; + } +} diff --git a/apps/web/src/app/billing/members/billing-constraint/billing-constraint.service.spec.ts b/apps/web/src/app/billing/members/billing-constraint/billing-constraint.service.spec.ts new file mode 100644 index 00000000000..f7bb510f579 --- /dev/null +++ b/apps/web/src/app/billing/members/billing-constraint/billing-constraint.service.spec.ts @@ -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; + let dialogService: jest.Mocked; + let toastService: jest.Mocked; + let router: jest.Mocked; + let organizationMetadataService: jest.Mocked; + + const mockOrganizationId = "org-123" as OrganizationId; + + const createMockOrganization = (overrides: Partial = {}): 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 => { + 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}`, + ); + }); + }); + }); +}); diff --git a/apps/web/src/app/billing/members/billing-constraint/billing-constraint.service.ts b/apps/web/src/app/billing/members/billing-constraint/billing-constraint.service.ts new file mode 100644 index 00000000000..d43c2e68497 --- /dev/null +++ b/apps/web/src/app/billing/members/billing-constraint/billing-constraint.service.ts @@ -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 { + 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 { + 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 { + 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 { + 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 { + await this.router.navigate( + ["organizations", `${organization.id}`, "billing", "payment-method"], + { + state: { launchPaymentModalAutomatically: true }, + }, + ); + } +} diff --git a/bitwarden_license/bit-web/src/app/admin-console/providers/manage/members.component.ts b/bitwarden_license/bit-web/src/app/admin-console/providers/manage/members.component.ts index affcfce9c17..e86956dec93 100644 --- a/bitwarden_license/bit-web/src/app/admin-console/providers/manage/members.component.ts +++ b/bitwarden_license/bit-web/src/app/admin-console/providers/manage/members.component.ts @@ -30,6 +30,7 @@ import { } 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 { 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 { AddEditMemberDialogComponent, @@ -199,16 +200,27 @@ export class MembersComponent extends BaseMembersComponent { await this.load(); } - async confirmUser(user: ProviderUser, publicKey: Uint8Array): Promise { - const providerKey = await this.keyService.getProviderKey(this.providerId); - const key = await this.encryptService.encapsulateKeyUnsigned(providerKey, publicKey); - const request = new ProviderUserConfirmRequest(); - request.key = key.encryptedString; - await this.apiService.postProviderUserConfirm(this.providerId, user.id, request); + async confirmUser(user: ProviderUser, publicKey: Uint8Array): Promise { + try { + const providerKey = await this.keyService.getProviderKey(this.providerId); + const key = await this.encryptService.encapsulateKeyUnsigned(providerKey, publicKey); + const request = new ProviderUserConfirmRequest(); + 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 => - this.apiService.deleteProviderUser(this.providerId, id); + removeUser = async (id: string): Promise => { + 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 => { const data: AddEditMemberDialogParams = { @@ -251,6 +263,12 @@ export class MembersComponent extends BaseMembersComponent { getUsers = (): Promise> => this.apiService.getProviderUsers(this.providerId); - reinviteUser = (id: string): Promise => - this.apiService.postProviderUserReinvite(this.providerId, id); + reinviteUser = async (id: string): Promise => { + try { + await this.apiService.postProviderUserReinvite(this.providerId, id); + return { success: true }; + } catch (error) { + return { success: false, error: error.message }; + } + }; } diff --git a/libs/common/src/billing/services/organization/organization-metadata.service.spec.ts b/libs/common/src/billing/services/organization/organization-metadata.service.spec.ts index 0ed60bef605..c67f4aed175 100644 --- a/libs/common/src/billing/services/organization/organization-metadata.service.spec.ts +++ b/libs/common/src/billing/services/organization/organization-metadata.service.spec.ts @@ -208,7 +208,7 @@ describe("DefaultOrganizationMetadataService", () => { }, 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); const mockResponse1 = createMockMetadataResponse(false, 10); @@ -232,11 +232,10 @@ describe("DefaultOrganizationMetadataService", () => { service.refreshMetadataCache(); - // wait to ensure no additional invocations await new Promise((resolve) => setTimeout(resolve, 10)); - expect(invocationCount).toBe(1); - expect(billingApiService.getOrganizationBillingMetadata).toHaveBeenCalledTimes(1); + expect(invocationCount).toBe(2); + expect(billingApiService.getOrganizationBillingMetadata).toHaveBeenCalledTimes(2); subscription.unsubscribe(); }); diff --git a/libs/common/src/billing/services/organization/organization-metadata.service.ts b/libs/common/src/billing/services/organization/organization-metadata.service.ts index 09aaa202112..fe96f0d984c 100644 --- a/libs/common/src/billing/services/organization/organization-metadata.service.ts +++ b/libs/common/src/billing/services/organization/organization-metadata.service.ts @@ -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"; @@ -18,57 +18,56 @@ export class DefaultOrganizationMetadataService implements OrganizationMetadataS private billingApiService: BillingApiServiceAbstraction, private configService: ConfigService, ) {} - private refreshMetadataTrigger = new Subject(); + private refreshMetadataTrigger = new BehaviorSubject(undefined); - refreshMetadataCache = () => this.refreshMetadataTrigger.next(); + refreshMetadataCache = () => { + this.metadataCache.clear(); + this.refreshMetadataTrigger.next(); + }; - getOrganizationMetadata$ = ( - organizationId: OrganizationId, - ): Observable => - this.configService - .getFeatureFlag$(FeatureFlag.PM25379_UseNewOrganizationMetadataStructure) - .pipe( - switchMap((featureFlagEnabled) => { - return merge( - this.getOrganizationMetadataInternal$(organizationId, featureFlagEnabled), - this.refreshMetadataTrigger.pipe( - filter(() => featureFlagEnabled), - switchMap(() => - this.getOrganizationMetadataInternal$(organizationId, featureFlagEnabled, true), - ), - ), - ); - }), - ); + getOrganizationMetadata$(orgId: OrganizationId): Observable { + return combineLatest([ + this.refreshMetadataTrigger, + this.configService.getFeatureFlag$(FeatureFlag.PM25379_UseNewOrganizationMetadataStructure), + ]).pipe( + switchMap(([_, featureFlagEnabled]) => + featureFlagEnabled + ? this.vNextGetOrganizationMetadataInternal$(orgId) + : this.getOrganizationMetadataInternal$(orgId), + ), + ); + } - private getOrganizationMetadataInternal$( - organizationId: OrganizationId, - featureFlagEnabled: boolean, - bypassCache: boolean = false, + private vNextGetOrganizationMetadataInternal$( + orgId: OrganizationId, ): Observable { - if (!bypassCache && featureFlagEnabled && this.metadataCache.has(organizationId)) { - return this.metadataCache.get(organizationId)!; + const cacheHit = this.metadataCache.get(orgId); + 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 }), ); - if (featureFlagEnabled) { - this.metadataCache.set(organizationId, metadata$); - } + this.metadataCache.set(orgId, result); + return result; + } - return metadata$; + private getOrganizationMetadataInternal$( + organizationId: OrganizationId, + ): Observable { + return from(this.fetchMetadata(organizationId, false)).pipe( + shareReplay({ bufferSize: 1, refCount: false }), + ); } private async fetchMetadata( organizationId: OrganizationId, featureFlagEnabled: boolean, ): Promise { - if (featureFlagEnabled) { - return await this.billingApiService.getOrganizationBillingMetadataVNext(organizationId); - } - - return await this.billingApiService.getOrganizationBillingMetadata(organizationId); + return featureFlagEnabled + ? await this.billingApiService.getOrganizationBillingMetadataVNext(organizationId) + : await this.billingApiService.getOrganizationBillingMetadata(organizationId); } }