[PM-34615] - add ability to toggle interstitial prompts (#20293)

* add flag to disable all interstitial prompts

* remove changes to search service

* fix type error

* check for auto confirm and enforce data ownership

* fix type error

* create ServerSettingsResponse
This commit is contained in:
Jordan Aasen 2026-06-02 11:30:59 -07:00 committed by GitHub
parent 8b48304c99
commit 0ef50abdf6
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
13 changed files with 150 additions and 8 deletions

View File

@ -4,8 +4,10 @@ import { of, BehaviorSubject } from "rxjs";
import { PremiumUpsellService } from "@bitwarden/angular/vault";
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
import { AccountService, Account } from "@bitwarden/common/auth/abstractions/account.service";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { ServerSettings } from "@bitwarden/common/platform/models/domain/server-settings";
import { SyncService } from "@bitwarden/common/platform/sync/sync.service";
import { DialogRef, DialogService } from "@bitwarden/components";
import { StateProvider } from "@bitwarden/state";
@ -31,6 +33,7 @@ describe("UnifiedUpgradePromptService", () => {
const mockStateProvider = mock<StateProvider>();
const mockLogService = mock<LogService>();
const mockPremiumUpsellService = mock<PremiumUpsellService>();
const mockConfigService = mock<ConfigService>();
/**
* Creates a mock DialogRef that implements the required properties for testing
@ -61,6 +64,7 @@ describe("UnifiedUpgradePromptService", () => {
mockStateProvider,
mockLogService,
mockPremiumUpsellService,
mockConfigService,
);
}
@ -74,6 +78,7 @@ describe("UnifiedUpgradePromptService", () => {
mockAccountService.activeAccount$ = accountSubject.asObservable();
mockPlatformUtilsService.isSelfHost.mockReturnValue(false);
mockStateProvider.getUserState$.mockReturnValue(of(false));
mockConfigService.serverSettings$ = of(new ServerSettings());
setupTestService();
});
@ -103,6 +108,9 @@ describe("UnifiedUpgradePromptService", () => {
// Default: showUpsell returns false
mockPremiumUpsellService.showUpsell.mockReturnValue(false);
// Default: server settings do not suppress onboarding interstitials
mockConfigService.serverSettings$ = of(new ServerSettings());
});
it("should subscribe to account observables when checking display conditions", async () => {
// Arrange
@ -276,5 +284,20 @@ describe("UnifiedUpgradePromptService", () => {
// Assert
expect(mockStateProvider.setUserState).not.toHaveBeenCalled();
});
it("should not show dialog when suppressOnboardingInterstitials is enabled", async () => {
// Arrange
mockConfigService.serverSettings$ = of(
new ServerSettings({ suppressOnboardingInterstitials: true }),
);
setupTestService();
// Act
const result = await sut.displayUpgradePromptConditionally();
// Assert
expect(result).toBeNull();
expect(mockDialogOpen).not.toHaveBeenCalled();
});
});
});

View File

@ -5,6 +5,7 @@ import { filter, switchMap, take, map } from "rxjs/operators";
import { PremiumUpsellService } from "@bitwarden/angular/vault";
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { SyncService } from "@bitwarden/common/platform/sync/sync.service";
@ -42,6 +43,7 @@ export class UnifiedUpgradePromptService {
private stateProvider: StateProvider,
private logService: LogService,
private premiumUpsellService: PremiumUpsellService,
private configService: ConfigService,
) {}
private shouldShowPrompt$: Observable<boolean> = this.accountService.activeAccount$.pipe(
@ -74,6 +76,11 @@ export class UnifiedUpgradePromptService {
* @returns A promise that resolves to the dialog result if shown, or null if not shown
*/
async displayUpgradePromptConditionally(): Promise<UnifiedUpgradeDialogResult | null> {
const serverSettings = await firstValueFrom(this.configService.serverSettings$);
if (serverSettings?.suppressOnboardingInterstitials) {
return null;
}
const shouldShow = await firstValueFrom(this.shouldShowPrompt$);
if (shouldShow) {

View File

@ -4,7 +4,9 @@ import { BehaviorSubject, of } from "rxjs";
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
import { Account, AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { ServerSettings } from "@bitwarden/common/platform/models/domain/server-settings";
import { UserId } from "@bitwarden/common/types/guid";
import { StateProvider } from "@bitwarden/state";
@ -23,6 +25,7 @@ describe("CoachmarkService", () => {
const t = jest.fn((key: string) => key);
let activeAccount$: BehaviorSubject<Account | null>;
let serverSettings$: BehaviorSubject<ServerSettings | null>;
function createAccount(overrides: Partial<Account> = {}): Account {
return {
@ -36,6 +39,7 @@ describe("CoachmarkService", () => {
jest.clearAllMocks();
activeAccount$ = new BehaviorSubject<Account | null>(createAccount());
serverSettings$ = new BehaviorSubject<ServerSettings | null>(new ServerSettings());
TestBed.configureTestingModule({
providers: [
@ -45,6 +49,7 @@ describe("CoachmarkService", () => {
{ provide: StateProvider, useValue: { getUserState$, setUserState } },
{ provide: I18nService, useValue: { t } },
{ provide: Router, useValue: { navigate } },
{ provide: ConfigService, useValue: { serverSettings$: serverSettings$.asObservable() } },
],
});
@ -128,6 +133,16 @@ describe("CoachmarkService", () => {
expect(navigate).not.toHaveBeenCalled();
}));
it("should not start if suppressOnboardingInterstitials is enabled", fakeAsync(() => {
serverSettings$.next(new ServerSettings({ suppressOnboardingInterstitials: true }));
void service.startTour();
tick(200);
expect(service.isRunning()).toBe(false);
expect(navigate).not.toHaveBeenCalled();
}));
it("should not start if there is no active account", fakeAsync(() => {
activeAccount$.next(null);

View File

@ -5,6 +5,7 @@ import { map } from "rxjs/operators";
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { StateProvider, UserKeyDefinition, VAULT_WELCOME_DIALOG_DISK } from "@bitwarden/state";
@ -52,6 +53,7 @@ export class CoachmarkService {
private stateProvider: StateProvider,
private i18nService: I18nService,
private router: Router,
private configService: ConfigService,
) {}
/**
@ -102,6 +104,11 @@ export class CoachmarkService {
return;
}
const serverSettings = await firstValueFrom(this.configService.serverSettings$);
if (serverSettings?.suppressOnboardingInterstitials) {
return;
}
const account = await firstValueFrom(this.accountService.activeAccount$);
if (!account) {
return;

View File

@ -4,7 +4,9 @@ import { BehaviorSubject } from "rxjs";
import { VaultProfileService } from "@bitwarden/angular/vault/services/vault-profile.service";
import { Account, AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { Utils } from "@bitwarden/common/platform/misc/utils";
import { ServerSettings } from "@bitwarden/common/platform/models/domain/server-settings";
import { StateProvider } from "@bitwarden/common/platform/state";
import { WebBrowserInteractionService } from "../services/web-browser-interaction.service";
@ -27,11 +29,14 @@ describe("setupExtensionRedirectGuard", () => {
const createUrlTree = jest.fn();
const getProfileCreationDate = jest.fn().mockResolvedValue(seventeenDaysAgo);
let serverSettings$: BehaviorSubject<ServerSettings | null>;
beforeEach(() => {
Utils.isMobileBrowser = false;
getProfileCreationDate.mockClear();
createUrlTree.mockClear();
serverSettings$ = new BehaviorSubject<ServerSettings | null>(new ServerSettings());
TestBed.configureTestingModule({
providers: [
@ -43,6 +48,10 @@ describe("setupExtensionRedirectGuard", () => {
provide: VaultProfileService,
useValue: { getProfileCreationDate },
},
{
provide: ConfigService,
useValue: { serverSettings$: serverSettings$.asObservable() },
},
],
});
});
@ -91,6 +100,16 @@ describe("setupExtensionRedirectGuard", () => {
expect(createUrlTree).toHaveBeenCalledWith(["/setup-extension"]);
});
it("returns `true` when suppressOnboardingInterstitials is enabled", async () => {
state$.next(false);
serverSettings$.next(new ServerSettings({ suppressOnboardingInterstitials: true }));
const result = await setupExtensionGuard();
expect(result).toBe(true);
expect(createUrlTree).not.toHaveBeenCalled();
});
describe("missing current account", () => {
afterAll(() => {
// reset `activeAccount$` observable

View File

@ -4,6 +4,7 @@ import { firstValueFrom, map } from "rxjs";
import { VaultProfileService } from "@bitwarden/angular/vault/services/vault-profile.service";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { Utils } from "@bitwarden/common/platform/misc/utils";
import {
SETUP_EXTENSION_DISMISSED_DISK,
@ -23,6 +24,7 @@ export const SETUP_EXTENSION_DISMISSED = new UserKeyDefinition<boolean>(
export const setupExtensionRedirectGuard: CanActivateFn = async () => {
const router = inject(Router);
const accountService = inject(AccountService);
const configService = inject(ConfigService);
const vaultProfileService = inject(VaultProfileService);
const stateProvider = inject(StateProvider);
@ -34,6 +36,11 @@ export const setupExtensionRedirectGuard: CanActivateFn = async () => {
return true;
}
const serverSettings = await firstValueFrom(configService.serverSettings$);
if (serverSettings?.suppressOnboardingInterstitials) {
return true;
}
const currentAcct = await firstValueFrom(accountService.activeAccount$);
if (!currentAcct) {

View File

@ -3,6 +3,7 @@ import { BehaviorSubject } from "rxjs";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { ServerSettings } from "@bitwarden/common/platform/models/domain/server-settings";
import { UserId } from "@bitwarden/common/types/guid";
import { DialogService } from "@bitwarden/components";
import { StateProvider } from "@bitwarden/state";
@ -27,11 +28,14 @@ describe("WebVaultExtensionPromptService", () => {
});
const getUser = jest.fn().mockReturnValue({ state$: mockStateSubject.asObservable() });
let serverSettings$: BehaviorSubject<ServerSettings | null>;
beforeEach(() => {
jest.clearAllMocks();
extensionInstalled$.next(false);
mockStateSubject.next(false);
activeAccountSubject.next({ id: mockUserId, creationDate: mockAccountCreationDate });
serverSettings$ = new BehaviorSubject<ServerSettings | null>(new ServerSettings());
TestBed.configureTestingModule({
providers: [
@ -58,6 +62,7 @@ describe("WebVaultExtensionPromptService", () => {
provide: ConfigService,
useValue: {
getFeatureFlag,
serverSettings$: serverSettings$.asObservable(),
},
},
{

View File

@ -8,6 +8,8 @@ import { PolicyType } from "@bitwarden/common/admin-console/enums";
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
import { Policy } from "@bitwarden/common/admin-console/models/domain/policy";
import { Account, AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { ServerSettings } from "@bitwarden/common/platform/models/domain/server-settings";
import { UserId } from "@bitwarden/common/types/guid";
import { DialogRef, DialogService } from "@bitwarden/components";
import { LogService } from "@bitwarden/logging";
@ -44,6 +46,7 @@ describe("WebVaultPromptService", () => {
const logError = jest.fn();
const conditionallyPromptUserForExtension = jest.fn().mockResolvedValue(false);
let serverSettings$: BehaviorSubject<ServerSettings | null>;
let activeAccount$: BehaviorSubject<Account | null>;
function createAccount(overrides: Partial<Account> = {}): Account {
@ -58,6 +61,7 @@ describe("WebVaultPromptService", () => {
jest.clearAllMocks();
activeAccount$ = new BehaviorSubject<Account | null>(createAccount());
serverSettings$ = new BehaviorSubject<ServerSettings | null>(new ServerSettings());
TestBed.configureTestingModule({
providers: [
@ -66,6 +70,7 @@ describe("WebVaultPromptService", () => {
{ provide: VaultItemsTransferService, useValue: { enforceOrganizationDataOwnership } },
{ provide: PolicyService, useValue: { policies$ } },
{ provide: AccountService, useValue: { activeAccount$ } },
{ provide: ConfigService, useValue: { serverSettings$: serverSettings$.asObservable() } },
{
provide: AutomaticUserConfirmationService,
useValue: { configuration$: configurationAutoConfirm$, upsert: upsertAutoConfirm },
@ -111,6 +116,17 @@ describe("WebVaultPromptService", () => {
service["webVaultExtensionPromptService"].conditionallyPromptUserForExtension,
).toHaveBeenCalledWith(mockUserId);
});
it("skips onboarding prompts but not policy flows when suppressOnboardingInterstitials is enabled", async () => {
serverSettings$.next(new ServerSettings({ suppressOnboardingInterstitials: true }));
await service.conditionallyPromptUser();
expect(enforceOrganizationDataOwnership).toHaveBeenCalledWith(mockUserId);
expect(displayUpgradePromptConditionally).not.toHaveBeenCalled();
expect(conditionallyShowWelcomeDialog).not.toHaveBeenCalled();
expect(conditionallyPromptUserForExtension).not.toHaveBeenCalled();
});
});
describe("setupAutoConfirm", () => {

View File

@ -8,6 +8,7 @@ import { PolicyType } from "@bitwarden/common/admin-console/enums";
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { getUserId } from "@bitwarden/common/auth/services/account.service";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { DialogService } from "@bitwarden/components";
import { LogService } from "@bitwarden/logging";
import { VaultItemsTransferService } from "@bitwarden/vault";
@ -27,6 +28,7 @@ export class WebVaultPromptService {
private vaultItemTransferService = inject(VaultItemsTransferService);
private policyService = inject(PolicyService);
private accountService = inject(AccountService);
private configService = inject(ConfigService);
private autoConfirmService = inject(AutomaticUserConfirmationService);
private organizationService = inject(OrganizationService);
private dialogService = inject(DialogService);
@ -48,17 +50,22 @@ export class WebVaultPromptService {
async conditionallyPromptUser() {
const userId = await firstValueFrom(this.userId$);
await this.vaultItemTransferService.enforceOrganizationDataOwnership(userId);
this.checkForAutoConfirm();
const serverSettings = await firstValueFrom(this.configService.serverSettings$);
if (serverSettings?.suppressOnboardingInterstitials) {
return;
}
if (await this.unifiedUpgradePromptService.displayUpgradePromptConditionally()) {
return;
}
await this.vaultItemTransferService.enforceOrganizationDataOwnership(userId);
await this.welcomeDialogService.conditionallyShowWelcomeDialog();
await this.webVaultExtensionPromptService.conditionallyPromptUserForExtension(userId);
this.checkForAutoConfirm();
}
private openAutoConfirmFeatureDialog(organization: Organization) {

View File

@ -19,6 +19,7 @@ describe("ServerConfigData", () => {
},
settings: {
disableUserRegistration: false,
suppressOnboardingInterstitials: false,
},
environment: {
cloudRegion: Region.EU,

View File

@ -17,4 +17,21 @@ describe("ServerSettings", () => {
expect(settings.disableUserRegistration).toBe(false);
});
});
describe("suppressOnboardingInterstitials", () => {
it("defaults suppressOnboardingInterstitials to false", () => {
const settings = new ServerSettings();
expect(settings.suppressOnboardingInterstitials).toBe(false);
});
it("sets suppressOnboardingInterstitials to true when provided", () => {
const settings = new ServerSettings({ suppressOnboardingInterstitials: true });
expect(settings.suppressOnboardingInterstitials).toBe(true);
});
it("sets suppressOnboardingInterstitials to false when provided", () => {
const settings = new ServerSettings({ suppressOnboardingInterstitials: false });
expect(settings.suppressOnboardingInterstitials).toBe(false);
});
});
});

View File

@ -1,7 +1,9 @@
export class ServerSettings {
disableUserRegistration: boolean;
suppressOnboardingInterstitials: boolean;
constructor(data?: ServerSettings) {
constructor(data?: Partial<ServerSettings>) {
this.disableUserRegistration = data?.disableUserRegistration ?? false;
this.suppressOnboardingInterstitials = data?.suppressOnboardingInterstitials ?? false;
}
}

View File

@ -3,7 +3,6 @@
import { AllowedFeatureFlagTypes } from "../../../enums/feature-flag.enum";
import { BaseResponse } from "../../../models/response/base.response";
import { Region } from "../../abstractions/environment.service";
import { ServerSettings } from "../domain/server-settings";
export class ServerConfigResponse extends BaseResponse {
version: string;
@ -12,7 +11,7 @@ export class ServerConfigResponse extends BaseResponse {
environment: EnvironmentServerConfigResponse;
featureStates: { [key: string]: AllowedFeatureFlagTypes } = {};
push: PushSettingsConfigResponse;
settings: ServerSettings;
settings: ServerSettingsResponse;
communication: CommunicationServerConfigResponse;
constructor(response: any) {
@ -28,13 +27,30 @@ export class ServerConfigResponse extends BaseResponse {
this.environment = new EnvironmentServerConfigResponse(this.getResponseProperty("Environment"));
this.featureStates = this.getResponseProperty("FeatureStates");
this.push = new PushSettingsConfigResponse(this.getResponseProperty("Push"));
this.settings = new ServerSettings(this.getResponseProperty("Settings"));
this.settings = new ServerSettingsResponse(this.getResponseProperty("Settings"));
this.communication = new CommunicationServerConfigResponse(
this.getResponseProperty("Communication"),
);
}
}
export class ServerSettingsResponse extends BaseResponse {
disableUserRegistration: boolean = false;
suppressOnboardingInterstitials: boolean = false;
constructor(response: any) {
super(response);
if (response == null) {
return;
}
this.disableUserRegistration = this.getResponseProperty("DisableUserRegistration") ?? false;
this.suppressOnboardingInterstitials =
this.getResponseProperty("SuppressOnboardingInterstitials") ?? false;
}
}
export class PushSettingsConfigResponse extends BaseResponse {
pushTechnology: number;
vapidPublicKey: string;