1
0
mirror of https://github.com/bitwarden/browser synced 2025-12-22 11:13:46 +00:00

[AC-1708] Teams Starter Plan (#6740)

* 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>
This commit is contained in:
Conner Turnbull
2023-11-03 18:32:44 -04:00
committed by GitHub
parent 197059d4fa
commit 9f5226f8a6
25 changed files with 417 additions and 101 deletions

View File

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

View File

@@ -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";
@@ -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,
@@ -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");
@@ -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;
}
@@ -507,6 +516,8 @@ export class MemberDialogComponent implements OnInit, OnDestroy {
type: "warning",
});
}
protected readonly ProductType = ProductType;
}
function mapCollectionToAccessItemView(

View File

@@ -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(
@@ -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;
@@ -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."
@@ -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."
@@ -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."
@@ -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."
@@ -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."

View File

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

View File

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

View File

@@ -345,38 +345,85 @@ 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) {
@@ -384,13 +431,14 @@ export class PeopleComponent
// 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;
}

View File

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