From 617c2c9dd8d768949db5c63240f073a326d3b912 Mon Sep 17 00:00:00 2001 From: Jonathan Prusik Date: Fri, 17 Oct 2025 17:09:43 -0400 Subject: [PATCH] add Autofill Targeting Rules settings manangement view --- .../autofill-targeting-rules.component.html | 205 +++++++++++++ .../autofill-targeting-rules.component.ts | 286 ++++++++++++++++++ .../popup/settings/autofill.component.html | 6 + .../collect-autofill-content.service.ts | 4 + apps/browser/src/popup/app-routing.module.ts | 7 + 5 files changed, 508 insertions(+) create mode 100644 apps/browser/src/autofill/popup/settings/autofill-targeting-rules.component.html create mode 100644 apps/browser/src/autofill/popup/settings/autofill-targeting-rules.component.ts diff --git a/apps/browser/src/autofill/popup/settings/autofill-targeting-rules.component.html b/apps/browser/src/autofill/popup/settings/autofill-targeting-rules.component.html new file mode 100644 index 00000000000..9f35b9827c2 --- /dev/null +++ b/apps/browser/src/autofill/popup/settings/autofill-targeting-rules.component.html @@ -0,0 +1,205 @@ + + + + + + + +
+ +

+ Override which inputs should be autofilled on a page using query selectors. +

+ Learn more about Autofill Targeting Rules +
+ + +

{{ "domainsTitle" | i18n }}

