1
0
mirror of https://github.com/bitwarden/browser.git synced 2024-06-25 10:25:36 +02: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
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
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;

View File

@ -0,0 +1,18 @@
<h1 class="tw-text-4xl !tw-text-alt2">Begin Teams Starter Free Trial Now</h1>
<div class="tw-pt-32">
<h2 class="tw-text-2xl">
Millions of individuals, teams, and organizations worldwide trust Bitwarden for secure password
storage and sharing.
</h2>
</div>
<ul class="tw-mt-12 tw-flex tw-flex-col tw-gap-10 tw-text-2xl tw-text-main">
<li>Powerful security for up to 10 users</li>
<li>Collaborate and share securely</li>
<li>Deploy and manage quickly and easily</li>
<li>Access anywhere on any device</li>
<li>Create your account to get started</li>
</ul>
<div class="tw-mt-28 tw-flex tw-flex-col tw-items-center tw-gap-5">
<app-logo-forbes></app-logo-forbes>
<app-logo-us-news></app-logo-us-news>
</div>

View File

@ -0,0 +1,7 @@
import { Component } from "@angular/core";
@Component({
selector: "app-teams3-content",
templateUrl: "teams3-content.component.html",
})
export class Teams3ContentComponent {}

View File

@ -28,6 +28,7 @@
<app-teams-content *ngIf="layout === layouts.teams"></app-teams-content>
<app-teams1-content *ngIf="layout === layouts.teams1"></app-teams1-content>
<app-teams2-content *ngIf="layout === layouts.teams2"></app-teams2-content>
<app-teams3-content *ngIf="layout === layouts.teams3"></app-teams3-content>
<app-enterprise-content *ngIf="layout === layouts.enterprise"></app-enterprise-content>
<app-enterprise1-content *ngIf="layout === layouts.enterprise1"></app-enterprise1-content>
<app-enterprise2-content *ngIf="layout === layouts.enterprise2"></app-enterprise2-content>
@ -60,7 +61,7 @@
<div class="tw-rounded tw-border tw-border-solid tw-border-secondary-300 tw-bg-background">
<div class="tw-flex tw-h-auto tw-w-full tw-gap-5 tw-rounded-t tw-bg-secondary-100">
<h2 class="tw-pb-4 tw-pl-4 tw-pt-5 tw-text-base tw-font-bold tw-uppercase">
{{ "startYour7DayFreeTrialOfBitwardenFor" | i18n : org }}
{{ "startYour7DayFreeTrialOfBitwardenFor" | i18n : orgDisplayName }}
</h2>
<environment-selector
class="tw-mr-4 tw-mt-6 tw-flex-shrink-0 tw-text-end"

View File

