Files
aliasvault/apps/browser-extension/src/entrypoints/contentScript/Popup.ts

2005 lines
77 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { sendMessage } from 'webext-bridge/content-script';
import { fillItem } from '@/entrypoints/contentScript/Form';
import { DISABLED_SITES_KEY, TEMPORARY_DISABLED_SITES_KEY, GLOBAL_AUTOFILL_POPUP_ENABLED_KEY, VAULT_LOCKED_DISMISS_UNTIL_KEY, AUTOFILL_MATCHING_MODE_KEY, CUSTOM_EMAIL_HISTORY_KEY, CUSTOM_USERNAME_HISTORY_KEY, PLACEHOLDER_ICON_SVG } from '@/utils/Constants';
import { AutofillMatchingMode } from '@/utils/credentialMatcher/CredentialMatcher';
import { CreateIdentityGenerator, IdentityHelperUtils } from '@/utils/dist/core/identity-generator';
import type { Item, ItemField } from '@/utils/dist/core/models/vault';
import { ItemTypes, FieldKey, createSystemField } from '@/utils/dist/core/models/vault';
import { CreatePasswordGenerator, PasswordGenerator, PasswordSettings } from '@/utils/dist/core/password-generator';
import { ClickValidator } from '@/utils/security/ClickValidator';
import { ServiceDetectionUtility } from '@/utils/serviceDetection/ServiceDetectionUtility';
import { SqliteClient } from '@/utils/SqliteClient';
import { IdentitySettingsResponse } from '@/utils/types/messaging/IdentitySettingsResponse';
import { ItemsResponse } from '@/utils/types/messaging/ItemsResponse';
import { PasswordSettingsResponse } from '@/utils/types/messaging/PasswordSettingsResponse';
import { StringResponse } from '@/utils/types/messaging/StringResponse';
import { t } from '@/i18n/StandaloneI18n';
import { storage } from '#imports';
/**
* WeakMap to store event listeners for popup containers
*/
let popupListeners = new WeakMap<HTMLElement, EventListener>();
/**
* Global ClickValidator instance for content script security
*/
const clickValidator = ClickValidator.getInstance();
/**
* Open (or refresh) the autofill popup including check if vault is locked.
*/
export function openAutofillPopup(input: HTMLInputElement, container: HTMLElement) : void {
createLoadingPopup(input, '', container);
/**
* Handle the Enter key.
*/
const handleEnterKey = (e: KeyboardEvent) : void => {
if (e.key === 'Enter') {
removeExistingPopup(container);
// Remove the event listener to clean up
document.body.removeEventListener('keydown', handleEnterKey);
}
};
document.addEventListener('keydown', handleEnterKey);
(async () : Promise<void> => {
// Load autofill matching mode setting to send to background for filtering
const matchingMode = await storage.getItem(AUTOFILL_MATCHING_MODE_KEY) as AutofillMatchingMode ?? AutofillMatchingMode.DEFAULT;
const response = await sendMessage('GET_FILTERED_ITEMS', {
currentUrl: window.location.href,
pageTitle: document.title,
matchingMode: matchingMode
}, 'background') as ItemsResponse;
if (response.success) {
await createAutofillPopup(input, response.items, container);
} else {
await createVaultLockedPopup(input, container);
}
})();
}
/**
* Create basic popup with default style.
*/
export function createBasePopup(input: HTMLInputElement, rootContainer: HTMLElement) : HTMLElement {
// Remove existing popup and its event listeners
removeExistingPopup(rootContainer);
const popup = document.createElement('div');
popup.id = 'aliasvault-credential-popup';
popup.className = 'av-popup';
// Get position of the input field relative to the viewport
const inputRect = input.getBoundingClientRect();
// Get position of the root container relative to the viewport
const rootContainerRect = rootContainer.getBoundingClientRect();
/*
* Calculate the position relative to the root container
* This accounts for any offset the shadow root might have in the page
*/
const relativeTop = inputRect.bottom - rootContainerRect.top;
const relativeLeft = inputRect.left - rootContainerRect.left;
// Set the position
popup.style.position = 'absolute';
popup.style.top = `${relativeTop}px`;
popup.style.left = `${relativeLeft}px`;
// Append popup to the root container
rootContainer.appendChild(popup);
return popup;
}
/**
* Create a loading popup.
*/
export function createLoadingPopup(input: HTMLInputElement, message: string, rootContainer: HTMLElement) : HTMLElement {
/**
* Get the loading wrapper HTML.
*/
const getLoadingHtml = (message: string): string => `
<div class="av-loading-container">
<div class="av-loading-spinner"></div>
<span class="av-loading-text">${message}</span>
</div>
`;
const popup = createBasePopup(input, rootContainer);
popup.innerHTML = getLoadingHtml(message);
rootContainer.appendChild(popup);
return popup;
}
/**
* Update the item list content in the popup.
*
* @param items - The items to display.
* @param itemList - The item list element.
* @param input - The input element that triggered the popup. Required when filling items to know which form to fill.
*/
export function updatePopupContent(items: Item[], itemList: HTMLElement | null, input: HTMLInputElement, rootContainer: HTMLElement, noMatchesText?: string) : void {
if (!itemList) {
itemList = document.getElementById('aliasvault-credential-list') as HTMLElement;
}
if (!itemList) {
return;
}
// Clear existing content
itemList.innerHTML = '';
// Add items using the shared function
const itemElements = createItemList(items, input, rootContainer, noMatchesText);
itemElements.forEach(element => itemList.appendChild(element));
}
/**
* Remove existing popup (if any exists).
*/
export function removeExistingPopup(container: HTMLElement) : void {
const existingInContainer = container.querySelector('#aliasvault-credential-popup');
if (existingInContainer) {
// Remove event listeners before removing the element
if (popupListeners && popupListeners.has(container)) {
const listener = popupListeners.get(container);
if (listener) {
container.removeEventListener('mousedown', listener);
popupListeners.delete(container);
}
}
existingInContainer.remove();
}
}
/**
* Create auto-fill popup
*/
export async function createAutofillPopup(input: HTMLInputElement, items: Item[] | undefined, rootContainer: HTMLElement) : Promise<void> {
// Get all translations first
const newText = await t('content.new');
const searchPlaceholder = await t('content.searchVault');
const hideFor1HourText = await t('content.hideFor1Hour');
const hidePermanentlyText = await t('content.hidePermanently');
const noMatchesText = await t('content.noMatchesFound');
const creatingText = await t('content.creatingNewAlias');
const failedText = await t('content.failedToCreateIdentity');
// Disable browser's native autocomplete to avoid conflicts with AliasVault's autocomplete.
input.setAttribute('autocomplete', 'false');
const popup = createBasePopup(input, rootContainer);
// Create credential list container with ID
const credentialList = document.createElement('div');
credentialList.id = 'aliasvault-credential-list';
credentialList.className = 'av-credential-list';
popup.appendChild(credentialList);
// Add initial items (already filtered by background script for performance)
if (!items) {
items = [];
}
updatePopupContent(items, credentialList, input, rootContainer, noMatchesText);
// Add divider
const divider = document.createElement('div');
divider.className = 'av-divider';
popup.appendChild(divider);
// Add action buttons container
const actionContainer = document.createElement('div');
actionContainer.className = 'av-action-container';
// Create New button
const createButton = document.createElement('button');
createButton.className = 'av-button av-button-primary';
createButton.innerHTML = `
<svg class="av-icon" viewBox="0 0 24 24">
<line x1="12" y1="5" x2="12" y2="19"></line>
<line x1="5" y1="12" x2="19" y2="12"></line>
</svg>
${newText}
`;
/**
* Handle create button click
*/
const handleCreateClick = async (e: Event) : Promise<void> => {
e.preventDefault();
e.stopPropagation();
e.stopImmediatePropagation();
const serviceInfo = ServiceDetectionUtility.getServiceInfo(document, window.location);
const result = await createAliasCreationPopup(serviceInfo.suggestedNames, rootContainer);
if (!result) {
// User cancelled
return;
}
const loadingPopup = createLoadingPopup(input, creatingText, rootContainer);
try {
// Sync with api to ensure we have the latest vault.
await sendMessage('SYNC_VAULT', {}, 'background');
// Retrieve default email domain from background
const response = await sendMessage('GET_DEFAULT_EMAIL_DOMAIN', {}, 'background') as StringResponse;
const domain = response.value;
let newItem: Item;
const currentDateTime = new Date().toISOString();
if (result.isCustomCredential) {
// Create custom item with information provided by user in popup.
const faviconBytes = await getFaviconBytes(document);
const serviceUrl = getValidServiceUrl();
// Build fields using factory - only include non-empty values
const fields: ItemField[] = [];
if (serviceUrl) {
fields.push(createSystemField(FieldKey.LoginUrl, { value: serviceUrl }));
}
if (result.customUsername) {
fields.push(createSystemField(FieldKey.LoginUsername, { value: result.customUsername }));
}
if (result.customEmail) {
fields.push(createSystemField(FieldKey.LoginEmail, { value: result.customEmail }));
}
if (result.customPassword) {
fields.push(createSystemField(FieldKey.LoginPassword, { value: result.customPassword }));
}
newItem = {
Id: '',
Name: result.serviceName ?? '',
ItemType: ItemTypes.Login,
Logo: faviconBytes ?? undefined,
Fields: fields,
CreatedAt: currentDateTime,
UpdatedAt: currentDateTime
};
} else {
// Generate new random identity using identity generator.
const identitySettings = await sendMessage('GET_DEFAULT_IDENTITY_SETTINGS', {}, 'background') as IdentitySettingsResponse;
const identityGenerator = CreateIdentityGenerator(identitySettings.settings?.language ?? 'en');
const identity = identityGenerator.generateRandomIdentity(identitySettings.settings?.gender);
// Get password settings from background
const passwordSettingsResponse = await sendMessage('GET_PASSWORD_SETTINGS', {}, 'background') as PasswordSettingsResponse;
// Initialize password generator with the retrieved settings
const passwordGenerator = CreatePasswordGenerator(passwordSettingsResponse.settings ?? {
Length: 12,
UseLowercase: true,
UseUppercase: true,
UseNumbers: true,
UseSpecialChars: true,
UseNonAmbiguousChars: true
});
const password = passwordGenerator.generateRandomPassword();
// Extract favicon from page and get the bytes
const faviconBytes = await getFaviconBytes(document);
const serviceUrl = getValidServiceUrl();
// Build fields using factory - metadata comes from SystemFieldRegistry
const fields: ItemField[] = [];
if (serviceUrl) {
fields.push(createSystemField(FieldKey.LoginUrl, { value: serviceUrl }));
}
fields.push(createSystemField(FieldKey.LoginUsername, { value: identity.nickName }));
fields.push(createSystemField(FieldKey.LoginEmail, { value: `${identity.emailPrefix}@${domain}` }));
fields.push(createSystemField(FieldKey.LoginPassword, { value: password }));
fields.push(createSystemField(FieldKey.AliasFirstName, { value: identity.firstName }));
fields.push(createSystemField(FieldKey.AliasLastName, { value: identity.lastName }));
fields.push(createSystemField(FieldKey.AliasBirthdate, { value: IdentityHelperUtils.normalizeBirthDate(identity.birthDate.toISOString()) }));
fields.push(createSystemField(FieldKey.AliasGender, { value: identity.gender }));
newItem = {
Id: '',
Name: result.serviceName ?? '',
ItemType: ItemTypes.Alias,
Logo: faviconBytes ?? undefined,
Fields: fields,
CreatedAt: currentDateTime,
UpdatedAt: currentDateTime
};
}
// Create item in background.
await sendMessage('CREATE_ITEM', {
item: JSON.parse(JSON.stringify(newItem))
}, 'background');
// Close popup.
removeExistingPopup(rootContainer);
// Fill the form with the new item immediately.
fillItem(newItem, input);
} catch (error) {
console.error('Error creating item:', error);
loadingPopup.innerHTML = `
<div style="padding: 16px; color: #ef4444;">
${failedText}
</div>
`;
setTimeout(() => {
removeExistingPopup(rootContainer);
}, 2000);
}
};
// Add click listener with capture and prevent removal and security validation.
addReliableClickHandler(createButton, handleCreateClick);
// Create search input with native placeholder.
const searchInput = document.createElement('input');
searchInput.type = 'text';
searchInput.placeholder = searchPlaceholder;
searchInput.dataset.avDisable = 'true';
searchInput.id = 'aliasvault-search-input';
searchInput.className = 'av-search-input';
// Handle search input.
let searchTimeout: NodeJS.Timeout | null = null;
searchInput.addEventListener('input', async () => {
await handleSearchInput(searchInput, items, rootContainer, searchTimeout, credentialList, input, noMatchesText);
});
// Close button
const closeButton = document.createElement('button');
closeButton.className = 'av-button av-button-close';
closeButton.innerHTML = `
<svg class="av-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M6 18L18 6M6 6l12 12" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
`;
/**
* Handle close button click
*/
const handleCloseClick = (e: Event): void => {
e.stopPropagation();
const rect = closeButton.getBoundingClientRect();
const contextMenu = document.createElement('div');
contextMenu.className = 'av-context-menu';
contextMenu.style.position = 'fixed';
contextMenu.style.left = `${rect.left}px`;
contextMenu.style.top = `${rect.bottom + 4}px`;
contextMenu.innerHTML = `
<button class="av-context-menu-item" data-action="temporary">
<svg class="av-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
${hideFor1HourText}
</button>
<button class="av-context-menu-item" data-action="permanent">
<svg class="av-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M6 18L18 6M6 6l12 12" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
${hidePermanentlyText}
</button>
`;
// Remove any existing context menu
const existingMenu = document.querySelector('.av-context-menu');
if (existingMenu) {
existingMenu.remove();
}
// Add the new context menu
popup.appendChild(contextMenu);
/**
* Handle clicks on context menu items
* @param e - The click event
*/
const handleContextMenuClick = (e: Event): void => {
e.preventDefault();
e.stopPropagation();
e.stopImmediatePropagation();
const target = e.target as HTMLElement;
const menuItem = target.closest('.av-context-menu-item') as HTMLElement;
if (!menuItem) {
// Clicked outside the menu, close everything
contextMenu.remove();
removeExistingPopup(rootContainer);
document.removeEventListener('click', handleContextMenuClick);
return;
}
const action = menuItem.dataset.action;
if (action === 'temporary') {
disableAutoShowPopup(true);
} else if (action === 'permanent') {
disableAutoShowPopup(false);
}
contextMenu.remove();
removeExistingPopup(rootContainer);
document.removeEventListener('click', handleContextMenuClick);
};
// Add click listener to handle menu item clicks
addReliableClickHandler(contextMenu, handleContextMenuClick);
};
// Add click handlers with security validation
addReliableClickHandler(closeButton, (e: Event) => {
handleCloseClick(e);
});
actionContainer.appendChild(searchInput);
actionContainer.appendChild(createButton);
actionContainer.appendChild(closeButton);
popup.appendChild(actionContainer);
/**
* Handle clicking outside the popup.
*/
const handleClickOutside = (event: MouseEvent) : void => {
const popup = rootContainer.querySelector('#aliasvault-credential-popup');
const target = event.target as Node;
const targetElement = event.target as HTMLElement;
// If popup doesn't exist, remove the listener
if (!popup) {
document.removeEventListener('mousedown', handleClickOutside);
return;
}
// Check if the click is outside the popup and outside the shadow UI
if (popup && !popup.contains(target) && !input.contains(target) && targetElement.tagName !== 'ALIASVAULT-UI') {
removeExistingPopup(rootContainer);
}
};
// Add the event listener for clicking outside
document.addEventListener('mousedown', handleClickOutside);
rootContainer.appendChild(popup);
}
/**
* Create vault locked popup.
*/
export async function createVaultLockedPopup(input: HTMLInputElement, rootContainer: HTMLElement): Promise<void> {
/**
* Handle unlock click.
*/
const handleUnlockClick = () : void => {
sendMessage('OPEN_POPUP', {}, 'background');
removeExistingPopup(rootContainer);
}
const popup = createBasePopup(input, rootContainer);
popup.classList.add('av-vault-locked');
// Create container for message and button
const container = document.createElement('div');
container.className = 'av-vault-locked-container';
// Make the entire container clickable with security validation
addReliableClickHandler(container, handleUnlockClick);
container.style.cursor = 'pointer';
// Add message
const messageElement = document.createElement('div');
messageElement.className = 'av-vault-locked-message';
messageElement.textContent = await t('content.vaultLocked');
container.appendChild(messageElement);
// Add unlock button with SVG icon
const button = document.createElement('button');
button.title = 'Unlock AliasVault';
button.className = 'av-vault-locked-button';
button.innerHTML = `
<svg class="av-icon-lock" viewBox="0 0 24 24">
<rect x="3" y="11" width="18" height="11" rx="2" ry="2"></rect>
<path d="M7 11V7a5 5 0 0 1 10 0v4"></path>
</svg>
`;
container.appendChild(button);
// Add the container to the popup
popup.appendChild(container);
// Add close button as a separate element positioned to the right
const closeButton = document.createElement('button');
closeButton.className = 'av-button av-button-close av-vault-locked-close';
closeButton.title = 'Dismiss popup';
closeButton.innerHTML = `
<svg class="av-icon" viewBox="0 0 24 24">
<line x1="18" y1="6" x2="6" y2="18"></line>
<line x1="6" y1="6" x2="18" y2="18"></line>
</svg>
`;
// Position the close button to the right of the container
closeButton.style.position = 'absolute';
closeButton.style.right = '8px';
closeButton.style.top = '50%';
closeButton.style.transform = 'translateY(-50%)';
// Handle close button click with security validation
addReliableClickHandler(closeButton, async (e) => {
e.stopPropagation(); // Prevent opening the unlock popup
await dismissVaultLockedPopup();
removeExistingPopup(rootContainer);
});
popup.appendChild(closeButton);
/**
* Add event listener to document to close popup when clicking outside.
*/
const handleClickOutside = (event: MouseEvent): void => {
const target = event.target as Node;
const targetElement = event.target as HTMLElement;
// Check if the click is outside the popup and outside the shadow UI
if (popup && !popup.contains(target) && !input.contains(target) && targetElement.tagName !== 'ALIASVAULT-UI') {
removeExistingPopup(rootContainer);
document.removeEventListener('mousedown', handleClickOutside);
}
};
setTimeout(() => {
document.addEventListener('mousedown', handleClickOutside);
}, 100);
rootContainer.appendChild(popup);
}
/**
* Handle popup search input - searches entire vault when user types.
* When empty, shows the initially URL-filtered items.
* When user types, searches ALL items in vault (not just the pre-filtered set).
*
* @param searchInput - The search input element
* @param initialItems - The initially URL-filtered items to show when search is empty
* @param rootContainer - The root container element
* @param searchTimeout - Timeout for debouncing search
* @param itemList - The item list element to update
* @param input - The input field that triggered the popup
* @param noMatchesText - Text to show when no matches found
*/
async function handleSearchInput(searchInput: HTMLInputElement, initialItems: Item[], rootContainer: HTMLElement, searchTimeout: NodeJS.Timeout | null, itemList: HTMLElement | null, input: HTMLInputElement, noMatchesText?: string) : Promise<void> {
if (searchTimeout) {
clearTimeout(searchTimeout);
}
const searchTerm = searchInput.value.trim();
if (searchTerm === '') {
// If search is empty, show the initially URL-filtered items
updatePopupContent(initialItems, itemList, input, rootContainer, noMatchesText);
} else {
// Search in full vault with search term
const response = await sendMessage('GET_SEARCH_ITEMS', {
searchTerm: searchTerm
}, 'background') as ItemsResponse;
if (response.success && response.items) {
updatePopupContent(response.items, itemList, input, rootContainer, noMatchesText);
} else {
// On error, fallback to showing initial filtered items
updatePopupContent(initialItems, itemList, input, rootContainer, noMatchesText);
}
}
}
/**
* Create item list content for popup
*
* @param items - The items to display.
* @param input - The input element that triggered the popup. Required when filling items to know which form to fill.
*/
function createItemList(items: Item[], input: HTMLInputElement, rootContainer: HTMLElement, noMatchesText?: string): HTMLElement[] {
const elements: HTMLElement[] = [];
if (items.length > 0) {
items.forEach(item => {
const itemElement = document.createElement('div');
itemElement.className = 'av-credential-item';
// Create container for item info (logo + username)
const itemInfo = document.createElement('div');
itemInfo.className = 'av-credential-info';
const logoSrc = SqliteClient.imgSrcFromBytes(item.Logo);
const logoContainer = document.createElement('div');
logoContainer.className = 'av-credential-logo';
if (logoSrc) {
logoContainer.innerHTML = `<img src="${logoSrc}" alt="" style="width:100%;height:100%;">`;
} else {
logoContainer.innerHTML = PLACEHOLDER_ICON_SVG;
}
itemInfo.appendChild(logoContainer);
const itemTextContainer = document.createElement('div');
itemTextContainer.className = 'av-credential-text';
// Service name (primary text) with passkey indicator
const serviceName = document.createElement('div');
serviceName.className = 'av-service-name';
// Create a flex container for service name and passkey icon
const serviceNameContainer = document.createElement('div');
serviceNameContainer.style.display = 'flex';
serviceNameContainer.style.alignItems = 'center';
serviceNameContainer.style.gap = '4px';
const serviceNameText = document.createElement('span');
serviceNameText.textContent = item.Name || '';
serviceNameContainer.appendChild(serviceNameText);
// Add passkey indicator if item has a passkey
if (item.HasPasskey) {
const passkeyIcon = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
passkeyIcon.setAttribute('class', 'av-passkey-icon');
passkeyIcon.setAttribute('viewBox', '0 0 24 24');
passkeyIcon.setAttribute('fill', 'none');
passkeyIcon.setAttribute('stroke', 'currentColor');
passkeyIcon.setAttribute('stroke-width', '2');
passkeyIcon.setAttribute('stroke-linecap', 'round');
passkeyIcon.setAttribute('stroke-linejoin', 'round');
passkeyIcon.setAttribute('aria-label', 'Has passkey');
passkeyIcon.style.width = '14px';
passkeyIcon.style.height = '14px';
passkeyIcon.style.flexShrink = '0';
passkeyIcon.style.opacity = '0.7';
const path = document.createElementNS('http://www.w3.org/2000/svg', 'path');
path.setAttribute('d', 'M21 2l-2 2m-7.61 7.61a5.5 5.5 0 1 1-7.778 7.778 5.5 5.5 0 0 1 7.777-7.777zm0 0L15.5 7.5m0 0l3 3L22 7l-3-3m-3.5 3.5L19 4');
passkeyIcon.appendChild(path);
serviceNameContainer.appendChild(passkeyIcon);
}
serviceName.appendChild(serviceNameContainer);
// Details container (secondary text) - extract from fields
const detailsContainer = document.createElement('div');
detailsContainer.className = 'av-service-details';
// Get field values using helper function
const firstName = item.Fields.find(f => f.FieldKey === FieldKey.AliasFirstName)?.Value;
const lastName = item.Fields.find(f => f.FieldKey === FieldKey.AliasLastName)?.Value;
const username = item.Fields.find(f => f.FieldKey === FieldKey.LoginUsername)?.Value;
const email = item.Fields.find(f => f.FieldKey === FieldKey.LoginEmail)?.Value;
// Combine full name (if available) and username or email
const details: string[] = [];
const firstNameStr = Array.isArray(firstName) ? firstName[0] : firstName;
const lastNameStr = Array.isArray(lastName) ? lastName[0] : lastName;
const usernameStr = Array.isArray(username) ? username[0] : username;
const emailStr = Array.isArray(email) ? email[0] : email;
if (firstNameStr && lastNameStr) {
details.push(`${firstNameStr} ${lastNameStr}`);
}
if (usernameStr) {
details.push(usernameStr);
} else if (emailStr) {
details.push(emailStr);
}
detailsContainer.textContent = details.join(' · ');
itemTextContainer.appendChild(serviceName);
itemTextContainer.appendChild(detailsContainer);
itemInfo.appendChild(itemTextContainer);
// Add popout icon
const popoutIcon = document.createElement('div');
popoutIcon.className = 'av-popout-icon';
popoutIcon.innerHTML = `
<svg class="av-icon" viewBox="0 0 24 24">
<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"></path>
<polyline points="15 3 21 3 21 9"></polyline>
<line x1="10" y1="14" x2="21" y2="3"></line>
</svg>
`;
// Handle popout click with security validation
addReliableClickHandler(popoutIcon, (e) => {
e.stopPropagation(); // Prevent item fill
sendMessage('OPEN_POPUP_WITH_ITEM', { itemId: item.Id }, 'background');
removeExistingPopup(rootContainer);
});
itemElement.appendChild(itemInfo);
itemElement.appendChild(popoutIcon);
// Update click handler to only trigger on itemInfo with security validation
addReliableClickHandler(itemInfo, () => {
fillItem(item, input);
removeExistingPopup(rootContainer);
});
elements.push(itemElement);
});
} else {
const noMatches = document.createElement('div');
noMatches.className = 'av-no-matches';
noMatches.textContent = noMatchesText || 'No matches found';
elements.push(noMatches);
}
return elements;
}
/**
* Check if auto-popup is disabled for current site
*/
export async function isAutoShowPopupEnabled(): Promise<boolean> {
const disabledSites = await storage.getItem(DISABLED_SITES_KEY) as string[] ?? [];
const temporaryDisabledSites = await storage.getItem(TEMPORARY_DISABLED_SITES_KEY) as Record<string, number> ?? {};
const globalPopupEnabled = await storage.getItem(GLOBAL_AUTOFILL_POPUP_ENABLED_KEY) ?? true;
const currentHostname = window.location.hostname;
if (!globalPopupEnabled) {
// Popup is disabled for all sites.
return false;
}
if (disabledSites.includes(currentHostname)) {
// Popup is permanently disabled for current site.
return false;
}
// Check temporary disable
const temporaryDisabledUntil = temporaryDisabledSites[currentHostname];
if (temporaryDisabledUntil && Date.now() < temporaryDisabledUntil) {
// Popup is temporarily disabled for current site.
return false;
}
// Check time-based dismissal
const dismissUntil = await storage.getItem(VAULT_LOCKED_DISMISS_UNTIL_KEY) as number;
if (dismissUntil && Date.now() < dismissUntil) {
// Popup is dismissed for a certain amount of time.
return false;
}
return true;
}
/**
* Disable auto-popup for current site
*/
export async function disableAutoShowPopup(temporary: boolean = false): Promise<void> {
const currentHostname = window.location.hostname;
if (temporary) {
// Add to temporary disabled sites with 1 hour expiry
const temporaryDisabledSites = await storage.getItem(TEMPORARY_DISABLED_SITES_KEY) as Record<string, number> ?? {};
temporaryDisabledSites[currentHostname] = Date.now() + (60 * 60 * 1000); // 1 hour from now
await storage.setItem(TEMPORARY_DISABLED_SITES_KEY, temporaryDisabledSites);
} else {
// Add to permanently disabled sites
const disabledSites = await storage.getItem(DISABLED_SITES_KEY) as string[] ?? [];
if (!disabledSites.includes(currentHostname)) {
disabledSites.push(currentHostname);
await storage.setItem(DISABLED_SITES_KEY, disabledSites);
}
}
}
/**
* Create alias creation popup where user can choose between random alias and custom alias.
*/
export async function createAliasCreationPopup(suggestedNames: string[], rootContainer: HTMLElement): Promise<{ serviceName: string | null, isCustomCredential: boolean, customEmail?: string, customUsername?: string, customPassword?: string } | null> {
// Close existing popup
removeExistingPopup(rootContainer);
// Load history
const emailHistory = await storage.getItem(CUSTOM_EMAIL_HISTORY_KEY) as string[] ?? [];
const usernameHistory = await storage.getItem(CUSTOM_USERNAME_HISTORY_KEY) as string[] ?? [];
return new Promise((resolve) => {
(async (): Promise<void> => {
// Create modal overlay
const overlay = document.createElement('div');
overlay.id = 'aliasvault-create-popup';
overlay.className = 'av-create-popup-overlay';
const popup = document.createElement('div');
popup.className = 'av-create-popup';
// Define input method base variables
const randomIdentityIcon = `
<svg class="av-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
<rect x="3" y="3" width="18" height="18" rx="2" ry="2"/>
<circle cx="8" cy="8" r="1"/>
<circle cx="16" cy="8" r="1"/>
<circle cx="12" cy="12" r="1"/>
<circle cx="8" cy="16" r="1"/>
<circle cx="16" cy="16" r="1"/>
</svg>
`;
const randomIdentitySubtext = await t('content.randomIdentityDescription');
const randomIdentityTitle = await t('content.createRandomAlias');
const randomIdentityTitleDropdown = await t('content.randomAlias');
const randomIdentitySubtextDropdown = await t('content.randomIdentityDescriptionDropdown');
const manualUsernamePasswordIcon = `
<svg class="av-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="7" r="4"/>
<path d="M5.5 20a6.5 6.5 0 0 1 13 0"/>
</svg>
`;
const manualUsernamePasswordSubtext = await t('content.manualCredentialDescription');
const manualUsernamePasswordTitle = await t('content.createUsernamePassword');
const manualUsernamePasswordTitleDropdown = await t('content.usernamePassword');
const manualUsernamePasswordSubtextDropdown = await t('content.manualCredentialDescriptionDropdown');
// Get all translated strings first
const serviceNameText = await t('common.serviceName');
const enterServiceNameText = await t('content.enterServiceName');
const cancelText = await t('common.cancel');
const createAndSaveAliasText = await t('content.createAndSaveAlias');
const emailText = await t('common.email');
const enterEmailAddressText = await t('content.enterEmailAddress');
const usernameText = await t('common.username');
const enterUsernameText = await t('content.enterUsername');
const passwordText = await t('common.password');
const generateNewPasswordText = await t('content.generateNewPassword');
const togglePasswordVisibilityText = await t('content.togglePasswordVisibility');
const createAndSaveCredentialText = await t('content.createAndSaveCredential');
const passwordLengthText = await t('credentials.passwordLength');
const changePasswordComplexityText = await t('credentials.changePasswordComplexity');
const suggestedNamesHtml = await getSuggestedNamesHtml(suggestedNames, suggestedNames[0] ?? '');
// Create the main content
popup.innerHTML = `
<div class="av-create-popup-header">
<div class="av-create-popup-title-container">
<div class="av-create-popup-title-wrapper">
${randomIdentityIcon}
<h3 class="av-create-popup-title">${randomIdentityTitle}</h3>
</div>
<div class="av-create-popup-header-buttons">
<button class="av-create-popup-mode-dropdown">
<svg class="av-icon" viewBox="0 0 24 24">
<path d="M6 9l6 6 6-6"/>
</svg>
</button>
<button class="av-create-popup-popout" title="Open in main popup">
<svg class="av-icon" viewBox="0 0 24 24">
<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"></path>
<polyline points="15 3 21 3 21 9"></polyline>
<line x1="10" y1="14" x2="21" y2="3"></line>
</svg>
</button>
</div>
</div>
</div>
<div class="av-create-popup-mode-dropdown-menu" style="display: none;">
<button class="av-create-popup-mode-option" data-mode="random">
<div class="av-create-popup-mode-icon">
${randomIdentityIcon}
</div>
<div class="av-create-popup-mode-content">
<h4>${randomIdentityTitleDropdown}</h4>
<p>${randomIdentitySubtextDropdown}</p>
</div>
</button>
<button class="av-create-popup-mode-option" data-mode="custom">
<div class="av-create-popup-mode-icon">
${manualUsernamePasswordIcon}
</div>
<div class="av-create-popup-mode-content">
<h4>${manualUsernamePasswordTitleDropdown}</h4>
<p>${manualUsernamePasswordSubtextDropdown}</p>
</div>
</button>
</div>
<div class="av-create-popup-help-text">${randomIdentitySubtext}</div>
<div class="av-create-popup-field-group">
<label for="service-name-input">${serviceNameText}</label>
<input
type="text"
id="service-name-input"
value="${suggestedNames[0] ?? ''}"
class="av-create-popup-input"
placeholder="${enterServiceNameText}"
>
${suggestedNames.length > 1 ? `<div class="av-suggested-names">${suggestedNamesHtml}</div>` : ''}
</div>
<div class="av-create-popup-mode av-create-popup-random-mode">
<div class="av-create-popup-actions">
<button id="cancel-btn" class="av-create-popup-cancel">${cancelText}</button>
<button id="save-btn" class="av-create-popup-save">${createAndSaveAliasText}</button>
</div>
</div>
<div class="av-create-popup-mode av-create-popup-custom-mode" style="display: none;">
<div class="av-create-popup-field-group">
<label for="custom-email">${emailText}</label>
<input
type="email"
id="custom-email"
class="av-create-popup-input"
placeholder="${enterEmailAddressText}"
>
<div class="av-field-suggestions" id="email-suggestions"></div>
</div>
<div class="av-create-popup-field-group">
<label for="custom-username">${usernameText}</label>
<input
type="text"
id="custom-username"
class="av-create-popup-input"
placeholder="${enterUsernameText}"
>
<div class="av-field-suggestions" id="username-suggestions"></div>
</div>
<div class="av-create-popup-field-group">
<label>${passwordText}</label>
<div class="av-create-popup-password-preview">
<input
type="text"
id="password-preview"
class="av-create-popup-input"
data-is-generated="true"
>
<button id="toggle-password-visibility" class="av-create-popup-visibility-btn" title="${togglePasswordVisibilityText}">
<svg class="av-icon" viewBox="0 0 24 24">
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"></path>
<circle cx="12" cy="12" r="3"></circle>
</svg>
</button>
<button id="regenerate-password" class="av-create-popup-regenerate-btn" title="${generateNewPasswordText}">
<svg class="av-icon" viewBox="0 0 24 24">
<path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"></path>
<path d="M3 3v5h5"></path>
</svg>
</button>
</div>
<div class="av-password-length-container">
<div class="av-password-length-header">
<label for="password-length-slider">${passwordLengthText}</label>
<div class="av-password-length-controls">
<span id="password-length-value" class="av-password-length-value">12</span>
<button id="password-config-btn" class="av-password-config-btn" title="${changePasswordComplexityText}">
<svg class="av-icon" viewBox="0 0 24 24">
<path d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"></path>
<path d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"></path>
</svg>
</button>
</div>
</div>
<input type="range" id="password-length-slider" min="8" max="64" value="12" class="av-password-length-slider">
</div>
</div>
<div class="av-create-popup-actions">
<button id="custom-cancel-btn" class="av-create-popup-cancel">${cancelText}</button>
<button id="custom-save-btn" class="av-create-popup-save">${createAndSaveCredentialText}</button>
</div>
</div>
`;
overlay.appendChild(popup);
rootContainer.appendChild(overlay);
// Animate in
requestAnimationFrame(() => {
popup.classList.add('show');
});
// Get all the elements
const randomMode = popup.querySelector('.av-create-popup-random-mode') as HTMLElement;
const customMode = popup.querySelector('.av-create-popup-custom-mode') as HTMLElement;
const dropdownMenu = popup.querySelector('.av-create-popup-mode-dropdown-menu') as HTMLElement;
const titleContainer = popup.querySelector('.av-create-popup-title-container') as HTMLElement;
const popoutBtn = popup.querySelector('.av-create-popup-popout') as HTMLButtonElement;
const cancelBtn = popup.querySelector('#cancel-btn') as HTMLButtonElement;
const customCancelBtn = popup.querySelector('#custom-cancel-btn') as HTMLButtonElement;
const saveBtn = popup.querySelector('#save-btn') as HTMLButtonElement;
const customSaveBtn = popup.querySelector('#custom-save-btn') as HTMLButtonElement;
const inputServiceName = popup.querySelector('#service-name-input') as HTMLInputElement;
const customEmail = popup.querySelector('#custom-email') as HTMLInputElement;
const customUsername = popup.querySelector('#custom-username') as HTMLInputElement;
const passwordPreview = popup.querySelector('#password-preview') as HTMLInputElement;
const regenerateBtn = popup.querySelector('#regenerate-password') as HTMLButtonElement;
const toggleVisibilityBtn = popup.querySelector('#toggle-password-visibility') as HTMLButtonElement;
const emailSuggestions = popup.querySelector('#email-suggestions') as HTMLElement;
const usernameSuggestions = popup.querySelector('#username-suggestions') as HTMLElement;
/**
* Update history with new value (max 2 unique entries)
*/
const updateHistory = async (value: string, historyKey: typeof CUSTOM_EMAIL_HISTORY_KEY | typeof CUSTOM_USERNAME_HISTORY_KEY, maxItems: number = 2): Promise<string[]> => {
const history = await storage.getItem(historyKey) as string[] ?? [];
// Remove the value if it already exists
const filteredHistory = history.filter((item: string) => item !== value);
// Add the new value at the beginning
if (value.trim()) {
filteredHistory.unshift(value);
}
// Keep only the first maxItems
const updatedHistory = filteredHistory.slice(0, maxItems);
// Save the updated history
await storage.setItem(historyKey, updatedHistory);
return updatedHistory;
};
/**
* Remove item from history
*/
const removeFromHistory = async (value: string, historyKey: typeof CUSTOM_EMAIL_HISTORY_KEY | typeof CUSTOM_USERNAME_HISTORY_KEY): Promise<string[]> => {
const history = await storage.getItem(historyKey) as string[] ?? [];
const updatedHistory = history.filter((item: string) => item !== value);
await storage.setItem(historyKey, updatedHistory);
return updatedHistory;
};
/**
* Format suggestions HTML as pill-style buttons
*/
const formatSuggestionsHtml = async (history: string[], currentValue: string): Promise<string> => {
// Filter out the current value from history and limit to 2 items
const filteredHistory = history
.filter(item => item.toLowerCase() !== currentValue.toLowerCase())
.slice(0, 2);
if (filteredHistory.length === 0) {
return '';
}
// Build HTML with pill-style buttons
return filteredHistory.map(item =>
`<span class="av-suggestion-pill">
<span class="av-suggestion-pill-text" data-value="${item}">${item}</span>
<span class="av-suggestion-pill-delete" data-value="${item}" title="Remove">×</span>
</span>`
).join(' ');
};
/**
* Update suggestions display
*/
const updateSuggestions = async (input: HTMLInputElement, suggestionsContainer: HTMLElement, history: string[]): Promise<void> => {
const currentValue = input.value.trim();
const html = await formatSuggestionsHtml(history, currentValue);
suggestionsContainer.innerHTML = html;
suggestionsContainer.style.display = html ? 'flex' : 'none';
};
// Initial display of suggestions
await updateSuggestions(customEmail, emailSuggestions, emailHistory);
await updateSuggestions(customUsername, usernameSuggestions, usernameHistory);
// Handle popout button click
popoutBtn.addEventListener('click', (e) => {
e.preventDefault();
e.stopPropagation();
const serviceName = inputServiceName.value.trim();
const encodedServiceInfo = ServiceDetectionUtility.getEncodedServiceInfo(document, window.location);
sendMessage('OPEN_POPUP_CREATE_CREDENTIAL', {
serviceName: serviceName || encodedServiceInfo.serviceName,
currentUrl: encodedServiceInfo.currentUrl
}, 'background');
closePopup(null);
});
// Handle email input
customEmail.addEventListener('input', async () => {
await updateSuggestions(customEmail, emailSuggestions, emailHistory);
});
// Handle username input
customUsername.addEventListener('input', async () => {
await updateSuggestions(customUsername, usernameSuggestions, usernameHistory);
});
// Handle suggestion clicks for email
emailSuggestions.addEventListener('click', async (e) => {
e.preventDefault();
e.stopPropagation();
const target = e.target as HTMLElement;
// Check if delete button was clicked
if (target.classList.contains('av-suggestion-pill-delete')) {
const value = target.dataset.value;
if (value) {
const updatedHistory = await removeFromHistory(value, CUSTOM_EMAIL_HISTORY_KEY);
emailHistory.splice(0, emailHistory.length, ...updatedHistory);
await updateSuggestions(customEmail, emailSuggestions, emailHistory);
}
} else {
// Check if pill or pill text was clicked
let pillElement = target.closest('.av-suggestion-pill') as HTMLElement;
if (pillElement) {
const textElement = pillElement.querySelector('.av-suggestion-pill-text') as HTMLElement;
const value = textElement?.dataset.value;
if (value) {
customEmail.value = value;
await updateSuggestions(customEmail, emailSuggestions, emailHistory);
}
}
}
});
// Handle suggestion clicks for username
usernameSuggestions.addEventListener('click', async (e) => {
e.preventDefault();
e.stopPropagation();
const target = e.target as HTMLElement;
// Check if delete button was clicked
if (target.classList.contains('av-suggestion-pill-delete')) {
const value = target.dataset.value;
if (value) {
const updatedHistory = await removeFromHistory(value, CUSTOM_USERNAME_HISTORY_KEY);
usernameHistory.splice(0, usernameHistory.length, ...updatedHistory);
await updateSuggestions(customUsername, usernameSuggestions, usernameHistory);
}
} else {
// Check if pill or pill text was clicked
let pillElement = target.closest('.av-suggestion-pill') as HTMLElement;
if (pillElement) {
const textElement = pillElement.querySelector('.av-suggestion-pill-text') as HTMLElement;
const value = textElement?.dataset.value;
if (value) {
customUsername.value = value;
await updateSuggestions(customUsername, usernameSuggestions, usernameHistory);
}
}
}
});
// Get password settings from background
let passwordGenerator: PasswordGenerator;
let currentPasswordSettings: PasswordSettings = {
Length: 12,
UseLowercase: true,
UseUppercase: true,
UseNumbers: true,
UseSpecialChars: true,
UseNonAmbiguousChars: true
};
sendMessage('GET_PASSWORD_SETTINGS', {}, 'background').then((response) => {
const passwordSettingsResponse = response as PasswordSettingsResponse;
currentPasswordSettings = passwordSettingsResponse.settings ?? currentPasswordSettings;
passwordGenerator = CreatePasswordGenerator(currentPasswordSettings);
// Update UI with loaded settings
const lengthSlider = popup.querySelector('#password-length-slider') as HTMLInputElement;
const lengthValue = popup.querySelector('#password-length-value') as HTMLSpanElement;
lengthSlider.value = currentPasswordSettings.Length.toString();
lengthValue.textContent = currentPasswordSettings.Length.toString();
// Generate initial password after settings are loaded
generatePassword();
});
/**
* Generate and set password.
*/
const generatePassword = () : void => {
if (!passwordGenerator) {
return;
}
passwordPreview.value = passwordGenerator.generateRandomPassword();
passwordPreview.type = 'text';
passwordPreview.dataset.isGenerated = 'true';
updateVisibilityIcon(true);
};
// Handle regenerate button click
regenerateBtn.addEventListener('click', generatePassword);
// Handle password length slider
const lengthSlider = popup.querySelector('#password-length-slider') as HTMLInputElement;
const lengthValue = popup.querySelector('#password-length-value') as HTMLSpanElement;
lengthSlider.addEventListener('input', () => {
const newLength = parseInt(lengthSlider.value, 10);
currentPasswordSettings.Length = newLength;
lengthValue.textContent = newLength.toString();
// Regenerate password with new settings
if (passwordGenerator) {
passwordGenerator = CreatePasswordGenerator(currentPasswordSettings);
generatePassword();
}
});
// Handle advanced configuration button
const configBtn = popup.querySelector('#password-config-btn') as HTMLButtonElement;
configBtn.addEventListener('click', () => {
showPasswordConfigDialog();
});
// Add password visibility toggle functionality
const passwordInput = popup.querySelector('#password-preview') as HTMLInputElement;
/**
* Toggle password visibility icon
*/
const updateVisibilityIcon = (isVisible: boolean): void => {
toggleVisibilityBtn.innerHTML = isVisible ? `
<svg class="av-icon" viewBox="0 0 24 24">
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"></path>
<circle cx="12" cy="12" r="3"></circle>
</svg>
` : `
<svg class="av-icon" viewBox="0 0 24 24">
<path d="M17.94 17.94A10.07 10.07 0 0 1 12 20c-7 0-11-8-11-8a18.45 18.45 0 0 1 5.06-5.94M9.9 4.24A9.12 9.12 0 0 1 12 4c7 0 11 8 11 8a18.5 18.5 0 0 1-2.16 3.19m-6.72-1.07a3 3 0 1 1-4.24-4.24"></path>
<line x1="1" y1="1" x2="23" y2="23"></line>
</svg>
`;
};
/**
* Toggle password visibility
*/
const togglePasswordVisibility = (): void => {
const isVisible = passwordInput.type === 'text';
passwordInput.type = isVisible ? 'password' : 'text';
updateVisibilityIcon(!isVisible);
};
toggleVisibilityBtn.addEventListener('click', togglePasswordVisibility);
/**
* Show password configuration dialog
*/
const showPasswordConfigDialog = async (): Promise<void> => {
// Get all translations first
const changePasswordComplexityText = await t('credentials.changePasswordComplexity');
const generateNewPreviewText = await t('credentials.generateNewPreview');
const includeLowercaseText = await t('credentials.includeLowercase');
const includeUppercaseText = await t('credentials.includeUppercase');
const includeNumbersText = await t('credentials.includeNumbers');
const includeSpecialCharsText = await t('credentials.includeSpecialChars');
const avoidAmbiguousCharsText = await t('credentials.avoidAmbiguousChars');
const useText = await t('common.use');
// Create dialog overlay
const dialogOverlay = document.createElement('div');
dialogOverlay.className = 'av-password-config-overlay';
const dialog = document.createElement('div');
dialog.className = 'av-password-config-dialog';
dialog.innerHTML = `
<div class="av-password-config-header">
<h3>${changePasswordComplexityText}</h3>
<button class="av-password-config-close">
<svg class="av-icon" viewBox="0 0 24 24">
<path d="M6 18L18 6M6 6l12 12"/>
</svg>
</button>
</div>
<div class="av-password-config-content">
<div class="av-password-preview-section">
<input type="text" id="config-preview" class="av-password-config-preview" readonly>
<button id="config-refresh" class="av-password-config-refresh" title="${generateNewPreviewText}">
<svg class="av-icon" viewBox="0 0 24 24">
<path d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"></path>
</svg>
</button>
</div>
<div class="av-password-config-options">
<div class="av-password-config-toggles">
<button class="av-password-config-toggle ${currentPasswordSettings.UseLowercase ? 'active' : ''}" data-setting="UseLowercase" title="${includeLowercaseText}">
<span>a-z</span>
</button>
<button class="av-password-config-toggle ${currentPasswordSettings.UseUppercase ? 'active' : ''}" data-setting="UseUppercase" title="${includeUppercaseText}">
<span>A-Z</span>
</button>
<button class="av-password-config-toggle ${currentPasswordSettings.UseNumbers ? 'active' : ''}" data-setting="UseNumbers" title="${includeNumbersText}">
<span>0-9</span>
</button>
<button class="av-password-config-toggle ${currentPasswordSettings.UseSpecialChars ? 'active' : ''}" data-setting="UseSpecialChars" title="${includeSpecialCharsText}">
<span>!@#</span>
</button>
</div>
<div class="av-password-config-checkbox">
<label>
<input type="checkbox" id="avoid-ambiguous" ${currentPasswordSettings.UseNonAmbiguousChars ? 'checked' : ''}>
<span>${avoidAmbiguousCharsText}</span>
</label>
</div>
</div>
<div class="av-password-config-actions">
<button id="config-use-btn" class="av-password-config-use">
<svg class="av-icon" viewBox="0 0 24 24">
<path d="M15 13l-3 3m0 0l-3-3m3 3V8m0 13a9 9 0 110-18 9 9 0 010 18z"/>
</svg>
${useText}
</button>
</div>
</div>
`;
dialogOverlay.appendChild(dialog);
popup.appendChild(dialogOverlay);
// Generate initial preview
const configPreview = dialog.querySelector('#config-preview') as HTMLInputElement;
/**
* Update the config preview.
*/
const updateConfigPreview = (): void => {
if (passwordGenerator) {
passwordGenerator = CreatePasswordGenerator(currentPasswordSettings);
configPreview.value = passwordGenerator.generateRandomPassword();
}
};
updateConfigPreview();
// Handle toggle buttons
dialog.querySelectorAll('.av-password-config-toggle').forEach(toggle => {
toggle.addEventListener('click', () => {
const setting = (toggle as HTMLElement).dataset.setting;
if (setting) {
switch (setting) {
case 'UseLowercase':
currentPasswordSettings.UseLowercase = !currentPasswordSettings.UseLowercase;
toggle.classList.toggle('active', currentPasswordSettings.UseLowercase);
break;
case 'UseUppercase':
currentPasswordSettings.UseUppercase = !currentPasswordSettings.UseUppercase;
toggle.classList.toggle('active', currentPasswordSettings.UseUppercase);
break;
case 'UseNumbers':
currentPasswordSettings.UseNumbers = !currentPasswordSettings.UseNumbers;
toggle.classList.toggle('active', currentPasswordSettings.UseNumbers);
break;
case 'UseSpecialChars':
currentPasswordSettings.UseSpecialChars = !currentPasswordSettings.UseSpecialChars;
toggle.classList.toggle('active', currentPasswordSettings.UseSpecialChars);
break;
}
updateConfigPreview();
}
});
});
// Handle checkbox
const avoidAmbiguousCheckbox = dialog.querySelector('#avoid-ambiguous') as HTMLInputElement;
avoidAmbiguousCheckbox.addEventListener('change', () => {
currentPasswordSettings.UseNonAmbiguousChars = avoidAmbiguousCheckbox.checked;
updateConfigPreview();
});
// Handle refresh button
const refreshBtn = dialog.querySelector('#config-refresh') as HTMLButtonElement;
refreshBtn.addEventListener('click', updateConfigPreview);
// Handle use button
const useBtn = dialog.querySelector('#config-use-btn') as HTMLButtonElement;
useBtn.addEventListener('click', () => {
passwordPreview.value = configPreview.value;
passwordPreview.type = 'text';
passwordPreview.dataset.isGenerated = 'true';
updateVisibilityIcon(true);
// Update main password generator
passwordGenerator = CreatePasswordGenerator(currentPasswordSettings);
// Update slider value
lengthSlider.value = currentPasswordSettings.Length.toString();
lengthValue.textContent = currentPasswordSettings.Length.toString();
// Close dialog
dialogOverlay.remove();
});
// Handle close button
const closeBtn = dialog.querySelector('.av-password-config-close') as HTMLButtonElement;
closeBtn.addEventListener('click', () => {
dialogOverlay.remove();
});
// Handle click outside to close
dialogOverlay.addEventListener('click', (e) => {
if (e.target === dialogOverlay) {
dialogOverlay.remove();
}
});
};
/**
* Handle password input changes
*/
const handlePasswordChange = (e: Event): void => {
const target = e.target as HTMLInputElement;
const isGenerated = target.dataset.isGenerated === 'true';
const isEmpty = target.value.trim().length <= 1;
// If manually cleared (empty or single char) and was previously generated, switch to password type
if (isEmpty && isGenerated) {
target.type = 'password';
target.dataset.isGenerated = 'false';
updateVisibilityIcon(false);
}
};
/**
* Handle paste events
*/
const handlePasswordPaste = (): void => {
passwordInput.dataset.isGenerated = 'false';
passwordInput.type = 'password';
updateVisibilityIcon(false);
};
passwordInput.addEventListener('input', handlePasswordChange);
passwordInput.addEventListener('paste', handlePasswordPaste);
/**
* Toggle dropdown visibility.
*/
const toggleDropdown = () : void => {
dropdownMenu.style.display = dropdownMenu.style.display === 'none' ? 'block' : 'none';
};
// Make title container clickable to trigger the dropdown
titleContainer.addEventListener('click', (e) => {
e.stopPropagation();
toggleDropdown();
});
// Close dropdown when clicking outside
document.addEventListener('click', (e) => {
if (!titleContainer.contains(e.target as Node) && !dropdownMenu.contains(e.target as Node)) {
dropdownMenu.style.display = 'none';
}
});
// Handle mode option clicks
dropdownMenu.querySelectorAll('.av-create-popup-mode-option').forEach(option => {
option.addEventListener('click', () => {
const mode = (option as HTMLElement).dataset.mode;
const titleWrapper = popup.querySelector('.av-create-popup-title-wrapper') as HTMLElement;
if (mode === 'random') {
titleWrapper.innerHTML = `
${randomIdentityIcon}
<h3 class="av-create-popup-title">${randomIdentityTitle}</h3>
`;
popup.querySelector('.av-create-popup-help-text')!.textContent = randomIdentitySubtext;
randomMode.style.display = 'block';
customMode.style.display = 'none';
} else if (mode === 'custom') {
titleWrapper.innerHTML = `
${manualUsernamePasswordIcon}
<h3 class="av-create-popup-title">${manualUsernamePasswordTitle}</h3>
`;
popup.querySelector('.av-create-popup-help-text')!.textContent = manualUsernamePasswordSubtext;
randomMode.style.display = 'none';
customMode.style.display = 'block';
}
dropdownMenu.style.display = 'none';
});
});
/**
* Close the popup.
*/
const closePopup = (value: { serviceName: string | null, isCustomCredential: boolean, customEmail?: string, customUsername?: string, customPassword?: string } | null) : void => {
popup.classList.remove('show');
setTimeout(() => {
overlay.remove();
resolve(value);
}, 200);
};
// Handle save buttons
saveBtn.addEventListener('click', () => {
const serviceName = inputServiceName.value.trim();
if (serviceName) {
closePopup({
serviceName,
isCustomCredential: false
});
}
});
/**
* Handle custom save button click.
*/
const handleCustomSave = async () : Promise<void> => {
const serviceName = inputServiceName.value.trim();
if (serviceName) {
const email = customEmail.value.trim();
const username = customUsername.value.trim();
const finalEmail = email;
const finalUsername = username;
if (!finalEmail && !finalUsername) {
// 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 = await t('content.enterEmailAndOrUsername');
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 = await t('content.enterEmailAndOrUsername');
usernameLabel.appendChild(usernameError);
}
/**
* Remove error styling.
*/
const removeError = () : void => {
customEmail.classList.remove('av-create-popup-input-error');
customUsername.classList.remove('av-create-popup-input-error');
const emailError = emailLabel.querySelector('.av-create-popup-error-text');
const usernameError = usernameLabel.querySelector('.av-create-popup-error-text');
if (emailError) {
emailError.remove();
}
if (usernameError) {
usernameError.remove();
}
};
customEmail.addEventListener('input', removeError, { once: true });
customUsername.addEventListener('input', removeError, { once: true });
return;
}
// Update history when saving
if (finalEmail) {
await updateHistory(finalEmail, CUSTOM_EMAIL_HISTORY_KEY);
}
if (finalUsername) {
await updateHistory(finalUsername, CUSTOM_USERNAME_HISTORY_KEY);
}
closePopup({
serviceName,
isCustomCredential: true,
customEmail: finalEmail,
customUsername: finalUsername,
customPassword: passwordPreview.value
});
}
}
customSaveBtn.addEventListener('click', handleCustomSave);
/**
* Handle custom form input enter key press to submit the form.
*/
const handleCustomEnter = (e: KeyboardEvent) : void => {
if (e.key === 'Enter') {
handleCustomSave();
}
};
inputServiceName.addEventListener('keyup', handleCustomEnter);
customEmail.addEventListener('keyup', handleCustomEnter);
customUsername.addEventListener('keyup', handleCustomEnter);
passwordPreview.addEventListener('keyup', handleCustomEnter);
// Handle cancel buttons
cancelBtn.addEventListener('click', () => {
closePopup(null);
});
customCancelBtn.addEventListener('click', () => {
closePopup(null);
});
// Handle Enter key
inputServiceName.addEventListener('keyup', (e) => {
if (e.key === 'Enter') {
const serviceName = inputServiceName.value.trim();
if (serviceName) {
closePopup({
serviceName,
isCustomCredential: false
});
}
}
});
/**
* Handle click outside.
*/
const handleClickOutside = (event: MouseEvent): void => {
const target = event.target as Node;
if (target === overlay) {
closePopup(null);
}
};
// Use mousedown instead of click to prevent closing when dragging text
overlay.addEventListener('mousedown', handleClickOutside);
/**
* Handle suggested name click.
*/
const handleSuggestedNameClick = async (e: Event) : Promise<void> => {
const target = e.target as HTMLElement;
if (target.classList.contains('av-suggested-name')) {
const name = target.dataset.name;
if (name) {
// Update input with clicked name
inputServiceName.value = name;
customUsername.value = name;
// Update the suggested names section
const suggestedNamesContainer = target.closest('.av-suggested-names');
if (suggestedNamesContainer) {
// Update the suggestions HTML using the helper function
suggestedNamesContainer.innerHTML = await getSuggestedNamesHtml(suggestedNames, name);
}
}
}
};
popup.addEventListener('click', handleSuggestedNameClick);
// Focus the input field
inputServiceName.select();
})();
});
}
/**
* Get suggested names HTML with current input value excluded
*/
async function getSuggestedNamesHtml(suggestedNames: string[], currentValue: string): Promise<string> {
// Filter out the current value and create unique set of remaining suggestions
const filteredSuggestions = [...new Set(suggestedNames.filter(n => n !== currentValue))];
if (filteredSuggestions.length === 0) {
return '';
}
const orLabel = await t('content.or');
return `${orLabel} ${filteredSuggestions.map((name, index) =>
`<span class="av-suggested-name" data-name="${name}">${name}</span>${index < filteredSuggestions.length - 1 ? ', ' : ''}`
).join('')}?`;
}
/**
* Get favicon bytes from page and resize if necessary.
*/
async function getFaviconBytes(document: Document): Promise<Uint8Array | null> {
const MAX_SIZE_BYTES = 50 * 1024; // 50KB max size before resizing
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"][sizes="96x96"]')),
...Array.from(document.querySelectorAll('link[rel="icon"][sizes="128x128"]')),
...Array.from(document.querySelectorAll('link[rel="icon"][sizes="48x48"]')),
...Array.from(document.querySelectorAll('link[rel="icon"][sizes="32x32"]')),
...Array.from(document.querySelectorAll('link[rel="icon"][sizes="192x192"]')),
...Array.from(document.querySelectorAll('link[rel="apple-touch-icon"], link[rel="apple-touch-icon-precomposed"]')),
...Array.from(document.querySelectorAll('link[rel="icon"], link[rel="shortcut icon"]')),
{ href: `${window.location.origin}/favicon.ico` }
] as HTMLLinkElement[];
const uniqueLinks = Array.from(new Map(faviconLinks.map(link => [link.href, link])).values());
for (const link of uniqueLinks) {
const imageData = await fetchAndProcessFavicon(link.href, MAX_SIZE_BYTES, TARGET_WIDTH);
if (imageData) {
return imageData;
}
}
return null;
}
/**
* Attempt to fetch and process a favicon from a given URL
*/
async function fetchAndProcessFavicon(url: string, maxSize: number, targetWidth: number): Promise<Uint8Array | null> {
try {
const response = await fetch(url);
if (!response.ok) {
return null;
}
const contentType = response.headers.get('content-type');
if (!contentType?.startsWith('image/')) {
return null;
}
const arrayBuffer = await response.arrayBuffer();
if (arrayBuffer.byteLength === 0) {
return null;
}
let imageData = new Uint8Array(arrayBuffer);
// If image is too large, attempt to resize
if (imageData.byteLength > maxSize) {
const resizedBlob = await resizeImage(imageData, contentType, targetWidth);
if (resizedBlob) {
imageData = new Uint8Array(await resizedBlob.arrayBuffer());
}
}
// Return only if within size limits
return imageData.byteLength <= maxSize ? imageData : null;
} catch (error) {
console.error('Error fetching favicon:', url, error);
return null;
}
}
/**
* Resizes an image using OffscreenCanvas and compresses it.
*/
async function resizeImage(imageData: Uint8Array, contentType: string, targetWidth: number): Promise<Blob | null> {
return new Promise((resolve) => {
// Convert Uint8Array to ArrayBuffer to ensure compatibility with Blob
const arrayBuffer = imageData.buffer.slice(
imageData.byteOffset,
imageData.byteOffset + imageData.byteLength
) as ArrayBuffer; // Assert as ArrayBuffer to ensure type compatibility
const blob = new Blob([arrayBuffer], { type: contentType });
const img = new Image();
/**
* Handle image load.
*/
img.onload = () : void => {
const scale = targetWidth / img.width;
const targetHeight = Math.floor(img.height * scale);
const canvas = new OffscreenCanvas(targetWidth, targetHeight);
const ctx = canvas.getContext("2d");
if (!ctx) {
resolve(null);
return;
}
ctx.drawImage(img, 0, 0, targetWidth, targetHeight);
canvas.convertToBlob({ type: "image/png", quality: 0.7 }).then(resolve).catch(() => resolve(null));
};
/**
* Handle image load error.
*/
img.onerror = () : void => {
resolve(null);
};
img.src = URL.createObjectURL(blob);
});
}
/**
* Dismiss vault locked popup for 4 hours if user is logged in, or for 3 days if user is not logged in.
*/
export async function dismissVaultLockedPopup(): Promise<void> {
// First check if user is logged in or not.
const authStatus = await sendMessage('CHECK_AUTH_STATUS', {}, 'background') as { isLoggedIn: boolean, isVaultLocked: boolean };
if (authStatus.isLoggedIn) {
// User is logged in - dismiss for 4 hours
const fourHoursFromNow = Date.now() + (4 * 60 * 60 * 1000);
await storage.setItem(VAULT_LOCKED_DISMISS_UNTIL_KEY, fourHoursFromNow);
} else {
// User is not logged in - dismiss for 3 days
const threeDaysFromNow = Date.now() + (3 * 24 * 60 * 60 * 1000);
await storage.setItem(VAULT_LOCKED_DISMISS_UNTIL_KEY, threeDaysFromNow);
}
}
/**
* Get a valid service URL from the current page.
*/
function getValidServiceUrl(): string {
try {
// Check if we're in an iframe with invalid/null source
if (window !== window.top && (!window.location.href || window.location.href === 'about:srcdoc')) {
return '';
}
const url = new URL(window.location.href);
// Validate the domain/origin
if (!url.origin || url.origin === 'null' || !url.hostname) {
return '';
}
// Check for valid protocol (only http/https)
if (!(/^https?:$/).exec(url.protocol)) {
return '';
}
return url.origin + url.pathname;
} catch (error) {
console.debug('Error validating service URL:', error);
return '';
}
}
/**
* Add click handler with mousedown/mouseup backup for better click reliability in shadow DOM.
* Now includes optional security validation.
*
* Some websites due to their design cause the AliasVault autofill to re-trigger when clicking
* outside of the input field, which causes the AliasVault popup to close before the click event
* is registered. This is a workaround to ensure the click event is always registered.
*/
function addReliableClickHandler(
element: HTMLElement,
handler: (e: Event) => void
): void {
/**
* Secure wrapper that validates clicks before executing handler
*/
const secureHandler = async (e: Event): Promise<void> => {
const mouseEvent = e as MouseEvent;
if (!await clickValidator.validateClick(mouseEvent)) {
console.warn(`[AliasVault Security] Blocked click action due to security validation failure`);
return;
}
handler(e);
};
// Add primary click listener with capture and prevent removal
element.addEventListener('click', secureHandler, {
capture: true,
passive: false
});
// Backup click handling using mousedown/mouseup if needed
let isMouseDown = false;
element.addEventListener('mousedown', (e) => {
e.preventDefault();
e.stopPropagation();
isMouseDown = true;
}, { capture: true });
element.addEventListener('mouseup', async (e) => {
e.preventDefault();
e.stopPropagation();
if (isMouseDown) {
await secureHandler(e);
}
isMouseDown = false;
}, { capture: true });
}
/**
* Create upgrade required popup.
*/
export async function createUpgradeRequiredPopup(input: HTMLInputElement, rootContainer: HTMLElement, errorMessage: string): Promise<void> {
/**
* Handle upgrade click.
*/
const handleUpgradeClick = () : void => {
sendMessage('OPEN_POPUP', {}, 'background');
removeExistingPopup(rootContainer);
}
const popup = createBasePopup(input, rootContainer);
popup.classList.add('av-upgrade-required');
// Create container for message and button
const container = document.createElement('div');
container.className = 'av-upgrade-required-container';
addReliableClickHandler(container, handleUpgradeClick);
container.style.cursor = 'pointer';
// Add message
const messageElement = document.createElement('div');
messageElement.className = 'av-upgrade-required-message';
messageElement.textContent = errorMessage;
container.appendChild(messageElement);
// Add upgrade button with SVG icon
const button = document.createElement('button');
button.title = await t('content.openAliasVaultToUpgrade');
button.className = 'av-upgrade-required-button';
button.innerHTML = `
<svg class="av-icon-upgrade" viewBox="0 0 24 24">
<path d="M12 2L2 7l10 5 10-5-10-5zM2 17l10 5 10-5M2 12l10 5 10-5"></path>
</svg>
`;
container.appendChild(button);
// Add the container to the popup
popup.appendChild(container);
// Add close button as a separate element positioned to the right
const closeButton = document.createElement('button');
closeButton.className = 'av-button av-button-close av-upgrade-required-close';
closeButton.title = await t('content.dismissPopup');
closeButton.innerHTML = `
<svg class="av-icon" viewBox="0 0 24 24">
<line x1="18" y1="6" x2="6" y2="18"></line>
<line x1="6" y1="6" x2="18" y2="18"></line>
</svg>
`;
// Position the close button to the right of the container
closeButton.style.position = 'absolute';
closeButton.style.right = '8px';
closeButton.style.top = '50%';
closeButton.style.transform = 'translateY(-50%)';
// Handle close button click
addReliableClickHandler(closeButton, (e) => {
e.stopPropagation(); // Prevent opening the upgrade popup
removeExistingPopup(rootContainer);
});
popup.appendChild(closeButton);
/**
* Add event listener to document to close popup when clicking outside.
*/
const handleClickOutside = (event: MouseEvent): void => {
const target = event.target as Node;
const targetElement = event.target as HTMLElement;
// Check if the click is outside the popup and outside the shadow UI
if (popup && !popup.contains(target) && !input.contains(target) && targetElement.tagName !== 'ALIASVAULT-UI') {
removeExistingPopup(rootContainer);
document.removeEventListener('mousedown', handleClickOutside);
}
};
setTimeout(() => {
document.addEventListener('mousedown', handleClickOutside);
}, 100);
rootContainer.appendChild(popup);
}