diff --git a/apps/browser/src/_locales/en/messages.json b/apps/browser/src/_locales/en/messages.json index 29601bfa70cb..5a09b9e08174 100644 --- a/apps/browser/src/_locales/en/messages.json +++ b/apps/browser/src/_locales/en/messages.json @@ -5718,6 +5718,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." diff --git a/apps/browser/src/vault/popup/components/vault-v2/attachments/open-attachments/open-attachments.component.spec.ts b/apps/browser/src/vault/popup/components/vault-v2/attachments/open-attachments/open-attachments.component.spec.ts index a2045736ce2d..459b328c44e8 100644 --- a/apps/browser/src/vault/popup/components/vault-v2/attachments/open-attachments/open-attachments.component.spec.ts +++ b/apps/browser/src/vault/popup/components/vault-v2/attachments/open-attachments/open-attachments.component.spec.ts @@ -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", () => { diff --git a/apps/browser/src/vault/popup/components/vault-v2/attachments/open-attachments/open-attachments.component.ts b/apps/browser/src/vault/popup/components/vault-v2/attachments/open-attachments/open-attachments.component.ts index e2af3c44c7ee..a267e7999abe 100644 --- a/apps/browser/src/vault/popup/components/vault-v2/attachments/open-attachments/open-attachments.component.ts +++ b/apps/browser/src/vault/popup/components/vault-v2/attachments/open-attachments/open-attachments.component.ts @@ -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; } diff --git a/apps/browser/src/vault/popup/services/browser-premium-upgrade-prompt.service.spec.ts b/apps/browser/src/vault/popup/services/browser-premium-upgrade-prompt.service.spec.ts index 9a00bacd6b0e..bf63cf1f668a 100644 --- a/apps/browser/src/vault/popup/services/browser-premium-upgrade-prompt.service.spec.ts +++ b/apps/browser/src/vault/popup/services/browser-premium-upgrade-prompt.service.spec.ts @@ -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; + let configService: MockProxy; + let dialogService: MockProxy; beforeEach(async () => { router = mock(); + configService = mock(); + dialogService = mock(); + 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(); }); }); }); diff --git a/apps/browser/src/vault/popup/services/browser-premium-upgrade-prompt.service.ts b/apps/browser/src/vault/popup/services/browser-premium-upgrade-prompt.service.ts index 2909e3b3bd61..53f7ffd5f5ae 100644 --- a/apps/browser/src/vault/popup/services/browser-premium-upgrade-prompt.service.ts +++ b/apps/browser/src/vault/popup/services/browser-premium-upgrade-prompt.service.ts @@ -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"]); + } } } diff --git a/apps/browser/tailwind.config.js b/apps/browser/tailwind.config.js index 1ad56562bb3d..134001bbf134 100644 --- a/apps/browser/tailwind.config.js +++ b/apps/browser/tailwind.config.js @@ -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; diff --git a/apps/desktop/src/app/app.component.ts b/apps/desktop/src/app/app.component.ts index 7f7eddcfe950..20d7df3e6c1b 100644 --- a/apps/desktop/src/app/app.component.ts +++ b/apps/desktop/src/app/app.component.ts @@ -350,18 +350,6 @@ export class AppComponent implements OnInit, OnDestroy { }); break; } - case "premiumRequired": { - const premiumConfirmed = await this.dialogService.openSimpleDialog({ - title: { key: "premiumRequired" }, - content: { key: "premiumRequiredDesc" }, - acceptButtonText: { key: "learnMore" }, - type: "success", - }); - if (premiumConfirmed) { - await this.openModal(PremiumComponent, this.premiumRef); - } - break; - } case "emailVerificationRequired": { const emailVerificationConfirmed = await this.dialogService.openSimpleDialog({ title: { key: "emailVerificationRequired" }, diff --git a/apps/desktop/src/app/tools/send/add-edit.component.ts b/apps/desktop/src/app/tools/send/add-edit.component.ts index b817adda848d..076b0f6c9d5f 100644 --- a/apps/desktop/src/app/tools/send/add-edit.component.ts +++ b/apps/desktop/src/app/tools/send/add-edit.component.ts @@ -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, ); } diff --git a/apps/desktop/src/locales/en/messages.json b/apps/desktop/src/locales/en/messages.json index 32545a0c1cd9..a83ad8ef75b6 100644 --- a/apps/desktop/src/locales/en/messages.json +++ b/apps/desktop/src/locales/en/messages.json @@ -4187,5 +4187,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" } } diff --git a/apps/desktop/src/services/desktop-premium-upgrade-prompt.service.spec.ts b/apps/desktop/src/services/desktop-premium-upgrade-prompt.service.spec.ts index 3b33116ea5ae..1eee4cd54f60 100644 --- a/apps/desktop/src/services/desktop-premium-upgrade-prompt.service.spec.ts +++ b/apps/desktop/src/services/desktop-premium-upgrade-prompt.service.spec.ts @@ -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; + let configService: MockProxy; + let dialogService: MockProxy; beforeEach(async () => { messager = mock(); + configService = mock(); + dialogService = mock(); + 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(); }); }); }); diff --git a/apps/desktop/src/services/desktop-premium-upgrade-prompt.service.ts b/apps/desktop/src/services/desktop-premium-upgrade-prompt.service.ts index f2375ecfebb7..5004e5ed547e 100644 --- a/apps/desktop/src/services/desktop-premium-upgrade-prompt.service.ts +++ b/apps/desktop/src/services/desktop-premium-upgrade-prompt.service.ts @@ -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"); + } } } diff --git a/apps/desktop/tailwind.config.js b/apps/desktop/tailwind.config.js index bf65ae8d7cbd..e67c0c38010a 100644 --- a/apps/desktop/tailwind.config.js +++ b/apps/desktop/tailwind.config.js @@ -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; diff --git a/apps/web/src/app/app.component.ts b/apps/web/src/app/app.component.ts index 60911173308a..939e942aa87e 100644 --- a/apps/web/src/app/app.component.ts +++ b/apps/web/src/app/app.component.ts @@ -145,18 +145,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" }, diff --git a/apps/web/src/app/auth/settings/emergency-access/emergency-access.component.ts b/apps/web/src/app/auth/settings/emergency-access/emergency-access.component.ts index c2b8127ec344..7ef94706ef6d 100644 --- a/apps/web/src/app/auth/settings/emergency-access/emergency-access.component.ts +++ b/apps/web/src/app/auth/settings/emergency-access/emergency-access.component.ts @@ -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, { diff --git a/apps/web/src/app/auth/settings/two-factor/two-factor-setup.component.ts b/apps/web/src/app/auth/settings/two-factor/two-factor-setup.component.ts index ef4d647a7d0c..024455cc1bf8 100644 --- a/apps/web/src/app/auth/settings/two-factor/two-factor-setup.component.ts +++ b/apps/web/src/app/auth/settings/two-factor/two-factor-setup.component.ts @@ -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(); } diff --git a/apps/web/src/app/billing/guards/has-premium.guard.ts b/apps/web/src/app/billing/guards/has-premium.guard.ts index 61853b25cb8a..f10e75d8268f 100644 --- a/apps/web/src/app/billing/guards/has-premium.guard.ts +++ b/apps/web/src/app/billing/guards/has-premium.guard.ts @@ -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 => { 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) => { diff --git a/apps/web/src/app/billing/individual/premium/premium-vnext.component.ts b/apps/web/src/app/billing/individual/premium/premium-vnext.component.ts index d25e035d1be4..334e84d14511 100644 --- a/apps/web/src/app/billing/individual/premium/premium-vnext.component.ts +++ b/apps/web/src/app/billing/individual/premium/premium-vnext.component.ts @@ -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, ) { diff --git a/apps/web/src/app/billing/individual/premium/premium.component.ts b/apps/web/src/app/billing/individual/premium/premium.component.ts index 6754f4c9f501..62d62331b94c 100644 --- a/apps/web/src/app/billing/individual/premium/premium.component.ts +++ b/apps/web/src/app/billing/individual/premium/premium.component.ts @@ -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(); diff --git a/apps/web/src/app/billing/individual/upgrade/unified-upgrade-dialog/unified-upgrade-dialog.component.spec.ts b/apps/web/src/app/billing/individual/upgrade/unified-upgrade-dialog/unified-upgrade-dialog.component.spec.ts index d09602517244..32c67df14347 100644 --- a/apps/web/src/app/billing/individual/upgrade/unified-upgrade-dialog/unified-upgrade-dialog.component.spec.ts +++ b/apps/web/src/app/billing/individual/upgrade/unified-upgrade-dialog/unified-upgrade-dialog.component.spec.ts @@ -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, diff --git a/apps/web/src/app/billing/individual/upgrade/unified-upgrade-dialog/unified-upgrade-dialog.component.ts b/apps/web/src/app/billing/individual/upgrade/unified-upgrade-dialog/unified-upgrade-dialog.component.ts index 077490cef435..07b21a9fb4b4 100644 --- a/apps/web/src/app/billing/individual/upgrade/unified-upgrade-dialog/unified-upgrade-dialog.component.ts +++ b/apps/web/src/app/billing/individual/upgrade/unified-upgrade-dialog/unified-upgrade-dialog.component.ts @@ -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 { diff --git a/apps/web/src/app/billing/individual/upgrade/upgrade-account/upgrade-account.component.spec.ts b/apps/web/src/app/billing/individual/upgrade/upgrade-account/upgrade-account.component.spec.ts index 27e69fcf0d4b..c8cc731a03b7 100644 --- a/apps/web/src/app/billing/individual/upgrade/upgrade-account/upgrade-account.component.spec.ts +++ b/apps/web/src/app/billing/individual/upgrade/upgrade-account/upgrade-account.component.spec.ts @@ -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; const mockI18nService = mock(); - const mockSubscriptionPricingService = mock(); + const mockSubscriptionPricingService = mock(); // 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, { diff --git a/apps/web/src/app/billing/individual/upgrade/upgrade-account/upgrade-account.component.ts b/apps/web/src/app/billing/individual/upgrade/upgrade-account/upgrade-account.component.ts index be09505d190a..b3d98e72deff 100644 --- a/apps/web/src/app/billing/individual/upgrade/upgrade-account/upgrade-account.component.ts +++ b/apps/web/src/app/billing/individual/upgrade/upgrade-account/upgrade-account.component.ts @@ -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); diff --git a/apps/web/src/app/billing/individual/upgrade/upgrade-nav-button/upgrade-nav-button/upgrade-nav-button.component.spec.ts b/apps/web/src/app/billing/individual/upgrade/upgrade-nav-button/upgrade-nav-button/upgrade-nav-button.component.spec.ts index 11b1787e90e8..787936c102ef 100644 --- a/apps/web/src/app/billing/individual/upgrade/upgrade-nav-button/upgrade-nav-button/upgrade-nav-button.component.spec.ts +++ b/apps/web/src/app/billing/individual/upgrade/upgrade-nav-button/upgrade-nav-button/upgrade-nav-button.component.spec.ts @@ -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>(); mockDialogRef.closed = of({ status: UnifiedUpgradeDialogStatus.UpgradedToPremium }); mockDialogService.open.mockReturnValue(mockDialogRef); await component.upgrade(); - expect(mockApiService.refreshIdentityToken).toHaveBeenCalled(); expect(mockSyncService.fullSync).toHaveBeenCalledWith(true); }); diff --git a/apps/web/src/app/billing/individual/upgrade/upgrade-nav-button/upgrade-nav-button/upgrade-nav-button.component.ts b/apps/web/src/app/billing/individual/upgrade/upgrade-nav-button/upgrade-nav-button/upgrade-nav-button.component.ts index 57d3b996e904..4dda16674ff2 100644 --- a/apps/web/src/app/billing/individual/upgrade/upgrade-nav-button/upgrade-nav-button/upgrade-nav-button.component.ts +++ b/apps/web/src/app/billing/individual/upgrade/upgrade-nav-button/upgrade-nav-button/upgrade-nav-button.component.ts @@ -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`; diff --git a/apps/web/src/app/billing/individual/upgrade/upgrade-payment/services/upgrade-payment.service.spec.ts b/apps/web/src/app/billing/individual/upgrade/upgrade-payment/services/upgrade-payment.service.spec.ts index 614fc8625773..daca452c1741 100644 --- a/apps/web/src/app/billing/individual/upgrade/upgrade-payment/services/upgrade-payment.service.spec.ts +++ b/apps/web/src/app/billing/individual/upgrade/upgrade-payment/services/upgrade-payment.service.spec.ts @@ -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"; diff --git a/apps/web/src/app/billing/individual/upgrade/upgrade-payment/services/upgrade-payment.service.ts b/apps/web/src/app/billing/individual/upgrade/upgrade-payment/services/upgrade-payment.service.ts index e175363af335..d14a1e40796b 100644 --- a/apps/web/src/app/billing/individual/upgrade/upgrade-payment/services/upgrade-payment.service.ts +++ b/apps/web/src/app/billing/individual/upgrade/upgrade-payment/services/upgrade-payment.service.ts @@ -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; diff --git a/apps/web/src/app/billing/individual/upgrade/upgrade-payment/upgrade-payment.component.ts b/apps/web/src/app/billing/individual/upgrade/upgrade-payment/upgrade-payment.component.ts index a0ba480fe1eb..e9179be1ffbf 100644 --- a/apps/web/src/app/billing/individual/upgrade/upgrade-payment/upgrade-payment.component.ts +++ b/apps/web/src/app/billing/individual/upgrade/upgrade-payment/upgrade-payment.component.ts @@ -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()); - - 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 ? "upgradeToFamilies" : "upgradeToPremium", - ); - } else { - this.complete.emit({ status: UpgradePaymentStatus.Closed, organizationId: null }); - return; - } - }); + 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", + }; + + this.upgradeToMessage = this.i18nService.t( + this.isFamiliesPlan ? "upgradeToFamilies" : "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), diff --git a/apps/web/src/app/billing/organizations/change-plan-dialog.component.ts b/apps/web/src/app/billing/organizations/change-plan-dialog.component.ts index e2a30dd585c8..1440273dd4f4 100644 --- a/apps/web/src/app/billing/organizations/change-plan-dialog.component.ts +++ b/apps/web/src/app/billing/organizations/change-plan-dialog.component.ts @@ -801,7 +801,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) { diff --git a/apps/web/src/app/billing/organizations/organization-plans.component.ts b/apps/web/src/app/billing/organizations/organization-plans.component.ts index 7c081b382793..c626d4f50428 100644 --- a/apps/web/src/app/billing/organizations/organization-plans.component.ts +++ b/apps/web/src/app/billing/organizations/organization-plans.component.ts @@ -671,7 +671,6 @@ export class OrganizationPlansComponent implements OnInit, OnDestroy { }); } - await this.apiService.refreshIdentityToken(); await this.syncService.fullSync(true); if (!this.acceptingSponsorship && !this.isInTrialFlow) { diff --git a/apps/web/src/app/core/core.module.ts b/apps/web/src/app/core/core.module.ts index 29b84ddc3820..9619c3e23bf0 100644 --- a/apps/web/src/app/core/core.module.ts +++ b/apps/web/src/app/core/core.module.ts @@ -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, + ], }), ]; diff --git a/apps/web/src/app/dirt/reports/shared/report-card/report-card.component.html b/apps/web/src/app/dirt/reports/shared/report-card/report-card.component.html index dab928e6ec38..a6ae7a246aca 100644 --- a/apps/web/src/app/dirt/reports/shared/report-card/report-card.component.html +++ b/apps/web/src/app/dirt/reports/shared/report-card/report-card.component.html @@ -15,14 +15,12 @@

{{ title }}

{{ description }}

- - {{ "premium" | i18n }} - {{ "upgrade" | i18n }} - + @if (requiresPremium) { + + } @else if (requiresUpgrade) { + + {{ "upgrade" | i18n }} + + } diff --git a/apps/web/src/app/dirt/reports/shared/report-card/report-card.component.ts b/apps/web/src/app/dirt/reports/shared/report-card/report-card.component.ts index 565035c2c557..87c005ea46b2 100644 --- a/apps/web/src/app/dirt/reports/shared/report-card/report-card.component.ts +++ b/apps/web/src/app/dirt/reports/shared/report-card/report-card.component.ts @@ -37,4 +37,8 @@ export class ReportCardComponent { protected get requiresPremium() { return this.variant == ReportVariant.RequiresPremium; } + + protected get requiresUpgrade() { + return this.variant == ReportVariant.RequiresUpgrade; + } } diff --git a/apps/web/src/app/dirt/reports/shared/report-card/report-card.stories.ts b/apps/web/src/app/dirt/reports/shared/report-card/report-card.stories.ts index 50798fea6e10..93ea79c84186 100644 --- a/apps/web/src/app/dirt/reports/shared/report-card/report-card.stories.ts +++ b/apps/web/src/app/dirt/reports/shared/report-card/report-card.stories.ts @@ -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)], diff --git a/apps/web/src/app/dirt/reports/shared/report-list/report-list.stories.ts b/apps/web/src/app/dirt/reports/shared/report-list/report-list.stories.ts index 5a89eeff8033..5a95e3328160 100644 --- a/apps/web/src/app/dirt/reports/shared/report-list/report-list.stories.ts +++ b/apps/web/src/app/dirt/reports/shared/report-list/report-list.stories.ts @@ -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)], diff --git a/apps/web/src/app/dirt/reports/shared/reports-shared.module.ts b/apps/web/src/app/dirt/reports/shared/reports-shared.module.ts index 59e59a6a5007..940a2d4e3a57 100644 --- a/apps/web/src/app/dirt/reports/shared/reports-shared.module.ts +++ b/apps/web/src/app/dirt/reports/shared/reports-shared.module.ts @@ -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], }) diff --git a/apps/web/src/app/vault/components/vault-item-dialog/vault-item-dialog.component.spec.ts b/apps/web/src/app/vault/components/vault-item-dialog/vault-item-dialog.component.spec.ts index e45a82d82ba1..6716cde629a3 100644 --- a/apps/web/src/app/vault/components/vault-item-dialog/vault-item-dialog.component.spec.ts +++ b/apps/web/src/app/vault/components/vault-item-dialog/vault-item-dialog.component.spec.ts @@ -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(); diff --git a/apps/web/src/app/vault/individual-vault/vault.component.ts b/apps/web/src/app/vault/individual-vault/vault.component.ts index b9a3bbfdd19e..7bdd290336dd 100644 --- a/apps/web/src/app/vault/individual-vault/vault.component.ts +++ b/apps/web/src/app/vault/individual-vault/vault.component.ts @@ -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 implements OnInit, OnDestr private organizationWarningsService: OrganizationWarningsService, private policyService: PolicyService, private unifiedUpgradePromptService: UnifiedUpgradePromptService, + private premiumUpgradePromptService: PremiumUpgradePromptService, ) {} async ngOnInit() { @@ -867,7 +869,7 @@ export class VaultComponent 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( diff --git a/apps/web/src/app/vault/services/web-premium-upgrade-prompt.service.spec.ts b/apps/web/src/app/vault/services/web-premium-upgrade-prompt.service.spec.ts index 1f34b823aec2..ad16baee42eb 100644 --- a/apps/web/src/app/vault/services/web-premium-upgrade-prompt.service.spec.ts +++ b/apps/web/src/app/vault/services/web-premium-upgrade-prompt.service.spec.ts @@ -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; let routerMock: jest.Mocked; - let dialogRefMock: jest.Mocked>; + let dialogRefMock: jest.Mocked; + let configServiceMock: jest.Mocked; + let accountServiceMock: jest.Mocked; + let apiServiceMock: jest.Mocked; + let syncServiceMock: jest.Mocked; + let billingAccountProfileServiceMock: jest.Mocked; + let platformUtilsServiceMock: jest.Mocked; beforeEach(() => { dialogServiceMock = { openSimpleDialog: jest.fn(), } as unknown as jest.Mocked; + configServiceMock = { + getFeatureFlag: jest.fn().mockReturnValue(false), + } as unknown as jest.Mocked; + + accountServiceMock = { + activeAccount$: of({ id: "user-123" }), + } as unknown as jest.Mocked; + routerMock = { navigate: jest.fn(), } as unknown as jest.Mocked; @@ -28,12 +53,34 @@ describe("WebVaultPremiumUpgradePromptService", () => { close: jest.fn(), } as unknown as jest.Mocked>; + apiServiceMock = { + refreshIdentityToken: jest.fn().mockReturnValue({}), + } as unknown as jest.Mocked; + + syncServiceMock = { + fullSync: jest.fn(), + } as unknown as jest.Mocked; + + billingAccountProfileServiceMock = { + hasPremiumFromAnySource$: jest.fn().mockReturnValue(of(false)), + } as unknown as jest.Mocked; + + platformUtilsServiceMock = { + isSelfHost: jest.fn().mockReturnValue(false), + } as unknown as jest.Mocked; + 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", + }); + }); + }); + }); }); diff --git a/apps/web/src/app/vault/services/web-premium-upgrade-prompt.service.ts b/apps/web/src/app/vault/services/web-premium-upgrade-prompt.service.ts index 87fcdc345d81..c456cf6cc130 100644 --- a/apps/web/src/app/vault/services/web-premium-upgrade-prompt.service.ts +++ b/apps/web/src/app/vault/services/web-premium-upgrade-prompt.service.ts @@ -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, ) {} + 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); + } + } } diff --git a/libs/angular/src/billing/components/index.ts b/libs/angular/src/billing/components/index.ts index 34e1d27c1edd..574ed01e0736 100644 --- a/libs/angular/src/billing/components/index.ts +++ b/libs/angular/src/billing/components/index.ts @@ -1 +1,2 @@ export * from "./premium.component"; +export * from "./premium-upgrade-dialog/premium-upgrade-dialog.component"; diff --git a/libs/angular/src/billing/components/premium-badge/premium-badge.component.ts b/libs/angular/src/billing/components/premium-badge/premium-badge.component.ts index e8a829d458d9..8890584186d0 100644 --- a/libs/angular/src/billing/components/premium-badge/premium-badge.component.ts +++ b/libs/angular/src/billing/components/premium-badge/premium-badge.component.ts @@ -10,7 +10,13 @@ import { BadgeModule } from "@bitwarden/components"; selector: "app-premium-badge", standalone: true, template: ` - `, @@ -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()); } } diff --git a/libs/angular/src/billing/components/premium-badge/premium-badge.stories.ts b/libs/angular/src/billing/components/premium-badge/premium-badge.stories.ts index 08259358f301..bf50d16d3c41 100644 --- a/libs/angular/src/billing/components/premium-badge/premium-badge.stories.ts +++ b/libs/angular/src/billing/components/premium-badge/premium-badge.stories.ts @@ -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: { diff --git a/libs/angular/src/billing/components/premium-upgrade-dialog/premium-upgrade-dialog.component.html b/libs/angular/src/billing/components/premium-upgrade-dialog/premium-upgrade-dialog.component.html new file mode 100644 index 000000000000..99e1c173c2a9 --- /dev/null +++ b/libs/angular/src/billing/components/premium-upgrade-dialog/premium-upgrade-dialog.component.html @@ -0,0 +1,98 @@ +@if (cardDetails$ | async; as cardDetails) { +
+
+ +
+
+
+
+

+ {{ "upgradeToPremium" | i18n }} +

+
+ + +
+

+ {{ cardDetails.tagline }} +

+
+ + +
+
+ {{ + cardDetails.price.amount | currency: "$" + }} + + / {{ cardDetails.price.cadence }} + +
+
+ + +
+ +
+ + +
+ @if (cardDetails.features.length > 0) { +
    + @for (feature of cardDetails.features; track feature) { +
  • + + {{ + feature + }} +
  • + } +
+ } +
+
+
+
+} @else { + + {{ "loading" | i18n }} +} diff --git a/libs/angular/src/billing/components/premium-upgrade-dialog/premium-upgrade-dialog.component.spec.ts b/libs/angular/src/billing/components/premium-upgrade-dialog/premium-upgrade-dialog.component.spec.ts new file mode 100644 index 000000000000..92165b53f44d --- /dev/null +++ b/libs/angular/src/billing/components/premium-upgrade-dialog/premium-upgrade-dialog.component.spec.ts @@ -0,0 +1,231 @@ +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; + let mockDialogRef: jest.Mocked; + let mockSubscriptionPricingService: jest.Mocked; + let mockI18nService: jest.Mocked; + let mockToastService: jest.Mocked; + let mockEnvironmentService: jest.Mocked; + let mockPlatformUtilsService: jest.Mocked; + let mockLogService: jest.Mocked; + + 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 null when getPersonalSubscriptionPricingTiers$ throws an error", async () => { + const error = new Error("Service error"); + mockSubscriptionPricingService.getPersonalSubscriptionPricingTiers$.mockReturnValue( + throwError(() => error), + ); + + const errorFixture = TestBed.createComponent(PremiumUpgradeDialogComponent); + const errorComponent = errorFixture.componentInstance; + errorFixture.detectChanges(); + + const cardDetails = await firstValueFrom(errorComponent["cardDetails$"]); + + expect(mockToastService.showToast).toHaveBeenCalledWith({ + variant: "error", + title: "error", + message: "unexpectedError", + }); + expect(cardDetails).toBeNull(); + }); + }); +}); diff --git a/libs/angular/src/billing/components/premium-upgrade-dialog/premium-upgrade-dialog.component.stories.ts b/libs/angular/src/billing/components/premium-upgrade-dialog/premium-upgrade-dialog.component.stories.ts new file mode 100644 index 000000000000..7ba09192d3c8 --- /dev/null +++ b/libs/angular/src/billing/components/premium-upgrade-dialog/premium-upgrade-dialog.component.stories.ts @@ -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; + +type Story = StoryObj; +export const Default: Story = {}; diff --git a/libs/angular/src/billing/components/premium-upgrade-dialog/premium-upgrade-dialog.component.ts b/libs/angular/src/billing/components/premium-upgrade-dialog/premium-upgrade-dialog.component.ts new file mode 100644 index 000000000000..77bb188b1fbe --- /dev/null +++ b/libs/angular/src/billing/components/premium-upgrade-dialog/premium-upgrade-dialog.component.ts @@ -0,0 +1,120 @@ +import { CdkTrapFocus } from "@angular/cdk/a11y"; +import { CommonModule } from "@angular/common"; +import { Component } from "@angular/core"; +import { catchError, firstValueFrom, map, Observable, of } 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", + imports: [ + CommonModule, + DialogModule, + ButtonModule, + IconButtonModule, + TypographyModule, + CdkTrapFocus, + JslibModule, + ], + templateUrl: "./premium-upgrade-dialog.component.html", +}) +export class PremiumUpgradeDialogComponent { + protected cardDetails$: Observable = 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); + return of(null); + }), + ); + + 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 { + 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 { + return dialogService.open(PremiumUpgradeDialogComponent); + } +} diff --git a/libs/angular/src/billing/directives/not-premium.directive.ts b/libs/angular/src/billing/directives/not-premium.directive.ts index 41d62bb773ef..8582a9f4396b 100644 --- a/libs/angular/src/billing/directives/not-premium.directive.ts +++ b/libs/angular/src/billing/directives/not-premium.directive.ts @@ -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, 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); + } + }); } } diff --git a/libs/angular/src/services/jslib-services.module.ts b/libs/angular/src/services/jslib-services.module.ts index 94b9f6240a49..bfe92b84e493 100644 --- a/libs/angular/src/services/jslib-services.module.ts +++ b/libs/angular/src/services/jslib-services.module.ts @@ -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, diff --git a/libs/angular/src/tools/send/add-edit.component.ts b/libs/angular/src/tools/send/add-edit.component.ts index 1680182f9de9..e03162c2d91e 100644 --- a/libs/angular/src/tools/send/add-edit.component.ts +++ b/libs/angular/src/tools/send/add-edit.component.ts @@ -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"); diff --git a/libs/common/src/billing/abstractions/subscription-pricing.service.abstraction.ts b/libs/common/src/billing/abstractions/subscription-pricing.service.abstraction.ts new file mode 100644 index 000000000000..f3928c0e2e70 --- /dev/null +++ b/libs/common/src/billing/abstractions/subscription-pricing.service.abstraction.ts @@ -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; + + /** + * 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; + + /** + * 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; +} diff --git a/apps/web/src/app/billing/services/subscription-pricing.service.spec.ts b/libs/common/src/billing/services/subscription-pricing.service.spec.ts similarity index 87% rename from apps/web/src/app/billing/services/subscription-pricing.service.spec.ts rename to libs/common/src/billing/services/subscription-pricing.service.spec.ts index de80cdcbdbf9..07ad292c568e 100644 --- a/apps/web/src/app/billing/services/subscription-pricing.service.spec.ts +++ b/libs/common/src/billing/services/subscription-pricing.service.spec.ts @@ -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; let configService: MockProxy; let i18nService: MockProxy; let logService: MockProxy; - let toastService: MockProxy; const mockFamiliesPlan = { type: PlanType.FamiliesAnnually, @@ -233,7 +230,6 @@ describe("SubscriptionPricingService", () => { beforeAll(() => { i18nService = mock(); logService = mock(); - toastService = mock(); 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(); const errorConfigService = mock(); const errorI18nService = mock(); const errorLogService = mock(); - const errorToastService = mock(); 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(); const errorConfigService = mock(); const errorI18nService = mock(); const errorLogService = mock(); - const errorToastService = mock(); 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(); const errorConfigService = mock(); const errorI18nService = mock(); const errorLogService = mock(); - const errorToastService = mock(); 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(); const errorConfigService = mock(); + 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(); const errorConfigService = mock(); + 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 diff --git a/apps/web/src/app/billing/services/subscription-pricing.service.ts b/libs/common/src/billing/services/subscription-pricing.service.ts similarity index 87% rename from apps/web/src/app/billing/services/subscription-pricing.service.ts rename to libs/common/src/billing/services/subscription-pricing.service.ts index 71729a42d230..a4223579c12a 100644 --- a/apps/web/src/app/billing/services/subscription-pricing.service.ts +++ b/libs/common/src/billing/services/subscription-pricing.service.ts @@ -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 => 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 => 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 => 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", diff --git a/apps/web/src/app/billing/types/subscription-pricing-tier.ts b/libs/common/src/billing/types/subscription-pricing-tier.ts similarity index 100% rename from apps/web/src/app/billing/types/subscription-pricing-tier.ts rename to libs/common/src/billing/types/subscription-pricing-tier.ts diff --git a/libs/common/src/enums/feature-flag.enum.ts b/libs/common/src/enums/feature-flag.enum.ts index d9cd1dbfab31..06c533ab3a9a 100644 --- a/libs/common/src/enums/feature-flag.enum.ts +++ b/libs/common/src/enums/feature-flag.enum.ts @@ -31,6 +31,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", @@ -117,6 +118,7 @@ export const DefaultFeatureFlagValue = { [FeatureFlag.PM24996_ImplementUpgradeFromFreeDialog]: FALSE, [FeatureFlag.PM24033PremiumUpgradeNewDesign]: FALSE, [FeatureFlag.PM26793_FetchPremiumPriceFromPricingService]: FALSE, + [FeatureFlag.PM23713_PremiumBadgeOpensNewPremiumUpgradeDialog]: FALSE, /* Key Management */ [FeatureFlag.PrivateKeyRegeneration]: FALSE, diff --git a/libs/tools/send/send-ui/src/new-send-dropdown/new-send-dropdown.component.html b/libs/tools/send/send-ui/src/new-send-dropdown/new-send-dropdown.component.html index 1d5629cfd48a..bcced7e012b6 100644 --- a/libs/tools/send/send-ui/src/new-send-dropdown/new-send-dropdown.component.html +++ b/libs/tools/send/send-ui/src/new-send-dropdown/new-send-dropdown.component.html @@ -3,19 +3,11 @@ {{ (hideIcon ? "createSend" : "new") | i18n }} - + {{ "sendTypeText" | i18n }} - +
{{ "sendTypeFile" | i18n }} diff --git a/libs/tools/send/send-ui/src/new-send-dropdown/new-send-dropdown.component.ts b/libs/tools/send/send-ui/src/new-send-dropdown/new-send-dropdown.component.ts index 1ffd9644208a..e14741752677 100644 --- a/libs/tools/send/send-ui/src/new-send-dropdown/new-send-dropdown.component.ts +++ b/libs/tools/send/send-ui/src/new-send-dropdown/new-send-dropdown.component.ts @@ -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), + }); + } + } }