From d4e5b724ff8685327d340ac97bfa23687be96c81 Mon Sep 17 00:00:00 2001 From: Leendert de Borst Date: Tue, 15 Apr 2025 10:48:08 +0200 Subject: [PATCH] Make autofill work with more input element variations (#794) --- browser-extension/src/entrypoints/content.ts | 33 +++---- .../src/entrypoints/contentScript/Form.ts | 91 ++++++++++++++++--- .../src/entrypoints/contentScript/Popup.ts | 24 ++--- docs/misc/dev/browser-extensions.md | 2 + 4 files changed, 111 insertions(+), 39 deletions(-) diff --git a/browser-extension/src/entrypoints/content.ts b/browser-extension/src/entrypoints/content.ts index f625b7956..e67bb2fe5 100644 --- a/browser-extension/src/entrypoints/content.ts +++ b/browser-extension/src/entrypoints/content.ts @@ -1,7 +1,7 @@ import './contentScript/style.css'; import { FormDetector } from '../utils/formDetector/FormDetector'; import { isAutoShowPopupEnabled, openAutofillPopup, removeExistingPopup } from './contentScript/Popup'; -import { injectIcon, popupDebounceTimeHasPassed } from './contentScript/Form'; +import { injectIcon, popupDebounceTimeHasPassed, validateInputField } from './contentScript/Form'; import { onMessage } from "webext-bridge/content-script"; import { BoolResponse as messageBoolResponse } from '../utils/types/messaging/BoolResponse'; import { defineContentScript } from 'wxt/sandbox'; @@ -25,7 +25,9 @@ export default defineContentScript({ // Create a shadow root UI for isolation const ui = await createShadowRootUi(ctx, { name: 'aliasvault-ui', - position: 'inline', + position: 'overlay', + alignment: 'top-left', + zIndex: 1000, anchor: 'html', /** * Handle mount. @@ -40,25 +42,24 @@ export default defineContentScript({ } // Check if element itself, html or body has av-disable attribute like av-disable="true" - const avDisable = (e.target as HTMLElement).getAttribute('av-disable') ?? document.body?.getAttribute('av-disable') ?? document.documentElement.getAttribute('av-disable'); - if (avDisable === 'true') { + const avDisable = ((e.target as HTMLElement).getAttribute('av-disable') ?? document.body?.getAttribute('av-disable') ?? document.documentElement.getAttribute('av-disable')) === 'true'; + if (avDisable) { return; } - const target = e.target as HTMLInputElement; - const textInputTypes = ['text', 'email', 'tel', 'password', 'search', 'url']; + const { isValid, inputElement } = validateInputField(e.target as Element); - if (target.tagName === 'INPUT' && textInputTypes.includes(target.type) && !target.dataset.aliasvaultIgnore) { - const formDetector = new FormDetector(document, target); + if (isValid && inputElement) { + const formDetector = new FormDetector(document, inputElement); if (!formDetector.containsLoginForm()) { return; } - injectIcon(target, container); + injectIcon(inputElement, container); // Only show popup if its enabled and debounce time has passed. if (await isAutoShowPopupEnabled() && popupDebounceTimeHasPassed()) { - openAutofillPopup(target, container); + openAutofillPopup(inputElement, container); } } }; @@ -85,19 +86,19 @@ export default defineContentScript({ } const target = document.getElementById(elementIdentifier) ?? document.getElementsByName(elementIdentifier)[0]; + const { isValid, inputElement } = validateInputField(target); - if (!(target instanceof HTMLInputElement)) { - return { success: false, error: 'Target element is not an input field' }; + if (!isValid || !inputElement) { + return { success: false, error: 'Target element is not a supported input field' }; } - const formDetector = new FormDetector(document, target); - + const formDetector = new FormDetector(document, inputElement); if (!formDetector.containsLoginForm()) { return { success: false, error: 'No form found' }; } - injectIcon(target, container); - openAutofillPopup(target, container); + injectIcon(inputElement, container); + openAutofillPopup(inputElement, container); return { success: true }; }); }, diff --git a/browser-extension/src/entrypoints/contentScript/Form.ts b/browser-extension/src/entrypoints/contentScript/Form.ts index 5d9d1dd98..42269ccd7 100644 --- a/browser-extension/src/entrypoints/contentScript/Form.ts +++ b/browser-extension/src/entrypoints/contentScript/Form.ts @@ -29,6 +29,34 @@ export function hidePopupFor(ms: number) : void { popupDebounceTime = Date.now() + ms; } +/** + * Validates if an element is a supported input field that can be processed for autofill. + * @param element The element to validate + * @returns An object containing validation result and the element cast as HTMLInputElement if valid + */ +export function validateInputField(element: Element | null): { isValid: boolean; inputElement?: HTMLInputElement } { + if (!element) { + return { isValid: false }; + } + + const textInputTypes = ['text', 'email', 'tel', 'password', 'search', 'url', 'number']; + const elementType = element.getAttribute('type'); + const isInputElement = element.tagName.toLowerCase() === 'input'; + + // Check if it's a valid input field we should process + const isValid = ( + // Case 1: It's an input element (with either explicit type or defaulting to "text") + (isInputElement && (!elementType || textInputTypes.includes(elementType?.toLowerCase() ?? ''))) || + // Case 2: Non-input element but has valid type attribute + (!isInputElement && elementType && textInputTypes.includes(elementType.toLowerCase())) + ) as boolean; + + return { + isValid, + inputElement: isValid ? (element as HTMLInputElement) : undefined + }; +} + /** * Fill credential into current form. * @@ -51,10 +79,44 @@ export function fillCredential(credential: Credential, input: HTMLInputElement) formFiller.fillFields(credential); } +/** + * Find the actual visible input element, either the element itself or a child input. + * Certain websites use custom input element wrappers that not only contain the input but + * also other elements like labels, icons, etc. As we want to position the icon relative to the actual + * input, we try to find the actual input element. If there is no actual input element, we fallback + * to the provided element. + * + * This method is optional, but it improves the AliasVault icon positioning on certain websites. + * + * @param element - The element to check. + * @returns The actual input element to use for positioning. + */ +function findActualInput(element: HTMLElement): HTMLInputElement { + // If it's already an input, return it + if (element.tagName.toLowerCase() === 'input') { + return element as HTMLInputElement; + } + + // Try to find a visible child input + const childInput = element.querySelector('input'); + if (childInput) { + const style = window.getComputedStyle(childInput); + if (style.display !== 'none' && style.visibility !== 'hidden') { + return childInput; + } + } + + // Fallback to the provided element if no child input found + return element as HTMLInputElement; +} + /** * Inject icon for a focused input element */ export function injectIcon(input: HTMLInputElement, container: HTMLElement): void { + // Find the actual input element to use for positioning + const actualInput = findActualInput(input); + const aliasvaultIconSvg = ` @@ -71,8 +133,8 @@ export function injectIcon(input: HTMLInputElement, container: HTMLElement): voi `; // Generate unique ID if input doesn't have one - if (!input.id) { - input.id = `aliasvault-input-${Math.random().toString(36).substring(2, 11)}`; + if (!actualInput.id) { + actualInput.id = `aliasvault-input-${Math.random().toString(36).substring(2, 11)}`; } // Create an overlay container at document level if it doesn't exist @@ -88,19 +150,26 @@ export function injectIcon(input: HTMLInputElement, container: HTMLElement): voi const iconContainer = document.createElement('div'); iconContainer.innerHTML = ICON_HTML; const icon = iconContainer.firstElementChild as HTMLElement; - icon.setAttribute('data-icon-for', input.id); + icon.setAttribute('data-icon-for', actualInput.id); // Enable pointer events just for the icon icon.style.pointerEvents = 'auto'; /** * Update position of the icon. + * Positions icon relative to right edge, moving it left by any existing padding. */ const updateIconPosition = () : void => { - const rect = input.getBoundingClientRect(); + const rect = actualInput.getBoundingClientRect(); + const computedStyle = window.getComputedStyle(actualInput); + const paddingRight = parseInt(computedStyle.paddingLeft + computedStyle.paddingRight); + + // Default offset is 32px, add any padding to move it further left + const rightOffset = 24 + paddingRight; + icon.style.position = 'fixed'; icon.style.top = `${rect.top + (rect.height - 24) / 2}px`; - icon.style.left = `${rect.right - 32}px`; + icon.style.left = `${(rect.left + rect.width) - rightOffset}px`; }; // Update position initially and on relevant events @@ -112,8 +181,8 @@ export function injectIcon(input: HTMLInputElement, container: HTMLElement): voi icon.addEventListener('click', (e: MouseEvent) => { e.preventDefault(); e.stopPropagation(); - setTimeout(() => input.focus(), 0); - openAutofillPopup(input, container); + setTimeout(() => actualInput.focus(), 0); + openAutofillPopup(actualInput, container); }); // Append the icon to the overlay container @@ -131,8 +200,8 @@ export function injectIcon(input: HTMLInputElement, container: HTMLElement): voi icon.style.opacity = '0'; setTimeout(() => { icon.remove(); - input.removeEventListener('blur', handleBlur); - input.removeEventListener('keydown', handleKeyPress); + actualInput.removeEventListener('blur', handleBlur); + actualInput.removeEventListener('keydown', handleKeyPress); window.removeEventListener('scroll', updateIconPosition, true); window.removeEventListener('resize', updateIconPosition); @@ -153,8 +222,8 @@ export function injectIcon(input: HTMLInputElement, container: HTMLElement): voi } }; - input.addEventListener('blur', handleBlur); - input.addEventListener('keydown', handleKeyPress); + actualInput.addEventListener('blur', handleBlur); + actualInput.addEventListener('keydown', handleKeyPress); } /** diff --git a/browser-extension/src/entrypoints/contentScript/Popup.ts b/browser-extension/src/entrypoints/contentScript/Popup.ts index dc9d57801..9a6a56a25 100644 --- a/browser-extension/src/entrypoints/contentScript/Popup.ts +++ b/browser-extension/src/entrypoints/contentScript/Popup.ts @@ -159,7 +159,7 @@ export function removeExistingPopup(container: HTMLElement) : void { /** * Create auto-fill popup */ -export function createAutofillPopup(input: HTMLInputElement, credentials: Credential[] | undefined, rootContainer: HTMLElement) : void { +export function createAutofillPopup(input: HTMLInputElement, credentials: Credential[] | undefined, rootContainer: HTMLElement) : void { // Disable browser's native autocomplete to avoid conflicts with AliasVault's autocomplete. input.setAttribute('autocomplete', 'false'); const popup = createBasePopup(input, rootContainer); @@ -265,7 +265,7 @@ export function createAutofillPopup(input: HTMLInputElement, credentials: Creden // Get password settings from background const passwordSettingsResponse = await sendMessage('GET_PASSWORD_SETTINGS', {}, 'background') as PasswordSettingsResponse; - + // Initialize password generator with the retrieved settings const passwordGenerator = new PasswordGenerator(passwordSettingsResponse.settings); const password = passwordGenerator.generateRandomPassword(); @@ -340,7 +340,7 @@ export function createAutofillPopup(input: HTMLInputElement, credentials: Creden // Create search input. const searchInput = document.createElement('input'); searchInput.type = 'text'; - searchInput.dataset.aliasvaultIgnore = 'true'; + searchInput.dataset.avDisable = 'true'; searchInput.placeholder = 'Search vault...'; searchInput.className = 'av-search-input'; @@ -517,7 +517,7 @@ function handleSearchInput(searchInput: HTMLInputElement, credentials: Credentia filteredCredentials = uniqueCredentials.filter(cred => { const searchableFields = [ cred.ServiceName?.toLowerCase(), - cred.Username?.toLowerCase(), + cred.Username?.toLowerCase(), cred.Alias?.Email?.toLowerCase(), cred.ServiceUrl?.toLowerCase() ]; @@ -993,25 +993,25 @@ export async function createAliasCreationPopup(defaultName: string, rootContaine // Add error styling to fields customEmail.classList.add('av-create-popup-input-error'); customUsername.classList.add('av-create-popup-input-error'); - + // Add error messages after labels const emailLabel = customEmail.previousElementSibling as HTMLLabelElement; const usernameLabel = customUsername.previousElementSibling as HTMLLabelElement; - + if (!emailLabel.querySelector('.av-create-popup-error-text')) { const emailError = document.createElement('span'); emailError.className = 'av-create-popup-error-text'; emailError.textContent = 'Enter email and/or username'; emailLabel.appendChild(emailError); } - + if (!usernameLabel.querySelector('.av-create-popup-error-text')) { const usernameError = document.createElement('span'); usernameError.className = 'av-create-popup-error-text'; usernameError.textContent = 'Enter email and/or username'; usernameLabel.appendChild(usernameError); } - + /** * Remove error styling. */ @@ -1027,10 +1027,10 @@ export async function createAliasCreationPopup(defaultName: string, rootContaine usernameError.remove(); } }; - + customEmail.addEventListener('input', removeError, { once: true }); customUsername.addEventListener('input', removeError, { once: true }); - + return; } @@ -1056,7 +1056,7 @@ export async function createAliasCreationPopup(defaultName: string, rootContaine }; customInput.addEventListener('keyup', handleCustomEnter); - customEmail.addEventListener('keyup', handleCustomEnter); + customEmail.addEventListener('keyup', handleCustomEnter); customUsername.addEventListener('keyup', handleCustomEnter); passwordPreview.addEventListener('keyup', handleCustomEnter); @@ -1108,7 +1108,7 @@ async function getFaviconBytes(document: Document): Promise { const TARGET_WIDTH = 96; // Resize target width const faviconLinks = [ - ...Array.from(document.querySelectorAll('link[rel="icon"][type="image/svg+xml"]')), + ...Array.from(document.querySelectorAll('link[rel="icon"][type="image/svg+xml"]')), ...Array.from(document.querySelectorAll('link[rel="icon"][sizes="96x96"]')), ...Array.from(document.querySelectorAll('link[rel="icon"][sizes="128x128"]')), ...Array.from(document.querySelectorAll('link[rel="icon"][sizes="48x48"]')), diff --git a/docs/misc/dev/browser-extensions.md b/docs/misc/dev/browser-extensions.md index 353bfe00d..a9fed4285 100644 --- a/docs/misc/dev/browser-extensions.md +++ b/docs/misc/dev/browser-extensions.md @@ -111,3 +111,5 @@ The following websites have been known to cause issues in the past (but should b | https://news.ycombinator.com/login?goto=news | Popup and client favicon not showing due to SVG format | | https://vault.bitwarden.com/#/login | Autofill password not detected (input not long enough), manually typing in works | | https://login.microsoftonline.com/ | Password gets reset after autofill | +| https://mijn.ing.nl/login/ | Autofill doesn't detect input fields and AliasVault autofill icon placement is off | +