mirror of
https://github.com/bitwarden/browser
synced 2026-02-24 08:33:29 +00:00
[PM-31433] Welcome Dialog with Extension Prompt (#18849)
* add welcome prompt when extension is not installed * add feature flag * move prompt logic to internal service and add day prompt * rename dialog component * remove feature flag hardcode and add documentation * use i18n for image alt * move state into service * be more explicit when the account or creation date is not available * remove spaces * fix types caused by introducing a numeric feature flag type * add `typeof` for feature flag typing
This commit is contained in:
@@ -51,7 +51,7 @@ import type { NativeWindowObject } from "./desktop-fido2-user-interface.service"
|
||||
export class DesktopAutofillService implements OnDestroy {
|
||||
private destroy$ = new Subject<void>();
|
||||
private registrationRequest: autofill.PasskeyRegistrationRequest;
|
||||
private featureFlag?: FeatureFlag;
|
||||
private featureFlag?: typeof FeatureFlag.MacOsNativeCredentialSync;
|
||||
private isEnabled: boolean = false;
|
||||
|
||||
constructor(
|
||||
|
||||
@@ -105,7 +105,7 @@ class MockBillingAccountProfileStateService implements Partial<BillingAccountPro
|
||||
|
||||
class MockConfigService implements Partial<ConfigService> {
|
||||
getFeatureFlag$<Flag extends FeatureFlag>(key: Flag): Observable<FeatureFlagValueType<Flag>> {
|
||||
return of(false);
|
||||
return of(false as FeatureFlagValueType<Flag>);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -100,7 +100,7 @@ class MockBillingAccountProfileStateService implements Partial<BillingAccountPro
|
||||
|
||||
class MockConfigService implements Partial<ConfigService> {
|
||||
getFeatureFlag$<Flag extends FeatureFlag>(key: Flag): Observable<FeatureFlagValueType<Flag>> {
|
||||
return of(false);
|
||||
return of(false as FeatureFlagValueType<Flag>);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
<div
|
||||
class="tw-max-w-3xl tw-p-9 tw-border tw-border-solid tw-border-border-base tw-rounded-xl tw-bg-background tw-text-center"
|
||||
>
|
||||
<img
|
||||
src="/images/vault/extension-mock-login.png"
|
||||
[alt]="'extensionPromptImageAlt' | i18n"
|
||||
class="tw-mb-[1.875rem]"
|
||||
/>
|
||||
<div class="tw-flex tw-flex-col tw-gap-[1.6875rem]">
|
||||
<h2 class="tw-mb-0">
|
||||
{{ "extensionPromptHeading" | i18n }}
|
||||
</h2>
|
||||
<p class="tw-mb-0">
|
||||
{{ "extensionPromptBody" | i18n }}
|
||||
</p>
|
||||
<div class="tw-flex tw-gap-[1.125rem] tw-justify-center">
|
||||
<button bitButton="secondary" type="button" (click)="dismissPrompt()">
|
||||
{{ "skip" | i18n }}
|
||||
</button>
|
||||
|
||||
<a
|
||||
bitButton
|
||||
buttonType="primary"
|
||||
[href]="webStoreUrl"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
(click)="dismissPrompt()"
|
||||
>
|
||||
{{ "downloadExtension" | i18n }}
|
||||
<bit-icon name="bwi-external-link" class="tw-ml-[0.42rem]"></bit-icon>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -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<WebVaultExtensionPromptDialogComponent>;
|
||||
let mockDialogRef: MockProxy<DialogRef<void>>;
|
||||
|
||||
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<DialogRef<void>>();
|
||||
|
||||
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<DialogService>() },
|
||||
{
|
||||
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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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<void>,
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -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<boolean>(false);
|
||||
const mockStateSubject = new BehaviorSubject<boolean>(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<void>(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",
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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<boolean>(
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -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<Account | null>;
|
||||
|
||||
@@ -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 }),
|
||||
);
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
BIN
apps/web/src/images/vault/extension-mock-login.png
Normal file
BIN
apps/web/src/images/vault/extension-mock-login.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 166 KiB |
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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 */
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user