diff --git a/apps/browser/src/_locales/en/messages.json b/apps/browser/src/_locales/en/messages.json
index fbfaa17a87d..51ca51960d7 100644
--- a/apps/browser/src/_locales/en/messages.json
+++ b/apps/browser/src/_locales/en/messages.json
@@ -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"
diff --git a/apps/web/src/app/vault/components/vault-welcome-dialog/vault-welcome-dialog.component.html b/apps/web/src/app/vault/components/vault-welcome-dialog/vault-welcome-dialog.component.html
new file mode 100644
index 00000000000..3304fa3e3cc
--- /dev/null
+++ b/apps/web/src/app/vault/components/vault-welcome-dialog/vault-welcome-dialog.component.html
@@ -0,0 +1,27 @@
+
+
+
+
+
+
+ {{ "vaultWelcomeDialogTitle" | i18n }}
+
+
+ {{ "vaultWelcomeDialogDescription" | i18n }}
+
+
+
+
+
+
+
+
+
diff --git a/apps/web/src/app/vault/components/vault-welcome-dialog/vault-welcome-dialog.component.spec.ts b/apps/web/src/app/vault/components/vault-welcome-dialog/vault-welcome-dialog.component.spec.ts
new file mode 100644
index 00000000000..bc0142b374d
--- /dev/null
+++ b/apps/web/src/app/vault/components/vault-welcome-dialog/vault-welcome-dialog.component.spec.ts
@@ -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;
+
+ const mockUserId = "user-123" as UserId;
+ const activeAccount$ = new BehaviorSubject({
+ 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();
+ });
+ });
+});
diff --git a/apps/web/src/app/vault/components/vault-welcome-dialog/vault-welcome-dialog.component.ts b/apps/web/src/app/vault/components/vault-welcome-dialog/vault-welcome-dialog.component.ts
new file mode 100644
index 00000000000..d43ea5165f7
--- /dev/null
+++ b/apps/web/src/app/vault/components/vault-welcome-dialog/vault-welcome-dialog.component.ts
@@ -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(
+ 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) {}
+
+ protected async onDismiss(): Promise {
+ await this.setAcknowledged();
+ this.dialogRef.close(VaultWelcomeDialogResult.Dismissed);
+ }
+
+ protected async onPrimaryCta(): Promise {
+ await this.setAcknowledged();
+ this.dialogRef.close(VaultWelcomeDialogResult.GetStarted);
+ }
+
+ private async setAcknowledged(): Promise {
+ 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 {
+ return dialogService.open(VaultWelcomeDialogComponent, {
+ disableClose: true,
+ positionStrategy: new CenterPositionStrategy(),
+ });
+ }
+}
diff --git a/apps/web/src/app/vault/services/web-vault-prompt.service.spec.ts b/apps/web/src/app/vault/services/web-vault-prompt.service.spec.ts
index a224b8e7c4b..eb72c80fe04 100644
--- a/apps/web/src/app/vault/services/web-vault-prompt.service.spec.ts
+++ b/apps/web/src/app/vault/services/web-vault-prompt.service.spec.ts
@@ -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;
+
+ function createAccount(overrides: Partial = {}): Account {
+ return {
+ id: mockUserId,
+ creationDate: new Date(),
+ ...overrides,
+ } as Account;
+ }
+
beforeEach(() => {
jest.clearAllMocks();
+ activeAccount$ = new BehaviorSubject(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 } },
],
});
diff --git a/apps/web/src/app/vault/services/web-vault-prompt.service.ts b/apps/web/src/app/vault/services/web-vault-prompt.service.ts
index 1774bfcc085..4c4e7a3fe78 100644
--- a/apps/web/src/app/vault/services/web-vault-prompt.service.ts
+++ b/apps/web/src/app/vault/services/web-vault-prompt.service.ts
@@ -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();
}
diff --git a/apps/web/src/app/vault/services/welcome-dialog.service.spec.ts b/apps/web/src/app/vault/services/welcome-dialog.service.spec.ts
new file mode 100644
index 00000000000..752514ca066
--- /dev/null
+++ b/apps/web/src/app/vault/services/welcome-dialog.service.spec.ts
@@ -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;
+
+ function createAccount(overrides: Partial = {}): Account {
+ return {
+ id: mockUserId,
+ creationDate: new Date(),
+ ...overrides,
+ } as Account;
+ }
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+ mockDialogOpen.mockReset();
+
+ activeAccount$ = new BehaviorSubject(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);
+
+ 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);
+
+ await service.conditionallyShowWelcomeDialog();
+
+ expect(mockDialogOpen).toHaveBeenCalled();
+ });
+ });
+});
diff --git a/apps/web/src/app/vault/services/welcome-dialog.service.ts b/apps/web/src/app/vault/services/welcome-dialog.service.ts
new file mode 100644
index 00000000000..25b24b6df2d
--- /dev/null
+++ b/apps/web/src/app/vault/services/welcome-dialog.service.ts
@@ -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(
+ 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;
+ }
+}
diff --git a/apps/web/src/images/welcome-dialog-graphic.png b/apps/web/src/images/welcome-dialog-graphic.png
new file mode 100644
index 00000000000..fd2a12c5272
Binary files /dev/null and b/apps/web/src/images/welcome-dialog-graphic.png differ
diff --git a/apps/web/src/locales/en/messages.json b/apps/web/src/locales/en/messages.json
index c45d7e5d630..b7ac6da15bc 100644
--- a/apps/web/src/locales/en/messages.json
+++ b/apps/web/src/locales/en/messages.json
@@ -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."
diff --git a/libs/common/src/enums/feature-flag.enum.ts b/libs/common/src/enums/feature-flag.enum.ts
index b7fad43ebbf..7b1013077d7 100644
--- a/libs/common/src/enums/feature-flag.enum.ts
+++ b/libs/common/src/enums/feature-flag.enum.ts
@@ -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,
diff --git a/libs/state/src/core/state-definitions.ts b/libs/state/src/core/state-definitions.ts
index ae6938b2069..30ee2be0592 100644
--- a/libs/state/src/core/state-definitions.ts
+++ b/libs/state/src/core/state-definitions.ts
@@ -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",