Skip to content

Commit

Permalink
[AC-1708] Teams Starter Plan (#6740)
Browse files Browse the repository at this point in the history
* Added support for the teams starter plan

* Plans now respect display sort order. Updated teams starter to be in its own product

* Remove upgrade button and show new copy instead -- wip copy

* Added upgrade dialog for teams starter plan when adding an 11th user

* Updated the add user validator to check if plan is teams starter. Updated to not count duplicated emails in the overall count

* Renamed validator to be more descriptive and added additional unit tests

* Added validator for org types that require customer support to upgrade

* Updated small localization for teams plan to account for new starter plan

* Removed invalid tests

* Resolved issues around free trial flow for teams starter

* Added new layout for teams starter free trial flow

* Updated copy following demo. Resolved display issues discovered during demo

* Removed temporary copy for testing

* Updated the second step of free trial flow to use org display name

* Updated invite user modal to display 10 instead of 20 as the invite limit for Teams Starter

---------

Co-authored-by: cyprain-okeke <108260115+cyprain-okeke@users.noreply.github.com>
  • Loading branch information
cturnbull-bitwarden and cyprain-okeke authored Nov 3, 2023
1 parent 197059d commit 9f5226f
Show file tree
Hide file tree
Showing 25 changed files with 417 additions and 101 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,10 @@
<bit-form-field>
<bit-label>{{ "email" | i18n }}</bit-label>
<input id="emails" type="text" appAutoFocus bitInput formControlName="emails" />
<bit-hint>{{ "inviteMultipleEmailDesc" | i18n : "20" }}</bit-hint>
<bit-hint>{{
"inviteMultipleEmailDesc"
| i18n : (organization.planProductType === ProductType.TeamsStarter ? "10" : "20")
}}</bit-hint>
</bit-form-field>
</ng-container>
<fieldset role="radiogroup" aria-labelledby="roleGroupLabel" class="tw-mb-6">
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
} from "@bitwarden/common/admin-console/enums";
import { PermissionsApi } from "@bitwarden/common/admin-console/models/api/permissions.api";
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
import { ProductType } from "@bitwarden/common/enums";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { ConfigServiceAbstraction } from "@bitwarden/common/platform/abstractions/config/config.service.abstraction";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
Expand All @@ -37,7 +38,8 @@ import {
} from "../../../shared/components/access-selector";

import { commaSeparatedEmails } from "./validators/comma-separated-emails.validator";
import { freeOrgSeatLimitReachedValidator } from "./validators/free-org-inv-limit-reached.validator";
import { orgWithoutAdditionalSeatLimitReachedWithUpgradePathValidator } from "./validators/org-without-additional-seat-limit-reached-with-upgrade-path.validator";
import { orgWithoutAdditionalSeatLimitReachedWithoutUpgradePathValidator } from "./validators/org-without-additional-seat-limit-reached-without-upgrade-path.validator";

