Files
aliasvault/apps/browser-extension/src/entrypoints/contentScript/Popup.ts
2026-06-18 09:49:35 +02:00

2840 lines
105 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 * as OTPAuth from 'otpauth';
import { fillItem, fillTotpCode } from '@/entrypoints/contentScript/Form';
import { CreateIdentityGenerator, IdentityHelperUtils } from '@/utils/dist/core/identity-generator';
import { ItemTypeIconSvgs } from '@/utils/dist/core/models/icons';
import type { Item, ItemField } from '@/utils/dist/core/models/vault';
import { ItemTypes, FieldKey, createSystemField, getFieldValue } from '@/utils/dist/core/models/vault';
import { CreatePasswordGenerator, PasswordGenerator, PasswordSettings } from '@/utils/dist/core/password-generator';
import { getAllFaviconLinks } from '@/utils/favicon';
import { LocalPreferencesService } from '@/utils/LocalPreferencesService';
import { sendMessage } from '@/utils/messaging/ExtensionMessaging';
import { sliderToLength, lengthToSlider, SLIDER_MIN, SLIDER_MAX } from '@/utils/PasswordLengthSlider';
import { ClickValidator } from '@/utils/security/ClickValidator';
import { ServiceDetectionUtility } from '@/utils/serviceDetection/ServiceDetectionUtility';
import { SqliteClient } from '@/utils/SqliteClient';
import { t } from '@/i18n/StandaloneI18n';
import { getCurrentAutofillFrameUrl } from './AutofillFrameUrl';
import { completeConditionalWithPasskey, getConditionalPasskeyOptions, hasPendingConditionalRequest } from './ConditionalPasskey';
/**
* 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();
/**
* Active TOTP update interval ID for cleanup
*/
let totpUpdateIntervalId: ReturnType<typeof setInterval> | null = null;
/**
* Cleanup TOTP update interval
*/
function cleanupTotpInterval(): void {
if (totpUpdateIntervalId !== null) {
clearInterval(totpUpdateIntervalId);
totpUpdateIntervalId = null;
}
}
/**
* Generate TOTP code from secret key
*/
function generateTotpCode(secretKey: string): string {
try {
const totp = new OTPAuth.TOTP({
secret: secretKey,
algorithm: 'SHA1',
digits: 6,
period: 30
});
const code = totp.generate();
// Format as "XXX XXX" with space in middle
return `${code.slice(0, 3)} ${code.slice(3)}`;
} catch {
return '--- ---';
}
}
/**
* Get remaining seconds until next TOTP code
*/
function getTotpRemainingSeconds(): number {
return 30 - (Math.floor(Date.now() / 1000) % 30);
}
/**
* Create a suggestion pill element using safe DOM methods.
*/
const createSuggestionPill = (value: string): HTMLElement => {
const pill = document.createElement('span');
pill.className = 'av-suggestion-pill';
const textSpan = document.createElement('span');
textSpan.className = 'av-suggestion-pill-text';
textSpan.dataset.value = value;
textSpan.textContent = value;
const deleteSpan = document.createElement('span');
deleteSpan.className = 'av-suggestion-pill-delete';
deleteSpan.dataset.value = value;
deleteSpan.title = 'Remove';
deleteSpan.textContent = '×';
pill.appendChild(textSpan);
pill.appendChild(deleteSpan);
return pill;
};
/**
* Create a suggested name element using safe DOM methods.
*/
const createSuggestedNameSpan = (name: string): HTMLElement => {
const span = document.createElement('span');
span.className = 'av-suggested-name';
span.dataset.name = name;
span.textContent = name;
return span;
};
/**
* Check if an outside-click event originated from AliasVault UI controls.
*/
function isClickInsidePopupUi(event: MouseEvent, popup: Element, input: HTMLInputElement): boolean {
const eventPath = event.composedPath();
if (eventPath.includes(popup) || eventPath.includes(input)) {
return true;
}
if (eventPath.some((pathTarget) => pathTarget instanceof Element && pathTarget.closest('.av-input-icon') !== null)) {
return true;
}
const rootNode = popup.getRootNode();
if (rootNode instanceof ShadowRoot && (event.target === rootNode.host || eventPath.includes(rootNode.host))) {
return true;
}
return false;
}
/**
* Open (or refresh) the autofill popup including check if vault is locked.
* @param input - The input element that triggered the popup
* @param container - The container element
* @param forceShow - If true, always show the popup even if dismissed (for manual icon clicks)
*/
export function openAutofillPopup(input: HTMLInputElement, container: HTMLElement, forceShow: boolean = false) : 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> => {
const currentUrl = getCurrentAutofillFrameUrl();
if (!currentUrl) {
removeExistingPopup(container);
document.removeEventListener('keydown', handleEnterKey);
return;
}
// Load autofill matching mode setting to send to background for filtering
const matchingMode = await LocalPreferencesService.getAutofillMatchingMode();
const response = await sendMessage('GET_FILTERED_ITEMS', {
currentUrl,
pageTitle: document.title,
matchingMode: matchingMode,
includeRecentlySelected: true // Enable for multi-step login autofill
});
if (response.success) {
await createAutofillPopup(input, response.items, container, response.recentlySelectedId);
} else {
// Check if the user has dismissed the vault locked popup (only for auto-show, not manual clicks)
if (!forceShow) {
const dismissUntil = await LocalPreferencesService.getVaultLockedDismissUntil();
if (dismissUntil && Date.now() < dismissUntil) {
// User has dismissed the popup, don't show it again
removeExistingPopup(container);
return;
}
}
await createVaultLockedPopup(input, container);
}
})();
}
/**
* Open (or refresh) the TOTP autofill popup for 2FA code fields.
* Shows only items that have TOTP codes stored.
* @param input - The input element that triggered the popup
* @param container - The container element
* @param forceShow - If true, always show the popup even if dismissed (for manual icon clicks)
*/
export function openTotpPopup(input: HTMLInputElement, container: HTMLElement, forceShow: boolean = false) : void {
createLoadingPopup(input, '', container);
/**
* Handle the Enter key.
*/
const handleEnterKey = (e: KeyboardEvent) : void => {
if (e.key === 'Enter') {
removeExistingPopup(container);
document.body.removeEventListener('keydown', handleEnterKey);
}
};
document.addEventListener('keydown', handleEnterKey);
(async () : Promise<void> => {
const currentUrl = getCurrentAutofillFrameUrl();
if (!currentUrl) {
removeExistingPopup(container);
document.removeEventListener('keydown', handleEnterKey);
return;
}
const matchingMode = await LocalPreferencesService.getAutofillMatchingMode();
const response = await sendMessage('GET_ITEMS_WITH_TOTP', {
currentUrl,
pageTitle: document.title,
matchingMode: matchingMode
});
if (response.success) {
await createTotpPopup(input, response.items, container, response.recentlySelectedId);
} else {
// Check if the user has dismissed the vault locked popup (only for auto-show, not manual clicks)
if (!forceShow) {
const dismissUntil = await LocalPreferencesService.getVaultLockedDismissUntil();
if (dismissUntil && Date.now() < dismissUntil) {
// User has dismissed the popup, don't show it again
removeExistingPopup(container);
return;
}
}
await createVaultLockedPopup(input, container);
}
})();
}
/**
* Create TOTP autofill popup showing items with 2FA codes.
* Matches the styling of the regular autofill popup.
*/
async function createTotpPopup(input: HTMLInputElement, items: Item[] | undefined, rootContainer: HTMLElement, recentlySelectedId?: string | null) : Promise<void> {
const searchPlaceholder = await t('content.searchVault');
const hideFor1HourText = await t('content.hideFor1Hour');
const hidePermanentlyText = await t('content.hidePermanently');
const noTotpItemsText = await t('content.noTotpItemsFound');
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
if (!items) {
items = [];
}
updateTotpPopupContent(items, credentialList, input, rootContainer, noTotpItemsText, recentlySelectedId);
// Add divider
const divider = document.createElement('div');
divider.className = 'av-divider';
popup.appendChild(divider);
// Add action buttons container (matches regular autofill popup)
const actionContainer = document.createElement('div');
actionContainer.className = 'av-action-container';
// 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 - search only TOTP items
let searchTimeout: NodeJS.Timeout | null = null;
searchInput.addEventListener('input', async () => {
if (searchTimeout) {
clearTimeout(searchTimeout);
}
const searchTerm = searchInput.value.trim();
if (searchTerm === '') {
// If search is empty, show the initially URL-filtered items
updateTotpPopupContent(items, credentialList, input, rootContainer, noTotpItemsText, recentlySelectedId);
} else {
// Search in TOTP items only
const response = await sendMessage('SEARCH_ITEMS_WITH_TOTP', {
searchTerm: searchTerm
});
if (response.success && response.items) {
// Search results don't carry prioritization, so don't highlight any item
updateTotpPopupContent(response.items, credentialList, input, rootContainer, noTotpItemsText);
} else {
// On error, fallback to showing initial filtered items
updateTotpPopupContent(items, credentialList, input, rootContainer, noTotpItemsText, recentlySelectedId);
}
}
});
// Close button (matches regular autofill popup)
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 - show context menu for hide options
*/
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
*/
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(closeButton);
popup.appendChild(actionContainer);
/**
* Handle clicking outside the popup.
*/
const handleClickOutside = (event: MouseEvent) : void => {
const popupElement = rootContainer.querySelector('#aliasvault-credential-popup');
// If popup doesn't exist, remove the listener
if (!popupElement) {
document.removeEventListener('mousedown', handleClickOutside);
return;
}
// Check if the click is outside the popup and outside the input/icon UI.
if (!isClickInsidePopupUi(event, popupElement, input)) {
removeExistingPopup(rootContainer);
}
};
// Add the event listener for clicking outside
document.addEventListener('mousedown', handleClickOutside);
rootContainer.appendChild(popup);
}
/**
* Update the TOTP item list content in the popup with live code preview.
*
* @param items - The items to display.
* @param itemList - The item list element.
* @param input - The input element that triggered the popup.
* @param rootContainer - The root container element.
* @param noMatchesText - Text to show when no items match.
*/
function updateTotpPopupContent(items: Item[], itemList: HTMLElement | null, input: HTMLInputElement, rootContainer: HTMLElement, noMatchesText?: string, recentlySelectedId?: string | null) : void {
if (!itemList) {
itemList = document.getElementById('aliasvault-credential-list') as HTMLElement;
}
if (!itemList) {
return;
}
// Cleanup any existing interval before creating new items
cleanupTotpInterval();
// Clear existing content
itemList.innerHTML = '';
if (items.length === 0) {
const noMatches = document.createElement('div');
noMatches.className = 'av-no-matches';
noMatches.textContent = noMatchesText || 'No credentials with 2FA codes found';
itemList.appendChild(noMatches);
return;
}
// Fetch TOTP secrets and create items with live codes
(async (): Promise<void> => {
const itemIds = items.map(item => item.Id);
const secretsResponse = await sendMessage('GET_TOTP_SECRETS', { itemIds });
const secrets = secretsResponse.success && secretsResponse.secrets ? secretsResponse.secrets : {};
const hasSecrets = Object.keys(secrets).length > 0;
// Create items (with live codes if secrets available, static otherwise)
const codeElements: Map<string, { codeSpan: HTMLSpanElement; pieChart: SVGPathElement }> = new Map();
items.forEach(item => {
const secret = secrets[item.Id];
const isRecentlySelected = recentlySelectedId != null && item.Id === recentlySelectedId;
const itemElement = createTotpItem(item, secret, input, rootContainer, hasSecrets ? codeElements : undefined, isRecentlySelected);
itemList!.appendChild(itemElement);
});
// Set up live updates only if we have secrets
if (hasSecrets) {
// Initial update
updateTotpCodes(codeElements, secrets);
// Set up interval to update codes every second
totpUpdateIntervalId = setInterval(() => {
updateTotpCodes(codeElements, secrets);
}, 1000);
}
})();
}
/**
* Create SVG path for a pie slice starting from top, going counter-clockwise.
* @param cx - Center X
* @param cy - Center Y
* @param r - Radius
* @param fraction - Fraction of the pie to show (0 to 1)
*/
function createPieSlicePath(cx: number, cy: number, r: number, fraction: number): string {
if (fraction <= 0) {
return '';
}
if (fraction >= 1) {
return `M ${cx},${cy} m 0,-${r} a ${r},${r} 0 1,0 0,${r * 2} a ${r},${r} 0 1,0 0,-${r * 2} Z`;
}
const angle = fraction * 2 * Math.PI;
// Start from top (12 o'clock position)
const startX = cx;
const startY = cy - r;
// End point going counter-clockwise
const endX = cx - r * Math.sin(angle);
const endY = cy - r * Math.cos(angle);
// Large arc flag: 1 if angle > 180 degrees
const largeArc = fraction > 0.5 ? 1 : 0;
return `M ${cx},${cy} L ${startX},${startY} A ${r},${r} 0 ${largeArc},0 ${endX},${endY} Z`;
}
/**
* Update TOTP codes and pie chart countdown for all items.
*/
function updateTotpCodes(
codeElements: Map<string, { codeSpan: HTMLSpanElement; pieChart: SVGPathElement }>,
secrets: Record<string, string>
): void {
const remainingSeconds = getTotpRemainingSeconds();
const fraction = remainingSeconds / 30;
codeElements.forEach((elements, itemId) => {
const secret = secrets[itemId];
if (secret) {
elements.codeSpan.textContent = generateTotpCode(secret);
}
elements.pieChart.setAttribute('d', createPieSlicePath(6, 6, 5, fraction));
});
}
/**
* Create a TOTP item element.
* If secret is provided, shows live code with pie chart countdown.
* If secret is undefined, shows static "000 000" placeholder.
*/
function createTotpItem(
item: Item,
secret: string | undefined,
input: HTMLInputElement,
rootContainer: HTMLElement,
codeElements?: Map<string, { codeSpan: HTMLSpanElement; pieChart: SVGPathElement }>,
isRecentlySelected: boolean = false
): HTMLElement {
const itemElement = document.createElement('div');
itemElement.className = 'av-credential-item';
// Create container for item info (logo + name)
const itemInfo = document.createElement('div');
itemInfo.className = 'av-credential-info';
itemInfo.appendChild(createLogoContainer(item.Logo));
const itemTextContainer = document.createElement('div');
itemTextContainer.className = 'av-credential-text';
// Service name (primary text) with optional "recently used" indicator
const serviceName = document.createElement('div');
serviceName.className = 'av-service-name';
if (isRecentlySelected) {
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);
serviceNameContainer.appendChild(createRecentlySelectedIcon());
serviceName.appendChild(serviceNameContainer);
} else {
serviceName.textContent = item.Name || '';
}
// TOTP code display beneath title (like username in normal credentials)
const detailsContainer = document.createElement('div');
detailsContainer.className = 'av-service-details';
detailsContainer.style.display = 'flex';
detailsContainer.style.alignItems = 'center';
detailsContainer.style.gap = '6px';
// TOTP code span - show generated code or static placeholder
const codeSpan = document.createElement('span');
codeSpan.textContent = secret ? generateTotpCode(secret) : '000 000';
// Pie chart countdown - blue pie that shrinks clockwise from top
const remainingSeconds = getTotpRemainingSeconds();
const fraction = secret ? remainingSeconds / 30 : 1; // Static shows full pie
const svgNS = 'http://www.w3.org/2000/svg';
const svg = document.createElementNS(svgNS, 'svg');
svg.setAttribute('width', '14');
svg.setAttribute('height', '14');
svg.setAttribute('viewBox', '0 0 12 12');
svg.style.flexShrink = '0';
// Blue pie slice that shrinks clockwise from top (like a clock)
const pieChart = document.createElementNS(svgNS, 'path');
pieChart.setAttribute('fill', secret ? '#3b82f6' : '#9ca3af'); // Gray for static
pieChart.setAttribute('d', createPieSlicePath(6, 6, 5, fraction));
svg.appendChild(pieChart);
detailsContainer.appendChild(codeSpan);
detailsContainer.appendChild(svg);
itemTextContainer.appendChild(serviceName);
itemTextContainer.appendChild(detailsContainer);
itemInfo.appendChild(itemTextContainer);
// Store references for live updates (only if secret exists and codeElements provided)
if (secret && codeElements) {
codeElements.set(item.Id, { codeSpan, pieChart });
}
itemElement.appendChild(itemInfo);
itemElement.appendChild(createPopoutIcon(item.Id, rootContainer));
// Handle click to fill TOTP code
addReliableClickHandler(itemInfo, async () => {
await fillTotpCode(item.Id, input);
removeExistingPopup(rootContainer);
});
return itemElement;
}
/**
* 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.
* The shadow container should be fixed at top:0, left:0, so we can use
* viewport-relative coordinates directly.
*
* If the rootContainer is unexpectedly positioned due to client-side
* modifications like ad-blockers, fall back to using
* fixed positioning relative to viewport.
*/
let relativeTop = inputRect.bottom - rootContainerRect.top;
let relativeLeft = inputRect.left - rootContainerRect.left;
let useFixedPositioning = false;
// If the container is not at top-left (within tolerance), use fixed positioning
if (Math.abs(rootContainerRect.top) > 10 || Math.abs(rootContainerRect.left) > 10) {
useFixedPositioning = true;
relativeTop = inputRect.bottom;
relativeLeft = inputRect.left;
}
// Set the position
popup.style.position = useFixedPositioning ? 'fixed' : 'absolute';
popup.style.top = `${relativeTop}px`;
popup.style.left = `${relativeLeft}px`;
// Append popup to the root container
rootContainer.appendChild(popup);
/*
* Some websites embed the login form inside an iframe. We constrain the popup to
* the (i)frame viewport so it doesn't get clipped inside small iframes.
* Instead, this will allow the popup to scroll internally if needed.
*/
requestAnimationFrame(() => {
const viewportHeight = window.innerHeight;
const viewportWidth = window.innerWidth;
const margin = 8;
const spaceBelow = viewportHeight - inputRect.bottom - margin;
const spaceAbove = inputRect.top - margin;
let availableHeight = spaceBelow;
if (spaceBelow < 160 && spaceAbove > spaceBelow) {
// Flip above the input
const popupHeight = popup.offsetHeight;
const constrainedHeight = Math.min(popupHeight, spaceAbove);
const newTop = useFixedPositioning
? inputRect.top - constrainedHeight - 4
: inputRect.top - rootContainerRect.top - constrainedHeight - 4;
popup.style.top = `${newTop}px`;
availableHeight = spaceAbove;
}
popup.style.maxHeight = `${Math.max(80, availableHeight)}px`;
popup.style.overflowY = 'auto';
// Clamp horizontally so the popup stays inside the viewport
const popupWidth = popup.offsetWidth;
if (inputRect.left + popupWidth > viewportWidth - margin) {
const newLeft = Math.max(margin, viewportWidth - popupWidth - margin);
popup.style.left = useFixedPositioning
? `${newLeft}px`
: `${newLeft - rootContainerRect.left}px`;
}
});
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 async function updatePopupContent(items: Item[], itemList: HTMLElement | null, input: HTMLInputElement, rootContainer: HTMLElement, noMatchesText?: string, recentlySelectedId?: string | null) : Promise<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, recentlySelectedId);
itemElements.forEach(element => itemList.appendChild(element));
}
/**
* Build a small history-arrow SVG badge indicating this item was the most recently
* autofilled on this site, so the user can quickly spot it among multiple matches.
*/
function createRecentlySelectedIcon(): SVGSVGElement {
const svgNS = 'http://www.w3.org/2000/svg';
const svg = document.createElementNS(svgNS, 'svg');
svg.setAttribute('viewBox', '0 0 24 24');
svg.setAttribute('fill', 'none');
svg.setAttribute('stroke', '#6b7280');
svg.setAttribute('stroke-width', '2');
svg.setAttribute('stroke-linecap', 'round');
svg.setAttribute('stroke-linejoin', 'round');
svg.style.width = '12px';
svg.style.height = '12px';
svg.style.flexShrink = '0';
const arc = document.createElementNS(svgNS, 'path');
arc.setAttribute('d', 'M3 12a9 9 0 1 0 9-9 9.74 9.74 0 0 0-6.74 2.74L3 8');
svg.appendChild(arc);
const corner = document.createElementNS(svgNS, 'polyline');
corner.setAttribute('points', '3 3 3 8 8 8');
svg.appendChild(corner);
const innerHands = document.createElementNS(svgNS, 'path');
innerHands.setAttribute('d', 'M12 7v5l4 2');
svg.appendChild(innerHands);
return svg;
}
/**
* 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);
}
}
// Cleanup TOTP interval
cleanupTotpInterval();
existingInContainer.remove();
}
}
/**
* Whether an item is usable for autofill: it must carry at least a username, email, or
* password. Items with none of these (e.g. a note-only entry) can't fill a login form, so
* showing them as a credential match is just noise.
*/
function hasFillableLoginField(item: Item): boolean {
return [FieldKey.LoginUsername, FieldKey.LoginEmail, FieldKey.LoginPassword]
.some((key) => (getFieldValue(item, key) ?? '').trim() !== '');
}
/**
* Create auto-fill popup
*/
export async function createAutofillPopup(input: HTMLInputElement, items: Item[] | undefined, rootContainer: HTMLElement, recentlySelectedId?: string | null) : 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 creatingAliasText = await t('content.creatingNewAlias');
const creatingCredentialText = await t('content.creatingNewCredential');
const failedText = await t('content.failedToCreateIdentity');
const popup = createBasePopup(input, rootContainer);
/*
* Conditional passkey autofill: when a page has a pending conditional get() request and we
* hold matching passkeys, the popup gains a persistent pill nav switching between a passkey
* view and the credential view.
*/
const passkeySection = await createPasskeySection(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 = [];
}
// Drop entries that have nothing to fill (no username/email/password) - they're not useful matches.
items = items.filter(hasFillableLoginField);
await updatePopupContent(items, credentialList, input, rootContainer, noMatchesText, recentlySelectedId);
// 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, `${result.isCustomCredential ? creatingCredentialText : creatingAliasText}...`, rootContainer);
try {
// Sync with api to ensure we have the latest vault.
await sendMessage('SYNC_VAULT');
// Retrieve default email domain from background
const response = await sendMessage('GET_DEFAULT_EMAIL_DOMAIN');
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');
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');
// 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();
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: domain ? `${identity.emailPrefix}@${domain}` : identity.emailPrefix }));
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.
const createResponse = await sendMessage('CREATE_ITEM', {
item: JSON.parse(JSON.stringify(newItem))
});
// Check if item creation succeeded
if (!createResponse.success) {
throw new Error(createResponse.error || 'Failed to create item');
}
// 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, recentlySelectedId);
});
// 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);
/*
* When passkeys are offered, show the pill nav and the passkey section.
*/
if (passkeySection) {
const credentialsText = await t('common.credentials');
const passkeysText = await t('common.passkeys');
const credentialsLabel = items.length > 0 ? `${credentialsText} (${items.length})` : credentialsText;
const passkeysLabel = `${passkeysText} (${passkeySection.count})`;
// Build the segmented pill nav and pin it to the very top of the popup.
const pillNav = document.createElement('div');
pillNav.className = 'av-pill-nav';
const passkeysPill = createViewSwitchPill(passkeysLabel);
const credentialsPill = createViewSwitchPill(credentialsLabel);
pillNav.appendChild(passkeysPill);
pillNav.appendChild(credentialsPill);
popup.insertBefore(pillNav, popup.firstChild);
// The passkey hint + list sit directly under the nav, above the credential list.
popup.insertBefore(passkeySection.hint, credentialList);
popup.insertBefore(passkeySection.list, credentialList);
const whenPasskeysShown = [passkeySection.hint, passkeySection.list];
const whenCredentialsShown = [credentialList, divider, actionContainer];
let showingPasskeys = true;
/**
* Apply the current view: highlight the active pill and show only that view's elements.
*/
const applyView = (): void => {
passkeysPill.classList.toggle('av-pill-active', showingPasskeys);
credentialsPill.classList.toggle('av-pill-active', !showingPasskeys);
whenPasskeysShown.forEach((element) => {
element.style.display = showingPasskeys ? '' : 'none';
});
whenCredentialsShown.forEach((element) => {
element.style.display = showingPasskeys ? 'none' : '';
});
if (!showingPasskeys) {
// Focus the search field so the user can immediately filter the revealed list.
searchInput.focus();
}
};
/**
* Add click handlers to the pill nav so clicking it selects its view (passkeys or credentials).
*/
const wirePill = (pill: HTMLElement, selectsPasskeys: boolean): void => {
pill.addEventListener('mousedown', (e) => {
e.preventDefault();
e.stopPropagation();
});
pill.addEventListener('click', (e) => {
e.preventDefault();
e.stopPropagation();
showingPasskeys = selectsPasskeys;
applyView();
});
};
wirePill(passkeysPill, true);
wirePill(credentialsPill, false);
// Default to the passkey view: if the user has a passkey, that's the likely choice.
applyView();
}
/**
* Handle clicking outside the popup.
*/
const handleClickOutside = (event: MouseEvent) : void => {
const popup = rootContainer.querySelector('#aliasvault-credential-popup');
// 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 input/icon UI.
if (!isClickInsidePopupUi(event, popup, input)) {
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');
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 => {
// Check if the click is outside the popup and outside the input/icon UI.
if (!isClickInsidePopupUi(event, popup, input)) {
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, recentlySelectedId?: string | null) : Promise<void> {
if (searchTimeout) {
clearTimeout(searchTimeout);
}
const searchTerm = searchInput.value.trim();
if (searchTerm === '') {
// If search is empty, show the initially URL-filtered items with the recently-used star restored
await updatePopupContent(initialItems, itemList, input, rootContainer, noMatchesText, recentlySelectedId);
} else {
// Search in full vault with search term
const response = await sendMessage('GET_SEARCH_ITEMS', {
searchTerm: searchTerm
});
if (response.success && response.items) {
// Search results don't carry prioritization, so don't highlight any item
const fillableItems = response.items.filter(hasFillableLoginField);
await updatePopupContent(fillableItems, itemList, input, rootContainer, noMatchesText);
} else {
// On error, fallback to showing initial filtered items
await updatePopupContent(initialItems, itemList, input, rootContainer, noMatchesText, recentlySelectedId);
}
}
}
/**
* Build small passkey badge icon shown next to a service name to mark that the entry is (or has) a passkey.
*/
function createPasskeyBadgeIcon(): SVGSVGElement {
const svgNS = 'http://www.w3.org/2000/svg';
const svg = document.createElementNS(svgNS, 'svg');
svg.setAttribute('class', 'av-passkey-icon');
svg.setAttribute('viewBox', '0 0 24 24');
svg.setAttribute('fill', 'none');
svg.setAttribute('stroke', 'currentColor');
svg.setAttribute('stroke-width', '2');
svg.setAttribute('stroke-linecap', 'round');
svg.setAttribute('stroke-linejoin', 'round');
svg.setAttribute('aria-label', 'Has passkey');
svg.style.width = '14px';
svg.style.height = '14px';
svg.style.flexShrink = '0';
svg.style.opacity = '0.7';
const path = document.createElementNS(svgNS, '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');
svg.appendChild(path);
return svg;
}
/**
* Build logo box for a credential/passkey/TOTP row: the item's favicon when present, otherwise the generic placeholder icon.
*/
function createLogoContainer(logo: Uint8Array | number[] | undefined): HTMLElement {
const logoContainer = document.createElement('div');
logoContainer.className = 'av-credential-logo';
const logoSrc = SqliteClient.imgSrcFromBytes(logo);
if (logoSrc) {
logoContainer.innerHTML = `<img src="${logoSrc}" alt="" style="width:100%;height:100%;">`;
} else {
logoContainer.innerHTML = ItemTypeIconSvgs.Placeholder;
}
return logoContainer;
}
/**
* Build popout icon shown at the trailing edge of a row, which opens the underlying item in the full extension popup.
*/
function createPopoutIcon(itemId: string, rootContainer: HTMLElement): HTMLElement {
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>
`;
addReliableClickHandler(popoutIcon, (e) => {
e.stopPropagation(); // Don't trigger the row's primary action.
sendMessage('OPEN_POPUP_WITH_ITEM', { itemId });
removeExistingPopup(rootContainer);
});
return popoutIcon;
}
/**
* Build passkey side of the autofill popup: a short hint line that makes the passkey view
* recognisably different from the credential view, the scrollable list of passkey sign-in rows,
* and the passkey count (for the pill-nav label).
*/
async function createPasskeySection(rootContainer: HTMLElement): Promise<{ hint: HTMLElement; list: HTMLElement; count: number } | null> {
if (!hasPendingConditionalRequest()) {
return null;
}
const options = getConditionalPasskeyOptions();
if (options.length === 0) {
return null;
}
/*
* Hint line (passkey icon + instruction) shown above the list so the passkey view reads
* clearly differently from the credential view when switching between the two pills.
*/
const hint = document.createElement('div');
hint.className = 'av-passkey-hint';
hint.appendChild(createPasskeyBadgeIcon());
const hintText = document.createElement('span');
hintText.textContent = await t('content.loginWithPasskey');
hint.appendChild(hintText);
/*
* Reuse the credential-list scroll styling but cap the height so at most ~3 passkeys are
* visible before the list scrolls, leaving room for the view-switch toggle below.
*/
const list = document.createElement('div');
list.className = 'av-credential-list av-passkey-list';
options.forEach((option) => {
const itemElement = document.createElement('div');
itemElement.className = 'av-credential-item';
const itemInfo = document.createElement('div');
itemInfo.className = 'av-credential-info';
// Show the credential's favicon (matching the normal rows), falling back to a placeholder.
itemInfo.appendChild(createLogoContainer(option.logo ?? undefined));
const textContainer = document.createElement('div');
textContainer.className = 'av-credential-text';
// Service name with a passkey badge so the row is recognisable as a passkey sign-in.
const serviceName = document.createElement('div');
serviceName.className = 'av-service-name';
const serviceNameContainer = document.createElement('div');
serviceNameContainer.style.display = 'flex';
serviceNameContainer.style.alignItems = 'center';
serviceNameContainer.style.gap = '4px';
const serviceNameText = document.createElement('span');
serviceNameText.textContent = option.serviceName;
serviceNameContainer.appendChild(serviceNameText);
serviceNameContainer.appendChild(createPasskeyBadgeIcon());
serviceName.appendChild(serviceNameContainer);
textContainer.appendChild(serviceName);
if (option.username) {
const details = document.createElement('div');
details.className = 'av-service-details';
details.textContent = option.username;
textContainer.appendChild(details);
}
itemInfo.appendChild(textContainer);
itemElement.appendChild(itemInfo);
// Popout icon opens the underlying credential in the full extension popup.
itemElement.appendChild(createPopoutIcon(option.itemId, rootContainer));
addReliableClickHandler(itemInfo, () => {
// Explicit user action - keep the vault from auto-locking mid-assertion.
sendMessage('RESET_AUTO_LOCK_TIMER').catch(() => {
// Ignore: background may be asleep.
});
void completeConditionalWithPasskey(option.id);
removeExistingPopup(rootContainer);
});
list.appendChild(itemElement);
});
return { hint, list, count: options.length };
}
/**
* Build a single segment button for the passkey/credentials pill nav shown at the top of the
* autofill popup when conditional passkeys are offered (e.g. "Passkeys (2)" or
* "Credentials (3)"). The caller marks the active segment via the `av-pill-active` class.
*
* @param label - The segment text, including the match count.
*/
function createViewSwitchPill(label: string): HTMLButtonElement {
const pill = document.createElement('button');
pill.type = 'button';
pill.className = 'av-pill';
pill.textContent = label;
return pill;
}
/**
* 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, recentlySelectedId?: string | null): 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';
itemInfo.appendChild(createLogoContainer(item.Logo));
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 "recently used" indicator if this item was the last autofill on this site
if (recentlySelectedId != null && item.Id === recentlySelectedId) {
serviceNameContainer.appendChild(createRecentlySelectedIcon());
}
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);
itemElement.appendChild(itemInfo);
itemElement.appendChild(createPopoutIcon(item.Id, rootContainer));
// 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
*/
/**
* 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 LocalPreferencesService.getTemporaryDisabledSites();
temporaryDisabledSites[currentHostname] = Date.now() + (60 * 60 * 1000); // 1 hour from now
await LocalPreferencesService.setTemporaryDisabledSites(temporaryDisabledSites);
} else {
// Add to permanently disabled sites
const disabledSites = await LocalPreferencesService.getDisabledSites();
if (!disabledSites.includes(currentHostname)) {
disabledSites.push(currentHostname);
await LocalPreferencesService.setDisabledSites(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 LocalPreferencesService.getCustomEmailHistory();
const usernameHistory = await LocalPreferencesService.getCustomUsernameHistory();
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('items.passwordLength');
const changePasswordComplexityText = await t('items.changePasswordComplexity');
// 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"></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="${SLIDER_MIN}" max="${SLIDER_MAX}" step="0.1" value="${lengthToSlider(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;
// Populate suggested names
const suggestedNamesContainer = popup.querySelector('.av-suggested-names') as HTMLElement;
if (suggestedNamesContainer) {
await populateSuggestedNames(suggestedNamesContainer, suggestedNames, suggestedNames[0] ?? '');
}
/**
* Update history with new value (max 2 unique entries)
*/
const updateHistory = async (value: string, historyType: 'email' | 'username', maxItems: number = 2): Promise<string[]> => {
const history = historyType === 'email'
? await LocalPreferencesService.getCustomEmailHistory()
: await LocalPreferencesService.getCustomUsernameHistory();
// 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
if (historyType === 'email') {
await LocalPreferencesService.setCustomEmailHistory(updatedHistory);
} else {
await LocalPreferencesService.setCustomUsernameHistory(updatedHistory);
}
return updatedHistory;
};
/**
* Remove item from history
*/
const removeFromHistory = async (value: string, historyType: 'email' | 'username'): Promise<string[]> => {
const history = historyType === 'email'
? await LocalPreferencesService.getCustomEmailHistory()
: await LocalPreferencesService.getCustomUsernameHistory();
const updatedHistory = history.filter((item: string) => item !== value);
if (historyType === 'email') {
await LocalPreferencesService.setCustomEmailHistory(updatedHistory);
} else {
await LocalPreferencesService.setCustomUsernameHistory(updatedHistory);
}
return updatedHistory;
};
/**
* Update suggestions display using safe DOM methods.
*/
const updateSuggestions = (input: HTMLInputElement, suggestionsContainer: HTMLElement, history: string[]): void => {
const currentValue = input.value.trim();
// Filter out the current value from history and limit to 2 items
const filteredHistory = history
.filter(item => item.toLowerCase() !== currentValue.toLowerCase())
.slice(0, 2);
// Clear existing content
suggestionsContainer.textContent = '';
if (filteredHistory.length === 0) {
suggestionsContainer.style.display = 'none';
return;
}
// Build pill elements
filteredHistory.forEach((item, index) => {
if (index > 0) {
suggestionsContainer.appendChild(document.createTextNode(' '));
}
suggestionsContainer.appendChild(createSuggestionPill(item));
});
suggestionsContainer.style.display = 'flex';
};
// Initial display of suggestions
updateSuggestions(customEmail, emailSuggestions, emailHistory);
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
});
closePopup(null);
});
// Handle email input
customEmail.addEventListener('input', () => {
updateSuggestions(customEmail, emailSuggestions, emailHistory);
});
// Handle username input
customUsername.addEventListener('input', () => {
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, 'email');
emailHistory.splice(0, emailHistory.length, ...updatedHistory);
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;
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, 'username');
usernameHistory.splice(0, usernameHistory.length, ...updatedHistory);
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;
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').then((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 = lengthToSlider(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 sliderValue = parseFloat(lengthSlider.value);
const newLength = sliderToLength(sliderValue);
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('items.changePasswordComplexity');
const generateNewPreviewText = await t('common.generate');
const includeLowercaseText = await t('items.includeLowercase');
const includeUppercaseText = await t('items.includeUppercase');
const includeNumbersText = await t('items.includeNumbers');
const includeSpecialCharsText = await t('items.includeSpecialChars');
const avoidAmbiguousCharsText = await t('items.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 = lengthToSlider(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, 'email');
}
if (finalUsername) {
await updateHistory(finalUsername, 'username');
}
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. Only fill the service name (title);
// the username must not be derived from the title.
inputServiceName.value = name;
// Update the suggested names section
const suggestedNamesContainer = target.closest('.av-suggested-names') as HTMLElement;
if (suggestedNamesContainer) {
await populateSuggestedNames(suggestedNamesContainer, suggestedNames, name);
}
}
}
};
popup.addEventListener('click', handleSuggestedNameClick);
// Focus the input field
inputServiceName.select();
})();
});
}
/**
* Populate a suggested names container using safe DOM methods.
*/
async function populateSuggestedNames(container: HTMLElement, suggestedNames: string[], currentValue: string): Promise<void> {
// Filter out the current value and create unique set of remaining suggestions
const filteredSuggestions = [...new Set(suggestedNames.filter(n => n !== currentValue))];
// Clear existing content
container.textContent = '';
if (filteredSuggestions.length === 0) {
return;
}
const orLabel = await t('content.or');
// Add "or" label as text node
container.appendChild(document.createTextNode(orLabel + ' '));
// Add each suggestion
filteredSuggestions.forEach((name, index) => {
container.appendChild(createSuggestedNameSpan(name));
if (index < filteredSuggestions.length - 1) {
container.appendChild(document.createTextNode(', '));
}
});
container.appendChild(document.createTextNode('?'));
}
/**
* Get favicon bytes from page and resize if necessary.
* Uses the shared FaviconExtractor utility for consistent favicon URL extraction.
*/
async function getFaviconBytes(doc: Document): Promise<Uint8Array | null> {
const MAX_SIZE_BYTES = 50 * 1024; // 50KB max size before resizing
const TARGET_WIDTH = 96; // Resize target width
// Use shared utility for consistent favicon extraction across the extension
const faviconLinks = getAllFaviconLinks(doc);
for (const link of faviconLinks) {
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');
if (authStatus.isLoggedIn) {
// User is logged in - dismiss for 4 hours
const fourHoursFromNow = Date.now() + (4 * 60 * 60 * 1000);
await LocalPreferencesService.setVaultLockedDismissUntil(fourHoursFromNow);
} else {
// User is not logged in - dismiss for 3 days
const threeDaysFromNow = Date.now() + (3 * 24 * 60 * 60 * 1000);
await LocalPreferencesService.setVaultLockedDismissUntil(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');
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 => {
// Check if the click is outside the popup and outside the input/icon UI.
if (!isClickInsidePopupUi(event, popup, input)) {
removeExistingPopup(rootContainer);
document.removeEventListener('mousedown', handleClickOutside);
}
};
setTimeout(() => {
document.addEventListener('mousedown', handleClickOutside);
}, 100);
rootContainer.appendChild(popup);
}