1123 lines
38 KiB
TypeScript
1123 lines
38 KiB
TypeScript
import "@webcomponents/custom-elements";
|
|
import "lit/polyfill-support.js";
|
|
import { FocusableElement, tabbable } from "tabbable";
|
|
|
|
import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status";
|
|
|
|
import { FocusedFieldData } from "../background/abstractions/overlay.background";
|
|
import { EVENTS } from "../constants";
|
|
import AutofillField from "../models/autofill-field";
|
|
import AutofillOverlayButtonIframe from "../overlay/iframe-content/autofill-overlay-button-iframe";
|
|
import AutofillOverlayListIframe from "../overlay/iframe-content/autofill-overlay-list-iframe";
|
|
import { ElementWithOpId, FillableFormFieldElement, FormFieldElement } from "../types";
|
|
import {
|
|
AutofillOverlayElement,
|
|
RedirectFocusDirection,
|
|
AutofillOverlayVisibility,
|
|
} from "../utils/autofill-overlay.enum";
|
|
import {
|
|
generateRandomCustomElementName,
|
|
sendExtensionMessage,
|
|
setElementStyles,
|
|
} from "../utils/utils";
|
|
|
|
import {
|
|
AutofillOverlayContentService as AutofillOverlayContentServiceInterface,
|
|
OpenAutofillOverlayOptions,
|
|
} from "./abstractions/autofill-overlay-content.service";
|
|
import { AutoFillConstants } from "./autofill-constants";
|
|
|
|
class AutofillOverlayContentService implements AutofillOverlayContentServiceInterface {
|
|
isFieldCurrentlyFocused = false;
|
|
isCurrentlyFilling = false;
|
|
isOverlayCiphersPopulated = false;
|
|
pageDetailsUpdateRequired = false;
|
|
private readonly findTabs = tabbable;
|
|
private readonly sendExtensionMessage = sendExtensionMessage;
|
|
private autofillOverlayVisibility: number;
|
|
private userFilledFields: Record<string, FillableFormFieldElement> = {};
|
|
private authStatus: AuthenticationStatus;
|
|
private focusableElements: FocusableElement[] = [];
|
|
private isOverlayButtonVisible = false;
|
|
private isOverlayListVisible = false;
|
|
private overlayButtonElement: HTMLElement;
|
|
private overlayListElement: HTMLElement;
|
|
private mostRecentlyFocusedField: ElementWithOpId<FormFieldElement>;
|
|
private focusedFieldData: FocusedFieldData;
|
|
private userInteractionEventTimeout: NodeJS.Timeout;
|
|
private overlayElementsMutationObserver: MutationObserver;
|
|
private bodyElementMutationObserver: MutationObserver;
|
|
private mutationObserverIterations = 0;
|
|
private mutationObserverIterationsResetTimeout: NodeJS.Timeout;
|
|
private autofillFieldKeywordsMap: WeakMap<AutofillField, string> = new WeakMap();
|
|
private eventHandlersMemo: { [key: string]: EventListener } = {};
|
|
private readonly customElementDefaultStyles: Partial<CSSStyleDeclaration> = {
|
|
all: "initial",
|
|
position: "fixed",
|
|
display: "block",
|
|
zIndex: "2147483647",
|
|
};
|
|
|
|
/**
|
|
* Initializes the autofill overlay content service by setting up the mutation observers.
|
|
* The observers will be instantiated on DOMContentLoaded if the page is current loading.
|
|
*/
|
|
init() {
|
|
if (globalThis.document.readyState === "loading") {
|
|
globalThis.document.addEventListener(EVENTS.DOMCONTENTLOADED, this.setupGlobalEventListeners);
|
|
return;
|
|
}
|
|
|
|
this.setupGlobalEventListeners();
|
|
}
|
|
|
|
/**
|
|
* Sets up the autofill overlay listener on the form field element. This method is called
|
|
* during the page details collection process.
|
|
*
|
|
* @param formFieldElement - Form field elements identified during the page details collection process.
|
|
* @param autofillFieldData - Autofill field data captured from the form field element.
|
|
*/
|
|
async setupAutofillOverlayListenerOnField(
|
|
formFieldElement: ElementWithOpId<FormFieldElement>,
|
|
autofillFieldData: AutofillField,
|
|
) {
|
|
if (this.isIgnoredField(autofillFieldData)) {
|
|
return;
|
|
}
|
|
|
|
if (!this.autofillOverlayVisibility) {
|
|
await this.getAutofillOverlayVisibility();
|
|
}
|
|
|
|
this.setupFormFieldElementEventListeners(formFieldElement);
|
|
|
|
if (this.getRootNodeActiveElement(formFieldElement) === formFieldElement) {
|
|
await this.triggerFormFieldFocusedAction(formFieldElement);
|
|
return;
|
|
}
|
|
|
|
if (!this.mostRecentlyFocusedField) {
|
|
await this.updateMostRecentlyFocusedField(formFieldElement);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Handles opening the autofill overlay. Will conditionally open
|
|
* the overlay based on the current autofill overlay visibility setting.
|
|
* Allows you to optionally focus the field element when opening the overlay.
|
|
* Will also optionally ignore the overlay visibility setting and open the
|
|
*
|
|
* @param options - Options for opening the autofill overlay.
|
|
*/
|
|
openAutofillOverlay(options: OpenAutofillOverlayOptions = {}) {
|
|
const { isFocusingFieldElement, isOpeningFullOverlay, authStatus } = options;
|
|
if (!this.mostRecentlyFocusedField) {
|
|
return;
|
|
}
|
|
|
|
if (this.pageDetailsUpdateRequired) {
|
|
this.sendExtensionMessage("bgCollectPageDetails", {
|
|
sender: "autofillOverlayContentService",
|
|
});
|
|
this.pageDetailsUpdateRequired = false;
|
|
}
|
|
|
|
if (isFocusingFieldElement && !this.recentlyFocusedFieldIsCurrentlyFocused()) {
|
|
this.focusMostRecentOverlayField();
|
|
}
|
|
|
|
if (typeof authStatus !== "undefined") {
|
|
this.authStatus = authStatus;
|
|
}
|
|
|
|
if (
|
|
this.autofillOverlayVisibility === AutofillOverlayVisibility.OnButtonClick &&
|
|
!isOpeningFullOverlay
|
|
) {
|
|
this.updateOverlayButtonPosition();
|
|
return;
|
|
}
|
|
|
|
this.updateOverlayElementsPosition();
|
|
}
|
|
|
|
/**
|
|
* Focuses the most recently focused field element.
|
|
*/
|
|
focusMostRecentOverlayField() {
|
|
this.mostRecentlyFocusedField?.focus();
|
|
}
|
|
|
|
/**
|
|
* Removes focus from the most recently focused field element.
|
|
*/
|
|
blurMostRecentOverlayField() {
|
|
this.mostRecentlyFocusedField?.blur();
|
|
}
|
|
|
|
/**
|
|
* Removes the autofill overlay from the page. This will initially
|
|
* unobserve the body element to ensure the mutation observer no
|
|
* longer triggers.
|
|
*/
|
|
removeAutofillOverlay = () => {
|
|
this.removeBodyElementObserver();
|
|
this.removeAutofillOverlayButton();
|
|
this.removeAutofillOverlayList();
|
|
};
|
|
|
|
/**
|
|
* Removes the overlay button from the DOM if it is currently present. Will
|
|
* also remove the overlay reposition event listeners.
|
|
*/
|
|
removeAutofillOverlayButton() {
|
|
if (!this.overlayButtonElement) {
|
|
return;
|
|
}
|
|
|
|
this.overlayButtonElement.remove();
|
|
this.isOverlayButtonVisible = false;
|
|
this.sendExtensionMessage("autofillOverlayElementClosed", {
|
|
overlayElement: AutofillOverlayElement.Button,
|
|
});
|
|
this.removeOverlayRepositionEventListeners();
|
|
}
|
|
|
|
/**
|
|
* Removes the overlay list from the DOM if it is currently present.
|
|
*/
|
|
removeAutofillOverlayList() {
|
|
if (!this.overlayListElement) {
|
|
return;
|
|
}
|
|
|
|
this.overlayListElement.remove();
|
|
this.isOverlayListVisible = false;
|
|
this.sendExtensionMessage("autofillOverlayElementClosed", {
|
|
overlayElement: AutofillOverlayElement.List,
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Formats any found user filled fields for a login cipher and sends a message
|
|
* to the background script to add a new cipher.
|
|
*/
|
|
addNewVaultItem() {
|
|
if (!this.isOverlayListVisible) {
|
|
return;
|
|
}
|
|
|
|
const login = {
|
|
username: this.userFilledFields["username"]?.value || "",
|
|
password: this.userFilledFields["password"]?.value || "",
|
|
uri: globalThis.document.URL,
|
|
hostname: globalThis.document.location.hostname,
|
|
};
|
|
|
|
this.sendExtensionMessage("autofillOverlayAddNewVaultItem", { login });
|
|
}
|
|
|
|
/**
|
|
* Redirects the keyboard focus out of the overlay, selecting the element that is
|
|
* either previous or next in the tab order. If the direction is current, the most
|
|
* recently focused field will be focused.
|
|
*
|
|
* @param direction - The direction to redirect the focus.
|
|
*/
|
|
redirectOverlayFocusOut(direction: string) {
|
|
if (!this.isOverlayListVisible || !this.mostRecentlyFocusedField) {
|
|
return;
|
|
}
|
|
|
|
if (direction === RedirectFocusDirection.Current) {
|
|
this.focusMostRecentOverlayField();
|
|
setTimeout(this.removeAutofillOverlay, 100);
|
|
return;
|
|
}
|
|
|
|
if (!this.focusableElements.length) {
|
|
this.focusableElements = this.findTabs(globalThis.document.body, { getShadowRoot: true });
|
|
}
|
|
|
|
const focusedElementIndex = this.focusableElements.findIndex(
|
|
(element) => element === this.mostRecentlyFocusedField,
|
|
);
|
|
|
|
const indexOffset = direction === RedirectFocusDirection.Previous ? -1 : 1;
|
|
const redirectFocusElement = this.focusableElements[focusedElementIndex + indexOffset];
|
|
redirectFocusElement?.focus();
|
|
}
|
|
|
|
/**
|
|
* Sets up the event listeners that facilitate interaction with the form field elements.
|
|
* Will clear any cached form field element handlers that are encountered when setting
|
|
* up a form field element to the overlay.
|
|
*
|
|
* @param formFieldElement - The form field element to set up the event listeners for.
|
|
*/
|
|
private setupFormFieldElementEventListeners(formFieldElement: ElementWithOpId<FormFieldElement>) {
|
|
this.removeCachedFormFieldEventListeners(formFieldElement);
|
|
|
|
formFieldElement.addEventListener(EVENTS.BLUR, this.handleFormFieldBlurEvent);
|
|
formFieldElement.addEventListener(EVENTS.KEYUP, this.handleFormFieldKeyupEvent);
|
|
formFieldElement.addEventListener(
|
|
EVENTS.INPUT,
|
|
this.handleFormFieldInputEvent(formFieldElement),
|
|
);
|
|
formFieldElement.addEventListener(
|
|
EVENTS.CLICK,
|
|
this.handleFormFieldClickEvent(formFieldElement),
|
|
);
|
|
formFieldElement.addEventListener(
|
|
EVENTS.FOCUS,
|
|
this.handleFormFieldFocusEvent(formFieldElement),
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Removes any cached form field element handlers that are encountered
|
|
* when setting up a form field element to present the overlay.
|
|
*
|
|
* @param formFieldElement - The form field element to remove the cached handlers for.
|
|
*/
|
|
private removeCachedFormFieldEventListeners(formFieldElement: ElementWithOpId<FormFieldElement>) {
|
|
const handlers = [EVENTS.INPUT, EVENTS.CLICK, EVENTS.FOCUS];
|
|
for (let index = 0; index < handlers.length; index++) {
|
|
const event = handlers[index];
|
|
const memoIndex = this.getFormFieldHandlerMemoIndex(formFieldElement, event);
|
|
const existingHandler = this.eventHandlersMemo[memoIndex];
|
|
if (!existingHandler) {
|
|
return;
|
|
}
|
|
|
|
formFieldElement.removeEventListener(event, existingHandler);
|
|
delete this.eventHandlersMemo[memoIndex];
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Helper method that facilitates registration of an event handler to a form field element.
|
|
*
|
|
* @param eventHandler - The event handler to memoize.
|
|
* @param memoIndex - The memo index to use for the event handler.
|
|
*/
|
|
private useEventHandlersMemo = (eventHandler: EventListener, memoIndex: string) => {
|
|
return this.eventHandlersMemo[memoIndex] || (this.eventHandlersMemo[memoIndex] = eventHandler);
|
|
};
|
|
|
|
/**
|
|
* Formats the memoIndex for the form field event handler.
|
|
*
|
|
* @param formFieldElement - The form field element to format the memo index for.
|
|
* @param event - The event to format the memo index for.
|
|
*/
|
|
private getFormFieldHandlerMemoIndex(
|
|
formFieldElement: ElementWithOpId<FormFieldElement>,
|
|
event: string,
|
|
) {
|
|
return `${formFieldElement.opid}-${formFieldElement.id}-${event}-handler`;
|
|
}
|
|
|
|
/**
|
|
* Form Field blur event handler. Updates the value identifying whether
|
|
* the field is focused and sends a message to check if the overlay itself
|
|
* is currently focused.
|
|
*/
|
|
private handleFormFieldBlurEvent = () => {
|
|
this.isFieldCurrentlyFocused = false;
|
|
this.sendExtensionMessage("checkAutofillOverlayFocused");
|
|
};
|
|
|
|
/**
|
|
* Form field keyup event handler. Facilitates the ability to remove the
|
|
* autofill overlay using the escape key, focusing the overlay list using
|
|
* the ArrowDown key, and ensuring that the overlay is repositioned when
|
|
* the form is submitted using the Enter key.
|
|
*
|
|
* @param event - The keyup event.
|
|
*/
|
|
private handleFormFieldKeyupEvent = (event: KeyboardEvent) => {
|
|
const eventCode = event.code;
|
|
if (eventCode === "Escape") {
|
|
this.removeAutofillOverlay();
|
|
return;
|
|
}
|
|
|
|
if (eventCode === "Enter" && !this.isCurrentlyFilling) {
|
|
this.handleOverlayRepositionEvent();
|
|
return;
|
|
}
|
|
|
|
if (eventCode === "ArrowDown") {
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
|
|
this.focusOverlayList();
|
|
}
|
|
};
|
|
|
|
/**
|
|
* Triggers a focus of the overlay list, if it is visible. If the list is not visible,
|
|
* the overlay will be opened and the list will be focused after a short delay. Ensures
|
|
* that the overlay list is focused when the user presses the down arrow key.
|
|
*/
|
|
private async focusOverlayList() {
|
|
if (!this.isOverlayListVisible && this.mostRecentlyFocusedField) {
|
|
await this.updateMostRecentlyFocusedField(this.mostRecentlyFocusedField);
|
|
this.openAutofillOverlay({ isOpeningFullOverlay: true });
|
|
setTimeout(() => this.sendExtensionMessage("focusAutofillOverlayList"), 125);
|
|
return;
|
|
}
|
|
|
|
this.sendExtensionMessage("focusAutofillOverlayList");
|
|
}
|
|
|
|
/**
|
|
* Sets up and memoizes the form field input event handler.
|
|
*
|
|
* @param formFieldElement - The form field element that triggered the input event.
|
|
*/
|
|
private handleFormFieldInputEvent = (formFieldElement: ElementWithOpId<FormFieldElement>) => {
|
|
return this.useEventHandlersMemo(
|
|
() => this.triggerFormFieldInput(formFieldElement),
|
|
this.getFormFieldHandlerMemoIndex(formFieldElement, EVENTS.INPUT),
|
|
);
|
|
};
|
|
|
|
/**
|
|
* Triggers when the form field element receives an input event. This method will
|
|
* store the modified form element data for use when the user attempts to add a new
|
|
* vault item. It also acts to remove the overlay list while the user is typing.
|
|
*
|
|
* @param formFieldElement - The form field element that triggered the input event.
|
|
*/
|
|
private triggerFormFieldInput(formFieldElement: ElementWithOpId<FormFieldElement>) {
|
|
if (formFieldElement instanceof HTMLSpanElement) {
|
|
return;
|
|
}
|
|
|
|
this.storeModifiedFormElement(formFieldElement);
|
|
|
|
if (formFieldElement.value && (this.isOverlayCiphersPopulated || !this.isUserAuthed())) {
|
|
this.removeAutofillOverlayList();
|
|
return;
|
|
}
|
|
|
|
this.openAutofillOverlay();
|
|
}
|
|
|
|
/**
|
|
* Stores the modified form element data for use when the user attempts to add a new
|
|
* vault item. This method will also store the most recently focused field, if it is
|
|
* not already stored.
|
|
*
|
|
* @param formFieldElement
|
|
* @private
|
|
*/
|
|
private storeModifiedFormElement(formFieldElement: ElementWithOpId<FillableFormFieldElement>) {
|
|
if (formFieldElement === this.mostRecentlyFocusedField) {
|
|
this.mostRecentlyFocusedField = formFieldElement;
|
|
}
|
|
|
|
if (formFieldElement.type === "password") {
|
|
this.userFilledFields.password = formFieldElement;
|
|
return;
|
|
}
|
|
|
|
this.userFilledFields.username = formFieldElement;
|
|
}
|
|
|
|
/**
|
|
* Sets up and memoizes the form field click event handler.
|
|
*
|
|
* @param formFieldElement - The form field element that triggered the click event.
|
|
*/
|
|
private handleFormFieldClickEvent = (formFieldElement: ElementWithOpId<FormFieldElement>) => {
|
|
return this.useEventHandlersMemo(
|
|
() => this.triggerFormFieldClickedAction(formFieldElement),
|
|
this.getFormFieldHandlerMemoIndex(formFieldElement, EVENTS.CLICK),
|
|
);
|
|
};
|
|
|
|
/**
|
|
* Triggers when the form field element receives a click event. This method will
|
|
* trigger the focused action for the form field element if the overlay is not visible.
|
|
*
|
|
* @param formFieldElement - The form field element that triggered the click event.
|
|
*/
|
|
private async triggerFormFieldClickedAction(formFieldElement: ElementWithOpId<FormFieldElement>) {
|
|
if (this.isOverlayButtonVisible || this.isOverlayListVisible) {
|
|
return;
|
|
}
|
|
|
|
await this.triggerFormFieldFocusedAction(formFieldElement);
|
|
}
|
|
|
|
/**
|
|
* Sets up and memoizes the form field focus event handler.
|
|
*
|
|
* @param formFieldElement - The form field element that triggered the focus event.
|
|
*/
|
|
private handleFormFieldFocusEvent = (formFieldElement: ElementWithOpId<FormFieldElement>) => {
|
|
return this.useEventHandlersMemo(
|
|
() => this.triggerFormFieldFocusedAction(formFieldElement),
|
|
this.getFormFieldHandlerMemoIndex(formFieldElement, EVENTS.FOCUS),
|
|
);
|
|
};
|
|
|
|
/**
|
|
* Triggers when the form field element receives a focus event. This method will
|
|
* update the most recently focused field and open the autofill overlay if the
|
|
* autofill process is not currently active.
|
|
*
|
|
* @param formFieldElement - The form field element that triggered the focus event.
|
|
*/
|
|
private async triggerFormFieldFocusedAction(formFieldElement: ElementWithOpId<FormFieldElement>) {
|
|
if (this.isCurrentlyFilling) {
|
|
return;
|
|
}
|
|
|
|
this.isFieldCurrentlyFocused = true;
|
|
this.clearUserInteractionEventTimeout();
|
|
const initiallyFocusedField = this.mostRecentlyFocusedField;
|
|
await this.updateMostRecentlyFocusedField(formFieldElement);
|
|
const formElementHasValue = Boolean((formFieldElement as HTMLInputElement).value);
|
|
|
|
if (
|
|
this.autofillOverlayVisibility === AutofillOverlayVisibility.OnButtonClick ||
|
|
(formElementHasValue && initiallyFocusedField !== this.mostRecentlyFocusedField)
|
|
) {
|
|
this.removeAutofillOverlayList();
|
|
}
|
|
|
|
if (!formElementHasValue || (!this.isOverlayCiphersPopulated && this.isUserAuthed())) {
|
|
this.sendExtensionMessage("openAutofillOverlay");
|
|
return;
|
|
}
|
|
|
|
this.updateOverlayButtonPosition();
|
|
}
|
|
|
|
/**
|
|
* Validates whether the user is currently authenticated.
|
|
*/
|
|
private isUserAuthed() {
|
|
return this.authStatus === AuthenticationStatus.Unlocked;
|
|
}
|
|
|
|
/**
|
|
* Identifies if the autofill field's data contains any of
|
|
* the keyboards matching the passed list of keywords.
|
|
*
|
|
* @param autofillFieldData - Autofill field data captured from the form field element.
|
|
* @param keywords - Keywords to search for in the autofill field data.
|
|
*/
|
|
private keywordsFoundInFieldData(autofillFieldData: AutofillField, keywords: string[]) {
|
|
const searchedString = this.getAutofillFieldDataKeywords(autofillFieldData);
|
|
return keywords.some((keyword) => searchedString.includes(keyword));
|
|
}
|
|
|
|
/**
|
|
* Aggregates the autofill field's data into a single string
|
|
* that can be used to search for keywords.
|
|
*
|
|
* @param autofillFieldData - Autofill field data captured from the form field element.
|
|
*/
|
|
private getAutofillFieldDataKeywords(autofillFieldData: AutofillField) {
|
|
if (this.autofillFieldKeywordsMap.has(autofillFieldData)) {
|
|
return this.autofillFieldKeywordsMap.get(autofillFieldData);
|
|
}
|
|
|
|
const keywordValues = [
|
|
autofillFieldData.htmlID,
|
|
autofillFieldData.htmlName,
|
|
autofillFieldData.htmlClass,
|
|
autofillFieldData.type,
|
|
autofillFieldData.title,
|
|
autofillFieldData.placeholder,
|
|
autofillFieldData.autoCompleteType,
|
|
autofillFieldData["label-data"],
|
|
autofillFieldData["label-aria"],
|
|
autofillFieldData["label-left"],
|
|
autofillFieldData["label-right"],
|
|
autofillFieldData["label-tag"],
|
|
autofillFieldData["label-top"],
|
|
]
|
|
.join(",")
|
|
.toLowerCase();
|
|
this.autofillFieldKeywordsMap.set(autofillFieldData, keywordValues);
|
|
|
|
return keywordValues;
|
|
}
|
|
|
|
/**
|
|
* Validates that the most recently focused field is currently
|
|
* focused within the root node relative to the field.
|
|
*/
|
|
private recentlyFocusedFieldIsCurrentlyFocused() {
|
|
return (
|
|
this.getRootNodeActiveElement(this.mostRecentlyFocusedField) === this.mostRecentlyFocusedField
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Updates the position of both the overlay button and overlay list.
|
|
*/
|
|
private updateOverlayElementsPosition() {
|
|
this.updateOverlayButtonPosition();
|
|
this.updateOverlayListPosition();
|
|
}
|
|
|
|
/**
|
|
* Updates the position of the overlay button.
|
|
*/
|
|
private updateOverlayButtonPosition() {
|
|
if (!this.overlayButtonElement) {
|
|
this.createAutofillOverlayButton();
|
|
}
|
|
|
|
if (!this.isOverlayButtonVisible) {
|
|
this.appendOverlayElementToBody(this.overlayButtonElement);
|
|
this.isOverlayButtonVisible = true;
|
|
this.setOverlayRepositionEventListeners();
|
|
}
|
|
this.sendExtensionMessage("updateAutofillOverlayPosition", {
|
|
overlayElement: AutofillOverlayElement.Button,
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Updates the position of the overlay list.
|
|
*/
|
|
private updateOverlayListPosition() {
|
|
if (!this.overlayListElement) {
|
|
this.createAutofillOverlayList();
|
|
}
|
|
|
|
if (!this.isOverlayListVisible) {
|
|
this.appendOverlayElementToBody(this.overlayListElement);
|
|
this.isOverlayListVisible = true;
|
|
}
|
|
|
|
this.sendExtensionMessage("updateAutofillOverlayPosition", {
|
|
overlayElement: AutofillOverlayElement.List,
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Appends the overlay element to the body element. This method will also
|
|
* observe the body element to ensure that the overlay element is not
|
|
* interfered with by any DOM changes.
|
|
*
|
|
* @param element - The overlay element to append to the body element.
|
|
*/
|
|
private appendOverlayElementToBody(element: HTMLElement) {
|
|
this.observeBodyElement();
|
|
globalThis.document.body.appendChild(element);
|
|
}
|
|
|
|
/**
|
|
* Sends a message that facilitates hiding the overlay elements.
|
|
*
|
|
* @param isHidden - Indicates if the overlay elements should be hidden.
|
|
*/
|
|
private toggleOverlayHidden(isHidden: boolean) {
|
|
const displayValue = isHidden ? "none" : "block";
|
|
this.sendExtensionMessage("updateAutofillOverlayHidden", { display: displayValue });
|
|
|
|
this.isOverlayButtonVisible = !isHidden;
|
|
this.isOverlayListVisible = !isHidden;
|
|
}
|
|
|
|
/**
|
|
* Updates the data used to position the overlay elements in relation
|
|
* to the most recently focused form field.
|
|
*
|
|
* @param formFieldElement - The form field element that triggered the focus event.
|
|
*/
|
|
private async updateMostRecentlyFocusedField(
|
|
formFieldElement: ElementWithOpId<FormFieldElement>,
|
|
) {
|
|
this.mostRecentlyFocusedField = formFieldElement;
|
|
const { paddingRight, paddingLeft } = globalThis.getComputedStyle(formFieldElement);
|
|
const { width, height, top, left } =
|
|
await this.getMostRecentlyFocusedFieldRects(formFieldElement);
|
|
this.focusedFieldData = {
|
|
focusedFieldStyles: { paddingRight, paddingLeft },
|
|
focusedFieldRects: { width, height, top, left },
|
|
};
|
|
|
|
this.sendExtensionMessage("updateFocusedFieldData", {
|
|
focusedFieldData: this.focusedFieldData,
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Gets the bounding client rects for the most recently focused field. This method will
|
|
* attempt to use an intersection observer to get the most recently focused field's
|
|
* bounding client rects. If the intersection observer is not supported, or the
|
|
* intersection observer does not return a valid bounding client rect, the form
|
|
* field element's bounding client rect will be used.
|
|
*
|
|
* @param formFieldElement - The form field element that triggered the focus event.
|
|
*/
|
|
private async getMostRecentlyFocusedFieldRects(
|
|
formFieldElement: ElementWithOpId<FormFieldElement>,
|
|
) {
|
|
const focusedFieldRects =
|
|
await this.getBoundingClientRectFromIntersectionObserver(formFieldElement);
|
|
if (focusedFieldRects) {
|
|
return focusedFieldRects;
|
|
}
|
|
|
|
return formFieldElement.getBoundingClientRect();
|
|
}
|
|
|
|
/**
|
|
* Gets the bounds of the form field element from the IntersectionObserver API.
|
|
*
|
|
* @param formFieldElement - The form field element that triggered the focus event.
|
|
*/
|
|
private async getBoundingClientRectFromIntersectionObserver(
|
|
formFieldElement: ElementWithOpId<FormFieldElement>,
|
|
): Promise<DOMRectReadOnly | null> {
|
|
if (!("IntersectionObserver" in window) && !("IntersectionObserverEntry" in window)) {
|
|
return null;
|
|
}
|
|
|
|
return new Promise((resolve) => {
|
|
const intersectionObserver = new IntersectionObserver(
|
|
(entries) => {
|
|
let fieldBoundingClientRects = entries[0]?.boundingClientRect;
|
|
if (!fieldBoundingClientRects?.width || !fieldBoundingClientRects.height) {
|
|
fieldBoundingClientRects = null;
|
|
}
|
|
|
|
intersectionObserver.disconnect();
|
|
resolve(fieldBoundingClientRects);
|
|
},
|
|
{
|
|
root: globalThis.document.body,
|
|
rootMargin: "0px",
|
|
threshold: 0.9999, // Safari doesn't seem to function properly with a threshold of 1
|
|
},
|
|
);
|
|
intersectionObserver.observe(formFieldElement);
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Identifies if the field should have the autofill overlay setup on it. Currently, this is mainly
|
|
* determined by whether the field correlates with a login cipher. This method will need to be
|
|
* updated in the future to support other types of forms.
|
|
*
|
|
* @param autofillFieldData - Autofill field data captured from the form field element.
|
|
*/
|
|
private isIgnoredField(autofillFieldData: AutofillField): boolean {
|
|
const ignoredFieldTypes = new Set(AutoFillConstants.ExcludedAutofillTypes);
|
|
if (
|
|
autofillFieldData.readonly ||
|
|
autofillFieldData.disabled ||
|
|
!autofillFieldData.viewable ||
|
|
ignoredFieldTypes.has(autofillFieldData.type) ||
|
|
this.keywordsFoundInFieldData(autofillFieldData, ["search", "captcha"])
|
|
) {
|
|
return true;
|
|
}
|
|
|
|
const isLoginCipherField =
|
|
autofillFieldData.type === "password" ||
|
|
this.keywordsFoundInFieldData(autofillFieldData, AutoFillConstants.UsernameFieldNames);
|
|
|
|
return !isLoginCipherField;
|
|
}
|
|
|
|
/**
|
|
* Creates the autofill overlay button element. Will not attempt
|
|
* to create the element if it already exists in the DOM.
|
|
*/
|
|
private createAutofillOverlayButton() {
|
|
if (this.overlayButtonElement) {
|
|
return;
|
|
}
|
|
|
|
const customElementName = generateRandomCustomElementName();
|
|
globalThis.customElements?.define(customElementName, AutofillOverlayButtonIframe);
|
|
this.overlayButtonElement = globalThis.document.createElement(customElementName);
|
|
|
|
this.updateCustomElementDefaultStyles(this.overlayButtonElement);
|
|
this.moveDocumentElementChildrenToBody(globalThis.document.documentElement.childNodes);
|
|
}
|
|
|
|
/**
|
|
* Creates the autofill overlay list element. Will not attempt
|
|
* to create the element if it already exists in the DOM.
|
|
*/
|
|
private createAutofillOverlayList() {
|
|
if (this.overlayListElement) {
|
|
return;
|
|
}
|
|
|
|
const customElementName = generateRandomCustomElementName();
|
|
globalThis.customElements?.define(customElementName, AutofillOverlayListIframe);
|
|
this.overlayListElement = globalThis.document.createElement(customElementName);
|
|
|
|
this.updateCustomElementDefaultStyles(this.overlayListElement);
|
|
}
|
|
|
|
/**
|
|
* Updates the default styles for the custom element. This method will
|
|
* remove any styles that are added to the custom element by other methods.
|
|
*
|
|
* @param element - The custom element to update the default styles for.
|
|
*/
|
|
private updateCustomElementDefaultStyles(element: HTMLElement) {
|
|
this.unobserveCustomElements();
|
|
|
|
setElementStyles(element, this.customElementDefaultStyles, true);
|
|
|
|
this.observeCustomElements();
|
|
}
|
|
|
|
/**
|
|
* Queries the background script for the autofill overlay visibility setting.
|
|
* If the setting is not found, a default value of OnFieldFocus will be used
|
|
* @private
|
|
*/
|
|
private async getAutofillOverlayVisibility() {
|
|
const overlayVisibility = await this.sendExtensionMessage("getAutofillOverlayVisibility");
|
|
this.autofillOverlayVisibility = overlayVisibility || AutofillOverlayVisibility.OnFieldFocus;
|
|
}
|
|
|
|
/**
|
|
* Sets up event listeners that facilitate repositioning
|
|
* the autofill overlay on scroll or resize.
|
|
*/
|
|
private setOverlayRepositionEventListeners() {
|
|
globalThis.addEventListener(EVENTS.SCROLL, this.handleOverlayRepositionEvent, {
|
|
capture: true,
|
|
});
|
|
globalThis.addEventListener(EVENTS.RESIZE, this.handleOverlayRepositionEvent);
|
|
}
|
|
|
|
/**
|
|
* Removes the listeners that facilitate repositioning
|
|
* the autofill overlay on scroll or resize.
|
|
*/
|
|
private removeOverlayRepositionEventListeners() {
|
|
globalThis.removeEventListener(EVENTS.SCROLL, this.handleOverlayRepositionEvent, {
|
|
capture: true,
|
|
});
|
|
globalThis.removeEventListener(EVENTS.RESIZE, this.handleOverlayRepositionEvent);
|
|
}
|
|
|
|
/**
|
|
* Handles the resize or scroll events that enact
|
|
* repositioning of the overlay.
|
|
*/
|
|
private handleOverlayRepositionEvent = () => {
|
|
if (!this.isOverlayButtonVisible && !this.isOverlayListVisible) {
|
|
return;
|
|
}
|
|
|
|
this.toggleOverlayHidden(true);
|
|
this.clearUserInteractionEventTimeout();
|
|
this.userInteractionEventTimeout = setTimeout(this.triggerOverlayRepositionUpdates, 750);
|
|
};
|
|
|
|
/**
|
|
* Triggers the overlay reposition updates. This method ensures that the overlay elements
|
|
* are correctly positioned when the viewport scrolls or repositions.
|
|
*/
|
|
private triggerOverlayRepositionUpdates = async () => {
|
|
if (!this.recentlyFocusedFieldIsCurrentlyFocused()) {
|
|
this.toggleOverlayHidden(false);
|
|
this.removeAutofillOverlay();
|
|
return;
|
|
}
|
|
|
|
await this.updateMostRecentlyFocusedField(this.mostRecentlyFocusedField);
|
|
this.updateOverlayElementsPosition();
|
|
this.toggleOverlayHidden(false);
|
|
this.clearUserInteractionEventTimeout();
|
|
|
|
if (
|
|
this.focusedFieldData.focusedFieldRects?.top > 0 &&
|
|
this.focusedFieldData.focusedFieldRects?.top < window.innerHeight
|
|
) {
|
|
return;
|
|
}
|
|
|
|
this.removeAutofillOverlay();
|
|
};
|
|
|
|
/**
|
|
* Clears the user interaction event timeout. This is used to ensure that
|
|
* the overlay is not repositioned while the user is interacting with it.
|
|
*/
|
|
private clearUserInteractionEventTimeout() {
|
|
if (this.userInteractionEventTimeout) {
|
|
clearTimeout(this.userInteractionEventTimeout);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Sets up global event listeners and the mutation
|
|
* observer to facilitate required changes to the
|
|
* overlay elements.
|
|
*/
|
|
private setupGlobalEventListeners = () => {
|
|
globalThis.document.addEventListener(EVENTS.VISIBILITYCHANGE, this.handleVisibilityChangeEvent);
|
|
globalThis.addEventListener(EVENTS.FOCUSOUT, this.handleFormFieldBlurEvent);
|
|
this.setupMutationObserver();
|
|
};
|
|
|
|
/**
|
|
* Handles the visibility change event. This method will remove the
|
|
* autofill overlay if the document is not visible.
|
|
*/
|
|
private handleVisibilityChangeEvent = () => {
|
|
if (document.visibilityState === "visible") {
|
|
return;
|
|
}
|
|
|
|
this.mostRecentlyFocusedField = null;
|
|
this.removeAutofillOverlay();
|
|
};
|
|
|
|
/**
|
|
* Sets up mutation observers for the overlay elements, the body element, and the
|
|
* document element. The mutation observers are used to remove any styles that are
|
|
* added to the overlay elements by the website. They are also used to ensure that
|
|
* the overlay elements are always present at the bottom of the body element.
|
|
*/
|
|
private setupMutationObserver = () => {
|
|
this.overlayElementsMutationObserver = new MutationObserver(
|
|
this.handleOverlayElementMutationObserverUpdate,
|
|
);
|
|
|
|
this.bodyElementMutationObserver = new MutationObserver(
|
|
this.handleBodyElementMutationObserverUpdate,
|
|
);
|
|
|
|
const documentElementMutationObserver = new MutationObserver(
|
|
this.handleDocumentElementMutationObserverUpdate,
|
|
);
|
|
documentElementMutationObserver.observe(globalThis.document.documentElement, {
|
|
childList: true,
|
|
});
|
|
};
|
|
|
|
/**
|
|
* Sets up mutation observers to verify that the overlay
|
|
* elements are not modified by the website.
|
|
*/
|
|
private observeCustomElements() {
|
|
if (this.overlayButtonElement) {
|
|
this.overlayElementsMutationObserver?.observe(this.overlayButtonElement, {
|
|
attributes: true,
|
|
});
|
|
}
|
|
|
|
if (this.overlayListElement) {
|
|
this.overlayElementsMutationObserver?.observe(this.overlayListElement, { attributes: true });
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Disconnects the mutation observers that are used to verify that the overlay
|
|
* elements are not modified by the website.
|
|
*/
|
|
private unobserveCustomElements() {
|
|
this.overlayElementsMutationObserver?.disconnect();
|
|
}
|
|
|
|
/**
|
|
* Sets up a mutation observer for the body element. The mutation observer is used
|
|
* to ensure that the overlay elements are always present at the bottom of the body
|
|
* element.
|
|
*/
|
|
private observeBodyElement() {
|
|
this.bodyElementMutationObserver?.observe(globalThis.document.body, { childList: true });
|
|
}
|
|
|
|
/**
|
|
* Disconnects the mutation observer for the body element.
|
|
*/
|
|
private removeBodyElementObserver() {
|
|
this.bodyElementMutationObserver?.disconnect();
|
|
}
|
|
|
|
/**
|
|
* Handles the mutation observer update for the overlay elements. This method will
|
|
* remove any attributes or styles that might be added to the overlay elements by
|
|
* a separate process within the website where this script is injected.
|
|
*
|
|
* @param mutationRecord - The mutation record that triggered the update.
|
|
*/
|
|
private handleOverlayElementMutationObserverUpdate = (mutationRecord: MutationRecord[]) => {
|
|
if (this.isTriggeringExcessiveMutationObserverIterations()) {
|
|
return;
|
|
}
|
|
|
|
for (let recordIndex = 0; recordIndex < mutationRecord.length; recordIndex++) {
|
|
const record = mutationRecord[recordIndex];
|
|
if (record.type !== "attributes") {
|
|
continue;
|
|
}
|
|
|
|
const element = record.target as HTMLElement;
|
|
if (record.attributeName !== "style") {
|
|
this.removeModifiedElementAttributes(element);
|
|
|
|
continue;
|
|
}
|
|
|
|
element.removeAttribute("style");
|
|
this.updateCustomElementDefaultStyles(element);
|
|
}
|
|
};
|
|
|
|
/**
|
|
* Removes all elements from a passed overlay
|
|
* element except for the style attribute.
|
|
*
|
|
* @param element - The element to remove the attributes from.
|
|
*/
|
|
private removeModifiedElementAttributes(element: HTMLElement) {
|
|
const attributes = Array.from(element.attributes);
|
|
for (let attributeIndex = 0; attributeIndex < attributes.length; attributeIndex++) {
|
|
const attribute = attributes[attributeIndex];
|
|
if (attribute.name === "style") {
|
|
continue;
|
|
}
|
|
|
|
element.removeAttribute(attribute.name);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Handles the mutation observer update for the body element. This method will
|
|
* ensure that the overlay elements are always present at the bottom of the body
|
|
* element.
|
|
*/
|
|
private handleBodyElementMutationObserverUpdate = () => {
|
|
if (
|
|
(!this.overlayButtonElement && !this.overlayListElement) ||
|
|
this.isTriggeringExcessiveMutationObserverIterations()
|
|
) {
|
|
return;
|
|
}
|
|
|
|
const lastChild = globalThis.document.body.lastElementChild;
|
|
const secondToLastChild = lastChild?.previousElementSibling;
|
|
const lastChildIsOverlayList = lastChild === this.overlayListElement;
|
|
const lastChildIsOverlayButton = lastChild === this.overlayButtonElement;
|
|
const secondToLastChildIsOverlayButton = secondToLastChild === this.overlayButtonElement;
|
|
|
|
if (
|
|
(lastChildIsOverlayList && secondToLastChildIsOverlayButton) ||
|
|
(lastChildIsOverlayButton && !this.isOverlayListVisible)
|
|
) {
|
|
return;
|
|
}
|
|
|
|
if (
|
|
(lastChildIsOverlayList && !secondToLastChildIsOverlayButton) ||
|
|
(lastChildIsOverlayButton && this.isOverlayListVisible)
|
|
) {
|
|
globalThis.document.body.insertBefore(this.overlayButtonElement, this.overlayListElement);
|
|
return;
|
|
}
|
|
|
|
globalThis.document.body.insertBefore(lastChild, this.overlayButtonElement);
|
|
};
|
|
|
|
/**
|
|
* Handles the mutation observer update for the document element. This
|
|
* method will ensure that any elements added to the document element
|
|
* are appended to the body element.
|
|
*
|
|
* @param mutationRecords - The mutation records that triggered the update.
|
|
*/
|
|
private handleDocumentElementMutationObserverUpdate = (mutationRecords: MutationRecord[]) => {
|
|
if (
|
|
(!this.overlayButtonElement && !this.overlayListElement) ||
|
|
this.isTriggeringExcessiveMutationObserverIterations()
|
|
) {
|
|
return;
|
|
}
|
|
|
|
for (const record of mutationRecords) {
|
|
if (record.type !== "childList" || record.addedNodes.length === 0) {
|
|
continue;
|
|
}
|
|
|
|
this.moveDocumentElementChildrenToBody(record.addedNodes);
|
|
}
|
|
};
|
|
|
|
/**
|
|
* Moves the passed nodes to the body element. This method is used to ensure that
|
|
* any elements added to the document element are higher in the DOM than the overlay
|
|
* elements.
|
|
*
|
|
* @param nodes - The nodes to move to the body element.
|
|
*/
|
|
private moveDocumentElementChildrenToBody(nodes: NodeList) {
|
|
const ignoredElements = new Set([globalThis.document.body, globalThis.document.head]);
|
|
for (const node of nodes) {
|
|
if (ignoredElements.has(node as HTMLElement)) {
|
|
continue;
|
|
}
|
|
|
|
// This is a workaround for an issue where the document element's children
|
|
// are not appended to the body element. This forces the children to be
|
|
// appended on the next tick of the event loop.
|
|
setTimeout(() => globalThis.document.body.appendChild(node), 0);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Identifies if the mutation observer is triggering excessive iterations.
|
|
* Will trigger a blur of the most recently focused field and remove the
|
|
* autofill overlay if any set mutation observer is triggering
|
|
* excessive iterations.
|
|
*/
|
|
private isTriggeringExcessiveMutationObserverIterations() {
|
|
if (this.mutationObserverIterationsResetTimeout) {
|
|
clearTimeout(this.mutationObserverIterationsResetTimeout);
|
|
}
|
|
|
|
this.mutationObserverIterations++;
|
|
this.mutationObserverIterationsResetTimeout = setTimeout(
|
|
() => (this.mutationObserverIterations = 0),
|
|
2000,
|
|
);
|
|
|
|
if (this.mutationObserverIterations > 100) {
|
|
clearTimeout(this.mutationObserverIterationsResetTimeout);
|
|
this.mutationObserverIterations = 0;
|
|
this.blurMostRecentOverlayField();
|
|
this.removeAutofillOverlay();
|
|
|
|
return true;
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* Gets the root node of the passed element and returns the active element within that root node.
|
|
*
|
|
* @param element - The element to get the root node active element for.
|
|
*/
|
|
private getRootNodeActiveElement(element: Element): Element {
|
|
const documentRoot = element.getRootNode() as ShadowRoot | Document;
|
|
return documentRoot?.activeElement;
|
|
}
|
|
}
|
|
|
|
export default AutofillOverlayContentService;
|