1
0
mirror of https://github.com/bitwarden/browser.git synced 2025-01-30 22:41:33 +01:00

[PM-13755] Exclude revoked users from the occupied seats count (#12277)

It also includes a refactor to decouple the invite and edit user flows.
This commit is contained in:
Jimmy Vo 2025-01-23 14:24:51 -05:00 committed by GitHub
parent aa1c0ca0ee
commit 620affd3d5
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 593 additions and 205 deletions

View File

@ -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"

View File

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

View File

@ -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();
});
});
});

View File

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

View File

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

View File

@ -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;
}

View File

@ -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;
}
}

View File

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