mirror of
https://github.com/aliasvault/aliasvault.git
synced 2026-08-01 09:47:18 -04:00
2840 lines
105 KiB
TypeScript
2840 lines
105 KiB
TypeScript
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);
|
||
}
|