mirror of
https://github.com/aliasvault/aliasvault.git
synced 2026-05-18 21:40:41 -04:00
Make autofill work with more input element variations (#794)
This commit is contained in:
committed by
Leendert de Borst
parent
e51219d513
commit
d4e5b724ff
@@ -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 };
|
||||
});
|
||||
},
|
||||
|
||||
@@ -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 = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg enable-background="new 0 0 500 500" version="1.1" viewBox="0 0 500 500" xml:space="preserve" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="m459.87 294.95c0.016205 5.4005 0.03241 10.801-0.35022 16.873-1.111 6.3392-1.1941 12.173-2.6351 17.649-10.922 41.508-36.731 69.481-77.351 83.408-7.2157 2.4739-14.972 3.3702-22.479 4.995-23.629 0.042205-47.257 0.11453-70.886 0.12027-46.762 0.011322-93.523-0.01416-140.95-0.43411-8.59-2.0024-16.766-2.8352-24.398-5.3326-21.595-7.0666-39.523-19.656-53.708-37.552-10.227-12.903-17.579-27.17-21.28-43.221-1.475-6.3967-2.4711-12.904-3.6852-19.361-0.051849-5.747-0.1037-11.494 0.26915-17.886 4.159-42.973 27.68-71.638 63.562-92.153 0-0.70761-0.001961-1.6988 3.12e-4 -2.69 0.022484-9.8293-1.3071-19.894 0.35664-29.438 3.2391-18.579 11.08-35.272 23.763-49.773 12.098-13.832 26.457-23.989 43.609-30.029 7.813-2.7512 16.14-4.0417 24.234-5.9948 7.392-0.025734 14.784-0.05146 22.835 0.32253 4.1959 0.95392 7.7946 1.2538 11.258 2.1053 17.16 4.2192 32.287 12.176 45.469 24.104 2.2558 2.0411 4.372 6.6241 9.621 3.868 16.839-8.8419 34.718-11.597 53.603-8.594 16.791 2.6699 31.602 9.4308 44.236 20.636 11.531 10.227 19.84 22.841 25.393 37.236 6.3436 16.445 10.389 33.163 6.0798 49.389 7.9587 8.9321 15.807 16.704 22.421 25.414 9.162 12.065 15.33 25.746 18.144 40.776 0.97046 5.1848 1.9111 10.375 2.8654 15.563m-71.597 71.012c5.5615-5.2284 12.002-9.7986 16.508-15.817 10.474-13.992 14.333-29.916 11.288-47.446-2.2496-12.95-8.1973-24.076-17.243-33.063-12.746-12.663-28.865-18.614-46.786-18.569-69.912 0.17712-139.82 0.56831-209.74 0.96176-15.922 0.089599-29.168 7.4209-39.685 18.296-14.45 14.944-20.408 33.343-16.655 54.368 2.2763 12.754 8.2167 23.748 17.158 32.66 13.299 13.255 30.097 18.653 48.728 18.651 59.321-0.005188 118.64 0.042358 177.96-0.046601 9.5912-0.014374 19.181-0.86588 28.773-0.88855 10.649-0.025146 19.978-3.825 29.687-9.1074z" fill="#EEC170"/>
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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<Uint8Array | null> {
|
||||
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"]')),
|
||||
|
||||
@@ -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 |
|
||||
|
||||
|
||||
Reference in New Issue
Block a user