1
0
mirror of https://github.com/bitwarden/browser synced 2026-02-20 11:24:07 +00:00

[PM-31974] - Vault Welcome dialog (#18960)

* premium upgrade prompt and onboarding dialog

* finalize onboard vault dialog

* vault welcome dialog no ext

* finish welcome dialog prompt

* revert changes to unified upgrade prompt service

* rename component

* rename feature flag

* add welcome dialog service

* fix tests

* fix footer position in welcome dialog

* present dialog in order

* fix tests

* fix padding
This commit is contained in:
Jordan Aasen
2026-02-19 09:29:54 -08:00
committed by GitHub
parent d0ccb9cd31
commit 46a2af38a0
12 changed files with 425 additions and 5 deletions

View File

@@ -2860,6 +2860,9 @@
"reviewAtRiskLoginSlideImgAltPeriod": {
"message": "Illustration of a list of logins that are at-risk."
},
"welcomeDialogGraphicAlt": {
"message": "Illustration of the layout of the Bitwarden vault page."
},
"generatePasswordSlideDesc": {
"message": "Quickly generate a strong, unique password with the Bitwarden autofill menu on the at-risk site.",
"description": "Description of the generate password slide on the at-risk password page carousel"

View File

@@ -0,0 +1,27 @@
<bit-dialog dialogSize="large" class="tw-p-0">
<div bitDialogContent class="tw-p-2 tw-pb-0">
<img
class="tw-max-w-full"
src="../../../../images/welcome-dialog-graphic.png"
[alt]="'welcomeDialogGraphicAlt' | i18n"
/>
<div class="tw-px-12 tw-pt-6 tw-text-center">
<div class="tw-px-12">
<h2 bitTypography="h2" class="tw-mb-4">
{{ "vaultWelcomeDialogTitle" | i18n }}
</h2>
<p bitTypography="body1" class="tw-mb-0 tw-text-muted">
{{ "vaultWelcomeDialogDescription" | i18n }}
</p>
</div>
</div>
</div>
<div bitDialogFooter class="tw-w-full tw-flex tw-justify-center tw-gap-4 tw-pb-3">
<button bitButton buttonType="secondary" type="button" (click)="onDismiss()">
{{ "vaultWelcomeDialogDismissCta" | i18n }}
</button>
<button bitButton buttonType="primary" type="button" (click)="onPrimaryCta()">
{{ "vaultWelcomeDialogPrimaryCta" | i18n }}
</button>
</div>
</bit-dialog>

View File

@@ -0,0 +1,87 @@
import { ComponentFixture, TestBed } from "@angular/core/testing";
import { BehaviorSubject } from "rxjs";
import { Account, AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { UserId } from "@bitwarden/common/types/guid";
import { DialogRef } from "@bitwarden/components";
import { StateProvider } from "@bitwarden/state";
import {
VaultWelcomeDialogComponent,
VaultWelcomeDialogResult,
} from "./vault-welcome-dialog.component";
describe("VaultWelcomeDialogComponent", () => {
let component: VaultWelcomeDialogComponent;
let fixture: ComponentFixture<VaultWelcomeDialogComponent>;
const mockUserId = "user-123" as UserId;
const activeAccount$ = new BehaviorSubject<Account | null>({
id: mockUserId,
} as Account);
const setUserState = jest.fn().mockResolvedValue([mockUserId, true]);
const close = jest.fn();
beforeEach(async () => {
jest.clearAllMocks();
await TestBed.configureTestingModule({
imports: [VaultWelcomeDialogComponent],
providers: [
{ provide: AccountService, useValue: { activeAccount$ } },
{ provide: StateProvider, useValue: { setUserState } },
{ provide: DialogRef, useValue: { close } },
{ provide: I18nService, useValue: { t: (key: string) => key } },
],
}).compileComponents();
fixture = TestBed.createComponent(VaultWelcomeDialogComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
describe("onDismiss", () => {
it("should set acknowledged state and close with Dismissed result", async () => {
await component["onDismiss"]();
expect(setUserState).toHaveBeenCalledWith(
expect.objectContaining({ key: "vaultWelcomeDialogAcknowledged" }),
true,
mockUserId,
);
expect(close).toHaveBeenCalledWith(VaultWelcomeDialogResult.Dismissed);
});
it("should throw if no active account", async () => {
activeAccount$.next(null);
await expect(component["onDismiss"]()).rejects.toThrow("Null or undefined account");
expect(setUserState).not.toHaveBeenCalled();
});
});
describe("onPrimaryCta", () => {
it("should set acknowledged state and close with GetStarted result", async () => {
activeAccount$.next({ id: mockUserId } as Account);
await component["onPrimaryCta"]();
expect(setUserState).toHaveBeenCalledWith(
expect.objectContaining({ key: "vaultWelcomeDialogAcknowledged" }),
true,
mockUserId,
);
expect(close).toHaveBeenCalledWith(VaultWelcomeDialogResult.GetStarted);
});
it("should throw if no active account", async () => {
activeAccount$.next(null);
await expect(component["onPrimaryCta"]()).rejects.toThrow("Null or undefined account");
expect(setUserState).not.toHaveBeenCalled();
});
});
});

View File

@@ -0,0 +1,69 @@
import { CommonModule } from "@angular/common";
import { ChangeDetectionStrategy, Component, inject } from "@angular/core";
import { firstValueFrom } from "rxjs";
import { JslibModule } from "@bitwarden/angular/jslib.module";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { getUserId } from "@bitwarden/common/auth/services/account.service";
import {
ButtonModule,
DialogModule,
DialogRef,
DialogService,
TypographyModule,
CenterPositionStrategy,
} from "@bitwarden/components";
import { StateProvider, UserKeyDefinition, VAULT_WELCOME_DIALOG_DISK } from "@bitwarden/state";
export const VaultWelcomeDialogResult = {
Dismissed: "dismissed",
GetStarted: "getStarted",
} as const;
export type VaultWelcomeDialogResult =
(typeof VaultWelcomeDialogResult)[keyof typeof VaultWelcomeDialogResult];
const VAULT_WELCOME_DIALOG_ACKNOWLEDGED_KEY = new UserKeyDefinition<boolean>(
VAULT_WELCOME_DIALOG_DISK,
"vaultWelcomeDialogAcknowledged",
{
deserializer: (value) => value,
clearOn: [],
},
);
@Component({
selector: "app-vault-welcome-dialog",
templateUrl: "./vault-welcome-dialog.component.html",
changeDetection: ChangeDetectionStrategy.OnPush,
standalone: true,
imports: [CommonModule, DialogModule, ButtonModule, TypographyModule, JslibModule],
})
export class VaultWelcomeDialogComponent {
private accountService = inject(AccountService);
private stateProvider = inject(StateProvider);
constructor(private dialogRef: DialogRef<VaultWelcomeDialogResult>) {}
protected async onDismiss(): Promise<void> {
await this.setAcknowledged();
this.dialogRef.close(VaultWelcomeDialogResult.Dismissed);
}
protected async onPrimaryCta(): Promise<void> {
await this.setAcknowledged();
this.dialogRef.close(VaultWelcomeDialogResult.GetStarted);
}
private async setAcknowledged(): Promise<void> {
const userId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId));
await this.stateProvider.setUserState(VAULT_WELCOME_DIALOG_ACKNOWLEDGED_KEY, true, userId);
}
static open(dialogService: DialogService): DialogRef<VaultWelcomeDialogResult> {
return dialogService.open<VaultWelcomeDialogResult>(VaultWelcomeDialogComponent, {
disableClose: true,
positionStrategy: new CenterPositionStrategy(),
});
}
}

View File

@@ -7,7 +7,7 @@ import { PolicyService } from "@bitwarden/common/admin-console/abstractions/poli
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 { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { Account, AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { UserId } from "@bitwarden/common/types/guid";
import { DialogRef, DialogService } from "@bitwarden/components";
@@ -21,6 +21,7 @@ import {
import { UnifiedUpgradePromptService } from "../../billing/individual/upgrade/services";
import { WebVaultPromptService } from "./web-vault-prompt.service";
import { WelcomeDialogService } from "./welcome-dialog.service";
describe("WebVaultPromptService", () => {
let service: WebVaultPromptService;
@@ -38,20 +39,33 @@ describe("WebVaultPromptService", () => {
);
const upsertAutoConfirm = jest.fn().mockResolvedValue(undefined);
const organizations$ = jest.fn().mockReturnValue(of([]));
const displayUpgradePromptConditionally = jest.fn().mockResolvedValue(undefined);
const displayUpgradePromptConditionally = jest.fn().mockResolvedValue(false);
const enforceOrganizationDataOwnership = jest.fn().mockResolvedValue(undefined);
const conditionallyShowWelcomeDialog = jest.fn().mockResolvedValue(false);
const logError = jest.fn();
let activeAccount$: BehaviorSubject<Account | null>;
function createAccount(overrides: Partial<Account> = {}): Account {
return {
id: mockUserId,
creationDate: new Date(),
...overrides,
} as Account;
}
beforeEach(() => {
jest.clearAllMocks();
activeAccount$ = new BehaviorSubject<Account | null>(createAccount());
TestBed.configureTestingModule({
providers: [
WebVaultPromptService,
{ provide: UnifiedUpgradePromptService, useValue: { displayUpgradePromptConditionally } },
{ provide: VaultItemsTransferService, useValue: { enforceOrganizationDataOwnership } },
{ provide: PolicyService, useValue: { policies$ } },
{ provide: AccountService, useValue: { activeAccount$: of({ id: mockUserId }) } },
{ provide: AccountService, useValue: { activeAccount$ } },
{
provide: AutomaticUserConfirmationService,
useValue: { configuration$: configurationAutoConfirm$, upsert: upsertAutoConfirm },
@@ -60,6 +74,7 @@ describe("WebVaultPromptService", () => {
{ provide: ConfigService, useValue: { getFeatureFlag$ } },
{ provide: DialogService, useValue: { open } },
{ provide: LogService, useValue: { error: logError } },
{ provide: WelcomeDialogService, useValue: { conditionallyShowWelcomeDialog } },
],
});

View File

@@ -20,6 +20,8 @@ import {
} from "../../admin-console/organizations/policies";
import { UnifiedUpgradePromptService } from "../../billing/individual/upgrade/services";
import { WelcomeDialogService } from "./welcome-dialog.service";
@Injectable()
export class WebVaultPromptService {
private unifiedUpgradePromptService = inject(UnifiedUpgradePromptService);
@@ -31,6 +33,7 @@ export class WebVaultPromptService {
private configService = inject(ConfigService);
private dialogService = inject(DialogService);
private logService = inject(LogService);
private welcomeDialogService = inject(WelcomeDialogService);
private userId$ = this.accountService.activeAccount$.pipe(getUserId);
@@ -46,9 +49,13 @@ export class WebVaultPromptService {
async conditionallyPromptUser() {
const userId = await firstValueFrom(this.userId$);
void this.unifiedUpgradePromptService.displayUpgradePromptConditionally();
if (await this.unifiedUpgradePromptService.displayUpgradePromptConditionally()) {
return;
}
void this.vaultItemTransferService.enforceOrganizationDataOwnership(userId);
await this.vaultItemTransferService.enforceOrganizationDataOwnership(userId);
await this.welcomeDialogService.conditionallyShowWelcomeDialog();
this.checkForAutoConfirm();
}

View File

@@ -0,0 +1,123 @@
import { TestBed } from "@angular/core/testing";
import { BehaviorSubject, of } from "rxjs";
import { Account, 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 { UserId } from "@bitwarden/common/types/guid";
import { DialogRef, DialogService } from "@bitwarden/components";
import { StateProvider } from "@bitwarden/state";
import { VaultWelcomeDialogComponent } from "../components/vault-welcome-dialog/vault-welcome-dialog.component";
import { WelcomeDialogService } from "./welcome-dialog.service";
describe("WelcomeDialogService", () => {
let service: WelcomeDialogService;
const mockUserId = "user-123" as UserId;
const getFeatureFlag = jest.fn().mockResolvedValue(false);
const getUserState$ = jest.fn().mockReturnValue(of(false));
const mockDialogOpen = jest.spyOn(VaultWelcomeDialogComponent, "open");
let activeAccount$: BehaviorSubject<Account | null>;
function createAccount(overrides: Partial<Account> = {}): Account {
return {
id: mockUserId,
creationDate: new Date(),
...overrides,
} as Account;
}
beforeEach(() => {
jest.clearAllMocks();
mockDialogOpen.mockReset();
activeAccount$ = new BehaviorSubject<Account | null>(createAccount());
TestBed.configureTestingModule({
providers: [
WelcomeDialogService,
{ provide: AccountService, useValue: { activeAccount$ } },
{ provide: ConfigService, useValue: { getFeatureFlag } },
{ provide: DialogService, useValue: {} },
{ provide: StateProvider, useValue: { getUserState$ } },
],
});
service = TestBed.inject(WelcomeDialogService);
});
describe("conditionallyShowWelcomeDialog", () => {
it("should not show dialog when no active account", async () => {
activeAccount$.next(null);
await service.conditionallyShowWelcomeDialog();
expect(mockDialogOpen).not.toHaveBeenCalled();
});
it("should not show dialog when feature flag is disabled", async () => {
getFeatureFlag.mockResolvedValueOnce(false);
await service.conditionallyShowWelcomeDialog();
expect(getFeatureFlag).toHaveBeenCalledWith(FeatureFlag.PM29437_WelcomeDialog);
expect(mockDialogOpen).not.toHaveBeenCalled();
});
it("should not show dialog when account has no creation date", async () => {
activeAccount$.next(createAccount({ creationDate: undefined }));
getFeatureFlag.mockResolvedValueOnce(true);
await service.conditionallyShowWelcomeDialog();
expect(mockDialogOpen).not.toHaveBeenCalled();
});
it("should not show dialog when account is older than 30 days", async () => {
const overThirtyDaysAgo = new Date(Date.now() - 1000 * 60 * 60 * 24 * 30 - 1000);
activeAccount$.next(createAccount({ creationDate: overThirtyDaysAgo }));
getFeatureFlag.mockResolvedValueOnce(true);
await service.conditionallyShowWelcomeDialog();
expect(mockDialogOpen).not.toHaveBeenCalled();
});
it("should not show dialog when user has already acknowledged it", async () => {
activeAccount$.next(createAccount({ creationDate: new Date() }));
getFeatureFlag.mockResolvedValueOnce(true);
getUserState$.mockReturnValueOnce(of(true));
await service.conditionallyShowWelcomeDialog();
expect(mockDialogOpen).not.toHaveBeenCalled();
});
it("should show dialog for new user who has not acknowledged", async () => {
activeAccount$.next(createAccount({ creationDate: new Date() }));
getFeatureFlag.mockResolvedValueOnce(true);
getUserState$.mockReturnValueOnce(of(false));
mockDialogOpen.mockReturnValue({ closed: of(undefined) } as DialogRef<any>);
await service.conditionallyShowWelcomeDialog();
expect(mockDialogOpen).toHaveBeenCalled();
});
it("should show dialog for account created exactly 30 days ago", async () => {
const exactlyThirtyDaysAgo = new Date(Date.now() - 1000 * 60 * 60 * 24 * 30);
activeAccount$.next(createAccount({ creationDate: exactlyThirtyDaysAgo }));
getFeatureFlag.mockResolvedValueOnce(true);
getUserState$.mockReturnValueOnce(of(false));
mockDialogOpen.mockReturnValue({ closed: of(undefined) } as DialogRef<any>);
await service.conditionallyShowWelcomeDialog();
expect(mockDialogOpen).toHaveBeenCalled();
});
});
});

View File

@@ -0,0 +1,72 @@
import { inject, Injectable } from "@angular/core";
import { firstValueFrom, map } from "rxjs";
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 { DialogService } from "@bitwarden/components";
import { StateProvider, UserKeyDefinition, VAULT_WELCOME_DIALOG_DISK } from "@bitwarden/state";
import { VaultWelcomeDialogComponent } from "../components/vault-welcome-dialog/vault-welcome-dialog.component";
const VAULT_WELCOME_DIALOG_ACKNOWLEDGED_KEY = new UserKeyDefinition<boolean>(
VAULT_WELCOME_DIALOG_DISK,
"vaultWelcomeDialogAcknowledged",
{
deserializer: (value) => value,
clearOn: [],
},
);
const THIRTY_DAY_MS = 1000 * 60 * 60 * 24 * 30;
@Injectable({ providedIn: "root" })
export class WelcomeDialogService {
private accountService = inject(AccountService);
private configService = inject(ConfigService);
private dialogService = inject(DialogService);
private stateProvider = inject(StateProvider);
/**
* Conditionally shows the welcome dialog to new users.
*
* @returns true if the dialog was shown, false otherwise
*/
async conditionallyShowWelcomeDialog() {
const account = await firstValueFrom(this.accountService.activeAccount$);
if (!account) {
return;
}
const enabled = await this.configService.getFeatureFlag(FeatureFlag.PM29437_WelcomeDialog);
if (!enabled) {
return;
}
const createdAt = account.creationDate;
if (!createdAt) {
return;
}
const ageMs = Date.now() - createdAt.getTime();
const isNewUser = ageMs >= 0 && ageMs <= THIRTY_DAY_MS;
if (!isNewUser) {
return;
}
const acknowledged = await firstValueFrom(
this.stateProvider
.getUserState$(VAULT_WELCOME_DIALOG_ACKNOWLEDGED_KEY, account.id)
.pipe(map((v) => v ?? false)),
);
if (acknowledged) {
return;
}
const dialogRef = VaultWelcomeDialogComponent.open(this.dialogService);
await firstValueFrom(dialogRef.closed);
return;
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 93 KiB

View File

@@ -12920,6 +12920,18 @@
"invalidSendPassword": {
"message": "Invalid Send password"
},
"vaultWelcomeDialogTitle": {
"message": "You're in! Welcome to Bitwarden"
},
"vaultWelcomeDialogDescription": {
"message": "Store all your passwords and personal info in your Bitwarden vault. We'll show you around."
},
"vaultWelcomeDialogPrimaryCta": {
"message": "Start tour"
},
"vaultWelcomeDialogDismissCta": {
"message": "Skip"
},
"sendPasswordHelperText": {
"message": "Individuals will need to enter the password to view this Send",
"description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated."

View File

@@ -69,6 +69,7 @@ export enum FeatureFlag {
BrowserPremiumSpotlight = "pm-23384-browser-premium-spotlight",
MigrateMyVaultToMyItems = "pm-20558-migrate-myvault-to-myitems",
PM27632_SdkCipherCrudOperations = "pm-27632-cipher-crud-operations-to-sdk",
PM29437_WelcomeDialog = "pm-29437-welcome-dialog-no-ext-prompt",
PM31039ItemActionInExtension = "pm-31039-item-action-in-extension",
/* Platform */
@@ -135,6 +136,7 @@ export const DefaultFeatureFlagValue = {
[FeatureFlag.BrowserPremiumSpotlight]: FALSE,
[FeatureFlag.PM27632_SdkCipherCrudOperations]: FALSE,
[FeatureFlag.MigrateMyVaultToMyItems]: FALSE,
[FeatureFlag.PM29437_WelcomeDialog]: FALSE,
/* Auth */
[FeatureFlag.PM23801_PrefetchPasswordPrelogin]: FALSE,

View File

@@ -212,6 +212,9 @@ export const SETUP_EXTENSION_DISMISSED_DISK = new StateDefinition(
web: "disk-local",
},
);
export const VAULT_WELCOME_DIALOG_DISK = new StateDefinition("vaultWelcomeDialog", "disk", {
web: "disk-local",
});
export const VAULT_BROWSER_INTRO_CAROUSEL = new StateDefinition(
"vaultBrowserIntroCarousel",
"disk",