@ -24,6 +24,7 @@ enum ValidOrgParams {
families = "families",
enterprise = "enterprise",
teams = "teams",
teamsStarter = "teamsStarter",
individual = "individual",
premium = "premium",
free = "free",
@ -34,6 +35,7 @@ enum ValidLayoutParams {
teams = "teams",
teams1 = "teams1",
teams2 = "teams2",
teams3 = "teams3",
enterprise = "enterprise",
enterprise1 = "enterprise1",
enterprise2 = "enterprise2",
@ -64,6 +66,7 @@ export class TrialInitiationComponent implements OnInit, OnDestroy {
enforcedPolicyOptions: MasterPasswordPolicyOptions;
trialFlowOrgs: string[] = [
ValidOrgParams.teams,
ValidOrgParams.teamsStarter,
ValidOrgParams.enterprise,
ValidOrgParams.families,
];
@ -143,6 +146,9 @@ export class TrialInitiationComponent implements OnInit, OnDestroy {
if (this.org === ValidOrgParams.families) {
this.plan = PlanType.FamiliesAnnually;
this.product = ProductType.Families;
} else if (this.org === ValidOrgParams.teamsStarter) {
this.plan = PlanType.TeamsStarter;
this.product = ProductType.TeamsStarter;
} else if (this.org === ValidOrgParams.teams) {
this.plan = PlanType.TeamsAnnually;
this.product = ProductType.Teams;
@ -206,7 +212,9 @@ export class TrialInitiationComponent implements OnInit, OnDestroy {
// Set org info sub label
if (event.selectedIndex === 1 && this.orgInfoFormGroup.controls.name.value === "") {
this.orgInfoSubLabel =
"Enter your " + this.titleCasePipe.transform(this.org) + " organization information";
"Enter your " +
this.titleCasePipe.transform(this.orgDisplayName) +
" organization information";
} else if (event.previouslySelectedIndex === 1) {
this.orgInfoSubLabel = this.orgInfoFormGroup.controls.name.value;
}
@ -241,6 +249,14 @@ export class TrialInitiationComponent implements OnInit, OnDestroy {
this.verticalStepper.previous();
}
get orgDisplayName() {
if (this.org === "teamsStarter") {
return "Teams Starter";
}
return this.org;
}
private setupFamilySponsorship(sponsorshipToken: string) {
if (sponsorshipToken != null) {
const route = this.router.createUrlTree(["setup/families-for-enterprise"], {

View File

@ -27,6 +27,7 @@ import { LogoUSNewsComponent } from "./content/logo-us-news.component";
import { TeamsContentComponent } from "./content/teams-content.component";
import { Teams1ContentComponent } from "./content/teams1-content.component";
import { Teams2ContentComponent } from "./content/teams2-content.component";
import { Teams3ContentComponent } from "./content/teams3-content.component";
import { TrialInitiationComponent } from "./trial-initiation.component";
import { VerticalStepperModule } from "./vertical-stepper/vertical-stepper.module";
@ -55,6 +56,7 @@ import { VerticalStepperModule } from "./vertical-stepper/vertical-stepper.modul
TeamsContentComponent,
Teams1ContentComponent,
Teams2ContentComponent,
Teams3ContentComponent,
CnetEnterpriseContentComponent,
CnetIndividualContentComponent,
CnetTeamsContentComponent,

View File

@ -24,7 +24,7 @@
}}
/{{ "yr" | i18n }}
</ng-container>
<ng-container *ngIf="selectablePlan.SecretsManager">
<ng-container *ngIf="!selectablePlan.PasswordManager && selectablePlan.SecretsManager">
{{ "annual" | i18n }} -
{{
(selectablePlan.SecretsManager.basePrice === 0
@ -46,7 +46,7 @@
}}
/{{ "monthAbbr" | i18n }}
</ng-container>
<ng-container *ngIf="selectablePlan.SecretsManager">
<ng-container *ngIf="!selectablePlan.PasswordManager && selectablePlan.SecretsManager">
{{ "monthly" | i18n }} -
{{
(selectablePlan.SecretsManager.basePrice === 0

View File

@ -1,6 +1,7 @@
import { Component, EventEmitter, Input, Output } from "@angular/core";
import { FormGroup } from "@angular/forms";
import { PlanType } from "@bitwarden/common/billing/enums";
import { ProductType } from "@bitwarden/common/enums";
import { OrganizationPlansComponent } from "../../organizations";
@ -14,7 +15,8 @@ export class BillingComponent extends OrganizationPlansComponent {
@Output() previousStep = new EventEmitter();
async ngOnInit() {
const additionalSeats = this.product == ProductType.Families ? 0 : 1;
const additionalSeats =
this.product == ProductType.Families || this.plan === PlanType.TeamsStarter ? 0 : 1;
this.formGroup.patchValue({
name: this.orgInfoForm.value.name,
billingEmail: this.orgInfoForm.value.email,

View File

@ -11,6 +11,7 @@
[plan]="defaultUpgradePlan"
[product]="defaultUpgradeProduct"
[organizationId]="organizationId"
[currentProductType]="currentProductType"
(onCanceled)="cancel()"
>
</app-organization-plans>

View File

@ -10,6 +10,7 @@ import { LogService } from "@bitwarden/common/platform/abstractions/log.service"
})
export class ChangePlanComponent {
@Input() organizationId: string;
@Input() currentProductType: ProductType;
@Output() onChanged = new EventEmitter();
@Output() onCanceled = new EventEmitter();

View File

@ -52,9 +52,7 @@
<label class="form-check-label" for="product{{ selectableProduct.product }}">
{{ selectableProduct.nameLocalizationKey | i18n }}
<small class="mb-1">{{ selectableProduct.descriptionLocalizationKey | i18n : "1" }}</small>
<ng-container
*ngIf="selectableProduct.product === productTypes.Enterprise; else fullFeatureList"
>
<ng-container *ngIf="selectableProduct.product === productTypes.Enterprise">
<small>• {{ "includeAllTeamsFeatures" | i18n }}</small>
<small *ngIf="selectableProduct.hasSelfHost">• {{ "onPremHostingOptional" | i18n }}</small>
<small *ngIf="selectableProduct.hasSso">• {{ "includeSsoAuthentication" | i18n }}</small>
@ -66,6 +64,19 @@
{{ "xDayFreeTrial" | i18n : selectableProduct.trialPeriodDays }}
</small>
</ng-container>
<ng-container
*ngIf="
teamsStarterPlanFeatureFlagIsEnabled && selectableProduct.product === productTypes.Teams;
else fullFeatureList
"
>
<small>• {{ "includeAllTeamsStarterFeatures" | i18n }}</small>
<small>• {{ "chooseMonthlyOrAnnualBilling" | i18n }}</small>
<small>• {{ "abilityToAddMoreThanNMembers" | i18n : 10 }}</small>
<small *ngIf="selectableProduct.trialPeriodDays && createOrganization">
• {{ "xDayFreeTrial" | i18n : selectableProduct.trialPeriodDays }}
</small>
</ng-container>
<ng-template #fullFeatureList>
<small *ngIf="selectableProduct.product == productTypes.Free"
>• {{ "limitedUsers" | i18n : selectableProduct.PasswordManager.maxSeats }}</small
@ -73,6 +84,7 @@
<small
*ngIf="
selectableProduct.product != productTypes.Free &&
selectableProduct.product != productTypes.TeamsStarter &&
selectableProduct.PasswordManager.maxSeats
"
>• {{ "addShareLimitedUsers" | i18n : selectableProduct.PasswordManager.maxSeats }}</small
@ -118,15 +130,23 @@
</ng-template>
<span *ngIf="selectableProduct.product != productTypes.Free">
<ng-container *ngIf="selectableProduct.PasswordManager.basePrice && !acceptingSponsorship">
{{ selectableProduct.PasswordManager.basePrice / 12 | currency : "$" }} /{{
"month" | i18n
}},
{{
(selectableProduct.isAnnual
? selectableProduct.PasswordManager.basePrice / 12
: selectableProduct.PasswordManager.basePrice
) | currency : "$"
}}
/{{ "month" | i18n }},
{{ "includesXUsers" | i18n : selectableProduct.PasswordManager.baseSeats }}
<ng-container *ngIf="selectableProduct.PasswordManager.hasAdditionalSeatsOption">
{{ ("additionalUsers" | i18n).toLowerCase() }}
{{ selectableProduct.PasswordManager.seatPrice / 12 | currency : "$" }} /{{
"month" | i18n
{{
(selectableProduct.isAnnual
? selectableProduct.PasswordManager.seatPrice / 12
: selectableProduct.PasswordManager.seatPrice
) | currency : "$"
}}
/{{ "month" | i18n }}
</ng-container>
</ng-container>
</span>
@ -137,7 +157,13 @@
"
>
{{
"costPerUser" | i18n : (selectableProduct.PasswordManager.seatPrice / 12 | currency : "$")
"costPerUser"
| i18n
: ((selectableProduct.isAnnual
? selectableProduct.PasswordManager.seatPrice / 12
: selectableProduct.PasswordManager.seatPrice
)
| currency : "$")
}}
/{{ "month" | i18n }}
</span>
@ -249,7 +275,13 @@
{{ "annually" | i18n }}
<small *ngIf="selectablePlan.PasswordManager.basePrice">
{{ "basePrice" | i18n }}:
{{ selectablePlan.PasswordManager.basePrice / 12 | currency : "$" }} &times; 12
{{
(selectablePlan.isAnnual
? selectablePlan.PasswordManager.basePrice / 12
: selectablePlan.PasswordManager.basePrice
) | currency : "$"
}}
&times; 12
{{ "monthAbbr" | i18n }}
=
<ng-container *ngIf="acceptingSponsorship; else notAcceptingSponsorship">
@ -269,8 +301,13 @@
>
<span *ngIf="!selectablePlan.PasswordManager.baseSeats">{{ "users" | i18n }}:</span>
{{ formGroup.controls["additionalSeats"].value || 0 }} &times;
{{ selectablePlan.PasswordManager.seatPrice / 12 | currency : "$" }} &times; 12
{{ "monthAbbr" | i18n }} =
{{
(selectablePlan.isAnnual
? selectablePlan.PasswordManager.seatPrice / 12
: selectablePlan.PasswordManager.seatPrice
) | currency : "$"
}}
&times; 12 {{ "monthAbbr" | i18n }} =
{{
passwordManagerSeatTotal(selectablePlan, formGroup.value.additionalSeats)
| currency : "$"
@ -280,7 +317,12 @@
<small *ngIf="selectablePlan.PasswordManager.hasAdditionalStorageOption">
{{ "additionalStorageGb" | i18n }}:
{{ formGroup.controls["additionalStorage"].value || 0 }} &times;
{{ selectablePlan.PasswordManager.additionalStoragePricePerGb / 12 | currency : "$" }}
{{
(selectablePlan.isAnnual
? selectablePlan.PasswordManager.additionalStoragePricePerGb / 12
: selectablePlan.PasswordManager.additionalStoragePricePerGb
) | currency : "$"
}}
&times; 12 {{ "monthAbbr" | i18n }} =
{{ additionalStorageTotal(selectablePlan) | currency : "$" }} /{{ "year" | i18n }}
</small>

View File

@ -58,6 +58,7 @@ export class OrganizationPlansComponent implements OnInit, OnDestroy {
@Input() showFree = true;
@Input() showCancel = false;
@Input() acceptingSponsorship = false;
@Input() currentProductType: ProductType;
@Input()
get product(): ProductType {
@ -196,39 +197,47 @@ export class OrganizationPlansComponent implements OnInit, OnDestroy {
}
get selectableProducts() {
let validPlans = this.passwordManagerPlans.filter((plan) => plan.type !== PlanType.Custom);
if (this.formGroup.controls.businessOwned.value) {
validPlans = validPlans.filter((plan) => plan.canBeUsedByBusiness);
}
if (!this.showFree) {
validPlans = validPlans.filter((plan) => plan.product !== ProductType.Free);
}
validPlans = validPlans.filter(
(plan) =>
!plan.legacyYear &&
!plan.disabled &&
(plan.isAnnual || plan.product === this.productTypes.Free)
);
if (this.acceptingSponsorship) {
const familyPlan = this.passwordManagerPlans.find(
(plan) => plan.type === PlanType.FamiliesAnnually
);
this.discount = familyPlan.PasswordManager.basePrice;
validPlans = [familyPlan];
return [familyPlan];
}
return validPlans;
const businessOwnedIsChecked = this.formGroup.controls.businessOwned.value;
const result = this.passwordManagerPlans.filter(
(plan) =>
plan.type !== PlanType.Custom &&
(!businessOwnedIsChecked || plan.canBeUsedByBusiness) &&
(this.showFree || plan.product !== ProductType.Free) &&
this.planIsEnabled(plan) &&
(plan.isAnnual ||
plan.product === ProductType.Free ||
plan.product === ProductType.TeamsStarter) &&
(this.currentProductType !== ProductType.TeamsStarter ||
plan.product === ProductType.Teams ||
plan.product === ProductType.Enterprise)
);
result.sort((planA, planB) => planA.displaySortOrder - planB.displaySortOrder);
return result;
}
get selectablePlans() {
return this.passwordManagerPlans?.filter(
(plan) =>
!plan.legacyYear && !plan.disabled && plan.product === this.formGroup.controls.product.value
const selectedProductType = this.formGroup.controls.product.value;
const result = this.passwordManagerPlans?.filter(
(plan) => this.planIsEnabled(plan) && plan.product === selectedProductType
);
result.sort((planA, planB) => planA.displaySortOrder - planB.displaySortOrder);
return result;
}
get teamsStarterPlanFeatureFlagIsEnabled(): boolean {
return this.passwordManagerPlans.some((plan) => plan.product === ProductType.TeamsStarter);
}
get hasProvider() {
@ -392,8 +401,13 @@ export class OrganizationPlansComponent implements OnInit, OnDestroy {
if (!this.formGroup.controls.businessOwned.value || this.selectedPlan.canBeUsedByBusiness) {
return;
}
this.formGroup.controls.product.setValue(ProductType.Teams);
this.formGroup.controls.plan.setValue(PlanType.TeamsAnnually);
if (this.teamsStarterPlanFeatureFlagIsEnabled) {
this.formGroup.controls.product.setValue(ProductType.TeamsStarter);
this.formGroup.controls.plan.setValue(PlanType.TeamsStarter);
} else {
this.formGroup.controls.product.setValue(ProductType.Teams);
this.formGroup.controls.plan.setValue(PlanType.TeamsAnnually);
}
this.changedProduct();
}
@ -646,4 +660,8 @@ export class OrganizationPlansComponent implements OnInit, OnDestroy {
this.changedProduct();
}
}
private planIsEnabled(plan: PlanResponse) {
return !plan.disabled && !plan.legacyYear;
}
}

View File

@ -71,7 +71,7 @@
<ng-container *ngIf="subscription">
<tr bitRow *ngFor="let i of subscriptionLineItems">
<td bitCell [ngClass]="{ 'tw-pl-20': i.addonSubscriptionItem }">
<span *ngIf="!i.addonSubscriptionItem">{{ i.productName }} -</span>
<span *ngIf="!i.addonSubscriptionItem">{{ i.productName | i18n }} -</span>
{{ i.name }} {{ i.quantity > 1 ? "&times;" + i.quantity : "" }} @
{{ i.amount | currency : "$" }}
</td>
@ -127,6 +127,7 @@
</button>
<app-change-plan
[organizationId]="organizationId"
[currentProductType]="sub.plan.product"
(onChanged)="closeChangePlan()"
(onCanceled)="closeChangePlan()"
*ngIf="showChangePlan"

View File

@ -11,6 +11,7 @@ import { Organization } from "@bitwarden/common/admin-console/models/domain/orga
import { PlanType } from "@bitwarden/common/billing/enums";
import { OrganizationSubscriptionResponse } from "@bitwarden/common/billing/models/response/organization-subscription.response";
import { BillingSubscriptionItemResponse } from "@bitwarden/common/billing/models/response/subscription.response";
import { ProductType } from "@bitwarden/common/enums";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
@ -43,6 +44,8 @@ export class OrganizationSubscriptionCloudComponent implements OnInit, OnDestroy
private readonly _smBetaEndingDate = new Date(2023, 7, 15);
private readonly _smGracePeriodEndingDate = new Date(2023, 10, 14);
protected readonly teamsStarter = ProductType.TeamsStarter;
private destroy$ = new Subject<void>();
constructor(
@ -95,8 +98,8 @@ export class OrganizationSubscriptionCloudComponent implements OnInit, OnDestroy
const seatPriceTotal = this.sub.plan?.SecretsManager?.seatPrice * item.quantity;
item.productName =
itemTotalAmount === seatPriceTotal || item.name.includes("Service Accounts")
? "SecretsManager"
: "PasswordManager";
? "secretsManager"
: "passwordManager";
return item;
})
.sort(sortSubscriptionItems);
@ -234,6 +237,8 @@ export class OrganizationSubscriptionCloudComponent implements OnInit, OnDestroy
return (
this.sub.planType === PlanType.EnterpriseAnnually ||
this.sub.planType === PlanType.EnterpriseMonthly ||
this.sub.planType === PlanType.EnterpriseAnnually2020 ||
this.sub.planType === PlanType.EnterpriseMonthly2020 ||
this.sub.planType === PlanType.EnterpriseAnnually2019 ||
this.sub.planType === PlanType.EnterpriseMonthly2019
);
@ -253,6 +258,8 @@ export class OrganizationSubscriptionCloudComponent implements OnInit, OnDestroy
}
} else if (this.sub.maxAutoscaleSeats === this.sub.seats && this.sub.seats != null) {
return this.i18nService.t("subscriptionMaxReached", this.sub.seats.toString());
} else if (this.userOrg.planProductType === ProductType.TeamsStarter) {
return this.i18nService.t("subscriptionUserSeatsWithoutAdditionalSeatsOption", 10);
} else if (this.sub.maxAutoscaleSeats == null) {
return this.i18nService.t("subscriptionUserSeatsUnlimitedAutoscale");
} else {

View File

@ -83,6 +83,7 @@ export class SecretsManagerSubscribeComponent implements OnInit, OnDestroy {
case ProductType.Free:
return this.i18nService.t("free2PersonOrganization");
case ProductType.Teams:
case ProductType.TeamsStarter:
return this.i18nService.t("planNameTeams");
case ProductType.Enterprise:
return this.i18nService.t("planNameEnterprise");

View File

@ -2280,6 +2280,9 @@
"contactSupport": {
"message": "Contact customer support"
},
"contactSupportShort": {
"message": "Contact Support"
},
"updatedPaymentMethod": {
"message": "Updated payment method."
},
@ -2381,6 +2384,9 @@
"planDescTeams": {
"message": "For businesses and other team organizations."
},
"planNameTeamsStarter": {
"message": "Teams Starter"
},
"planNameEnterprise": {
"message": "Enterprise"
},
@ -3485,6 +3491,15 @@
}
}
},
"subscriptionUserSeatsWithoutAdditionalSeatsOption": {
"message": "You can invite up to $COUNT$ members for no additional charge. Contact Customer Support to upgrade your plan and invite more members.",
"placeholders": {
"count": {
"content": "$1",
"example": "10"
}
}
},
"subscriptionFreePlan": {
"message": "You cannot invite more than $COUNT$ members without upgrading your plan.",
"placeholders": {
@ -3983,6 +3998,21 @@
"includeAllTeamsFeatures": {
"message": "All Teams features, plus:"
},
"includeAllTeamsStarterFeatures": {
"message": "All Teams Starter features, plus:"
},
"chooseMonthlyOrAnnualBilling": {
"message": "Choose monthly or annual billing"
},
"abilityToAddMoreThanNMembers": {
"message": "Ability to add more than $COUNT$ members",
"placeholders": {
"count": {
"content": "$1",
"example": "10"
}
}
},
"includeSsoAuthentication": {
"message": "SSO Authentication via SAML2.0 and OpenID Connect"
},
@ -6598,6 +6628,24 @@
}
}
},
"teamsStarterPlanInvLimitReachedManageBilling": {
"message": "Teams Starter plans may have up to $SEATCOUNT$ members. Contact Customer Support to upgrade your plan and invite more members.",
"placeholders": {
"seatcount": {
"content": "$1",
"example": "10"
}
}
},
"teamsStarterPlanInvLimitReachedNoManageBilling": {
"message": "Teams Starter plans may have up to $SEATCOUNT$ members. Contact your organization owner to upgrade your plan and invite more members.",
"placeholders": {
"seatcount": {
"content": "$1",
"example": "10"
}
}
},
"freeOrgMaxCollectionReachedManageBilling": {
"message": "Free organizations may have up to $COLLECTIONCOUNT$ collections. Upgrade to a paid plan to add more collections.",
"placeholders": {

View File

@ -7,8 +7,13 @@ export enum PlanType {
EnterpriseAnnually2019 = 5,
Custom = 6,
FamiliesAnnually = 7,
TeamsMonthly = 8,
TeamsAnnually = 9,
EnterpriseMonthly = 10,
EnterpriseAnnually = 11,
TeamsMonthly2020 = 8,
TeamsAnnually2020 = 9,
EnterpriseMonthly2020 = 10,
EnterpriseAnnually2020 = 11,
TeamsMonthly = 12,
TeamsAnnually = 13,
EnterpriseMonthly = 14,
EnterpriseAnnually = 15,
TeamsStarter = 16,
}

View File

@ -51,7 +51,7 @@ export class PlanResponse extends BaseResponse {
this.hasResetPassword = this.getResponseProperty("HasResetPassword");
this.usersGetPremium = this.getResponseProperty("UsersGetPremium");
this.upgradeSortOrder = this.getResponseProperty("UpgradeSortOrder");
this.displaySortOrder = this.getResponseProperty("SortOrder");
this.displaySortOrder = this.getResponseProperty("DisplaySortOrder");
this.legacyYear = this.getResponseProperty("LegacyYear");
this.disabled = this.getResponseProperty("Disabled");
const passwordManager = this.getResponseProperty("PasswordManager");

View File

@ -3,4 +3,5 @@ export enum ProductType {
Families = 1,
Teams = 2,
Enterprise = 3,
TeamsStarter = 4,
}