mirror of
https://github.com/bitwarden/browser
synced 2025-12-06 00:13:28 +00:00
[PM-24722][PM-27695] - add persistent callout in settings for non-premium users (#17246)
* add persistent callout in settings for non-premium users * remove premium v2 component * add spec * remove premium-v2.component.html * fix title * fix typo * conditionally render h2 * re-add pemiumv2component. change class prop to observable * change from bold to semibold * remove unecessary tw classes. use transform: booleanAttribute * add spotlight specs * code cleanup
This commit is contained in:
@@ -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"
|
||||
},
|
||||
|
||||
@@ -1,4 +1,19 @@
|
||||
<popup-page>
|
||||
<bit-spotlight *ngIf="!(hasPremium$ | async)" persistent>
|
||||
<span class="tw-text-xs"
|
||||
>{{ "unlockFeaturesWithPremium" | i18n }}
|
||||
<button
|
||||
bitLink
|
||||
buttonType="primary"
|
||||
class="tw-text-xs"
|
||||
type="button"
|
||||
(click)="openUpgradeDialog()"
|
||||
[title]="'upgradeNow' | i18n"
|
||||
>
|
||||
{{ "upgradeNow" | i18n }}
|
||||
</button>
|
||||
</span>
|
||||
</bit-spotlight>
|
||||
<popup-header slot="header" pageTitle="{{ 'settings' | i18n }}">
|
||||
<ng-container slot="end">
|
||||
<app-pop-out></app-pop-out>
|
||||
@@ -20,7 +35,7 @@
|
||||
<div class="tw-flex tw-items-center tw-justify-center">
|
||||
<p class="tw-pr-2">{{ "autofill" | i18n }}</p>
|
||||
<span
|
||||
*ngIf="!isBrowserAutofillSettingOverridden && (showAutofillBadge$ | async)"
|
||||
*ngIf="!(isBrowserAutofillSettingOverridden$ | async) && (showAutofillBadge$ | async)"
|
||||
bitBadge
|
||||
variant="notification"
|
||||
[attr.aria-label]="'nudgeBadgeAria' | i18n"
|
||||
|
||||
@@ -0,0 +1,260 @@
|
||||
import { ChangeDetectionStrategy, Component, CUSTOM_ELEMENTS_SCHEMA } from "@angular/core";
|
||||
import { TestBed, waitForAsync } from "@angular/core/testing";
|
||||
import { RouterTestingModule } from "@angular/router/testing";
|
||||
import { mock, MockProxy } from "jest-mock-extended";
|
||||
import { BehaviorSubject, firstValueFrom, of, Subject } from "rxjs";
|
||||
|
||||
import { PremiumUpgradeDialogComponent } from "@bitwarden/angular/billing/components";
|
||||
import { NudgesService, NudgeType } from "@bitwarden/angular/vault";
|
||||
import { AutofillBrowserSettingsService } from "@bitwarden/browser/autofill/services/autofill-browser-settings.service";
|
||||
import { BrowserApi } from "@bitwarden/browser/platform/browser/browser-api";
|
||||
import { Account, AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||
import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service";
|
||||
import { AvatarService } from "@bitwarden/common/auth/abstractions/avatar.service";
|
||||
import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
|
||||
import { DialogService } from "@bitwarden/components";
|
||||
import { GlobalStateProvider } from "@bitwarden/state";
|
||||
import { FakeGlobalStateProvider } from "@bitwarden/state-test-utils";
|
||||
|
||||
import { CurrentAccountComponent } from "../../../auth/popup/account-switching/current-account.component";
|
||||
|
||||
import { SettingsV2Component } from "./settings-v2.component";
|
||||
|
||||
@Component({
|
||||
selector: "app-current-account",
|
||||
standalone: true,
|
||||
template: "",
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
class CurrentAccountStubComponent {}
|
||||
|
||||
describe("SettingsV2Component", () => {
|
||||
let account$: BehaviorSubject<Account | null>;
|
||||
let mockAccountService: Partial<AccountService>;
|
||||
let mockBillingState: { hasPremiumFromAnySource$: jest.Mock };
|
||||
let mockNudges: {
|
||||
showNudgeBadge$: jest.Mock;
|
||||
dismissNudge: jest.Mock;
|
||||
};
|
||||
let mockAutofillSettings: {
|
||||
defaultBrowserAutofillDisabled$: Subject<boolean>;
|
||||
isBrowserAutofillSettingOverridden: jest.Mock<Promise<boolean>>;
|
||||
};
|
||||
let dialogService: MockProxy<DialogService>;
|
||||
let openSpy: jest.SpyInstance;
|
||||
|
||||
beforeEach(waitForAsync(async () => {
|
||||
dialogService = mock<DialogService>();
|
||||
account$ = new BehaviorSubject<Account | null>(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<boolean>(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<PlatformUtilsService>() },
|
||||
{ provide: AvatarService, useValue: mock<AvatarService>() },
|
||||
{ provide: AuthService, useValue: mock<AuthService>() },
|
||||
],
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -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<Account> = 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<boolean> = 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) {
|
||||
|
||||
@@ -6,12 +6,6 @@
|
||||
</popup-header>
|
||||
|
||||
<bit-item-group>
|
||||
<bit-item *ngIf="!(canAccessPremium$ | async)">
|
||||
<a type="button" bit-item-content routerLink="/premium">
|
||||
{{ "premiumMembership" | i18n }}
|
||||
<i slot="end" class="bwi bwi-angle-right" aria-hidden="true"></i>
|
||||
</a>
|
||||
</bit-item>
|
||||
<bit-item
|
||||
*ngIf="
|
||||
(familySponsorshipAvailable$ | async) &&
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
import { CommonModule } from "@angular/common";
|
||||
import { Component } from "@angular/core";
|
||||
import { RouterModule } from "@angular/router";
|
||||
import { Observable, firstValueFrom, of, switchMap } from "rxjs";
|
||||
import { Observable, firstValueFrom, switchMap } from "rxjs";
|
||||
|
||||
import { JslibModule } from "@bitwarden/angular/jslib.module";
|
||||
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
|
||||
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||
import { getUserId } from "@bitwarden/common/auth/services/account.service";
|
||||
import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service";
|
||||
import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service";
|
||||
import { DialogService, ItemModule } from "@bitwarden/components";
|
||||
|
||||
@@ -32,14 +31,12 @@ import { PopupPageComponent } from "../../../platform/popup/layout/popup-page.co
|
||||
],
|
||||
})
|
||||
export class MoreFromBitwardenPageV2Component {
|
||||
canAccessPremium$: Observable<boolean>;
|
||||
protected familySponsorshipAvailable$: Observable<boolean>;
|
||||
protected isFreeFamilyPolicyEnabled$: Observable<boolean>;
|
||||
protected hasSingleEnterpriseOrg$: Observable<boolean>;
|
||||
|
||||
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$();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user