Skip to content

Commit

Permalink
[PM-11395] [Defect] View Login - TOTP premium badge does nothing when…
Browse files Browse the repository at this point in the history
… 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.
  • Loading branch information
alec-livefront authored Sep 18, 2024
1 parent 1940256 commit 6c1d74a
Show file tree
Hide file tree
Showing 10 changed files with 225 additions and 12 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
@@ -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<Router>;

beforeEach(async () => {
router = mock<Router>();
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"]);
});
});
});
Original file line number Diff line number Diff line change
@@ -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"]);
}
}
2 changes: 1 addition & 1 deletion apps/web/src/app/app.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
8 changes: 7 additions & 1 deletion apps/web/src/app/vault/individual-vault/view.component.ts
Original file line number Diff line number Diff line change
@@ -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";

Expand All @@ -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;
Expand All @@ -29,6 +31,7 @@ export interface ViewCipherDialogParams {
export enum ViewCipherDialogResult {
Edited = "edited",
Deleted = "deleted",
PremiumUpgrade = "premiumUpgrade",
}

export interface ViewCipherDialogCloseResult {
Expand All @@ -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;
Expand Down
Original file line number Diff line number Diff line change
@@ -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<DialogService>;
let routerMock: jest.Mocked<Router>;
let dialogRefMock: jest.Mocked<DialogRef<ViewCipherDialogCloseResult>>;

beforeEach(() => {
dialogServiceMock = {
openSimpleDialog: jest.fn(),
} as unknown as jest.Mocked<DialogService>;

routerMock = {
navigate: jest.fn(),
} as unknown as jest.Mocked<Router>;

dialogRefMock = {
close: jest.fn(),
} as unknown as jest.Mocked<DialogRef<ViewCipherDialogCloseResult>>;

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();
});
});
Original file line number Diff line number Diff line change
@@ -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<ViewCipherDialogCloseResult>,
) {}

/**
* 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 });
}
}
}
Original file line number Diff line number Diff line change
@@ -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<void>;
}
Original file line number Diff line number Diff line change
Expand Up @@ -97,7 +97,7 @@ <h2 bitTypography="h6">{{ "loginCredentials" | i18n }}</h2>
bitBadge
variant="success"
class="tw-ml-2 tw-cursor-pointer"
(click)="getPremium()"
(click)="getPremium(cipher.organizationId)"
slot="end"
>
{{ "premium" | i18n }}
Expand Down
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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";

Expand Down Expand Up @@ -61,8 +61,8 @@ export class LoginCredentialsViewComponent {

constructor(
private billingAccountProfileStateService: BillingAccountProfileStateService,
private router: Router,
private i18nService: I18nService,
private premiumUpgradeService: PremiumUpgradePromptService,
private eventCollectionService: EventCollectionService,
) {}

Expand All @@ -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) {
Expand Down

0 comments on commit 6c1d74a

Please sign in to comment.