mirror of
https://github.com/aliasvault/aliasvault.git
synced 2026-06-08 07:37:12 -04:00
Add context menu autofill 2FA option (#2027)
This commit is contained in:
committed by
Leendert de Borst
parent
573f00c66d
commit
fbd8a61587
@@ -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}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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",
|
||||
|
||||
46
apps/browser-extension/src/utils/autofill/PopupTypes.ts
Normal file
46
apps/browser-extension/src/utils/autofill/PopupTypes.ts
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user