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 91d0fc97e6..f449b7f206 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 @@ -35,6 +35,7 @@ import { } from "../../../shared/components/access-selector"; import { commaSeparatedEmails } from "./validators/comma-separated-emails.validator"; +import { freeOrgSeatLimitReachedValidator } from "./validators/free-org-inv-limit-reached.validator"; export enum MemberDialogTab { Role = 0, @@ -46,6 +47,7 @@ export interface MemberDialogParams { name: string; organizationId: string; organizationUserId: string; + allOrganizationUserEmails: string[]; usesKeyConnector: boolean; initialTab?: MemberDialogTab; } @@ -79,7 +81,7 @@ export class MemberDialogComponent implements OnInit, OnDestroy { protected groupAccessItems: AccessItemView[] = []; protected tabIndex: MemberDialogTab; protected formGroup = this.formBuilder.group({ - emails: ["", [Validators.required, commaSeparatedEmails]], + emails: ["", { updateOn: "blur" }], type: OrganizationUserType.User, externalId: this.formBuilder.control({ value: "", disabled: true }), accessAllCollections: false, @@ -167,6 +169,20 @@ export class MemberDialogComponent implements OnInit, OnDestroy { this.canUseCustomPermissions = organization.useCustomPermissions; this.canUseSecretsManager = organization.useSecretsManager && flagEnabled("secretsManager"); + const emailsControlValidators = [ + Validators.required, + commaSeparatedEmails, + freeOrgSeatLimitReachedValidator( + this.organization, + this.params.allOrganizationUserEmails, + this.i18nService.t("subscriptionFreePlan", organization.seats) + ), + ]; + + const emailsControl = this.formGroup.get("emails"); + emailsControl.setValidators(emailsControlValidators); + emailsControl.updateValueAndValidity(); + this.collectionAccessItems = [].concat( collections.map((c) => mapCollectionToAccessItemView(c)) ); diff --git a/apps/web/src/app/admin-console/organizations/members/components/member-dialog/validators/free-org-inv-limit-reached.validator.spec.ts b/apps/web/src/app/admin-console/organizations/members/components/member-dialog/validators/free-org-inv-limit-reached.validator.spec.ts new file mode 100644 index 0000000000..5c17a128ac --- /dev/null +++ b/apps/web/src/app/admin-console/organizations/members/components/member-dialog/validators/free-org-inv-limit-reached.validator.spec.ts @@ -0,0 +1,106 @@ +import { AbstractControl, FormControl, ValidationErrors } from "@angular/forms"; + +import { OrganizationUserType } from "@bitwarden/common/admin-console/enums"; +import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; +import { ProductType } from "@bitwarden/common/enums"; + +import { freeOrgSeatLimitReachedValidator } from "./free-org-inv-limit-reached.validator"; + +const orgFactory = (props: Partial = {}) => + Object.assign( + new Organization(), + { + id: "myOrgId", + enabled: true, + type: OrganizationUserType.Admin, + }, + props + ); + +describe("freeOrgSeatLimitReachedValidator", () => { + let organization: Organization; + let allOrganizationUserEmails: string[]; + let validatorFn: (control: AbstractControl) => ValidationErrors | null; + + beforeEach(() => { + allOrganizationUserEmails = ["user1@example.com"]; + }); + + it("should return null when control value is empty", () => { + validatorFn = freeOrgSeatLimitReachedValidator( + organization, + allOrganizationUserEmails, + "You cannot invite more than 2 members without upgrading your plan." + ); + const control = new FormControl(""); + + const result = validatorFn(control); + + expect(result).toBeNull(); + }); + + it("should return null when control value is null", () => { + validatorFn = freeOrgSeatLimitReachedValidator( + organization, + allOrganizationUserEmails, + "You cannot invite more than 2 members without upgrading your plan." + ); + const control = new FormControl(null); + + const result = validatorFn(control); + + expect(result).toBeNull(); + }); + + it("should return null when max seats are not exceeded on free plan", () => { + organization = orgFactory({ + planProductType: ProductType.Free, + seats: 2, + }); + validatorFn = freeOrgSeatLimitReachedValidator( + 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 validation error when max seats are exceeded on free plan", () => { + organization = orgFactory({ + planProductType: ProductType.Free, + seats: 2, + }); + const errorMessage = "You cannot invite more than 2 members without upgrading your plan."; + validatorFn = freeOrgSeatLimitReachedValidator( + 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({ freePlanLimitReached: { message: errorMessage } }); + }); + + it("should return null when not on free plan", () => { + const control = new FormControl("user2@example.com,user3@example.com"); + organization = orgFactory({ + planProductType: ProductType.Enterprise, + seats: 100, + }); + validatorFn = freeOrgSeatLimitReachedValidator( + organization, + allOrganizationUserEmails, + "You cannot invite more than 2 members without upgrading your plan." + ); + + const result = validatorFn(control); + + expect(result).toBeNull(); + }); +}); diff --git a/apps/web/src/app/admin-console/organizations/members/components/member-dialog/validators/free-org-inv-limit-reached.validator.ts b/apps/web/src/app/admin-console/organizations/members/components/member-dialog/validators/free-org-inv-limit-reached.validator.ts new file mode 100644 index 0000000000..6d5c45a64d --- /dev/null +++ b/apps/web/src/app/admin-console/organizations/members/components/member-dialog/validators/free-org-inv-limit-reached.validator.ts @@ -0,0 +1,36 @@ +import { AbstractControl, ValidationErrors, ValidatorFn } from "@angular/forms"; + +import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; +import { ProductType } from "@bitwarden/common/enums"; + +/** + * Checks if the limit of free organization seats has been reached when adding new users + * @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 + * @returns A function that validates an `AbstractControl` and returns `ValidationErrors` or `null` + */ +export function freeOrgSeatLimitReachedValidator( + organization: Organization, + allOrganizationUserEmails: string[], + errorMessage: string +): ValidatorFn { + return (control: AbstractControl): ValidationErrors | null => { + if (control.value === "" || !control.value) { + return null; + } + + const newEmailsToAdd = control.value + .split(",") + .filter( + (newEmailToAdd: string) => + newEmailToAdd && + !allOrganizationUserEmails.some((existingEmail) => existingEmail === newEmailToAdd) + ); + + return organization.planProductType === ProductType.Free && + allOrganizationUserEmails.length + newEmailsToAdd.length > organization.seats + ? { freePlanLimitReached: { message: errorMessage } } + : null; + }; +} diff --git a/apps/web/src/app/admin-console/organizations/members/people.component.ts b/apps/web/src/app/admin-console/organizations/members/people.component.ts index 58a02274f8..e7d9bf6772 100644 --- a/apps/web/src/app/admin-console/organizations/members/people.component.ts +++ b/apps/web/src/app/admin-console/organizations/members/people.component.ts @@ -396,6 +396,7 @@ export class PeopleComponent name: this.userNamePipe.transform(user), organizationId: this.organization.id, organizationUserId: user != null ? user.id : null, + allOrganizationUserEmails: this.allUsers?.map((user) => user.email) ?? [], usesKeyConnector: user?.usesKeyConnector, initialTab: initialTab, },