Add context menu autofill 2FA option (#2027)

This commit is contained in:
Leendert de Borst
2026-05-20 13:10:06 +02:00
committed by Leendert de Borst
parent 573f00c66d
commit fbd8a61587
4 changed files with 134 additions and 47 deletions

View File

@@ -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<void> {
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<void> {
});
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}`);
}
});
}

View File

@@ -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<PopupType, {
open: (input: HTMLInputElement, container: HTMLElement) => void;
enabled: () => Promise<boolean>;
}> = {
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<messageBoolResponse> => {
onMessage('OPEN_AUTOFILL_POPUP', async (message: { data: { elementIdentifier: string; popupType?: string } }) : Promise<messageBoolResponse> => {
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<boolean> {
// 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<void> {
async function showPopupWithAuthCheck(inputElement: HTMLInputElement, container: HTMLElement, popupType: PopupType = DEFAULT_POPUP_TYPE, forceShow: boolean = false) : Promise<void> {
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);
}
}
},

View File

@@ -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",

View File

@@ -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<string, PopupTypeConfig>;
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;
}