diff --git a/apps/browser/src/_locales/en/messages.json b/apps/browser/src/_locales/en/messages.json index 5cc7c30bfb..14915175da 100644 --- a/apps/browser/src/_locales/en/messages.json +++ b/apps/browser/src/_locales/en/messages.json @@ -4902,6 +4902,9 @@ "premium": { "message": "Premium" }, + "unlockFeaturesWithPremium": { + "message": "Unlock reporting, emergency access, and more security features with Premium." + }, "freeOrgsCannotUseAttachments": { "message": "Free organizations cannot use attachments" }, diff --git a/apps/browser/src/tools/popup/settings/settings-v2.component.html b/apps/browser/src/tools/popup/settings/settings-v2.component.html index a12c5fe005..683b7d70ed 100644 --- a/apps/browser/src/tools/popup/settings/settings-v2.component.html +++ b/apps/browser/src/tools/popup/settings/settings-v2.component.html @@ -1,4 +1,19 @@ + + {{ "unlockFeaturesWithPremium" | i18n }} + + + @@ -20,7 +35,7 @@

{{ "autofill" | i18n }}

{ + let account$: BehaviorSubject; + let mockAccountService: Partial; + let mockBillingState: { hasPremiumFromAnySource$: jest.Mock }; + let mockNudges: { + showNudgeBadge$: jest.Mock; + dismissNudge: jest.Mock; + }; + let mockAutofillSettings: { + defaultBrowserAutofillDisabled$: Subject; + isBrowserAutofillSettingOverridden: jest.Mock>; + }; + let dialogService: MockProxy; + let openSpy: jest.SpyInstance; + + beforeEach(waitForAsync(async () => { + dialogService = mock(); + account$ = new BehaviorSubject(null); + mockAccountService = { + activeAccount$: account$ as unknown as AccountService["activeAccount$"], + }; + + mockBillingState = { + hasPremiumFromAnySource$: jest.fn().mockReturnValue(of(false)), + }; + + mockNudges = { + showNudgeBadge$: jest.fn().mockImplementation(() => of(false)), + dismissNudge: jest.fn().mockResolvedValue(undefined), + }; + + mockAutofillSettings = { + defaultBrowserAutofillDisabled$: new BehaviorSubject(false), + isBrowserAutofillSettingOverridden: jest.fn().mockResolvedValue(false), + }; + + jest.spyOn(BrowserApi, "getBrowserClientVendor").mockReturnValue("Chrome"); + + const cfg = TestBed.configureTestingModule({ + imports: [SettingsV2Component, RouterTestingModule], + providers: [ + { provide: AccountService, useValue: mockAccountService }, + { provide: BillingAccountProfileStateService, useValue: mockBillingState }, + { provide: NudgesService, useValue: mockNudges }, + { provide: AutofillBrowserSettingsService, useValue: mockAutofillSettings }, + { provide: DialogService, useValue: dialogService }, + { provide: I18nService, useValue: { t: jest.fn((key: string) => key) } }, + { provide: GlobalStateProvider, useValue: new FakeGlobalStateProvider() }, + { provide: PlatformUtilsService, useValue: mock() }, + { provide: AvatarService, useValue: mock() }, + { provide: AuthService, useValue: mock() }, + ], + schemas: [CUSTOM_ELEMENTS_SCHEMA], + }); + + TestBed.overrideComponent(SettingsV2Component, { + add: { + imports: [CurrentAccountStubComponent], + providers: [{ provide: DialogService, useValue: dialogService }], + }, + remove: { + imports: [CurrentAccountComponent], + }, + }); + + await cfg.compileComponents(); + })); + + afterEach(() => { + jest.resetAllMocks(); + }); + + function pushActiveAccount(id = "user-123"): Account { + const acct = { id } as Account; + account$.next(acct); + return acct; + } + + it("shows the premium spotlight when user does NOT have premium", async () => { + mockBillingState.hasPremiumFromAnySource$.mockReturnValue(of(false)); + pushActiveAccount(); + + const fixture = TestBed.createComponent(SettingsV2Component); + fixture.detectChanges(); + await fixture.whenStable(); + + const el: HTMLElement = fixture.nativeElement; + + expect(el.querySelector("bit-spotlight")).toBeTruthy(); + }); + + it("hides the premium spotlight when user HAS premium", async () => { + mockBillingState.hasPremiumFromAnySource$.mockReturnValue(of(true)); + pushActiveAccount(); + + const fixture = TestBed.createComponent(SettingsV2Component); + fixture.detectChanges(); + await fixture.whenStable(); + + const el: HTMLElement = fixture.nativeElement; + expect(el.querySelector("bit-spotlight")).toBeFalsy(); + }); + + it("openUpgradeDialog calls PremiumUpgradeDialogComponent.open with the DialogService", async () => { + openSpy = jest.spyOn(PremiumUpgradeDialogComponent, "open").mockImplementation(); + mockBillingState.hasPremiumFromAnySource$.mockReturnValue(of(false)); + pushActiveAccount(); + + const fixture = TestBed.createComponent(SettingsV2Component); + const component = fixture.componentInstance; + fixture.detectChanges(); + await fixture.whenStable(); + + component["openUpgradeDialog"](); + expect(openSpy).toHaveBeenCalledTimes(1); + expect(openSpy).toHaveBeenCalledWith(dialogService); + }); + + it("isBrowserAutofillSettingOverridden$ emits the value from the AutofillBrowserSettingsService", async () => { + pushActiveAccount(); + + mockAutofillSettings.isBrowserAutofillSettingOverridden.mockResolvedValue(true); + + const fixture = TestBed.createComponent(SettingsV2Component); + const component = fixture.componentInstance; + fixture.detectChanges(); + await fixture.whenStable(); + + const value = await firstValueFrom(component["isBrowserAutofillSettingOverridden$"]); + expect(value).toBe(true); + + mockAutofillSettings.isBrowserAutofillSettingOverridden.mockResolvedValue(false); + + const fixture2 = TestBed.createComponent(SettingsV2Component); + const component2 = fixture2.componentInstance; + fixture2.detectChanges(); + await fixture2.whenStable(); + + const value2 = await firstValueFrom(component2["isBrowserAutofillSettingOverridden$"]); + expect(value2).toBe(false); + }); + + it("showAutofillBadge$ emits true when default autofill is NOT disabled and nudge is true", async () => { + pushActiveAccount(); + + mockNudges.showNudgeBadge$.mockImplementation((type: NudgeType) => + of(type === NudgeType.AutofillNudge), + ); + + const fixture = TestBed.createComponent(SettingsV2Component); + const component = fixture.componentInstance; + fixture.detectChanges(); + await fixture.whenStable(); + + mockAutofillSettings.defaultBrowserAutofillDisabled$.next(false); + + const value = await firstValueFrom(component.showAutofillBadge$); + expect(value).toBe(true); + }); + + it("showAutofillBadge$ emits false when default autofill IS disabled even if nudge is true", async () => { + pushActiveAccount(); + + mockNudges.showNudgeBadge$.mockImplementation((type: NudgeType) => + of(type === NudgeType.AutofillNudge), + ); + + const fixture = TestBed.createComponent(SettingsV2Component); + const component = fixture.componentInstance; + fixture.detectChanges(); + await fixture.whenStable(); + + mockAutofillSettings.defaultBrowserAutofillDisabled$.next(true); + + const value = await firstValueFrom(component.showAutofillBadge$); + expect(value).toBe(false); + }); + + it("dismissBadge dismisses when showVaultBadge$ emits true", async () => { + const acct = pushActiveAccount(); + + mockNudges.showNudgeBadge$.mockImplementation((type: NudgeType) => { + return of(type === NudgeType.EmptyVaultNudge); + }); + + const fixture = TestBed.createComponent(SettingsV2Component); + const component = fixture.componentInstance; + fixture.detectChanges(); + await fixture.whenStable(); + + await component.dismissBadge(NudgeType.EmptyVaultNudge); + + expect(mockNudges.dismissNudge).toHaveBeenCalledTimes(1); + expect(mockNudges.dismissNudge).toHaveBeenCalledWith(NudgeType.EmptyVaultNudge, acct.id, true); + }); + + it("dismissBadge does nothing when showVaultBadge$ emits false", async () => { + pushActiveAccount(); + + mockNudges.showNudgeBadge$.mockReturnValue(of(false)); + + const fixture = TestBed.createComponent(SettingsV2Component); + const component = fixture.componentInstance; + fixture.detectChanges(); + await fixture.whenStable(); + + await component.dismissBadge(NudgeType.EmptyVaultNudge); + + expect(mockNudges.dismissNudge).not.toHaveBeenCalled(); + }); + + it("showDownloadBitwardenNudge$ proxies to nudges service for the active account", async () => { + const acct = pushActiveAccount("user-xyz"); + + mockNudges.showNudgeBadge$.mockImplementation((type: NudgeType) => + of(type === NudgeType.DownloadBitwarden), + ); + + const fixture = TestBed.createComponent(SettingsV2Component); + const component = fixture.componentInstance; + fixture.detectChanges(); + await fixture.whenStable(); + + const val = await firstValueFrom(component.showDownloadBitwardenNudge$); + expect(val).toBe(true); + expect(mockNudges.showNudgeBadge$).toHaveBeenCalledWith(NudgeType.DownloadBitwarden, acct.id); + }); +}); diff --git a/apps/browser/src/tools/popup/settings/settings-v2.component.ts b/apps/browser/src/tools/popup/settings/settings-v2.component.ts index 1c370381f5..95aeeb2f48 100644 --- a/apps/browser/src/tools/popup/settings/settings-v2.component.ts +++ b/apps/browser/src/tools/popup/settings/settings-v2.component.ts @@ -1,21 +1,31 @@ import { CommonModule } from "@angular/common"; -import { Component, OnInit } from "@angular/core"; +import { ChangeDetectionStrategy, Component } from "@angular/core"; import { RouterModule } from "@angular/router"; import { combineLatest, filter, firstValueFrom, + from, map, Observable, shareReplay, switchMap, } from "rxjs"; +import { PremiumUpgradeDialogComponent } from "@bitwarden/angular/billing/components"; import { JslibModule } from "@bitwarden/angular/jslib.module"; import { NudgesService, NudgeType } from "@bitwarden/angular/vault"; +import { SpotlightComponent } from "@bitwarden/angular/vault/components/spotlight/spotlight.component"; import { Account, AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions"; import { UserId } from "@bitwarden/common/types/guid"; -import { BadgeComponent, ItemModule } from "@bitwarden/components"; +import { + BadgeComponent, + DialogService, + ItemModule, + LinkModule, + TypographyModule, +} from "@bitwarden/components"; import { CurrentAccountComponent } from "../../../auth/popup/account-switching/current-account.component"; import { AutofillBrowserSettingsService } from "../../../autofill/services/autofill-browser-settings.service"; @@ -24,8 +34,6 @@ import { PopOutComponent } from "../../../platform/popup/components/pop-out.comp import { PopupHeaderComponent } from "../../../platform/popup/layout/popup-header.component"; import { PopupPageComponent } from "../../../platform/popup/layout/popup-page.component"; -// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush -// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection @Component({ templateUrl: "settings-v2.component.html", imports: [ @@ -38,18 +46,30 @@ import { PopupPageComponent } from "../../../platform/popup/layout/popup-page.co ItemModule, CurrentAccountComponent, BadgeComponent, + SpotlightComponent, + TypographyModule, + LinkModule, ], + changeDetection: ChangeDetectionStrategy.OnPush, }) -export class SettingsV2Component implements OnInit { +export class SettingsV2Component { NudgeType = NudgeType; - activeUserId: UserId | null = null; - protected isBrowserAutofillSettingOverridden = false; + + protected isBrowserAutofillSettingOverridden$ = from( + this.autofillBrowserSettingsService.isBrowserAutofillSettingOverridden( + BrowserApi.getBrowserClientVendor(window), + ), + ); private authenticatedAccount$: Observable = this.accountService.activeAccount$.pipe( filter((account): account is Account => account !== null), shareReplay({ bufferSize: 1, refCount: true }), ); + protected hasPremium$ = this.authenticatedAccount$.pipe( + switchMap((account) => this.accountProfileStateService.hasPremiumFromAnySource$(account.id)), + ); + showDownloadBitwardenNudge$: Observable = this.authenticatedAccount$.pipe( switchMap((account) => this.nudgesService.showNudgeBadge$(NudgeType.DownloadBitwarden, account.id), @@ -79,13 +99,12 @@ export class SettingsV2Component implements OnInit { private readonly nudgesService: NudgesService, private readonly accountService: AccountService, private readonly autofillBrowserSettingsService: AutofillBrowserSettingsService, + private readonly accountProfileStateService: BillingAccountProfileStateService, + private readonly dialogService: DialogService, ) {} - async ngOnInit() { - this.isBrowserAutofillSettingOverridden = - await this.autofillBrowserSettingsService.isBrowserAutofillSettingOverridden( - BrowserApi.getBrowserClientVendor(window), - ); + protected openUpgradeDialog() { + PremiumUpgradeDialogComponent.open(this.dialogService); } async dismissBadge(type: NudgeType) { diff --git a/apps/browser/src/vault/popup/settings/more-from-bitwarden-page-v2.component.html b/apps/browser/src/vault/popup/settings/more-from-bitwarden-page-v2.component.html index a2d01ce752..a8ed75b5de 100644 --- a/apps/browser/src/vault/popup/settings/more-from-bitwarden-page-v2.component.html +++ b/apps/browser/src/vault/popup/settings/more-from-bitwarden-page-v2.component.html @@ -6,12 +6,6 @@ - - - {{ "premiumMembership" | i18n }} - - - ; protected familySponsorshipAvailable$: Observable; protected isFreeFamilyPolicyEnabled$: Observable; protected hasSingleEnterpriseOrg$: Observable; constructor( private dialogService: DialogService, - private billingAccountProfileStateService: BillingAccountProfileStateService, private environmentService: EnvironmentService, private organizationService: OrganizationService, private familiesPolicyService: FamiliesPolicyService, @@ -48,13 +45,6 @@ export class MoreFromBitwardenPageV2Component { this.familySponsorshipAvailable$ = getUserId(this.accountService.activeAccount$).pipe( switchMap((userId) => this.organizationService.familySponsorshipAvailable$(userId)), ); - this.canAccessPremium$ = this.accountService.activeAccount$.pipe( - switchMap((account) => - account - ? this.billingAccountProfileStateService.hasPremiumFromAnySource$(account.id) - : of(false), - ), - ); this.hasSingleEnterpriseOrg$ = this.familiesPolicyService.hasSingleEnterpriseOrg$(); this.isFreeFamilyPolicyEnabled$ = this.familiesPolicyService.isFreeFamilyPolicyEnabled$(); } diff --git a/libs/angular/src/vault/components/spotlight/spotlight.component.html b/libs/angular/src/vault/components/spotlight/spotlight.component.html index 720bf5c190..92b88eb967 100644 --- a/libs/angular/src/vault/components/spotlight/spotlight.component.html +++ b/libs/angular/src/vault/components/spotlight/spotlight.component.html @@ -3,20 +3,20 @@ >
-

{{ title }}

+

{{ title() }}

- +
diff --git a/libs/angular/src/vault/components/spotlight/spotlight.component.spec.ts b/libs/angular/src/vault/components/spotlight/spotlight.component.spec.ts new file mode 100644 index 0000000000..3d4d35fdf6 --- /dev/null +++ b/libs/angular/src/vault/components/spotlight/spotlight.component.spec.ts @@ -0,0 +1,208 @@ +import { CommonModule } from "@angular/common"; +import { ChangeDetectionStrategy, Component } from "@angular/core"; +import { ComponentFixture, TestBed } from "@angular/core/testing"; +import { By } from "@angular/platform-browser"; + +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; + +import { SpotlightComponent } from "./spotlight.component"; + +describe("SpotlightComponent", () => { + let fixture: ComponentFixture; + let component: SpotlightComponent; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [SpotlightComponent], + providers: [{ provide: I18nService, useValue: { t: (key: string) => key } }], + }).compileComponents(); + + fixture = TestBed.createComponent(SpotlightComponent); + component = fixture.componentInstance; + }); + + function detect(): void { + fixture.detectChanges(); + } + + it("should create", () => { + expect(component).toBeTruthy(); + }); + + describe("rendering when inputs are null", () => { + it("should render without crashing when inputs are null/undefined", () => { + // Explicitly drive the inputs to null to exercise template null branches + fixture.componentRef.setInput("title", null); + fixture.componentRef.setInput("subtitle", null); + fixture.componentRef.setInput("buttonText", null); + fixture.componentRef.setInput("buttonIcon", null); + // persistent has a default, but drive it as well for coverage sanity + fixture.componentRef.setInput("persistent", false); + + expect(() => detect()).not.toThrow(); + + const root = fixture.debugElement.nativeElement as HTMLElement; + expect(root).toBeTruthy(); + }); + }); + + describe("close button visibility based on persistent", () => { + it("should show the close button when persistent is false", () => { + fixture.componentRef.setInput("persistent", false); + detect(); + + // Assumes dismiss uses bitIconButton + const dismissButton = fixture.debugElement.query(By.css("button[bitIconButton]")); + + expect(dismissButton).toBeTruthy(); + }); + + it("should hide the close button when persistent is true", () => { + fixture.componentRef.setInput("persistent", true); + detect(); + + const dismissButton = fixture.debugElement.query(By.css("button[bitIconButton]")); + expect(dismissButton).toBeNull(); + }); + }); + + describe("event emission", () => { + it("should emit onButtonClick when CTA button is clicked", () => { + const clickSpy = jest.fn(); + component.onButtonClick.subscribe(clickSpy); + + fixture.componentRef.setInput("buttonText", "Click me"); + detect(); + + const buttonDe = fixture.debugElement.query(By.css("button[bitButton]")); + expect(buttonDe).toBeTruthy(); + + const event = new MouseEvent("click"); + buttonDe.triggerEventHandler("click", event); + + expect(clickSpy).toHaveBeenCalledTimes(1); + expect(clickSpy.mock.calls[0][0]).toBeInstanceOf(MouseEvent); + }); + + it("should emit onDismiss when close button is clicked", () => { + const dismissSpy = jest.fn(); + component.onDismiss.subscribe(dismissSpy); + + fixture.componentRef.setInput("persistent", false); + detect(); + + const dismissButton = fixture.debugElement.query(By.css("button[bitIconButton]")); + expect(dismissButton).toBeTruthy(); + + dismissButton.triggerEventHandler("click", new MouseEvent("click")); + + expect(dismissSpy).toHaveBeenCalledTimes(1); + }); + + it("handleButtonClick should emit via onButtonClick()", () => { + const clickSpy = jest.fn(); + component.onButtonClick.subscribe(clickSpy); + + const event = new MouseEvent("click"); + component.handleButtonClick(event); + + expect(clickSpy).toHaveBeenCalledTimes(1); + expect(clickSpy.mock.calls[0][0]).toBe(event); + }); + + it("handleDismiss should emit via onDismiss()", () => { + const dismissSpy = jest.fn(); + component.onDismiss.subscribe(dismissSpy); + + component.handleDismiss(); + + expect(dismissSpy).toHaveBeenCalledTimes(1); + }); + }); + + describe("content projection behavior", () => { + @Component({ + standalone: true, + imports: [SpotlightComponent], + changeDetection: ChangeDetectionStrategy.OnPush, + template: ` + + Projected content + + `, + }) + class HostWithProjectionComponent {} + + let hostFixture: ComponentFixture; + + beforeEach(async () => { + hostFixture = TestBed.createComponent(HostWithProjectionComponent); + }); + + it("should render projected content inside the spotlight", () => { + hostFixture.detectChanges(); + + const projected = hostFixture.debugElement.query(By.css(".tw-text-sm")); + expect(projected).toBeTruthy(); + expect(projected.nativeElement.textContent.trim()).toBe("Projected content"); + }); + }); + + describe("boolean attribute transform for persistent", () => { + @Component({ + standalone: true, + imports: [CommonModule, SpotlightComponent], + changeDetection: ChangeDetectionStrategy.OnPush, + template: ` + + + + + + + + + `, + }) + class BooleanHostComponent { + mode: "bare" | "none" | "falseStr" = "bare"; + } + + let boolFixture: ComponentFixture; + let boolHost: BooleanHostComponent; + + beforeEach(async () => { + boolFixture = TestBed.createComponent(BooleanHostComponent); + boolHost = boolFixture.componentInstance; + }); + + function getSpotlight(): SpotlightComponent { + const de = boolFixture.debugElement.query(By.directive(SpotlightComponent)); + return de.componentInstance as SpotlightComponent; + } + + it("treats bare 'persistent' attribute as true via booleanAttribute", () => { + boolHost.mode = "bare"; + boolFixture.detectChanges(); + + const spotlight = getSpotlight(); + expect(spotlight.persistent()).toBe(true); + }); + + it("uses default false when 'persistent' is omitted", () => { + boolHost.mode = "none"; + boolFixture.detectChanges(); + + const spotlight = getSpotlight(); + expect(spotlight.persistent()).toBe(false); + }); + + it('treats persistent="false" as false', () => { + boolHost.mode = "falseStr"; + boolFixture.detectChanges(); + + const spotlight = getSpotlight(); + expect(spotlight.persistent()).toBe(false); + }); + }); +}); diff --git a/libs/angular/src/vault/components/spotlight/spotlight.component.ts b/libs/angular/src/vault/components/spotlight/spotlight.component.ts index a912e4ce11..1b75e1ee73 100644 --- a/libs/angular/src/vault/components/spotlight/spotlight.component.ts +++ b/libs/angular/src/vault/components/spotlight/spotlight.component.ts @@ -1,43 +1,28 @@ import { CommonModule } from "@angular/common"; -import { Component, EventEmitter, Input, Output } from "@angular/core"; +import { booleanAttribute, ChangeDetectionStrategy, Component, input, output } from "@angular/core"; import { ButtonModule, IconButtonModule, TypographyModule } from "@bitwarden/components"; import { I18nPipe } from "@bitwarden/ui-common"; -// 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: "bit-spotlight", templateUrl: "spotlight.component.html", imports: [ButtonModule, CommonModule, IconButtonModule, I18nPipe, TypographyModule], + changeDetection: ChangeDetectionStrategy.OnPush, }) export class SpotlightComponent { // The title of the component - // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals - // eslint-disable-next-line @angular-eslint/prefer-signals - @Input({ required: true }) title: string | null = null; + readonly title = input(); // The subtitle of the component - // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals - // eslint-disable-next-line @angular-eslint/prefer-signals - @Input() subtitle?: string | null = null; + readonly subtitle = input(); // The text to display on the button - // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals - // eslint-disable-next-line @angular-eslint/prefer-signals - @Input() buttonText?: string; - // Wheter the component can be dismissed, if true, the component will not show a close button - // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals - // eslint-disable-next-line @angular-eslint/prefer-signals - @Input() persistent = false; + readonly buttonText = input(); + // Whether the component can be dismissed, if true, the component will not show a close button + readonly persistent = input(false, { transform: booleanAttribute }); // Optional icon to display on the button - // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals - // eslint-disable-next-line @angular-eslint/prefer-signals - @Input() buttonIcon: string | null = null; - // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals - // eslint-disable-next-line @angular-eslint/prefer-output-emitter-ref - @Output() onDismiss = new EventEmitter(); - // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals - // eslint-disable-next-line @angular-eslint/prefer-output-emitter-ref - @Output() onButtonClick = new EventEmitter(); + readonly buttonIcon = input(); + readonly onDismiss = output(); + readonly onButtonClick = output(); handleButtonClick(event: MouseEvent): void { this.onButtonClick.emit(event);