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:
parent
aa1c0ca0ee
commit
620affd3d5
@ -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"
|
||||
|
@ -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",
|
||||
});
|
||||
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
@ -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) } };
|
||||
};
|
||||
}
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
@ -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;
|
||||
}
|
||||
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
@ -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,
|
||||
},
|
||||
});
|
||||
|
||||
|
Loading…
Reference in New Issue
Block a user