1
0
mirror of https://github.com/bitwarden/browser.git synced 2024-12-22 16:29:09 +01:00

[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.
This commit is contained in:
Alec Rippberger 2024-09-18 16:00:54 -05:00 committed by GitHub
parent 1940256fe2
commit 6c1d74a4ce
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 225 additions and 12 deletions

View File

@ -27,18 +27,22 @@ import {
ToastService, ToastService,
} from "@bitwarden/components"; } from "@bitwarden/components";
import { PremiumUpgradePromptService } from "../../../../../../../../libs/common/src/vault/abstractions/premium-upgrade-prompt.service";
import { CipherViewComponent } from "../../../../../../../../libs/vault/src/cipher-view"; import { CipherViewComponent } from "../../../../../../../../libs/vault/src/cipher-view";
import { PopOutComponent } from "../../../../../platform/popup/components/pop-out.component"; import { PopOutComponent } from "../../../../../platform/popup/components/pop-out.component";
import { PopupFooterComponent } from "../../../../../platform/popup/layout/popup-footer.component";
import { PopupFooterComponent } from "./../../../../../platform/popup/layout/popup-footer.component"; import { PopupHeaderComponent } from "../../../../../platform/popup/layout/popup-header.component";
import { PopupHeaderComponent } from "./../../../../../platform/popup/layout/popup-header.component"; import { PopupPageComponent } from "../../../../../platform/popup/layout/popup-page.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"; import { VaultPopupAutofillService } from "../../../services/vault-popup-autofill.service";
@Component({ @Component({
selector: "app-view-v2", selector: "app-view-v2",
templateUrl: "view-v2.component.html", templateUrl: "view-v2.component.html",
standalone: true, standalone: true,
providers: [
{ provide: PremiumUpgradePromptService, useClass: BrowserPremiumUpgradePromptService },
],
imports: [ imports: [
CommonModule, CommonModule,
SearchModule, SearchModule,

View File

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

View File

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

View File

@ -188,7 +188,7 @@ export class AppComponent implements OnDestroy, OnInit {
if (premiumConfirmed) { if (premiumConfirmed) {
// FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling. // 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 // eslint-disable-next-line @typescript-eslint/no-floating-promises
this.router.navigate(["settings/subscription/premium"]); await this.router.navigate(["settings/subscription/premium"]);
} }
break; break;
} }

View File

@ -1,6 +1,6 @@
import { DIALOG_DATA, DialogConfig, DialogRef } from "@angular/cdk/dialog"; import { DIALOG_DATA, DialogConfig, DialogRef } from "@angular/cdk/dialog";
import { CommonModule } from "@angular/common"; 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 { Router } from "@angular/router";
import { Subject } from "rxjs"; import { Subject } from "rxjs";
@ -19,8 +19,10 @@ import {
ToastService, ToastService,
} from "@bitwarden/components"; } 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 { CipherViewComponent } from "../../../../../../libs/vault/src/cipher-view/cipher-view.component";
import { SharedModule } from "../../shared/shared.module"; import { SharedModule } from "../../shared/shared.module";
import { WebVaultPremiumUpgradePromptService } from "../services/web-premium-upgrade-prompt.service";
export interface ViewCipherDialogParams { export interface ViewCipherDialogParams {
cipher: CipherView; cipher: CipherView;
@ -29,6 +31,7 @@ export interface ViewCipherDialogParams {
export enum ViewCipherDialogResult { export enum ViewCipherDialogResult {
Edited = "edited", Edited = "edited",
Deleted = "deleted", Deleted = "deleted",
PremiumUpgrade = "premiumUpgrade",
} }
export interface ViewCipherDialogCloseResult { export interface ViewCipherDialogCloseResult {
@ -43,6 +46,9 @@ export interface ViewCipherDialogCloseResult {
templateUrl: "view.component.html", templateUrl: "view.component.html",
standalone: true, standalone: true,
imports: [CipherViewComponent, CommonModule, AsyncActionsModule, DialogModule, SharedModule], imports: [CipherViewComponent, CommonModule, AsyncActionsModule, DialogModule, SharedModule],
providers: [
{ provide: PremiumUpgradePromptService, useClass: WebVaultPremiumUpgradePromptService },
],
}) })
export class ViewComponent implements OnInit, OnDestroy { export class ViewComponent implements OnInit, OnDestroy {
cipher: CipherView; cipher: CipherView;

View File

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

View File

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

View File

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

View File

@ -97,7 +97,7 @@
bitBadge bitBadge
variant="success" variant="success"
class="tw-ml-2 tw-cursor-pointer" class="tw-ml-2 tw-cursor-pointer"
(click)="getPremium()" (click)="getPremium(cipher.organizationId)"
slot="end" slot="end"
> >
{{ "premium" | i18n }} {{ "premium" | i18n }}

View File

@ -1,6 +1,5 @@
import { CommonModule, DatePipe } from "@angular/common"; import { CommonModule, DatePipe } from "@angular/common";
import { Component, inject, Input } from "@angular/core"; import { Component, inject, Input } from "@angular/core";
import { Router } from "@angular/router";
import { Observable, shareReplay } from "rxjs"; import { Observable, shareReplay } from "rxjs";
import { JslibModule } from "@bitwarden/angular/jslib.module"; import { JslibModule } from "@bitwarden/angular/jslib.module";
@ -20,6 +19,7 @@ import {
ColorPasswordModule, ColorPasswordModule,
} from "@bitwarden/components"; } 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 { BitTotpCountdownComponent } from "../../components/totp-countdown/totp-countdown.component";
import { ReadOnlyCipherCardComponent } from "../read-only-cipher-card/read-only-cipher-card.component"; import { ReadOnlyCipherCardComponent } from "../read-only-cipher-card/read-only-cipher-card.component";
@ -61,8 +61,8 @@ export class LoginCredentialsViewComponent {
constructor( constructor(
private billingAccountProfileStateService: BillingAccountProfileStateService, private billingAccountProfileStateService: BillingAccountProfileStateService,
private router: Router,
private i18nService: I18nService, private i18nService: I18nService,
private premiumUpgradeService: PremiumUpgradePromptService,
private eventCollectionService: EventCollectionService, private eventCollectionService: EventCollectionService,
) {} ) {}
@ -75,8 +75,8 @@ export class LoginCredentialsViewComponent {
return `${dateCreated} ${creationDate}`; return `${dateCreated} ${creationDate}`;
} }
async getPremium() { async getPremium(organizationId?: string) {
await this.router.navigate(["/premium"]); await this.premiumUpgradeService.promptForPremium(organizationId);
} }
async pwToggleValue(passwordVisible: boolean) { async pwToggleValue(passwordVisible: boolean) {