diff --git a/apps/web/src/app/admin-console/organizations/members/components/member-dialog/member-dialog.component.html b/apps/web/src/app/admin-console/organizations/members/components/member-dialog/member-dialog.component.html index 34d407e7b23..1c718372af6 100644 --- a/apps/web/src/app/admin-console/organizations/members/components/member-dialog/member-dialog.component.html +++ b/apps/web/src/app/admin-console/organizations/members/components/member-dialog/member-dialog.component.html @@ -23,7 +23,10 @@ {{ "email" | i18n }} - {{ "inviteMultipleEmailDesc" | i18n : "20" }} + {{ + "inviteMultipleEmailDesc" + | i18n : (organization.planProductType === ProductType.TeamsStarter ? "10" : "20") + }}
diff --git a/apps/web/src/app/admin-console/organizations/members/components/member-dialog/member-dialog.component.ts b/apps/web/src/app/admin-console/organizations/members/components/member-dialog/member-dialog.component.ts index f885a05453c..9d0dc6799d3 100644 --- a/apps/web/src/app/admin-console/organizations/members/components/member-dialog/member-dialog.component.ts +++ b/apps/web/src/app/admin-console/organizations/members/components/member-dialog/member-dialog.component.ts @@ -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( diff --git a/apps/web/src/app/admin-console/organizations/members/components/member-dialog/validators/free-org-inv-limit-reached.validator.spec.ts b/apps/web/src/app/admin-console/organizations/members/components/member-dialog/validators/org-without-additional-seat-limit-reached-with-upgrade-path.validator.spec.ts similarity index 66% rename from apps/web/src/app/admin-console/organizations/members/components/member-dialog/validators/free-org-inv-limit-reached.validator.spec.ts rename to apps/web/src/app/admin-console/organizations/members/components/member-dialog/validators/org-without-additional-seat-limit-reached-with-upgrade-path.validator.spec.ts index 5c17a128ac9..09ba1d583d2 100644 --- a/apps/web/src/app/admin-console/organizations/members/components/member-dialog/validators/free-org-inv-limit-reached.validator.spec.ts +++ b/apps/web/src/app/admin-console/organizations/members/components/member-dialog/validators/org-without-additional-seat-limit-reached-with-upgrade-path.validator.spec.ts @@ -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 = {}) => Object.assign( @@ -17,7 +17,7 @@ const orgFactory = (props: Partial = {}) => 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." diff --git a/apps/web/src/app/admin-console/organizations/members/components/member-dialog/validators/free-org-inv-limit-reached.validator.ts b/apps/web/src/app/admin-console/organizations/members/components/member-dialog/validators/org-without-additional-seat-limit-reached-with-upgrade-path.validator.ts similarity index 63% rename from apps/web/src/app/admin-console/organizations/members/components/member-dialog/validators/free-org-inv-limit-reached.validator.ts rename to apps/web/src/app/admin-console/organizations/members/components/member-dialog/validators/org-without-additional-seat-limit-reached-with-upgrade-path.validator.ts index 6d5c45a64d4..76634e71966 100644 --- a/apps/web/src/app/admin-console/organizations/members/components/member-dialog/validators/free-org-inv-limit-reached.validator.ts +++ b/apps/web/src/app/admin-console/organizations/members/components/member-dialog/validators/org-without-additional-seat-limit-reached-with-upgrade-path.validator.ts @@ -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 diff --git a/apps/web/src/app/admin-console/organizations/members/components/member-dialog/validators/org-without-additional-seat-limit-reached-without-upgrade-path.validator.ts b/apps/web/src/app/admin-console/organizations/members/components/member-dialog/validators/org-without-additional-seat-limit-reached-without-upgrade-path.validator.ts new file mode 100644 index 00000000000..f59d35d6371 --- /dev/null +++ b/apps/web/src/app/admin-console/organizations/members/components/member-dialog/validators/org-without-additional-seat-limit-reached-without-upgrade-path.validator.ts @@ -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; + }; +} diff --git a/apps/web/src/app/admin-console/organizations/members/people.component.ts b/apps/web/src/app/admin-console/organizations/members/people.component.ts index a7d4c00d2b9..896d88090fa 100644 --- a/apps/web/src/app/admin-console/organizations/members/people.component.ts +++ b/apps/web/src/app/admin-console/organizations/members/people.component.ts @@ -345,38 +345,85 @@ export class PeopleComponent ); } - private async showFreeOrgUpgradeDialog(): Promise { + 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 { + 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 { 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; } diff --git a/apps/web/src/app/admin-console/settings/create-organization.component.ts b/apps/web/src/app/admin-console/settings/create-organization.component.ts index 7728ddc540a..5e8aaf8684e 100644 --- a/apps/web/src/app/admin-console/settings/create-organization.component.ts +++ b/apps/web/src/app/admin-console/settings/create-organization.component.ts @@ -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; diff --git a/apps/web/src/app/auth/trial-initiation/content/teams3-content.component.html b/apps/web/src/app/auth/trial-initiation/content/teams3-content.component.html new file mode 100644 index 00000000000..6354bc7c0fd --- /dev/null +++ b/apps/web/src/app/auth/trial-initiation/content/teams3-content.component.html @@ -0,0 +1,18 @@ +

Begin Teams Starter Free Trial Now

+
+

+ Millions of individuals, teams, and organizations worldwide trust Bitwarden for secure password + storage and sharing. +

+
+
    +
  • Powerful security for up to 10 users
  • +
  • Collaborate and share securely
  • +
  • Deploy and manage quickly and easily
  • +
  • Access anywhere on any device
  • +
  • Create your account to get started
  • +
+
+ + +
diff --git a/apps/web/src/app/auth/trial-initiation/content/teams3-content.component.ts b/apps/web/src/app/auth/trial-initiation/content/teams3-content.component.ts new file mode 100644 index 00000000000..df91268ab26 --- /dev/null +++ b/apps/web/src/app/auth/trial-initiation/content/teams3-content.component.ts @@ -0,0 +1,7 @@ +import { Component } from "@angular/core"; + +@Component({ + selector: "app-teams3-content", + templateUrl: "teams3-content.component.html", +}) +export class Teams3ContentComponent {} diff --git a/apps/web/src/app/auth/trial-initiation/trial-initiation.component.html b/apps/web/src/app/auth/trial-initiation/trial-initiation.component.html index af8c255f632..1e2f3950851 100644 --- a/apps/web/src/app/auth/trial-initiation/trial-initiation.component.html +++ b/apps/web/src/app/auth/trial-initiation/trial-initiation.component.html @@ -28,6 +28,7 @@

{{ "createAccount" | i18n }}

+ @@ -60,7 +61,7 @@

{{ "createAccount" | i18n }}

- {{ "startYour7DayFreeTrialOfBitwardenFor" | i18n : org }} + {{ "startYour7DayFreeTrialOfBitwardenFor" | i18n : orgDisplayName }}

{{ "billingPlanLabel" | i18n } }} /{{ "yr" | i18n }} - + {{ "annual" | i18n }} - {{ (selectablePlan.SecretsManager.basePrice === 0 @@ -46,7 +46,7 @@

{{ "billingPlanLabel" | i18n } }} /{{ "monthAbbr" | i18n }} - + {{ "monthly" | i18n }} - {{ (selectablePlan.SecretsManager.basePrice === 0 diff --git a/apps/web/src/app/billing/accounts/trial-initiation/billing.component.ts b/apps/web/src/app/billing/accounts/trial-initiation/billing.component.ts index 2e540191651..30ed2c9ddd6 100644 --- a/apps/web/src/app/billing/accounts/trial-initiation/billing.component.ts +++ b/apps/web/src/app/billing/accounts/trial-initiation/billing.component.ts @@ -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, diff --git a/apps/web/src/app/billing/organizations/change-plan.component.html b/apps/web/src/app/billing/organizations/change-plan.component.html index 0861c516434..27ca56c2d47 100644 --- a/apps/web/src/app/billing/organizations/change-plan.component.html +++ b/apps/web/src/app/billing/organizations/change-plan.component.html @@ -11,6 +11,7 @@

{{ "changeBillingPlan" | i18n }}

[plan]="defaultUpgradePlan" [product]="defaultUpgradeProduct" [organizationId]="organizationId" + [currentProductType]="currentProductType" (onCanceled)="cancel()" > diff --git a/apps/web/src/app/billing/organizations/change-plan.component.ts b/apps/web/src/app/billing/organizations/change-plan.component.ts index d14fd96a97f..10ac04b418a 100644 --- a/apps/web/src/app/billing/organizations/change-plan.component.ts +++ b/apps/web/src/app/billing/organizations/change-plan.component.ts @@ -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(); diff --git a/apps/web/src/app/billing/organizations/organization-plans.component.html b/apps/web/src/app/billing/organizations/organization-plans.component.html index 6dd97b1eec7..ff0458c39a5 100644 --- a/apps/web/src/app/billing/organizations/organization-plans.component.html +++ b/apps/web/src/app/billing/organizations/organization-plans.component.html @@ -52,9 +52,7 @@

{{ "chooseYourPlan" | i18n }}

• {{ "addShareLimitedUsers" | i18n : selectableProduct.PasswordManager.maxSeats }}{{ "chooseYourPlan" | i18n }} - {{ selectableProduct.PasswordManager.basePrice / 12 | currency : "$" }} /{{ - "month" | i18n - }}, + {{ + (selectableProduct.isAnnual + ? selectableProduct.PasswordManager.basePrice / 12 + : selectableProduct.PasswordManager.basePrice + ) | currency : "$" + }} + /{{ "month" | i18n }}, {{ "includesXUsers" | i18n : selectableProduct.PasswordManager.baseSeats }} {{ ("additionalUsers" | i18n).toLowerCase() }} - {{ selectableProduct.PasswordManager.seatPrice / 12 | currency : "$" }} /{{ - "month" | i18n + {{ + (selectableProduct.isAnnual + ? selectableProduct.PasswordManager.seatPrice / 12 + : selectableProduct.PasswordManager.seatPrice + ) | currency : "$" }} + /{{ "month" | i18n }} @@ -137,7 +157,13 @@

{{ "chooseYourPlan" | i18n }}

" > {{ - "costPerUser" | i18n : (selectableProduct.PasswordManager.seatPrice / 12 | currency : "$") + "costPerUser" + | i18n + : ((selectableProduct.isAnnual + ? selectableProduct.PasswordManager.seatPrice / 12 + : selectableProduct.PasswordManager.seatPrice + ) + | currency : "$") }} /{{ "month" | i18n }} @@ -249,7 +275,13 @@

{{ "summary" | i18n }}

{{ "annually" | i18n }} {{ "basePrice" | i18n }}: - {{ selectablePlan.PasswordManager.basePrice / 12 | currency : "$" }} × 12 + {{ + (selectablePlan.isAnnual + ? selectablePlan.PasswordManager.basePrice / 12 + : selectablePlan.PasswordManager.basePrice + ) | currency : "$" + }} + × 12 {{ "monthAbbr" | i18n }} = @@ -269,8 +301,13 @@

{{ "summary" | i18n }}

> {{ "users" | i18n }}: {{ formGroup.controls["additionalSeats"].value || 0 }} × - {{ selectablePlan.PasswordManager.seatPrice / 12 | currency : "$" }} × 12 - {{ "monthAbbr" | i18n }} = + {{ + (selectablePlan.isAnnual + ? selectablePlan.PasswordManager.seatPrice / 12 + : selectablePlan.PasswordManager.seatPrice + ) | currency : "$" + }} + × 12 {{ "monthAbbr" | i18n }} = {{ passwordManagerSeatTotal(selectablePlan, formGroup.value.additionalSeats) | currency : "$" @@ -280,7 +317,12 @@

{{ "summary" | i18n }}

{{ "additionalStorageGb" | i18n }}: {{ formGroup.controls["additionalStorage"].value || 0 }} × - {{ selectablePlan.PasswordManager.additionalStoragePricePerGb / 12 | currency : "$" }} + {{ + (selectablePlan.isAnnual + ? selectablePlan.PasswordManager.additionalStoragePricePerGb / 12 + : selectablePlan.PasswordManager.additionalStoragePricePerGb + ) | currency : "$" + }} × 12 {{ "monthAbbr" | i18n }} = {{ additionalStorageTotal(selectablePlan) | currency : "$" }} /{{ "year" | i18n }} diff --git a/apps/web/src/app/billing/organizations/organization-plans.component.ts b/apps/web/src/app/billing/organizations/organization-plans.component.ts index 9af3296169b..c6b7b4c9773 100644 --- a/apps/web/src/app/billing/organizations/organization-plans.component.ts +++ b/apps/web/src/app/billing/organizations/organization-plans.component.ts @@ -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; + } } diff --git a/apps/web/src/app/billing/organizations/organization-subscription-cloud.component.html b/apps/web/src/app/billing/organizations/organization-subscription-cloud.component.html index 62d17a7e00d..da115459b42 100644 --- a/apps/web/src/app/billing/organizations/organization-subscription-cloud.component.html +++ b/apps/web/src/app/billing/organizations/organization-subscription-cloud.component.html @@ -71,7 +71,7 @@

- {{ i.productName }} - + {{ i.productName | i18n }} - {{ i.name }} {{ i.quantity > 1 ? "×" + i.quantity : "" }} @ {{ i.amount | currency : "$" }} @@ -127,6 +127,7 @@

(); 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 { diff --git a/apps/web/src/app/billing/shared/sm-subscribe.component.ts b/apps/web/src/app/billing/shared/sm-subscribe.component.ts index 85836bf17f2..d382f3d1d51 100644 --- a/apps/web/src/app/billing/shared/sm-subscribe.component.ts +++ b/apps/web/src/app/billing/shared/sm-subscribe.component.ts @@ -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"); diff --git a/apps/web/src/locales/en/messages.json b/apps/web/src/locales/en/messages.json index 267d27a638d..4689608f391 100644 --- a/apps/web/src/locales/en/messages.json +++ b/apps/web/src/locales/en/messages.json @@ -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": { diff --git a/libs/common/src/billing/enums/plan-type.enum.ts b/libs/common/src/billing/enums/plan-type.enum.ts index f4c89d2cbdc..38febc50e4b 100644 --- a/libs/common/src/billing/enums/plan-type.enum.ts +++ b/libs/common/src/billing/enums/plan-type.enum.ts @@ -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, } diff --git a/libs/common/src/billing/models/response/plan.response.ts b/libs/common/src/billing/models/response/plan.response.ts index 530b7dd8778..827eb841cbf 100644 --- a/libs/common/src/billing/models/response/plan.response.ts +++ b/libs/common/src/billing/models/response/plan.response.ts @@ -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"); diff --git a/libs/common/src/enums/product-type.enum.ts b/libs/common/src/enums/product-type.enum.ts index 50b836d1f78..7ed3a79e988 100644 --- a/libs/common/src/enums/product-type.enum.ts +++ b/libs/common/src/enums/product-type.enum.ts @@ -3,4 +3,5 @@ export enum ProductType { Families = 1, Teams = 2, Enterprise = 3, + TeamsStarter = 4, }