diff --git a/apps/web/src/app/billing/individual/individual-billing-routing.module.ts b/apps/web/src/app/billing/individual/individual-billing-routing.module.ts index 26d0c43ff8f..cdccaaab8ab 100644 --- a/apps/web/src/app/billing/individual/individual-billing-routing.module.ts +++ b/apps/web/src/app/billing/individual/individual-billing-routing.module.ts @@ -2,15 +2,15 @@ import { inject, NgModule } from "@angular/core"; import { RouterModule, Routes } from "@angular/router"; import { map } from "rxjs"; -import { componentRouteSwap } from "@bitwarden/angular/utils/component-route-swap"; import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { AccountPaymentDetailsComponent } from "@bitwarden/web-vault/app/billing/individual/payment-details/account-payment-details.component"; +import { SelfHostedPremiumComponent } from "@bitwarden/web-vault/app/billing/individual/premium/self-hosted-premium.component"; import { BillingHistoryViewComponent } from "./billing-history-view.component"; -import { PremiumVNextComponent } from "./premium/premium-vnext.component"; -import { PremiumComponent } from "./premium/premium.component"; +import { CloudHostedPremiumVNextComponent } from "./premium/cloud-hosted-premium-vnext.component"; +import { CloudHostedPremiumComponent } from "./premium/cloud-hosted-premium.component"; import { SubscriptionComponent } from "./subscription.component"; import { UserSubscriptionComponent } from "./user-subscription.component"; @@ -26,22 +26,55 @@ const routes: Routes = [ component: UserSubscriptionComponent, data: { titleId: "premiumMembership" }, }, - ...componentRouteSwap( - PremiumComponent, - PremiumVNextComponent, - () => { - const configService = inject(ConfigService); - const platformUtilsService = inject(PlatformUtilsService); + /** + * Three-Route Matching Strategy for /premium: + * + * Routes are evaluated in order using canMatch guards. The first route that matches will be selected. + * + * 1. Self-Hosted Environment → SelfHostedPremiumComponent + * - Matches when platformUtilsService.isSelfHost() === true + * + * 2. Cloud-Hosted + Feature Flag Enabled → CloudHostedPremiumVNextComponent + * - Only evaluated if Route 1 doesn't match (not self-hosted) + * - Matches when PM24033PremiumUpgradeNewDesign feature flag === true + * + * 3. Cloud-Hosted + Feature Flag Disabled → CloudHostedPremiumComponent (Fallback) + * - No canMatch guard, so this always matches as the fallback route + * - Used when neither Route 1 nor Route 2 match + */ + // Route 1: Self-Hosted -> SelfHostedPremiumComponent + { + path: "premium", + component: SelfHostedPremiumComponent, + data: { titleId: "goPremium" }, + canMatch: [ + () => { + const platformUtilsService = inject(PlatformUtilsService); + return platformUtilsService.isSelfHost(); + }, + ], + }, + // Route 2: Cloud Hosted + FF -> CloudHostedPremiumVNextComponent + { + path: "premium", + component: CloudHostedPremiumVNextComponent, + data: { titleId: "goPremium" }, + canMatch: [ + () => { + const configService = inject(ConfigService); - return configService - .getFeatureFlag$(FeatureFlag.PM24033PremiumUpgradeNewDesign) - .pipe(map((flagValue) => flagValue === true && !platformUtilsService.isSelfHost())); - }, - { - data: { titleId: "goPremium" }, - path: "premium", - }, - ), + return configService + .getFeatureFlag$(FeatureFlag.PM24033PremiumUpgradeNewDesign) + .pipe(map((flagValue) => flagValue === true)); + }, + ], + }, + // Route 3: Cloud Hosted + FF Disabled -> CloudHostedPremiumComponent (Fallback) + { + path: "premium", + component: CloudHostedPremiumComponent, + data: { titleId: "goPremium" }, + }, { path: "payment-details", component: AccountPaymentDetailsComponent, diff --git a/apps/web/src/app/billing/individual/individual-billing.module.ts b/apps/web/src/app/billing/individual/individual-billing.module.ts index 56c40002f1d..200df5d9f07 100644 --- a/apps/web/src/app/billing/individual/individual-billing.module.ts +++ b/apps/web/src/app/billing/individual/individual-billing.module.ts @@ -11,7 +11,7 @@ import { BillingSharedModule } from "../shared"; import { BillingHistoryViewComponent } from "./billing-history-view.component"; import { IndividualBillingRoutingModule } from "./individual-billing-routing.module"; -import { PremiumComponent } from "./premium/premium.component"; +import { CloudHostedPremiumComponent } from "./premium/cloud-hosted-premium.component"; import { SubscriptionComponent } from "./subscription.component"; import { UserSubscriptionComponent } from "./user-subscription.component"; @@ -28,7 +28,7 @@ import { UserSubscriptionComponent } from "./user-subscription.component"; SubscriptionComponent, BillingHistoryViewComponent, UserSubscriptionComponent, - PremiumComponent, + CloudHostedPremiumComponent, ], }) export class IndividualBillingModule {} diff --git a/apps/web/src/app/billing/individual/premium/premium-vnext.component.html b/apps/web/src/app/billing/individual/premium/cloud-hosted-premium-vnext.component.html similarity index 97% rename from apps/web/src/app/billing/individual/premium/premium-vnext.component.html rename to apps/web/src/app/billing/individual/premium/cloud-hosted-premium-vnext.component.html index ee2bef9baa3..6b168901b2e 100644 --- a/apps/web/src/app/billing/individual/premium/premium-vnext.component.html +++ b/apps/web/src/app/billing/individual/premium/cloud-hosted-premium-vnext.component.html @@ -7,7 +7,7 @@ -

+

{{ "upgradeCompleteSecurity" | i18n }}

diff --git a/apps/web/src/app/billing/individual/premium/premium-vnext.component.ts b/apps/web/src/app/billing/individual/premium/cloud-hosted-premium-vnext.component.ts similarity index 94% rename from apps/web/src/app/billing/individual/premium/premium-vnext.component.ts rename to apps/web/src/app/billing/individual/premium/cloud-hosted-premium-vnext.component.ts index 334e84d1451..9fb34a6ccf0 100644 --- a/apps/web/src/app/billing/individual/premium/premium-vnext.component.ts +++ b/apps/web/src/app/billing/individual/premium/cloud-hosted-premium-vnext.component.ts @@ -21,7 +21,6 @@ import { PersonalSubscriptionPricingTier, PersonalSubscriptionPricingTierIds, } from "@bitwarden/common/billing/types/subscription-pricing-tier"; -import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { SyncService } from "@bitwarden/common/platform/sync"; import { BadgeModule, @@ -52,7 +51,7 @@ const RouteParamValues = { // FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush // eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection @Component({ - templateUrl: "./premium-vnext.component.html", + templateUrl: "./cloud-hosted-premium-vnext.component.html", standalone: true, imports: [ CommonModule, @@ -64,7 +63,7 @@ const RouteParamValues = { PricingCardComponent, ], }) -export class PremiumVNextComponent { +export class CloudHostedPremiumVNextComponent { protected hasPremiumFromAnyOrganization$: Observable; protected hasPremiumPersonally$: Observable; protected shouldShowNewDesign$: Observable; @@ -81,22 +80,18 @@ export class PremiumVNextComponent { features: string[]; }>; protected subscriber!: BitwardenSubscriber; - protected isSelfHost = false; private destroyRef = inject(DestroyRef); constructor( private accountService: AccountService, private apiService: ApiService, private dialogService: DialogService, - private platformUtilsService: PlatformUtilsService, private syncService: SyncService, private billingAccountProfileStateService: BillingAccountProfileStateService, private subscriptionPricingService: SubscriptionPricingServiceAbstraction, private router: Router, private activatedRoute: ActivatedRoute, ) { - this.isSelfHost = this.platformUtilsService.isSelfHost(); - this.hasPremiumFromAnyOrganization$ = this.accountService.activeAccount$.pipe( switchMap((account) => account @@ -187,10 +182,12 @@ export class PremiumVNextComponent { this.shouldShowUpgradeDialogOnInit$ .pipe( - switchMap(async (shouldShowUpgradeDialogOnInit) => { + switchMap((shouldShowUpgradeDialogOnInit) => { if (shouldShowUpgradeDialogOnInit) { - from(this.openUpgradeDialog("Premium")); + return from(this.openUpgradeDialog("Premium")); } + // Return an Observable that completes immediately when dialog should not be shown + return of(void 0); }), takeUntilDestroyed(this.destroyRef), ) diff --git a/apps/web/src/app/billing/individual/premium/premium.component.html b/apps/web/src/app/billing/individual/premium/cloud-hosted-premium.component.html similarity index 88% rename from apps/web/src/app/billing/individual/premium/premium.component.html rename to apps/web/src/app/billing/individual/premium/cloud-hosted-premium.component.html index 39b32be0853..63c26bd61f1 100644 --- a/apps/web/src/app/billing/individual/premium/premium.component.html +++ b/apps/web/src/app/billing/individual/premium/cloud-hosted-premium.component.html @@ -10,7 +10,7 @@ } @else { -

{{ "goPremium" | i18n }}

+

{{ "goPremium" | i18n }}

-

+

{{ "premiumPriceWithFamilyPlan" | i18n: (premiumPrice$ | async | currency: "$") : familyPlanMaxUserCount @@ -65,24 +65,9 @@ {{ "bitwardenFamiliesPlan" | i18n }}

- - {{ "purchasePremium" | i18n }} -
- - - -
+

{{ "addons" | i18n }}

diff --git a/apps/web/src/app/billing/individual/premium/premium.component.ts b/apps/web/src/app/billing/individual/premium/cloud-hosted-premium.component.ts similarity index 92% rename from apps/web/src/app/billing/individual/premium/premium.component.ts rename to apps/web/src/app/billing/individual/premium/cloud-hosted-premium.component.ts index 62d62331b94..fceeeedf170 100644 --- a/apps/web/src/app/billing/individual/premium/premium.component.ts +++ b/apps/web/src/app/billing/individual/premium/cloud-hosted-premium.component.ts @@ -27,7 +27,6 @@ import { DefaultSubscriptionPricingService } from "@bitwarden/common/billing/ser import { PersonalSubscriptionPricingTierIds } from "@bitwarden/common/billing/types/subscription-pricing-tier"; import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; -import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { SyncService } from "@bitwarden/common/platform/sync"; import { ToastService } from "@bitwarden/components"; import { SubscriberBillingClient, TaxClient } from "@bitwarden/web-vault/app/billing/clients"; @@ -45,11 +44,11 @@ import { mapAccountToSubscriber } from "@bitwarden/web-vault/app/billing/types"; // FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush // eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection @Component({ - templateUrl: "./premium.component.html", + templateUrl: "./cloud-hosted-premium.component.html", standalone: false, providers: [SubscriberBillingClient, TaxClient], }) -export class PremiumComponent { +export class CloudHostedPremiumComponent { // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals // eslint-disable-next-line @angular-eslint/prefer-signals @ViewChild(EnterPaymentMethodComponent) enterPaymentMethodComponent!: EnterPaymentMethodComponent; @@ -121,7 +120,6 @@ export class PremiumComponent { ); protected cloudWebVaultURL: string; - protected isSelfHost = false; protected readonly familyPlanMaxUserCount = 6; constructor( @@ -130,7 +128,6 @@ export class PremiumComponent { private billingAccountProfileStateService: BillingAccountProfileStateService, private environmentService: EnvironmentService, private i18nService: I18nService, - private platformUtilsService: PlatformUtilsService, private router: Router, private syncService: SyncService, private toastService: ToastService, @@ -139,8 +136,6 @@ export class PremiumComponent { private taxClient: TaxClient, private subscriptionPricingService: DefaultSubscriptionPricingService, ) { - this.isSelfHost = this.platformUtilsService.isSelfHost(); - this.hasPremiumFromAnyOrganization$ = this.accountService.activeAccount$.pipe( switchMap((account) => this.billingAccountProfileStateService.hasPremiumFromAnyOrganization$(account.id), @@ -231,7 +226,10 @@ export class PremiumComponent { const formData = new FormData(); formData.append("paymentMethodType", paymentMethodType.toString()); formData.append("paymentToken", paymentToken); - formData.append("additionalStorageGb", this.formGroup.value.additionalStorage.toString()); + formData.append( + "additionalStorageGb", + (this.formGroup.value.additionalStorage ?? 0).toString(), + ); formData.append("country", this.formGroup.value.billingAddress.country); formData.append("postalCode", this.formGroup.value.billingAddress.postalCode); @@ -239,12 +237,4 @@ export class PremiumComponent { await this.finalizeUpgrade(); await this.postFinalizeUpgrade(); }; - - protected get premiumURL(): string { - return `${this.cloudWebVaultURL}/#/settings/subscription/premium`; - } - - protected async onLicenseFileSelectedChanged(): Promise { - await this.postFinalizeUpgrade(); - } } diff --git a/apps/web/src/app/billing/individual/premium/self-hosted-premium.component.html b/apps/web/src/app/billing/individual/premium/self-hosted-premium.component.html new file mode 100644 index 00000000000..1e32e73c8f5 --- /dev/null +++ b/apps/web/src/app/billing/individual/premium/self-hosted-premium.component.html @@ -0,0 +1,49 @@ + + + +

{{ "premiumUpgradeUnlockFeatures" | i18n }}

+
    +
  • + + {{ "premiumSignUpStorage" | i18n }} +
  • +
  • + + {{ "premiumSignUpTwoStepOptions" | i18n }} +
  • +
  • + + {{ "premiumSignUpEmergency" | i18n }} +
  • +
  • + + {{ "premiumSignUpReports" | i18n }} +
  • +
  • + + {{ "premiumSignUpTotp" | i18n }} +
  • +
  • + + {{ "premiumSignUpSupport" | i18n }} +
  • +
  • + + {{ "premiumSignUpFuture" | i18n }} +
  • +
+ + {{ "purchasePremium" | i18n }} + +
+
+ + + +
diff --git a/apps/web/src/app/billing/individual/premium/self-hosted-premium.component.ts b/apps/web/src/app/billing/individual/premium/self-hosted-premium.component.ts new file mode 100644 index 00000000000..c28f2d45b6f --- /dev/null +++ b/apps/web/src/app/billing/individual/premium/self-hosted-premium.component.ts @@ -0,0 +1,79 @@ +import { Component } from "@angular/core"; +import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; +import { ActivatedRoute, Router } from "@angular/router"; +import { combineLatest, map, of, switchMap } from "rxjs"; + +import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions"; +import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { ToastService } from "@bitwarden/components"; +import { BillingSharedModule } from "@bitwarden/web-vault/app/billing/shared"; +import { SharedModule } from "@bitwarden/web-vault/app/shared"; + +// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush +// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection +@Component({ + templateUrl: "./self-hosted-premium.component.html", + imports: [SharedModule, BillingSharedModule], +}) +export class SelfHostedPremiumComponent { + cloudPremiumPageUrl$ = this.environmentService.cloudWebVaultUrl$.pipe( + map((url) => `${url}/#/settings/subscription/premium`), + ); + + hasPremiumFromAnyOrganization$ = this.accountService.activeAccount$.pipe( + switchMap((account) => + account + ? this.billingAccountProfileStateService.hasPremiumFromAnyOrganization$(account.id) + : of(false), + ), + ); + + hasPremiumPersonally$ = this.accountService.activeAccount$.pipe( + switchMap((account) => + account + ? this.billingAccountProfileStateService.hasPremiumPersonally$(account.id) + : of(false), + ), + ); + + onLicenseFileUploaded = async () => { + this.toastService.showToast({ + variant: "success", + title: "", + message: this.i18nService.t("premiumUpdated"), + }); + await this.navigateToSubscription(); + }; + + constructor( + private accountService: AccountService, + private activatedRoute: ActivatedRoute, + private billingAccountProfileStateService: BillingAccountProfileStateService, + private environmentService: EnvironmentService, + private i18nService: I18nService, + private router: Router, + private toastService: ToastService, + ) { + combineLatest([this.hasPremiumFromAnyOrganization$, this.hasPremiumPersonally$]) + .pipe( + takeUntilDestroyed(), + switchMap(([hasPremiumFromAnyOrganization, hasPremiumPersonally]) => { + if (hasPremiumFromAnyOrganization) { + return this.navigateToVault(); + } + if (hasPremiumPersonally) { + return this.navigateToSubscription(); + } + + return of(true); + }), + ) + .subscribe(); + } + + navigateToSubscription = () => + this.router.navigate(["../user-subscription"], { relativeTo: this.activatedRoute }); + navigateToVault = () => this.router.navigate(["/vault"]); +}