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:
@@ -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."
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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"]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -147,18 +147,6 @@ export class AppComponent implements OnDestroy, OnInit {
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "premiumRequired": {
|
||||
const premiumConfirmed = await this.dialogService.openSimpleDialog({
|
||||
title: { key: "premiumRequired" },
|
||||
content: { key: "premiumRequiredDesc" },
|
||||
acceptButtonText: { key: "upgrade" },
|
||||
type: "success",
|
||||
});
|
||||
if (premiumConfirmed) {
|
||||
await this.router.navigate(["settings/subscription/premium"]);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "emailVerificationRequired": {
|
||||
const emailVerificationConfirmed = await this.dialogService.openSimpleDialog({
|
||||
title: { key: "emailVerificationRequired" },
|
||||
|
||||
@@ -96,15 +96,6 @@ export class EmergencyAccessComponent implements OnInit {
|
||||
this.loaded = true;
|
||||
}
|
||||
|
||||
async premiumRequired() {
|
||||
const canAccessPremium = await firstValueFrom(this.canAccessPremium$);
|
||||
|
||||
if (!canAccessPremium) {
|
||||
this.messagingService.send("premiumRequired");
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
edit = async (details: GranteeEmergencyAccess) => {
|
||||
const canAccessPremium = await firstValueFrom(this.canAccessPremium$);
|
||||
const dialogRef = EmergencyAccessAddEditComponent.open(this.dialogService, {
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
import { Component, OnDestroy, OnInit } from "@angular/core";
|
||||
import {
|
||||
first,
|
||||
firstValueFrom,
|
||||
lastValueFrom,
|
||||
Observable,
|
||||
Subject,
|
||||
@@ -264,13 +263,6 @@ export class TwoFactorSetupComponent implements OnInit, OnDestroy {
|
||||
}
|
||||
}
|
||||
|
||||
async premiumRequired() {
|
||||
if (!(await firstValueFrom(this.canAccessPremium$))) {
|
||||
this.messagingService.send("premiumRequired");
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
protected getTwoFactorProviders() {
|
||||
return this.twoFactorApiService.getTwoFactorProviders();
|
||||
}
|
||||
|
||||
@@ -1,21 +1,21 @@
|
||||
import { inject } from "@angular/core";
|
||||
import {
|
||||
ActivatedRouteSnapshot,
|
||||
RouterStateSnapshot,
|
||||
Router,
|
||||
CanActivateFn,
|
||||
Router,
|
||||
RouterStateSnapshot,
|
||||
UrlTree,
|
||||
} from "@angular/router";
|
||||
import { Observable, of } from "rxjs";
|
||||
import { from, Observable, of } from "rxjs";
|
||||
import { switchMap, tap } from "rxjs/operators";
|
||||
|
||||
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||
import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service";
|
||||
import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service";
|
||||
import { PremiumUpgradePromptService } from "@bitwarden/common/vault/abstractions/premium-upgrade-prompt.service";
|
||||
|
||||
/**
|
||||
* CanActivate guard that checks if the user has premium and otherwise triggers the "premiumRequired"
|
||||
* message and blocks navigation.
|
||||
* CanActivate guard that checks if the user has premium and otherwise triggers the premium upgrade
|
||||
* flow and blocks navigation.
|
||||
*/
|
||||
export function hasPremiumGuard(): CanActivateFn {
|
||||
return (
|
||||
@@ -23,7 +23,7 @@ export function hasPremiumGuard(): CanActivateFn {
|
||||
_state: RouterStateSnapshot,
|
||||
): Observable<boolean | UrlTree> => {
|
||||
const router = inject(Router);
|
||||
const messagingService = inject(MessagingService);
|
||||
const premiumUpgradePromptService = inject(PremiumUpgradePromptService);
|
||||
const billingAccountProfileStateService = inject(BillingAccountProfileStateService);
|
||||
const accountService = inject(AccountService);
|
||||
|
||||
@@ -33,10 +33,14 @@ export function hasPremiumGuard(): CanActivateFn {
|
||||
? billingAccountProfileStateService.hasPremiumFromAnySource$(account.id)
|
||||
: of(false),
|
||||
),
|
||||
tap((userHasPremium: boolean) => {
|
||||
switchMap((userHasPremium: boolean) => {
|
||||
// Can't call async method inside observables so instead, wait for service then switch back to the boolean
|
||||
if (!userHasPremium) {
|
||||
messagingService.send("premiumRequired");
|
||||
return from(premiumUpgradePromptService.promptForPremium()).pipe(
|
||||
switchMap(() => of(userHasPremium)),
|
||||
);
|
||||
}
|
||||
return of(userHasPremium);
|
||||
}),
|
||||
// Prevent trapping the user on the login page, since that's an awful UX flow
|
||||
tap((userHasPremium: boolean) => {
|
||||
|
||||
@@ -16,6 +16,11 @@ import {
|
||||
import { ApiService } from "@bitwarden/common/abstractions/api.service";
|
||||
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||
import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions";
|
||||
import { SubscriptionPricingServiceAbstraction } from "@bitwarden/common/billing/abstractions/subscription-pricing.service.abstraction";
|
||||
import {
|
||||
PersonalSubscriptionPricingTier,
|
||||
PersonalSubscriptionPricingTierIds,
|
||||
} from "@bitwarden/common/billing/types/subscription-pricing-tier";
|
||||
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
|
||||
import { SyncService } from "@bitwarden/common/platform/sync";
|
||||
import {
|
||||
@@ -28,12 +33,7 @@ import {
|
||||
import { PricingCardComponent } from "@bitwarden/pricing";
|
||||
import { I18nPipe } from "@bitwarden/ui-common";
|
||||
|
||||
import { SubscriptionPricingService } from "../../services/subscription-pricing.service";
|
||||
import { BitwardenSubscriber, mapAccountToSubscriber } from "../../types";
|
||||
import {
|
||||
PersonalSubscriptionPricingTier,
|
||||
PersonalSubscriptionPricingTierIds,
|
||||
} from "../../types/subscription-pricing-tier";
|
||||
import {
|
||||
UnifiedUpgradeDialogComponent,
|
||||
UnifiedUpgradeDialogParams,
|
||||
@@ -91,7 +91,7 @@ export class PremiumVNextComponent {
|
||||
private platformUtilsService: PlatformUtilsService,
|
||||
private syncService: SyncService,
|
||||
private billingAccountProfileStateService: BillingAccountProfileStateService,
|
||||
private subscriptionPricingService: SubscriptionPricingService,
|
||||
private subscriptionPricingService: SubscriptionPricingServiceAbstraction,
|
||||
private router: Router,
|
||||
private activatedRoute: ActivatedRoute,
|
||||
) {
|
||||
|
||||
@@ -5,6 +5,7 @@ import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
|
||||
import { FormControl, FormGroup, Validators } from "@angular/forms";
|
||||
import { ActivatedRoute, Router } from "@angular/router";
|
||||
import {
|
||||
catchError,
|
||||
combineLatest,
|
||||
concatMap,
|
||||
filter,
|
||||
@@ -12,10 +13,9 @@ import {
|
||||
map,
|
||||
Observable,
|
||||
of,
|
||||
shareReplay,
|
||||
startWith,
|
||||
switchMap,
|
||||
catchError,
|
||||
shareReplay,
|
||||
} from "rxjs";
|
||||
import { debounceTime } from "rxjs/operators";
|
||||
|
||||
@@ -23,6 +23,8 @@ import { ApiService } from "@bitwarden/common/abstractions/api.service";
|
||||
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||
import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions";
|
||||
import { PaymentMethodType } from "@bitwarden/common/billing/enums";
|
||||
import { DefaultSubscriptionPricingService } from "@bitwarden/common/billing/services/subscription-pricing.service";
|
||||
import { PersonalSubscriptionPricingTierIds } from "@bitwarden/common/billing/types/subscription-pricing-tier";
|
||||
import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
|
||||
@@ -35,12 +37,10 @@ import {
|
||||
getBillingAddressFromForm,
|
||||
} from "@bitwarden/web-vault/app/billing/payment/components";
|
||||
import {
|
||||
tokenizablePaymentMethodToLegacyEnum,
|
||||
NonTokenizablePaymentMethods,
|
||||
tokenizablePaymentMethodToLegacyEnum,
|
||||
} from "@bitwarden/web-vault/app/billing/payment/types";
|
||||
import { SubscriptionPricingService } from "@bitwarden/web-vault/app/billing/services/subscription-pricing.service";
|
||||
import { mapAccountToSubscriber } from "@bitwarden/web-vault/app/billing/types";
|
||||
import { PersonalSubscriptionPricingTierIds } from "@bitwarden/web-vault/app/billing/types/subscription-pricing-tier";
|
||||
|
||||
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
|
||||
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
|
||||
@@ -137,7 +137,7 @@ export class PremiumComponent {
|
||||
private accountService: AccountService,
|
||||
private subscriberBillingClient: SubscriberBillingClient,
|
||||
private taxClient: TaxClient,
|
||||
private subscriptionPricingService: SubscriptionPricingService,
|
||||
private subscriptionPricingService: DefaultSubscriptionPricingService,
|
||||
) {
|
||||
this.isSelfHost = this.platformUtilsService.isSelfHost();
|
||||
|
||||
|
||||
@@ -4,13 +4,13 @@ import { NoopAnimationsModule } from "@angular/platform-browser/animations";
|
||||
import { mock } from "jest-mock-extended";
|
||||
|
||||
import { Account } from "@bitwarden/common/auth/abstractions/account.service";
|
||||
import { UserId } from "@bitwarden/common/types/guid";
|
||||
import { DIALOG_DATA, DialogRef } from "@bitwarden/components";
|
||||
|
||||
import {
|
||||
PersonalSubscriptionPricingTierId,
|
||||
PersonalSubscriptionPricingTierIds,
|
||||
} from "../../../types/subscription-pricing-tier";
|
||||
} from "@bitwarden/common/billing/types/subscription-pricing-tier";
|
||||
import { UserId } from "@bitwarden/common/types/guid";
|
||||
import { DIALOG_DATA, DialogRef } from "@bitwarden/components";
|
||||
|
||||
import {
|
||||
UpgradeAccountComponent,
|
||||
UpgradeAccountStatus,
|
||||
|
||||
@@ -4,6 +4,7 @@ import { Component, Inject, OnInit, signal } from "@angular/core";
|
||||
import { Router } from "@angular/router";
|
||||
|
||||
import { Account } from "@bitwarden/common/auth/abstractions/account.service";
|
||||
import { PersonalSubscriptionPricingTierId } from "@bitwarden/common/billing/types/subscription-pricing-tier";
|
||||
import { UnionOfValues } from "@bitwarden/common/vault/types/union-of-values";
|
||||
import {
|
||||
ButtonModule,
|
||||
@@ -15,7 +16,6 @@ import {
|
||||
|
||||
import { AccountBillingClient, TaxClient } from "../../../clients";
|
||||
import { BillingServicesModule } from "../../../services";
|
||||
import { PersonalSubscriptionPricingTierId } from "../../../types/subscription-pricing-tier";
|
||||
import { UpgradeAccountComponent } from "../upgrade-account/upgrade-account.component";
|
||||
import { UpgradePaymentService } from "../upgrade-payment/services/upgrade-payment.service";
|
||||
import {
|
||||
|
||||
@@ -4,15 +4,15 @@ import { NoopAnimationsModule } from "@angular/platform-browser/animations";
|
||||
import { mock } from "jest-mock-extended";
|
||||
import { of } from "rxjs";
|
||||
|
||||
import { SubscriptionPricingServiceAbstraction } from "@bitwarden/common/billing/abstractions/subscription-pricing.service.abstraction";
|
||||
import {
|
||||
PersonalSubscriptionPricingTier,
|
||||
PersonalSubscriptionPricingTierIds,
|
||||
} from "@bitwarden/common/billing/types/subscription-pricing-tier";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { PricingCardComponent } from "@bitwarden/pricing";
|
||||
|
||||
import { BillingServicesModule } from "../../../services";
|
||||
import { SubscriptionPricingService } from "../../../services/subscription-pricing.service";
|
||||
import {
|
||||
PersonalSubscriptionPricingTier,
|
||||
PersonalSubscriptionPricingTierIds,
|
||||
} from "../../../types/subscription-pricing-tier";
|
||||
|
||||
import { UpgradeAccountComponent, UpgradeAccountStatus } from "./upgrade-account.component";
|
||||
|
||||
@@ -20,7 +20,7 @@ describe("UpgradeAccountComponent", () => {
|
||||
let sut: UpgradeAccountComponent;
|
||||
let fixture: ComponentFixture<UpgradeAccountComponent>;
|
||||
const mockI18nService = mock<I18nService>();
|
||||
const mockSubscriptionPricingService = mock<SubscriptionPricingService>();
|
||||
const mockSubscriptionPricingService = mock<SubscriptionPricingServiceAbstraction>();
|
||||
|
||||
// Mock pricing tiers data
|
||||
const mockPricingTiers: PersonalSubscriptionPricingTier[] = [
|
||||
@@ -57,7 +57,10 @@ describe("UpgradeAccountComponent", () => {
|
||||
imports: [NoopAnimationsModule, UpgradeAccountComponent, PricingCardComponent, CdkTrapFocus],
|
||||
providers: [
|
||||
{ provide: I18nService, useValue: mockI18nService },
|
||||
{ provide: SubscriptionPricingService, useValue: mockSubscriptionPricingService },
|
||||
{
|
||||
provide: SubscriptionPricingServiceAbstraction,
|
||||
useValue: mockSubscriptionPricingService,
|
||||
},
|
||||
],
|
||||
})
|
||||
.overrideComponent(UpgradeAccountComponent, {
|
||||
@@ -170,7 +173,10 @@ describe("UpgradeAccountComponent", () => {
|
||||
],
|
||||
providers: [
|
||||
{ provide: I18nService, useValue: mockI18nService },
|
||||
{ provide: SubscriptionPricingService, useValue: mockSubscriptionPricingService },
|
||||
{
|
||||
provide: SubscriptionPricingServiceAbstraction,
|
||||
useValue: mockSubscriptionPricingService,
|
||||
},
|
||||
],
|
||||
})
|
||||
.overrideComponent(UpgradeAccountComponent, {
|
||||
|
||||
@@ -2,22 +2,23 @@ import { CdkTrapFocus } from "@angular/cdk/a11y";
|
||||
import { CommonModule } from "@angular/common";
|
||||
import { Component, DestroyRef, OnInit, computed, input, output, signal } from "@angular/core";
|
||||
import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
|
||||
import { catchError, of } from "rxjs";
|
||||
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { UnionOfValues } from "@bitwarden/common/vault/types/union-of-values";
|
||||
import { ButtonType, DialogModule } from "@bitwarden/components";
|
||||
import { PricingCardComponent } from "@bitwarden/pricing";
|
||||
|
||||
import { SharedModule } from "../../../../shared";
|
||||
import { BillingServicesModule } from "../../../services";
|
||||
import { SubscriptionPricingService } from "../../../services/subscription-pricing.service";
|
||||
import { SubscriptionPricingServiceAbstraction } from "@bitwarden/common/billing/abstractions/subscription-pricing.service.abstraction";
|
||||
import {
|
||||
PersonalSubscriptionPricingTier,
|
||||
PersonalSubscriptionPricingTierId,
|
||||
PersonalSubscriptionPricingTierIds,
|
||||
SubscriptionCadence,
|
||||
SubscriptionCadenceIds,
|
||||
} from "../../../types/subscription-pricing-tier";
|
||||
} from "@bitwarden/common/billing/types/subscription-pricing-tier";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { UnionOfValues } from "@bitwarden/common/vault/types/union-of-values";
|
||||
import { ButtonType, DialogModule, ToastService } from "@bitwarden/components";
|
||||
import { PricingCardComponent } from "@bitwarden/pricing";
|
||||
|
||||
import { SharedModule } from "../../../../shared";
|
||||
import { BillingServicesModule } from "../../../services";
|
||||
|
||||
export const UpgradeAccountStatus = {
|
||||
Closed: "closed",
|
||||
@@ -72,14 +73,26 @@ export class UpgradeAccountComponent implements OnInit {
|
||||
|
||||
constructor(
|
||||
private i18nService: I18nService,
|
||||
private subscriptionPricingService: SubscriptionPricingService,
|
||||
private subscriptionPricingService: SubscriptionPricingServiceAbstraction,
|
||||
private toastService: ToastService,
|
||||
private destroyRef: DestroyRef,
|
||||
) {}
|
||||
|
||||
ngOnInit(): void {
|
||||
this.subscriptionPricingService
|
||||
.getPersonalSubscriptionPricingTiers$()
|
||||
.pipe(takeUntilDestroyed(this.destroyRef))
|
||||
.pipe(
|
||||
catchError((error: unknown) => {
|
||||
this.toastService.showToast({
|
||||
variant: "error",
|
||||
title: "",
|
||||
message: this.i18nService.t("unexpectedError"),
|
||||
});
|
||||
this.loading.set(false);
|
||||
return of([]);
|
||||
}),
|
||||
takeUntilDestroyed(this.destroyRef),
|
||||
)
|
||||
.subscribe((plans) => {
|
||||
this.setupCardDetails(plans);
|
||||
this.loading.set(false);
|
||||
|
||||
@@ -119,14 +119,13 @@ describe("UpgradeNavButtonComponent", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("should refresh token and sync after upgrading to premium", async () => {
|
||||
it("should full sync after upgrading to premium", async () => {
|
||||
const mockDialogRef = mock<DialogRef<UnifiedUpgradeDialogResult>>();
|
||||
mockDialogRef.closed = of({ status: UnifiedUpgradeDialogStatus.UpgradedToPremium });
|
||||
mockDialogService.open.mockReturnValue(mockDialogRef);
|
||||
|
||||
await component.upgrade();
|
||||
|
||||
expect(mockApiService.refreshIdentityToken).toHaveBeenCalled();
|
||||
expect(mockSyncService.fullSync).toHaveBeenCalledWith(true);
|
||||
});
|
||||
|
||||
|
||||
@@ -60,7 +60,6 @@ export class UpgradeNavButtonComponent {
|
||||
const result = await lastValueFrom(dialogRef.closed);
|
||||
|
||||
if (result?.status === UnifiedUpgradeDialogStatus.UpgradedToPremium) {
|
||||
await this.apiService.refreshIdentityToken();
|
||||
await this.syncService.fullSync(true);
|
||||
} else if (result?.status === UnifiedUpgradeDialogStatus.UpgradedToFamilies) {
|
||||
const redirectUrl = `/organizations/${result.organizationId}/vault`;
|
||||
|
||||
@@ -11,6 +11,7 @@ import { OrganizationResponse } from "@bitwarden/common/admin-console/models/res
|
||||
import { Account, AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||
import { OrganizationBillingServiceAbstraction } from "@bitwarden/common/billing/abstractions";
|
||||
import { PaymentMethodType, PlanType } from "@bitwarden/common/billing/enums";
|
||||
import { PersonalSubscriptionPricingTierIds } from "@bitwarden/common/billing/types/subscription-pricing-tier";
|
||||
import { SyncService } from "@bitwarden/common/platform/sync";
|
||||
import { UserId } from "@bitwarden/common/types/guid";
|
||||
import { LogService } from "@bitwarden/logging";
|
||||
@@ -27,7 +28,6 @@ import {
|
||||
NonTokenizedPaymentMethod,
|
||||
TokenizedPaymentMethod,
|
||||
} from "../../../../payment/types";
|
||||
import { PersonalSubscriptionPricingTierIds } from "../../../../types/subscription-pricing-tier";
|
||||
|
||||
import { UpgradePaymentService, PlanDetails } from "./upgrade-payment.service";
|
||||
|
||||
|
||||
@@ -12,6 +12,11 @@ import {
|
||||
SubscriptionInformation,
|
||||
} from "@bitwarden/common/billing/abstractions";
|
||||
import { PaymentMethodType, PlanType } from "@bitwarden/common/billing/enums";
|
||||
import {
|
||||
PersonalSubscriptionPricingTier,
|
||||
PersonalSubscriptionPricingTierId,
|
||||
PersonalSubscriptionPricingTierIds,
|
||||
} from "@bitwarden/common/billing/types/subscription-pricing-tier";
|
||||
import { SyncService } from "@bitwarden/common/vault/abstractions/sync/sync.service.abstraction";
|
||||
import { LogService } from "@bitwarden/logging";
|
||||
|
||||
@@ -30,11 +35,6 @@ import {
|
||||
TokenizedPaymentMethod,
|
||||
} from "../../../../payment/types";
|
||||
import { mapAccountToSubscriber } from "../../../../types";
|
||||
import {
|
||||
PersonalSubscriptionPricingTier,
|
||||
PersonalSubscriptionPricingTierId,
|
||||
PersonalSubscriptionPricingTierIds,
|
||||
} from "../../../../types/subscription-pricing-tier";
|
||||
|
||||
export type PlanDetails = {
|
||||
tier: PersonalSubscriptionPricingTierId;
|
||||
|
||||
@@ -24,6 +24,12 @@ import {
|
||||
} from "rxjs";
|
||||
|
||||
import { Account } from "@bitwarden/common/auth/abstractions/account.service";
|
||||
import { SubscriptionPricingServiceAbstraction } from "@bitwarden/common/billing/abstractions/subscription-pricing.service.abstraction";
|
||||
import {
|
||||
PersonalSubscriptionPricingTier,
|
||||
PersonalSubscriptionPricingTierId,
|
||||
PersonalSubscriptionPricingTierIds,
|
||||
} from "@bitwarden/common/billing/types/subscription-pricing-tier";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { UnionOfValues } from "@bitwarden/common/vault/types/union-of-values";
|
||||
import { ButtonModule, DialogModule, ToastService } from "@bitwarden/components";
|
||||
@@ -43,13 +49,7 @@ import {
|
||||
TokenizedPaymentMethod,
|
||||
} from "../../../payment/types";
|
||||
import { BillingServicesModule } from "../../../services";
|
||||
import { SubscriptionPricingService } from "../../../services/subscription-pricing.service";
|
||||
import { BitwardenSubscriber } from "../../../types";
|
||||
import {
|
||||
PersonalSubscriptionPricingTier,
|
||||
PersonalSubscriptionPricingTierId,
|
||||
PersonalSubscriptionPricingTierIds,
|
||||
} from "../../../types/subscription-pricing-tier";
|
||||
|
||||
import {
|
||||
PaymentFormValues,
|
||||
@@ -128,7 +128,7 @@ export class UpgradePaymentComponent implements OnInit, AfterViewInit {
|
||||
|
||||
constructor(
|
||||
private i18nService: I18nService,
|
||||
private subscriptionPricingService: SubscriptionPricingService,
|
||||
private subscriptionPricingService: SubscriptionPricingServiceAbstraction,
|
||||
private toastService: ToastService,
|
||||
private logService: LogService,
|
||||
private destroyRef: DestroyRef,
|
||||
@@ -145,29 +145,42 @@ export class UpgradePaymentComponent implements OnInit, AfterViewInit {
|
||||
}
|
||||
|
||||
this.pricingTiers$ = this.subscriptionPricingService.getPersonalSubscriptionPricingTiers$();
|
||||
this.pricingTiers$.pipe(takeUntilDestroyed(this.destroyRef)).subscribe((plans) => {
|
||||
const planDetails = plans.find((plan) => plan.id === this.selectedPlanId());
|
||||
this.pricingTiers$
|
||||
.pipe(
|
||||
catchError((error: unknown) => {
|
||||
this.toastService.showToast({
|
||||
variant: "error",
|
||||
title: this.i18nService.t("error"),
|
||||
message: this.i18nService.t("unexpectedError"),
|
||||
});
|
||||
this.loading.set(false);
|
||||
return of([]);
|
||||
}),
|
||||
takeUntilDestroyed(this.destroyRef),
|
||||
)
|
||||
.subscribe((plans) => {
|
||||
const planDetails = plans.find((plan) => plan.id === this.selectedPlanId());
|
||||
|
||||
if (planDetails) {
|
||||
this.selectedPlan = {
|
||||
tier: this.selectedPlanId(),
|
||||
details: planDetails,
|
||||
};
|
||||
this.passwordManager = {
|
||||
name: this.isFamiliesPlan ? "familiesMembership" : "premiumMembership",
|
||||
cost: this.selectedPlan.details.passwordManager.annualPrice,
|
||||
quantity: 1,
|
||||
cadence: "year",
|
||||
};
|
||||
if (planDetails) {
|
||||
this.selectedPlan = {
|
||||
tier: this.selectedPlanId(),
|
||||
details: planDetails,
|
||||
};
|
||||
this.passwordManager = {
|
||||
name: this.isFamiliesPlan ? "familiesMembership" : "premiumMembership",
|
||||
cost: this.selectedPlan.details.passwordManager.annualPrice,
|
||||
quantity: 1,
|
||||
cadence: "year",
|
||||
};
|
||||
|
||||
this.upgradeToMessage = this.i18nService.t(
|
||||
this.isFamiliesPlan ? "startFreeFamiliesTrial" : "upgradeToPremium",
|
||||
);
|
||||
} else {
|
||||
this.complete.emit({ status: UpgradePaymentStatus.Closed, organizationId: null });
|
||||
return;
|
||||
}
|
||||
});
|
||||
this.upgradeToMessage = this.i18nService.t(
|
||||
this.isFamiliesPlan ? "startFreeFamiliesTrial" : "upgradeToPremium",
|
||||
);
|
||||
} else {
|
||||
this.complete.emit({ status: UpgradePaymentStatus.Closed, organizationId: null });
|
||||
return;
|
||||
}
|
||||
});
|
||||
|
||||
this.estimatedTax$ = this.formGroup.controls.billingAddress.valueChanges.pipe(
|
||||
startWith(this.formGroup.controls.billingAddress.value),
|
||||
|
||||
@@ -795,7 +795,6 @@ export class ChangePlanDialogComponent implements OnInit, OnDestroy {
|
||||
: this.i18nService.t("organizationUpgraded"),
|
||||
});
|
||||
|
||||
await this.apiService.refreshIdentityToken();
|
||||
await this.syncService.fullSync(true);
|
||||
|
||||
if (!this.acceptingSponsorship && !this.isInTrialFlow) {
|
||||
|
||||
@@ -675,7 +675,6 @@ export class OrganizationPlansComponent implements OnInit, OnDestroy {
|
||||
});
|
||||
}
|
||||
|
||||
await this.apiService.refreshIdentityToken();
|
||||
await this.syncService.fullSync(true);
|
||||
|
||||
if (!this.acceptingSponsorship && !this.isInTrialFlow) {
|
||||
|
||||
@@ -55,6 +55,7 @@ import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service";
|
||||
import { MasterPasswordApiService } from "@bitwarden/common/auth/abstractions/master-password-api.service.abstraction";
|
||||
import { SsoLoginServiceAbstraction } from "@bitwarden/common/auth/abstractions/sso-login.service.abstraction";
|
||||
import { OrganizationInviteService } from "@bitwarden/common/auth/services/organization-invite/organization-invite.service";
|
||||
import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service";
|
||||
import { ClientType } from "@bitwarden/common/enums";
|
||||
import { ProcessReloadServiceAbstraction } from "@bitwarden/common/key-management/abstractions/process-reload.service";
|
||||
import { CryptoFunctionService } from "@bitwarden/common/key-management/crypto/abstractions/crypto-function.service";
|
||||
@@ -94,6 +95,7 @@ import { NoopSdkLoadService } from "@bitwarden/common/platform/services/sdk/noop
|
||||
import { StorageServiceProvider } from "@bitwarden/common/platform/services/storage-service.provider";
|
||||
import { GlobalStateProvider, StateProvider } from "@bitwarden/common/platform/state";
|
||||
import { WindowStorageService } from "@bitwarden/common/platform/storage/window-storage.service";
|
||||
import { SyncService } from "@bitwarden/common/platform/sync/sync.service";
|
||||
import {
|
||||
DefaultThemeStateService,
|
||||
ThemeStateService,
|
||||
@@ -408,7 +410,16 @@ const safeProviders: SafeProvider[] = [
|
||||
safeProvider({
|
||||
provide: PremiumUpgradePromptService,
|
||||
useClass: WebVaultPremiumUpgradePromptService,
|
||||
deps: [DialogService, Router],
|
||||
deps: [
|
||||
DialogService,
|
||||
ConfigService,
|
||||
AccountService,
|
||||
ApiService,
|
||||
SyncService,
|
||||
BillingAccountProfileStateService,
|
||||
PlatformUtilsService,
|
||||
Router,
|
||||
],
|
||||
}),
|
||||
];
|
||||
|
||||
|
||||
@@ -15,14 +15,12 @@
|
||||
<h3 class="tw-mb-4 tw-text-xl tw-font-bold">{{ title }}</h3>
|
||||
<p class="tw-mb-0">{{ description }}</p>
|
||||
</bit-card-content>
|
||||
<span
|
||||
bitBadge
|
||||
[variant]="requiresPremium ? 'success' : 'primary'"
|
||||
class="tw-absolute tw-left-2 tw-top-2 tw-leading-none"
|
||||
*ngIf="disabled"
|
||||
>
|
||||
<ng-container *ngIf="requiresPremium">{{ "premium" | i18n }}</ng-container>
|
||||
<ng-container *ngIf="!requiresPremium">{{ "upgrade" | i18n }}</ng-container>
|
||||
</span>
|
||||
@if (requiresPremium) {
|
||||
<app-premium-badge class="tw-absolute tw-left-2 tw-top-2"></app-premium-badge>
|
||||
} @else if (requiresUpgrade) {
|
||||
<span bitBadge variant="primary" class="tw-absolute tw-left-2 tw-top-2">
|
||||
{{ "upgrade" | i18n }}
|
||||
</span>
|
||||
}
|
||||
</bit-base-card>
|
||||
</a>
|
||||
|
||||
@@ -37,4 +37,8 @@ export class ReportCardComponent {
|
||||
protected get requiresPremium() {
|
||||
return this.variant == ReportVariant.RequiresPremium;
|
||||
}
|
||||
|
||||
protected get requiresUpgrade() {
|
||||
return this.variant == ReportVariant.RequiresUpgrade;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,14 +1,20 @@
|
||||
import { importProvidersFrom } from "@angular/core";
|
||||
import { RouterTestingModule } from "@angular/router/testing";
|
||||
import { Meta, StoryObj, applicationConfig, moduleMetadata } from "@storybook/angular";
|
||||
import { applicationConfig, Meta, moduleMetadata, StoryObj } from "@storybook/angular";
|
||||
import { of } from "rxjs";
|
||||
|
||||
import { PremiumBadgeComponent } from "@bitwarden/angular/billing/components/premium-badge";
|
||||
import { JslibModule } from "@bitwarden/angular/jslib.module";
|
||||
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||
import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { PremiumUpgradePromptService } from "@bitwarden/common/vault/abstractions/premium-upgrade-prompt.service";
|
||||
import {
|
||||
BadgeModule,
|
||||
BaseCardComponent,
|
||||
IconModule,
|
||||
CardContentComponent,
|
||||
I18nMockService,
|
||||
IconModule,
|
||||
} from "@bitwarden/components";
|
||||
|
||||
import { PreloadedEnglishI18nModule } from "../../../../core/tests";
|
||||
@@ -30,6 +36,37 @@ export default {
|
||||
PremiumBadgeComponent,
|
||||
BaseCardComponent,
|
||||
],
|
||||
providers: [
|
||||
{
|
||||
provide: AccountService,
|
||||
useValue: {
|
||||
activeAccount$: of({
|
||||
id: "123",
|
||||
}),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: I18nService,
|
||||
useFactory: () => {
|
||||
return new I18nMockService({
|
||||
premium: "Premium",
|
||||
upgrade: "Upgrade",
|
||||
});
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: BillingAccountProfileStateService,
|
||||
useValue: {
|
||||
hasPremiumFromAnySource$: () => of(false),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: PremiumUpgradePromptService,
|
||||
useValue: {
|
||||
promptForPremium: (orgId?: string) => {},
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
applicationConfig({
|
||||
providers: [importProvidersFrom(PreloadedEnglishI18nModule)],
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
import { importProvidersFrom } from "@angular/core";
|
||||
import { RouterTestingModule } from "@angular/router/testing";
|
||||
import { Meta, StoryObj, applicationConfig, moduleMetadata } from "@storybook/angular";
|
||||
import { applicationConfig, Meta, moduleMetadata, StoryObj } from "@storybook/angular";
|
||||
import { of } from "rxjs";
|
||||
|
||||
import { PremiumBadgeComponent } from "@bitwarden/angular/billing/components/premium-badge";
|
||||
import { JslibModule } from "@bitwarden/angular/jslib.module";
|
||||
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||
import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions";
|
||||
import { PremiumUpgradePromptService } from "@bitwarden/common/vault/abstractions/premium-upgrade-prompt.service";
|
||||
import {
|
||||
BadgeModule,
|
||||
BaseCardComponent,
|
||||
@@ -33,6 +37,28 @@ export default {
|
||||
BaseCardComponent,
|
||||
],
|
||||
declarations: [ReportCardComponent],
|
||||
providers: [
|
||||
{
|
||||
provide: AccountService,
|
||||
useValue: {
|
||||
activeAccount$: of({
|
||||
id: "123",
|
||||
}),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: BillingAccountProfileStateService,
|
||||
useValue: {
|
||||
hasPremiumFromAnySource$: () => of(false),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: PremiumUpgradePromptService,
|
||||
useValue: {
|
||||
promptForPremium: (orgId?: string) => {},
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
applicationConfig({
|
||||
providers: [importProvidersFrom(PreloadedEnglishI18nModule)],
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { CommonModule } from "@angular/common";
|
||||
import { NgModule } from "@angular/core";
|
||||
|
||||
import { PremiumBadgeComponent } from "@bitwarden/angular/billing/components/premium-badge";
|
||||
import { BaseCardComponent, CardContentComponent } from "@bitwarden/components";
|
||||
|
||||
import { SharedModule } from "../../../shared/shared.module";
|
||||
@@ -9,7 +10,13 @@ import { ReportCardComponent } from "./report-card/report-card.component";
|
||||
import { ReportListComponent } from "./report-list/report-list.component";
|
||||
|
||||
@NgModule({
|
||||
imports: [CommonModule, SharedModule, BaseCardComponent, CardContentComponent],
|
||||
imports: [
|
||||
CommonModule,
|
||||
SharedModule,
|
||||
BaseCardComponent,
|
||||
CardContentComponent,
|
||||
PremiumBadgeComponent,
|
||||
],
|
||||
declarations: [ReportCardComponent, ReportListComponent],
|
||||
exports: [ReportCardComponent, ReportListComponent],
|
||||
})
|
||||
|
||||
@@ -6,11 +6,14 @@ import { ApiService } from "@bitwarden/common/abstractions/api.service";
|
||||
import { EventCollectionService } from "@bitwarden/common/abstractions/event/event-collection.service";
|
||||
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||
import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions";
|
||||
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
|
||||
import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service";
|
||||
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
|
||||
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
|
||||
import { PremiumUpgradePromptService } from "@bitwarden/common/vault/abstractions/premium-upgrade-prompt.service";
|
||||
import { SyncService } from "@bitwarden/common/vault/abstractions/sync/sync.service.abstraction";
|
||||
import { CipherType } from "@bitwarden/common/vault/enums";
|
||||
import { CipherAuthorizationService } from "@bitwarden/common/vault/services/cipher-authorization.service";
|
||||
import { DialogRef, DIALOG_DATA, DialogService, ToastService } from "@bitwarden/components";
|
||||
@@ -73,6 +76,7 @@ describe("VaultItemDialogComponent", () => {
|
||||
{ provide: LogService, useValue: {} },
|
||||
{ provide: CipherService, useValue: {} },
|
||||
{ provide: AccountService, useValue: { activeAccount$: { pipe: () => ({}) } } },
|
||||
{ provide: ConfigService, useValue: { getFeatureFlag: () => Promise.resolve(false) } },
|
||||
{ provide: Router, useValue: {} },
|
||||
{ provide: ActivatedRoute, useValue: {} },
|
||||
{
|
||||
@@ -84,6 +88,8 @@ describe("VaultItemDialogComponent", () => {
|
||||
{ provide: ApiService, useValue: {} },
|
||||
{ provide: EventCollectionService, useValue: {} },
|
||||
{ provide: RoutedVaultFilterService, useValue: {} },
|
||||
{ provide: SyncService, useValue: {} },
|
||||
{ provide: PlatformUtilsService, useValue: {} },
|
||||
],
|
||||
}).compileComponents();
|
||||
|
||||
|
||||
@@ -65,6 +65,7 @@ import { SyncService } from "@bitwarden/common/platform/sync";
|
||||
import { CipherId, CollectionId, OrganizationId, UserId } from "@bitwarden/common/types/guid";
|
||||
import { CipherArchiveService } from "@bitwarden/common/vault/abstractions/cipher-archive.service";
|
||||
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
|
||||
import { PremiumUpgradePromptService } from "@bitwarden/common/vault/abstractions/premium-upgrade-prompt.service";
|
||||
import { SearchService } from "@bitwarden/common/vault/abstractions/search.service";
|
||||
import { TotpService } from "@bitwarden/common/vault/abstractions/totp.service";
|
||||
import { CipherType } from "@bitwarden/common/vault/enums";
|
||||
@@ -326,6 +327,7 @@ export class VaultComponent<C extends CipherViewLike> implements OnInit, OnDestr
|
||||
private organizationWarningsService: OrganizationWarningsService,
|
||||
private policyService: PolicyService,
|
||||
private unifiedUpgradePromptService: UnifiedUpgradePromptService,
|
||||
private premiumUpgradePromptService: PremiumUpgradePromptService,
|
||||
) {}
|
||||
|
||||
async ngOnInit() {
|
||||
@@ -867,7 +869,7 @@ export class VaultComponent<C extends CipherViewLike> implements OnInit, OnDestr
|
||||
}
|
||||
|
||||
if (cipher.organizationId == null && !this.canAccessPremium) {
|
||||
this.messagingService.send("premiumRequired");
|
||||
await this.premiumUpgradePromptService.promptForPremium();
|
||||
return;
|
||||
} else if (cipher.organizationId != null) {
|
||||
const org = await firstValueFrom(
|
||||
|
||||
@@ -2,8 +2,19 @@ import { TestBed } from "@angular/core/testing";
|
||||
import { Router } from "@angular/router";
|
||||
import { lastValueFrom, of } from "rxjs";
|
||||
|
||||
import { ApiService } from "@bitwarden/common/abstractions/api.service";
|
||||
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||
import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions";
|
||||
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
|
||||
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
|
||||
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
|
||||
import { SyncService } from "@bitwarden/common/platform/sync";
|
||||
import { OrganizationId } from "@bitwarden/common/types/guid";
|
||||
import { DialogRef, DialogService } from "@bitwarden/components";
|
||||
import {
|
||||
UnifiedUpgradeDialogComponent,
|
||||
UnifiedUpgradeDialogStatus,
|
||||
} from "@bitwarden/web-vault/app/billing/individual/upgrade/unified-upgrade-dialog/unified-upgrade-dialog.component";
|
||||
|
||||
import { VaultItemDialogResult } from "../components/vault-item-dialog/vault-item-dialog.component";
|
||||
|
||||
@@ -13,13 +24,27 @@ describe("WebVaultPremiumUpgradePromptService", () => {
|
||||
let service: WebVaultPremiumUpgradePromptService;
|
||||
let dialogServiceMock: jest.Mocked<DialogService>;
|
||||
let routerMock: jest.Mocked<Router>;
|
||||
let dialogRefMock: jest.Mocked<DialogRef<VaultItemDialogResult>>;
|
||||
let dialogRefMock: jest.Mocked<DialogRef>;
|
||||
let configServiceMock: jest.Mocked<ConfigService>;
|
||||
let accountServiceMock: jest.Mocked<AccountService>;
|
||||
let apiServiceMock: jest.Mocked<ApiService>;
|
||||
let syncServiceMock: jest.Mocked<SyncService>;
|
||||
let billingAccountProfileServiceMock: jest.Mocked<BillingAccountProfileStateService>;
|
||||
let platformUtilsServiceMock: jest.Mocked<PlatformUtilsService>;
|
||||
|
||||
beforeEach(() => {
|
||||
dialogServiceMock = {
|
||||
openSimpleDialog: jest.fn(),
|
||||
} as unknown as jest.Mocked<DialogService>;
|
||||
|
||||
configServiceMock = {
|
||||
getFeatureFlag: jest.fn().mockReturnValue(false),
|
||||
} as unknown as jest.Mocked<ConfigService>;
|
||||
|
||||
accountServiceMock = {
|
||||
activeAccount$: of({ id: "user-123" }),
|
||||
} as unknown as jest.Mocked<AccountService>;
|
||||
|
||||
routerMock = {
|
||||
navigate: jest.fn(),
|
||||
} as unknown as jest.Mocked<Router>;
|
||||
@@ -28,12 +53,34 @@ describe("WebVaultPremiumUpgradePromptService", () => {
|
||||
close: jest.fn(),
|
||||
} as unknown as jest.Mocked<DialogRef<VaultItemDialogResult>>;
|
||||
|
||||
apiServiceMock = {
|
||||
refreshIdentityToken: jest.fn().mockReturnValue({}),
|
||||
} as unknown as jest.Mocked<ApiService>;
|
||||
|
||||
syncServiceMock = {
|
||||
fullSync: jest.fn(),
|
||||
} as unknown as jest.Mocked<SyncService>;
|
||||
|
||||
billingAccountProfileServiceMock = {
|
||||
hasPremiumFromAnySource$: jest.fn().mockReturnValue(of(false)),
|
||||
} as unknown as jest.Mocked<BillingAccountProfileStateService>;
|
||||
|
||||
platformUtilsServiceMock = {
|
||||
isSelfHost: jest.fn().mockReturnValue(false),
|
||||
} as unknown as jest.Mocked<PlatformUtilsService>;
|
||||
|
||||
TestBed.configureTestingModule({
|
||||
providers: [
|
||||
WebVaultPremiumUpgradePromptService,
|
||||
{ provide: DialogService, useValue: dialogServiceMock },
|
||||
{ provide: Router, useValue: routerMock },
|
||||
{ provide: DialogRef, useValue: dialogRefMock },
|
||||
{ provide: ConfigService, useValue: configServiceMock },
|
||||
{ provide: AccountService, useValue: accountServiceMock },
|
||||
{ provide: ApiService, useValue: apiServiceMock },
|
||||
{ provide: SyncService, useValue: syncServiceMock },
|
||||
{ provide: BillingAccountProfileStateService, useValue: billingAccountProfileServiceMock },
|
||||
{ provide: PlatformUtilsService, useValue: platformUtilsServiceMock },
|
||||
],
|
||||
});
|
||||
|
||||
@@ -84,4 +131,144 @@ describe("WebVaultPremiumUpgradePromptService", () => {
|
||||
expect(routerMock.navigate).not.toHaveBeenCalled();
|
||||
expect(dialogRefMock.close).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
describe("premium status check", () => {
|
||||
it("should not prompt if user already has premium (feature flag off)", async () => {
|
||||
configServiceMock.getFeatureFlag.mockReturnValue(Promise.resolve(false));
|
||||
billingAccountProfileServiceMock.hasPremiumFromAnySource$.mockReturnValue(of(true));
|
||||
|
||||
await service.promptForPremium();
|
||||
|
||||
expect(dialogServiceMock.openSimpleDialog).not.toHaveBeenCalled();
|
||||
expect(routerMock.navigate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should not prompt if user already has premium (feature flag on)", async () => {
|
||||
configServiceMock.getFeatureFlag.mockImplementation((flag: FeatureFlag) => {
|
||||
if (flag === FeatureFlag.PM23713_PremiumBadgeOpensNewPremiumUpgradeDialog) {
|
||||
return Promise.resolve(true);
|
||||
}
|
||||
return Promise.resolve(false);
|
||||
});
|
||||
billingAccountProfileServiceMock.hasPremiumFromAnySource$.mockReturnValue(of(true));
|
||||
|
||||
const unifiedDialogRefMock = {
|
||||
closed: of({ status: UnifiedUpgradeDialogStatus.Closed }),
|
||||
close: jest.fn(),
|
||||
} as any;
|
||||
jest.spyOn(UnifiedUpgradeDialogComponent, "open").mockReturnValue(unifiedDialogRefMock);
|
||||
|
||||
await service.promptForPremium();
|
||||
|
||||
expect(UnifiedUpgradeDialogComponent.open).not.toHaveBeenCalled();
|
||||
expect(dialogServiceMock.openSimpleDialog).not.toHaveBeenCalled();
|
||||
expect(routerMock.navigate).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("new premium upgrade dialog with post-upgrade actions", () => {
|
||||
beforeEach(() => {
|
||||
configServiceMock.getFeatureFlag.mockImplementation((flag: FeatureFlag) => {
|
||||
if (flag === FeatureFlag.PM23713_PremiumBadgeOpensNewPremiumUpgradeDialog) {
|
||||
return Promise.resolve(true);
|
||||
}
|
||||
return Promise.resolve(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("when self-hosted", () => {
|
||||
beforeEach(() => {
|
||||
platformUtilsServiceMock.isSelfHost.mockReturnValue(true);
|
||||
});
|
||||
|
||||
it("should navigate to subscription page instead of opening dialog", async () => {
|
||||
await service.promptForPremium();
|
||||
|
||||
expect(routerMock.navigate).toHaveBeenCalledWith(["settings/subscription/premium"]);
|
||||
expect(dialogServiceMock.openSimpleDialog).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("when not self-hosted", () => {
|
||||
beforeEach(() => {
|
||||
platformUtilsServiceMock.isSelfHost.mockReturnValue(false);
|
||||
});
|
||||
|
||||
it("should full sync when user upgrades to premium", async () => {
|
||||
const unifiedDialogRefMock = {
|
||||
closed: of({ status: UnifiedUpgradeDialogStatus.UpgradedToPremium }),
|
||||
close: jest.fn(),
|
||||
} as any;
|
||||
jest.spyOn(UnifiedUpgradeDialogComponent, "open").mockReturnValue(unifiedDialogRefMock);
|
||||
|
||||
await service.promptForPremium();
|
||||
|
||||
expect(UnifiedUpgradeDialogComponent.open).toHaveBeenCalledWith(dialogServiceMock, {
|
||||
data: {
|
||||
account: { id: "user-123" },
|
||||
planSelectionStepTitleOverride: "upgradeYourPlan",
|
||||
hideContinueWithoutUpgradingButton: true,
|
||||
},
|
||||
});
|
||||
expect(syncServiceMock.fullSync).toHaveBeenCalledWith(true);
|
||||
});
|
||||
|
||||
it("should full sync when user upgrades to families", async () => {
|
||||
const unifiedDialogRefMock = {
|
||||
closed: of({ status: UnifiedUpgradeDialogStatus.UpgradedToFamilies }),
|
||||
close: jest.fn(),
|
||||
} as any;
|
||||
jest.spyOn(UnifiedUpgradeDialogComponent, "open").mockReturnValue(unifiedDialogRefMock);
|
||||
|
||||
await service.promptForPremium();
|
||||
|
||||
expect(UnifiedUpgradeDialogComponent.open).toHaveBeenCalledWith(dialogServiceMock, {
|
||||
data: {
|
||||
account: { id: "user-123" },
|
||||
planSelectionStepTitleOverride: "upgradeYourPlan",
|
||||
hideContinueWithoutUpgradingButton: true,
|
||||
},
|
||||
});
|
||||
expect(syncServiceMock.fullSync).toHaveBeenCalledWith(true);
|
||||
});
|
||||
|
||||
it("should not refresh or sync when user closes dialog without upgrading", async () => {
|
||||
const unifiedDialogRefMock = {
|
||||
closed: of({ status: UnifiedUpgradeDialogStatus.Closed }),
|
||||
close: jest.fn(),
|
||||
} as any;
|
||||
jest.spyOn(UnifiedUpgradeDialogComponent, "open").mockReturnValue(unifiedDialogRefMock);
|
||||
|
||||
await service.promptForPremium();
|
||||
|
||||
expect(UnifiedUpgradeDialogComponent.open).toHaveBeenCalledWith(dialogServiceMock, {
|
||||
data: {
|
||||
account: { id: "user-123" },
|
||||
planSelectionStepTitleOverride: "upgradeYourPlan",
|
||||
hideContinueWithoutUpgradingButton: true,
|
||||
},
|
||||
});
|
||||
expect(apiServiceMock.refreshIdentityToken).not.toHaveBeenCalled();
|
||||
expect(syncServiceMock.fullSync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should not open new dialog if organizationId is provided", async () => {
|
||||
const organizationId = "test-org-id" as OrganizationId;
|
||||
dialogServiceMock.openSimpleDialog.mockReturnValue(lastValueFrom(of(true)));
|
||||
|
||||
const openSpy = jest.spyOn(UnifiedUpgradeDialogComponent, "open");
|
||||
openSpy.mockClear();
|
||||
|
||||
await service.promptForPremium(organizationId);
|
||||
|
||||
expect(openSpy).not.toHaveBeenCalled();
|
||||
expect(dialogServiceMock.openSimpleDialog).toHaveBeenCalledWith({
|
||||
title: { key: "upgradeOrganization" },
|
||||
content: { key: "upgradeOrganizationDesc" },
|
||||
acceptButtonText: { key: "upgradeOrganization" },
|
||||
type: "info",
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,10 +1,21 @@
|
||||
import { Injectable, Optional } from "@angular/core";
|
||||
import { Router } from "@angular/router";
|
||||
import { Subject } from "rxjs";
|
||||
import { firstValueFrom, lastValueFrom, Subject } from "rxjs";
|
||||
|
||||
import { ApiService } from "@bitwarden/common/abstractions/api.service";
|
||||
import { Account, AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||
import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions";
|
||||
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
|
||||
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
|
||||
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
|
||||
import { SyncService } from "@bitwarden/common/platform/sync";
|
||||
import { OrganizationId } from "@bitwarden/common/types/guid";
|
||||
import { PremiumUpgradePromptService } from "@bitwarden/common/vault/abstractions/premium-upgrade-prompt.service";
|
||||
import { DialogRef, DialogService } from "@bitwarden/components";
|
||||
import {
|
||||
UnifiedUpgradeDialogComponent,
|
||||
UnifiedUpgradeDialogStatus,
|
||||
} from "@bitwarden/web-vault/app/billing/individual/upgrade/unified-upgrade-dialog/unified-upgrade-dialog.component";
|
||||
|
||||
import { VaultItemDialogResult } from "../components/vault-item-dialog/vault-item-dialog.component";
|
||||
|
||||
@@ -15,14 +26,44 @@ export class WebVaultPremiumUpgradePromptService implements PremiumUpgradePrompt
|
||||
|
||||
constructor(
|
||||
private dialogService: DialogService,
|
||||
private configService: ConfigService,
|
||||
private accountService: AccountService,
|
||||
private apiService: ApiService,
|
||||
private syncService: SyncService,
|
||||
private billingAccountProfileStateService: BillingAccountProfileStateService,
|
||||
private platformUtilsService: PlatformUtilsService,
|
||||
private router: Router,
|
||||
@Optional() private dialog?: DialogRef<VaultItemDialogResult>,
|
||||
) {}
|
||||
private readonly subscriptionPageRoute = "settings/subscription/premium";
|
||||
|
||||
/**
|
||||
* Prompts the user for a premium upgrade.
|
||||
*/
|
||||
async promptForPremium(organizationId?: OrganizationId) {
|
||||
const account = await firstValueFrom(this.accountService.activeAccount$);
|
||||
if (!account) {
|
||||
return;
|
||||
}
|
||||
const hasPremium = await firstValueFrom(
|
||||
this.billingAccountProfileStateService.hasPremiumFromAnySource$(account.id),
|
||||
);
|
||||
if (hasPremium) {
|
||||
// Already has premium, don't prompt
|
||||
return;
|
||||
}
|
||||
|
||||
const showNewDialog = await this.configService.getFeatureFlag(
|
||||
FeatureFlag.PM23713_PremiumBadgeOpensNewPremiumUpgradeDialog,
|
||||
);
|
||||
|
||||
// Per conversation in PM-23713, retain the existing upgrade org flow for now, will be addressed
|
||||
// as a part of https://bitwarden.atlassian.net/browse/PM-25507
|
||||
if (showNewDialog && !organizationId) {
|
||||
await this.promptForPremiumVNext(account);
|
||||
return;
|
||||
}
|
||||
|
||||
let confirmed = false;
|
||||
let route: string[] | null = null;
|
||||
|
||||
@@ -44,7 +85,7 @@ export class WebVaultPremiumUpgradePromptService implements PremiumUpgradePrompt
|
||||
type: "success",
|
||||
});
|
||||
if (confirmed) {
|
||||
route = ["settings/subscription/premium"];
|
||||
route = [this.subscriptionPageRoute];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -57,4 +98,31 @@ export class WebVaultPremiumUpgradePromptService implements PremiumUpgradePrompt
|
||||
this.dialog.close(VaultItemDialogResult.PremiumUpgrade);
|
||||
}
|
||||
}
|
||||
|
||||
private async promptForPremiumVNext(account: Account) {
|
||||
await (this.platformUtilsService.isSelfHost()
|
||||
? this.redirectToSubscriptionPage()
|
||||
: this.openUpgradeDialog(account));
|
||||
}
|
||||
|
||||
private async redirectToSubscriptionPage() {
|
||||
await this.router.navigate([this.subscriptionPageRoute]);
|
||||
}
|
||||
|
||||
private async openUpgradeDialog(account: Account) {
|
||||
const dialogRef = UnifiedUpgradeDialogComponent.open(this.dialogService, {
|
||||
data: {
|
||||
account,
|
||||
planSelectionStepTitleOverride: "upgradeYourPlan",
|
||||
hideContinueWithoutUpgradingButton: true,
|
||||
},
|
||||
});
|
||||
const result = await lastValueFrom(dialogRef.closed);
|
||||
if (
|
||||
result?.status === UnifiedUpgradeDialogStatus.UpgradedToPremium ||
|
||||
result?.status === UnifiedUpgradeDialogStatus.UpgradedToFamilies
|
||||
) {
|
||||
await this.syncService.fullSync(true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1 +1,2 @@
|
||||
export * from "./premium.component";
|
||||
export * from "./premium-upgrade-dialog/premium-upgrade-dialog.component";
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
@@ -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}`),
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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 = {};
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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[]>;
|
||||
}
|
||||
@@ -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
|
||||
@@ -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",
|
||||
@@ -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,
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
@@ -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),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user