diff --git a/apps/desktop/src/autofill/services/desktop-autofill.service.ts b/apps/desktop/src/autofill/services/desktop-autofill.service.ts index cca0097d65e..473ce593cb6 100644 --- a/apps/desktop/src/autofill/services/desktop-autofill.service.ts +++ b/apps/desktop/src/autofill/services/desktop-autofill.service.ts @@ -51,7 +51,7 @@ import type { NativeWindowObject } from "./desktop-fido2-user-interface.service" export class DesktopAutofillService implements OnDestroy { private destroy$ = new Subject(); private registrationRequest: autofill.PasskeyRegistrationRequest; - private featureFlag?: FeatureFlag; + private featureFlag?: typeof FeatureFlag.MacOsNativeCredentialSync; private isEnabled: boolean = false; constructor( diff --git a/apps/web/src/app/layouts/product-switcher/navigation-switcher/navigation-switcher.stories.ts b/apps/web/src/app/layouts/product-switcher/navigation-switcher/navigation-switcher.stories.ts index ba36063fb7b..7af255c6823 100644 --- a/apps/web/src/app/layouts/product-switcher/navigation-switcher/navigation-switcher.stories.ts +++ b/apps/web/src/app/layouts/product-switcher/navigation-switcher/navigation-switcher.stories.ts @@ -105,7 +105,7 @@ class MockBillingAccountProfileStateService implements Partial { getFeatureFlag$(key: Flag): Observable> { - return of(false); + return of(false as FeatureFlagValueType); } } diff --git a/apps/web/src/app/layouts/product-switcher/product-switcher.stories.ts b/apps/web/src/app/layouts/product-switcher/product-switcher.stories.ts index ad18b2b3490..7378e619b1a 100644 --- a/apps/web/src/app/layouts/product-switcher/product-switcher.stories.ts +++ b/apps/web/src/app/layouts/product-switcher/product-switcher.stories.ts @@ -100,7 +100,7 @@ class MockBillingAccountProfileStateService implements Partial { getFeatureFlag$(key: Flag): Observable> { - return of(false); + return of(false as FeatureFlagValueType); } } diff --git a/apps/web/src/app/vault/components/web-vault-extension-prompt/web-vault-extension-prompt-dialog.component.html b/apps/web/src/app/vault/components/web-vault-extension-prompt/web-vault-extension-prompt-dialog.component.html new file mode 100644 index 00000000000..e9932ac9022 --- /dev/null +++ b/apps/web/src/app/vault/components/web-vault-extension-prompt/web-vault-extension-prompt-dialog.component.html @@ -0,0 +1,34 @@ +
+ +
+

+ {{ "extensionPromptHeading" | i18n }} +

+

+ {{ "extensionPromptBody" | i18n }} +

+
+ + + + {{ "downloadExtension" | i18n }} + + +
+
+
diff --git a/apps/web/src/app/vault/components/web-vault-extension-prompt/web-vault-extension-prompt-dialog.component.spec.ts b/apps/web/src/app/vault/components/web-vault-extension-prompt/web-vault-extension-prompt-dialog.component.spec.ts new file mode 100644 index 00000000000..fdf218d8c35 --- /dev/null +++ b/apps/web/src/app/vault/components/web-vault-extension-prompt/web-vault-extension-prompt-dialog.component.spec.ts @@ -0,0 +1,86 @@ +import { ComponentFixture, TestBed } from "@angular/core/testing"; +import { provideNoopAnimations } from "@angular/platform-browser/animations"; +import { mock, MockProxy } from "jest-mock-extended"; + +import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { DeviceType } from "@bitwarden/common/enums"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; +import { mockAccountServiceWith } from "@bitwarden/common/spec"; +import { UserId } from "@bitwarden/common/types/guid"; +import { DialogRef, DialogService } from "@bitwarden/components"; + +import { WebVaultExtensionPromptService } from "../../services/web-vault-extension-prompt.service"; + +import { WebVaultExtensionPromptDialogComponent } from "./web-vault-extension-prompt-dialog.component"; + +describe("WebVaultExtensionPromptDialogComponent", () => { + let component: WebVaultExtensionPromptDialogComponent; + let fixture: ComponentFixture; + let mockDialogRef: MockProxy>; + + const mockUserId = "test-user-id" as UserId; + + const getDevice = jest.fn(() => DeviceType.ChromeBrowser); + const mockUpdate = jest.fn().mockResolvedValue(undefined); + + const getDialogDismissedState = jest.fn().mockReturnValue({ + update: mockUpdate, + }); + + beforeEach(async () => { + const mockAccountService = mockAccountServiceWith(mockUserId); + mockDialogRef = mock>(); + + await TestBed.configureTestingModule({ + imports: [WebVaultExtensionPromptDialogComponent], + providers: [ + provideNoopAnimations(), + { + provide: PlatformUtilsService, + useValue: { getDevice }, + }, + { provide: I18nService, useValue: { t: (key: string) => key } }, + { provide: AccountService, useValue: mockAccountService }, + { provide: DialogRef, useValue: mockDialogRef }, + { provide: DialogService, useValue: mock() }, + { + provide: WebVaultExtensionPromptService, + useValue: { getDialogDismissedState }, + }, + ], + }).compileComponents(); + + fixture = TestBed.createComponent(WebVaultExtensionPromptDialogComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + describe("ngOnInit", () => { + it("sets webStoreUrl", () => { + expect(getDevice).toHaveBeenCalled(); + + expect(component["webStoreUrl"]).toBe( + "https://chromewebstore.google.com/detail/bitwarden-password-manage/nngceckbapebfimnlniiiahkandclblb", + ); + }); + }); + + describe("dismissPrompt", () => { + it("calls webVaultExtensionPromptService.getDialogDismissedState and updates to true", async () => { + await component.dismissPrompt(); + + expect(getDialogDismissedState).toHaveBeenCalledWith(mockUserId); + expect(mockUpdate).toHaveBeenCalledWith(expect.any(Function)); + + const updateFn = mockUpdate.mock.calls[0][0]; + expect(updateFn()).toBe(true); + }); + + it("closes the dialog", async () => { + await component.dismissPrompt(); + + expect(mockDialogRef.close).toHaveBeenCalled(); + }); + }); +}); diff --git a/apps/web/src/app/vault/components/web-vault-extension-prompt/web-vault-extension-prompt-dialog.component.ts b/apps/web/src/app/vault/components/web-vault-extension-prompt/web-vault-extension-prompt-dialog.component.ts new file mode 100644 index 00000000000..e5dcf5e3cf2 --- /dev/null +++ b/apps/web/src/app/vault/components/web-vault-extension-prompt/web-vault-extension-prompt-dialog.component.ts @@ -0,0 +1,51 @@ +import { CommonModule } from "@angular/common"; +import { Component, ChangeDetectionStrategy, OnInit } from "@angular/core"; +import { firstValueFrom } from "rxjs"; + +import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { getUserId } from "@bitwarden/common/auth/services/account.service"; +import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; +import { getWebStoreUrl } from "@bitwarden/common/vault/utils/get-web-store-url"; +import { + ButtonModule, + DialogModule, + DialogRef, + DialogService, + IconComponent, +} from "@bitwarden/components"; +import { I18nPipe } from "@bitwarden/ui-common"; + +import { WebVaultExtensionPromptService } from "../../services/web-vault-extension-prompt.service"; + +@Component({ + selector: "web-vault-extension-prompt-dialog", + templateUrl: "./web-vault-extension-prompt-dialog.component.html", + imports: [CommonModule, ButtonModule, DialogModule, I18nPipe, IconComponent], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class WebVaultExtensionPromptDialogComponent implements OnInit { + constructor( + private platformUtilsService: PlatformUtilsService, + private accountService: AccountService, + private dialogRef: DialogRef, + private webVaultExtensionPromptService: WebVaultExtensionPromptService, + ) {} + + /** Download Url for the extension based on the browser */ + protected webStoreUrl: string = ""; + + ngOnInit(): void { + this.webStoreUrl = getWebStoreUrl(this.platformUtilsService.getDevice()); + } + + async dismissPrompt() { + const userId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId)); + await this.webVaultExtensionPromptService.getDialogDismissedState(userId).update(() => true); + this.dialogRef.close(); + } + + /** Opens the web extension prompt generator dialog. */ + static open(dialogService: DialogService) { + return dialogService.open(WebVaultExtensionPromptDialogComponent); + } +} diff --git a/apps/web/src/app/vault/services/web-vault-extension-prompt.service.spec.ts b/apps/web/src/app/vault/services/web-vault-extension-prompt.service.spec.ts new file mode 100644 index 00000000000..4a8865990df --- /dev/null +++ b/apps/web/src/app/vault/services/web-vault-extension-prompt.service.spec.ts @@ -0,0 +1,269 @@ +import { TestBed } from "@angular/core/testing"; +import { BehaviorSubject } 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 { UserId } from "@bitwarden/common/types/guid"; +import { DialogService } from "@bitwarden/components"; +import { StateProvider } from "@bitwarden/state"; + +import { WebVaultExtensionPromptDialogComponent } from "../components/web-vault-extension-prompt/web-vault-extension-prompt-dialog.component"; + +import { WebBrowserInteractionService } from "./web-browser-interaction.service"; +import { WebVaultExtensionPromptService } from "./web-vault-extension-prompt.service"; + +describe("WebVaultExtensionPromptService", () => { + let service: WebVaultExtensionPromptService; + + const mockUserId = "user-123" as UserId; + const mockAccountCreationDate = new Date("2026-01-15"); + + const getFeatureFlag = jest.fn(); + const extensionInstalled$ = new BehaviorSubject(false); + const mockStateSubject = new BehaviorSubject(false); + const activeAccountSubject = new BehaviorSubject<{ id: UserId; creationDate: Date | null }>({ + id: mockUserId, + creationDate: mockAccountCreationDate, + }); + const getUser = jest.fn().mockReturnValue({ state$: mockStateSubject.asObservable() }); + + beforeEach(() => { + jest.clearAllMocks(); + getFeatureFlag.mockResolvedValue(false); + extensionInstalled$.next(false); + mockStateSubject.next(false); + activeAccountSubject.next({ id: mockUserId, creationDate: mockAccountCreationDate }); + + TestBed.configureTestingModule({ + providers: [ + WebVaultExtensionPromptService, + { + provide: StateProvider, + useValue: { + getUser, + }, + }, + { + provide: WebBrowserInteractionService, + useValue: { + extensionInstalled$: extensionInstalled$.asObservable(), + }, + }, + { + provide: AccountService, + useValue: { + activeAccount$: activeAccountSubject.asObservable(), + }, + }, + { + provide: ConfigService, + useValue: { + getFeatureFlag, + }, + }, + { + provide: DialogService, + useValue: { + open: jest.fn(), + }, + }, + ], + }); + + service = TestBed.inject(WebVaultExtensionPromptService); + }); + + describe("conditionallyPromptUserForExtension", () => { + it("returns false when feature flag is disabled", async () => { + getFeatureFlag.mockResolvedValueOnce(false); + + const result = await service.conditionallyPromptUserForExtension(mockUserId); + + expect(result).toBe(false); + expect(getFeatureFlag).toHaveBeenCalledWith( + FeatureFlag.PM29438_WelcomeDialogWithExtensionPrompt, + ); + }); + + it("returns false when dialog has been dismissed", async () => { + getFeatureFlag.mockResolvedValueOnce(true); + mockStateSubject.next(true); + extensionInstalled$.next(false); + + const result = await service.conditionallyPromptUserForExtension(mockUserId); + + expect(result).toBe(false); + }); + + it("returns false when profile is not within thresholds (too old)", async () => { + getFeatureFlag + .mockResolvedValueOnce(true) // Main feature flag + .mockResolvedValueOnce(0); // Min age days + mockStateSubject.next(false); + extensionInstalled$.next(false); + const oldAccountDate = new Date("2025-12-01"); // More than 30 days old + activeAccountSubject.next({ id: mockUserId, creationDate: oldAccountDate }); + + const result = await service.conditionallyPromptUserForExtension(mockUserId); + + expect(result).toBe(false); + }); + + it("returns false when profile is not within thresholds (too young)", async () => { + getFeatureFlag + .mockResolvedValueOnce(true) // Main feature flag + .mockResolvedValueOnce(10); // Min age days = 10 + mockStateSubject.next(false); + extensionInstalled$.next(false); + const youngAccountDate = new Date(); // Today + youngAccountDate.setDate(youngAccountDate.getDate() - 5); // 5 days old + activeAccountSubject.next({ id: mockUserId, creationDate: youngAccountDate }); + + const result = await service.conditionallyPromptUserForExtension(mockUserId); + + expect(result).toBe(false); + }); + + it("returns false when extension is installed", async () => { + getFeatureFlag + .mockResolvedValueOnce(true) // Main feature flag + .mockResolvedValueOnce(0); // Min age days + mockStateSubject.next(false); + extensionInstalled$.next(true); + + const result = await service.conditionallyPromptUserForExtension(mockUserId); + + expect(result).toBe(false); + }); + + it("returns true and opens dialog when all conditions are met", async () => { + getFeatureFlag + .mockResolvedValueOnce(true) // Main feature flag + .mockResolvedValueOnce(0); // Min age days + mockStateSubject.next(false); + extensionInstalled$.next(false); + + // Set account creation date to be within threshold (15 days old) + const validCreationDate = new Date(); + validCreationDate.setDate(validCreationDate.getDate() - 15); + activeAccountSubject.next({ id: mockUserId, creationDate: validCreationDate }); + + const dialogClosedSubject = new BehaviorSubject(undefined); + const openSpy = jest + .spyOn(WebVaultExtensionPromptDialogComponent, "open") + .mockReturnValue({ closed: dialogClosedSubject.asObservable() } as any); + + const resultPromise = service.conditionallyPromptUserForExtension(mockUserId); + + // Close the dialog + dialogClosedSubject.next(undefined); + + const result = await resultPromise; + + expect(openSpy).toHaveBeenCalledWith(expect.anything()); + expect(result).toBe(true); + }); + }); + + describe("profileIsWithinThresholds", () => { + it("returns false when account is younger than min threshold", async () => { + const minAgeDays = 7; + getFeatureFlag.mockResolvedValueOnce(minAgeDays); + + const recentDate = new Date(); + recentDate.setDate(recentDate.getDate() - 5); // 5 days old + activeAccountSubject.next({ id: mockUserId, creationDate: recentDate }); + + const result = await service["profileIsWithinThresholds"](); + + expect(result).toBe(false); + }); + + it("returns true when account is exactly at min threshold", async () => { + const minAgeDays = 7; + getFeatureFlag.mockResolvedValueOnce(minAgeDays); + + const exactDate = new Date(); + exactDate.setDate(exactDate.getDate() - 7); // Exactly 7 days old + activeAccountSubject.next({ id: mockUserId, creationDate: exactDate }); + + const result = await service["profileIsWithinThresholds"](); + + expect(result).toBe(true); + }); + + it("returns true when account is within the thresholds", async () => { + const minAgeDays = 0; + getFeatureFlag.mockResolvedValueOnce(minAgeDays); + + const validDate = new Date(); + validDate.setDate(validDate.getDate() - 15); // 15 days old (between 0 and 30) + activeAccountSubject.next({ id: mockUserId, creationDate: validDate }); + + const result = await service["profileIsWithinThresholds"](); + + expect(result).toBe(true); + }); + + it("returns false when account is older than max threshold (30 days)", async () => { + const minAgeDays = 0; + getFeatureFlag.mockResolvedValueOnce(minAgeDays); + + const oldDate = new Date(); + oldDate.setDate(oldDate.getDate() - 31); // 31 days old + activeAccountSubject.next({ id: mockUserId, creationDate: oldDate }); + + const result = await service["profileIsWithinThresholds"](); + + expect(result).toBe(false); + }); + + it("returns false when account is exactly 30 days old", async () => { + const minAgeDays = 0; + getFeatureFlag.mockResolvedValueOnce(minAgeDays); + + const exactDate = new Date(); + exactDate.setDate(exactDate.getDate() - 30); // Exactly 30 days old + activeAccountSubject.next({ id: mockUserId, creationDate: exactDate }); + + const result = await service["profileIsWithinThresholds"](); + + expect(result).toBe(false); + }); + + it("uses default min age of 0 when feature flag is null", async () => { + getFeatureFlag.mockResolvedValueOnce(null); + + const recentDate = new Date(); + recentDate.setDate(recentDate.getDate() - 5); // 5 days old + activeAccountSubject.next({ id: mockUserId, creationDate: recentDate }); + + const result = await service["profileIsWithinThresholds"](); + + expect(result).toBe(true); + }); + + it("defaults to false", async () => { + getFeatureFlag.mockResolvedValueOnce(0); + activeAccountSubject.next({ id: mockUserId, creationDate: null }); + + const result = await service["profileIsWithinThresholds"](); + + expect(result).toBe(false); + }); + }); + + describe("getDialogDismissedState", () => { + it("returns the SingleUserState for the dialog dismissed state", () => { + service.getDialogDismissedState(mockUserId); + + expect(getUser).toHaveBeenCalledWith( + mockUserId, + expect.objectContaining({ + key: "vaultWelcomeExtensionDialogDismissed", + }), + ); + }); + }); +}); diff --git a/apps/web/src/app/vault/services/web-vault-extension-prompt.service.ts b/apps/web/src/app/vault/services/web-vault-extension-prompt.service.ts new file mode 100644 index 00000000000..3e13935f94c --- /dev/null +++ b/apps/web/src/app/vault/services/web-vault-extension-prompt.service.ts @@ -0,0 +1,104 @@ +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 { UserId } from "@bitwarden/common/types/guid"; +import { DialogService } from "@bitwarden/components"; +import { StateProvider, UserKeyDefinition, WELCOME_EXTENSION_DIALOG_DISK } from "@bitwarden/state"; + +import { WebVaultExtensionPromptDialogComponent } from "../components/web-vault-extension-prompt/web-vault-extension-prompt-dialog.component"; + +import { WebBrowserInteractionService } from "./web-browser-interaction.service"; + +export const WELCOME_EXTENSION_DIALOG_DISMISSED = new UserKeyDefinition( + WELCOME_EXTENSION_DIALOG_DISK, + "vaultWelcomeExtensionDialogDismissed", + { + deserializer: (dismissed) => dismissed, + clearOn: [], + }, +); + +@Injectable({ providedIn: "root" }) +export class WebVaultExtensionPromptService { + private stateProvider = inject(StateProvider); + private webBrowserInteractionService = inject(WebBrowserInteractionService); + private accountService = inject(AccountService); + private configService = inject(ConfigService); + private dialogService = inject(DialogService); + + /** + * Conditionally prompts the user to install the web extension + */ + async conditionallyPromptUserForExtension(userId: UserId) { + const featureFlagEnabled = await this.configService.getFeatureFlag( + FeatureFlag.PM29438_WelcomeDialogWithExtensionPrompt, + ); + + if (!featureFlagEnabled) { + return false; + } + + // Extension check takes time, trigger it early + const hasExtensionInstalled = firstValueFrom( + this.webBrowserInteractionService.extensionInstalled$, + ); + + const hasDismissedExtensionPrompt = await firstValueFrom( + this.getDialogDismissedState(userId).state$.pipe(map((dismissed) => dismissed ?? false)), + ); + if (hasDismissedExtensionPrompt) { + return false; + } + + const profileIsWithinThresholds = await this.profileIsWithinThresholds(); + if (!profileIsWithinThresholds) { + return false; + } + + if (await hasExtensionInstalled) { + return false; + } + + const dialogRef = WebVaultExtensionPromptDialogComponent.open(this.dialogService); + await firstValueFrom(dialogRef.closed); + + return true; + } + + /** Returns the SingleUserState for the dialog dismissed state */ + getDialogDismissedState(userId: UserId) { + return this.stateProvider.getUser(userId, WELCOME_EXTENSION_DIALOG_DISMISSED); + } + + /** + * Returns true if the user's profile is within the defined thresholds for showing the extension prompt, false otherwise. + * Thresholds are defined as account age between a configurable number of days and 30 days. + */ + private async profileIsWithinThresholds() { + const creationDate = await firstValueFrom( + this.accountService.activeAccount$.pipe(map((account) => account?.creationDate)), + ); + + // When account or creationDate is not available for some reason, + // default to not showing the prompt to avoid disrupting the user. + if (!creationDate) { + return false; + } + + const minAccountAgeDays = await this.configService.getFeatureFlag( + FeatureFlag.PM29438_DialogWithExtensionPromptAccountAge, + ); + + const now = new Date(); + const accountAgeMs = now.getTime() - creationDate.getTime(); + const accountAgeDays = accountAgeMs / (1000 * 60 * 60 * 24); + + const minAgeDays = minAccountAgeDays ?? 0; + const maxAgeDays = 30; + + return accountAgeDays >= minAgeDays && accountAgeDays < maxAgeDays; + } +} 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 eb72c80fe04..14bbc1a86d5 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 @@ -20,6 +20,7 @@ import { } from "../../admin-console/organizations/policies"; import { UnifiedUpgradePromptService } from "../../billing/individual/upgrade/services"; +import { WebVaultExtensionPromptService } from "./web-vault-extension-prompt.service"; import { WebVaultPromptService } from "./web-vault-prompt.service"; import { WelcomeDialogService } from "./welcome-dialog.service"; @@ -43,6 +44,7 @@ describe("WebVaultPromptService", () => { const enforceOrganizationDataOwnership = jest.fn().mockResolvedValue(undefined); const conditionallyShowWelcomeDialog = jest.fn().mockResolvedValue(false); const logError = jest.fn(); + const conditionallyPromptUserForExtension = jest.fn().mockResolvedValue(false); let activeAccount$: BehaviorSubject; @@ -74,7 +76,14 @@ describe("WebVaultPromptService", () => { { provide: ConfigService, useValue: { getFeatureFlag$ } }, { provide: DialogService, useValue: { open } }, { provide: LogService, useValue: { error: logError } }, - { provide: WelcomeDialogService, useValue: { conditionallyShowWelcomeDialog } }, + { + provide: WebVaultExtensionPromptService, + useValue: { conditionallyPromptUserForExtension }, + }, + { + provide: WelcomeDialogService, + useValue: { conditionallyShowWelcomeDialog, conditionallyPromptUserForExtension }, + }, ], }); @@ -97,11 +106,19 @@ describe("WebVaultPromptService", () => { service["vaultItemTransferService"].enforceOrganizationDataOwnership, ).toHaveBeenCalledWith(mockUserId); }); + + it("calls conditionallyPromptUserForExtension with the userId", async () => { + await service.conditionallyPromptUser(); + + expect( + service["webVaultExtensionPromptService"].conditionallyPromptUserForExtension, + ).toHaveBeenCalledWith(mockUserId); + }); }); describe("setupAutoConfirm", () => { it("shows dialog when all conditions are met", fakeAsync(() => { - getFeatureFlag$.mockReturnValueOnce(of(true)); + getFeatureFlag$.mockReturnValue(of(true)); configurationAutoConfirm$.mockReturnValueOnce( of({ showSetupDialog: true, enabled: false, showBrowserNotification: false }), ); 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 4c4e7a3fe78..aac30e7d0f4 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,7 @@ import { } from "../../admin-console/organizations/policies"; import { UnifiedUpgradePromptService } from "../../billing/individual/upgrade/services"; +import { WebVaultExtensionPromptService } from "./web-vault-extension-prompt.service"; import { WelcomeDialogService } from "./welcome-dialog.service"; @Injectable() @@ -33,6 +34,7 @@ export class WebVaultPromptService { private configService = inject(ConfigService); private dialogService = inject(DialogService); private logService = inject(LogService); + private webVaultExtensionPromptService = inject(WebVaultExtensionPromptService); private welcomeDialogService = inject(WelcomeDialogService); private userId$ = this.accountService.activeAccount$.pipe(getUserId); @@ -57,6 +59,8 @@ export class WebVaultPromptService { await this.welcomeDialogService.conditionallyShowWelcomeDialog(); + await this.webVaultExtensionPromptService.conditionallyPromptUserForExtension(userId); + this.checkForAutoConfirm(); } diff --git a/apps/web/src/images/vault/extension-mock-login.png b/apps/web/src/images/vault/extension-mock-login.png new file mode 100644 index 00000000000..e002da6db2d Binary files /dev/null and b/apps/web/src/images/vault/extension-mock-login.png differ diff --git a/apps/web/src/locales/en/messages.json b/apps/web/src/locales/en/messages.json index 2d9cba6d409..ef8c109bc4b 100644 --- a/apps/web/src/locales/en/messages.json +++ b/apps/web/src/locales/en/messages.json @@ -12877,6 +12877,21 @@ "storageFullDescription": { "message": "You have used all $GB$ GB of your encrypted storage. To continue storing files, add more storage." }, + "extensionPromptHeading": { + "message": "Get the extension for easy vault access" + }, + "extensionPromptBody": { + "message": "With the browser extension installed, you'll take Bitwarden everywhere online. It'll fill in passwords, so you can log into your accounts with a single click." + }, + "extensionPromptImageAlt": { + "message": "A web browser showing the Bitwarden extension with autofill items for the current webpage." + }, + "skip": { + "message": "Skip" + }, + "downloadExtension": { + "message": "Download extension" + }, "whoCanView": { "message": "Who can view" }, diff --git a/libs/common/src/enums/feature-flag.enum.ts b/libs/common/src/enums/feature-flag.enum.ts index 6fdb146beb8..0cd97eb7f2e 100644 --- a/libs/common/src/enums/feature-flag.enum.ts +++ b/libs/common/src/enums/feature-flag.enum.ts @@ -70,6 +70,8 @@ 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", + PM29438_WelcomeDialogWithExtensionPrompt = "pm-29438-welcome-dialog-with-extension-prompt", + PM29438_DialogWithExtensionPromptAccountAge = "pm-29438-dialog-with-extension-prompt-account-age", PM29437_WelcomeDialog = "pm-29437-welcome-dialog-no-ext-prompt", PM31039ItemActionInExtension = "pm-31039-item-action-in-extension", @@ -137,6 +139,8 @@ export const DefaultFeatureFlagValue = { [FeatureFlag.BrowserPremiumSpotlight]: FALSE, [FeatureFlag.PM27632_SdkCipherCrudOperations]: FALSE, [FeatureFlag.MigrateMyVaultToMyItems]: FALSE, + [FeatureFlag.PM29438_WelcomeDialogWithExtensionPrompt]: FALSE, + [FeatureFlag.PM29438_DialogWithExtensionPromptAccountAge]: 5, [FeatureFlag.PM29437_WelcomeDialog]: FALSE, /* Auth */ diff --git a/libs/state/src/core/state-definitions.ts b/libs/state/src/core/state-definitions.ts index 30ee2be0592..33c9c780dec 100644 --- a/libs/state/src/core/state-definitions.ts +++ b/libs/state/src/core/state-definitions.ts @@ -220,6 +220,13 @@ export const VAULT_BROWSER_INTRO_CAROUSEL = new StateDefinition( "disk", ); export const VAULT_AT_RISK_PASSWORDS_MEMORY = new StateDefinition("vaultAtRiskPasswords", "memory"); +export const WELCOME_EXTENSION_DIALOG_DISK = new StateDefinition( + "vaultWelcomeExtensionDialogDismissed", + "disk", + { + web: "disk-local", + }, +); // KM