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:
@@ -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">
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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."
|
||||
@@ -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
|
||||
@@ -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;
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user