From 6c1d74a4cec9714ea0d3ad45697ec8422826dfd5 Mon Sep 17 00:00:00 2001 From: Alec Rippberger <127791530+alec-livefront@users.noreply.github.com> Date: Wed, 18 Sep 2024 16:00:54 -0500 Subject: [PATCH] [PM-11395] [Defect] View Login - TOTP premium badge does nothing when clicked (#10857) * Add MessagingService to LoginCredentialView component. * Add comments. * Add WIP PremiumUpgradeService * Simplify web PremiumUpgradeServices into one service. * Relocate service files. * Add browser version of PremiumUpgradePromptService. * Cleanup debug comments. * Run prettier. * rework promptForPremium to take organization id and add test. * Add test for browser * Rework imports to fix linter errors. * Add Shane's reworked WebVaultPremiumUpgradePromptService. --- .../vault-v2/view-v2/view-v2.component.ts | 14 ++- ...ser-premium-upgrade-prompt.service.spec.ts | 26 +++++ .../browser-premium-upgrade-prompt.service.ts | 18 ++++ apps/web/src/app/app.component.ts | 2 +- .../vault/individual-vault/view.component.ts | 8 +- ...web-premium-upgrade-prompt.service.spec.ts | 95 +++++++++++++++++++ .../web-premium-upgrade-prompt.service.ts | 57 +++++++++++ .../premium-upgrade-prompt.service.ts | 7 ++ .../login-credentials-view.component.html | 2 +- .../login-credentials-view.component.ts | 8 +- 10 files changed, 225 insertions(+), 12 deletions(-) create mode 100644 apps/browser/src/vault/popup/services/browser-premium-upgrade-prompt.service.spec.ts create mode 100644 apps/browser/src/vault/popup/services/browser-premium-upgrade-prompt.service.ts create mode 100644 apps/web/src/app/vault/services/web-premium-upgrade-prompt.service.spec.ts create mode 100644 apps/web/src/app/vault/services/web-premium-upgrade-prompt.service.ts create mode 100644 libs/common/src/vault/abstractions/premium-upgrade-prompt.service.ts diff --git a/apps/browser/src/vault/popup/components/vault-v2/view-v2/view-v2.component.ts b/apps/browser/src/vault/popup/components/vault-v2/view-v2/view-v2.component.ts index b2ef6701b4..a640abe69f 100644 --- a/apps/browser/src/vault/popup/components/vault-v2/view-v2/view-v2.component.ts +++ b/apps/browser/src/vault/popup/components/vault-v2/view-v2/view-v2.component.ts @@ -27,18 +27,22 @@ import { ToastService, } from "@bitwarden/components"; +import { PremiumUpgradePromptService } from "../../../../../../../../libs/common/src/vault/abstractions/premium-upgrade-prompt.service"; import { CipherViewComponent } from "../../../../../../../../libs/vault/src/cipher-view"; import { PopOutComponent } from "../../../../../platform/popup/components/pop-out.component"; - -import { PopupFooterComponent } from "./../../../../../platform/popup/layout/popup-footer.component"; -import { PopupHeaderComponent } from "./../../../../../platform/popup/layout/popup-header.component"; -import { PopupPageComponent } from "./../../../../../platform/popup/layout/popup-page.component"; -import { VaultPopupAutofillService } from "./../../../services/vault-popup-autofill.service"; +import { PopupFooterComponent } from "../../../../../platform/popup/layout/popup-footer.component"; +import { PopupHeaderComponent } from "../../../../../platform/popup/layout/popup-header.component"; +import { PopupPageComponent } from "../../../../../platform/popup/layout/popup-page.component"; +import { BrowserPremiumUpgradePromptService } from "../../../services/browser-premium-upgrade-prompt.service"; +import { VaultPopupAutofillService } from "../../../services/vault-popup-autofill.service"; @Component({ selector: "app-view-v2", templateUrl: "view-v2.component.html", standalone: true, + providers: [ + { provide: PremiumUpgradePromptService, useClass: BrowserPremiumUpgradePromptService }, + ], imports: [ CommonModule, SearchModule, 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 new file mode 100644 index 0000000000..9a00bacd6b --- /dev/null +++ b/apps/browser/src/vault/popup/services/browser-premium-upgrade-prompt.service.spec.ts @@ -0,0 +1,26 @@ +import { TestBed } from "@angular/core/testing"; +import { Router } from "@angular/router"; +import { mock, MockProxy } from "jest-mock-extended"; + +import { BrowserPremiumUpgradePromptService } from "./browser-premium-upgrade-prompt.service"; + +describe("BrowserPremiumUpgradePromptService", () => { + let service: BrowserPremiumUpgradePromptService; + let router: MockProxy; + + beforeEach(async () => { + router = mock(); + await TestBed.configureTestingModule({ + providers: [BrowserPremiumUpgradePromptService, { provide: Router, useValue: router }], + }).compileComponents(); + + service = TestBed.inject(BrowserPremiumUpgradePromptService); + }); + + describe("promptForPremium", () => { + it("navigates to the premium update screen", async () => { + await service.promptForPremium(); + expect(router.navigate).toHaveBeenCalledWith(["/premium"]); + }); + }); +}); 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 new file mode 100644 index 0000000000..2909e3b3bd --- /dev/null +++ b/apps/browser/src/vault/popup/services/browser-premium-upgrade-prompt.service.ts @@ -0,0 +1,18 @@ +import { inject } from "@angular/core"; +import { Router } from "@angular/router"; + +import { PremiumUpgradePromptService } from "@bitwarden/common/vault/abstractions/premium-upgrade-prompt.service"; + +/** + * This class handles the premium upgrade process for the browser extension. + */ +export class BrowserPremiumUpgradePromptService implements PremiumUpgradePromptService { + private router = inject(Router); + + async promptForPremium() { + /** + * Navigate to the premium update screen. + */ + await this.router.navigate(["/premium"]); + } +} diff --git a/apps/web/src/app/app.component.ts b/apps/web/src/app/app.component.ts index ef6cbd2804..1314670c44 100644 --- a/apps/web/src/app/app.component.ts +++ b/apps/web/src/app/app.component.ts @@ -188,7 +188,7 @@ export class AppComponent implements OnDestroy, OnInit { if (premiumConfirmed) { // FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling. // eslint-disable-next-line @typescript-eslint/no-floating-promises - this.router.navigate(["settings/subscription/premium"]); + await this.router.navigate(["settings/subscription/premium"]); } break; } diff --git a/apps/web/src/app/vault/individual-vault/view.component.ts b/apps/web/src/app/vault/individual-vault/view.component.ts index 964be0e8ab..fe846c9f64 100644 --- a/apps/web/src/app/vault/individual-vault/view.component.ts +++ b/apps/web/src/app/vault/individual-vault/view.component.ts @@ -1,6 +1,6 @@ import { DIALOG_DATA, DialogConfig, DialogRef } from "@angular/cdk/dialog"; import { CommonModule } from "@angular/common"; -import { Component, EventEmitter, Inject, OnDestroy, OnInit } from "@angular/core"; +import { Component, Inject, OnInit, EventEmitter, OnDestroy } from "@angular/core"; import { Router } from "@angular/router"; import { Subject } from "rxjs"; @@ -19,8 +19,10 @@ import { ToastService, } from "@bitwarden/components"; +import { PremiumUpgradePromptService } from "../../../../../../libs/common/src/vault/abstractions/premium-upgrade-prompt.service"; import { CipherViewComponent } from "../../../../../../libs/vault/src/cipher-view/cipher-view.component"; import { SharedModule } from "../../shared/shared.module"; +import { WebVaultPremiumUpgradePromptService } from "../services/web-premium-upgrade-prompt.service"; export interface ViewCipherDialogParams { cipher: CipherView; @@ -29,6 +31,7 @@ export interface ViewCipherDialogParams { export enum ViewCipherDialogResult { Edited = "edited", Deleted = "deleted", + PremiumUpgrade = "premiumUpgrade", } export interface ViewCipherDialogCloseResult { @@ -43,6 +46,9 @@ export interface ViewCipherDialogCloseResult { templateUrl: "view.component.html", standalone: true, imports: [CipherViewComponent, CommonModule, AsyncActionsModule, DialogModule, SharedModule], + providers: [ + { provide: PremiumUpgradePromptService, useClass: WebVaultPremiumUpgradePromptService }, + ], }) export class ViewComponent implements OnInit, OnDestroy { cipher: CipherView; 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 new file mode 100644 index 0000000000..6c68dae707 --- /dev/null +++ b/apps/web/src/app/vault/services/web-premium-upgrade-prompt.service.spec.ts @@ -0,0 +1,95 @@ +import { DialogRef } from "@angular/cdk/dialog"; +import { TestBed } from "@angular/core/testing"; +import { Router } from "@angular/router"; +import { of, lastValueFrom } from "rxjs"; + +import { OrganizationId } from "@bitwarden/common/types/guid"; +import { DialogService } from "@bitwarden/components"; + +import { + ViewCipherDialogCloseResult, + ViewCipherDialogResult, +} from "../individual-vault/view.component"; + +import { WebVaultPremiumUpgradePromptService } from "./web-premium-upgrade-prompt.service"; + +describe("WebVaultPremiumUpgradePromptService", () => { + let service: WebVaultPremiumUpgradePromptService; + let dialogServiceMock: jest.Mocked; + let routerMock: jest.Mocked; + let dialogRefMock: jest.Mocked>; + + beforeEach(() => { + dialogServiceMock = { + openSimpleDialog: jest.fn(), + } as unknown as jest.Mocked; + + routerMock = { + navigate: jest.fn(), + } as unknown as jest.Mocked; + + dialogRefMock = { + close: jest.fn(), + } as unknown as jest.Mocked>; + + TestBed.configureTestingModule({ + providers: [ + WebVaultPremiumUpgradePromptService, + { provide: DialogService, useValue: dialogServiceMock }, + { provide: Router, useValue: routerMock }, + { provide: DialogRef, useValue: dialogRefMock }, + ], + }); + + service = TestBed.inject(WebVaultPremiumUpgradePromptService); + }); + + it("prompts for premium upgrade and navigates to organization billing if organizationId is provided", async () => { + dialogServiceMock.openSimpleDialog.mockReturnValue(lastValueFrom(of(true))); + const organizationId = "test-org-id" as OrganizationId; + + await service.promptForPremium(organizationId); + + expect(dialogServiceMock.openSimpleDialog).toHaveBeenCalledWith({ + title: { key: "upgradeOrganization" }, + content: { key: "upgradeOrganizationDesc" }, + acceptButtonText: { key: "upgradeOrganization" }, + type: "info", + }); + expect(routerMock.navigate).toHaveBeenCalledWith([ + "organizations", + organizationId, + "billing", + "subscription", + ]); + expect(dialogRefMock.close).toHaveBeenCalledWith({ + action: ViewCipherDialogResult.PremiumUpgrade, + }); + }); + + it("prompts for premium upgrade and navigates to premium subscription if organizationId is not provided", async () => { + dialogServiceMock.openSimpleDialog.mockReturnValue(lastValueFrom(of(true))); + + await service.promptForPremium(); + + expect(dialogServiceMock.openSimpleDialog).toHaveBeenCalledWith({ + title: { key: "premiumRequired" }, + content: { key: "premiumRequiredDesc" }, + acceptButtonText: { key: "upgrade" }, + type: "success", + }); + expect(routerMock.navigate).toHaveBeenCalledWith(["settings/subscription/premium"]); + expect(dialogRefMock.close).toHaveBeenCalledWith({ + action: ViewCipherDialogResult.PremiumUpgrade, + }); + }); + + it("does not navigate or close dialog if upgrade is no action is taken", async () => { + dialogServiceMock.openSimpleDialog.mockReturnValue(lastValueFrom(of(false))); + + await service.promptForPremium("test-org-id" as OrganizationId); + + expect(routerMock.navigate).not.toHaveBeenCalled(); + expect(dialogRefMock.close).not.toHaveBeenCalled(); + }); +}); 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 new file mode 100644 index 0000000000..8f9c8c0bd7 --- /dev/null +++ b/apps/web/src/app/vault/services/web-premium-upgrade-prompt.service.ts @@ -0,0 +1,57 @@ +import { DialogRef } from "@angular/cdk/dialog"; +import { Injectable } from "@angular/core"; +import { Router } from "@angular/router"; + +import { OrganizationId } from "@bitwarden/common/types/guid"; +import { PremiumUpgradePromptService } from "@bitwarden/common/vault/abstractions/premium-upgrade-prompt.service"; +import { DialogService } from "@bitwarden/components"; + +import { + ViewCipherDialogCloseResult, + ViewCipherDialogResult, +} from "../individual-vault/view.component"; + +/** + * This service is used to prompt the user to upgrade to premium. + */ +@Injectable() +export class WebVaultPremiumUpgradePromptService implements PremiumUpgradePromptService { + constructor( + private dialogService: DialogService, + private router: Router, + private dialog: DialogRef, + ) {} + + /** + * Prompts the user to upgrade to premium. + * @param organizationId The ID of the organization to upgrade. + */ + async promptForPremium(organizationId?: OrganizationId) { + let upgradeConfirmed; + if (organizationId) { + upgradeConfirmed = await this.dialogService.openSimpleDialog({ + title: { key: "upgradeOrganization" }, + content: { key: "upgradeOrganizationDesc" }, + acceptButtonText: { key: "upgradeOrganization" }, + type: "info", + }); + if (upgradeConfirmed) { + await this.router.navigate(["organizations", organizationId, "billing", "subscription"]); + } + } else { + upgradeConfirmed = await this.dialogService.openSimpleDialog({ + title: { key: "premiumRequired" }, + content: { key: "premiumRequiredDesc" }, + acceptButtonText: { key: "upgrade" }, + type: "success", + }); + if (upgradeConfirmed) { + await this.router.navigate(["settings/subscription/premium"]); + } + } + + if (upgradeConfirmed) { + this.dialog.close({ action: ViewCipherDialogResult.PremiumUpgrade }); + } + } +} diff --git a/libs/common/src/vault/abstractions/premium-upgrade-prompt.service.ts b/libs/common/src/vault/abstractions/premium-upgrade-prompt.service.ts new file mode 100644 index 0000000000..8733baaa47 --- /dev/null +++ b/libs/common/src/vault/abstractions/premium-upgrade-prompt.service.ts @@ -0,0 +1,7 @@ +/** + * This interface defines the a contract for a service that prompts the user to upgrade to premium. + * It ensures that PremiumUpgradePromptService contains a promptForPremium method. + */ +export abstract class PremiumUpgradePromptService { + abstract promptForPremium(organizationId?: string): Promise; +} diff --git a/libs/vault/src/cipher-view/login-credentials/login-credentials-view.component.html b/libs/vault/src/cipher-view/login-credentials/login-credentials-view.component.html index 88a59d9cc4..43be62f8c6 100644 --- a/libs/vault/src/cipher-view/login-credentials/login-credentials-view.component.html +++ b/libs/vault/src/cipher-view/login-credentials/login-credentials-view.component.html @@ -97,7 +97,7 @@ bitBadge variant="success" class="tw-ml-2 tw-cursor-pointer" - (click)="getPremium()" + (click)="getPremium(cipher.organizationId)" slot="end" > {{ "premium" | i18n }} diff --git a/libs/vault/src/cipher-view/login-credentials/login-credentials-view.component.ts b/libs/vault/src/cipher-view/login-credentials/login-credentials-view.component.ts index c6957b1848..b05d3318c3 100644 --- a/libs/vault/src/cipher-view/login-credentials/login-credentials-view.component.ts +++ b/libs/vault/src/cipher-view/login-credentials/login-credentials-view.component.ts @@ -1,6 +1,5 @@ import { CommonModule, DatePipe } from "@angular/common"; import { Component, inject, Input } from "@angular/core"; -import { Router } from "@angular/router"; import { Observable, shareReplay } from "rxjs"; import { JslibModule } from "@bitwarden/angular/jslib.module"; @@ -20,6 +19,7 @@ import { ColorPasswordModule, } from "@bitwarden/components"; +import { PremiumUpgradePromptService } from "../../../../../libs/common/src/vault/abstractions/premium-upgrade-prompt.service"; import { BitTotpCountdownComponent } from "../../components/totp-countdown/totp-countdown.component"; import { ReadOnlyCipherCardComponent } from "../read-only-cipher-card/read-only-cipher-card.component"; @@ -61,8 +61,8 @@ export class LoginCredentialsViewComponent { constructor( private billingAccountProfileStateService: BillingAccountProfileStateService, - private router: Router, private i18nService: I18nService, + private premiumUpgradeService: PremiumUpgradePromptService, private eventCollectionService: EventCollectionService, ) {} @@ -75,8 +75,8 @@ export class LoginCredentialsViewComponent { return `${dateCreated} ${creationDate}`; } - async getPremium() { - await this.router.navigate(["/premium"]); + async getPremium(organizationId?: string) { + await this.premiumUpgradeService.promptForPremium(organizationId); } async pwToggleValue(passwordVisible: boolean) {