diff --git a/apps/browser/src/_locales/en/messages.json b/apps/browser/src/_locales/en/messages.json index 1df0bf9661..1c3214a6ef 100644 --- a/apps/browser/src/_locales/en/messages.json +++ b/apps/browser/src/_locales/en/messages.json @@ -1589,6 +1589,24 @@ "autofillSuggestionsSectionTitle": { "message": "Autofill suggestions" }, + "autofillSpotlightTitle": { + "message": "Easily find autofill suggestions" + }, + "autofillSpotlightDesc": { + "message": "Turn off your browser's autofill settings, so they don't conflict with Bitwarden." + }, + "turnOffBrowserAutofill": { + "message": "Turn off $BROWSER$ autofill", + "placeholders": { + "browser": { + "content": "$1", + "example": "Chrome" + } + } + }, + "turnOffAutofill": { + "message": "Turn off autofill" + }, "showInlineMenuLabel": { "message": "Show autofill suggestions on form fields" }, diff --git a/apps/browser/src/autofill/popup/settings/autofill.component.html b/apps/browser/src/autofill/popup/settings/autofill.component.html index 4fd85ddce3..264b04b039 100644 --- a/apps/browser/src/autofill/popup/settings/autofill.component.html +++ b/apps/browser/src/autofill/popup/settings/autofill.component.html @@ -6,6 +6,16 @@
+
+ +

{{ "autofillSuggestionsSectionTitle" | i18n }}

