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:
@@ -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" },
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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,
|
||||
) {
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
@@ -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`;
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -675,7 +675,6 @@ export class OrganizationPlansComponent implements OnInit, OnDestroy {
|
||||
});
|
||||
}
|
||||
|
||||
await this.apiService.refreshIdentityToken();
|
||||
await this.syncService.fullSync(true);
|
||||
|
||||
if (!this.acceptingSponsorship && !this.isInTrialFlow) {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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"),
|
||||
}),
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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,
|
||||
],
|
||||
}),
|
||||
];
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -37,4 +37,8 @@ export class ReportCardComponent {
|
||||
protected get requiresPremium() {
|
||||
return this.variant == ReportVariant.RequiresPremium;
|
||||
}
|
||||
|
||||
protected get requiresUpgrade() {
|
||||
return this.variant == ReportVariant.RequiresUpgrade;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)],
|
||||
|
||||
@@ -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)],
|
||||
|
||||
@@ -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],
|
||||
})
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user