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 7b76c66b9c..62643bc023 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 @@ -39,7 +39,6 @@ import { import { commaSeparatedEmails } from "./validators/comma-separated-emails.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, @@ -186,12 +185,7 @@ export class MemberDialogComponent implements OnInit, OnDestroy { orgWithoutAdditionalSeatLimitReachedWithUpgradePathValidator( this.organization, this.params.allOrganizationUserEmails, - this.i18nService.t("subscriptionFreePlan", organization.seats), - ), - orgWithoutAdditionalSeatLimitReachedWithoutUpgradePathValidator( - this.organization, - this.params.allOrganizationUserEmails, - this.i18nService.t("subscriptionFamiliesPlan", organization.seats), + this.i18nService.t("subscriptionUpgrade", organization.seats), ), ]; diff --git a/apps/web/src/app/admin-console/organizations/members/components/member-dialog/validators/org-without-additional-seat-limit-reached-with-upgrade-path.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 index 2548f28a1c..b0acfb4dce 100644 --- a/apps/web/src/app/admin-console/organizations/members/components/member-dialog/validators/org-without-additional-seat-limit-reached-with-upgrade-path.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 @@ -111,7 +111,7 @@ describe("orgWithoutAdditionalSeatLimitReachedWithUpgradePathValidator", () => { const result = validatorFn(control); - expect(result).toStrictEqual({ freePlanLimitReached: { message: errorMessage } }); + expect(result).toStrictEqual({ seatLimitReached: { message: errorMessage } }); }); it("should return null when not on free plan", () => { diff --git a/apps/web/src/app/admin-console/organizations/members/components/member-dialog/validators/org-without-additional-seat-limit-reached-with-upgrade-path.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 index ede73cbe82..3eef9d4944 100644 --- a/apps/web/src/app/admin-console/organizations/members/components/member-dialog/validators/org-without-additional-seat-limit-reached-with-upgrade-path.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 @@ -36,9 +36,14 @@ export function orgWithoutAdditionalSeatLimitReachedWithUpgradePathValidator( ), ); - return organization.planProductType === ProductType.Free && + const productHasAdditionalSeatsOption = + organization.planProductType !== ProductType.Free && + organization.planProductType !== ProductType.Families && + organization.planProductType !== ProductType.TeamsStarter; + + return !productHasAdditionalSeatsOption && allOrganizationUserEmails.length + newEmailsToAdd.length > organization.seats - ? { freePlanLimitReached: { message: errorMessage } } + ? { seatLimitReached: { message: errorMessage } } : null; }; } 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 deleted file mode 100644 index 4e7cac8fd8..0000000000 --- a/apps/web/src/app/admin-console/organizations/members/components/member-dialog/validators/org-without-additional-seat-limit-reached-without-upgrade-path.validator.ts +++ /dev/null @@ -1,45 +0,0 @@ -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 bfe2238b24..d4740ab8ee 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 @@ -376,17 +376,6 @@ export class PeopleComponent 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), @@ -399,7 +388,13 @@ export class PeopleComponent return this.i18nService.t("ok"); } - return this.i18nService.t(this.getDialogTitle(this.organization.planProductType)); + const productType = this.organization.planProductType; + + if (productType !== ProductType.Free && productType !== ProductType.TeamsStarter) { + throw new Error(`Unsupported product type: ${productType}`); + } + + return this.i18nService.t("upgrade"); } private async handleDialogClose(result: boolean | undefined): Promise { @@ -407,19 +402,16 @@ export class PeopleComponent 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}`); + const productType = this.organization.planProductType; + + if (productType !== ProductType.Free && productType !== ProductType.TeamsStarter) { + throw new Error(`Unsupported product type: ${this.organization.planProductType}`); } + + await this.router.navigate( + ["/organizations", this.organization.id, "billing", "subscription"], + { queryParams: { upgrade: true } }, + ); } private async showSeatLimitReachedDialog(): Promise { 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 27ca56c2d4..b9a15be5ea 100644 --- a/apps/web/src/app/billing/organizations/change-plan.component.html +++ b/apps/web/src/app/billing/organizations/change-plan.component.html @@ -8,10 +8,8 @@ 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 10ac04b418..a131e344b7 100644 --- a/apps/web/src/app/billing/organizations/change-plan.component.ts +++ b/apps/web/src/app/billing/organizations/change-plan.component.ts @@ -1,7 +1,6 @@ import { Component, EventEmitter, Input, Output } from "@angular/core"; -import { PlanType } from "@bitwarden/common/billing/enums"; -import { ProductType } from "@bitwarden/common/enums"; +import { PlanResponse } from "@bitwarden/common/billing/models/response/plan.response"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; @Component({ @@ -10,13 +9,11 @@ import { LogService } from "@bitwarden/common/platform/abstractions/log.service" }) export class ChangePlanComponent { @Input() organizationId: string; - @Input() currentProductType: ProductType; + @Input() currentPlan: PlanResponse; @Output() onChanged = new EventEmitter(); @Output() onCanceled = new EventEmitter(); formPromise: Promise; - defaultUpgradePlan: PlanType = PlanType.FamiliesAnnually; - defaultUpgradeProduct: ProductType = ProductType.Families; constructor(private logService: LogService) {} 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 f1e9743afb..6f01d42174 100644 --- a/apps/web/src/app/billing/organizations/organization-plans.component.ts +++ b/apps/web/src/app/billing/organizations/organization-plans.component.ts @@ -25,6 +25,7 @@ import { ProviderResponse } from "@bitwarden/common/admin-console/models/respons import { PaymentMethodType, PlanType } from "@bitwarden/common/billing/enums"; import { PaymentRequest } from "@bitwarden/common/billing/models/request/payment.request"; import { BillingResponse } from "@bitwarden/common/billing/models/response/billing.response"; +import { OrganizationSubscriptionResponse } from "@bitwarden/common/billing/models/response/organization-subscription.response"; import { PlanResponse } from "@bitwarden/common/billing/models/response/plan.response"; import { ProductType } from "@bitwarden/common/enums"; import { CryptoService } from "@bitwarden/common/platform/abstractions/crypto.service"; @@ -69,7 +70,7 @@ export class OrganizationPlansComponent implements OnInit, OnDestroy { @Input() showFree = true; @Input() showCancel = false; @Input() acceptingSponsorship = false; - @Input() currentProductType: ProductType; + @Input() currentPlan: PlanResponse; @Input() get product(): ProductType { @@ -126,6 +127,7 @@ export class OrganizationPlansComponent implements OnInit, OnDestroy { passwordManagerPlans: PlanResponse[]; secretsManagerPlans: PlanResponse[]; organization: Organization; + sub: OrganizationSubscriptionResponse; billing: BillingResponse; provider: ProviderResponse; @@ -149,6 +151,12 @@ export class OrganizationPlansComponent implements OnInit, OnDestroy { } async ngOnInit() { + if (this.organizationId) { + this.organization = this.organizationService.get(this.organizationId); + this.billing = await this.organizationApiService.getBilling(this.organizationId); + this.sub = await this.organizationApiService.getSubscription(this.organizationId); + } + if (!this.selfHosted) { const plans = await this.apiService.getPlans(); this.passwordManagerPlans = plans.data.filter((plan) => !!plan.PasswordManager); @@ -179,11 +187,6 @@ export class OrganizationPlansComponent implements OnInit, OnDestroy { this.singleOrgPolicyAppliesToActiveUser = policyAppliesToActiveUser; }); - if (this.organizationId) { - this.organization = this.organizationService.get(this.organizationId); - this.billing = await this.organizationApiService.getBilling(this.organizationId); - } - this.loading = false; } @@ -254,10 +257,8 @@ export class OrganizationPlansComponent implements OnInit, OnDestroy { (plan.isAnnual || plan.product === ProductType.Free || plan.product === ProductType.TeamsStarter) && - (this.currentProductType !== ProductType.TeamsStarter || - plan.product === ProductType.Teams || - plan.product === ProductType.Enterprise) && - (!this.providerId || plan.product !== ProductType.TeamsStarter) && + (!this.currentPlan || this.currentPlan.upgradeSortOrder < plan.upgradeSortOrder) && + (!this.hasProvider || plan.product !== ProductType.TeamsStarter) && ((!this.isProviderQualifiedFor2020Plan() && this.planIsEnabled(plan)) || (this.isProviderQualifiedFor2020Plan() && Allowed2020PlanTypes.includes(plan.type))), ); @@ -269,16 +270,12 @@ export class OrganizationPlansComponent implements OnInit, OnDestroy { get selectablePlans() { const selectedProductType = this.formGroup.controls.product.value; - const result = this.passwordManagerPlans?.filter((plan) => { - const productMatch = plan.product === selectedProductType; - - return ( - (!this.isProviderQualifiedFor2020Plan() && this.planIsEnabled(plan) && productMatch) || - (this.isProviderQualifiedFor2020Plan() && - Allowed2020PlanTypes.includes(plan.type) && - productMatch) - ); - }); + const result = this.passwordManagerPlans?.filter( + (plan) => + plan.product === selectedProductType && + ((!this.isProviderQualifiedFor2020Plan() && this.planIsEnabled(plan)) || + (this.isProviderQualifiedFor2020Plan() && Allowed2020PlanTypes.includes(plan.type))), + ); result.sort((planA, planB) => planA.displaySortOrder - planB.displaySortOrder); return result; @@ -415,27 +412,64 @@ export class OrganizationPlansComponent implements OnInit, OnDestroy { } changedProduct() { - this.formGroup.controls.plan.setValue(this.selectablePlans[0].type); - if (!this.selectedPlan.PasswordManager.hasPremiumAccessOption) { - this.formGroup.controls.premiumAccessAddon.setValue(false); - } - if (!this.selectedPlan.PasswordManager.hasAdditionalStorageOption) { + const selectedPlan = this.selectablePlans[0]; + + this.setPlanType(selectedPlan.type); + this.handlePremiumAddonAccess(selectedPlan.PasswordManager.hasPremiumAccessOption); + this.handleAdditionalStorage(selectedPlan.PasswordManager.hasAdditionalStorageOption); + this.handleAdditionalSeats(selectedPlan.PasswordManager.hasAdditionalSeatsOption); + this.handleSecretsManagerForm(); + } + + setPlanType(planType: PlanType) { + this.formGroup.controls.plan.setValue(planType); + } + + handlePremiumAddonAccess(hasPremiumAccessOption: boolean) { + this.formGroup.controls.premiumAccessAddon.setValue(!hasPremiumAccessOption); + } + + handleAdditionalStorage(selectedPlanHasAdditionalStorageOption: boolean) { + if (!selectedPlanHasAdditionalStorageOption || !this.currentPlan) { this.formGroup.controls.additionalStorage.setValue(0); - } - if (!this.selectedPlan.PasswordManager.hasAdditionalSeatsOption) { - this.formGroup.controls.additionalSeats.setValue(0); - } else if ( - !this.formGroup.controls.additionalSeats.value && - !this.selectedPlan.PasswordManager.baseSeats && - this.selectedPlan.PasswordManager.hasAdditionalSeatsOption - ) { - this.formGroup.controls.additionalSeats.setValue(1); + return; } + if (this.organization?.maxStorageGb) { + this.formGroup.controls.additionalStorage.setValue( + this.organization.maxStorageGb - this.currentPlan.PasswordManager.baseStorageGb, + ); + } + } + + handleAdditionalSeats(selectedPlanHasAdditionalSeatsOption: boolean) { + if (!selectedPlanHasAdditionalSeatsOption) { + this.formGroup.controls.additionalSeats.setValue(0); + return; + } + + if (!this.currentPlan?.PasswordManager?.hasAdditionalSeatsOption) { + this.formGroup.controls.additionalSeats.setValue(this.currentPlan.PasswordManager.baseSeats); + return; + } + + this.formGroup.controls.additionalSeats.setValue(this.organization.seats); + } + + handleSecretsManagerForm() { if (this.planOffersSecretsManager) { this.secretsManagerForm.enable(); - } else { - this.secretsManagerForm.disable(); + } + + if (this.organization.useSecretsManager) { + this.secretsManagerForm.controls.enabled.setValue(true); + } + + if (this.secretsManagerForm.controls.enabled.value) { + this.secretsManagerForm.controls.userSeats.setValue(this.sub?.smSeats || 1); + this.secretsManagerForm.controls.additionalServiceAccounts.setValue( + this.sub?.smServiceAccounts - this.currentPlan.SecretsManager?.baseServiceAccount || 0, + ); } this.secretsManagerForm.updateValueAndValidity(); @@ -700,11 +734,15 @@ export class OrganizationPlansComponent implements OnInit, OnDestroy { return; } - // If they already have SM enabled, bump them up to Teams and enable SM to maintain this access - const organization = this.organizationService.get(this.organizationId); - if (organization.useSecretsManager) { - this.formGroup.controls.product.setValue(ProductType.Teams); - this.secretsManagerForm.controls.enabled.setValue(true); + if (this.currentPlan && this.currentPlan.product !== ProductType.Enterprise) { + const upgradedPlan = this.passwordManagerPlans.find((plan) => + this.currentPlan.product === ProductType.Free + ? plan.type === PlanType.FamiliesAnnually + : plan.upgradeSortOrder == this.currentPlan.upgradeSortOrder + 1, + ); + + this.plan = upgradedPlan.type; + this.product = upgradedPlan.product; this.changedProduct(); } } 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 d4c34982e7..1be65b36f4 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 @@ -127,7 +127,7 @@