mirror of
https://github.com/bitwarden/browser
synced 2025-12-12 22:33:35 +00: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:
@@ -2,9 +2,11 @@
|
|||||||
<bit-dialog [disablePadding]="!loading" dialogSize="large">
|
<bit-dialog [disablePadding]="!loading" dialogSize="large">
|
||||||
<span bitDialogTitle>
|
<span bitDialogTitle>
|
||||||
{{ title }}
|
{{ title }}
|
||||||
<span class="tw-text-sm tw-normal-case tw-text-muted" *ngIf="!loading && params.name">{{
|
<span
|
||||||
params.name
|
class="tw-text-sm tw-normal-case tw-text-muted"
|
||||||
}}</span>
|
*ngIf="!loading && editParams$ && (editParams$ | async)?.name"
|
||||||
|
>{{ (editParams$ | async)?.name }}</span
|
||||||
|
>
|
||||||
<span bitBadge variant="secondary" *ngIf="isRevoked">{{ "revoked" | i18n }}</span>
|
<span bitBadge variant="secondary" *ngIf="isRevoked">{{ "revoked" | i18n }}</span>
|
||||||
</span>
|
</span>
|
||||||
<div bitDialogContent>
|
<div bitDialogContent>
|
||||||
@@ -268,7 +270,9 @@
|
|||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
*ngIf="
|
*ngIf="
|
||||||
editMode && (!(accountDeprovisioningEnabled$ | async) || !params.managedByOrganization)
|
this.editMode &&
|
||||||
|
(!(accountDeprovisioningEnabled$ | async) ||
|
||||||
|
!(editParams$ | async)?.managedByOrganization)
|
||||||
"
|
"
|
||||||
type="button"
|
type="button"
|
||||||
bitIconButton="bwi-close"
|
bitIconButton="bwi-close"
|
||||||
@@ -280,7 +284,9 @@
|
|||||||
></button>
|
></button>
|
||||||
<button
|
<button
|
||||||
*ngIf="
|
*ngIf="
|
||||||
editMode && (accountDeprovisioningEnabled$ | async) && params.managedByOrganization
|
this.editMode &&
|
||||||
|
(accountDeprovisioningEnabled$ | async) &&
|
||||||
|
(editParams$ | async)?.managedByOrganization
|
||||||
"
|
"
|
||||||
type="button"
|
type="button"
|
||||||
bitIconButton="bwi-trash"
|
bitIconButton="bwi-trash"
|
||||||
|
|||||||
@@ -55,6 +55,7 @@ import {
|
|||||||
} from "../../../shared/components/access-selector";
|
} from "../../../shared/components/access-selector";
|
||||||
|
|
||||||
import { commaSeparatedEmails } from "./validators/comma-separated-emails.validator";
|
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";
|
import { orgSeatLimitReachedValidator } from "./validators/org-seat-limit-reached.validator";
|
||||||
|
|
||||||
export enum MemberDialogTab {
|
export enum MemberDialogTab {
|
||||||
@@ -63,18 +64,28 @@ export enum MemberDialogTab {
|
|||||||
Collections = 2,
|
Collections = 2,
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface MemberDialogParams {
|
interface CommonMemberDialogParams {
|
||||||
name: string;
|
|
||||||
organizationId: string;
|
|
||||||
organizationUserId: string;
|
|
||||||
allOrganizationUserEmails: string[];
|
|
||||||
usesKeyConnector: boolean;
|
|
||||||
isOnSecretsManagerStandalone: boolean;
|
isOnSecretsManagerStandalone: boolean;
|
||||||
initialTab?: MemberDialogTab;
|
organizationId: string;
|
||||||
numSeatsUsed: number;
|
|
||||||
managedByOrganization?: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 {
|
export enum MemberDialogResult {
|
||||||
Saved = "saved",
|
Saved = "saved",
|
||||||
Canceled = "canceled",
|
Canceled = "canceled",
|
||||||
@@ -98,6 +109,7 @@ export class MemberDialogComponent implements OnDestroy {
|
|||||||
showNoMasterPasswordWarning = false;
|
showNoMasterPasswordWarning = false;
|
||||||
isOnSecretsManagerStandalone: boolean;
|
isOnSecretsManagerStandalone: boolean;
|
||||||
remainingSeats$: Observable<number>;
|
remainingSeats$: Observable<number>;
|
||||||
|
editParams$: Observable<EditMemberDialogParams>;
|
||||||
|
|
||||||
protected organization$: Observable<Organization>;
|
protected organization$: Observable<Organization>;
|
||||||
protected collectionAccessItems: AccessItemView[] = [];
|
protected collectionAccessItems: AccessItemView[] = [];
|
||||||
@@ -143,6 +155,12 @@ export class MemberDialogComponent implements OnDestroy {
|
|||||||
return this.formGroup.value.type === OrganizationUserType.Custom;
|
return this.formGroup.value.type === OrganizationUserType.Custom;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
isEditDialogParams(
|
||||||
|
params: EditMemberDialogParams | AddMemberDialogParams,
|
||||||
|
): params is EditMemberDialogParams {
|
||||||
|
return params.kind === "Edit";
|
||||||
|
}
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
@Inject(DIALOG_DATA) protected params: MemberDialogParams,
|
@Inject(DIALOG_DATA) protected params: MemberDialogParams,
|
||||||
private dialogRef: DialogRef<MemberDialogResult>,
|
private dialogRef: DialogRef<MemberDialogResult>,
|
||||||
@@ -168,9 +186,24 @@ export class MemberDialogComponent implements OnDestroy {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
this.editMode = this.params.organizationUserId != null;
|
let userDetails$;
|
||||||
this.tabIndex = this.params.initialTab ?? MemberDialogTab.Role;
|
if (this.isEditDialogParams(this.params)) {
|
||||||
this.title = this.i18nService.t(this.editMode ? "editMember" : "inviteMember");
|
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;
|
this.isOnSecretsManagerStandalone = this.params.isOnSecretsManagerStandalone;
|
||||||
|
|
||||||
if (this.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(
|
this.allowAdminAccessToAllCollectionItems$ = this.organization$.pipe(
|
||||||
map((organization) => {
|
map((organization) => {
|
||||||
return organization.allowAdminAccessToAllCollectionItems;
|
return organization.allowAdminAccessToAllCollectionItems;
|
||||||
@@ -271,18 +300,32 @@ export class MemberDialogComponent implements OnDestroy {
|
|||||||
});
|
});
|
||||||
|
|
||||||
this.remainingSeats$ = this.organization$.pipe(
|
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) {
|
private setFormValidators(organization: Organization) {
|
||||||
|
if (this.isEditDialogParams(this.params)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const emailsControlValidators = [
|
const emailsControlValidators = [
|
||||||
Validators.required,
|
Validators.required,
|
||||||
commaSeparatedEmails,
|
commaSeparatedEmails,
|
||||||
|
inputEmailLimitValidator(organization, (maxEmailsCount: number) =>
|
||||||
|
this.i18nService.t("tooManyEmails", maxEmailsCount),
|
||||||
|
),
|
||||||
orgSeatLimitReachedValidator(
|
orgSeatLimitReachedValidator(
|
||||||
organization,
|
organization,
|
||||||
this.params.allOrganizationUserEmails,
|
this.params.allOrganizationUserEmails,
|
||||||
this.i18nService.t("subscriptionUpgrade", organization.seats),
|
this.i18nService.t("subscriptionUpgrade", organization.seats),
|
||||||
|
this.params.occupiedSeatCount,
|
||||||
),
|
),
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -433,14 +476,25 @@ export class MemberDialogComponent implements OnDestroy {
|
|||||||
return;
|
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();
|
const userView = new OrganizationUserAdminView();
|
||||||
userView.id = this.params.organizationUserId;
|
|
||||||
userView.organizationId = this.params.organizationId;
|
userView.organizationId = this.params.organizationId;
|
||||||
userView.type = this.formGroup.value.type;
|
userView.type = this.formGroup.value.type;
|
||||||
|
|
||||||
userView.permissions = this.setRequestPermissions(
|
userView.permissions = this.setRequestPermissions(
|
||||||
userView.permissions ?? new PermissionsApi(),
|
userView.permissions ?? new PermissionsApi(),
|
||||||
userView.type !== OrganizationUserType.Custom,
|
userView.type !== OrganizationUserType.Custom,
|
||||||
);
|
);
|
||||||
|
|
||||||
userView.collections = this.formGroup.value.access
|
userView.collections = this.formGroup.value.access
|
||||||
.filter((v) => v.type === AccessItemType.Collection)
|
.filter((v) => v.type === AccessItemType.Collection)
|
||||||
.map(convertToSelectionView);
|
.map(convertToSelectionView);
|
||||||
@@ -451,44 +505,40 @@ export class MemberDialogComponent implements OnDestroy {
|
|||||||
|
|
||||||
userView.accessSecretsManager = this.formGroup.value.accessSecretsManager;
|
userView.accessSecretsManager = this.formGroup.value.accessSecretsManager;
|
||||||
|
|
||||||
if (this.editMode) {
|
return userView;
|
||||||
await this.userService.save(userView);
|
}
|
||||||
} else {
|
|
||||||
userView.id = this.params.organizationUserId;
|
private async handleEditUser(
|
||||||
const maxEmailsCount =
|
userView: OrganizationUserAdminView,
|
||||||
organization.productTierType === ProductTierType.TeamsStarter ? 10 : 20;
|
params: EditMemberDialogParams,
|
||||||
const emails = [...new Set(this.formGroup.value.emails.trim().split(/\s*,\s*/))];
|
) {
|
||||||
if (emails.length > maxEmailsCount) {
|
userView.id = params.organizationUserId;
|
||||||
this.formGroup.controls.emails.setErrors({
|
await this.userService.save(userView);
|
||||||
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);
|
|
||||||
}
|
|
||||||
|
|
||||||
this.toastService.showToast({
|
this.toastService.showToast({
|
||||||
variant: "success",
|
variant: "success",
|
||||||
title: null,
|
title: null,
|
||||||
message: this.i18nService.t(
|
message: this.i18nService.t("editedUserId", params.name),
|
||||||
this.editMode ? "editedUserId" : "invitedUsers",
|
});
|
||||||
this.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);
|
this.close(MemberDialogResult.Saved);
|
||||||
};
|
}
|
||||||
|
|
||||||
remove = async () => {
|
remove = async () => {
|
||||||
if (!this.editMode) {
|
if (!this.isEditDialogParams(this.params)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -507,7 +557,7 @@ export class MemberDialogComponent implements OnDestroy {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (this.showNoMasterPasswordWarning) {
|
if (this.showNoMasterPasswordWarning) {
|
||||||
confirmed = await this.noMasterPasswordConfirmationDialog();
|
confirmed = await this.noMasterPasswordConfirmationDialog(this.params.name);
|
||||||
|
|
||||||
if (!confirmed) {
|
if (!confirmed) {
|
||||||
return false;
|
return false;
|
||||||
@@ -528,7 +578,7 @@ export class MemberDialogComponent implements OnDestroy {
|
|||||||
};
|
};
|
||||||
|
|
||||||
revoke = async () => {
|
revoke = async () => {
|
||||||
if (!this.editMode) {
|
if (!this.isEditDialogParams(this.params)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -544,7 +594,7 @@ export class MemberDialogComponent implements OnDestroy {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (this.showNoMasterPasswordWarning) {
|
if (this.showNoMasterPasswordWarning) {
|
||||||
confirmed = await this.noMasterPasswordConfirmationDialog();
|
confirmed = await this.noMasterPasswordConfirmationDialog(this.params.name);
|
||||||
|
|
||||||
if (!confirmed) {
|
if (!confirmed) {
|
||||||
return false;
|
return false;
|
||||||
@@ -566,7 +616,7 @@ export class MemberDialogComponent implements OnDestroy {
|
|||||||
};
|
};
|
||||||
|
|
||||||
restore = async () => {
|
restore = async () => {
|
||||||
if (!this.editMode) {
|
if (!this.isEditDialogParams(this.params)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -585,7 +635,7 @@ export class MemberDialogComponent implements OnDestroy {
|
|||||||
};
|
};
|
||||||
|
|
||||||
delete = async () => {
|
delete = async () => {
|
||||||
if (!this.editMode) {
|
if (!this.isEditDialogParams(this.params)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -633,14 +683,14 @@ export class MemberDialogComponent implements OnDestroy {
|
|||||||
this.dialogRef.close(result);
|
this.dialogRef.close(result);
|
||||||
}
|
}
|
||||||
|
|
||||||
private noMasterPasswordConfirmationDialog() {
|
private noMasterPasswordConfirmationDialog(username: string) {
|
||||||
return this.dialogService.openSimpleDialog({
|
return this.dialogService.openSimpleDialog({
|
||||||
title: {
|
title: {
|
||||||
key: "removeOrgUserNoMasterPasswordTitle",
|
key: "removeOrgUserNoMasterPasswordTitle",
|
||||||
},
|
},
|
||||||
content: {
|
content: {
|
||||||
key: "removeOrgUserNoMasterPasswordDesc",
|
key: "removeOrgUserNoMasterPasswordDesc",
|
||||||
placeholders: [this.params.name],
|
placeholders: [username],
|
||||||
},
|
},
|
||||||
type: "warning",
|
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 { OrganizationUserType } from "@bitwarden/common/admin-console/enums";
|
||||||
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
|
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
|
||||||
import { ProductTierType } from "@bitwarden/common/billing/enums";
|
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> = {}) =>
|
const orgFactory = (props: Partial<Organization> = {}) =>
|
||||||
Object.assign(
|
Object.assign(
|
||||||
@@ -17,20 +21,35 @@ const orgFactory = (props: Partial<Organization> = {}) =>
|
|||||||
props,
|
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", () => {
|
describe("orgSeatLimitReachedValidator", () => {
|
||||||
let organization: Organization;
|
let organization: Organization;
|
||||||
let allOrganizationUserEmails: string[];
|
let allOrganizationUserEmails: string[];
|
||||||
let validatorFn: (control: AbstractControl) => ValidationErrors | null;
|
let occupiedSeatCount: number;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
allOrganizationUserEmails = ["user1@example.com"];
|
allOrganizationUserEmails = [createUniqueEmailString(1)];
|
||||||
|
occupiedSeatCount = 1;
|
||||||
|
organization = null;
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should return null when control value is empty", () => {
|
it("should return null when control value is empty", () => {
|
||||||
validatorFn = orgSeatLimitReachedValidator(
|
const validatorFn = orgSeatLimitReachedValidator(
|
||||||
organization,
|
organization,
|
||||||
allOrganizationUserEmails,
|
allOrganizationUserEmails,
|
||||||
"You cannot invite more than 2 members without upgrading your plan.",
|
"You cannot invite more than 2 members without upgrading your plan.",
|
||||||
|
occupiedSeatCount,
|
||||||
);
|
);
|
||||||
const control = new FormControl("");
|
const control = new FormControl("");
|
||||||
|
|
||||||
@@ -40,10 +59,11 @@ describe("orgSeatLimitReachedValidator", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("should return null when control value is null", () => {
|
it("should return null when control value is null", () => {
|
||||||
validatorFn = orgSeatLimitReachedValidator(
|
const validatorFn = orgSeatLimitReachedValidator(
|
||||||
organization,
|
organization,
|
||||||
allOrganizationUserEmails,
|
allOrganizationUserEmails,
|
||||||
"You cannot invite more than 2 members without upgrading your plan.",
|
"You cannot invite more than 2 members without upgrading your plan.",
|
||||||
|
occupiedSeatCount,
|
||||||
);
|
);
|
||||||
const control = new FormControl(null);
|
const control = new FormControl(null);
|
||||||
|
|
||||||
@@ -52,82 +72,123 @@ describe("orgSeatLimitReachedValidator", () => {
|
|||||||
expect(result).toBeNull();
|
expect(result).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should return null when max seats are not exceeded on free plan", () => {
|
it("should return null when on dynamic seat plan", () => {
|
||||||
organization = orgFactory({
|
const control = new FormControl(createUniqueEmailString(1));
|
||||||
productTierType: ProductTierType.Free,
|
const organization = orgFactory({
|
||||||
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({
|
|
||||||
productTierType: ProductTierType.Enterprise,
|
productTierType: ProductTierType.Enterprise,
|
||||||
seats: 100,
|
seats: 100,
|
||||||
});
|
});
|
||||||
validatorFn = orgSeatLimitReachedValidator(
|
|
||||||
|
const validatorFn = orgSeatLimitReachedValidator(
|
||||||
organization,
|
organization,
|
||||||
allOrganizationUserEmails,
|
allOrganizationUserEmails,
|
||||||
"You cannot invite more than 2 members without upgrading your plan.",
|
"Enterprise plan dummy error.",
|
||||||
|
occupiedSeatCount,
|
||||||
);
|
);
|
||||||
|
|
||||||
const result = validatorFn(control);
|
const result = validatorFn(control);
|
||||||
|
|
||||||
expect(result).toBeNull();
|
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 organization An object representing the organization
|
||||||
* @param allOrganizationUserEmails An array of strings with existing user email addresses
|
* @param allOrganizationUserEmails An array of strings with existing user email addresses
|
||||||
* @param errorMessage A localized string to display if validation fails
|
* @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`
|
* @returns A function that validates an `AbstractControl` and returns `ValidationErrors` or `null`
|
||||||
*/
|
*/
|
||||||
export function orgSeatLimitReachedValidator(
|
export function orgSeatLimitReachedValidator(
|
||||||
organization: Organization,
|
organization: Organization,
|
||||||
allOrganizationUserEmails: string[],
|
allOrganizationUserEmails: string[],
|
||||||
errorMessage: string,
|
errorMessage: string,
|
||||||
|
occupiedSeatCount: number,
|
||||||
): ValidatorFn {
|
): ValidatorFn {
|
||||||
return (control: AbstractControl): ValidationErrors | null => {
|
return (control: AbstractControl): ValidationErrors | null => {
|
||||||
if (control.value === "" || !control.value) {
|
if (!control.value?.trim()) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const newEmailsToAdd = Array.from(
|
if (isDynamicSeatPlan(organization.productTierType)) {
|
||||||
new Set(
|
return null;
|
||||||
control.value
|
}
|
||||||
.split(",")
|
|
||||||
.filter(
|
|
||||||
(newEmailToAdd: string) =>
|
|
||||||
newEmailToAdd &&
|
|
||||||
newEmailToAdd.trim() !== "" &&
|
|
||||||
!allOrganizationUserEmails.some(
|
|
||||||
(existingEmail) => existingEmail === newEmailToAdd.trim(),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
const productHasAdditionalSeatsOption =
|
const newTotalUserCount =
|
||||||
organization.productTierType !== ProductTierType.Free &&
|
occupiedSeatCount + getUniqueNewEmailCount(allOrganizationUserEmails, control);
|
||||||
organization.productTierType !== ProductTierType.Families &&
|
|
||||||
organization.productTierType !== ProductTierType.TeamsStarter;
|
|
||||||
|
|
||||||
return !productHasAdditionalSeatsOption &&
|
if (newTotalUserCount > organization.seats) {
|
||||||
allOrganizationUserEmails.length + newEmailsToAdd.length > organization.seats
|
return { seatLimitReached: { message: errorMessage } };
|
||||||
? { seatLimitReached: { message: errorMessage } }
|
}
|
||||||
: null;
|
|
||||||
|
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,
|
MemberDialogTab,
|
||||||
openUserAddEditDialog,
|
openUserAddEditDialog,
|
||||||
} from "./components/member-dialog";
|
} from "./components/member-dialog";
|
||||||
|
import { isFixedSeatPlan } from "./components/member-dialog/validators/org-seat-limit-reached.validator";
|
||||||
import {
|
import {
|
||||||
ResetPasswordComponent,
|
ResetPasswordComponent,
|
||||||
ResetPasswordDialogResult,
|
ResetPasswordDialogResult,
|
||||||
@@ -109,6 +110,10 @@ export class MembersComponent extends BaseMembersComponent<OrganizationUserView>
|
|||||||
protected rowHeight = 69;
|
protected rowHeight = 69;
|
||||||
protected rowHeightClass = `tw-h-[69px]`;
|
protected rowHeightClass = `tw-h-[69px]`;
|
||||||
|
|
||||||
|
get occupiedSeatCount(): number {
|
||||||
|
return this.dataSource.activeUserCount;
|
||||||
|
}
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
apiService: ApiService,
|
apiService: ApiService,
|
||||||
i18nService: I18nService,
|
i18nService: I18nService,
|
||||||
@@ -475,68 +480,79 @@ export class MembersComponent extends BaseMembersComponent<OrganizationUserView>
|
|||||||
firstValueFrom(simpleDialog.closed).then(this.handleDialogClose.bind(this));
|
firstValueFrom(simpleDialog.closed).then(this.handleDialogClose.bind(this));
|
||||||
}
|
}
|
||||||
|
|
||||||
async edit(user: OrganizationUserView, initialTab: MemberDialogTab = MemberDialogTab.Role) {
|
private async handleInviteDialog() {
|
||||||
if (
|
const dialog = openUserAddEditDialog(this.dialogService, {
|
||||||
!user &&
|
data: {
|
||||||
this.organization.hasReseller &&
|
kind: "Add",
|
||||||
this.organization.seats === this.dataSource.confirmedUserCount
|
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({
|
this.toastService.showToast({
|
||||||
variant: "error",
|
variant: "error",
|
||||||
title: this.i18nService.t("seatLimitReached"),
|
title: this.i18nService.t("seatLimitReached"),
|
||||||
message: this.i18nService.t("contactYourProvider"),
|
message: this.i18nService.t("contactYourProvider"),
|
||||||
});
|
});
|
||||||
|
|
||||||
return;
|
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 (
|
if (
|
||||||
!user &&
|
this.occupiedSeatCount === this.organization.seats &&
|
||||||
this.dataSource.data.length === this.organization.seats &&
|
isFixedSeatPlan(this.organization.productTierType)
|
||||||
(this.organization.productTierType === ProductTierType.Free ||
|
|
||||||
this.organization.productTierType === ProductTierType.TeamsStarter ||
|
|
||||||
this.organization.productTierType === ProductTierType.Families)
|
|
||||||
) {
|
) {
|
||||||
if (!this.organization.canEditSubscription) {
|
await this.handleSeatLimitForFixedTiers();
|
||||||
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();
|
|
||||||
}
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const numSeatsUsed =
|
await this.handleInviteDialog();
|
||||||
this.dataSource.confirmedUserCount +
|
}
|
||||||
this.dataSource.invitedUserCount +
|
|
||||||
this.dataSource.acceptedUserCount;
|
|
||||||
|
|
||||||
|
async edit(user: OrganizationUserView, initialTab: MemberDialogTab = MemberDialogTab.Role) {
|
||||||
const dialog = openUserAddEditDialog(this.dialogService, {
|
const dialog = openUserAddEditDialog(this.dialogService, {
|
||||||
data: {
|
data: {
|
||||||
|
kind: "Edit",
|
||||||
name: this.userNamePipe.transform(user),
|
name: this.userNamePipe.transform(user),
|
||||||
organizationId: this.organization.id,
|
organizationId: this.organization.id,
|
||||||
organizationUserId: user != null ? user.id : null,
|
organizationUserId: user.id,
|
||||||
allOrganizationUserEmails: this.dataSource.data?.map((user) => user.email) ?? [],
|
usesKeyConnector: user.usesKeyConnector,
|
||||||
usesKeyConnector: user?.usesKeyConnector,
|
|
||||||
isOnSecretsManagerStandalone: this.orgIsOnSecretsManagerStandalone,
|
isOnSecretsManagerStandalone: this.orgIsOnSecretsManagerStandalone,
|
||||||
initialTab: initialTab,
|
initialTab: initialTab,
|
||||||
numSeatsUsed,
|
managedByOrganization: user.managedByOrganization,
|
||||||
managedByOrganization: user?.managedByOrganization,
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -548,9 +564,7 @@ export class MembersComponent extends BaseMembersComponent<OrganizationUserView>
|
|||||||
case MemberDialogResult.Saved:
|
case MemberDialogResult.Saved:
|
||||||
case MemberDialogResult.Revoked:
|
case MemberDialogResult.Revoked:
|
||||||
case MemberDialogResult.Restored:
|
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.
|
await this.load();
|
||||||
// eslint-disable-next-line @typescript-eslint/no-floating-promises
|
|
||||||
this.load();
|
|
||||||
break;
|
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, {
|
const dialog = openUserAddEditDialog(this.dialogService, {
|
||||||
data: {
|
data: {
|
||||||
|
kind: "Edit",
|
||||||
name: this.userNamePipe.transform(user),
|
name: this.userNamePipe.transform(user),
|
||||||
organizationId: this.organizationId,
|
organizationId: this.organizationId,
|
||||||
organizationUserId: user != null ? user.userGuid : null,
|
organizationUserId: user.userGuid,
|
||||||
allOrganizationUserEmails: this.dataSource.data?.map((user) => user.email) ?? [],
|
usesKeyConnector: user.usesKeyConnector,
|
||||||
usesKeyConnector: user?.usesKeyConnector,
|
|
||||||
isOnSecretsManagerStandalone: this.orgIsOnSecretsManagerStandalone,
|
isOnSecretsManagerStandalone: this.orgIsOnSecretsManagerStandalone,
|
||||||
initialTab: MemberDialogTab.Role,
|
initialTab: MemberDialogTab.Role,
|
||||||
numSeatsUsed: this.dataSource.data.length,
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user