1
0
mirror of https://github.com/bitwarden/browser synced 2025-12-06 00:13:28 +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

@@ -1523,12 +1523,6 @@
"enableAutoBiometricsPrompt": {
"message": "Ask for biometrics on launch"
},
"premiumRequired": {
"message": "Premium required"
},
"premiumRequiredDesc": {
"message": "A Premium membership is required to use this feature."
},
"authenticationTimeout": {
"message": "Authentication timeout"
},
@@ -5772,6 +5766,30 @@
"atRiskLoginsSecured": {
"message": "Great job securing your at-risk logins!"
},
"upgradeNow": {
"message": "Upgrade now"
},
"builtInAuthenticator": {
"message": "Built-in authenticator"
},
"secureFileStorage": {
"message": "Secure file storage"
},
"emergencyAccess": {
"message": "Emergency access"
},
"breachMonitoring": {
"message": "Breach monitoring"
},
"andMoreFeatures": {
"message": "And more!"
},
"planDescPremium": {
"message": "Complete online security"
},
"upgradeToPremium": {
"message": "Upgrade to Premium"
},
"settingDisabledByPolicy": {
"message": "This setting is disabled by your organization's policy.",
"description": "This hint text is displayed when a user setting is disabled due to an organization policy."

View File

@@ -155,11 +155,12 @@ describe("OpenAttachmentsComponent", () => {
});
it("routes the user to the premium page when they cannot access premium features", async () => {
const premiumUpgradeService = TestBed.inject(PremiumUpgradePromptService);
hasPremiumFromAnySource$.next(false);
await component.openAttachments();
expect(router.navigate).toHaveBeenCalledWith(["/premium"]);
expect(premiumUpgradeService.promptForPremium).toHaveBeenCalled();
});
it("disables attachments when the edit form is disabled", () => {

View File

@@ -19,6 +19,7 @@ import { ProductTierType } from "@bitwarden/common/billing/enums";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { CipherId } from "@bitwarden/common/types/guid";
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
import { PremiumUpgradePromptService } from "@bitwarden/common/vault/abstractions/premium-upgrade-prompt.service";
import { BadgeModule, ItemModule, ToastService, TypographyModule } from "@bitwarden/components";
import { CipherFormContainer } from "@bitwarden/vault";
@@ -67,6 +68,7 @@ export class OpenAttachmentsComponent implements OnInit {
private filePopoutUtilsService: FilePopoutUtilsService,
private accountService: AccountService,
private cipherFormContainer: CipherFormContainer,
private premiumUpgradeService: PremiumUpgradePromptService,
) {
this.accountService.activeAccount$
.pipe(
@@ -115,7 +117,7 @@ export class OpenAttachmentsComponent implements OnInit {
/** Routes the user to the attachments screen, if available */
async openAttachments() {
if (!this.canAccessAttachments) {
await this.router.navigate(["/premium"]);
await this.premiumUpgradeService.promptForPremium();
return;
}

View File

@@ -2,25 +2,69 @@ import { TestBed } from "@angular/core/testing";
import { Router } from "@angular/router";
import { mock, MockProxy } from "jest-mock-extended";
import { PremiumUpgradeDialogComponent } from "@bitwarden/angular/billing/components";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { DialogService } from "@bitwarden/components";
import { BrowserPremiumUpgradePromptService } from "./browser-premium-upgrade-prompt.service";
describe("BrowserPremiumUpgradePromptService", () => {
let service: BrowserPremiumUpgradePromptService;
let router: MockProxy<Router>;
let configService: MockProxy<ConfigService>;
let dialogService: MockProxy<DialogService>;
beforeEach(async () => {
router = mock<Router>();
configService = mock<ConfigService>();
dialogService = mock<DialogService>();
await TestBed.configureTestingModule({
providers: [BrowserPremiumUpgradePromptService, { provide: Router, useValue: router }],
providers: [
BrowserPremiumUpgradePromptService,
{ provide: Router, useValue: router },
{ provide: ConfigService, useValue: configService },
{ provide: DialogService, useValue: dialogService },
],
}).compileComponents();
service = TestBed.inject(BrowserPremiumUpgradePromptService);
});
describe("promptForPremium", () => {
it("navigates to the premium update screen", async () => {
let openSpy: jest.SpyInstance;
beforeEach(() => {
openSpy = jest.spyOn(PremiumUpgradeDialogComponent, "open").mockImplementation();
});
afterEach(() => {
openSpy.mockRestore();
});
it("opens the new premium upgrade dialog when feature flag is enabled", async () => {
configService.getFeatureFlag.mockResolvedValue(true);
await service.promptForPremium();
expect(configService.getFeatureFlag).toHaveBeenCalledWith(
FeatureFlag.PM23713_PremiumBadgeOpensNewPremiumUpgradeDialog,
);
expect(openSpy).toHaveBeenCalledWith(dialogService);
expect(router.navigate).not.toHaveBeenCalled();
});
it("navigates to the premium update screen when feature flag is disabled", async () => {
configService.getFeatureFlag.mockResolvedValue(false);
await service.promptForPremium();
expect(configService.getFeatureFlag).toHaveBeenCalledWith(
FeatureFlag.PM23713_PremiumBadgeOpensNewPremiumUpgradeDialog,
);
expect(router.navigate).toHaveBeenCalledWith(["/premium"]);
expect(openSpy).not.toHaveBeenCalled();
});
});
});

View File

@@ -1,18 +1,32 @@
import { inject } from "@angular/core";
import { Router } from "@angular/router";
import { PremiumUpgradeDialogComponent } from "@bitwarden/angular/billing/components";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { PremiumUpgradePromptService } from "@bitwarden/common/vault/abstractions/premium-upgrade-prompt.service";
import { DialogService } from "@bitwarden/components";
/**
* This class handles the premium upgrade process for the browser extension.
*/
export class BrowserPremiumUpgradePromptService implements PremiumUpgradePromptService {
private router = inject(Router);
private configService = inject(ConfigService);
private dialogService = inject(DialogService);
async promptForPremium() {
/**
* Navigate to the premium update screen.
*/
await this.router.navigate(["/premium"]);
const showNewDialog = await this.configService.getFeatureFlag(
FeatureFlag.PM23713_PremiumBadgeOpensNewPremiumUpgradeDialog,
);
if (showNewDialog) {
PremiumUpgradeDialogComponent.open(this.dialogService);
} else {
/**
* Navigate to the premium update screen.
*/
await this.router.navigate(["/premium"]);
}
}
}

View File

@@ -10,6 +10,7 @@ config.content = [
"../../libs/vault/src/**/*.{html,ts}",
"../../libs/angular/src/**/*.{html,ts}",
"../../libs/vault/src/**/*.{html,ts}",
"../../libs/pricing/src/**/*.{html,ts}",
];
module.exports = config;

View File

@@ -19,14 +19,23 @@ import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/pl
import { StateService } from "@bitwarden/common/platform/abstractions/state.service";
import { SendApiService } from "@bitwarden/common/tools/send/services/send-api.service.abstraction";
import { SendService } from "@bitwarden/common/tools/send/services/send.service.abstraction";
import { PremiumUpgradePromptService } from "@bitwarden/common/vault/abstractions/premium-upgrade-prompt.service";
import { CalloutModule, DialogService, ToastService } from "@bitwarden/components";
import { DesktopPremiumUpgradePromptService } from "../../../services/desktop-premium-upgrade-prompt.service";
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
@Component({
selector: "app-send-add-edit",
templateUrl: "add-edit.component.html",
imports: [CommonModule, JslibModule, ReactiveFormsModule, CalloutModule],
providers: [
{
provide: PremiumUpgradePromptService,
useClass: DesktopPremiumUpgradePromptService,
},
],
})
export class AddEditComponent extends BaseAddEditComponent {
constructor(
@@ -45,6 +54,7 @@ export class AddEditComponent extends BaseAddEditComponent {
billingAccountProfileStateService: BillingAccountProfileStateService,
accountService: AccountService,
toastService: ToastService,
premiumUpgradePromptService: PremiumUpgradePromptService,
) {
super(
i18nService,
@@ -62,6 +72,7 @@ export class AddEditComponent extends BaseAddEditComponent {
billingAccountProfileStateService,
accountService,
toastService,
premiumUpgradePromptService,
);
}

View File

@@ -4193,5 +4193,29 @@
},
"cardNumberLabel": {
"message": "Card number"
},
"upgradeNow": {
"message": "Upgrade now"
},
"builtInAuthenticator": {
"message": "Built-in authenticator"
},
"secureFileStorage": {
"message": "Secure file storage"
},
"emergencyAccess": {
"message": "Emergency access"
},
"breachMonitoring": {
"message": "Breach monitoring"
},
"andMoreFeatures": {
"message": "And more!"
},
"planDescPremium": {
"message": "Complete online security"
},
"upgradeToPremium": {
"message": "Upgrade to Premium"
}
}

View File

@@ -1,20 +1,31 @@
import { TestBed } from "@angular/core/testing";
import { mock, MockProxy } from "jest-mock-extended";
import { PremiumUpgradeDialogComponent } from "@bitwarden/angular/billing/components";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service";
import { DialogService } from "@bitwarden/components";
import { DesktopPremiumUpgradePromptService } from "./desktop-premium-upgrade-prompt.service";
describe("DesktopPremiumUpgradePromptService", () => {
let service: DesktopPremiumUpgradePromptService;
let messager: MockProxy<MessagingService>;
let configService: MockProxy<ConfigService>;
let dialogService: MockProxy<DialogService>;
beforeEach(async () => {
messager = mock<MessagingService>();
configService = mock<ConfigService>();
dialogService = mock<DialogService>();
await TestBed.configureTestingModule({
providers: [
DesktopPremiumUpgradePromptService,
{ provide: MessagingService, useValue: messager },
{ provide: ConfigService, useValue: configService },
{ provide: DialogService, useValue: dialogService },
],
}).compileComponents();
@@ -22,9 +33,38 @@ describe("DesktopPremiumUpgradePromptService", () => {
});
describe("promptForPremium", () => {
it("navigates to the premium update screen", async () => {
let openSpy: jest.SpyInstance;
beforeEach(() => {
openSpy = jest.spyOn(PremiumUpgradeDialogComponent, "open").mockImplementation();
});
afterEach(() => {
openSpy.mockRestore();
});
it("opens the new premium upgrade dialog when feature flag is enabled", async () => {
configService.getFeatureFlag.mockResolvedValue(true);
await service.promptForPremium();
expect(configService.getFeatureFlag).toHaveBeenCalledWith(
FeatureFlag.PM23713_PremiumBadgeOpensNewPremiumUpgradeDialog,
);
expect(openSpy).toHaveBeenCalledWith(dialogService);
expect(messager.send).not.toHaveBeenCalled();
});
it("sends openPremium message when feature flag is disabled", async () => {
configService.getFeatureFlag.mockResolvedValue(false);
await service.promptForPremium();
expect(configService.getFeatureFlag).toHaveBeenCalledWith(
FeatureFlag.PM23713_PremiumBadgeOpensNewPremiumUpgradeDialog,
);
expect(messager.send).toHaveBeenCalledWith("openPremium");
expect(openSpy).not.toHaveBeenCalled();
});
});
});

View File

@@ -1,15 +1,29 @@
import { inject } from "@angular/core";
import { PremiumUpgradeDialogComponent } from "@bitwarden/angular/billing/components";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service";
import { PremiumUpgradePromptService } from "@bitwarden/common/vault/abstractions/premium-upgrade-prompt.service";
import { DialogService } from "@bitwarden/components";
/**
* This class handles the premium upgrade process for the desktop.
*/
export class DesktopPremiumUpgradePromptService implements PremiumUpgradePromptService {
private messagingService = inject(MessagingService);
private configService = inject(ConfigService);
private dialogService = inject(DialogService);
async promptForPremium() {
this.messagingService.send("openPremium");
const showNewDialog = await this.configService.getFeatureFlag(
FeatureFlag.PM23713_PremiumBadgeOpensNewPremiumUpgradeDialog,
);
if (showNewDialog) {
PremiumUpgradeDialogComponent.open(this.dialogService);
} else {
this.messagingService.send("openPremium");
}
}
}

View File

@@ -9,6 +9,7 @@ config.content = [
"../../libs/key-management-ui/src/**/*.{html,ts}",
"../../libs/angular/src/**/*.{html,ts}",
"../../libs/vault/src/**/*.{html,ts,mdx}",
"../../libs/pricing/src/**/*.{html,ts}",
];
module.exports = config;

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

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

View File

@@ -1 +1,2 @@
export * from "./premium.component";
export * from "./premium-upgrade-dialog/premium-upgrade-dialog.component";

View File

@@ -10,7 +10,13 @@ import { BadgeModule } from "@bitwarden/components";
selector: "app-premium-badge",
standalone: true,
template: `
<button type="button" *appNotPremium bitBadge variant="success" (click)="promptForPremium()">
<button
type="button"
*appNotPremium
bitBadge
variant="success"
(click)="promptForPremium($event)"
>
{{ "premium" | i18n }}
</button>
`,
@@ -21,7 +27,9 @@ export class PremiumBadgeComponent {
constructor(private premiumUpgradePromptService: PremiumUpgradePromptService) {}
async promptForPremium() {
async promptForPremium(event: Event) {
event.stopPropagation();
event.preventDefault();
await this.premiumUpgradePromptService.promptForPremium(this.organizationId());
}
}

View File

@@ -5,18 +5,11 @@ import { JslibModule } from "@bitwarden/angular/jslib.module";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { MessageSender } from "@bitwarden/common/platform/messaging";
import { PremiumUpgradePromptService } from "@bitwarden/common/vault/abstractions/premium-upgrade-prompt.service";
import { BadgeModule, I18nMockService } from "@bitwarden/components";
import { PremiumBadgeComponent } from "./premium-badge.component";
class MockMessagingService implements MessageSender {
send = () => {
alert("Clicked on badge");
};
}
export default {
title: "Billing/Premium Badge",
component: PremiumBadgeComponent,
@@ -40,12 +33,6 @@ export default {
});
},
},
{
provide: MessageSender,
useFactory: () => {
return new MockMessagingService();
},
},
{
provide: BillingAccountProfileStateService,
useValue: {

View File

@@ -0,0 +1,98 @@
@if (cardDetails$ | async; as cardDetails) {
<section
class="tw-min-w-[332px] md:tw-max-w-sm tw-overflow-y-auto tw-self-center tw-bg-background tw-rounded-xl tw-shadow-lg tw-border-secondary-100 tw-border-solid tw-border"
cdkTrapFocus
cdkTrapFocusAutoCapture
>
<header
class="tw-flex tw-items-center tw-justify-end tw-pl-6 tw-pt-3 tw-pr-2 !tw-bg-background !tw-border-none"
>
<button
type="button"
bitIconButton="bwi-close"
buttonType="main"
size="default"
[label]="'close' | i18n"
(click)="close()"
></button>
</header>
<div class="tw-flex tw-justify-center tw-mb-6">
<div
class="tw-box-border tw-bg-background tw-text-main tw-size-full tw-flex tw-flex-col tw-px-8 tw-pb-2 tw-w-full tw-max-w-md"
>
<div class="tw-flex tw-items-center tw-justify-between tw-mb-2">
<h3 slot="title" class="tw-m-0" bitTypography="h3">
{{ "upgradeToPremium" | i18n }}
</h3>
</div>
<!-- Tagline with consistent height (exactly 2 lines) -->
<div class="tw-mb-6 tw-h-6">
<p bitTypography="helper" class="tw-text-muted tw-m-0 tw-leading-relaxed tw-line-clamp-2">
{{ cardDetails.tagline }}
</p>
</div>
<!-- Price Section -->
<div class="tw-mb-6">
<div class="tw-flex tw-items-baseline tw-gap-1 tw-flex-wrap">
<span class="tw-text-3xl tw-font-bold tw-leading-none tw-m-0">{{
cardDetails.price.amount | currency: "$"
}}</span>
<span bitTypography="helper" class="tw-text-muted">
/ {{ cardDetails.price.cadence }}
</span>
</div>
</div>
<!-- Button space (always reserved) -->
<div class="tw-mb-6 tw-h-12">
<button
bitButton
[buttonType]="cardDetails.button.type"
[block]="true"
(click)="upgrade()"
type="button"
>
@if (cardDetails.button.icon?.position === "before") {
<i class="bwi {{ cardDetails.button.icon.type }} tw-me-2" aria-hidden="true"></i>
}
{{ cardDetails.button.text }}
@if (
cardDetails.button.icon &&
(cardDetails.button.icon.position === "after" || !cardDetails.button.icon.position)
) {
<i class="bwi {{ cardDetails.button.icon.type }} tw-ms-2" aria-hidden="true"></i>
}
</button>
</div>
<!-- Features List -->
<div class="tw-flex-grow">
@if (cardDetails.features.length > 0) {
<ul class="tw-list-none tw-p-0 tw-m-0">
@for (feature of cardDetails.features; track feature) {
<li class="tw-flex tw-items-start tw-gap-2 tw-mb-2 last:tw-mb-0">
<i
class="bwi bwi-check tw-text-primary-600 tw-mt-0.5 tw-flex-shrink-0"
aria-hidden="true"
></i>
<span bitTypography="helper" class="tw-text-muted tw-leading-relaxed">{{
feature
}}</span>
</li>
}
</ul>
}
</div>
</div>
</div>
</section>
} @else {
<i
class="bwi bwi-spinner bwi-spin tw-text-muted"
title="{{ 'loading' | i18n }}"
aria-hidden="true"
></i>
<span class="tw-sr-only">{{ "loading" | i18n }}</span>
}

View File

@@ -0,0 +1,240 @@
import { CdkTrapFocus } from "@angular/cdk/a11y";
import { ComponentFixture, TestBed } from "@angular/core/testing";
import { NoopAnimationsModule } from "@angular/platform-browser/animations";
import { firstValueFrom, of, throwError } from "rxjs";
import { SubscriptionPricingServiceAbstraction } from "@bitwarden/common/billing/abstractions/subscription-pricing.service.abstraction";
import {
PersonalSubscriptionPricingTier,
PersonalSubscriptionPricingTierIds,
SubscriptionCadenceIds,
} from "@bitwarden/common/billing/types/subscription-pricing-tier";
import {
EnvironmentService,
Region,
} from "@bitwarden/common/platform/abstractions/environment.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { DialogRef, ToastService } from "@bitwarden/components";
import { LogService } from "@bitwarden/logging";
import { PremiumUpgradeDialogComponent } from "./premium-upgrade-dialog.component";
describe("PremiumUpgradeDialogComponent", () => {
let component: PremiumUpgradeDialogComponent;
let fixture: ComponentFixture<PremiumUpgradeDialogComponent>;
let mockDialogRef: jest.Mocked<DialogRef>;
let mockSubscriptionPricingService: jest.Mocked<SubscriptionPricingServiceAbstraction>;
let mockI18nService: jest.Mocked<I18nService>;
let mockToastService: jest.Mocked<ToastService>;
let mockEnvironmentService: jest.Mocked<EnvironmentService>;
let mockPlatformUtilsService: jest.Mocked<PlatformUtilsService>;
let mockLogService: jest.Mocked<LogService>;
const mockPremiumTier: PersonalSubscriptionPricingTier = {
id: PersonalSubscriptionPricingTierIds.Premium,
name: "Premium",
description: "Advanced features for power users",
availableCadences: [SubscriptionCadenceIds.Annually],
passwordManager: {
type: "standalone",
annualPrice: 10,
annualPricePerAdditionalStorageGB: 4,
features: [
{ key: "feature1", value: "Feature 1" },
{ key: "feature2", value: "Feature 2" },
{ key: "feature3", value: "Feature 3" },
],
},
};
const mockFamiliesTier: PersonalSubscriptionPricingTier = {
id: PersonalSubscriptionPricingTierIds.Families,
name: "Families",
description: "Family plan",
availableCadences: [SubscriptionCadenceIds.Annually],
passwordManager: {
type: "packaged",
users: 6,
annualPrice: 40,
annualPricePerAdditionalStorageGB: 4,
features: [{ key: "featureA", value: "Feature A" }],
},
};
beforeEach(async () => {
mockDialogRef = {
close: jest.fn(),
} as any;
mockSubscriptionPricingService = {
getPersonalSubscriptionPricingTiers$: jest.fn(),
} as any;
mockI18nService = {
t: jest.fn((key: string) => key),
} as any;
mockToastService = {
showToast: jest.fn(),
} as any;
mockEnvironmentService = {
environment$: of({
getWebVaultUrl: () => "https://vault.bitwarden.com",
getRegion: () => Region.US,
}),
} as any;
mockPlatformUtilsService = {
launchUri: jest.fn(),
} as any;
mockSubscriptionPricingService.getPersonalSubscriptionPricingTiers$.mockReturnValue(
of([mockPremiumTier, mockFamiliesTier]),
);
mockLogService = {
error: jest.fn(),
} as any;
await TestBed.configureTestingModule({
imports: [NoopAnimationsModule, PremiumUpgradeDialogComponent, CdkTrapFocus],
providers: [
{ provide: DialogRef, useValue: mockDialogRef },
{
provide: SubscriptionPricingServiceAbstraction,
useValue: mockSubscriptionPricingService,
},
{ provide: I18nService, useValue: mockI18nService },
{ provide: ToastService, useValue: mockToastService },
{ provide: EnvironmentService, useValue: mockEnvironmentService },
{ provide: PlatformUtilsService, useValue: mockPlatformUtilsService },
{ provide: LogService, useValue: mockLogService },
],
}).compileComponents();
fixture = TestBed.createComponent(PremiumUpgradeDialogComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it("should create", () => {
expect(component).toBeTruthy();
});
it("should emit cardDetails$ observable with Premium tier data", async () => {
const cardDetails = await firstValueFrom(component["cardDetails$"]);
expect(mockSubscriptionPricingService.getPersonalSubscriptionPricingTiers$).toHaveBeenCalled();
expect(cardDetails).toBeDefined();
expect(cardDetails?.title).toBe("Premium");
});
it("should filter to Premium tier only", async () => {
const cardDetails = await firstValueFrom(component["cardDetails$"]);
expect(cardDetails?.title).toBe("Premium");
expect(cardDetails?.title).not.toBe("Families");
});
it("should map Premium tier to card details correctly", async () => {
const cardDetails = await firstValueFrom(component["cardDetails$"]);
expect(cardDetails?.title).toBe("Premium");
expect(cardDetails?.tagline).toBe("Advanced features for power users");
expect(cardDetails?.price.amount).toBe(10 / 12);
expect(cardDetails?.price.cadence).toBe("monthly");
expect(cardDetails?.button.text).toBe("upgradeNow");
expect(cardDetails?.button.type).toBe("primary");
expect(cardDetails?.features).toEqual(["Feature 1", "Feature 2", "Feature 3"]);
});
it("should use i18nService for button text", async () => {
const cardDetails = await firstValueFrom(component["cardDetails$"]);
expect(mockI18nService.t).toHaveBeenCalledWith("upgradeNow");
expect(cardDetails?.button.text).toBe("upgradeNow");
});
describe("upgrade()", () => {
it("should launch URI with query parameter for cloud-hosted environments", async () => {
mockEnvironmentService.environment$ = of({
getWebVaultUrl: () => "https://vault.bitwarden.com",
getRegion: () => Region.US,
} as any);
await component["upgrade"]();
expect(mockPlatformUtilsService.launchUri).toHaveBeenCalledWith(
"https://vault.bitwarden.com/#/settings/subscription/premium?callToAction=upgradeToPremium",
);
expect(mockDialogRef.close).toHaveBeenCalled();
});
it("should launch URI without query parameter for self-hosted environments", async () => {
mockEnvironmentService.environment$ = of({
getWebVaultUrl: () => "https://self-hosted.example.com",
getRegion: () => Region.SelfHosted,
} as any);
await component["upgrade"]();
expect(mockPlatformUtilsService.launchUri).toHaveBeenCalledWith(
"https://self-hosted.example.com/#/settings/subscription/premium",
);
expect(mockDialogRef.close).toHaveBeenCalled();
});
it("should launch URI with query parameter for EU cloud region", async () => {
mockEnvironmentService.environment$ = of({
getWebVaultUrl: () => "https://vault.bitwarden.eu",
getRegion: () => Region.EU,
} as any);
await component["upgrade"]();
expect(mockPlatformUtilsService.launchUri).toHaveBeenCalledWith(
"https://vault.bitwarden.eu/#/settings/subscription/premium?callToAction=upgradeToPremium",
);
expect(mockDialogRef.close).toHaveBeenCalled();
});
});
it("should close dialog when close button clicked", () => {
component["close"]();
expect(mockDialogRef.close).toHaveBeenCalled();
});
describe("error handling", () => {
it("should show error toast and return EMPTY and close dialog when getPersonalSubscriptionPricingTiers$ throws an error", (done) => {
const error = new Error("Service error");
mockSubscriptionPricingService.getPersonalSubscriptionPricingTiers$.mockReturnValue(
throwError(() => error),
);
const errorFixture = TestBed.createComponent(PremiumUpgradeDialogComponent);
const errorComponent = errorFixture.componentInstance;
errorFixture.detectChanges();
const cardDetails$ = errorComponent["cardDetails$"];
cardDetails$.subscribe({
next: () => {
done.fail("Observable should not emit any values");
},
complete: () => {
expect(mockToastService.showToast).toHaveBeenCalledWith({
variant: "error",
title: "error",
message: "unexpectedError",
});
expect(mockDialogRef.close).toHaveBeenCalled();
done();
},
error: (err: unknown) => done.fail(`Observable should not error: ${err}`),
});
});
});
});

View File

@@ -0,0 +1,117 @@
import { Meta, moduleMetadata, StoryObj } from "@storybook/angular";
import { of } from "rxjs";
import { SubscriptionPricingServiceAbstraction } from "@bitwarden/common/billing/abstractions/subscription-pricing.service.abstraction";
import {
PersonalSubscriptionPricingTier,
PersonalSubscriptionPricingTierIds,
SubscriptionCadenceIds,
} 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 { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import {
ButtonModule,
DialogModule,
DialogRef,
ToastOptions,
ToastService,
TypographyModule,
} from "@bitwarden/components";
import { PremiumUpgradeDialogComponent } from "./premium-upgrade-dialog.component";
const mockPremiumTier: PersonalSubscriptionPricingTier = {
id: PersonalSubscriptionPricingTierIds.Premium,
name: "Premium",
description: "Complete online security",
availableCadences: [SubscriptionCadenceIds.Annually],
passwordManager: {
type: "standalone",
annualPrice: 10,
annualPricePerAdditionalStorageGB: 4,
features: [
{ key: "builtInAuthenticator", value: "Built-in authenticator" },
{ key: "secureFileStorage", value: "Secure file storage" },
{ key: "emergencyAccess", value: "Emergency access" },
{ key: "breachMonitoring", value: "Breach monitoring" },
{ key: "andMoreFeatures", value: "And more!" },
],
},
};
export default {
title: "Billing/Premium Upgrade Dialog",
component: PremiumUpgradeDialogComponent,
description: "A dialog for upgrading to Premium subscription",
decorators: [
moduleMetadata({
imports: [DialogModule, ButtonModule, TypographyModule],
providers: [
{
provide: DialogRef,
useValue: {
close: () => {},
},
},
{
provide: SubscriptionPricingServiceAbstraction,
useValue: {
getPersonalSubscriptionPricingTiers$: () => of([mockPremiumTier]),
},
},
{
provide: ToastService,
useValue: {
showToast: (options: ToastOptions) => {},
},
},
{
provide: EnvironmentService,
useValue: {
cloudWebVaultUrl$: of("https://vault.bitwarden.com"),
},
},
{
provide: PlatformUtilsService,
useValue: {
launchUri: (uri: string) => {},
},
},
{
provide: I18nService,
useValue: {
t: (key: string) => {
switch (key) {
case "upgradeNow":
return "Upgrade Now";
case "month":
return "month";
case "upgradeToPremium":
return "Upgrade To Premium";
default:
return key;
}
},
},
},
{
provide: LogService,
useValue: {
error: {},
},
},
],
}),
],
parameters: {
design: {
type: "figma",
url: "https://www.figma.com/design/nuFrzHsgEoEk2Sm8fWOGuS/Premium---business-upgrade-flows?node-id=931-17785&t=xOhvwjYLpjoMPgND-1",
},
},
} as Meta<PremiumUpgradeDialogComponent>;
type Story = StoryObj<PremiumUpgradeDialogComponent>;
export const Default: Story = {};

View File

@@ -0,0 +1,123 @@
import { CdkTrapFocus } from "@angular/cdk/a11y";
import { CommonModule } from "@angular/common";
import { ChangeDetectionStrategy, Component } from "@angular/core";
import { catchError, EMPTY, firstValueFrom, map, Observable } from "rxjs";
import { JslibModule } from "@bitwarden/angular/jslib.module";
import { SubscriptionPricingServiceAbstraction } from "@bitwarden/common/billing/abstractions/subscription-pricing.service.abstraction";
import {
PersonalSubscriptionPricingTier,
PersonalSubscriptionPricingTierIds,
SubscriptionCadence,
SubscriptionCadenceIds,
} from "@bitwarden/common/billing/types/subscription-pricing-tier";
import {
EnvironmentService,
Region,
} from "@bitwarden/common/platform/abstractions/environment.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import {
ButtonModule,
ButtonType,
DialogModule,
DialogRef,
DialogService,
IconButtonModule,
ToastService,
TypographyModule,
} from "@bitwarden/components";
import { LogService } from "@bitwarden/logging";
type CardDetails = {
title: string;
tagline: string;
price: { amount: number; cadence: SubscriptionCadence };
button: { text: string; type: ButtonType; icon?: { type: string; position: "before" | "after" } };
features: string[];
};
@Component({
selector: "billing-premium-upgrade-dialog",
standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [
CommonModule,
DialogModule,
ButtonModule,
IconButtonModule,
TypographyModule,
CdkTrapFocus,
JslibModule,
],
templateUrl: "./premium-upgrade-dialog.component.html",
})
export class PremiumUpgradeDialogComponent {
protected cardDetails$: Observable<CardDetails | null> = this.subscriptionPricingService
.getPersonalSubscriptionPricingTiers$()
.pipe(
map((tiers) => tiers.find((tier) => tier.id === PersonalSubscriptionPricingTierIds.Premium)),
map((tier) => this.mapPremiumTierToCardDetails(tier!)),
catchError((error: unknown) => {
this.toastService.showToast({
variant: "error",
title: this.i18nService.t("error"),
message: this.i18nService.t("unexpectedError"),
});
this.logService.error("Error fetching and mapping pricing tiers", error);
this.dialogRef.close();
return EMPTY;
}),
);
constructor(
private dialogRef: DialogRef,
private subscriptionPricingService: SubscriptionPricingServiceAbstraction,
private i18nService: I18nService,
private toastService: ToastService,
private environmentService: EnvironmentService,
private platformUtilsService: PlatformUtilsService,
private logService: LogService,
) {}
protected async upgrade(): Promise<void> {
const environment = await firstValueFrom(this.environmentService.environment$);
let vaultUrl = environment.getWebVaultUrl() + "/#/settings/subscription/premium";
if (environment.getRegion() !== Region.SelfHosted) {
vaultUrl += "?callToAction=upgradeToPremium";
}
this.platformUtilsService.launchUri(vaultUrl);
this.dialogRef.close();
}
protected close(): void {
this.dialogRef.close();
}
private mapPremiumTierToCardDetails(tier: PersonalSubscriptionPricingTier): CardDetails {
return {
title: tier.name,
tagline: tier.description,
price: {
amount: tier.passwordManager.annualPrice / 12,
cadence: SubscriptionCadenceIds.Monthly,
},
button: {
text: this.i18nService.t("upgradeNow"),
type: "primary",
icon: { type: "bwi-external-link", position: "after" },
},
features: tier.passwordManager.features.map((f) => f.value),
};
}
/**
* Opens the premium upgrade dialog.
*
* @param dialogService - The dialog service used to open the component
* @returns A dialog reference object
*/
static open(dialogService: DialogService): DialogRef<PremiumUpgradeDialogComponent> {
return dialogService.open(PremiumUpgradeDialogComponent);
}
}

View File

@@ -1,4 +1,5 @@
import { Directive, OnInit, TemplateRef, ViewContainerRef } from "@angular/core";
import { DestroyRef, Directive, OnInit, TemplateRef, ViewContainerRef } from "@angular/core";
import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
import { firstValueFrom } from "rxjs";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
@@ -16,6 +17,7 @@ export class NotPremiumDirective implements OnInit {
private templateRef: TemplateRef<any>,
private viewContainer: ViewContainerRef,
private billingAccountProfileStateService: BillingAccountProfileStateService,
private destroyRef: DestroyRef,
private accountService: AccountService,
) {}
@@ -27,14 +29,15 @@ export class NotPremiumDirective implements OnInit {
return;
}
const premium = await firstValueFrom(
this.billingAccountProfileStateService.hasPremiumFromAnySource$(account.id),
);
if (premium) {
this.viewContainer.clear();
} else {
this.viewContainer.createEmbeddedView(this.templateRef);
}
this.billingAccountProfileStateService
.hasPremiumFromAnySource$(account.id)
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((premium) => {
if (premium) {
this.viewContainer.clear();
} else {
this.viewContainer.createEmbeddedView(this.templateRef);
}
});
}
}

View File

@@ -151,6 +151,7 @@ import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abs
import { OrganizationMetadataServiceAbstraction } from "@bitwarden/common/billing/abstractions/organization-metadata.service.abstraction";
import { OrganizationBillingApiServiceAbstraction } from "@bitwarden/common/billing/abstractions/organizations/organization-billing-api.service.abstraction";
import { OrganizationSponsorshipApiServiceAbstraction } from "@bitwarden/common/billing/abstractions/organizations/organization-sponsorship-api.service.abstraction";
import { SubscriptionPricingServiceAbstraction } from "@bitwarden/common/billing/abstractions/subscription-pricing.service.abstraction";
import { AccountBillingApiService } from "@bitwarden/common/billing/services/account/account-billing-api.service";
import { DefaultBillingAccountProfileStateService } from "@bitwarden/common/billing/services/account/billing-account-profile-state.service";
import { BillingApiService } from "@bitwarden/common/billing/services/billing-api.service";
@@ -158,6 +159,7 @@ import { OrganizationBillingApiService } from "@bitwarden/common/billing/service
import { DefaultOrganizationMetadataService } from "@bitwarden/common/billing/services/organization/organization-metadata.service";
import { OrganizationSponsorshipApiService } from "@bitwarden/common/billing/services/organization/organization-sponsorship-api.service";
import { OrganizationBillingService } from "@bitwarden/common/billing/services/organization-billing.service";
import { DefaultSubscriptionPricingService } from "@bitwarden/common/billing/services/subscription-pricing.service";
import { HibpApiService } from "@bitwarden/common/dirt/services/hibp-api.service";
import {
DefaultKeyGenerationService,
@@ -1455,6 +1457,11 @@ const safeProviders: SafeProvider[] = [
useClass: DefaultBillingAccountProfileStateService,
deps: [StateProvider, PlatformUtilsServiceAbstraction, ApiServiceAbstraction],
}),
safeProvider({
provide: SubscriptionPricingServiceAbstraction,
useClass: DefaultSubscriptionPricingService,
deps: [BillingApiServiceAbstraction, ConfigService, I18nServiceAbstraction, LogService],
}),
safeProvider({
provide: OrganizationManagementPreferencesService,
useClass: DefaultOrganizationManagementPreferencesService,

View File

@@ -11,6 +11,7 @@ import {
BehaviorSubject,
concatMap,
switchMap,
tap,
} from "rxjs";
import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
@@ -33,6 +34,7 @@ import { SendTextView } from "@bitwarden/common/tools/send/models/view/send-text
import { SendView } from "@bitwarden/common/tools/send/models/view/send.view";
import { SendApiService } from "@bitwarden/common/tools/send/services/send-api.service.abstraction";
import { SendService } from "@bitwarden/common/tools/send/services/send.service.abstraction";
import { PremiumUpgradePromptService } from "@bitwarden/common/vault/abstractions/premium-upgrade-prompt.service";
import { DialogService, ToastService } from "@bitwarden/components";
// Value = hours
@@ -144,6 +146,7 @@ export class AddEditComponent implements OnInit, OnDestroy {
protected billingAccountProfileStateService: BillingAccountProfileStateService,
protected accountService: AccountService,
protected toastService: ToastService,
protected premiumUpgradePromptService: PremiumUpgradePromptService,
) {
this.typeOptions = [
{ name: i18nService.t("sendTypeFile"), value: SendType.File, premium: true },
@@ -192,10 +195,15 @@ export class AddEditComponent implements OnInit, OnDestroy {
}
});
this.formGroup.controls.type.valueChanges.pipe(takeUntil(this.destroy$)).subscribe((val) => {
this.type = val;
this.typeChanged();
});
this.formGroup.controls.type.valueChanges
.pipe(
tap((val) => {
this.type = val;
}),
switchMap(() => this.typeChanged()),
takeUntil(this.destroy$),
)
.subscribe();
this.formGroup.controls.selectedDeletionDatePreset.valueChanges
.pipe(takeUntil(this.destroy$))
@@ -426,11 +434,11 @@ export class AddEditComponent implements OnInit, OnDestroy {
return false;
}
typeChanged() {
async typeChanged() {
if (this.type === SendType.File && !this.alertShown) {
if (!this.canAccessPremium) {
this.alertShown = true;
this.messagingService.send("premiumRequired");
await this.premiumUpgradePromptService.promptForPremium();
} else if (!this.emailVerified) {
this.alertShown = true;
this.messagingService.send("emailVerificationRequired");

View File

@@ -0,0 +1,32 @@
import { Observable } from "rxjs";
import {
BusinessSubscriptionPricingTier,
PersonalSubscriptionPricingTier,
} from "../types/subscription-pricing-tier";
export abstract class SubscriptionPricingServiceAbstraction {
/**
* Gets personal subscription pricing tiers (Premium and Families).
* Throws any errors that occur during api request so callers must handle errors.
* @returns An observable of an array of personal subscription pricing tiers.
* @throws Error if any errors occur during api request.
*/
abstract getPersonalSubscriptionPricingTiers$(): Observable<PersonalSubscriptionPricingTier[]>;
/**
* Gets business subscription pricing tiers (Teams, Enterprise, and Custom).
* Throws any errors that occur during api request so callers must handle errors.
* @returns An observable of an array of business subscription pricing tiers.
* @throws Error if any errors occur during api request.
*/
abstract getBusinessSubscriptionPricingTiers$(): Observable<BusinessSubscriptionPricingTier[]>;
/**
* Gets developer subscription pricing tiers (Free, Teams, and Enterprise).
* Throws any errors that occur during api request so callers must handle errors.
* @returns An observable of an array of business subscription pricing tiers for developers.
* @throws Error if any errors occur during api request.
*/
abstract getDeveloperSubscriptionPricingTiers$(): Observable<BusinessSubscriptionPricingTier[]>;
}

View File

@@ -1,4 +1,3 @@
import { TestBed } from "@angular/core/testing";
import { mock, MockProxy } from "jest-mock-extended";
import { of } from "rxjs";
@@ -8,7 +7,6 @@ import { PlanResponse } from "@bitwarden/common/billing/models/response/plan.res
import { PremiumPlanResponse } from "@bitwarden/common/billing/models/response/premium-plan.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 {
@@ -17,15 +15,14 @@ import {
SubscriptionCadenceIds,
} from "../types/subscription-pricing-tier";
import { SubscriptionPricingService } from "./subscription-pricing.service";
import { DefaultSubscriptionPricingService } from "./subscription-pricing.service";
describe("SubscriptionPricingService", () => {
let service: SubscriptionPricingService;
describe("DefaultSubscriptionPricingService", () => {
let service: DefaultSubscriptionPricingService;
let billingApiService: MockProxy<BillingApiServiceAbstraction>;
let configService: MockProxy<ConfigService>;
let i18nService: MockProxy<I18nService>;
let logService: MockProxy<LogService>;
let toastService: MockProxy<ToastService>;
const mockFamiliesPlan = {
type: PlanType.FamiliesAnnually,
@@ -233,7 +230,6 @@ describe("SubscriptionPricingService", () => {
beforeAll(() => {
i18nService = mock<I18nService>();
logService = mock<LogService>();
toastService = mock<ToastService>();
i18nService.t.mockImplementation((key: string, ...args: any[]) => {
switch (key) {
@@ -324,8 +320,6 @@ describe("SubscriptionPricingService", () => {
return "Boost productivity";
case "seamlessIntegration":
return "Seamless integration";
case "unexpectedError":
return "An unexpected error has occurred.";
default:
return key;
}
@@ -340,18 +334,12 @@ describe("SubscriptionPricingService", () => {
billingApiService.getPremiumPlan.mockResolvedValue(mockPremiumPlanResponse);
configService.getFeatureFlag$.mockReturnValue(of(false)); // Default to false (use hardcoded value)
TestBed.configureTestingModule({
providers: [
SubscriptionPricingService,
{ provide: BillingApiServiceAbstraction, useValue: billingApiService },
{ provide: ConfigService, useValue: configService },
{ provide: I18nService, useValue: i18nService },
{ provide: LogService, useValue: logService },
{ provide: ToastService, useValue: toastService },
],
});
service = TestBed.inject(SubscriptionPricingService);
service = new DefaultSubscriptionPricingService(
billingApiService,
configService,
i18nService,
logService,
);
});
describe("getPersonalSubscriptionPricingTiers$", () => {
@@ -422,46 +410,37 @@ describe("SubscriptionPricingService", () => {
});
});
it("should handle API errors by logging and showing toast", (done) => {
it("should handle API errors by logging and throwing error", (done) => {
const errorBillingApiService = mock<BillingApiServiceAbstraction>();
const errorConfigService = mock<ConfigService>();
const errorI18nService = mock<I18nService>();
const errorLogService = mock<LogService>();
const errorToastService = mock<ToastService>();
const testError = new Error("API error");
errorBillingApiService.getPlans.mockRejectedValue(testError);
errorBillingApiService.getPremiumPlan.mockResolvedValue(mockPremiumPlanResponse);
errorConfigService.getFeatureFlag$.mockReturnValue(of(false));
errorI18nService.t.mockImplementation((key: string) => {
if (key === "unexpectedError") {
return "An unexpected error has occurred.";
}
return key;
});
errorI18nService.t.mockImplementation((key: string) => key);
const errorService = new SubscriptionPricingService(
const errorService = new DefaultSubscriptionPricingService(
errorBillingApiService,
errorConfigService,
errorI18nService,
errorLogService,
errorToastService,
);
errorService.getPersonalSubscriptionPricingTiers$().subscribe({
next: (tiers) => {
expect(tiers).toEqual([]);
expect(errorLogService.error).toHaveBeenCalledWith(testError);
expect(errorToastService.showToast).toHaveBeenCalledWith({
variant: "error",
title: "",
message: "An unexpected error has occurred.",
});
done();
next: () => {
fail("Observable should error, not return a value");
},
error: () => {
fail("Observable should not error, it should return empty array");
error: (error: unknown) => {
expect(errorLogService.error).toHaveBeenCalledWith(
"Failed to load personal subscription pricing tiers",
testError,
);
expect(error).toBe(testError);
done();
},
});
});
@@ -611,46 +590,37 @@ describe("SubscriptionPricingService", () => {
});
});
it("should handle API errors by logging and showing toast", (done) => {
it("should handle API errors by logging and throwing error", (done) => {
const errorBillingApiService = mock<BillingApiServiceAbstraction>();
const errorConfigService = mock<ConfigService>();
const errorI18nService = mock<I18nService>();
const errorLogService = mock<LogService>();
const errorToastService = mock<ToastService>();
const testError = new Error("API error");
errorBillingApiService.getPlans.mockRejectedValue(testError);
errorBillingApiService.getPremiumPlan.mockResolvedValue(mockPremiumPlanResponse);
errorConfigService.getFeatureFlag$.mockReturnValue(of(false));
errorI18nService.t.mockImplementation((key: string) => {
if (key === "unexpectedError") {
return "An unexpected error has occurred.";
}
return key;
});
errorI18nService.t.mockImplementation((key: string) => key);
const errorService = new SubscriptionPricingService(
const errorService = new DefaultSubscriptionPricingService(
errorBillingApiService,
errorConfigService,
errorI18nService,
errorLogService,
errorToastService,
);
errorService.getBusinessSubscriptionPricingTiers$().subscribe({
next: (tiers) => {
expect(tiers).toEqual([]);
expect(errorLogService.error).toHaveBeenCalledWith(testError);
expect(errorToastService.showToast).toHaveBeenCalledWith({
variant: "error",
title: "",
message: "An unexpected error has occurred.",
});
done();
next: () => {
fail("Observable should error, not return a value");
},
error: () => {
fail("Observable should not error, it should return empty array");
error: (error: unknown) => {
expect(errorLogService.error).toHaveBeenCalledWith(
"Failed to load business subscription pricing tiers",
testError,
);
expect(error).toBe(testError);
done();
},
});
});
@@ -855,46 +825,37 @@ describe("SubscriptionPricingService", () => {
});
});
it("should handle API errors by logging and showing toast", (done) => {
it("should handle API errors by logging and throwing error", (done) => {
const errorBillingApiService = mock<BillingApiServiceAbstraction>();
const errorConfigService = mock<ConfigService>();
const errorI18nService = mock<I18nService>();
const errorLogService = mock<LogService>();
const errorToastService = mock<ToastService>();
const testError = new Error("API error");
errorBillingApiService.getPlans.mockRejectedValue(testError);
errorBillingApiService.getPremiumPlan.mockResolvedValue(mockPremiumPlanResponse);
errorConfigService.getFeatureFlag$.mockReturnValue(of(false));
errorI18nService.t.mockImplementation((key: string) => {
if (key === "unexpectedError") {
return "An unexpected error has occurred.";
}
return key;
});
errorI18nService.t.mockImplementation((key: string) => key);
const errorService = new SubscriptionPricingService(
const errorService = new DefaultSubscriptionPricingService(
errorBillingApiService,
errorConfigService,
errorI18nService,
errorLogService,
errorToastService,
);
errorService.getDeveloperSubscriptionPricingTiers$().subscribe({
next: (tiers) => {
expect(tiers).toEqual([]);
expect(errorLogService.error).toHaveBeenCalledWith(testError);
expect(errorToastService.showToast).toHaveBeenCalledWith({
variant: "error",
title: "",
message: "An unexpected error has occurred.",
});
done();
next: () => {
fail("Observable should error, not return a value");
},
error: () => {
fail("Observable should not error, it should return empty array");
error: (error: unknown) => {
expect(errorLogService.error).toHaveBeenCalledWith(
"Failed to load developer subscription pricing tiers",
testError,
);
expect(error).toBe(testError);
done();
},
});
});
@@ -910,38 +871,36 @@ describe("SubscriptionPricingService", () => {
errorBillingApiService.getPremiumPlan.mockRejectedValue(testError);
errorConfigService.getFeatureFlag$.mockReturnValue(of(true)); // Enable feature flag to use premium plan API
const errorService = new SubscriptionPricingService(
const errorService = new DefaultSubscriptionPricingService(
errorBillingApiService,
errorConfigService,
i18nService,
logService,
toastService,
);
errorService.getPersonalSubscriptionPricingTiers$().subscribe({
next: (tiers) => {
// Should return empty array due to error in premium plan fetch
expect(tiers).toEqual([]);
next: () => {
fail("Observable should error, not return a value");
},
error: (error: unknown) => {
expect(logService.error).toHaveBeenCalledWith(
"Failed to fetch premium plan from API",
testError,
);
expect(toastService.showToast).toHaveBeenCalledWith({
variant: "error",
title: "",
message: "An unexpected error has occurred.",
});
expect(logService.error).toHaveBeenCalledWith(
"Failed to load personal subscription pricing tiers",
testError,
);
expect(error).toBe(testError);
done();
},
error: () => {
fail("Observable should not error, it should return empty array");
},
});
});
it("should handle malformed premium plan API response", (done) => {
const errorBillingApiService = mock<BillingApiServiceAbstraction>();
const errorConfigService = mock<ConfigService>();
const testError = new TypeError("Cannot read properties of undefined (reading 'price')");
// Malformed response missing the Seat property
const malformedResponse = {
@@ -955,28 +914,24 @@ describe("SubscriptionPricingService", () => {
errorBillingApiService.getPremiumPlan.mockResolvedValue(malformedResponse as any);
errorConfigService.getFeatureFlag$.mockReturnValue(of(true)); // Enable feature flag
const errorService = new SubscriptionPricingService(
const errorService = new DefaultSubscriptionPricingService(
errorBillingApiService,
errorConfigService,
i18nService,
logService,
toastService,
);
errorService.getPersonalSubscriptionPricingTiers$().subscribe({
next: (tiers) => {
// Should return empty array due to validation error
expect(tiers).toEqual([]);
expect(logService.error).toHaveBeenCalled();
expect(toastService.showToast).toHaveBeenCalledWith({
variant: "error",
title: "",
message: "An unexpected error has occurred.",
});
done();
next: () => {
fail("Observable should error, not return a value");
},
error: () => {
fail("Observable should not error, it should return empty array");
error: (error: unknown) => {
expect(logService.error).toHaveBeenCalledWith(
"Failed to load personal subscription pricing tiers",
testError,
);
expect(error).toEqual(testError);
done();
},
});
});
@@ -984,6 +939,7 @@ describe("SubscriptionPricingService", () => {
it("should handle malformed premium plan with invalid price types", (done) => {
const errorBillingApiService = mock<BillingApiServiceAbstraction>();
const errorConfigService = mock<ConfigService>();
const testError = new TypeError("Cannot read properties of undefined (reading 'price')");
// Malformed response with price as string instead of number
const malformedResponse = {
@@ -1001,28 +957,24 @@ describe("SubscriptionPricingService", () => {
errorBillingApiService.getPremiumPlan.mockResolvedValue(malformedResponse as any);
errorConfigService.getFeatureFlag$.mockReturnValue(of(true)); // Enable feature flag
const errorService = new SubscriptionPricingService(
const errorService = new DefaultSubscriptionPricingService(
errorBillingApiService,
errorConfigService,
i18nService,
logService,
toastService,
);
errorService.getPersonalSubscriptionPricingTiers$().subscribe({
next: (tiers) => {
// Should return empty array due to validation error
expect(tiers).toEqual([]);
expect(logService.error).toHaveBeenCalled();
expect(toastService.showToast).toHaveBeenCalledWith({
variant: "error",
title: "",
message: "An unexpected error has occurred.",
});
done();
next: () => {
fail("Observable should error, not return a value");
},
error: () => {
fail("Observable should not error, it should return empty array");
error: (error: unknown) => {
expect(logService.error).toHaveBeenCalledWith(
"Failed to load personal subscription pricing tiers",
testError,
);
expect(error).toEqual(testError);
done();
},
});
});
@@ -1053,12 +1005,11 @@ describe("SubscriptionPricingService", () => {
const getPremiumPlanSpy = jest.spyOn(newBillingApiService, "getPremiumPlan");
// Create a new service instance with the feature flag enabled
const newService = new SubscriptionPricingService(
const newService = new DefaultSubscriptionPricingService(
newBillingApiService,
newConfigService,
i18nService,
logService,
toastService,
);
// Subscribe to the premium pricing tier multiple times
@@ -1082,12 +1033,11 @@ describe("SubscriptionPricingService", () => {
newConfigService.getFeatureFlag$.mockReturnValue(of(false));
// Create a new service instance with the feature flag disabled
const newService = new SubscriptionPricingService(
const newService = new DefaultSubscriptionPricingService(
newBillingApiService,
newConfigService,
i18nService,
logService,
toastService,
);
// Subscribe with feature flag disabled

View File

@@ -1,5 +1,14 @@
import { Injectable } from "@angular/core";
import { combineLatest, from, map, Observable, of, shareReplay, switchMap, take } from "rxjs";
import {
combineLatest,
from,
map,
Observable,
of,
shareReplay,
switchMap,
take,
throwError,
} from "rxjs";
import { catchError } from "rxjs/operators";
import { BillingApiServiceAbstraction } from "@bitwarden/common/billing/abstractions";
@@ -10,19 +19,18 @@ 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 { SubscriptionPricingServiceAbstraction } from "../abstractions/subscription-pricing.service.abstraction";
import {
BusinessSubscriptionPricingTier,
BusinessSubscriptionPricingTierIds,
PersonalSubscriptionPricingTier,
PersonalSubscriptionPricingTierIds,
SubscriptionCadenceIds,
} from "@bitwarden/web-vault/app/billing/types/subscription-pricing-tier";
} from "../types/subscription-pricing-tier";
@Injectable({ providedIn: BillingServicesModule })
export class SubscriptionPricingService {
export class DefaultSubscriptionPricingService implements SubscriptionPricingServiceAbstraction {
/**
* Fallback premium pricing used when the feature flag is disabled.
* These values represent the legacy pricing model and will not reflect
@@ -37,33 +45,47 @@ export class SubscriptionPricingService {
private configService: ConfigService,
private i18nService: I18nService,
private logService: LogService,
private toastService: ToastService,
) {}
/**
* Gets personal subscription pricing tiers (Premium and Families).
* Throws any errors that occur during api request so callers must handle errors.
* @returns An observable of an array of personal subscription pricing tiers.
* @throws Error if any errors occur during api request.
*/
getPersonalSubscriptionPricingTiers$ = (): Observable<PersonalSubscriptionPricingTier[]> =>
combineLatest([this.premium$, this.families$]).pipe(
catchError((error: unknown) => {
this.logService.error(error);
this.showUnexpectedErrorToast();
return of([]);
this.logService.error("Failed to load personal subscription pricing tiers", error);
return throwError(() => error);
}),
);
/**
* Gets business subscription pricing tiers (Teams, Enterprise, and Custom).
* Throws any errors that occur during api request so callers must handle errors.
* @returns An observable of an array of business subscription pricing tiers.
* @throws Error if any errors occur during api request.
*/
getBusinessSubscriptionPricingTiers$ = (): Observable<BusinessSubscriptionPricingTier[]> =>
combineLatest([this.teams$, this.enterprise$, this.custom$]).pipe(
catchError((error: unknown) => {
this.logService.error(error);
this.showUnexpectedErrorToast();
return of([]);
this.logService.error("Failed to load business subscription pricing tiers", error);
return throwError(() => error);
}),
);
/**
* Gets developer subscription pricing tiers (Free, Teams, and Enterprise).
* Throws any errors that occur during api request so callers must handle errors.
* @returns An observable of an array of business subscription pricing tiers for developers.
* @throws Error if any errors occur during api request.
*/
getDeveloperSubscriptionPricingTiers$ = (): Observable<BusinessSubscriptionPricingTier[]> =>
combineLatest([this.free$, this.teams$, this.enterprise$]).pipe(
catchError((error: unknown) => {
this.logService.error(error);
this.showUnexpectedErrorToast();
return of([]);
this.logService.error("Failed to load developer subscription pricing tiers", error);
return throwError(() => error);
}),
);
@@ -76,7 +98,7 @@ export class SubscriptionPricingService {
).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
return throwError(() => error); // Re-throw to propagate to higher-level error handler
}),
shareReplay({ bufferSize: 1, refCount: false }),
);
@@ -94,8 +116,8 @@ export class SubscriptionPricingService {
})),
)
: of({
seat: SubscriptionPricingService.FALLBACK_PREMIUM_SEAT_PRICE,
storage: SubscriptionPricingService.FALLBACK_PREMIUM_STORAGE_PRICE,
seat: DefaultSubscriptionPricingService.FALLBACK_PREMIUM_SEAT_PRICE,
storage: DefaultSubscriptionPricingService.FALLBACK_PREMIUM_STORAGE_PRICE,
}),
),
map((premiumPrices) => ({
@@ -268,14 +290,6 @@ export class SubscriptionPricingService {
),
);
private showUnexpectedErrorToast() {
this.toastService.showToast({
variant: "error",
title: "",
message: this.i18nService.t("unexpectedError"),
});
}
private featureTranslations = {
builtInAuthenticator: () => ({
key: "builtInAuthenticator",

View File

@@ -30,6 +30,7 @@ export enum FeatureFlag {
PM24996_ImplementUpgradeFromFreeDialog = "pm-24996-implement-upgrade-from-free-dialog",
PM24033PremiumUpgradeNewDesign = "pm-24033-updat-premium-subscription-page",
PM26793_FetchPremiumPriceFromPricingService = "pm-26793-fetch-premium-price-from-pricing-service",
PM23713_PremiumBadgeOpensNewPremiumUpgradeDialog = "pm-23713-premium-badge-opens-new-premium-upgrade-dialog",
/* Key Management */
PrivateKeyRegeneration = "pm-12241-private-key-regeneration",
@@ -118,6 +119,7 @@ export const DefaultFeatureFlagValue = {
[FeatureFlag.PM24996_ImplementUpgradeFromFreeDialog]: FALSE,
[FeatureFlag.PM24033PremiumUpgradeNewDesign]: FALSE,
[FeatureFlag.PM26793_FetchPremiumPriceFromPricingService]: FALSE,
[FeatureFlag.PM23713_PremiumBadgeOpensNewPremiumUpgradeDialog]: FALSE,
/* Key Management */
[FeatureFlag.PrivateKeyRegeneration]: FALSE,

View File

@@ -3,19 +3,11 @@
{{ (hideIcon ? "createSend" : "new") | i18n }}
</button>
<bit-menu #itemOptions>
<a
bitMenuItem
[routerLink]="buildRouterLink(sendType.Text)"
[queryParams]="buildQueryParams(sendType.Text)"
>
<a bitMenuItem [routerLink]="buildRouterLink()" [queryParams]="buildQueryParams(sendType.Text)">
<i class="bwi bwi-file-text" slot="start" aria-hidden="true"></i>
{{ "sendTypeText" | i18n }}
</a>
<a
bitMenuItem
[routerLink]="buildRouterLink(sendType.File)"
[queryParams]="buildQueryParams(sendType.File)"
>
<a bitMenuItem (click)="sendFileClick()">
<div class="tw-flex tw-items-center tw-gap-2">
<i class="bwi bwi-file" slot="start" aria-hidden="true"></i>
{{ "sendTypeFile" | i18n }}

View File

@@ -1,6 +1,6 @@
import { CommonModule } from "@angular/common";
import { Component, Input, OnInit } from "@angular/core";
import { RouterLink } from "@angular/router";
import { Router, RouterLink } from "@angular/router";
import { firstValueFrom } from "rxjs";
import { PremiumBadgeComponent } from "@bitwarden/angular/billing/components/premium-badge";
@@ -8,6 +8,7 @@ import { JslibModule } from "@bitwarden/angular/jslib.module";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions";
import { SendType } from "@bitwarden/common/tools/send/enums/send-type";
import { PremiumUpgradePromptService } from "@bitwarden/common/vault/abstractions/premium-upgrade-prompt.service";
import { ButtonModule, ButtonType, MenuModule } from "@bitwarden/components";
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
@@ -32,6 +33,8 @@ export class NewSendDropdownComponent implements OnInit {
constructor(
private billingAccountProfileStateService: BillingAccountProfileStateService,
private accountService: AccountService,
private router: Router,
private premiumUpgradePromptService: PremiumUpgradePromptService,
) {}
async ngOnInit() {
@@ -46,18 +49,21 @@ export class NewSendDropdownComponent implements OnInit {
));
}
buildRouterLink(type: SendType) {
if (this.hasNoPremium && type === SendType.File) {
return "/premium";
} else {
return "/add-send";
}
buildRouterLink() {
return "/add-send";
}
buildQueryParams(type: SendType) {
if (this.hasNoPremium && type === SendType.File) {
return null;
}
return { type: type, isNew: true };
}
async sendFileClick() {
if (this.hasNoPremium) {
await this.premiumUpgradePromptService.promptForPremium();
} else {
await this.router.navigate([this.buildRouterLink()], {
queryParams: this.buildQueryParams(SendType.File),
});
}
}
}