[AC-1754] Provide upgrade flow for paid organizations (#6948)

* wip

* Running prettier after npm ci

* Defects AC-1929 AC-1955 AC-1956

* Updated logic to correctly set seat count depending on how you approach the upgrade flow

* Setting sm seats when upgrading to the current count

* Setting max storage if the organization's current plan has it set above the base

* Refactored logic in changedProduct to be a bit more concise. Added logic for handling sm service accounts and storage increases

* Decomposed the logic in changedProduct

* Resolved defects introduced in the merge conflict

---------

Co-authored-by: Conner Turnbull <cturnbull@bitwarden.com>
Co-authored-by: Conner Turnbull <133619638+cturnbull-bitwarden@users.noreply.github.com>
This commit is contained in:
Alex Morask 2023-12-27 10:52:40 -05:00 committed by GitHub
parent 690f4a0ae9
commit 3d30823d2a
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
11 changed files with 116 additions and 136 deletions

View File

@ -39,7 +39,6 @@ import {
import { commaSeparatedEmails } from "./validators/comma-separated-emails.validator"; import { commaSeparatedEmails } from "./validators/comma-separated-emails.validator";
import { orgWithoutAdditionalSeatLimitReachedWithUpgradePathValidator } from "./validators/org-without-additional-seat-limit-reached-with-upgrade-path.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 { export enum MemberDialogTab {
Role = 0, Role = 0,
@ -186,12 +185,7 @@ export class MemberDialogComponent implements OnInit, OnDestroy {
orgWithoutAdditionalSeatLimitReachedWithUpgradePathValidator( orgWithoutAdditionalSeatLimitReachedWithUpgradePathValidator(
this.organization, this.organization,
this.params.allOrganizationUserEmails, this.params.allOrganizationUserEmails,
this.i18nService.t("subscriptionFreePlan", organization.seats), this.i18nService.t("subscriptionUpgrade", organization.seats),
),
orgWithoutAdditionalSeatLimitReachedWithoutUpgradePathValidator(
this.organization,
this.params.allOrganizationUserEmails,
this.i18nService.t("subscriptionFamiliesPlan", organization.seats),
), ),
]; ];

View File

@ -111,7 +111,7 @@ describe("orgWithoutAdditionalSeatLimitReachedWithUpgradePathValidator", () => {
const result = validatorFn(control); 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", () => { it("should return null when not on free plan", () => {

View File

@ -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 allOrganizationUserEmails.length + newEmailsToAdd.length > organization.seats
? { freePlanLimitReached: { message: errorMessage } } ? { seatLimitReached: { message: errorMessage } }
: null; : null;
}; };
} }

View File

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

View File

