From 620affd3d5dc5dab2df17e7291c6ff3affd236c4 Mon Sep 17 00:00:00 2001 From: Jimmy Vo <huynhmaivo82@gmail.com> Date: Thu, 23 Jan 2025 14:24:51 -0500 Subject: [PATCH] [PM-13755] Exclude revoked users from the occupied seats count (#12277) It also includes a refactor to decouple the invite and edit user flows. --- .../member-dialog.component.html | 16 +- .../member-dialog/member-dialog.component.ts | 160 +++++++++----- .../input-email-limit.validator.spec.ts | 191 ++++++++++++++++ .../validators/input-email-limit.validator.ts | 40 ++++ .../org-seat-limit-reached.validator.spec.ts | 207 ++++++++++++------ .../org-seat-limit-reached.validator.ts | 73 ++++-- .../members/members.component.ts | 102 +++++---- .../member-access-report.component.ts | 9 +- 8 files changed, 593 insertions(+), 205 deletions(-) create mode 100644 apps/web/src/app/admin-console/organizations/members/components/member-dialog/validators/input-email-limit.validator.spec.ts create mode 100644 apps/web/src/app/admin-console/organizations/members/components/member-dialog/validators/input-email-limit.validator.ts diff --git a/apps/web/src/app/admin-console/organizations/members/components/member-dialog/member-dialog.component.html b/apps/web/src/app/admin-console/organizations/members/components/member-dialog/member-dialog.component.html index 5e81e4ee71..bef479c231 100644 --- a/apps/web/src/app/admin-console/organizations/members/components/member-dialog/member-dialog.component.html +++ b/apps/web/src/app/admin-console/organizations/members/components/member-dialog/member-dialog.component.html @@ -2,9 +2,11 @@ <bit-dialog [disablePadding]="!loading" dialogSize="large"> <span bitDialogTitle> {{ title }} - <span class="tw-text-sm tw-normal-case tw-text-muted" *ngIf="!loading && params.name">{{ - params.name - }}</span> + <span + class="tw-text-sm tw-normal-case tw-text-muted" + *ngIf="!loading && editParams$ && (editParams$ | async)?.name" + >{{ (editParams$ | async)?.name }}</span + > <span bitBadge variant="secondary" *ngIf="isRevoked">{{ "revoked" | i18n }}</span> </span> <div bitDialogContent> @@ -268,7 +270,9 @@ </button> <button *ngIf=" - editMode && (!(accountDeprovisioningEnabled$ | async) || !params.managedByOrganization) + this.editMode && + (!(accountDeprovisioningEnabled$ | async) || + !(editParams$ | async)?.managedByOrganization) " type="button" bitIconButton="bwi-close" @@ -280,7 +284,9 @@ ></button> <button *ngIf=" - editMode && (accountDeprovisioningEnabled$ | async) && params.managedByOrganization + this.editMode && + (accountDeprovisioningEnabled$ | async) && + (editParams$ | async)?.managedByOrganization " type="button" bitIconButton="bwi-trash" diff --git a/apps/web/src/app/admin-console/organizations/members/components/member-dialog/member-dialog.component.ts b/apps/web/src/app/admin-console/organizations/members/components/member-dialog/member-dialog.component.ts index fbf29602e0..7a30eba9e1 100644 --- a/apps/web/src/app/admin-console/organizations/members/components/member-dialog/member-dialog.component.ts +++ b/apps/web/src/app/admin-console/organizations/members/components/member-dialog/member-dialog.component.ts @@ -55,6 +55,7 @@ import { } from "../../../shared/components/access-selector"; import { commaSeparatedEmails } from "./validators/comma-separated-emails.validator"; +import { inputEmailLimitValidator } from "./validators/input-email-limit.validator"; import { orgSeatLimitReachedValidator } from "./validators/org-seat-limit-reached.validator"; export enum MemberDialogTab { @@ -63,18 +64,28 @@ export enum MemberDialogTab { Collections = 2, } -export interface MemberDialogParams { - name: string; - organizationId: string; - organizationUserId: string; - allOrganizationUserEmails: string[]; - usesKeyConnector: boolean; +interface CommonMemberDialogParams { isOnSecretsManagerStandalone: boolean; - initialTab?: MemberDialogTab; - numSeatsUsed: number; - managedByOrganization?: boolean; + organizationId: string; } +export interface AddMemberDialogParams extends CommonMemberDialogParams { + kind: "Add"; + occupiedSeatCount: number; + allOrganizationUserEmails: string[]; +} + +export interface EditMemberDialogParams extends CommonMemberDialogParams { + kind: "Edit"; + name: string; + organizationUserId: string; + usesKeyConnector: boolean; + managedByOrganization?: boolean; + initialTab: MemberDialogTab; +} + +export type MemberDialogParams = EditMemberDialogParams | AddMemberDialogParams; + export enum MemberDialogResult { Saved = "saved", Canceled = "canceled", @@ -98,6 +109,7 @@ export class MemberDialogComponent implements OnDestroy { showNoMasterPasswordWarning = false; isOnSecretsManagerStandalone: boolean; remainingSeats$: Observable<number>; + editParams$: Observable<EditMemberDialogParams>; protected organization$: Observable<Organization>; protected collectionAccessItems: AccessItemView[] = []; @@ -143,6 +155,12 @@ export class MemberDialogComponent implements OnDestroy { return this.formGroup.value.type === OrganizationUserType.Custom; } + isEditDialogParams( + params: EditMemberDialogParams | AddMemberDialogParams, + ): params is EditMemberDialogParams { + return params.kind === "Edit"; + } + constructor( @Inject(DIALOG_DATA) protected params: MemberDialogParams, private dialogRef: DialogRef<MemberDialogResult>, @@ -168,9 +186,24 @@ export class MemberDialogComponent implements OnDestroy { ), ); - this.editMode = this.params.organizationUserId != null; - this.tabIndex = this.params.initialTab ?? MemberDialogTab.Role; - this.title = this.i18nService.t(this.editMode ? "editMember" : "inviteMember"); + let userDetails$; + if (this.isEditDialogParams(this.params)) { + this.editMode = true; + this.title = this.i18nService.t("editMember"); + userDetails$ = this.userService.get( + this.params.organizationId, + this.params.organizationUserId, + ); + this.tabIndex = this.params.initialTab; + this.editParams$ = of(this.params); + } else { + this.editMode = false; + this.title = this.i18nService.t("inviteMember"); + this.editParams$ = of(null); + userDetails$ = of(null); + this.tabIndex = MemberDialogTab.Role; + } + this.isOnSecretsManagerStandalone = this.params.isOnSecretsManagerStandalone; if (this.isOnSecretsManagerStandalone) { @@ -187,10 +220,6 @@ export class MemberDialogComponent implements OnDestroy { ), ); - const userDetails$ = this.params.organizationUserId - ? this.userService.get(this.params.organizationId, this.params.organizationUserId) - : of(null); - this.allowAdminAccessToAllCollectionItems$ = this.organization$.pipe( map((organization) => { return organization.allowAdminAccessToAllCollectionItems; @@ -271,18 +300,32 @@ export class MemberDialogComponent implements OnDestroy { }); this.remainingSeats$ = this.organization$.pipe( - map((organization) => organization.seats - this.params.numSeatsUsed), + map((organization) => { + if (!this.isEditDialogParams(this.params)) { + return organization.seats - this.params.occupiedSeatCount; + } + + return organization.seats; + }), ); } private setFormValidators(organization: Organization) { + if (this.isEditDialogParams(this.params)) { + return; + } + const emailsControlValidators = [ Validators.required, commaSeparatedEmails, + inputEmailLimitValidator(organization, (maxEmailsCount: number) => + this.i18nService.t("tooManyEmails", maxEmailsCount), + ), orgSeatLimitReachedValidator( organization, this.params.allOrganizationUserEmails, this.i18nService.t("subscriptionUpgrade", organization.seats), + this.params.occupiedSeatCount, ), ]; @@ -433,14 +476,25 @@ export class MemberDialogComponent implements OnDestroy { return; } + const userView = await this.getUserView(); + + if (this.isEditDialogParams(this.params)) { + await this.handleEditUser(userView, this.params); + } else { + await this.handleInviteUsers(userView, organization); + } + }; + + private async getUserView(): Promise<OrganizationUserAdminView> { const userView = new OrganizationUserAdminView(); - userView.id = this.params.organizationUserId; userView.organizationId = this.params.organizationId; userView.type = this.formGroup.value.type; + userView.permissions = this.setRequestPermissions( userView.permissions ?? new PermissionsApi(), userView.type !== OrganizationUserType.Custom, ); + userView.collections = this.formGroup.value.access .filter((v) => v.type === AccessItemType.Collection) .map(convertToSelectionView); @@ -451,44 +505,40 @@ export class MemberDialogComponent implements OnDestroy { userView.accessSecretsManager = this.formGroup.value.accessSecretsManager; - if (this.editMode) { - await this.userService.save(userView); - } else { - userView.id = this.params.organizationUserId; - const maxEmailsCount = - organization.productTierType === ProductTierType.TeamsStarter ? 10 : 20; - const emails = [...new Set(this.formGroup.value.emails.trim().split(/\s*,\s*/))]; - if (emails.length > maxEmailsCount) { - this.formGroup.controls.emails.setErrors({ - tooManyEmails: { message: this.i18nService.t("tooManyEmails", maxEmailsCount) }, - }); - return; - } - if ( - organization.hasReseller && - this.params.numSeatsUsed + emails.length > organization.seats - ) { - this.formGroup.controls.emails.setErrors({ - tooManyEmails: { message: this.i18nService.t("seatLimitReachedContactYourProvider") }, - }); - return; - } - await this.userService.invite(emails, userView); - } + return userView; + } + + private async handleEditUser( + userView: OrganizationUserAdminView, + params: EditMemberDialogParams, + ) { + userView.id = params.organizationUserId; + await this.userService.save(userView); this.toastService.showToast({ variant: "success", title: null, - message: this.i18nService.t( - this.editMode ? "editedUserId" : "invitedUsers", - this.params.name, - ), + message: this.i18nService.t("editedUserId", params.name), + }); + + this.close(MemberDialogResult.Saved); + } + + private async handleInviteUsers(userView: OrganizationUserAdminView, organization: Organization) { + const emails = [...new Set(this.formGroup.value.emails.trim().split(/\s*,\s*/))]; + + await this.userService.invite(emails, userView); + + this.toastService.showToast({ + variant: "success", + title: null, + message: this.i18nService.t("invitedUsers"), }); this.close(MemberDialogResult.Saved); - }; + } remove = async () => { - if (!this.editMode) { + if (!this.isEditDialogParams(this.params)) { return; } @@ -507,7 +557,7 @@ export class MemberDialogComponent implements OnDestroy { } if (this.showNoMasterPasswordWarning) { - confirmed = await this.noMasterPasswordConfirmationDialog(); + confirmed = await this.noMasterPasswordConfirmationDialog(this.params.name); if (!confirmed) { return false; @@ -528,7 +578,7 @@ export class MemberDialogComponent implements OnDestroy { }; revoke = async () => { - if (!this.editMode) { + if (!this.isEditDialogParams(this.params)) { return; } @@ -544,7 +594,7 @@ export class MemberDialogComponent implements OnDestroy { } if (this.showNoMasterPasswordWarning) { - confirmed = await this.noMasterPasswordConfirmationDialog(); + confirmed = await this.noMasterPasswordConfirmationDialog(this.params.name); if (!confirmed) { return false; @@ -566,7 +616,7 @@ export class MemberDialogComponent implements OnDestroy { }; restore = async () => { - if (!this.editMode) { + if (!this.isEditDialogParams(this.params)) { return; } @@ -585,7 +635,7 @@ export class MemberDialogComponent implements OnDestroy { }; delete = async () => { - if (!this.editMode) { + if (!this.isEditDialogParams(this.params)) { return; } @@ -633,14 +683,14 @@ export class MemberDialogComponent implements OnDestroy { this.dialogRef.close(result); } - private noMasterPasswordConfirmationDialog() { + private noMasterPasswordConfirmationDialog(username: string) { return this.dialogService.openSimpleDialog({ title: { key: "removeOrgUserNoMasterPasswordTitle", }, content: { key: "removeOrgUserNoMasterPasswordDesc", - placeholders: [this.params.name], + placeholders: [username], }, type: "warning", }); diff --git a/apps/web/src/app/admin-console/organizations/members/components/member-dialog/validators/input-email-limit.validator.spec.ts b/apps/web/src/app/admin-console/organizations/members/components/member-dialog/validators/input-email-limit.validator.spec.ts new file mode 100644 index 0000000000..5a9a0e128e --- /dev/null +++ b/apps/web/src/app/admin-console/organizations/members/components/member-dialog/validators/input-email-limit.validator.spec.ts @@ -0,0 +1,191 @@ +import { AbstractControl, FormControl } from "@angular/forms"; + +import { OrganizationUserType } from "@bitwarden/common/admin-console/enums"; +import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; +import { ProductTierType } from "@bitwarden/common/billing/enums"; + +import { inputEmailLimitValidator } from "./input-email-limit.validator"; + +const orgFactory = (props: Partial<Organization> = {}) => + Object.assign( + new Organization(), + { + id: "myOrgId", + enabled: true, + type: OrganizationUserType.Admin, + }, + props, + ); + +describe("inputEmailLimitValidator", () => { + const getErrorMessage = (max: number) => `You can only add up to ${max} unique emails.`; + + const createUniqueEmailString = (numberOfEmails: number) => + Array(numberOfEmails) + .fill(null) + .map((_, i) => `email${i}@example.com`) + .join(", "); + + const createIdenticalEmailString = (numberOfEmails: number) => + Array(numberOfEmails) + .fill(null) + .map(() => `email@example.com`) + .join(", "); + + describe("TeamsStarter limit validation", () => { + let teamsStarterOrganization: Organization; + + beforeEach(() => { + teamsStarterOrganization = orgFactory({ + productTierType: ProductTierType.TeamsStarter, + seats: 10, + }); + }); + + it("should return null if unique email count is within the limit", () => { + // Arrange + const control = new FormControl(createUniqueEmailString(3)); + + const validatorFn = inputEmailLimitValidator(teamsStarterOrganization, getErrorMessage); + + // Act + const result = validatorFn(control); + + // Assert + expect(result).toBeNull(); + }); + + it("should return null if unique email count is equal the limit", () => { + // Arrange + const control = new FormControl(createUniqueEmailString(10)); + + const validatorFn = inputEmailLimitValidator(teamsStarterOrganization, getErrorMessage); + + // Act + const result = validatorFn(control); + + // Assert + expect(result).toBeNull(); + }); + + it("should return an error if unique email count exceeds the limit", () => { + // Arrange + const control = new FormControl(createUniqueEmailString(11)); + + const validatorFn = inputEmailLimitValidator(teamsStarterOrganization, getErrorMessage); + + // Act + const result = validatorFn(control); + + // Assert + expect(result).toEqual({ + tooManyEmails: { message: "You can only add up to 10 unique emails." }, + }); + }); + }); + + describe("Non-TeamsStarter limit validation", () => { + let nonTeamsStarterOrganization: Organization; + + beforeEach(() => { + nonTeamsStarterOrganization = orgFactory({ + productTierType: ProductTierType.Enterprise, + seats: 100, + }); + }); + + it("should return null if unique email count is within the limit", () => { + // Arrange + const control = new FormControl(createUniqueEmailString(3)); + + const validatorFn = inputEmailLimitValidator(nonTeamsStarterOrganization, getErrorMessage); + + // Act + const result = validatorFn(control); + + // Assert + expect(result).toBeNull(); + }); + + it("should return null if unique email count is equal the limit", () => { + // Arrange + const control = new FormControl(createUniqueEmailString(10)); + + const validatorFn = inputEmailLimitValidator(nonTeamsStarterOrganization, getErrorMessage); + + // Act + const result = validatorFn(control); + + // Assert + expect(result).toBeNull(); + }); + + it("should return an error if unique email count exceeds the limit", () => { + // Arrange + + const control = new FormControl(createUniqueEmailString(21)); + + const validatorFn = inputEmailLimitValidator(nonTeamsStarterOrganization, getErrorMessage); + + // Act + const result = validatorFn(control); + + // Assert + expect(result).toEqual({ + tooManyEmails: { message: "You can only add up to 20 unique emails." }, + }); + }); + }); + + describe("input email validation", () => { + let organization: Organization; + + beforeEach(() => { + organization = orgFactory({ + productTierType: ProductTierType.Enterprise, + seats: 100, + }); + }); + + it("should ignore duplicate emails and validate only unique ones", () => { + // Arrange + const sixUniqueEmails = createUniqueEmailString(6); + const sixDuplicateEmails = createIdenticalEmailString(6); + + const control = new FormControl(sixUniqueEmails + sixDuplicateEmails); + const validatorFn = inputEmailLimitValidator(organization, getErrorMessage); + + // Act + const result = validatorFn(control); + + // Assert + expect(result).toBeNull(); + }); + + it("should return null if input is null", () => { + // Arrange + const control: AbstractControl = new FormControl(null); + + const validatorFn = inputEmailLimitValidator(organization, getErrorMessage); + + // Act + const result = validatorFn(control); + + // Assert + expect(result).toBeNull(); + }); + + it("should return null if input is empty", () => { + // Arrange + const control: AbstractControl = new FormControl(""); + + const validatorFn = inputEmailLimitValidator(organization, getErrorMessage); + + // Act + const result = validatorFn(control); + + // Assert + expect(result).toBeNull(); + }); + }); +}); diff --git a/apps/web/src/app/admin-console/organizations/members/components/member-dialog/validators/input-email-limit.validator.ts b/apps/web/src/app/admin-console/organizations/members/components/member-dialog/validators/input-email-limit.validator.ts new file mode 100644 index 0000000000..f34c2e909a --- /dev/null +++ b/apps/web/src/app/admin-console/organizations/members/components/member-dialog/validators/input-email-limit.validator.ts @@ -0,0 +1,40 @@ +import { AbstractControl, ValidationErrors, ValidatorFn } from "@angular/forms"; + +import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; +import { ProductTierType } from "@bitwarden/common/billing/enums"; + +function getUniqueInputEmails(control: AbstractControl): string[] { + const emails: string[] = control.value + .split(",") + .filter((email: string) => email && email.trim() !== ""); + const uniqueEmails: string[] = Array.from(new Set(emails)); + + return uniqueEmails; +} + +/** + * Ensure the number of unique emails in an input does not exceed the allowed maximum. + * @param organization An object representing the organization + * @param getErrorMessage A callback function that generates the error message. It takes the `maxEmailsCount` as a parameter. + * @returns A function that validates an `AbstractControl` and returns `ValidationErrors` or `null` + */ +export function inputEmailLimitValidator( + organization: Organization, + getErrorMessage: (maxEmailsCount: number) => string, +): ValidatorFn { + return (control: AbstractControl): ValidationErrors | null => { + if (!control.value?.trim()) { + return null; + } + + const maxEmailsCount = organization.productTierType === ProductTierType.TeamsStarter ? 10 : 20; + + const uniqueEmails = getUniqueInputEmails(control); + + if (uniqueEmails.length <= maxEmailsCount) { + return null; + } + + return { tooManyEmails: { message: getErrorMessage(maxEmailsCount) } }; + }; +} diff --git a/apps/web/src/app/admin-console/organizations/members/components/member-dialog/validators/org-seat-limit-reached.validator.spec.ts b/apps/web/src/app/admin-console/organizations/members/components/member-dialog/validators/org-seat-limit-reached.validator.spec.ts index 6c693ee8f8..a97623d7a3 100644 --- a/apps/web/src/app/admin-console/organizations/members/components/member-dialog/validators/org-seat-limit-reached.validator.spec.ts +++ b/apps/web/src/app/admin-console/organizations/members/components/member-dialog/validators/org-seat-limit-reached.validator.spec.ts @@ -1,10 +1,14 @@ -import { AbstractControl, FormControl, ValidationErrors } from "@angular/forms"; +import { FormControl } from "@angular/forms"; import { OrganizationUserType } from "@bitwarden/common/admin-console/enums"; import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; import { ProductTierType } from "@bitwarden/common/billing/enums"; -import { orgSeatLimitReachedValidator } from "./org-seat-limit-reached.validator"; +import { + orgSeatLimitReachedValidator, + isFixedSeatPlan, + isDynamicSeatPlan, +} from "./org-seat-limit-reached.validator"; const orgFactory = (props: Partial<Organization> = {}) => Object.assign( @@ -17,20 +21,35 @@ const orgFactory = (props: Partial<Organization> = {}) => props, ); +const createUniqueEmailString = (numberOfEmails: number) => + Array(numberOfEmails) + .fill(null) + .map((_, i) => `email${i}@example.com`) + .join(", "); + +const createIdenticalEmailString = (numberOfEmails: number) => + Array(numberOfEmails) + .fill(null) + .map(() => `email@example.com`) + .join(", "); + describe("orgSeatLimitReachedValidator", () => { let organization: Organization; let allOrganizationUserEmails: string[]; - let validatorFn: (control: AbstractControl) => ValidationErrors | null; + let occupiedSeatCount: number; beforeEach(() => { - allOrganizationUserEmails = ["user1@example.com"]; + allOrganizationUserEmails = [createUniqueEmailString(1)]; + occupiedSeatCount = 1; + organization = null; }); it("should return null when control value is empty", () => { - validatorFn = orgSeatLimitReachedValidator( + const validatorFn = orgSeatLimitReachedValidator( organization, allOrganizationUserEmails, "You cannot invite more than 2 members without upgrading your plan.", + occupiedSeatCount, ); const control = new FormControl(""); @@ -40,10 +59,11 @@ describe("orgSeatLimitReachedValidator", () => { }); it("should return null when control value is null", () => { - validatorFn = orgSeatLimitReachedValidator( + const validatorFn = orgSeatLimitReachedValidator( organization, allOrganizationUserEmails, "You cannot invite more than 2 members without upgrading your plan.", + occupiedSeatCount, ); const control = new FormControl(null); @@ -52,82 +72,123 @@ describe("orgSeatLimitReachedValidator", () => { expect(result).toBeNull(); }); - it("should return null when max seats are not exceeded on free plan", () => { - organization = orgFactory({ - productTierType: ProductTierType.Free, - seats: 2, - }); - validatorFn = orgSeatLimitReachedValidator( - organization, - allOrganizationUserEmails, - "You cannot invite more than 2 members without upgrading your plan.", - ); - const control = new FormControl("user2@example.com"); - - const result = validatorFn(control); - - expect(result).toBeNull(); - }); - - it("should return null when max seats are not exceeded on teams starter plan", () => { - organization = orgFactory({ - productTierType: ProductTierType.TeamsStarter, - seats: 10, - }); - validatorFn = orgSeatLimitReachedValidator( - organization, - allOrganizationUserEmails, - "You cannot invite more than 10 members without upgrading your plan.", - ); - const control = new FormControl( - "user2@example.com," + - "user3@example.com," + - "user4@example.com," + - "user5@example.com," + - "user6@example.com," + - "user7@example.com," + - "user8@example.com," + - "user9@example.com," + - "user10@example.com", - ); - - const result = validatorFn(control); - - expect(result).toBeNull(); - }); - - it("should return validation error when max seats are exceeded on free plan", () => { - organization = orgFactory({ - productTierType: ProductTierType.Free, - seats: 2, - }); - const errorMessage = "You cannot invite more than 2 members without upgrading your plan."; - validatorFn = orgSeatLimitReachedValidator( - organization, - allOrganizationUserEmails, - "You cannot invite more than 2 members without upgrading your plan.", - ); - const control = new FormControl("user2@example.com,user3@example.com"); - - const result = validatorFn(control); - - expect(result).toStrictEqual({ seatLimitReached: { message: errorMessage } }); - }); - - it("should return null when not on free plan", () => { - const control = new FormControl("user2@example.com,user3@example.com"); - organization = orgFactory({ + it("should return null when on dynamic seat plan", () => { + const control = new FormControl(createUniqueEmailString(1)); + const organization = orgFactory({ productTierType: ProductTierType.Enterprise, seats: 100, }); - validatorFn = orgSeatLimitReachedValidator( + + const validatorFn = orgSeatLimitReachedValidator( organization, allOrganizationUserEmails, - "You cannot invite more than 2 members without upgrading your plan.", + "Enterprise plan dummy error.", + occupiedSeatCount, ); const result = validatorFn(control); expect(result).toBeNull(); }); + + it("should only count unique input email addresses", () => { + const twoUniqueEmails = createUniqueEmailString(2); + const sixDuplicateEmails = createIdenticalEmailString(6); + const control = new FormControl(twoUniqueEmails + sixDuplicateEmails); + const organization = orgFactory({ + productTierType: ProductTierType.Families, + seats: 6, + }); + + const occupiedSeatCount = 3; + const validatorFn = orgSeatLimitReachedValidator( + organization, + allOrganizationUserEmails, + "Family plan dummy error.", + occupiedSeatCount, + ); + + const result = validatorFn(control); + + expect(result).toBeNull(); + }); + + describe("when total occupied seat count is below plan's max count", () => { + test.each([ + [ProductTierType.Free, 2], + [ProductTierType.Families, 6], + [ProductTierType.TeamsStarter, 10], + ])(`should return null on plan %s`, (plan, planSeatCount) => { + const organization = orgFactory({ + productTierType: plan, + seats: planSeatCount, + }); + + const occupiedSeatCount = 0; + + const validatorFn = orgSeatLimitReachedValidator( + organization, + allOrganizationUserEmails, + "Generic error message", + occupiedSeatCount, + ); + + const control = new FormControl(createUniqueEmailString(1)); + + const result = validatorFn(control); + + expect(result).toBeNull(); + }); + }); + + describe("when total occupied seat count is at plan's max count", () => { + test.each([ + [ProductTierType.Free, 2, 1], + [ProductTierType.Families, 6, 5], + [ProductTierType.TeamsStarter, 10, 9], + ])(`should return null on plan %s`, (plan, planSeatCount, newEmailCount) => { + const organization = orgFactory({ + productTierType: plan, + seats: planSeatCount, + }); + + const occupiedSeatCount = 1; + + const validatorFn = orgSeatLimitReachedValidator( + organization, + allOrganizationUserEmails, + "Generic error message", + occupiedSeatCount, + ); + + const control = new FormControl(createUniqueEmailString(newEmailCount)); + + const result = validatorFn(control); + + expect(result).toBeNull(); + }); + }); +}); + +describe("isFixedSeatPlan", () => { + test.each([ + [true, ProductTierType.Free], + [true, ProductTierType.Families], + [true, ProductTierType.TeamsStarter], + [false, ProductTierType.Enterprise], + ])("should return %s for %s", (expected, input) => { + expect(isFixedSeatPlan(input)).toBe(expected); + }); +}); + +describe("isDynamicSeatPlan", () => { + test.each([ + [true, ProductTierType.Enterprise], + [true, ProductTierType.Teams], + [false, ProductTierType.Free], + [false, ProductTierType.Families], + [false, ProductTierType.TeamsStarter], + ])("should return %s for %s", (expected, input) => { + expect(isDynamicSeatPlan(input)).toBe(expected); + }); }); diff --git a/apps/web/src/app/admin-console/organizations/members/components/member-dialog/validators/org-seat-limit-reached.validator.ts b/apps/web/src/app/admin-console/organizations/members/components/member-dialog/validators/org-seat-limit-reached.validator.ts index bcd8474391..1990bf7e32 100644 --- a/apps/web/src/app/admin-console/organizations/members/components/member-dialog/validators/org-seat-limit-reached.validator.ts +++ b/apps/web/src/app/admin-console/organizations/members/components/member-dialog/validators/org-seat-limit-reached.validator.ts @@ -9,41 +9,68 @@ import { ProductTierType } from "@bitwarden/common/billing/enums"; * @param organization An object representing the organization * @param allOrganizationUserEmails An array of strings with existing user email addresses * @param errorMessage A localized string to display if validation fails + * @param occupiedSeatCount The current count of active users occupying the organization's seats. * @returns A function that validates an `AbstractControl` and returns `ValidationErrors` or `null` */ export function orgSeatLimitReachedValidator( organization: Organization, allOrganizationUserEmails: string[], errorMessage: string, + occupiedSeatCount: number, ): ValidatorFn { return (control: AbstractControl): ValidationErrors | null => { - if (control.value === "" || !control.value) { + if (!control.value?.trim()) { return null; } - const newEmailsToAdd = Array.from( - new Set( - control.value - .split(",") - .filter( - (newEmailToAdd: string) => - newEmailToAdd && - newEmailToAdd.trim() !== "" && - !allOrganizationUserEmails.some( - (existingEmail) => existingEmail === newEmailToAdd.trim(), - ), - ), - ), - ); + if (isDynamicSeatPlan(organization.productTierType)) { + return null; + } - const productHasAdditionalSeatsOption = - organization.productTierType !== ProductTierType.Free && - organization.productTierType !== ProductTierType.Families && - organization.productTierType !== ProductTierType.TeamsStarter; + const newTotalUserCount = + occupiedSeatCount + getUniqueNewEmailCount(allOrganizationUserEmails, control); - return !productHasAdditionalSeatsOption && - allOrganizationUserEmails.length + newEmailsToAdd.length > organization.seats - ? { seatLimitReached: { message: errorMessage } } - : null; + if (newTotalUserCount > organization.seats) { + return { seatLimitReached: { message: errorMessage } }; + } + + return null; }; } + +export function isDynamicSeatPlan(productTierType: ProductTierType): boolean { + return !isFixedSeatPlan(productTierType); +} + +export function isFixedSeatPlan(productTierType: ProductTierType): boolean { + switch (productTierType) { + case ProductTierType.Free: + case ProductTierType.Families: + case ProductTierType.TeamsStarter: + return true; + default: + return false; + } +} + +function getUniqueNewEmailCount( + allOrganizationUserEmails: string[], + control: AbstractControl, +): number { + const newEmailsToAdd = Array.from( + new Set( + control.value + .split(",") + .filter( + (newEmailToAdd: string) => + newEmailToAdd && + newEmailToAdd.trim() !== "" && + !allOrganizationUserEmails.some( + (existingEmail) => existingEmail === newEmailToAdd.trim(), + ), + ), + ), + ); + + return newEmailsToAdd.length; +} 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 5e1da94be7..1ea7764222 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 @@ -76,6 +76,7 @@ import { MemberDialogTab, openUserAddEditDialog, } from "./components/member-dialog"; +import { isFixedSeatPlan } from "./components/member-dialog/validators/org-seat-limit-reached.validator"; import { ResetPasswordComponent, ResetPasswordDialogResult, @@ -109,6 +110,10 @@ export class MembersComponent extends BaseMembersComponent<OrganizationUserView> protected rowHeight = 69; protected rowHeightClass = `tw-h-[69px]`; + get occupiedSeatCount(): number { + return this.dataSource.activeUserCount; + } + constructor( apiService: ApiService, i18nService: I18nService, @@ -475,68 +480,79 @@ export class MembersComponent extends BaseMembersComponent<OrganizationUserView> firstValueFrom(simpleDialog.closed).then(this.handleDialogClose.bind(this)); } - async edit(user: OrganizationUserView, initialTab: MemberDialogTab = MemberDialogTab.Role) { - if ( - !user && - this.organization.hasReseller && - this.organization.seats === this.dataSource.confirmedUserCount - ) { + private async handleInviteDialog() { + const dialog = openUserAddEditDialog(this.dialogService, { + data: { + kind: "Add", + organizationId: this.organization.id, + allOrganizationUserEmails: this.dataSource.data?.map((user) => user.email) ?? [], + occupiedSeatCount: this.occupiedSeatCount, + isOnSecretsManagerStandalone: this.orgIsOnSecretsManagerStandalone, + }, + }); + + const result = await lastValueFrom(dialog.closed); + + if (result === MemberDialogResult.Saved) { + await this.load(); + } + } + + private async handleSeatLimitForFixedTiers() { + if (!this.organization.canEditSubscription) { + await this.showSeatLimitReachedDialog(); + return; + } + + const reference = openChangePlanDialog(this.dialogService, { + data: { + organizationId: this.organization.id, + subscription: null, + productTierType: this.organization.productTierType, + }, + }); + + const result = await lastValueFrom(reference.closed); + + if (result === ChangePlanDialogResultType.Submitted) { + await this.load(); + } + } + + async invite() { + if (this.organization.hasReseller && this.organization.seats === this.occupiedSeatCount) { this.toastService.showToast({ variant: "error", title: this.i18nService.t("seatLimitReached"), message: this.i18nService.t("contactYourProvider"), }); + return; } - // Invite User: Add Flow - // Click on user email: Edit Flow - - // User attempting to invite new users in a free org with max users if ( - !user && - this.dataSource.data.length === this.organization.seats && - (this.organization.productTierType === ProductTierType.Free || - this.organization.productTierType === ProductTierType.TeamsStarter || - this.organization.productTierType === ProductTierType.Families) + this.occupiedSeatCount === this.organization.seats && + isFixedSeatPlan(this.organization.productTierType) ) { - if (!this.organization.canEditSubscription) { - await this.showSeatLimitReachedDialog(); - return; - } + await this.handleSeatLimitForFixedTiers(); - const reference = openChangePlanDialog(this.dialogService, { - data: { - organizationId: this.organization.id, - subscription: null, - productTierType: this.organization.productTierType, - }, - }); - - const result = await lastValueFrom(reference.closed); - - if (result === ChangePlanDialogResultType.Submitted) { - await this.load(); - } return; } - const numSeatsUsed = - this.dataSource.confirmedUserCount + - this.dataSource.invitedUserCount + - this.dataSource.acceptedUserCount; + await this.handleInviteDialog(); + } + async edit(user: OrganizationUserView, initialTab: MemberDialogTab = MemberDialogTab.Role) { const dialog = openUserAddEditDialog(this.dialogService, { data: { + kind: "Edit", name: this.userNamePipe.transform(user), organizationId: this.organization.id, - organizationUserId: user != null ? user.id : null, - allOrganizationUserEmails: this.dataSource.data?.map((user) => user.email) ?? [], - usesKeyConnector: user?.usesKeyConnector, + organizationUserId: user.id, + usesKeyConnector: user.usesKeyConnector, isOnSecretsManagerStandalone: this.orgIsOnSecretsManagerStandalone, initialTab: initialTab, - numSeatsUsed, - managedByOrganization: user?.managedByOrganization, + managedByOrganization: user.managedByOrganization, }, }); @@ -548,9 +564,7 @@ export class MembersComponent extends BaseMembersComponent<OrganizationUserView> case MemberDialogResult.Saved: case MemberDialogResult.Revoked: case MemberDialogResult.Restored: - // FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling. - // eslint-disable-next-line @typescript-eslint/no-floating-promises - this.load(); + await this.load(); break; } } diff --git a/bitwarden_license/bit-web/src/app/tools/reports/member-access-report/member-access-report.component.ts b/bitwarden_license/bit-web/src/app/tools/reports/member-access-report/member-access-report.component.ts index 321aae165c..52ec290103 100644 --- a/bitwarden_license/bit-web/src/app/tools/reports/member-access-report/member-access-report.component.ts +++ b/bitwarden_license/bit-web/src/app/tools/reports/member-access-report/member-access-report.component.ts @@ -93,17 +93,16 @@ export class MemberAccessReportComponent implements OnInit { }); }; - edit = async (user: MemberAccessReportView | null): Promise<void> => { + edit = async (user: MemberAccessReportView): Promise<void> => { const dialog = openUserAddEditDialog(this.dialogService, { data: { + kind: "Edit", name: this.userNamePipe.transform(user), organizationId: this.organizationId, - organizationUserId: user != null ? user.userGuid : null, - allOrganizationUserEmails: this.dataSource.data?.map((user) => user.email) ?? [], - usesKeyConnector: user?.usesKeyConnector, + organizationUserId: user.userGuid, + usesKeyConnector: user.usesKeyConnector, isOnSecretsManagerStandalone: this.orgIsOnSecretsManagerStandalone, initialTab: MemberDialogTab.Role, - numSeatsUsed: this.dataSource.data.length, }, });