1
0
mirror of https://github.com/bitwarden/browser synced 2026-02-04 10:43:47 +00:00

Remove premium banner from vault page

This commit is contained in:
Cy Okeke
2025-09-30 14:36:58 +01:00
parent 8095ac3ed2
commit 8fc96888c2
5 changed files with 10 additions and 246 deletions

View File

@@ -1,6 +1,6 @@
import { TestBed } from "@angular/core/testing";
import { mock, MockProxy } from "jest-mock-extended";
import { BehaviorSubject, firstValueFrom, of, take, timeout } from "rxjs";
import { BehaviorSubject, of } from "rxjs";
import {
AuthRequestServiceAbstraction,
@@ -22,11 +22,7 @@ import { UserId } from "@bitwarden/common/types/guid";
import { SyncService } from "@bitwarden/common/vault/abstractions/sync/sync.service.abstraction";
import { KdfConfigService, KdfType } from "@bitwarden/key-management";
import {
PREMIUM_BANNER_REPROMPT_KEY,
VaultBannersService,
VisibleVaultBanner,
} from "./vault-banners.service";
import { VaultBannersService, VisibleVaultBanner } from "./vault-banners.service";
describe("VaultBannersService", () => {
let service: VaultBannersService;
@@ -111,101 +107,6 @@ describe("VaultBannersService", () => {
jest.useRealTimers();
});
describe("Premium", () => {
it("waits until sync is completed before showing premium banner", async () => {
hasPremiumFromAnySource$.next(false);
isSelfHost.mockReturnValue(false);
lastSync$.next(null);
service = TestBed.inject(VaultBannersService);
const premiumBanner$ = service.shouldShowPremiumBanner$(userId);
// Should not emit when sync is null
await expect(firstValueFrom(premiumBanner$.pipe(take(1), timeout(100)))).rejects.toThrow();
// Should emit when sync is completed
lastSync$.next(new Date("2024-05-14"));
expect(await firstValueFrom(premiumBanner$)).toBe(true);
});
it("does not show a premium banner for self-hosted users", async () => {
hasPremiumFromAnySource$.next(false);
isSelfHost.mockReturnValue(true);
service = TestBed.inject(VaultBannersService);
expect(await firstValueFrom(service.shouldShowPremiumBanner$(userId))).toBe(false);
});
it("does not show a premium banner when they have access to premium", async () => {
hasPremiumFromAnySource$.next(true);
isSelfHost.mockReturnValue(false);
service = TestBed.inject(VaultBannersService);
expect(await firstValueFrom(service.shouldShowPremiumBanner$(userId))).toBe(false);
});
describe("dismissing", () => {
beforeEach(async () => {
jest.useFakeTimers();
const date = new Date("2023-06-08");
date.setHours(0, 0, 0, 0);
jest.setSystemTime(date.getTime());
service = TestBed.inject(VaultBannersService);
await service.dismissBanner(userId, VisibleVaultBanner.Premium);
});
afterEach(() => {
jest.useRealTimers();
});
it("updates state on first dismiss", async () => {
const state = await firstValueFrom(
fakeStateProvider.getUser(userId, PREMIUM_BANNER_REPROMPT_KEY).state$,
);
const oneWeekLater = new Date("2023-06-15");
oneWeekLater.setHours(0, 0, 0, 0);
expect(state).toEqual({
numberOfDismissals: 1,
nextPromptDate: oneWeekLater.getTime(),
});
});
it("updates state on second dismiss", async () => {
const state = await firstValueFrom(
fakeStateProvider.getUser(userId, PREMIUM_BANNER_REPROMPT_KEY).state$,
);
const oneMonthLater = new Date("2023-07-08");
oneMonthLater.setHours(0, 0, 0, 0);
expect(state).toEqual({
numberOfDismissals: 2,
nextPromptDate: oneMonthLater.getTime(),
});
});
it("updates state on third dismiss", async () => {
const state = await firstValueFrom(
fakeStateProvider.getUser(userId, PREMIUM_BANNER_REPROMPT_KEY).state$,
);
const oneYearLater = new Date("2024-06-08");
oneYearLater.setHours(0, 0, 0, 0);
expect(state).toEqual({
numberOfDismissals: 3,
nextPromptDate: oneYearLater.getTime(),
});
});
});
});
describe("KDFSettings", () => {
beforeEach(async () => {
userDecryptionOptions$.next({ hasMasterPassword: true });

View File

@@ -1,5 +1,5 @@
import { Injectable } from "@angular/core";
import { Observable, combineLatest, firstValueFrom, map, filter, mergeMap, take } from "rxjs";
import { firstValueFrom, map } from "rxjs";
import {
AuthRequestServiceAbstraction,
@@ -13,7 +13,6 @@ import { ConfigService } from "@bitwarden/common/platform/abstractions/config/co
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import {
StateProvider,
PREMIUM_BANNER_DISK_LOCAL,
BANNERS_DISMISSED_DISK,
UserKeyDefinition,
SingleUserState,
@@ -26,30 +25,14 @@ import { PBKDF2KdfConfig, KdfConfigService, KdfType } from "@bitwarden/key-manag
export const VisibleVaultBanner = {
KDFSettings: "kdf-settings",
OutdatedBrowser: "outdated-browser",
Premium: "premium",
VerifyEmail: "verify-email",
PendingAuthRequest: "pending-auth-request",
} as const;
export type VisibleVaultBanner = UnionOfValues<typeof VisibleVaultBanner>;
type PremiumBannerReprompt = {
numberOfDismissals: number;
/** Timestamp representing when to show the prompt next */
nextPromptDate: number;
};
/** Banners that will be re-shown on a new session */
type SessionBanners = Omit<VisibleVaultBanner, typeof VisibleVaultBanner.Premium>;
export const PREMIUM_BANNER_REPROMPT_KEY = new UserKeyDefinition<PremiumBannerReprompt>(
PREMIUM_BANNER_DISK_LOCAL,
"bannerReprompt",
{
deserializer: (bannerReprompt) => bannerReprompt,
clearOn: [], // Do not clear user tutorials
},
);
type SessionBanners = VisibleVaultBanner;
export const BANNERS_DISMISSED_DISK_KEY = new UserKeyDefinition<SessionBanners[]>(
BANNERS_DISMISSED_DISK,
@@ -100,33 +83,6 @@ export class VaultBannersService {
}
}
shouldShowPremiumBanner$(userId: UserId): Observable<boolean> {
const premiumBannerState = this.premiumBannerState(userId);
const premiumSources$ = combineLatest([
this.billingAccountProfileStateService.hasPremiumFromAnySource$(userId),
premiumBannerState.state$,
]);
return this.syncService.lastSync$(userId).pipe(
filter((lastSync) => lastSync !== null),
take(1), // Wait until the first sync is complete before considering the premium status
mergeMap(() => premiumSources$),
map(([canAccessPremium, dismissedState]) => {
const shouldShowPremiumBanner =
!canAccessPremium && !this.platformUtilsService.isSelfHost();
// Check if nextPromptDate is in the past passed
if (shouldShowPremiumBanner && dismissedState?.nextPromptDate) {
const nextPromptDate = new Date(dismissedState.nextPromptDate);
const now = new Date();
return now >= nextPromptDate;
}
return shouldShowPremiumBanner;
}),
);
}
/** Returns true when the update browser banner should be shown */
async shouldShowUpdateBrowserBanner(userId: UserId): Promise<boolean> {
const outdatedBrowser = window.navigator.userAgent.indexOf("MSIE") !== -1;
@@ -167,23 +123,11 @@ export class VaultBannersService {
/** Dismiss the given banner and perform any respective side effects */
async dismissBanner(userId: UserId, banner: SessionBanners): Promise<void> {
if (banner === VisibleVaultBanner.Premium) {
await this.dismissPremiumBanner(userId);
} else {
await this.sessionBannerState(userId).update((current) => {
const bannersDismissed = current ?? [];
await this.sessionBannerState(userId).update((current) => {
const bannersDismissed = current ?? [];
return [...bannersDismissed, banner];
});
}
}
/**
*
* @returns a SingleUserState for the premium banner reprompt state
*/
private premiumBannerState(userId: UserId): SingleUserState<PremiumBannerReprompt> {
return this.stateProvider.getUser(userId, PREMIUM_BANNER_REPROMPT_KEY);
return [...bannersDismissed, banner];
});
}
/**
@@ -201,44 +145,6 @@ export class VaultBannersService {
return (await firstValueFrom(this.sessionBannerState(userId).state$)) ?? [];
}
/** Increment dismissal state of the premium banner */
private async dismissPremiumBanner(userId: UserId): Promise<void> {
await this.premiumBannerState(userId).update((current) => {
const numberOfDismissals = current?.numberOfDismissals ?? 0;
const now = new Date();
// Set midnight of the current day
now.setHours(0, 0, 0, 0);
// First dismissal, re-prompt in 1 week
if (numberOfDismissals === 0) {
now.setDate(now.getDate() + 7);
return {
numberOfDismissals: 1,
nextPromptDate: now.getTime(),
};
}
// Second dismissal, re-prompt in 1 month
if (numberOfDismissals === 1) {
now.setMonth(now.getMonth() + 1);
return {
numberOfDismissals: 2,
nextPromptDate: now.getTime(),
};
}
// 3+ dismissals, re-prompt each year
// Avoid day/month edge cases and only increment year
const nextYear = new Date(now.getFullYear() + 1, now.getMonth(), now.getDate());
nextYear.setHours(0, 0, 0, 0);
return {
numberOfDismissals: numberOfDismissals + 1,
nextPromptDate: nextYear.getTime(),
};
});
}
private async isLowKdfIteration(userId: UserId) {
const kdfConfig = await firstValueFrom(this.kdfConfigService.getKdfConfig$(userId));
return (

View File

@@ -56,15 +56,3 @@
(onDismiss)="dismissBanner(VisibleVaultBanner.VerifyEmail)"
(onVerified)="dismissBanner(VisibleVaultBanner.VerifyEmail)"
></app-verify-email>
<bit-banner
id="premium-banner"
bannerType="premium"
*ngIf="premiumBannerVisible$ | async"
(onClose)="dismissBanner(VisibleVaultBanner.Premium)"
>
{{ "premiumUpgradeUnlockFeatures" | i18n }}
<a bitLink linkType="secondary" routerLink="/settings/subscription/premium">
{{ "goPremium" | i18n }}
</a>
</bit-banner>

View File

@@ -1,5 +1,4 @@
import { ComponentFixture, TestBed } from "@angular/core/testing";
import { By } from "@angular/platform-browser";
import { RouterTestingModule } from "@angular/router/testing";
import { mock } from "jest-mock-extended";
import { BehaviorSubject, Subject } from "rxjs";
@@ -15,7 +14,7 @@ import { MessageListener } from "@bitwarden/common/platform/messaging";
import { Utils } from "@bitwarden/common/platform/misc/utils";
import { FakeAccountService, mockAccountServiceWith } from "@bitwarden/common/spec";
import { UserId } from "@bitwarden/common/types/guid";
import { BannerComponent, BannerModule } from "@bitwarden/components";
import { BannerModule } from "@bitwarden/components";
import { VerifyEmailComponent } from "../../../auth/settings/verify-email.component";
import { SharedModule } from "../../../shared";
@@ -27,12 +26,10 @@ describe("VaultBannersComponent", () => {
let component: VaultBannersComponent;
let fixture: ComponentFixture<VaultBannersComponent>;
let messageSubject: Subject<{ command: string }>;
const premiumBanner$ = new BehaviorSubject<boolean>(false);
const pendingAuthRequest$ = new BehaviorSubject<boolean>(false);
const mockUserId = Utils.newGuid() as UserId;
const bannerService = mock<VaultBannersService>({
shouldShowPremiumBanner$: jest.fn((userId: UserId) => premiumBanner$),
shouldShowUpdateBrowserBanner: jest.fn(),
shouldShowVerifyEmailBanner: jest.fn(),
shouldShowLowKDFBanner: jest.fn(),
@@ -50,7 +47,6 @@ describe("VaultBannersComponent", () => {
bannerService.shouldShowVerifyEmailBanner.mockResolvedValue(false);
bannerService.shouldShowLowKDFBanner.mockResolvedValue(false);
pendingAuthRequest$.next(false);
premiumBanner$.next(false);
await TestBed.configureTestingModule({
imports: [
@@ -105,26 +101,6 @@ describe("VaultBannersComponent", () => {
fixture.detectChanges();
});
describe("premiumBannerVisible$", () => {
it("shows premium banner", async () => {
premiumBanner$.next(true);
fixture.detectChanges();
const banner = fixture.debugElement.query(By.directive(BannerComponent));
expect(banner.componentInstance.bannerType()).toBe("premium");
});
it("dismisses premium banner", async () => {
premiumBanner$.next(false);
fixture.detectChanges();
const banner = fixture.debugElement.query(By.directive(BannerComponent));
expect(banner).toBeNull();
});
});
describe("determineVisibleBanner", () => {
[
{

View File

@@ -1,14 +1,13 @@
import { Component, Input, OnInit } from "@angular/core";
import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
import { Router } from "@angular/router";
import { filter, firstValueFrom, map, Observable, switchMap } from "rxjs";
import { filter, firstValueFrom, map } from "rxjs";
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { MessageListener } from "@bitwarden/common/platform/messaging";
import { UserId } from "@bitwarden/common/types/guid";
import { BannerModule } from "@bitwarden/components";
import { OrganizationFreeTrialWarningComponent } from "@bitwarden/web-vault/app/billing/organizations/warnings/components";
@@ -30,7 +29,6 @@ import { VaultBannersService, VisibleVaultBanner } from "./services/vault-banner
})
export class VaultBannersComponent implements OnInit {
visibleBanners: VisibleVaultBanner[] = [];
premiumBannerVisible$: Observable<boolean>;
VisibleVaultBanner = VisibleVaultBanner;
@Input() organizations: Organization[] = [];
@@ -43,11 +41,6 @@ export class VaultBannersComponent implements OnInit {
private messageListener: MessageListener,
private configService: ConfigService,
) {
this.premiumBannerVisible$ = this.activeUserId$.pipe(
filter((userId): userId is UserId => userId != null),
switchMap((userId) => this.vaultBannerService.shouldShowPremiumBanner$(userId)),
);
// Listen for auth request messages and show banner immediately
this.messageListener.allMessages$
.pipe(