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:
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user