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,
       },
     });