From fbd8a61587bf6163a6c5864cd0d25bb575264c88 Mon Sep 17 00:00:00 2001 From: Leendert de Borst Date: Wed, 20 May 2026 13:10:06 +0200 Subject: [PATCH] Add context menu autofill 2FA option (#2027) --- .../src/entrypoints/background/ContextMenu.ts | 39 ++++++-- .../src/entrypoints/content.ts | 93 +++++++++++-------- .../src/i18n/locales/en.json | 3 +- .../src/utils/autofill/PopupTypes.ts | 46 +++++++++ 4 files changed, 134 insertions(+), 47 deletions(-) create mode 100644 apps/browser-extension/src/utils/autofill/PopupTypes.ts diff --git a/apps/browser-extension/src/entrypoints/background/ContextMenu.ts b/apps/browser-extension/src/entrypoints/background/ContextMenu.ts index cfdcd507b..ab93f02ab 100644 --- a/apps/browser-extension/src/entrypoints/background/ContextMenu.ts +++ b/apps/browser-extension/src/entrypoints/background/ContextMenu.ts @@ -1,12 +1,29 @@ import { type Browser } from '@wxt-dev/browser'; import { sendMessage } from 'webext-bridge/background'; +import { POPUP_TYPES, type PopupType, isPopupType } from '@/utils/autofill/PopupTypes'; import { PasswordGenerator } from '@/utils/dist/core/password-generator'; import { t } from '@/i18n/StandaloneI18n'; import { browser } from "#imports"; +const MENU_ID_PREFIX = 'aliasvault-activate-form-'; + +/** Context-menu item id for a popup type. */ +function menuIdForPopupType(type: PopupType): string { + return `${MENU_ID_PREFIX}${type}`; +} + +/** Reverse lookup: context-menu item id -> popup type. */ +function popupTypeForMenuId(menuId: string | number): PopupType | undefined { + if (typeof menuId !== 'string' || !menuId.startsWith(MENU_ID_PREFIX)) { + return undefined; + } + const candidate = menuId.slice(MENU_ID_PREFIX.length); + return isPopupType(candidate) ? candidate : undefined; +} + /* * Register the click listener once at module load (top-level scope). */ @@ -48,8 +65,9 @@ export async function setupContextMenus() : Promise { console.error('Failed to remove existing context menus:', error); } - const [activateFormTitle, generatePasswordTitle] = await Promise.all([ - t('content.autofillWithAliasVault'), + const popupEntries = Object.entries(POPUP_TYPES) as [PopupType, typeof POPUP_TYPES[PopupType]][]; + const [popupTitles, generatePasswordTitle] = await Promise.all([ + Promise.all(popupEntries.map(([, config]) => t(config.titleKey))), t('content.generateRandomPassword'), ]); @@ -62,12 +80,12 @@ export async function setupContextMenus() : Promise { }); await Promise.all([ - createContextMenu({ - id: "aliasvault-activate-form", + ...popupEntries.map(([type], i) => createContextMenu({ + id: menuIdForPopupType(type), parentId: "aliasvault-root", - title: activateFormTitle, + title: popupTitles[i], contexts: ["editable"], - }), + })), createContextMenu({ id: "aliasvault-separator", parentId: "aliasvault-root", @@ -106,7 +124,12 @@ export function handleContextMenuClick(info: Browser.contextMenus.OnClickData, t }); }); } - } else if (info.menuItemId === "aliasvault-activate-form" && tab?.id) { + } else if (tab?.id) { + const popupType = popupTypeForMenuId(info.menuItemId); + if (!popupType) { + return; + } + // First get the active element's identifier browser.scripting.executeScript({ target: { tabId: tab.id }, @@ -115,7 +138,7 @@ export function handleContextMenuClick(info: Browser.contextMenus.OnClickData, t const elementIdentifier = results[0]?.result; if (elementIdentifier) { // Send message to content script with proper tab targeting - sendMessage('OPEN_AUTOFILL_POPUP', { elementIdentifier }, `content-script@${tab.id}`); + sendMessage('OPEN_AUTOFILL_POPUP', { elementIdentifier, popupType }, `content-script@${tab.id}`); } }); } diff --git a/apps/browser-extension/src/entrypoints/content.ts b/apps/browser-extension/src/entrypoints/content.ts index f21066d57..2ae248de6 100644 --- a/apps/browser-extension/src/entrypoints/content.ts +++ b/apps/browser-extension/src/entrypoints/content.ts @@ -10,8 +10,8 @@ import { openAutofillPopup, openTotpPopup, removeExistingPopup, createUpgradeReq import { showSavePrompt, showAddUrlPrompt, isSavePromptVisible, updateSavePromptLogin, getPersistedSavePromptState, restoreSavePromptFromState, restoreAddUrlPromptFromState } from '@/entrypoints/contentScript/SavePrompt'; import { initializeWebAuthnInterceptor } from '@/entrypoints/contentScript/WebAuthnInterceptor'; +import { DEFAULT_POPUP_TYPE, isPopupType, popupTypeForFieldType, POPUP_TYPES, type PopupType } from '@/utils/autofill/PopupTypes'; import { FormDetector } from '@/utils/formDetector/FormDetector'; -import { DetectedFieldType } from '@/utils/formDetector/types/FormFields'; import { LocalPreferencesService } from '@/utils/LocalPreferencesService'; import { LoginDetector } from '@/utils/loginDetector'; import type { CapturedLogin, LastAutofilledCredential } from '@/utils/loginDetector'; @@ -24,6 +24,29 @@ import { defineContentScript, createShadowRootUi, storage } from '#imports'; /** Global login detector instance */ let loginDetector: LoginDetector | null = null; +/** + * Content-side runtime for each popup type: how to open the popup and whether + * its feature toggle is enabled. Keyed by the shared {@link PopupType} so the + * compiler enforces that every popup type has an opener + toggle wired up. + * + * Add a new entry here when adding a new popup type to {@link POPUP_TYPES}. + */ +const POPUP_RUNTIME: Record void; + enabled: () => Promise; +}> = { + credentials: { + open: openAutofillPopup, + /** Resolves true when the user has the credential autofill popup enabled. */ + enabled: () => LocalPreferencesService.getGlobalAutofillPopupEnabled(), + }, + totp: { + open: openTotpPopup, + /** Resolves true when the user has the TOTP autofill popup enabled. */ + enabled: () => LocalPreferencesService.getTotpAutofillEnabled(), + }, +}; + /** * Handle save login request from the save prompt. * Sends the captured credentials to the background script to save to the vault. @@ -520,7 +543,8 @@ export default defineContentScript({ } // Check if we should show autofill UI for this field type - if (!await shouldShowAutofillUi(detectedFieldType)) { + const popupType = popupTypeForFieldType(detectedFieldType); + if (!await POPUP_RUNTIME[popupType].enabled()) { return; } @@ -531,7 +555,7 @@ export default defineContentScript({ // Only show popup if debounce time has passed if (popupDebounceTimeHasPassed()) { - await showPopupWithAuthCheck(inputElement, container, detectedFieldType); + await showPopupWithAuthCheck(inputElement, container, popupType); } } }; @@ -561,9 +585,9 @@ export default defineContentScript({ void checkAndRestorePersistedSavePrompt(container); // Listen for messages from the background script - onMessage('OPEN_AUTOFILL_POPUP', async (message: { data: { elementIdentifier: string } }) : Promise => { + onMessage('OPEN_AUTOFILL_POPUP', async (message: { data: { elementIdentifier: string; popupType?: string } }) : Promise => { const { data } = message; - const { elementIdentifier } = data; + const { elementIdentifier, popupType } = data; if (!elementIdentifier) { return { success: false, error: 'No element identifier provided' }; @@ -571,6 +595,16 @@ export default defineContentScript({ const target = document.getElementById(elementIdentifier) ?? document.getElementsByName(elementIdentifier)[0]; + if (isPopupType(popupType)) { + const { isValid, inputElement } = validateInputField(target); + if (!isValid || !inputElement) { + return { success: false, error: 'Invalid input element' }; + } + injectIcon(inputElement, container, POPUP_TYPES[popupType].fieldType); + await showPopupWithAuthCheck(inputElement, container, popupType, true); + return { success: true }; + } + await showPopupForElement(target, true); return { success: true }; @@ -597,23 +631,6 @@ export default defineContentScript({ return true; } - /** - * Check if we should show autofill UI (icon/popup) for a given field type. - * Checks the appropriate toggle setting based on field type. - * - * @param fieldType - The detected field type - * @returns True if we should show UI for this field type, false otherwise - */ - async function shouldShowAutofillUi(fieldType: DetectedFieldType | null): Promise { - // For TOTP fields, check TOTP autofill toggle - if (fieldType === DetectedFieldType.Totp) { - return await LocalPreferencesService.getTotpAutofillEnabled(); - } - - // For credential fields, check credential autofill toggle - return await LocalPreferencesService.getGlobalAutofillPopupEnabled(); - } - /** * Show popup for element. */ @@ -630,6 +647,7 @@ export default defineContentScript({ } const detectedFieldType = formDetector.getDetectedFieldType(); + const popupType = popupTypeForFieldType(detectedFieldType); /** * By default we check if the site allows autofill and if the field is autofill-triggerable @@ -638,13 +656,13 @@ export default defineContentScript({ const canShowPopup = forceShow || (await isSiteAllowed() && formDetector.isAutofillTriggerableField()); if (canShowPopup) { - // Check field-type-specific settings (credential vs TOTP toggles) - if (!await shouldShowAutofillUi(detectedFieldType)) { + // Check the per-popup-type feature toggle (credential vs TOTP, etc.) + if (!await POPUP_RUNTIME[popupType].enabled()) { return; } injectIcon(inputElement, container, detectedFieldType ?? undefined); - await showPopupWithAuthCheck(inputElement, container, detectedFieldType ?? undefined); + await showPopupWithAuthCheck(inputElement, container, popupType); } } @@ -652,9 +670,10 @@ export default defineContentScript({ * Show popup with auth check. * @param inputElement - The input element to show the popup for. * @param container - The container element. - * @param fieldType - The detected field type (optional, defaults to regular autofill). + * @param popupType - Which popup to open (defaults to credentials). + * @param forceShow - When true, bypass the popup's feature toggle (e.g. for explicit context menu actions). */ - async function showPopupWithAuthCheck(inputElement: HTMLInputElement, container: HTMLElement, fieldType?: DetectedFieldType) : Promise { + async function showPopupWithAuthCheck(inputElement: HTMLInputElement, container: HTMLElement, popupType: PopupType = DEFAULT_POPUP_TYPE, forceShow: boolean = false) : Promise { try { // Check auth status and pending migrations in a single call const { sendMessage } = await import('webext-bridge/content-script'); @@ -691,21 +710,19 @@ export default defineContentScript({ return; } - // Show appropriate popup based on field type - if (fieldType === DetectedFieldType.Totp) { - // Check if TOTP autofill is enabled - const totpAutofillEnabled = await LocalPreferencesService.getTotpAutofillEnabled(); - if (totpAutofillEnabled) { - openTotpPopup(inputElement, container); - } - // If disabled, don't show any popup (user can rely on clipboard auto-copy) - } else { - openAutofillPopup(inputElement, container); + /* + * Dispatch via the popup runtime registry. Feature toggle is re-checked + * here for defensive consistency; explicit context menu actions bypass it via forceShow. + */ + const runtime = POPUP_RUNTIME[popupType]; + if (forceShow || await runtime.enabled()) { + runtime.open(inputElement, container); } + // If disabled, don't show any popup (user can rely on clipboard auto-copy for TOTP) } catch (error) { console.error('[AliasVault] Error checking vault status:', error); // Fall back to normal autofill popup if check fails - openAutofillPopup(inputElement, container); + POPUP_RUNTIME[DEFAULT_POPUP_TYPE].open(inputElement, container); } } }, diff --git a/apps/browser-extension/src/i18n/locales/en.json b/apps/browser-extension/src/i18n/locales/en.json index 8dfc03a27..a3f25913e 100644 --- a/apps/browser-extension/src/i18n/locales/en.json +++ b/apps/browser-extension/src/i18n/locales/en.json @@ -165,7 +165,8 @@ "manualCredentialDescriptionDropdown": "Manual username and password", "failedToCreateIdentity": "Failed to create identity. Please try again.", "enterEmailAndOrUsername": "Enter email and/or username", - "autofillWithAliasVault": "Autofill with AliasVault", + "autofillWithAliasVault": "Autofill username/password", + "autofillTotp": "Autofill 2FA", "generateRandomPassword": "Generate random password (copy to clipboard)", "generateNewPassword": "Generate new password", "togglePasswordVisibility": "Toggle password visibility", diff --git a/apps/browser-extension/src/utils/autofill/PopupTypes.ts b/apps/browser-extension/src/utils/autofill/PopupTypes.ts new file mode 100644 index 000000000..795c8873d --- /dev/null +++ b/apps/browser-extension/src/utils/autofill/PopupTypes.ts @@ -0,0 +1,46 @@ +/** + * Autofill popup type registry. + */ + +import { DetectedFieldType } from '@/utils/formDetector/types/FormFields'; + +export type PopupTypeConfig = { + /** + * The DetectedFieldType this popup is responsible for. `undefined` means + * the default credentials popup, which handles any non-specialized field. + */ + fieldType: DetectedFieldType | undefined; + /** i18n key for the context-menu label. */ + titleKey: string; +}; + +export const POPUP_TYPES = { + credentials: { + fieldType: undefined, + titleKey: 'content.autofillWithAliasVault', + }, + totp: { + fieldType: DetectedFieldType.Totp, + titleKey: 'content.autofillTotp', + }, +} as const satisfies Record; + +export type PopupType = keyof typeof POPUP_TYPES; + +/** Default popup type when no detected field type matches a specialized popup. */ +export const DEFAULT_POPUP_TYPE: PopupType = 'credentials'; + +/** Runtime guard: narrow a wire-format string to a known popup type. */ +export function isPopupType(value: string | undefined): value is PopupType { + return value !== undefined && value in POPUP_TYPES; +} + +/** Reverse lookup: detected field type -> popup type (falls back to default). */ +export function popupTypeForFieldType(fieldType: DetectedFieldType | null | undefined): PopupType { + for (const [type, config] of Object.entries(POPUP_TYPES) as [PopupType, PopupTypeConfig][]) { + if (config.fieldType !== undefined && config.fieldType === fieldType) { + return type; + } + } + return DEFAULT_POPUP_TYPE; +}