export enum MemberDialogTab {
Role = 0,
Expand Down Expand Up @@ -180,11 +182,16 @@ export class MemberDialogComponent implements OnInit, OnDestroy {
const emailsControlValidators = [
Validators.required,
commaSeparatedEmails,
freeOrgSeatLimitReachedValidator(
orgWithoutAdditionalSeatLimitReachedWithUpgradePathValidator(
this.organization,
this.params.allOrganizationUserEmails,
this.i18nService.t("subscriptionFreePlan", organization.seats)
),
orgWithoutAdditionalSeatLimitReachedWithoutUpgradePathValidator(
this.organization,
this.params.allOrganizationUserEmails,
this.i18nService.t("subscriptionFamiliesPlan", organization.seats)
),
];

const emailsControl = this.formGroup.get("emails");
Expand Down Expand Up @@ -367,10 +374,12 @@ export class MemberDialogComponent implements OnInit, OnDestroy {
await this.userService.save(userView);
} else {
userView.id = this.params.organizationUserId;
const maxEmailsCount =
this.organization.planProductType === ProductType.TeamsStarter ? 10 : 20;
const emails = [...new Set(this.formGroup.value.emails.trim().split(/\s*,\s*/))];
if (emails.length > 20) {
if (emails.length > maxEmailsCount) {
this.formGroup.controls.emails.setErrors({
tooManyEmails: { message: this.i18nService.t("tooManyEmails", 20) },
tooManyEmails: { message: this.i18nService.t("tooManyEmails", maxEmailsCount) },
});
return;
}
Expand Down Expand Up @@ -507,6 +516,8 @@ export class MemberDialogComponent implements OnInit, OnDestroy {
type: "warning",
});
}

protected readonly ProductType = ProductType;
}

function mapCollectionToAccessItemView(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { OrganizationUserType } from "@bitwarden/common/admin-console/enums";
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
import { ProductType } from "@bitwarden/common/enums";

import { freeOrgSeatLimitReachedValidator } from "./free-org-inv-limit-reached.validator";
import { orgWithoutAdditionalSeatLimitReachedWithUpgradePathValidator } from "./org-without-additional-seat-limit-reached-with-upgrade-path.validator";

const orgFactory = (props: Partial<Organization> = {}) =>
Object.assign(
Expand All @@ -17,7 +17,7 @@ const orgFactory = (props: Partial<Organization> = {}) =>
props
);

describe("freeOrgSeatLimitReachedValidator", () => {
describe("orgWithoutAdditionalSeatLimitReachedWithUpgradePathValidator", () => {
let organization: Organization;
let allOrganizationUserEmails: string[];
let validatorFn: (control: AbstractControl) => ValidationErrors | null;
Expand All @@ -27,7 +27,7 @@ describe("freeOrgSeatLimitReachedValidator", () => {
});

it("should return null when control value is empty", () => {
validatorFn = freeOrgSeatLimitReachedValidator(
validatorFn = orgWithoutAdditionalSeatLimitReachedWithUpgradePathValidator(
organization,
allOrganizationUserEmails,
"You cannot invite more than 2 members without upgrading your plan."
Expand All @@ -40,7 +40,7 @@ describe("freeOrgSeatLimitReachedValidator", () => {
});

it("should return null when control value is null", () => {
validatorFn = freeOrgSeatLimitReachedValidator(
validatorFn = orgWithoutAdditionalSeatLimitReachedWithUpgradePathValidator(
organization,
allOrganizationUserEmails,
"You cannot invite more than 2 members without upgrading your plan."
Expand All @@ -57,7 +57,7 @@ describe("freeOrgSeatLimitReachedValidator", () => {
planProductType: ProductType.Free,
seats: 2,
});
validatorFn = freeOrgSeatLimitReachedValidator(
validatorFn = orgWithoutAdditionalSeatLimitReachedWithUpgradePathValidator(
organization,
allOrganizationUserEmails,
"You cannot invite more than 2 members without upgrading your plan."
Expand All @@ -69,13 +69,40 @@ describe("freeOrgSeatLimitReachedValidator", () => {
expect(result).toBeNull();
});

it("should return null when max seats are not exceeded on teams starter plan", () => {
organization = orgFactory({
planProductType: ProductType.TeamsStarter,
seats: 10,
});
validatorFn = orgWithoutAdditionalSeatLimitReachedWithUpgradePathValidator(
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({
planProductType: ProductType.Free,
seats: 2,
});
const errorMessage = "You cannot invite more than 2 members without upgrading your plan.";
validatorFn = freeOrgSeatLimitReachedValidator(
validatorFn = orgWithoutAdditionalSeatLimitReachedWithUpgradePathValidator(
organization,
allOrganizationUserEmails,
"You cannot invite more than 2 members without upgrading your plan."
Expand All @@ -93,7 +120,7 @@ describe("freeOrgSeatLimitReachedValidator", () => {
planProductType: ProductType.Enterprise,
seats: 100,
});
validatorFn = freeOrgSeatLimitReachedValidator(
validatorFn = orgWithoutAdditionalSeatLimitReachedWithUpgradePathValidator(
organization,
allOrganizationUserEmails,
"You cannot invite more than 2 members without upgrading your plan."
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,14 @@ import { Organization } from "@bitwarden/common/admin-console/models/domain/orga
import { ProductType } from "@bitwarden/common/enums";

/**
* Checks if the limit of free organization seats has been reached when adding new users
* If the organization doesn't allow additional seat options, this checks if the seat limit has been reached when adding
* new users
* @param organization An object representing the organization
* @param allOrganizationUserEmails An array of strings with existing user email addresses
* @param errorMessage A localized string to display if validation fails
* @returns A function that validates an `AbstractControl` and returns `ValidationErrors` or `null`
*/
export function freeOrgSeatLimitReachedValidator(
export function orgWithoutAdditionalSeatLimitReachedWithUpgradePathValidator(
organization: Organization,
allOrganizationUserEmails: string[],
errorMessage: string
Expand All @@ -20,13 +21,20 @@ export function freeOrgSeatLimitReachedValidator(
return null;
}

const newEmailsToAdd = control.value
.split(",")
.filter(
(newEmailToAdd: string) =>
newEmailToAdd &&
!allOrganizationUserEmails.some((existingEmail) => existingEmail === newEmailToAdd)
);
const newEmailsToAdd = Array.from(
new Set(
control.value
.split(",")
.filter(
(newEmailToAdd: string) =>
newEmailToAdd &&
newEmailToAdd.trim() !== "" &&
!allOrganizationUserEmails.some(
(existingEmail) => existingEmail === newEmailToAdd.trim()
)
)
)
);

return organization.planProductType === ProductType.Free &&
allOrganizationUserEmails.length + newEmailsToAdd.length > organization.seats
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import { AbstractControl, ValidationErrors, ValidatorFn } from "@angular/forms";

import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
import { ProductType } from "@bitwarden/common/enums";

/**
* If the organization doesn't allow additional seat options, this checks if the seat limit has been reached when adding
* new users
* @param organization An object representing the organization
* @param allOrganizationUserEmails An array of strings with existing user email addresses
* @param errorMessage A localized string to display if validation fails
* @returns A function that validates an `AbstractControl` and returns `ValidationErrors` or `null`
*/
export function orgWithoutAdditionalSeatLimitReachedWithoutUpgradePathValidator(
organization: Organization,
allOrganizationUserEmails: string[],
errorMessage: string
): ValidatorFn {
return (control: AbstractControl): ValidationErrors | null => {
if (control.value === "" || !control.value) {
return null;
}

const newEmailsToAdd = Array.from(
new Set(
control.value
.split(",")
.filter(
(newEmailToAdd: string) =>
newEmailToAdd &&
newEmailToAdd.trim() !== "" &&
!allOrganizationUserEmails.some(
(existingEmail) => existingEmail === newEmailToAdd.trim()
)
)
)
);

return (organization.planProductType === ProductType.Families ||
organization.planProductType === ProductType.TeamsStarter) &&
allOrganizationUserEmails.length + newEmailsToAdd.length > organization.seats
? { orgSeatLimitReachedWithoutUpgradePath: { message: errorMessage } }
: null;
};
}
Original file line number Diff line number Diff line change
Expand Up @@ -345,52 +345,100 @@ export class PeopleComponent
);
}

private async showFreeOrgUpgradeDialog(): Promise<void> {
private getManageBillingText(): string {
return this.organization.canEditSubscription ? "ManageBilling" : "NoManageBilling";
}

private getProductKey(productType: ProductType): string {
let product = "";
switch (productType) {
case ProductType.Free:
product = "freeOrg";
break;
case ProductType.TeamsStarter:
product = "teamsStarterPlan";
break;
default:
throw new Error(`Unsupported product type: ${productType}`);
}
return `${product}InvLimitReached${this.getManageBillingText()}`;
}

private getDialogTitle(productType: ProductType): string {
switch (productType) {
case ProductType.Free:
return "upgrade";
case ProductType.TeamsStarter:
return "contactSupportShort";
default:
throw new Error(`Unsupported product type: ${productType}`);
}
}

private getDialogContent(): string {
return this.i18nService.t(
this.getProductKey(this.organization.planProductType),
this.organization.seats
);
}

private getAcceptButtonText(): string {
if (!this.organization.canEditSubscription) {
return this.i18nService.t("ok");
}

return this.i18nService.t(this.getDialogTitle(this.organization.planProductType));
}

private async handleDialogClose(result: boolean | undefined): Promise<void> {
if (!result || !this.organization.canEditSubscription) {
return;
}

switch (this.organization.planProductType) {
case ProductType.Free:
await this.router.navigate(
["/organizations", this.organization.id, "billing", "subscription"],
{ queryParams: { upgrade: true } }
);
break;
case ProductType.TeamsStarter:
window.open("https://bitwarden.com/contact/", "_blank");
break;
default:
throw new Error(`Unsupported product type: ${this.organization.planProductType}`);
}
}

private async showSeatLimitReachedDialog(): Promise<void> {
const orgUpgradeSimpleDialogOpts: SimpleDialogOptions = {
title: this.i18nService.t("upgradeOrganization"),
content: this.i18nService.t(
this.organization.canEditSubscription
? "freeOrgInvLimitReachedManageBilling"
: "freeOrgInvLimitReachedNoManageBilling",
this.organization.seats
),
content: this.getDialogContent(),
type: "primary",
acceptButtonText: this.getAcceptButtonText(),
};

if (this.organization.canEditSubscription) {
orgUpgradeSimpleDialogOpts.acceptButtonText = this.i18nService.t("upgrade");
} else {
orgUpgradeSimpleDialogOpts.acceptButtonText = this.i18nService.t("ok");
orgUpgradeSimpleDialogOpts.cancelButtonText = null; // hide secondary btn
if (!this.organization.canEditSubscription) {
orgUpgradeSimpleDialogOpts.cancelButtonText = null;
}

const simpleDialog = this.dialogService.openSimpleDialogRef(orgUpgradeSimpleDialogOpts);

firstValueFrom(simpleDialog.closed).then((result: boolean | undefined) => {
if (!result) {
return;
}

if (result && this.organization.canEditSubscription) {
this.router.navigate(["/organizations", this.organization.id, "billing", "subscription"], {
queryParams: { upgrade: true },
});
}
});
firstValueFrom(simpleDialog.closed).then(this.handleDialogClose.bind(this));
}

async edit(user: OrganizationUserView, initialTab: MemberDialogTab = MemberDialogTab.Role) {
// 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.organization.planProductType === ProductType.Free &&
this.allUsers.length === this.organization.seats
) {
if (!user && this.allUsers.length === this.organization.seats) {
// Show org upgrade modal
await this.showFreeOrgUpgradeDialog();
if (
this.organization.planProductType === ProductType.Free ||
this.organization.planProductType === ProductType.TeamsStarter
) {
await this.showSeatLimitReachedDialog();
}
return;
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,9 @@ export class CreateOrganizationComponent implements OnInit {
} else if (qParams.plan === "teams") {
this.orgPlansComponent.plan = PlanType.TeamsAnnually;
this.orgPlansComponent.product = ProductType.Teams;
} else if (qParams.plan === "teamsStarter") {
this.orgPlansComponent.plan = PlanType.TeamsStarter;
this.orgPlansComponent.product = ProductType.TeamsStarter;
} else if (qParams.plan === "enterprise") {
this.orgPlansComponent.plan = PlanType.EnterpriseAnnually;
this.orgPlansComponent.product = ProductType.Enterprise;
Expand Down
Loading

0 comments on commit 9f5226f

Please sign in to comment.