From bcbf013cd99dd2d0baa8e624ecef0a87d6bf894d Mon Sep 17 00:00:00 2001 From: cyprain-okeke <108260115+cyprain-okeke@users.noreply.github.com> Date: Wed, 17 Dec 2025 21:13:18 +0100 Subject: [PATCH] [PM 27122] Individual subscription page for self-hosted customers (#17517) * implement the self-host subscription changes * Correct few ui changes * Update to h1 * PR review changes * Changes for the async cancel * Resolve the two bug issues * implement the review comments * Resolve the Active issue * Fix the space issues * Remove the tabs for billing and payment * revert the self-host changes * Fix the subtitle issue --- .../individual/individual-billing.module.ts | 2 + .../self-hosted-premium.component.html | 129 +++++++++++------ .../premium/self-hosted-premium.component.ts | 135 ++++++++++++++---- .../individual/subscription.component.html | 16 ++- .../user-subscription.component.html | 61 +++++--- .../individual/user-subscription.component.ts | 26 +++- .../update-license-dialog.component.html | 36 +++-- .../shared/update-license-dialog.component.ts | 43 +++++- apps/web/src/locales/en/messages.json | 51 +++++++ 9 files changed, 381 insertions(+), 118 deletions(-) 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 200df5d9f07..2a529d43416 100644 --- a/apps/web/src/app/billing/individual/individual-billing.module.ts +++ b/apps/web/src/app/billing/individual/individual-billing.module.ts @@ -1,5 +1,6 @@ import { NgModule } from "@angular/core"; +import { BaseCardComponent } from "@bitwarden/components"; import { PricingCardComponent } from "@bitwarden/pricing"; import { EnterBillingAddressComponent, @@ -23,6 +24,7 @@ import { UserSubscriptionComponent } from "./user-subscription.component"; EnterPaymentMethodComponent, EnterBillingAddressComponent, PricingCardComponent, + BaseCardComponent, ], declarations: [ SubscriptionComponent, 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 index 1e32e73c8f5..9efcd2d2e96 100644 --- 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 @@ -1,49 +1,88 @@ - - - -

{{ "premiumUpgradeUnlockFeatures" | i18n }}

-
    -
  • - - {{ "premiumSignUpStorage" | i18n }} -
  • -
  • - - {{ "premiumSignUpTwoStepOptions" | i18n }} -
  • -
  • - - {{ "premiumSignUpEmergency" | i18n }} -
  • -
  • - - {{ "premiumSignUpReports" | i18n }} -
  • -
  • - - {{ "premiumSignUpTotp" | i18n }} -
  • -
  • - - {{ "premiumSignUpSupport" | i18n }} -
  • -
  • - - {{ "premiumSignUpFuture" | i18n }} -
  • -
+
+ + +
+ + {{ "bitwardenFreeplanMessage" | i18n }} + +
+ + +
+

+ {{ "upgradeCompleteSecurity" | i18n }} +

+

+ {{ "individualUpgradeDescriptionMessage" | i18n }} +

+
+ + +
+

+ {{ "alreadyHaveSubscriptionQuestion" | i18n }} +

+

+ {{ "alreadyHaveSubscriptionSelfHostedMessage" | i18n }} +

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

{{ "premium" | i18n }}

+
+
+ + +
+ +

{{ "families" | 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 index c28f2d45b6f..9dcd75ac9b1 100644 --- 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 @@ -1,36 +1,61 @@ -import { Component } from "@angular/core"; +import { CommonModule } from "@angular/common"; +import { Component, DestroyRef, inject } from "@angular/core"; import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; import { ActivatedRoute, Router } from "@angular/router"; -import { combineLatest, map, of, switchMap } from "rxjs"; +import { firstValueFrom, lastValueFrom, map, Observable, 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"; +import { + BadgeModule, + DialogService, + LinkModule, + SectionComponent, + ToastService, + TypographyModule, +} from "@bitwarden/components"; +import { PricingCardComponent } from "@bitwarden/pricing"; +import { I18nPipe } from "@bitwarden/ui-common"; + +import { UpdateLicenseDialogComponent } from "../../shared/update-license-dialog.component"; +import { UpdateLicenseDialogResult } from "../../shared/update-license-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: "./self-hosted-premium.component.html", - imports: [SharedModule, BillingSharedModule], + standalone: true, + imports: [ + CommonModule, + SectionComponent, + BadgeModule, + TypographyModule, + LinkModule, + I18nPipe, + PricingCardComponent, + ], }) export class SelfHostedPremiumComponent { - cloudPremiumPageUrl$ = this.environmentService.cloudWebVaultUrl$.pipe( + protected 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), - ), + protected cloudFamiliesPageUrl$ = this.environmentService.cloudWebVaultUrl$.pipe( + map((url) => `${url}/#/settings/subscription/premium`), ); - hasPremiumPersonally$ = this.accountService.activeAccount$.pipe( + protected hasPremiumFromAnyOrganization$: Observable = + this.accountService.activeAccount$.pipe( + switchMap((account) => + account + ? this.billingAccountProfileStateService.hasPremiumFromAnyOrganization$(account.id) + : of(false), + ), + ); + + protected hasPremiumPersonally$: Observable = this.accountService.activeAccount$.pipe( switchMap((account) => account ? this.billingAccountProfileStateService.hasPremiumPersonally$(account.id) @@ -38,42 +63,90 @@ export class SelfHostedPremiumComponent { ), ); - onLicenseFileUploaded = async () => { - this.toastService.showToast({ - variant: "success", - title: "", - message: this.i18nService.t("premiumUpdated"), - }); - await this.navigateToSubscription(); - }; + protected shouldShowUpgradeView$: Observable = this.hasPremiumPersonally$.pipe( + map((hasPremium) => !hasPremium), + ); + + protected premiumFeatures = [ + this.i18nService.t("builtInAuthenticator"), + this.i18nService.t("secureFileStorage"), + this.i18nService.t("emergencyAccess"), + this.i18nService.t("breachMonitoring"), + this.i18nService.t("andMoreFeatures"), + ]; + + protected familiesFeatures = [ + this.i18nService.t("premiumAccounts"), + this.i18nService.t("familiesUnlimitedSharing"), + this.i18nService.t("familiesUnlimitedCollections"), + this.i18nService.t("familiesSharedStorage"), + ]; + + private destroyRef = inject(DestroyRef); constructor( private accountService: AccountService, private activatedRoute: ActivatedRoute, private billingAccountProfileStateService: BillingAccountProfileStateService, + private dialogService: DialogService, private environmentService: EnvironmentService, private i18nService: I18nService, private router: Router, private toastService: ToastService, ) { - combineLatest([this.hasPremiumFromAnyOrganization$, this.hasPremiumPersonally$]) + // Redirect premium users to subscription page + this.hasPremiumPersonally$ .pipe( - takeUntilDestroyed(), - switchMap(([hasPremiumFromAnyOrganization, hasPremiumPersonally]) => { - if (hasPremiumFromAnyOrganization) { - return this.navigateToVault(); - } + takeUntilDestroyed(this.destroyRef), + switchMap((hasPremiumPersonally) => { if (hasPremiumPersonally) { return this.navigateToSubscription(); } - return of(true); }), ) .subscribe(); } - navigateToSubscription = () => + protected openUploadLicenseDialog = async () => { + const dialogRef = UpdateLicenseDialogComponent.open(this.dialogService); + const result = await lastValueFrom(dialogRef.closed); + if (result === UpdateLicenseDialogResult.Updated) { + this.toastService.showToast({ + variant: "success", + title: "", + message: this.i18nService.t("premiumUpdated"), + }); + await this.navigateToSubscription(); + } + }; + + protected navigateToSubscription = async (): Promise => this.router.navigate(["../user-subscription"], { relativeTo: this.activatedRoute }); - navigateToVault = () => this.router.navigate(["/vault"]); + + protected onPremiumUpgradeClick = async () => { + const url = await firstValueFrom(this.cloudPremiumPageUrl$); + if (!url) { + this.toastService.showToast({ + variant: "error", + title: "", + message: this.i18nService.t("cloudUrlNotConfigured"), + }); + return; + } + window.open(url, "_blank", "noopener,noreferrer"); + }; + + protected onFamiliesUpgradeClick = async () => { + const url = await firstValueFrom(this.cloudFamiliesPageUrl$); + if (!url) { + this.toastService.showToast({ + variant: "error", + title: "", + message: this.i18nService.t("cloudUrlNotConfigured"), + }); + return; + } + window.open(url, "_blank", "noopener,noreferrer"); + }; } diff --git a/apps/web/src/app/billing/individual/subscription.component.html b/apps/web/src/app/billing/individual/subscription.component.html index 4cbec4b4338..7fd7beff109 100644 --- a/apps/web/src/app/billing/individual/subscription.component.html +++ b/apps/web/src/app/billing/individual/subscription.component.html @@ -1,11 +1,13 @@ - - {{ - "subscription" | i18n - }} - {{ "paymentDetails" | i18n }} - {{ "billingHistory" | i18n }} - + @if (!selfHosted) { + + {{ + "subscription" | i18n + }} + {{ "paymentDetails" | i18n }} + {{ "billingHistory" | i18n }} + + } diff --git a/apps/web/src/app/billing/individual/user-subscription.component.html b/apps/web/src/app/billing/individual/user-subscription.component.html index b7e490cdf2e..2d653ff200b 100644 --- a/apps/web/src/app/billing/individual/user-subscription.component.html +++ b/apps/web/src/app/billing/individual/user-subscription.component.html @@ -32,11 +32,6 @@ {{ "reinstateSubscription" | i18n }}
-
-
{{ "expiration" | i18n }}
-
{{ sub.expiration | date: "mediumDate" }}
-
{{ "neverExpires" | i18n }}
-
@@ -97,19 +92,49 @@
-
- - - {{ "launchCloudSubscription" | i18n }} - +
+

{{ "youHaveBitwardenPremium" | i18n }}

+
+ {{ "viewAndManagePremiumSubscription" | i18n }} +
+
+
+ +
+
+
+

+ {{ "premiumMembership" | i18n }} +

+
+ {{ + "active" | i18n + }} +
+ +

+ {{ "youNeedToUpdateLicenseFile" | i18n }} + {{ sub.expiration | date: "MMMM d, y" }}. +

+ +
+ + + {{ "launchCloudSubscriptionSentenceCase" | i18n }} + + +
+
+
diff --git a/apps/web/src/app/billing/individual/user-subscription.component.ts b/apps/web/src/app/billing/individual/user-subscription.component.ts index c39b5d153b1..8d99b807540 100644 --- a/apps/web/src/app/billing/individual/user-subscription.component.ts +++ b/apps/web/src/app/billing/individual/user-subscription.component.ts @@ -159,7 +159,9 @@ export class UserSubscriptionComponent implements OnInit { if (this.loading) { return; } - const dialogRef = UpdateLicenseDialogComponent.open(this.dialogService); + const dialogRef = UpdateLicenseDialogComponent.open(this.dialogService, { + data: { fromUserSubscriptionPage: true }, + }); const result = await lastValueFrom(dialogRef.closed); if (result === UpdateLicenseDialogResult.Updated) { await this.load(); @@ -259,4 +261,26 @@ export class UserSubscriptionComponent implements OnInit { amountOff: discount.amountOff, }; } + + get isSubscriptionActive(): boolean { + if (!this.sub) { + return false; + } + + if (this.selfHosted) { + return true; + } + + const expiration = this.sub.expiration; + if (!expiration || expiration.trim() === "") { + return true; + } + + const expirationDate = new Date(expiration); + if (isNaN(expirationDate.getTime())) { + return true; + } + + return expirationDate > new Date(); + } } diff --git a/apps/web/src/app/billing/shared/update-license-dialog.component.html b/apps/web/src/app/billing/shared/update-license-dialog.component.html index 7535fe9b30b..95b2fc69295 100644 --- a/apps/web/src/app/billing/shared/update-license-dialog.component.html +++ b/apps/web/src/app/billing/shared/update-license-dialog.component.html @@ -1,16 +1,30 @@
- + - - {{ "licenseFile" | i18n }} -
- - {{ licenseFile ? licenseFile.name : ("noFileChosen" | i18n) }} + {{ + licenseFile ? licenseFile.name : ("noFileChosen" | i18n) + }}
- {{ "licenseFileDesc" | i18n: "bitwarden_premium_license.json" }} -
+

{{ "maxFileSizeSansPunctuation" | i18n }}

+
-