+ {{ + viewTargetingRulesDomains.length + domainForms.value.length + }} +
+ + +
{{ domain }}
+
+
Username:
+
+ {{ targetingRulesDomainsViewState[domain].username }} +
+
+
+
Password:
+
+ {{ targetingRulesDomainsViewState[domain].password }} +
+
+
+
TOTP:
+
+ {{ targetingRulesDomainsViewState[domain].totp }} +
+
+
+ +
+
+ +
+ + + {{ "websiteItemLabel" | i18n: i + fieldsEditThreshold + 1 }} + + + +
+ +
+ + +
+ + +
+ + +
+ + +
+ + +
+
+
+
+ +
+
+
+ + + + +
diff --git a/apps/browser/src/autofill/popup/settings/autofill-targeting-rules.component.ts b/apps/browser/src/autofill/popup/settings/autofill-targeting-rules.component.ts new file mode 100644 index 00000000000..89982aca938 --- /dev/null +++ b/apps/browser/src/autofill/popup/settings/autofill-targeting-rules.component.ts @@ -0,0 +1,286 @@ +import { CommonModule } from "@angular/common"; +import { + QueryList, + Component, + ElementRef, + OnDestroy, + AfterViewInit, + ViewChildren, +} from "@angular/core"; +import { + FormsModule, + ReactiveFormsModule, + FormBuilder, + FormGroup, + FormArray, +} from "@angular/forms"; +import { RouterModule } from "@angular/router"; +import { Subject, takeUntil } from "rxjs"; + +import { JslibModule } from "@bitwarden/angular/jslib.module"; +import { DomainSettingsService } from "@bitwarden/common/autofill/services/domain-settings.service"; +import { AutofillTargetingRulesByDomain } from "@bitwarden/common/autofill/types"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { Utils } from "@bitwarden/common/platform/misc/utils"; +import { + ButtonModule, + CardComponent, + FormFieldModule, + IconButtonModule, + ItemModule, + LinkModule, + SectionComponent, + SectionHeaderComponent, + ToastService, + TypographyModule, +} from "@bitwarden/components"; + +import { PopOutComponent } from "../../../platform/popup/components/pop-out.component"; +import { PopupFooterComponent } from "../../../platform/popup/layout/popup-footer.component"; +import { PopupHeaderComponent } from "../../../platform/popup/layout/popup-header.component"; +import { PopupPageComponent } from "../../../platform/popup/layout/popup-page.component"; +import { PopupRouterCacheService } from "../../../platform/popup/view-cache/popup-router-cache.service"; + +@Component({ + selector: "autofill-targeting-rules", + templateUrl: "autofill-targeting-rules.component.html", + imports: [ + ButtonModule, + CardComponent, + CommonModule, + FormFieldModule, + FormsModule, + ReactiveFormsModule, + IconButtonModule, + ItemModule, + JslibModule, + LinkModule, + PopOutComponent, + PopupFooterComponent, + PopupHeaderComponent, + PopupPageComponent, + RouterModule, + SectionComponent, + SectionHeaderComponent, + TypographyModule, + ], +}) +export class AutofillTargetingRulesComponent implements AfterViewInit, OnDestroy { + @ViewChildren("uriInput") uriInputElements: QueryList> = + new QueryList(); + + dataIsPristine = true; + isLoading = false; + /** Source-of-truth state from the service used to populate the view state */ + storedTargetingRulesState: AutofillTargetingRulesByDomain = {}; + /** Key names for the view state properties */ + viewTargetingRulesDomains: string[] = []; + /** Tentative, unsaved state used to populate the view */ + targetingRulesDomainsViewState: AutofillTargetingRulesByDomain = {}; + + protected domainListForm = new FormGroup({ + domains: this.formBuilder.array([]), + }); + + // How many fields should be non-editable before editable fields + fieldsEditThreshold: number = 0; + + private destroy$ = new Subject(); + + constructor( + private domainSettingsService: DomainSettingsService, + private i18nService: I18nService, + private toastService: ToastService, + private formBuilder: FormBuilder, + private popupRouterCacheService: PopupRouterCacheService, + ) {} + + get domainForms() { + return this.domainListForm.get("domains") as FormArray; + } + + async ngAfterViewInit() { + this.domainSettingsService.autofillTargetingRules$ + .pipe(takeUntil(this.destroy$)) + .subscribe((targetingRulesSet: AutofillTargetingRulesByDomain) => + this.handleStateUpdate(targetingRulesSet), + ); + + this.uriInputElements.changes.pipe(takeUntil(this.destroy$)).subscribe(({ last }) => { + this.focusNewUriInput(last); + }); + } + + ngOnDestroy() { + this.destroy$.next(); + this.destroy$.complete(); + } + + /** Handles changes to the service state */ + handleStateUpdate(targetingRulesSet: AutofillTargetingRulesByDomain) { + if (targetingRulesSet) { + this.storedTargetingRulesState = { ...targetingRulesSet }; + this.viewTargetingRulesDomains = Object.keys(targetingRulesSet); + this.targetingRulesDomainsViewState = { ...targetingRulesSet }; + } + + // Do not allow the first x (pre-existing) fields to be edited + this.fieldsEditThreshold = this.viewTargetingRulesDomains.length; + + this.dataIsPristine = true; + this.isLoading = false; + } + + focusNewUriInput(elementRef: ElementRef) { + if (elementRef?.nativeElement) { + elementRef.nativeElement.focus(); + } + } + + async addNewDomain() { + this.domainForms.push( + this.formBuilder.group({ + domain: null, + username: null, + password: null, + totp: null, + }), + ); + + await this.fieldChange(); + } + + async removeDomain(i: number) { + const removedDomainName = this.viewTargetingRulesDomains[i]; + this.viewTargetingRulesDomains.splice(i, 1); + delete this.targetingRulesDomainsViewState[removedDomainName]; + + // If a pre-existing field was dropped, lower the edit threshold + if (i < this.fieldsEditThreshold) { + this.fieldsEditThreshold--; + } + + await this.fieldChange(); + } + + async fieldChange() { + if (this.dataIsPristine) { + this.dataIsPristine = false; + } + } + + async saveChanges() { + if (this.dataIsPristine) { + return; + } + + this.isLoading = true; + + const newUriTargetingRulesSaveState: AutofillTargetingRulesByDomain = { + ...this.targetingRulesDomainsViewState, + }; + + // Then process form values + this.domainForms.controls.forEach((control) => { + const formGroup = control as FormGroup; + const domain = formGroup.get("domain")?.value; + + if (domain && domain !== "") { + const validatedHost = Utils.getHostname(domain); + + if (!validatedHost) { + this.toastService.showToast({ + message: this.i18nService.t("blockedDomainsInvalidDomain", domain), + title: "", + variant: "error", + }); + return; + } + + const enteredUsername = formGroup.get("username")?.value; + const enteredPassword = formGroup.get("password")?.value; + const enteredTotp = formGroup.get("totp")?.value; + + if (!enteredUsername && !enteredPassword && !enteredTotp) { + this.toastService.showToast({ + message: "No targeting rules were specified for the URL", + title: "", + variant: "error", + }); + + return; + } + + newUriTargetingRulesSaveState[validatedHost] = {}; + + // Only add the property to the object if it has a value + if (enteredUsername) { + newUriTargetingRulesSaveState[validatedHost].username = enteredUsername; + } + + if (enteredPassword) { + newUriTargetingRulesSaveState[validatedHost].password = enteredPassword; + } + + if (enteredTotp) { + newUriTargetingRulesSaveState[validatedHost].totp = enteredTotp; + } + } + }); + + try { + const existingStateKeys = Object.keys(this.storedTargetingRulesState); + const newStateKeys = Object.keys(newUriTargetingRulesSaveState); + + // Check if any domains were added or removed + const domainsChanged = + new Set([...existingStateKeys, ...newStateKeys]).size !== existingStateKeys.length; + + // Check if any domain's properties were modified + const propertiesChanged = existingStateKeys.some((domain) => { + const oldRules = this.storedTargetingRulesState[domain]; + const newRules = newUriTargetingRulesSaveState[domain]; + + // Check if any properties were added, removed, or modified + return ( + !oldRules || + !newRules || + oldRules.username !== newRules.username || + oldRules.password !== newRules.password || + oldRules.totp !== newRules.totp + ); + }); + + const stateIsChanged = domainsChanged || propertiesChanged; + + if (stateIsChanged) { + await this.domainSettingsService.setAutofillTargetingRules(newUriTargetingRulesSaveState); + } else { + this.handleStateUpdate(this.storedTargetingRulesState); + } + + this.toastService.showToast({ + message: this.i18nService.t("blockedDomainsSavedSuccess"), + title: "", + variant: "success", + }); + + this.domainForms.clear(); + } catch { + this.toastService.showToast({ + message: this.i18nService.t("unexpectedError"), + title: "", + variant: "error", + }); + this.isLoading = false; + } + } + + async goBack() { + await this.popupRouterCacheService.back(); + } + + trackByFunction(index: number, _: string) { + return index; + } +} diff --git a/apps/browser/src/autofill/popup/settings/autofill.component.html b/apps/browser/src/autofill/popup/settings/autofill.component.html index add53a0cd33..862727b01b8 100644 --- a/apps/browser/src/autofill/popup/settings/autofill.component.html +++ b/apps/browser/src/autofill/popup/settings/autofill.component.html @@ -285,5 +285,11 @@ + + + Autofill Targeting Rules + + + diff --git a/apps/browser/src/autofill/services/collect-autofill-content.service.ts b/apps/browser/src/autofill/services/collect-autofill-content.service.ts index e25f7b0bcc7..c8cca3ded96 100644 --- a/apps/browser/src/autofill/services/collect-autofill-content.service.ts +++ b/apps/browser/src/autofill/services/collect-autofill-content.service.ts @@ -184,6 +184,10 @@ export class CollectAutofillContentService implements CollectAutofillContentServ // Note - potential bottleneck at async lookup (alternatively, promise map) const foundTargetedFields = definedTargetingRuleFields.reduce((foundFields, fieldName) => { const targetingRule = this.pageTargetingRules[fieldName]; + if (!targetingRule) { + return foundFields; + } + const fieldMatches = this.domQueryService.queryDeepSelector( globalThis.document, targetingRule, diff --git a/apps/browser/src/popup/app-routing.module.ts b/apps/browser/src/popup/app-routing.module.ts index e3d63d20c17..0e9dbe7fb5e 100644 --- a/apps/browser/src/popup/app-routing.module.ts +++ b/apps/browser/src/popup/app-routing.module.ts @@ -49,6 +49,7 @@ import { fido2AuthGuard } from "../auth/popup/guards/fido2-auth.guard"; import { AccountSecurityComponent } from "../auth/popup/settings/account-security.component"; import { ExtensionDeviceManagementComponent } from "../auth/popup/settings/extension-device-management.component"; import { Fido2Component } from "../autofill/popup/fido2/fido2.component"; +import { AutofillTargetingRulesComponent } from "../autofill/popup/settings/autofill-targeting-rules.component"; import { AutofillComponent } from "../autofill/popup/settings/autofill.component"; import { BlockedDomainsComponent } from "../autofill/popup/settings/blocked-domains.component"; import { ExcludedDomainsComponent } from "../autofill/popup/settings/excluded-domains.component"; @@ -283,6 +284,12 @@ const routes: Routes = [ canActivate: [authGuard], data: { elevation: 2 } satisfies RouteDataProperties, }, + { + path: "autofill-targeting-rules", + component: AutofillTargetingRulesComponent, + canActivate: [authGuard], + data: { elevation: 2 } satisfies RouteDataProperties, + }, { path: "blocked-domains", component: BlockedDomainsComponent,