1
0
mirror of https://github.com/bitwarden/browser synced 2025-12-10 13:23:34 +00:00

[PM-23713] premium badge interaction (#16911)

* feature flag

* new upgrade dialog component and moved pricing service into libs

first draft

* moved pricing service to libs/common

removed toast service from the pricing service and implemented error handling in calling components

# Conflicts:
#	apps/web/src/app/billing/individual/upgrade/upgrade-payment/upgrade-payment.component.ts

* moved new premium upgrade dialog component to libs/angular

* badge opens new dialog in browser extension

* adds new dialog to desktop and fixes tests

* updates send dropdown to use premium prompt service

* styling and copy updates

* implement in web and desktop

* unit tests

* converting premium reports to use premium badge, and some cleanup

* fixes issue after merge

* linter errors

* pr feedback

* handle async promise correctly

* full sync after the premium upgrade is complete

* fixing test

* add padding to bottom of card in new dialog

* add support for self hosting

* fixing tests

* fix test

* Update has-premium.guard.ts

* pr feedback

* fix build and pr feedback

* fix build

* prettier

* fixing stories and making badge line height consistent

* pr feedback

* updated upgrade dialog to no longer use pricing card

* fixing incorrect markup and removing unused bits

* formatting

* pr feedback

removing unused message keys and adding back in code that was erroneously removed

* change detection

* close dialog when error

* claude pr feedback
This commit is contained in:
Kyle Denney
2025-11-03 10:16:01 -06:00
committed by GitHub
parent 3c16547f11
commit e1e3966cc2
55 changed files with 1462 additions and 355 deletions

View File

@@ -147,18 +147,6 @@ export class AppComponent implements OnDestroy, OnInit {
}
break;
}
case "premiumRequired": {
const premiumConfirmed = await this.dialogService.openSimpleDialog({
title: { key: "premiumRequired" },
content: { key: "premiumRequiredDesc" },
acceptButtonText: { key: "upgrade" },
type: "success",
});
if (premiumConfirmed) {
await this.router.navigate(["settings/subscription/premium"]);
}
break;
}
case "emailVerificationRequired": {
const emailVerificationConfirmed = await this.dialogService.openSimpleDialog({
title: { key: "emailVerificationRequired" },

View File

@@ -96,15 +96,6 @@ export class EmergencyAccessComponent implements OnInit {
this.loaded = true;
}
async premiumRequired() {
const canAccessPremium = await firstValueFrom(this.canAccessPremium$);
if (!canAccessPremium) {
this.messagingService.send("premiumRequired");
return;
}
}
edit = async (details: GranteeEmergencyAccess) => {
const canAccessPremium = await firstValueFrom(this.canAccessPremium$);
const dialogRef = EmergencyAccessAddEditComponent.open(this.dialogService, {

View File

@@ -3,7 +3,6 @@
import { Component, OnDestroy, OnInit } from "@angular/core";
import {
first,
firstValueFrom,
lastValueFrom,
Observable,
Subject,
@@ -264,13 +263,6 @@ export class TwoFactorSetupComponent implements OnInit, OnDestroy {
}
}
async premiumRequired() {
if (!(await firstValueFrom(this.canAccessPremium$))) {
this.messagingService.send("premiumRequired");
return;
}
}
protected getTwoFactorProviders() {
return this.twoFactorApiService.getTwoFactorProviders();
}

View File

@@ -1,21 +1,21 @@
import { inject } from "@angular/core";
import {
ActivatedRouteSnapshot,
RouterStateSnapshot,
Router,
CanActivateFn,
Router,
RouterStateSnapshot,
UrlTree,
} from "@angular/router";
import { Observable, of } from "rxjs";
import { from, Observable, of } from "rxjs";
import { switchMap, tap } from "rxjs/operators";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service";
import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service";
import { PremiumUpgradePromptService } from "@bitwarden/common/vault/abstractions/premium-upgrade-prompt.service";
/**
* CanActivate guard that checks if the user has premium and otherwise triggers the "premiumRequired"
* message and blocks navigation.
* CanActivate guard that checks if the user has premium and otherwise triggers the premium upgrade
* flow and blocks navigation.
*/
export function hasPremiumGuard(): CanActivateFn {
return (
@@ -23,7 +23,7 @@ export function hasPremiumGuard(): CanActivateFn {
_state: RouterStateSnapshot,
): Observable<boolean | UrlTree> => {
const router = inject(Router);
const messagingService = inject(MessagingService);
const premiumUpgradePromptService = inject(PremiumUpgradePromptService);
const billingAccountProfileStateService = inject(BillingAccountProfileStateService);
const accountService = inject(AccountService);
@@ -33,10 +33,14 @@ export function hasPremiumGuard(): CanActivateFn {
? billingAccountProfileStateService.hasPremiumFromAnySource$(account.id)
: of(false),
),
tap((userHasPremium: boolean) => {
switchMap((userHasPremium: boolean) => {
// Can't call async method inside observables so instead, wait for service then switch back to the boolean
if (!userHasPremium) {
messagingService.send("premiumRequired");
return from(premiumUpgradePromptService.promptForPremium()).pipe(
switchMap(() => of(userHasPremium)),
);
}
return of(userHasPremium);
}),
// Prevent trapping the user on the login page, since that's an awful UX flow
tap((userHasPremium: boolean) => {

View File

@@ -16,6 +16,11 @@ import {
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions";
import { SubscriptionPricingServiceAbstraction } from "@bitwarden/common/billing/abstractions/subscription-pricing.service.abstraction";
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 {
@@ -28,12 +33,7 @@ import {
import { PricingCardComponent } from "@bitwarden/pricing";
import { I18nPipe } from "@bitwarden/ui-common";
import { SubscriptionPricingService } from "../../services/subscription-pricing.service";
import { BitwardenSubscriber, mapAccountToSubscriber } from "../../types";
import {
PersonalSubscriptionPricingTier,
PersonalSubscriptionPricingTierIds,
} from "../../types/subscription-pricing-tier";
import {
UnifiedUpgradeDialogComponent,
UnifiedUpgradeDialogParams,
@@ -91,7 +91,7 @@ export class PremiumVNextComponent {
private platformUtilsService: PlatformUtilsService,
private syncService: SyncService,
private billingAccountProfileStateService: BillingAccountProfileStateService,
private subscriptionPricingService: SubscriptionPricingService,
private subscriptionPricingService: SubscriptionPricingServiceAbstraction,
private router: Router,
private activatedRoute: ActivatedRoute,
) {

View File

@@ -5,6 +5,7 @@ import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
import { FormControl, FormGroup, Validators } from "@angular/forms";
import { ActivatedRoute, Router } from "@angular/router";
import {
catchError,
combineLatest,
concatMap,
filter,
@@ -12,10 +13,9 @@ import {
map,
Observable,
of,
shareReplay,
startWith,
switchMap,
catchError,
shareReplay,
} from "rxjs";
import { debounceTime } from "rxjs/operators";
@@ -23,6 +23,8 @@ import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions";
import { PaymentMethodType } from "@bitwarden/common/billing/enums";
import { DefaultSubscriptionPricingService } from "@bitwarden/common/billing/services/subscription-pricing.service";
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";
@@ -35,12 +37,10 @@ import {
getBillingAddressFromForm,
} from "@bitwarden/web-vault/app/billing/payment/components";
import {
tokenizablePaymentMethodToLegacyEnum,
NonTokenizablePaymentMethods,
tokenizablePaymentMethodToLegacyEnum,
} from "@bitwarden/web-vault/app/billing/payment/types";
import { SubscriptionPricingService } from "@bitwarden/web-vault/app/billing/services/subscription-pricing.service";
import { mapAccountToSubscriber } from "@bitwarden/web-vault/app/billing/types";
import { PersonalSubscriptionPricingTierIds } from "@bitwarden/web-vault/app/billing/types/subscription-pricing-tier";
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
@@ -137,7 +137,7 @@ export class PremiumComponent {
private accountService: AccountService,
private subscriberBillingClient: SubscriberBillingClient,
private taxClient: TaxClient,
private subscriptionPricingService: SubscriptionPricingService,
private subscriptionPricingService: DefaultSubscriptionPricingService,
) {
this.isSelfHost = this.platformUtilsService.isSelfHost();

View File

@@ -4,13 +4,13 @@ import { NoopAnimationsModule } from "@angular/platform-browser/animations";
import { mock } from "jest-mock-extended";
import { Account } from "@bitwarden/common/auth/abstractions/account.service";
import { UserId } from "@bitwarden/common/types/guid";
import { DIALOG_DATA, DialogRef } from "@bitwarden/components";
import {
PersonalSubscriptionPricingTierId,
PersonalSubscriptionPricingTierIds,
} from "../../../types/subscription-pricing-tier";
} from "@bitwarden/common/billing/types/subscription-pricing-tier";
import { UserId } from "@bitwarden/common/types/guid";
import { DIALOG_DATA, DialogRef } from "@bitwarden/components";
import {
UpgradeAccountComponent,
UpgradeAccountStatus,

View File

@@ -4,6 +4,7 @@ import { Component, Inject, OnInit, signal } from "@angular/core";
import { Router } from "@angular/router";
import { Account } from "@bitwarden/common/auth/abstractions/account.service";
import { PersonalSubscriptionPricingTierId } from "@bitwarden/common/billing/types/subscription-pricing-tier";
import { UnionOfValues } from "@bitwarden/common/vault/types/union-of-values";
import {
ButtonModule,
@@ -15,7 +16,6 @@ import {
import { AccountBillingClient, TaxClient } from "../../../clients";
import { BillingServicesModule } from "../../../services";
import { PersonalSubscriptionPricingTierId } from "../../../types/subscription-pricing-tier";
import { UpgradeAccountComponent } from "../upgrade-account/upgrade-account.component";
import { UpgradePaymentService } from "../upgrade-payment/services/upgrade-payment.service";
import {

View File

@@ -4,15 +4,15 @@ import { NoopAnimationsModule } from "@angular/platform-browser/animations";
import { mock } from "jest-mock-extended";
import { of } from "rxjs";
import { SubscriptionPricingServiceAbstraction } from "@bitwarden/common/billing/abstractions/subscription-pricing.service.abstraction";
import {
PersonalSubscriptionPricingTier,
PersonalSubscriptionPricingTierIds,
} from "@bitwarden/common/billing/types/subscription-pricing-tier";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { PricingCardComponent } from "@bitwarden/pricing";
import { BillingServicesModule } from "../../../services";
import { SubscriptionPricingService } from "../../../services/subscription-pricing.service";
import {
PersonalSubscriptionPricingTier,
PersonalSubscriptionPricingTierIds,
} from "../../../types/subscription-pricing-tier";
import { UpgradeAccountComponent, UpgradeAccountStatus } from "./upgrade-account.component";
@@ -20,7 +20,7 @@ describe("UpgradeAccountComponent", () => {
let sut: UpgradeAccountComponent;
let fixture: ComponentFixture<UpgradeAccountComponent>;
const mockI18nService = mock<I18nService>();
const mockSubscriptionPricingService = mock<SubscriptionPricingService>();
const mockSubscriptionPricingService = mock<SubscriptionPricingServiceAbstraction>();
// Mock pricing tiers data
const mockPricingTiers: PersonalSubscriptionPricingTier[] = [
@@ -57,7 +57,10 @@ describe("UpgradeAccountComponent", () => {
imports: [NoopAnimationsModule, UpgradeAccountComponent, PricingCardComponent, CdkTrapFocus],
providers: [
{ provide: I18nService, useValue: mockI18nService },
{ provide: SubscriptionPricingService, useValue: mockSubscriptionPricingService },
{
provide: SubscriptionPricingServiceAbstraction,
useValue: mockSubscriptionPricingService,
},
],
})
.overrideComponent(UpgradeAccountComponent, {
@@ -170,7 +173,10 @@ describe("UpgradeAccountComponent", () => {
],
providers: [
{ provide: I18nService, useValue: mockI18nService },
{ provide: SubscriptionPricingService, useValue: mockSubscriptionPricingService },
{
provide: SubscriptionPricingServiceAbstraction,
useValue: mockSubscriptionPricingService,
},
],
})
.overrideComponent(UpgradeAccountComponent, {

View File

@@ -2,22 +2,23 @@ import { CdkTrapFocus } from "@angular/cdk/a11y";
import { CommonModule } from "@angular/common";
import { Component, DestroyRef, OnInit, computed, input, output, signal } from "@angular/core";
import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
import { catchError, of } from "rxjs";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { UnionOfValues } from "@bitwarden/common/vault/types/union-of-values";
import { ButtonType, DialogModule } from "@bitwarden/components";
import { PricingCardComponent } from "@bitwarden/pricing";
import { SharedModule } from "../../../../shared";
import { BillingServicesModule } from "../../../services";
import { SubscriptionPricingService } from "../../../services/subscription-pricing.service";
import { SubscriptionPricingServiceAbstraction } from "@bitwarden/common/billing/abstractions/subscription-pricing.service.abstraction";
import {
PersonalSubscriptionPricingTier,
PersonalSubscriptionPricingTierId,
PersonalSubscriptionPricingTierIds,
SubscriptionCadence,
SubscriptionCadenceIds,
} from "../../../types/subscription-pricing-tier";
} from "@bitwarden/common/billing/types/subscription-pricing-tier";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { UnionOfValues } from "@bitwarden/common/vault/types/union-of-values";
import { ButtonType, DialogModule, ToastService } from "@bitwarden/components";
import { PricingCardComponent } from "@bitwarden/pricing";
import { SharedModule } from "../../../../shared";
import { BillingServicesModule } from "../../../services";
export const UpgradeAccountStatus = {
Closed: "closed",
@@ -72,14 +73,26 @@ export class UpgradeAccountComponent implements OnInit {
constructor(
private i18nService: I18nService,
private subscriptionPricingService: SubscriptionPricingService,
private subscriptionPricingService: SubscriptionPricingServiceAbstraction,
private toastService: ToastService,
private destroyRef: DestroyRef,
) {}
ngOnInit(): void {
this.subscriptionPricingService
.getPersonalSubscriptionPricingTiers$()
.pipe(takeUntilDestroyed(this.destroyRef))
.pipe(
catchError((error: unknown) => {
this.toastService.showToast({
variant: "error",
title: "",
message: this.i18nService.t("unexpectedError"),
});
this.loading.set(false);
return of([]);
}),
takeUntilDestroyed(this.destroyRef),
)
.subscribe((plans) => {
this.setupCardDetails(plans);
this.loading.set(false);

View File

@@ -119,14 +119,13 @@ describe("UpgradeNavButtonComponent", () => {
);
});
it("should refresh token and sync after upgrading to premium", async () => {
it("should full sync after upgrading to premium", async () => {
const mockDialogRef = mock<DialogRef<UnifiedUpgradeDialogResult>>();
mockDialogRef.closed = of({ status: UnifiedUpgradeDialogStatus.UpgradedToPremium });
mockDialogService.open.mockReturnValue(mockDialogRef);
await component.upgrade();
expect(mockApiService.refreshIdentityToken).toHaveBeenCalled();
expect(mockSyncService.fullSync).toHaveBeenCalledWith(true);
});

View File

@@ -60,7 +60,6 @@ export class UpgradeNavButtonComponent {
const result = await lastValueFrom(dialogRef.closed);
if (result?.status === UnifiedUpgradeDialogStatus.UpgradedToPremium) {
await this.apiService.refreshIdentityToken();
await this.syncService.fullSync(true);
} else if (result?.status === UnifiedUpgradeDialogStatus.UpgradedToFamilies) {
const redirectUrl = `/organizations/${result.organizationId}/vault`;

View File

@@ -11,6 +11,7 @@ import { OrganizationResponse } from "@bitwarden/common/admin-console/models/res
import { Account, AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { OrganizationBillingServiceAbstraction } from "@bitwarden/common/billing/abstractions";
import { PaymentMethodType, PlanType } from "@bitwarden/common/billing/enums";
import { PersonalSubscriptionPricingTierIds } from "@bitwarden/common/billing/types/subscription-pricing-tier";
import { SyncService } from "@bitwarden/common/platform/sync";
import { UserId } from "@bitwarden/common/types/guid";
import { LogService } from "@bitwarden/logging";
@@ -27,7 +28,6 @@ import {
NonTokenizedPaymentMethod,
TokenizedPaymentMethod,
} from "../../../../payment/types";
import { PersonalSubscriptionPricingTierIds } from "../../../../types/subscription-pricing-tier";
import { UpgradePaymentService, PlanDetails } from "./upgrade-payment.service";

View File

@@ -12,6 +12,11 @@ import {
SubscriptionInformation,
} from "@bitwarden/common/billing/abstractions";
import { PaymentMethodType, PlanType } from "@bitwarden/common/billing/enums";
import {
PersonalSubscriptionPricingTier,
PersonalSubscriptionPricingTierId,
PersonalSubscriptionPricingTierIds,
} from "@bitwarden/common/billing/types/subscription-pricing-tier";
import { SyncService } from "@bitwarden/common/vault/abstractions/sync/sync.service.abstraction";
import { LogService } from "@bitwarden/logging";
@@ -30,11 +35,6 @@ import {
TokenizedPaymentMethod,
} from "../../../../payment/types";
import { mapAccountToSubscriber } from "../../../../types";
import {
PersonalSubscriptionPricingTier,
PersonalSubscriptionPricingTierId,
PersonalSubscriptionPricingTierIds,
} from "../../../../types/subscription-pricing-tier";
export type PlanDetails = {
tier: PersonalSubscriptionPricingTierId;

View File

@@ -24,6 +24,12 @@ import {
} from "rxjs";
import { Account } from "@bitwarden/common/auth/abstractions/account.service";
import { SubscriptionPricingServiceAbstraction } from "@bitwarden/common/billing/abstractions/subscription-pricing.service.abstraction";
import {
PersonalSubscriptionPricingTier,
PersonalSubscriptionPricingTierId,
PersonalSubscriptionPricingTierIds,
} from "@bitwarden/common/billing/types/subscription-pricing-tier";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { UnionOfValues } from "@bitwarden/common/vault/types/union-of-values";
import { ButtonModule, DialogModule, ToastService } from "@bitwarden/components";
@@ -43,13 +49,7 @@ import {
TokenizedPaymentMethod,
} from "../../../payment/types";
import { BillingServicesModule } from "../../../services";
import { SubscriptionPricingService } from "../../../services/subscription-pricing.service";
import { BitwardenSubscriber } from "../../../types";
import {
PersonalSubscriptionPricingTier,
PersonalSubscriptionPricingTierId,
PersonalSubscriptionPricingTierIds,
} from "../../../types/subscription-pricing-tier";
import {
PaymentFormValues,
@@ -128,7 +128,7 @@ export class UpgradePaymentComponent implements OnInit, AfterViewInit {
constructor(
private i18nService: I18nService,
private subscriptionPricingService: SubscriptionPricingService,
private subscriptionPricingService: SubscriptionPricingServiceAbstraction,
private toastService: ToastService,
private logService: LogService,
private destroyRef: DestroyRef,
@@ -145,29 +145,42 @@ export class UpgradePaymentComponent implements OnInit, AfterViewInit {
}
this.pricingTiers$ = this.subscriptionPricingService.getPersonalSubscriptionPricingTiers$();
this.pricingTiers$.pipe(takeUntilDestroyed(this.destroyRef)).subscribe((plans) => {
const planDetails = plans.find((plan) => plan.id === this.selectedPlanId());
this.pricingTiers$
.pipe(
catchError((error: unknown) => {
this.toastService.showToast({
variant: "error",
title: this.i18nService.t("error"),
message: this.i18nService.t("unexpectedError"),
});
this.loading.set(false);
return of([]);
}),
takeUntilDestroyed(this.destroyRef),
)
.subscribe((plans) => {
const planDetails = plans.find((plan) => plan.id === this.selectedPlanId());
if (planDetails) {
this.selectedPlan = {
tier: this.selectedPlanId(),
details: planDetails,
};
this.passwordManager = {
name: this.isFamiliesPlan ? "familiesMembership" : "premiumMembership",
cost: this.selectedPlan.details.passwordManager.annualPrice,
quantity: 1,
cadence: "year",
};
if (planDetails) {
this.selectedPlan = {
tier: this.selectedPlanId(),
details: planDetails,
};
this.passwordManager = {
name: this.isFamiliesPlan ? "familiesMembership" : "premiumMembership",
cost: this.selectedPlan.details.passwordManager.annualPrice,
quantity: 1,
cadence: "year",
};
this.upgradeToMessage = this.i18nService.t(
this.isFamiliesPlan ? "startFreeFamiliesTrial" : "upgradeToPremium",
);
} else {
this.complete.emit({ status: UpgradePaymentStatus.Closed, organizationId: null });
return;
}
});
this.upgradeToMessage = this.i18nService.t(
this.isFamiliesPlan ? "startFreeFamiliesTrial" : "upgradeToPremium",
);
} else {
this.complete.emit({ status: UpgradePaymentStatus.Closed, organizationId: null });
return;
}
});
this.estimatedTax$ = this.formGroup.controls.billingAddress.valueChanges.pipe(
startWith(this.formGroup.controls.billingAddress.value),

View File

@@ -795,7 +795,6 @@ export class ChangePlanDialogComponent implements OnInit, OnDestroy {
: this.i18nService.t("organizationUpgraded"),
});
await this.apiService.refreshIdentityToken();
await this.syncService.fullSync(true);
if (!this.acceptingSponsorship && !this.isInTrialFlow) {

View File

@@ -675,7 +675,6 @@ export class OrganizationPlansComponent implements OnInit, OnDestroy {
});
}
await this.apiService.refreshIdentityToken();
await this.syncService.fullSync(true);
if (!this.acceptingSponsorship && !this.isInTrialFlow) {

View File

@@ -1,397 +0,0 @@
import { Injectable } from "@angular/core";
import { combineLatest, from, map, Observable, of, shareReplay, switchMap, take } from "rxjs";
import { catchError } from "rxjs/operators";
import { BillingApiServiceAbstraction } from "@bitwarden/common/billing/abstractions";
import { PlanType } from "@bitwarden/common/billing/enums";
import { PlanResponse } from "@bitwarden/common/billing/models/response/plan.response";
import { PremiumPlanResponse } from "@bitwarden/common/billing/models/response/premium-plan.response";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { ListResponse } from "@bitwarden/common/models/response/list.response";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { ToastService } from "@bitwarden/components";
import { LogService } from "@bitwarden/logging";
import { BillingServicesModule } from "@bitwarden/web-vault/app/billing/services/billing-services.module";
import {
BusinessSubscriptionPricingTier,
BusinessSubscriptionPricingTierIds,
PersonalSubscriptionPricingTier,
PersonalSubscriptionPricingTierIds,
SubscriptionCadenceIds,
} from "@bitwarden/web-vault/app/billing/types/subscription-pricing-tier";
@Injectable({ providedIn: BillingServicesModule })
export class SubscriptionPricingService {
/**
* Fallback premium pricing used when the feature flag is disabled.
* These values represent the legacy pricing model and will not reflect
* server-side price changes. They are retained for backward compatibility
* during the feature flag rollout period.
*/
private static readonly FALLBACK_PREMIUM_SEAT_PRICE = 10;
private static readonly FALLBACK_PREMIUM_STORAGE_PRICE = 4;
constructor(
private billingApiService: BillingApiServiceAbstraction,
private configService: ConfigService,
private i18nService: I18nService,
private logService: LogService,
private toastService: ToastService,
) {}
getPersonalSubscriptionPricingTiers$ = (): Observable<PersonalSubscriptionPricingTier[]> =>
combineLatest([this.premium$, this.families$]).pipe(
catchError((error: unknown) => {
this.logService.error(error);
this.showUnexpectedErrorToast();
return of([]);
}),
);
getBusinessSubscriptionPricingTiers$ = (): Observable<BusinessSubscriptionPricingTier[]> =>
combineLatest([this.teams$, this.enterprise$, this.custom$]).pipe(
catchError((error: unknown) => {
this.logService.error(error);
this.showUnexpectedErrorToast();
return of([]);
}),
);
getDeveloperSubscriptionPricingTiers$ = (): Observable<BusinessSubscriptionPricingTier[]> =>
combineLatest([this.free$, this.teams$, this.enterprise$]).pipe(
catchError((error: unknown) => {
this.logService.error(error);
this.showUnexpectedErrorToast();
return of([]);
}),
);
private plansResponse$: Observable<ListResponse<PlanResponse>> = from(
this.billingApiService.getPlans(),
).pipe(shareReplay({ bufferSize: 1, refCount: false }));
private premiumPlanResponse$: Observable<PremiumPlanResponse> = from(
this.billingApiService.getPremiumPlan(),
).pipe(
catchError((error: unknown) => {
this.logService.error("Failed to fetch premium plan from API", error);
throw error; // Re-throw to propagate to higher-level error handler
}),
shareReplay({ bufferSize: 1, refCount: false }),
);
private premium$: Observable<PersonalSubscriptionPricingTier> = this.configService
.getFeatureFlag$(FeatureFlag.PM26793_FetchPremiumPriceFromPricingService)
.pipe(
take(1), // Lock behavior at first subscription to prevent switching data sources mid-stream
switchMap((fetchPremiumFromPricingService) =>
fetchPremiumFromPricingService
? this.premiumPlanResponse$.pipe(
map((premiumPlan) => ({
seat: premiumPlan.seat.price,
storage: premiumPlan.storage.price,
})),
)
: of({
seat: SubscriptionPricingService.FALLBACK_PREMIUM_SEAT_PRICE,
storage: SubscriptionPricingService.FALLBACK_PREMIUM_STORAGE_PRICE,
}),
),
map((premiumPrices) => ({
id: PersonalSubscriptionPricingTierIds.Premium,
name: this.i18nService.t("premium"),
description: this.i18nService.t("planDescPremium"),
availableCadences: [SubscriptionCadenceIds.Annually],
passwordManager: {
type: "standalone",
annualPrice: premiumPrices.seat,
annualPricePerAdditionalStorageGB: premiumPrices.storage,
features: [
this.featureTranslations.builtInAuthenticator(),
this.featureTranslations.secureFileStorage(),
this.featureTranslations.emergencyAccess(),
this.featureTranslations.breachMonitoring(),
this.featureTranslations.andMoreFeatures(),
],
},
})),
);
private families$: Observable<PersonalSubscriptionPricingTier> = this.plansResponse$.pipe(
map((plans) => {
const familiesPlan = plans.data.find((plan) => plan.type === PlanType.FamiliesAnnually)!;
return {
id: PersonalSubscriptionPricingTierIds.Families,
name: this.i18nService.t("planNameFamilies"),
description: this.i18nService.t("planDescFamiliesV2"),
availableCadences: [SubscriptionCadenceIds.Annually],
passwordManager: {
type: "packaged",
users: familiesPlan.PasswordManager.baseSeats,
annualPrice: familiesPlan.PasswordManager.basePrice,
annualPricePerAdditionalStorageGB:
familiesPlan.PasswordManager.additionalStoragePricePerGb,
features: [
this.featureTranslations.premiumAccounts(),
this.featureTranslations.familiesUnlimitedSharing(),
this.featureTranslations.familiesUnlimitedCollections(),
this.featureTranslations.familiesSharedStorage(),
],
},
};
}),
);
private free$: Observable<BusinessSubscriptionPricingTier> = this.plansResponse$.pipe(
map((plans): BusinessSubscriptionPricingTier => {
const freePlan = plans.data.find((plan) => plan.type === PlanType.Free)!;
return {
id: BusinessSubscriptionPricingTierIds.Free,
name: this.i18nService.t("planNameFree"),
description: this.i18nService.t("planDescFreeV2", "1"),
availableCadences: [],
passwordManager: {
type: "free",
features: [
this.featureTranslations.limitedUsersV2(freePlan.PasswordManager.maxSeats),
this.featureTranslations.limitedCollectionsV2(freePlan.PasswordManager.maxCollections),
this.featureTranslations.alwaysFree(),
],
},
secretsManager: {
type: "free",
features: [
this.featureTranslations.twoSecretsIncluded(),
this.featureTranslations.projectsIncludedV2(freePlan.SecretsManager.maxProjects),
],
},
};
}),
);
private teams$: Observable<BusinessSubscriptionPricingTier> = this.plansResponse$.pipe(
map((plans) => {
const annualTeamsPlan = plans.data.find((plan) => plan.type === PlanType.TeamsAnnually)!;
return {
id: BusinessSubscriptionPricingTierIds.Teams,
name: this.i18nService.t("planNameTeams"),
description: this.i18nService.t("teamsPlanUpgradeMessage"),
availableCadences: [SubscriptionCadenceIds.Annually, SubscriptionCadenceIds.Monthly],
passwordManager: {
type: "scalable",
annualPricePerUser: annualTeamsPlan.PasswordManager.seatPrice,
annualPricePerAdditionalStorageGB:
annualTeamsPlan.PasswordManager.additionalStoragePricePerGb,
features: [
this.featureTranslations.secureItemSharing(),
this.featureTranslations.eventLogMonitoring(),
this.featureTranslations.directoryIntegration(),
this.featureTranslations.scimSupport(),
],
},
secretsManager: {
type: "scalable",
annualPricePerUser: annualTeamsPlan.SecretsManager.seatPrice,
annualPricePerAdditionalServiceAccount:
annualTeamsPlan.SecretsManager.additionalPricePerServiceAccount,
features: [
this.featureTranslations.unlimitedSecretsAndProjects(),
this.featureTranslations.includedMachineAccountsV2(
annualTeamsPlan.SecretsManager.baseServiceAccount,
),
],
},
};
}),
);
private enterprise$: Observable<BusinessSubscriptionPricingTier> = this.plansResponse$.pipe(
map((plans) => {
const annualEnterprisePlan = plans.data.find(
(plan) => plan.type === PlanType.EnterpriseAnnually,
)!;
return {
id: BusinessSubscriptionPricingTierIds.Enterprise,
name: this.i18nService.t("planNameEnterprise"),
description: this.i18nService.t("planDescEnterpriseV2"),
availableCadences: [SubscriptionCadenceIds.Annually, SubscriptionCadenceIds.Monthly],
passwordManager: {
type: "scalable",
annualPricePerUser: annualEnterprisePlan.PasswordManager.seatPrice,
annualPricePerAdditionalStorageGB:
annualEnterprisePlan.PasswordManager.additionalStoragePricePerGb,
features: [
this.featureTranslations.enterpriseSecurityPolicies(),
this.featureTranslations.passwordLessSso(),
this.featureTranslations.accountRecovery(),
this.featureTranslations.selfHostOption(),
this.featureTranslations.complimentaryFamiliesPlan(),
],
},
secretsManager: {
type: "scalable",
annualPricePerUser: annualEnterprisePlan.SecretsManager.seatPrice,
annualPricePerAdditionalServiceAccount:
annualEnterprisePlan.SecretsManager.additionalPricePerServiceAccount,
features: [
this.featureTranslations.unlimitedUsers(),
this.featureTranslations.includedMachineAccountsV2(
annualEnterprisePlan.SecretsManager.baseServiceAccount,
),
],
},
};
}),
);
private custom$: Observable<BusinessSubscriptionPricingTier> = this.plansResponse$.pipe(
map(
(): BusinessSubscriptionPricingTier => ({
id: BusinessSubscriptionPricingTierIds.Custom,
name: this.i18nService.t("planNameCustom"),
description: this.i18nService.t("planDescCustom"),
availableCadences: [],
passwordManager: {
type: "custom",
features: [
this.featureTranslations.strengthenCybersecurity(),
this.featureTranslations.boostProductivity(),
this.featureTranslations.seamlessIntegration(),
],
},
}),
),
);
private showUnexpectedErrorToast() {
this.toastService.showToast({
variant: "error",
title: "",
message: this.i18nService.t("unexpectedError"),
});
}
private featureTranslations = {
builtInAuthenticator: () => ({
key: "builtInAuthenticator",
value: this.i18nService.t("builtInAuthenticator"),
}),
emergencyAccess: () => ({
key: "emergencyAccess",
value: this.i18nService.t("emergencyAccess"),
}),
breachMonitoring: () => ({
key: "breachMonitoring",
value: this.i18nService.t("breachMonitoring"),
}),
andMoreFeatures: () => ({
key: "andMoreFeatures",
value: this.i18nService.t("andMoreFeatures"),
}),
premiumAccounts: () => ({
key: "premiumAccounts",
value: this.i18nService.t("premiumAccounts"),
}),
secureFileStorage: () => ({
key: "secureFileStorage",
value: this.i18nService.t("secureFileStorage"),
}),
familiesUnlimitedSharing: () => ({
key: "familiesUnlimitedSharing",
value: this.i18nService.t("familiesUnlimitedSharing"),
}),
familiesUnlimitedCollections: () => ({
key: "familiesUnlimitedCollections",
value: this.i18nService.t("familiesUnlimitedCollections"),
}),
familiesSharedStorage: () => ({
key: "familiesSharedStorage",
value: this.i18nService.t("familiesSharedStorage"),
}),
limitedUsersV2: (users: number) => ({
key: "limitedUsersV2",
value: this.i18nService.t("limitedUsersV2", users),
}),
limitedCollectionsV2: (collections: number) => ({
key: "limitedCollectionsV2",
value: this.i18nService.t("limitedCollectionsV2", collections),
}),
alwaysFree: () => ({
key: "alwaysFree",
value: this.i18nService.t("alwaysFree"),
}),
twoSecretsIncluded: () => ({
key: "twoSecretsIncluded",
value: this.i18nService.t("twoSecretsIncluded"),
}),
projectsIncludedV2: (projects: number) => ({
key: "projectsIncludedV2",
value: this.i18nService.t("projectsIncludedV2", projects),
}),
secureItemSharing: () => ({
key: "secureItemSharing",
value: this.i18nService.t("secureItemSharing"),
}),
eventLogMonitoring: () => ({
key: "eventLogMonitoring",
value: this.i18nService.t("eventLogMonitoring"),
}),
directoryIntegration: () => ({
key: "directoryIntegration",
value: this.i18nService.t("directoryIntegration"),
}),
scimSupport: () => ({
key: "scimSupport",
value: this.i18nService.t("scimSupport"),
}),
unlimitedSecretsAndProjects: () => ({
key: "unlimitedSecretsAndProjects",
value: this.i18nService.t("unlimitedSecretsAndProjects"),
}),
includedMachineAccountsV2: (included: number) => ({
key: "includedMachineAccountsV2",
value: this.i18nService.t("includedMachineAccountsV2", included),
}),
enterpriseSecurityPolicies: () => ({
key: "enterpriseSecurityPolicies",
value: this.i18nService.t("enterpriseSecurityPolicies"),
}),
passwordLessSso: () => ({
key: "passwordLessSso",
value: this.i18nService.t("passwordLessSso"),
}),
accountRecovery: () => ({
key: "accountRecovery",
value: this.i18nService.t("accountRecovery"),
}),
selfHostOption: () => ({
key: "selfHostOption",
value: this.i18nService.t("selfHostOption"),
}),
complimentaryFamiliesPlan: () => ({
key: "complimentaryFamiliesPlan",
value: this.i18nService.t("complimentaryFamiliesPlan"),
}),
unlimitedUsers: () => ({
key: "unlimitedUsers",
value: this.i18nService.t("unlimitedUsers"),
}),
strengthenCybersecurity: () => ({
key: "strengthenCybersecurity",
value: this.i18nService.t("strengthenCybersecurity"),
}),
boostProductivity: () => ({
key: "boostProductivity",
value: this.i18nService.t("boostProductivity"),
}),
seamlessIntegration: () => ({
key: "seamlessIntegration",
value: this.i18nService.t("seamlessIntegration"),
}),
};
}

View File

@@ -1,85 +0,0 @@
export const PersonalSubscriptionPricingTierIds = {
Premium: "premium",
Families: "families",
} as const;
export const BusinessSubscriptionPricingTierIds = {
Free: "free",
Teams: "teams",
Enterprise: "enterprise",
Custom: "custom",
} as const;
export const SubscriptionCadenceIds = {
Annually: "annually",
Monthly: "monthly",
} as const;
export type PersonalSubscriptionPricingTierId =
(typeof PersonalSubscriptionPricingTierIds)[keyof typeof PersonalSubscriptionPricingTierIds];
export type BusinessSubscriptionPricingTierId =
(typeof BusinessSubscriptionPricingTierIds)[keyof typeof BusinessSubscriptionPricingTierIds];
export type SubscriptionCadence =
(typeof SubscriptionCadenceIds)[keyof typeof SubscriptionCadenceIds];
type HasFeatures = {
features: { key: string; value: string }[];
};
type HasAdditionalStorage = {
annualPricePerAdditionalStorageGB: number;
};
type StandalonePasswordManager = HasFeatures &
HasAdditionalStorage & {
type: "standalone";
annualPrice: number;
};
type PackagedPasswordManager = HasFeatures &
HasAdditionalStorage & {
type: "packaged";
users: number;
annualPrice: number;
};
type FreePasswordManager = HasFeatures & {
type: "free";
};
type CustomPasswordManager = HasFeatures & {
type: "custom";
};
type ScalablePasswordManager = HasFeatures &
HasAdditionalStorage & {
type: "scalable";
annualPricePerUser: number;
};
type FreeSecretsManager = HasFeatures & {
type: "free";
};
type ScalableSecretsManager = HasFeatures & {
type: "scalable";
annualPricePerUser: number;
annualPricePerAdditionalServiceAccount: number;
};
export type PersonalSubscriptionPricingTier = {
id: PersonalSubscriptionPricingTierId;
name: string;
description: string;
availableCadences: Omit<SubscriptionCadence, "monthly">[]; // personal plans are only ever annual
passwordManager: StandalonePasswordManager | PackagedPasswordManager;
};
export type BusinessSubscriptionPricingTier = {
id: BusinessSubscriptionPricingTierId;
name: string;
description: string;
availableCadences: SubscriptionCadence[];
passwordManager: FreePasswordManager | ScalablePasswordManager | CustomPasswordManager;
secretsManager?: FreeSecretsManager | ScalableSecretsManager;
};

View File

@@ -55,6 +55,7 @@ import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service";
import { MasterPasswordApiService } from "@bitwarden/common/auth/abstractions/master-password-api.service.abstraction";
import { SsoLoginServiceAbstraction } from "@bitwarden/common/auth/abstractions/sso-login.service.abstraction";
import { OrganizationInviteService } from "@bitwarden/common/auth/services/organization-invite/organization-invite.service";
import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service";
import { ClientType } from "@bitwarden/common/enums";
import { ProcessReloadServiceAbstraction } from "@bitwarden/common/key-management/abstractions/process-reload.service";
import { CryptoFunctionService } from "@bitwarden/common/key-management/crypto/abstractions/crypto-function.service";
@@ -94,6 +95,7 @@ import { NoopSdkLoadService } from "@bitwarden/common/platform/services/sdk/noop
import { StorageServiceProvider } from "@bitwarden/common/platform/services/storage-service.provider";
import { GlobalStateProvider, StateProvider } from "@bitwarden/common/platform/state";
import { WindowStorageService } from "@bitwarden/common/platform/storage/window-storage.service";
import { SyncService } from "@bitwarden/common/platform/sync/sync.service";
import {
DefaultThemeStateService,
ThemeStateService,
@@ -408,7 +410,16 @@ const safeProviders: SafeProvider[] = [
safeProvider({
provide: PremiumUpgradePromptService,
useClass: WebVaultPremiumUpgradePromptService,
deps: [DialogService, Router],
deps: [
DialogService,
ConfigService,
AccountService,
ApiService,
SyncService,
BillingAccountProfileStateService,
PlatformUtilsService,
Router,
],
}),
];

View File

@@ -15,14 +15,12 @@
<h3 class="tw-mb-4 tw-text-xl tw-font-bold">{{ title }}</h3>
<p class="tw-mb-0">{{ description }}</p>
</bit-card-content>
<span
bitBadge
[variant]="requiresPremium ? 'success' : 'primary'"
class="tw-absolute tw-left-2 tw-top-2 tw-leading-none"
*ngIf="disabled"
>
<ng-container *ngIf="requiresPremium">{{ "premium" | i18n }}</ng-container>
<ng-container *ngIf="!requiresPremium">{{ "upgrade" | i18n }}</ng-container>
</span>
@if (requiresPremium) {
<app-premium-badge class="tw-absolute tw-left-2 tw-top-2"></app-premium-badge>
} @else if (requiresUpgrade) {
<span bitBadge variant="primary" class="tw-absolute tw-left-2 tw-top-2">
{{ "upgrade" | i18n }}
</span>
}
</bit-base-card>
</a>

View File

@@ -37,4 +37,8 @@ export class ReportCardComponent {
protected get requiresPremium() {
return this.variant == ReportVariant.RequiresPremium;
}
protected get requiresUpgrade() {
return this.variant == ReportVariant.RequiresUpgrade;
}
}

View File

@@ -1,14 +1,20 @@
import { importProvidersFrom } from "@angular/core";
import { RouterTestingModule } from "@angular/router/testing";
import { Meta, StoryObj, applicationConfig, moduleMetadata } from "@storybook/angular";
import { applicationConfig, Meta, moduleMetadata, StoryObj } from "@storybook/angular";
import { of } from "rxjs";
import { PremiumBadgeComponent } from "@bitwarden/angular/billing/components/premium-badge";
import { JslibModule } from "@bitwarden/angular/jslib.module";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { PremiumUpgradePromptService } from "@bitwarden/common/vault/abstractions/premium-upgrade-prompt.service";
import {
BadgeModule,
BaseCardComponent,
IconModule,
CardContentComponent,
I18nMockService,
IconModule,
} from "@bitwarden/components";
import { PreloadedEnglishI18nModule } from "../../../../core/tests";
@@ -30,6 +36,37 @@ export default {
PremiumBadgeComponent,
BaseCardComponent,
],
providers: [
{
provide: AccountService,
useValue: {
activeAccount$: of({
id: "123",
}),
},
},
{
provide: I18nService,
useFactory: () => {
return new I18nMockService({
premium: "Premium",
upgrade: "Upgrade",
});
},
},
{
provide: BillingAccountProfileStateService,
useValue: {
hasPremiumFromAnySource$: () => of(false),
},
},
{
provide: PremiumUpgradePromptService,
useValue: {
promptForPremium: (orgId?: string) => {},
},
},
],
}),
applicationConfig({
providers: [importProvidersFrom(PreloadedEnglishI18nModule)],

View File

@@ -1,9 +1,13 @@
import { importProvidersFrom } from "@angular/core";
import { RouterTestingModule } from "@angular/router/testing";
import { Meta, StoryObj, applicationConfig, moduleMetadata } from "@storybook/angular";
import { applicationConfig, Meta, moduleMetadata, StoryObj } from "@storybook/angular";
import { of } from "rxjs";
import { PremiumBadgeComponent } from "@bitwarden/angular/billing/components/premium-badge";
import { JslibModule } from "@bitwarden/angular/jslib.module";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions";
import { PremiumUpgradePromptService } from "@bitwarden/common/vault/abstractions/premium-upgrade-prompt.service";
import {
BadgeModule,
BaseCardComponent,
@@ -33,6 +37,28 @@ export default {
BaseCardComponent,
],
declarations: [ReportCardComponent],
providers: [
{
provide: AccountService,
useValue: {
activeAccount$: of({
id: "123",
}),
},
},
{
provide: BillingAccountProfileStateService,
useValue: {
hasPremiumFromAnySource$: () => of(false),
},
},
{
provide: PremiumUpgradePromptService,
useValue: {
promptForPremium: (orgId?: string) => {},
},
},
],
}),
applicationConfig({
providers: [importProvidersFrom(PreloadedEnglishI18nModule)],

View File

@@ -1,6 +1,7 @@
import { CommonModule } from "@angular/common";
import { NgModule } from "@angular/core";
import { PremiumBadgeComponent } from "@bitwarden/angular/billing/components/premium-badge";
import { BaseCardComponent, CardContentComponent } from "@bitwarden/components";
import { SharedModule } from "../../../shared/shared.module";
@@ -9,7 +10,13 @@ import { ReportCardComponent } from "./report-card/report-card.component";
import { ReportListComponent } from "./report-list/report-list.component";
@NgModule({
imports: [CommonModule, SharedModule, BaseCardComponent, CardContentComponent],
imports: [
CommonModule,
SharedModule,
BaseCardComponent,
CardContentComponent,
PremiumBadgeComponent,
],
declarations: [ReportCardComponent, ReportListComponent],
exports: [ReportCardComponent, ReportListComponent],
})

View File

@@ -6,11 +6,14 @@ import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { EventCollectionService } from "@bitwarden/common/abstractions/event/event-collection.service";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
import { PremiumUpgradePromptService } from "@bitwarden/common/vault/abstractions/premium-upgrade-prompt.service";
import { SyncService } from "@bitwarden/common/vault/abstractions/sync/sync.service.abstraction";
import { CipherType } from "@bitwarden/common/vault/enums";
import { CipherAuthorizationService } from "@bitwarden/common/vault/services/cipher-authorization.service";
import { DialogRef, DIALOG_DATA, DialogService, ToastService } from "@bitwarden/components";
@@ -73,6 +76,7 @@ describe("VaultItemDialogComponent", () => {
{ provide: LogService, useValue: {} },
{ provide: CipherService, useValue: {} },
{ provide: AccountService, useValue: { activeAccount$: { pipe: () => ({}) } } },
{ provide: ConfigService, useValue: { getFeatureFlag: () => Promise.resolve(false) } },
{ provide: Router, useValue: {} },
{ provide: ActivatedRoute, useValue: {} },
{
@@ -84,6 +88,8 @@ describe("VaultItemDialogComponent", () => {
{ provide: ApiService, useValue: {} },
{ provide: EventCollectionService, useValue: {} },
{ provide: RoutedVaultFilterService, useValue: {} },
{ provide: SyncService, useValue: {} },
{ provide: PlatformUtilsService, useValue: {} },
],
}).compileComponents();

View File

@@ -65,6 +65,7 @@ import { SyncService } from "@bitwarden/common/platform/sync";
import { CipherId, CollectionId, OrganizationId, UserId } from "@bitwarden/common/types/guid";
import { CipherArchiveService } from "@bitwarden/common/vault/abstractions/cipher-archive.service";
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
import { PremiumUpgradePromptService } from "@bitwarden/common/vault/abstractions/premium-upgrade-prompt.service";
import { SearchService } from "@bitwarden/common/vault/abstractions/search.service";
import { TotpService } from "@bitwarden/common/vault/abstractions/totp.service";
import { CipherType } from "@bitwarden/common/vault/enums";
@@ -326,6 +327,7 @@ export class VaultComponent<C extends CipherViewLike> implements OnInit, OnDestr
private organizationWarningsService: OrganizationWarningsService,
private policyService: PolicyService,
private unifiedUpgradePromptService: UnifiedUpgradePromptService,
private premiumUpgradePromptService: PremiumUpgradePromptService,
) {}
async ngOnInit() {
@@ -867,7 +869,7 @@ export class VaultComponent<C extends CipherViewLike> implements OnInit, OnDestr
}
if (cipher.organizationId == null && !this.canAccessPremium) {
this.messagingService.send("premiumRequired");
await this.premiumUpgradePromptService.promptForPremium();
return;
} else if (cipher.organizationId != null) {
const org = await firstValueFrom(

View File

@@ -2,8 +2,19 @@ import { TestBed } from "@angular/core/testing";
import { Router } from "@angular/router";
import { lastValueFrom, of } from "rxjs";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions";
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 { SyncService } from "@bitwarden/common/platform/sync";
import { OrganizationId } from "@bitwarden/common/types/guid";
import { DialogRef, DialogService } from "@bitwarden/components";
import {
UnifiedUpgradeDialogComponent,
UnifiedUpgradeDialogStatus,
} from "@bitwarden/web-vault/app/billing/individual/upgrade/unified-upgrade-dialog/unified-upgrade-dialog.component";
import { VaultItemDialogResult } from "../components/vault-item-dialog/vault-item-dialog.component";
@@ -13,13 +24,27 @@ describe("WebVaultPremiumUpgradePromptService", () => {
let service: WebVaultPremiumUpgradePromptService;
let dialogServiceMock: jest.Mocked<DialogService>;
let routerMock: jest.Mocked<Router>;
let dialogRefMock: jest.Mocked<DialogRef<VaultItemDialogResult>>;
let dialogRefMock: jest.Mocked<DialogRef>;
let configServiceMock: jest.Mocked<ConfigService>;
let accountServiceMock: jest.Mocked<AccountService>;
let apiServiceMock: jest.Mocked<ApiService>;
let syncServiceMock: jest.Mocked<SyncService>;
let billingAccountProfileServiceMock: jest.Mocked<BillingAccountProfileStateService>;
let platformUtilsServiceMock: jest.Mocked<PlatformUtilsService>;
beforeEach(() => {
dialogServiceMock = {
openSimpleDialog: jest.fn(),
} as unknown as jest.Mocked<DialogService>;
configServiceMock = {
getFeatureFlag: jest.fn().mockReturnValue(false),
} as unknown as jest.Mocked<ConfigService>;
accountServiceMock = {
activeAccount$: of({ id: "user-123" }),
} as unknown as jest.Mocked<AccountService>;
routerMock = {
navigate: jest.fn(),
} as unknown as jest.Mocked<Router>;
@@ -28,12 +53,34 @@ describe("WebVaultPremiumUpgradePromptService", () => {
close: jest.fn(),
} as unknown as jest.Mocked<DialogRef<VaultItemDialogResult>>;
apiServiceMock = {
refreshIdentityToken: jest.fn().mockReturnValue({}),
} as unknown as jest.Mocked<ApiService>;
syncServiceMock = {
fullSync: jest.fn(),
} as unknown as jest.Mocked<SyncService>;
billingAccountProfileServiceMock = {
hasPremiumFromAnySource$: jest.fn().mockReturnValue(of(false)),
} as unknown as jest.Mocked<BillingAccountProfileStateService>;
platformUtilsServiceMock = {
isSelfHost: jest.fn().mockReturnValue(false),
} as unknown as jest.Mocked<PlatformUtilsService>;
TestBed.configureTestingModule({
providers: [
WebVaultPremiumUpgradePromptService,
{ provide: DialogService, useValue: dialogServiceMock },
{ provide: Router, useValue: routerMock },
{ provide: DialogRef, useValue: dialogRefMock },
{ provide: ConfigService, useValue: configServiceMock },
{ provide: AccountService, useValue: accountServiceMock },
{ provide: ApiService, useValue: apiServiceMock },
{ provide: SyncService, useValue: syncServiceMock },
{ provide: BillingAccountProfileStateService, useValue: billingAccountProfileServiceMock },
{ provide: PlatformUtilsService, useValue: platformUtilsServiceMock },
],
});
@@ -84,4 +131,144 @@ describe("WebVaultPremiumUpgradePromptService", () => {
expect(routerMock.navigate).not.toHaveBeenCalled();
expect(dialogRefMock.close).not.toHaveBeenCalled();
});
describe("premium status check", () => {
it("should not prompt if user already has premium (feature flag off)", async () => {
configServiceMock.getFeatureFlag.mockReturnValue(Promise.resolve(false));
billingAccountProfileServiceMock.hasPremiumFromAnySource$.mockReturnValue(of(true));
await service.promptForPremium();
expect(dialogServiceMock.openSimpleDialog).not.toHaveBeenCalled();
expect(routerMock.navigate).not.toHaveBeenCalled();
});
it("should not prompt if user already has premium (feature flag on)", async () => {
configServiceMock.getFeatureFlag.mockImplementation((flag: FeatureFlag) => {
if (flag === FeatureFlag.PM23713_PremiumBadgeOpensNewPremiumUpgradeDialog) {
return Promise.resolve(true);
}
return Promise.resolve(false);
});
billingAccountProfileServiceMock.hasPremiumFromAnySource$.mockReturnValue(of(true));
const unifiedDialogRefMock = {
closed: of({ status: UnifiedUpgradeDialogStatus.Closed }),
close: jest.fn(),
} as any;
jest.spyOn(UnifiedUpgradeDialogComponent, "open").mockReturnValue(unifiedDialogRefMock);
await service.promptForPremium();
expect(UnifiedUpgradeDialogComponent.open).not.toHaveBeenCalled();
expect(dialogServiceMock.openSimpleDialog).not.toHaveBeenCalled();
expect(routerMock.navigate).not.toHaveBeenCalled();
});
});
describe("new premium upgrade dialog with post-upgrade actions", () => {
beforeEach(() => {
configServiceMock.getFeatureFlag.mockImplementation((flag: FeatureFlag) => {
if (flag === FeatureFlag.PM23713_PremiumBadgeOpensNewPremiumUpgradeDialog) {
return Promise.resolve(true);
}
return Promise.resolve(false);
});
});
describe("when self-hosted", () => {
beforeEach(() => {
platformUtilsServiceMock.isSelfHost.mockReturnValue(true);
});
it("should navigate to subscription page instead of opening dialog", async () => {
await service.promptForPremium();
expect(routerMock.navigate).toHaveBeenCalledWith(["settings/subscription/premium"]);
expect(dialogServiceMock.openSimpleDialog).not.toHaveBeenCalled();
});
});
describe("when not self-hosted", () => {
beforeEach(() => {
platformUtilsServiceMock.isSelfHost.mockReturnValue(false);
});
it("should full sync when user upgrades to premium", async () => {
const unifiedDialogRefMock = {
closed: of({ status: UnifiedUpgradeDialogStatus.UpgradedToPremium }),
close: jest.fn(),
} as any;
jest.spyOn(UnifiedUpgradeDialogComponent, "open").mockReturnValue(unifiedDialogRefMock);
await service.promptForPremium();
expect(UnifiedUpgradeDialogComponent.open).toHaveBeenCalledWith(dialogServiceMock, {
data: {
account: { id: "user-123" },
planSelectionStepTitleOverride: "upgradeYourPlan",
hideContinueWithoutUpgradingButton: true,
},
});
expect(syncServiceMock.fullSync).toHaveBeenCalledWith(true);
});
it("should full sync when user upgrades to families", async () => {
const unifiedDialogRefMock = {
closed: of({ status: UnifiedUpgradeDialogStatus.UpgradedToFamilies }),
close: jest.fn(),
} as any;
jest.spyOn(UnifiedUpgradeDialogComponent, "open").mockReturnValue(unifiedDialogRefMock);
await service.promptForPremium();
expect(UnifiedUpgradeDialogComponent.open).toHaveBeenCalledWith(dialogServiceMock, {
data: {
account: { id: "user-123" },
planSelectionStepTitleOverride: "upgradeYourPlan",
hideContinueWithoutUpgradingButton: true,
},
});
expect(syncServiceMock.fullSync).toHaveBeenCalledWith(true);
});
it("should not refresh or sync when user closes dialog without upgrading", async () => {
const unifiedDialogRefMock = {
closed: of({ status: UnifiedUpgradeDialogStatus.Closed }),
close: jest.fn(),
} as any;
jest.spyOn(UnifiedUpgradeDialogComponent, "open").mockReturnValue(unifiedDialogRefMock);
await service.promptForPremium();
expect(UnifiedUpgradeDialogComponent.open).toHaveBeenCalledWith(dialogServiceMock, {
data: {
account: { id: "user-123" },
planSelectionStepTitleOverride: "upgradeYourPlan",
hideContinueWithoutUpgradingButton: true,
},
});
expect(apiServiceMock.refreshIdentityToken).not.toHaveBeenCalled();
expect(syncServiceMock.fullSync).not.toHaveBeenCalled();
});
it("should not open new dialog if organizationId is provided", async () => {
const organizationId = "test-org-id" as OrganizationId;
dialogServiceMock.openSimpleDialog.mockReturnValue(lastValueFrom(of(true)));
const openSpy = jest.spyOn(UnifiedUpgradeDialogComponent, "open");
openSpy.mockClear();
await service.promptForPremium(organizationId);
expect(openSpy).not.toHaveBeenCalled();
expect(dialogServiceMock.openSimpleDialog).toHaveBeenCalledWith({
title: { key: "upgradeOrganization" },
content: { key: "upgradeOrganizationDesc" },
acceptButtonText: { key: "upgradeOrganization" },
type: "info",
});
});
});
});
});

View File

@@ -1,10 +1,21 @@
import { Injectable, Optional } from "@angular/core";
import { Router } from "@angular/router";
import { Subject } from "rxjs";
import { firstValueFrom, lastValueFrom, Subject } from "rxjs";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { Account, AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions";
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 { SyncService } from "@bitwarden/common/platform/sync";
import { OrganizationId } from "@bitwarden/common/types/guid";
import { PremiumUpgradePromptService } from "@bitwarden/common/vault/abstractions/premium-upgrade-prompt.service";
import { DialogRef, DialogService } from "@bitwarden/components";
import {
UnifiedUpgradeDialogComponent,
UnifiedUpgradeDialogStatus,
} from "@bitwarden/web-vault/app/billing/individual/upgrade/unified-upgrade-dialog/unified-upgrade-dialog.component";
import { VaultItemDialogResult } from "../components/vault-item-dialog/vault-item-dialog.component";
@@ -15,14 +26,44 @@ export class WebVaultPremiumUpgradePromptService implements PremiumUpgradePrompt
constructor(
private dialogService: DialogService,
private configService: ConfigService,
private accountService: AccountService,
private apiService: ApiService,
private syncService: SyncService,
private billingAccountProfileStateService: BillingAccountProfileStateService,
private platformUtilsService: PlatformUtilsService,
private router: Router,
@Optional() private dialog?: DialogRef<VaultItemDialogResult>,
) {}
private readonly subscriptionPageRoute = "settings/subscription/premium";
/**
* Prompts the user for a premium upgrade.
*/
async promptForPremium(organizationId?: OrganizationId) {
const account = await firstValueFrom(this.accountService.activeAccount$);
if (!account) {
return;
}
const hasPremium = await firstValueFrom(
this.billingAccountProfileStateService.hasPremiumFromAnySource$(account.id),
);
if (hasPremium) {
// Already has premium, don't prompt
return;
}
const showNewDialog = await this.configService.getFeatureFlag(
FeatureFlag.PM23713_PremiumBadgeOpensNewPremiumUpgradeDialog,
);
// Per conversation in PM-23713, retain the existing upgrade org flow for now, will be addressed
// as a part of https://bitwarden.atlassian.net/browse/PM-25507
if (showNewDialog && !organizationId) {
await this.promptForPremiumVNext(account);
return;
}
let confirmed = false;
let route: string[] | null = null;
@@ -44,7 +85,7 @@ export class WebVaultPremiumUpgradePromptService implements PremiumUpgradePrompt
type: "success",
});
if (confirmed) {
route = ["settings/subscription/premium"];
route = [this.subscriptionPageRoute];
}
}
@@ -57,4 +98,31 @@ export class WebVaultPremiumUpgradePromptService implements PremiumUpgradePrompt
this.dialog.close(VaultItemDialogResult.PremiumUpgrade);
}
}
private async promptForPremiumVNext(account: Account) {
await (this.platformUtilsService.isSelfHost()
? this.redirectToSubscriptionPage()
: this.openUpgradeDialog(account));
}
private async redirectToSubscriptionPage() {
await this.router.navigate([this.subscriptionPageRoute]);
}
private async openUpgradeDialog(account: Account) {
const dialogRef = UnifiedUpgradeDialogComponent.open(this.dialogService, {
data: {
account,
planSelectionStepTitleOverride: "upgradeYourPlan",
hideContinueWithoutUpgradingButton: true,
},
});
const result = await lastValueFrom(dialogRef.closed);
if (
result?.status === UnifiedUpgradeDialogStatus.UpgradedToPremium ||
result?.status === UnifiedUpgradeDialogStatus.UpgradedToFamilies
) {
await this.syncService.fullSync(true);
}
}
}