diff --git a/apps/browser/src/autofill/popup/settings/autofill.component.ts b/apps/browser/src/autofill/popup/settings/autofill.component.ts index c30f150e71..d63f9a4589 100644 --- a/apps/browser/src/autofill/popup/settings/autofill.component.ts +++ b/apps/browser/src/autofill/popup/settings/autofill.component.ts @@ -11,9 +11,11 @@ import { FormControl, } from "@angular/forms"; import { RouterModule } from "@angular/router"; -import { firstValueFrom } from "rxjs"; +import { Observable, filter, firstValueFrom, map, switchMap } from "rxjs"; import { JslibModule } from "@bitwarden/angular/jslib.module"; +import { Account, AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { getUserId } from "@bitwarden/common/auth/services/account.service"; import { AutofillOverlayVisibility, BrowserClientVendors, @@ -53,7 +55,9 @@ import { SelectModule, TypographyModule, } from "@bitwarden/components"; +import { SpotlightComponent, VaultNudgesService, VaultNudgeType } from "@bitwarden/vault"; +import { AutofillBrowserSettingsService } from "../../../autofill/services/autofill-browser-settings.service"; import { BrowserApi } from "../../../platform/browser/browser-api"; import { PopOutComponent } from "../../../platform/popup/components/pop-out.component"; import { PopupHeaderComponent } from "../../../platform/popup/layout/popup-header.component"; @@ -81,6 +85,7 @@ import { PopupPageComponent } from "../../../platform/popup/layout/popup-page.co SelectModule, TypographyModule, ReactiveFormsModule, + SpotlightComponent, ], }) export class AutofillComponent implements OnInit { @@ -100,6 +105,14 @@ export class AutofillComponent implements OnInit { protected browserClientIsUnknown: boolean; protected autofillOnPageLoadFromPolicy$ = this.autofillSettingsService.activateAutofillOnPageLoadFromPolicy$; + protected showSpotlightNudge$: Observable = this.accountService.activeAccount$.pipe( + filter((account): account is Account => account !== null), + switchMap((account) => + this.vaultNudgesService + .showNudge$(VaultNudgeType.AutofillNudge, account.id) + .pipe(map((nudgeStatus) => !nudgeStatus.hasSpotlightDismissed)), + ), + ); protected autofillOnPageLoadForm = new FormGroup({ autofillOnPageLoad: new FormControl(), @@ -142,6 +155,9 @@ export class AutofillComponent implements OnInit { private configService: ConfigService, private formBuilder: FormBuilder, private destroyRef: DestroyRef, + private vaultNudgesService: VaultNudgesService, + private accountService: AccountService, + private autofillBrowserSettingsService: AutofillBrowserSettingsService, ) { this.autofillOnPageLoadOptions = [ { name: this.i18nService.t("autoFillOnPageLoadYes"), value: true }, @@ -165,7 +181,7 @@ export class AutofillComponent implements OnInit { { name: i18nService.t("never"), value: UriMatchStrategy.Never }, ]; - this.browserClientVendor = this.getBrowserClientVendor(); + this.browserClientVendor = BrowserApi.getBrowserClientVendor(window); this.disablePasswordManagerURI = DisablePasswordManagerUris[this.browserClientVendor]; this.browserShortcutsURI = BrowserShortcutsUris[this.browserClientVendor]; this.browserClientIsUnknown = this.browserClientVendor === BrowserClientVendors.Unknown; @@ -173,7 +189,11 @@ export class AutofillComponent implements OnInit { async ngOnInit() { this.canOverrideBrowserAutofillSetting = !this.browserClientIsUnknown; - this.defaultBrowserAutofillDisabled = await this.browserAutofillSettingCurrentlyOverridden(); + + this.defaultBrowserAutofillDisabled = + await this.autofillBrowserSettingsService.isBrowserAutofillSettingOverridden( + this.browserClientVendor, + ); this.inlineMenuVisibility = await firstValueFrom( this.autofillSettingsService.inlineMenuVisibility$, @@ -308,6 +328,27 @@ export class AutofillComponent implements OnInit { ); } + get spotlightButtonIcon() { + if (this.browserClientVendor === BrowserClientVendors.Unknown) { + return "bwi-external-link"; + } + return null; + } + + get spotlightButtonText() { + if (this.browserClientVendor === BrowserClientVendors.Unknown) { + return this.i18nService.t("turnOffAutofill"); + } + return this.i18nService.t("turnOffBrowserAutofill", this.browserClientVendor); + } + + async dismissSpotlight() { + await this.vaultNudgesService.dismissNudge( + VaultNudgeType.AutofillNudge, + await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId)), + ); + } + async updateInlineMenuVisibility() { if (!this.enableInlineMenu) { this.enableInlineMenuOnIconSelect = false; @@ -346,26 +387,6 @@ export class AutofillComponent implements OnInit { } } - private getBrowserClientVendor(): BrowserClientVendor { - if (this.platformUtilsService.isChrome()) { - return BrowserClientVendors.Chrome; - } - - if (this.platformUtilsService.isOpera()) { - return BrowserClientVendors.Opera; - } - - if (this.platformUtilsService.isEdge()) { - return BrowserClientVendors.Edge; - } - - if (this.platformUtilsService.isVivaldi()) { - return BrowserClientVendors.Vivaldi; - } - - return BrowserClientVendors.Unknown; - } - protected async openURI(event: Event, uri: BrowserShortcutsUri | DisablePasswordManagerUri) { event.preventDefault(); @@ -422,7 +443,7 @@ export class AutofillComponent implements OnInit { if ( this.inlineMenuVisibility === AutofillOverlayVisibility.Off || !this.canOverrideBrowserAutofillSetting || - (await this.browserAutofillSettingCurrentlyOverridden()) + this.defaultBrowserAutofillDisabled ) { return; } @@ -460,6 +481,9 @@ export class AutofillComponent implements OnInit { } await BrowserApi.updateDefaultBrowserAutofillSettings(!this.defaultBrowserAutofillDisabled); + this.autofillBrowserSettingsService.setDefaultBrowserAutofillDisabled( + this.defaultBrowserAutofillDisabled, + ); } private handleOverrideDialogAccept = async () => { @@ -467,18 +491,6 @@ export class AutofillComponent implements OnInit { await this.updateDefaultBrowserAutofillDisabled(); }; - async browserAutofillSettingCurrentlyOverridden() { - if (!this.canOverrideBrowserAutofillSetting) { - return false; - } - - if (!(await this.privacyPermissionGranted())) { - return false; - } - - return await BrowserApi.browserAutofillSettingsOverridden(); - } - async privacyPermissionGranted(): Promise { return await BrowserApi.permissionsGranted(["privacy"]); } diff --git a/apps/browser/src/autofill/services/autofill-browser-settings.service.ts b/apps/browser/src/autofill/services/autofill-browser-settings.service.ts new file mode 100644 index 0000000000..ba59a655b7 --- /dev/null +++ b/apps/browser/src/autofill/services/autofill-browser-settings.service.ts @@ -0,0 +1,31 @@ +import { Injectable } from "@angular/core"; +import { BehaviorSubject, Observable } from "rxjs"; + +import { BrowserClientVendors } from "@bitwarden/common/autofill/constants"; +import { BrowserClientVendor } from "@bitwarden/common/autofill/types"; + +import { BrowserApi } from "../../platform/browser/browser-api"; + +/** + * Service class for various Autofill-related browser API operations. + */ +@Injectable({ + providedIn: "root", +}) +export class AutofillBrowserSettingsService { + async isBrowserAutofillSettingOverridden(browserClient: BrowserClientVendor) { + return ( + browserClient !== BrowserClientVendors.Unknown && + (await BrowserApi.browserAutofillSettingsOverridden()) + ); + } + + private _defaultBrowserAutofillDisabled$ = new BehaviorSubject(false); + + defaultBrowserAutofillDisabled$: Observable = + this._defaultBrowserAutofillDisabled$.asObservable(); + + setDefaultBrowserAutofillDisabled(value: boolean) { + this._defaultBrowserAutofillDisabled$.next(value); + } +} diff --git a/apps/browser/src/platform/browser/browser-api.ts b/apps/browser/src/platform/browser/browser-api.ts index 4b4cec7e7d..b27e8ca7c9 100644 --- a/apps/browser/src/platform/browser/browser-api.ts +++ b/apps/browser/src/platform/browser/browser-api.ts @@ -2,6 +2,8 @@ // @ts-strict-ignore import { Observable } from "rxjs"; +import { BrowserClientVendors } from "@bitwarden/common/autofill/constants"; +import { BrowserClientVendor } from "@bitwarden/common/autofill/types"; import { DeviceType } from "@bitwarden/common/enums"; import { isBrowserSafariApi } from "@bitwarden/platform"; @@ -131,6 +133,27 @@ export class BrowserApi { }); } + static getBrowserClientVendor(clientWindow: Window): BrowserClientVendor { + const device = BrowserPlatformUtilsService.getDevice(clientWindow); + + switch (device) { + case DeviceType.ChromeExtension: + case DeviceType.ChromeBrowser: + return BrowserClientVendors.Chrome; + case DeviceType.OperaExtension: + case DeviceType.OperaBrowser: + return BrowserClientVendors.Opera; + case DeviceType.EdgeExtension: + case DeviceType.EdgeBrowser: + return BrowserClientVendors.Edge; + case DeviceType.VivaldiExtension: + case DeviceType.VivaldiBrowser: + return BrowserClientVendors.Vivaldi; + default: + return BrowserClientVendors.Unknown; + } + } + /** * Gets the tab with the given id. * diff --git a/apps/browser/src/platform/popup/view-cache/popup-view-cache.service.ts b/apps/browser/src/platform/popup/view-cache/popup-view-cache.service.ts index 6fc3e11493..ff63b52ab3 100644 --- a/apps/browser/src/platform/popup/view-cache/popup-view-cache.service.ts +++ b/apps/browser/src/platform/popup/view-cache/popup-view-cache.service.ts @@ -82,7 +82,7 @@ export class PopupViewCacheService implements ViewCacheService { initialValue, persistNavigation, } = options; - const cachedValue = this.cache[key] + const cachedValue = this.cache[key]?.value ? deserializer(JSON.parse(this.cache[key].value)) : initialValue; const _signal = signal(cachedValue); diff --git a/apps/browser/src/tools/popup/settings/settings-v2.component.html b/apps/browser/src/tools/popup/settings/settings-v2.component.html index b9f4176b92..22e2d9a28d 100644 --- a/apps/browser/src/tools/popup/settings/settings-v2.component.html +++ b/apps/browser/src/tools/popup/settings/settings-v2.component.html @@ -17,7 +17,15 @@ - {{ "autofill" | i18n }} +
+

{{ "autofill" | i18n }}

+ 1 +
diff --git a/apps/browser/src/tools/popup/settings/settings-v2.component.ts b/apps/browser/src/tools/popup/settings/settings-v2.component.ts index 9301b8622c..be05452529 100644 --- a/apps/browser/src/tools/popup/settings/settings-v2.component.ts +++ b/apps/browser/src/tools/popup/settings/settings-v2.component.ts @@ -1,7 +1,15 @@ import { CommonModule } from "@angular/common"; -import { Component } from "@angular/core"; +import { Component, OnInit } from "@angular/core"; import { RouterModule } from "@angular/router"; -import { filter, firstValueFrom, Observable, shareReplay, switchMap } from "rxjs"; +import { + combineLatest, + filter, + firstValueFrom, + map, + Observable, + shareReplay, + switchMap, +} from "rxjs"; import { JslibModule } from "@bitwarden/angular/jslib.module"; import { Account, AccountService } from "@bitwarden/common/auth/abstractions/account.service"; @@ -12,6 +20,8 @@ import { BadgeComponent, ItemModule } from "@bitwarden/components"; import { NudgeStatus, VaultNudgesService, VaultNudgeType } from "@bitwarden/vault"; import { CurrentAccountComponent } from "../../../auth/popup/account-switching/current-account.component"; +import { AutofillBrowserSettingsService } from "../../../autofill/services/autofill-browser-settings.service"; +import { BrowserApi } from "../../../platform/browser/browser-api"; import { PopOutComponent } from "../../../platform/popup/components/pop-out.component"; import { PopupHeaderComponent } from "../../../platform/popup/layout/popup-header.component"; import { PopupPageComponent } from "../../../platform/popup/layout/popup-page.component"; @@ -31,8 +41,10 @@ import { PopupPageComponent } from "../../../platform/popup/layout/popup-page.co BadgeComponent, ], }) -export class SettingsV2Component { +export class SettingsV2Component implements OnInit { VaultNudgeType = VaultNudgeType; + activeUserId: UserId | null = null; + protected isBrowserAutofillSettingOverridden = false; private authenticatedAccount$: Observable = this.accountService.activeAccount$.pipe( filter((account): account is Account => account !== null), @@ -51,6 +63,19 @@ export class SettingsV2Component { ), ); + showAutofillBadge$: Observable = combineLatest([ + this.autofillBrowserSettingsService.defaultBrowserAutofillDisabled$, + this.authenticatedAccount$, + ]).pipe( + switchMap(([defaultBrowserAutofillDisabled, account]) => + this.vaultNudgesService.showNudge$(VaultNudgeType.AutofillNudge, account.id).pipe( + map((nudgeStatus) => { + return !defaultBrowserAutofillDisabled && nudgeStatus.hasBadgeDismissed === false; + }), + ), + ), + ); + protected isNudgeFeatureEnabled$ = this.configService.getFeatureFlag$( FeatureFlag.PM8851_BrowserOnboardingNudge, ); @@ -58,9 +83,17 @@ export class SettingsV2Component { constructor( private readonly vaultNudgesService: VaultNudgesService, private readonly accountService: AccountService, + private readonly autofillBrowserSettingsService: AutofillBrowserSettingsService, private readonly configService: ConfigService, ) {} + async ngOnInit() { + this.isBrowserAutofillSettingOverridden = + await this.autofillBrowserSettingsService.isBrowserAutofillSettingOverridden( + BrowserApi.getBrowserClientVendor(window), + ); + } + async dismissBadge(type: VaultNudgeType) { if (!(await firstValueFrom(this.showVaultBadge$)).hasBadgeDismissed) { const account = await firstValueFrom(this.authenticatedAccount$); diff --git a/libs/vault/src/components/spotlight/spotlight.component.html b/libs/vault/src/components/spotlight/spotlight.component.html index e949ca4d91..0c6a37914d 100644 --- a/libs/vault/src/components/spotlight/spotlight.component.html +++ b/libs/vault/src/components/spotlight/spotlight.component.html @@ -23,10 +23,9 @@ type="button" buttonType="primary" *ngIf="buttonText" - (click)="handleButtonClick()" + (click)="handleButtonClick($event)" > {{ buttonText }} + - -
diff --git a/libs/vault/src/components/spotlight/spotlight.component.ts b/libs/vault/src/components/spotlight/spotlight.component.ts index e52669cc40..8639fe7947 100644 --- a/libs/vault/src/components/spotlight/spotlight.component.ts +++ b/libs/vault/src/components/spotlight/spotlight.component.ts @@ -19,11 +19,13 @@ export class SpotlightComponent { @Input() buttonText?: string; // Wheter the component can be dismissed, if true, the component will not show a close button @Input() persistent = false; + // Optional icon to display on the button + @Input() buttonIcon: string | null = null; @Output() onDismiss = new EventEmitter(); - @Output() onButtonClick = new EventEmitter(); + @Output() onButtonClick = new EventEmitter(); - handleButtonClick(): void { - this.onButtonClick.emit(); + handleButtonClick(event: MouseEvent): void { + this.onButtonClick.emit(event); } handleDismiss(): void { diff --git a/libs/vault/src/components/spotlight/spotlight.stories.ts b/libs/vault/src/components/spotlight/spotlight.stories.ts index 9f7757e452..8e660aacba 100644 --- a/libs/vault/src/components/spotlight/spotlight.stories.ts +++ b/libs/vault/src/components/spotlight/spotlight.stories.ts @@ -52,9 +52,9 @@ export const Persistent: Story = { }, }; -export const WithCustomButton: Story = { +export const WithButtonIcon: Story = { args: { - buttonText: "Custom Button", + buttonIcon: "bwi bwi-external-link", }, render: (args) => ({ props: args, @@ -62,19 +62,9 @@ export const WithCustomButton: Story = { - - + buttonText="External Link" + buttonIcon="bwi-external-link" + > `, }), }; diff --git a/libs/vault/src/services/custom-nudges-services/autofill-nudge.service.ts b/libs/vault/src/services/custom-nudges-services/autofill-nudge.service.ts new file mode 100644 index 0000000000..b5595e590c --- /dev/null +++ b/libs/vault/src/services/custom-nudges-services/autofill-nudge.service.ts @@ -0,0 +1,47 @@ +import { Injectable, inject } from "@angular/core"; +import { Observable, combineLatest, from, map, of } from "rxjs"; +import { catchError } from "rxjs/operators"; + +import { VaultProfileService } from "@bitwarden/angular/vault/services/vault-profile.service"; +import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; +import { UserId } from "@bitwarden/common/types/guid"; + +import { DefaultSingleNudgeService } from "../default-single-nudge.service"; +import { NudgeStatus, VaultNudgeType } from "../vault-nudges.service"; + +const THIRTY_DAYS_MS = 30 * 24 * 60 * 60 * 1000; + +/** + * Custom Nudge Service to use for the Autofill Nudge in the Vault + */ +@Injectable({ + providedIn: "root", +}) +export class AutofillNudgeService extends DefaultSingleNudgeService { + vaultProfileService = inject(VaultProfileService); + logService = inject(LogService); + + nudgeStatus$(_: VaultNudgeType, userId: UserId): Observable { + const profileDate$ = from(this.vaultProfileService.getProfileCreationDate(userId)).pipe( + catchError(() => { + this.logService.error("Error getting profile creation date"); + // Default to today to ensure we show the nudge + return of(new Date()); + }), + ); + + return combineLatest([ + profileDate$, + this.getNudgeStatus$(VaultNudgeType.AutofillNudge, userId), + of(Date.now() - THIRTY_DAYS_MS), + ]).pipe( + map(([profileCreationDate, status, profileCutoff]) => { + const profileOlderThanCutoff = profileCreationDate.getTime() < profileCutoff; + return { + hasBadgeDismissed: status.hasBadgeDismissed || profileOlderThanCutoff, + hasSpotlightDismissed: status.hasSpotlightDismissed || profileOlderThanCutoff, + }; + }), + ); + } +} diff --git a/libs/vault/src/services/custom-nudges-services/index.ts b/libs/vault/src/services/custom-nudges-services/index.ts index 68427a8dc4..2e9ade985c 100644 --- a/libs/vault/src/services/custom-nudges-services/index.ts +++ b/libs/vault/src/services/custom-nudges-services/index.ts @@ -1,3 +1,4 @@ +export * from "./autofill-nudge.service"; export * from "./has-items-nudge.service"; export * from "./download-bitwarden-nudge.service"; export * from "./empty-vault-nudge.service"; diff --git a/libs/vault/src/services/vault-nudges.service.spec.ts b/libs/vault/src/services/vault-nudges.service.spec.ts index a746941071..89465fc538 100644 --- a/libs/vault/src/services/vault-nudges.service.spec.ts +++ b/libs/vault/src/services/vault-nudges.service.spec.ts @@ -55,6 +55,7 @@ describe("Vault Nudges Service", () => { useValue: mock(), }, { provide: CipherService, useValue: mock() }, + { provide: LogService, useValue: mock() }, { provide: AccountService, useValue: mock(), diff --git a/libs/vault/src/services/vault-nudges.service.ts b/libs/vault/src/services/vault-nudges.service.ts index 171fe85252..e04cb609d7 100644 --- a/libs/vault/src/services/vault-nudges.service.ts +++ b/libs/vault/src/services/vault-nudges.service.ts @@ -9,6 +9,7 @@ import { UserId } from "@bitwarden/common/types/guid"; import { HasItemsNudgeService, EmptyVaultNudgeService, + AutofillNudgeService, DownloadBitwardenNudgeService, NewItemNudgeService, } from "./custom-nudges-services"; @@ -28,6 +29,7 @@ export enum VaultNudgeType { */ EmptyVaultNudge = "empty-vault-nudge", HasVaultItems = "has-vault-items", + AutofillNudge = "autofill-nudge", DownloadBitwarden = "download-bitwarden", newLoginItemStatus = "new-login-item-status", newCardItemStatus = "new-card-item-status", @@ -57,6 +59,7 @@ export class VaultNudgesService { private customNudgeServices: Partial> = { [VaultNudgeType.HasVaultItems]: inject(HasItemsNudgeService), [VaultNudgeType.EmptyVaultNudge]: inject(EmptyVaultNudgeService), + [VaultNudgeType.AutofillNudge]: inject(AutofillNudgeService), [VaultNudgeType.DownloadBitwarden]: inject(DownloadBitwardenNudgeService), [VaultNudgeType.newLoginItemStatus]: this.newItemNudgeService, [VaultNudgeType.newCardItemStatus]: this.newItemNudgeService,