1
0
mirror of https://github.com/bitwarden/browser synced 2026-02-18 02:19:18 +00:00

add Autofill Targeting Rules settings manangement view

This commit is contained in:
Jonathan Prusik
2025-10-17 17:09:43 -04:00
parent 189cd86941
commit 617c2c9dd8
5 changed files with 508 additions and 0 deletions

View File

@@ -0,0 +1,205 @@
<popup-page>
<popup-header slot="header" pageTitle="Autofill Targeting Rules" showBackButton>
<ng-container slot="end">
<app-pop-out></app-pop-out>
</ng-container>
</popup-header>
<div class="tw-bg-background-alt">
<bit-section>
<p class="tw-m-0">
Override which inputs should be autofilled on a page using query selectors.
</p>
<a
bitLink
linkType="primary"
rel="noreferrer"
target="_blank"
href="https://bitwarden.com/help/blocking-uris/"
>Learn more about Autofill Targeting Rules</a
>
</bit-section>
<bit-section *ngIf="!isLoading">
<bit-section-header>
<h2 bitTypography="h6">{{ "domainsTitle" | i18n }}</h2>
<span bitTypography="body2" slot="end">{{
viewTargetingRulesDomains.length + domainForms.value.length
}}</span>
</bit-section-header>
<bit-item
*ngFor="let domain of viewTargetingRulesDomains; let i = index; trackBy: trackByFunction"
>
<bit-item-content *ngIf="i < fieldsEditThreshold">
<div id="uriTargetingRules{{ i }}">{{ domain }}</div>
<div
*ngIf="targetingRulesDomainsViewState[domain].username"
title="{{ targetingRulesDomainsViewState[domain].username }}"
style="padding-left: 12px"
>
<div style="font-size: 0.8rem; font-weight: bold">Username:</div>
<div
style="
font-size: 0.7rem;
font-weight: lighter;
font-family: monospace;
text-overflow: ellipsis;
overflow-y: clip;
"
>
{{ targetingRulesDomainsViewState[domain].username }}
</div>
</div>
<div
*ngIf="targetingRulesDomainsViewState[domain].password"
title="{{ targetingRulesDomainsViewState[domain].password }}"
style="padding-left: 12px"
>
<div style="font-size: 0.8rem; font-weight: bold">Password:</div>
<div
style="
font-size: 0.7rem;
font-weight: lighter;
font-family: monospace;
text-overflow: ellipsis;
overflow-y: clip;
"
>
{{ targetingRulesDomainsViewState[domain].password }}
</div>
</div>
<div
*ngIf="targetingRulesDomainsViewState[domain].totp"
title="{{ targetingRulesDomainsViewState[domain].totp }}"
style="padding-left: 12px"
>
<div style="font-size: 0.8rem; font-weight: bold">TOTP:</div>
<div
style="
font-size: 0.7rem;
font-weight: lighter;
font-family: monospace;
text-overflow: ellipsis;
overflow-y: clip;
"
>
{{ targetingRulesDomainsViewState[domain].totp }}
</div>
</div>
</bit-item-content>
<button
*ngIf="i < fieldsEditThreshold && domain"
label="{{ 'remove' | i18n }}"
bitIconButton="bwi-minus-circle"
buttonType="danger"
size="small"
slot="end"
type="button"
(click)="removeDomain(i)"
></button>
</bit-item>
<form [formGroup]="domainListForm">
<bit-card
formArrayName="domains"
*ngFor="let domain of domainForms.controls; let i = index"
>
<div [formGroupName]="i">
<bit-form-field>
<bit-label>
{{ "websiteItemLabel" | i18n: i + fieldsEditThreshold + 1 }}
</bit-label>
<input
#uriInput
appInputVerbatim
bitInput
id="targeting-rules-uri-{{ i + fieldsEditThreshold }}"
inputmode="url"
name="targeting-rules-uri-{{ i + fieldsEditThreshold }}"
type="text"
(change)="fieldChange()"
formControlName="domain"
/>
</bit-form-field>
<div style="display: flex; align-content: center; flex-wrap: wrap; row-gap: 3px">
<!-- Username field -->
<div
class="field-row"
style="gap: 15px; display: flex; justify-content: flex-end; width: 100%"
>
<label style="flex: 1 1 25%">Username: </label>
<input
style="
flex: 1 1 75%;
font-family: monospace;
font-size: 0.75rem;
font-weight: light;
"
appInputVerbatim
type="text"
formControlName="username"
(change)="fieldChange()"
/>
</div>
<!-- Password field -->
<div
class="field-row"
style="gap: 15px; display: flex; justify-content: flex-end; width: 100%"
>
<label style="flex: 1 1 25%">Password: </label>
<input
style="
flex: 1 1 75%;
font-size: 0.75rem;
font-weight: light;
font-family: monospace;
"
appInputVerbatim
type="text"
formControlName="password"
(change)="fieldChange()"
/>
</div>
<!-- TOTP field -->
<div
class="field-row"
style="gap: 15px; display: flex; justify-content: flex-end; width: 100%"
>
<label style="flex: 1 1 25%">TOTP: </label>
<input
style="
flex: 1 1 75%;
font-size: 0.75rem;
font-weight: light;
font-family: monospace;
"
appInputVerbatim
type="text"
formControlName="totp"
(change)="fieldChange()"
/>
</div>
</div>
</div>
</bit-card>
<button bitLink class="tw-pt-2" type="button" linkType="primary" (click)="addNewDomain()">
<i class="bwi bwi-plus-circle bwi-fw" aria-hidden="true"></i> {{ "addDomain" | i18n }}
</button>
</form>
</bit-section>
</div>
<popup-footer slot="footer">
<button
bitButton
buttonType="primary"
type="submit"
[disabled]="dataIsPristine"
(click)="saveChanges()"
>
{{ "save" | i18n }}
</button>
<button (click)="goBack()" bitButton type="button" buttonType="secondary">
{{ "cancel" | i18n }}
</button>
</popup-footer>
</popup-page>

View File

@@ -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<ElementRef<HTMLInputElement>> =
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<void>();
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;
}
}

View File

@@ -285,5 +285,11 @@
<i slot="end" class="bwi bwi-angle-right bwi-lg" aria-hidden="true"></i>
</bit-item>
</bit-section>
<bit-section disableMargin>
<bit-item>
<a bit-item-content routerLink="/autofill-targeting-rules">Autofill Targeting Rules</a>
<i slot="end" class="bwi bwi-angle-right bwi-lg" aria-hidden="true"></i>
</bit-item>
</bit-section>
</div>
</popup-page>

View File

@@ -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,

View File

@@ -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,