@ -376,17 +376,6 @@ export class PeopleComponent
return `${product}InvLimitReached${this.getManageBillingText()}`; 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 { private getDialogContent(): string {
return this.i18nService.t( return this.i18nService.t(
this.getProductKey(this.organization.planProductType), this.getProductKey(this.organization.planProductType),
@ -399,7 +388,13 @@ export class PeopleComponent
return this.i18nService.t("ok"); 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<void> { private async handleDialogClose(result: boolean | undefined): Promise<void> {
@ -407,19 +402,16 @@ export class PeopleComponent
return; return;
} }
switch (this.organization.planProductType) { const productType = this.organization.planProductType;
case ProductType.Free:
await this.router.navigate( if (productType !== ProductType.Free && productType !== ProductType.TeamsStarter) {
["/organizations", this.organization.id, "billing", "subscription"], throw new Error(`Unsupported product type: ${this.organization.planProductType}`);
{ 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}`);
} }
await this.router.navigate(
["/organizations", this.organization.id, "billing", "subscription"],
{ queryParams: { upgrade: true } },
);
} }
private async showSeatLimitReachedDialog(): Promise<void> { private async showSeatLimitReachedDialog(): Promise<void> {

View File

@ -8,10 +8,8 @@
<app-organization-plans <app-organization-plans
[showFree]="false" [showFree]="false"
[showCancel]="true" [showCancel]="true"
[plan]="defaultUpgradePlan"
[product]="defaultUpgradeProduct"
[organizationId]="organizationId" [organizationId]="organizationId"
[currentProductType]="currentProductType" [currentPlan]="currentPlan"
(onCanceled)="cancel()" (onCanceled)="cancel()"
> >
</app-organization-plans> </app-organization-plans>

View File

@ -1,7 +1,6 @@
import { Component, EventEmitter, Input, Output } from "@angular/core"; import { Component, EventEmitter, Input, Output } from "@angular/core";
import { PlanType } from "@bitwarden/common/billing/enums"; import { PlanResponse } from "@bitwarden/common/billing/models/response/plan.response";
import { ProductType } from "@bitwarden/common/enums";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
@Component({ @Component({
@ -10,13 +9,11 @@ import { LogService } from "@bitwarden/common/platform/abstractions/log.service"
}) })
export class ChangePlanComponent { export class ChangePlanComponent {
@Input() organizationId: string; @Input() organizationId: string;
@Input() currentProductType: ProductType; @Input() currentPlan: PlanResponse;
@Output() onChanged = new EventEmitter(); @Output() onChanged = new EventEmitter();
@Output() onCanceled = new EventEmitter(); @Output() onCanceled = new EventEmitter();
formPromise: Promise<any>; formPromise: Promise<any>;
defaultUpgradePlan: PlanType = PlanType.FamiliesAnnually;
defaultUpgradeProduct: ProductType = ProductType.Families;
constructor(private logService: LogService) {} constructor(private logService: LogService) {}

View File

@ -25,6 +25,7 @@ import { ProviderResponse } from "@bitwarden/common/admin-console/models/respons
import { PaymentMethodType, PlanType } from "@bitwarden/common/billing/enums"; import { PaymentMethodType, PlanType } from "@bitwarden/common/billing/enums";
import { PaymentRequest } from "@bitwarden/common/billing/models/request/payment.request"; import { PaymentRequest } from "@bitwarden/common/billing/models/request/payment.request";
import { BillingResponse } from "@bitwarden/common/billing/models/response/billing.response"; 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 { PlanResponse } from "@bitwarden/common/billing/models/response/plan.response";
import { ProductType } from "@bitwarden/common/enums"; import { ProductType } from "@bitwarden/common/enums";
import { CryptoService } from "@bitwarden/common/platform/abstractions/crypto.service"; import { CryptoService } from "@bitwarden/common/platform/abstractions/crypto.service";
@ -69,7 +70,7 @@ export class OrganizationPlansComponent implements OnInit, OnDestroy {
@Input() showFree = true; @Input() showFree = true;
@Input() showCancel = false; @Input() showCancel = false;
@Input() acceptingSponsorship = false; @Input() acceptingSponsorship = false;
@Input() currentProductType: ProductType; @Input() currentPlan: PlanResponse;
@Input() @Input()
get product(): ProductType { get product(): ProductType {
@ -126,6 +127,7 @@ export class OrganizationPlansComponent implements OnInit, OnDestroy {
passwordManagerPlans: PlanResponse[]; passwordManagerPlans: PlanResponse[];
secretsManagerPlans: PlanResponse[]; secretsManagerPlans: PlanResponse[];
organization: Organization; organization: Organization;
sub: OrganizationSubscriptionResponse;
billing: BillingResponse; billing: BillingResponse;
provider: ProviderResponse; provider: ProviderResponse;
@ -149,6 +151,12 @@ export class OrganizationPlansComponent implements OnInit, OnDestroy {
} }
async ngOnInit() { 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) { if (!this.selfHosted) {
const plans = await this.apiService.getPlans(); const plans = await this.apiService.getPlans();
this.passwordManagerPlans = plans.data.filter((plan) => !!plan.PasswordManager); this.passwordManagerPlans = plans.data.filter((plan) => !!plan.PasswordManager);
@ -179,11 +187,6 @@ export class OrganizationPlansComponent implements OnInit, OnDestroy {
this.singleOrgPolicyAppliesToActiveUser = policyAppliesToActiveUser; this.singleOrgPolicyAppliesToActiveUser = policyAppliesToActiveUser;
}); });
if (this.organizationId) {
this.organization = this.organizationService.get(this.organizationId);
this.billing = await this.organizationApiService.getBilling(this.organizationId);
}
this.loading = false; this.loading = false;
} }
@ -254,10 +257,8 @@ export class OrganizationPlansComponent implements OnInit, OnDestroy {
(plan.isAnnual || (plan.isAnnual ||
plan.product === ProductType.Free || plan.product === ProductType.Free ||
plan.product === ProductType.TeamsStarter) && plan.product === ProductType.TeamsStarter) &&
(this.currentProductType !== ProductType.TeamsStarter || (!this.currentPlan || this.currentPlan.upgradeSortOrder < plan.upgradeSortOrder) &&
plan.product === ProductType.Teams || (!this.hasProvider || plan.product !== ProductType.TeamsStarter) &&
plan.product === ProductType.Enterprise) &&
(!this.providerId || plan.product !== ProductType.TeamsStarter) &&
((!this.isProviderQualifiedFor2020Plan() && this.planIsEnabled(plan)) || ((!this.isProviderQualifiedFor2020Plan() && this.planIsEnabled(plan)) ||
(this.isProviderQualifiedFor2020Plan() && Allowed2020PlanTypes.includes(plan.type))), (this.isProviderQualifiedFor2020Plan() && Allowed2020PlanTypes.includes(plan.type))),
); );
@ -269,16 +270,12 @@ export class OrganizationPlansComponent implements OnInit, OnDestroy {
get selectablePlans() { get selectablePlans() {
const selectedProductType = this.formGroup.controls.product.value; const selectedProductType = this.formGroup.controls.product.value;
const result = this.passwordManagerPlans?.filter((plan) => { const result = this.passwordManagerPlans?.filter(
const productMatch = plan.product === selectedProductType; (plan) =>
plan.product === selectedProductType &&
return ( ((!this.isProviderQualifiedFor2020Plan() && this.planIsEnabled(plan)) ||
(!this.isProviderQualifiedFor2020Plan() && this.planIsEnabled(plan) && productMatch) || (this.isProviderQualifiedFor2020Plan() && Allowed2020PlanTypes.includes(plan.type))),
(this.isProviderQualifiedFor2020Plan() && );
Allowed2020PlanTypes.includes(plan.type) &&
productMatch)
);
});
result.sort((planA, planB) => planA.displaySortOrder - planB.displaySortOrder); result.sort((planA, planB) => planA.displaySortOrder - planB.displaySortOrder);
return result; return result;
@ -415,27 +412,64 @@ export class OrganizationPlansComponent implements OnInit, OnDestroy {
} }
changedProduct() { changedProduct() {
this.formGroup.controls.plan.setValue(this.selectablePlans[0].type); const selectedPlan = this.selectablePlans[0];
if (!this.selectedPlan.PasswordManager.hasPremiumAccessOption) {
this.formGroup.controls.premiumAccessAddon.setValue(false); this.setPlanType(selectedPlan.type);
} this.handlePremiumAddonAccess(selectedPlan.PasswordManager.hasPremiumAccessOption);
if (!this.selectedPlan.PasswordManager.hasAdditionalStorageOption) { 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); this.formGroup.controls.additionalStorage.setValue(0);
} return;
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);
} }
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) { if (this.planOffersSecretsManager) {
this.secretsManagerForm.enable(); 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(); this.secretsManagerForm.updateValueAndValidity();
@ -700,11 +734,15 @@ export class OrganizationPlansComponent implements OnInit, OnDestroy {
return; return;
} }
// If they already have SM enabled, bump them up to Teams and enable SM to maintain this access if (this.currentPlan && this.currentPlan.product !== ProductType.Enterprise) {
const organization = this.organizationService.get(this.organizationId); const upgradedPlan = this.passwordManagerPlans.find((plan) =>
if (organization.useSecretsManager) { this.currentPlan.product === ProductType.Free
this.formGroup.controls.product.setValue(ProductType.Teams); ? plan.type === PlanType.FamiliesAnnually
this.secretsManagerForm.controls.enabled.setValue(true); : plan.upgradeSortOrder == this.currentPlan.upgradeSortOrder + 1,
);
this.plan = upgradedPlan.type;
this.product = upgradedPlan.product;
this.changedProduct(); this.changedProduct();
} }
} }

View File

@ -127,7 +127,7 @@
</button> </button>
<app-change-plan <app-change-plan
[organizationId]="organizationId" [organizationId]="organizationId"
[currentProductType]="sub.plan.product" [currentPlan]="sub.plan"
(onChanged)="closeChangePlan()" (onChanged)="closeChangePlan()"
(onCanceled)="closeChangePlan()" (onCanceled)="closeChangePlan()"
*ngIf="showChangePlan" *ngIf="showChangePlan"

View File

@ -249,12 +249,13 @@ export class OrganizationSubscriptionCloudComponent implements OnInit, OnDestroy
return this.i18nService.t("subscriptionFreePlan", this.sub.seats.toString()); return this.i18nService.t("subscriptionFreePlan", this.sub.seats.toString());
} else if ( } else if (
this.sub.planType === PlanType.FamiliesAnnually || this.sub.planType === PlanType.FamiliesAnnually ||
this.sub.planType === PlanType.FamiliesAnnually2019 this.sub.planType === PlanType.FamiliesAnnually2019 ||
this.sub.planType === PlanType.TeamsStarter
) { ) {
if (this.isSponsoredSubscription) { if (this.isSponsoredSubscription) {
return this.i18nService.t("subscriptionSponsoredFamiliesPlan", this.sub.seats.toString()); return this.i18nService.t("subscriptionSponsoredFamiliesPlan", this.sub.seats.toString());
} else { } else {
return this.i18nService.t("subscriptionFamiliesPlan", this.sub.seats.toString()); return this.i18nService.t("subscriptionUpgrade", this.sub.seats.toString());
} }
} else if (this.sub.maxAutoscaleSeats === this.sub.seats && this.sub.seats != null) { } else if (this.sub.maxAutoscaleSeats === this.sub.seats && this.sub.seats != null) {
return this.i18nService.t("subscriptionMaxReached", this.sub.seats.toString()); return this.i18nService.t("subscriptionMaxReached", this.sub.seats.toString());
@ -413,7 +414,7 @@ export class OrganizationSubscriptionCloudComponent implements OnInit, OnDestroy
}; };
get showChangePlanButton() { get showChangePlanButton() {
return this.subscription == null && this.sub.planType === PlanType.Free && !this.showChangePlan; return this.sub.plan.product !== ProductType.Enterprise && !this.showChangePlan;
} }
} }

View File

@ -3548,12 +3548,12 @@
} }
} }
}, },
"subscriptionFamiliesPlan": { "subscriptionUpgrade": {
"message": "You cannot invite more than $COUNT$ members without upgrading your plan. Please contact Customer Support to upgrade.", "message": "You cannot invite more than $COUNT$ members without upgrading your plan.",
"placeholders": { "placeholders": {
"count": { "count": {
"content": "$1", "content": "$1",
"example": "6" "example": "2"
} }
} }
}, },
@ -6668,7 +6668,7 @@
} }
}, },
"teamsStarterPlanInvLimitReachedManageBilling": { "teamsStarterPlanInvLimitReachedManageBilling": {
"message": "Teams Starter plans may have up to $SEATCOUNT$ members. Contact Customer Support to upgrade your plan and invite more members.", "message": "Teams Starter plans may have up to $SEATCOUNT$ members. Upgrade to your plan to invite more members.",
"placeholders": { "placeholders": {
"seatcount": { "seatcount": {
"content": "$1", "content": "$1",