Add recently selected item logic to browser extension to make autofilling multi-step forms easier (#1756)

This commit is contained in:
Leendert de Borst
2026-02-20 16:07:01 +01:00
parent 3f961f26af
commit 32bc1afc0e
7 changed files with 271 additions and 30 deletions

View File

@@ -11,7 +11,7 @@ import { handleGetWebAuthnSettings, handleWebAuthnCreate, handleWebAuthnGet, han
import { handleOpenPopup, handlePopupWithItem, handleOpenPopupCreateCredential, handleToggleContextMenu } from '@/entrypoints/background/PopupMessageHandler';
import { handleStoreSavePromptState, handleGetSavePromptState, handleClearSavePromptState, handleStoreLastAutofilled, handleGetLastAutofilled, handleClearLastAutofilled } from '@/entrypoints/background/SavePromptStateHandler';
import { handleStoreTwoFactorState, handleGetTwoFactorState, handleClearTwoFactorState } from '@/entrypoints/background/TwoFactorStateHandler';
import { handleCheckAuthStatus, handleClearPersistedFormValues, handleClearSession, handleClearVaultData, handleLockVault, handleCreateItem, handleGetFilteredItems, handleGetSearchItems, handleGetDefaultEmailDomain, handleGetDefaultIdentitySettings, handleGetEncryptionKey, handleGetEncryptionKeyDerivationParams, handleGetPasswordSettings, handleGetPersistedFormValues, handleGetVault, handlePersistFormValues, handleStoreEncryptionKey, handleStoreEncryptionKeyDerivationParams, handleStoreVaultMetadata, handleSyncVault, handleUploadVault, handleGetEncryptedVault, handleStoreEncryptedVault, handleGetSyncState, handleMarkVaultClean, handleGetServerRevision, handleCheckSyncStatus, handleFullVaultSync, handleCheckLoginDuplicate, handleSaveLoginCredential, handleAddUrlToCredential, handleGetLoginSaveSettings, handleSetLoginSaveEnabled, handleGetItemsWithTotp, handleSearchItemsWithTotp, handleGetTotpSecrets, handleGenerateTotpCode } from '@/entrypoints/background/VaultMessageHandler';
import { handleCheckAuthStatus, handleClearPersistedFormValues, handleClearSession, handleClearVaultData, handleLockVault, handleCreateItem, handleGetFilteredItems, handleGetSearchItems, handleGetDefaultEmailDomain, handleGetDefaultIdentitySettings, handleGetEncryptionKey, handleGetEncryptionKeyDerivationParams, handleGetPasswordSettings, handleGetPersistedFormValues, handleGetVault, handlePersistFormValues, handleStoreEncryptionKey, handleStoreEncryptionKeyDerivationParams, handleStoreVaultMetadata, handleSyncVault, handleUploadVault, handleGetEncryptedVault, handleStoreEncryptedVault, handleGetSyncState, handleMarkVaultClean, handleGetServerRevision, handleCheckSyncStatus, handleFullVaultSync, handleCheckLoginDuplicate, handleSaveLoginCredential, handleAddUrlToCredential, handleGetLoginSaveSettings, handleSetLoginSaveEnabled, handleGetItemsWithTotp, handleSearchItemsWithTotp, handleGetTotpSecrets, handleGenerateTotpCode, handleSetRecentlySelected, handleGetRecentlySelected } from '@/entrypoints/background/VaultMessageHandler';
import { EncryptionKeyDerivationParams } from "@/utils/dist/core/models/metadata";
import type { LoginResponse } from "@/utils/dist/core/models/webapi";
@@ -31,7 +31,7 @@ export default defineBackground({
onMessage('GET_ENCRYPTION_KEY', () => handleGetEncryptionKey());
onMessage('GET_ENCRYPTION_KEY_DERIVATION_PARAMS', () => handleGetEncryptionKeyDerivationParams());
onMessage('GET_VAULT', () => handleGetVault());
onMessage('GET_FILTERED_ITEMS', ({ data }) => handleGetFilteredItems(data as { currentUrl: string, pageTitle: string, matchingMode?: string }));
onMessage('GET_FILTERED_ITEMS', ({ data }) => handleGetFilteredItems(data as { currentUrl: string, pageTitle: string, matchingMode?: string, includeRecentlySelected?: boolean }));
onMessage('GET_SEARCH_ITEMS', ({ data }) => handleGetSearchItems(data as { searchTerm: string }));
onMessage('GET_DEFAULT_EMAIL_DOMAIN', () => handleGetDefaultEmailDomain());
@@ -79,6 +79,10 @@ export default defineBackground({
onMessage('GET_TOTP_SECRETS', ({ data }) => handleGetTotpSecrets(data as { itemIds: string[] }));
onMessage('GENERATE_TOTP_CODE', ({ data }) => handleGenerateTotpCode(data as { itemId: string }));
// Track recently selected items for autofill prioritization
onMessage('SET_RECENTLY_SELECTED', ({ data }) => handleSetRecentlySelected(data as { itemId: string; domain: string }));
onMessage('GET_RECENTLY_SELECTED', ({ data }) => handleGetRecentlySelected(data as { domain: string }));
// Remember login save state (for surviving page navigation)
onMessage('STORE_SAVE_PROMPT_STATE', ({ data, sender }) => handleStoreSavePromptState({ tabId: sender.tabId!, state: data as SavePromptPersistedState }));
onMessage('GET_SAVE_PROMPT_STATE', ({ sender }) => handleGetSavePromptState({ tabId: sender.tabId! }));

View File

@@ -8,6 +8,7 @@ import type { Vault, VaultResponse, VaultPostResponse } from '@/utils/dist/core/
import { EncryptionUtility } from '@/utils/EncryptionUtility';
import { filterItems, AutofillMatchingMode } from '@/utils/itemMatcher/ItemMatcher';
import { LocalPreferencesService } from '@/utils/LocalPreferencesService';
import { RecentlySelectedItemService } from '@/utils/RecentlySelectedItemService';
import { SqliteClient } from '@/utils/SqliteClient';
import { getItemWithFallback } from '@/utils/StorageUtility';
import { ApiAuthError } from '@/utils/types/errors/ApiAuthError';
@@ -359,8 +360,68 @@ function filterItemsByUrl(items: Item[], currentUrl: string, pageTitle: string,
return filterItems(items, currentUrl, pageTitle, matchingMode);
}
/**
* Prioritize recently selected item in the filtered items list.
* If a recently selected item exists and is valid, ensure it's at the front of the array.
* If the item is not in the filtered results, fetch it from the vault and add it.
*
* @param items - The filtered items array
* @param domain - The current domain for recently selected item validation
* @param allItems - All items from the vault (to fetch recently selected if not in filtered)
* @returns The items array with recently selected item prioritized
*/
async function prioritizeRecentlySelectedItem(items: Item[], domain: string, allItems: Item[]): Promise<Item[]> {
const recentlySelectedId = await RecentlySelectedItemService.getRecentlySelected(domain);
if (!recentlySelectedId) {
return items;
}
// Find the recently selected item in the filtered results
const recentlySelectedIndex = items.findIndex(item => item.Id === recentlySelectedId);
if (recentlySelectedIndex !== -1) {
// Item is already in filtered results - move it to the front
const recentlySelectedItem = items[recentlySelectedIndex];
const reorderedItems = [
recentlySelectedItem,
...items.slice(0, recentlySelectedIndex),
...items.slice(recentlySelectedIndex + 1)
];
return reorderedItems;
}
// Item is not in filtered results - fetch it from all items and prepend it
const recentlySelectedItem = allItems.find(item => item.Id === recentlySelectedId);
if (!recentlySelectedItem) {
// Item not found in vault (might have been deleted)
return items;
}
// Prepend the recently selected item to the filtered results
return [recentlySelectedItem, ...items];
}
/**
* Extract domain from URL for recently selected item scoping.
* @param url - The full URL
* @returns The domain or the original URL if parsing fails
*/
function extractDomain(url: string): string {
try {
const urlObj = new URL(url);
return urlObj.hostname;
} catch {
// If URL parsing fails, return the original URL
return url;
}
}
/**
* Filter items by search term.
* Splits search into words and matches items where ALL words appear in searchable fields.
* Word order doesn't matter - matching behavior consistent with popup search.
*
* @param items - The items to filter
* @param searchTerm - The search term to use
@@ -371,7 +432,10 @@ function filterItemsBySearchTerm(items: Item[], searchTerm: string): Item[] {
return [];
}
const normalizedTerm = searchTerm.toLowerCase().trim();
const searchLower = searchTerm.toLowerCase().trim();
// Split search query into individual words (same as popup search)
const searchWords = searchLower.split(/\s+/).filter(word => word.length > 0);
const searchableFieldKeys = [
FieldKey.LoginUsername,
@@ -382,19 +446,24 @@ function filterItemsBySearchTerm(items: Item[], searchTerm: string): Item[] {
];
return items.filter((item: Item) => {
// Search in item name
if (item.Name?.toLowerCase().includes(normalizedTerm)) {
return true;
}
// Build searchable fields array
const searchableFields: string[] = [
item.Name?.toLowerCase() || ''
];
// Search in field values
return item.Fields?.some((field: { FieldKey: string; Value: string | string[] }) => {
// Add field values to searchable fields
item.Fields?.forEach((field: { FieldKey: string; Value: string | string[]; Label: string }) => {
if ((searchableFieldKeys as string[]).includes(field.FieldKey)) {
const value = Array.isArray(field.Value) ? field.Value.join(' ') : field.Value;
return value?.toLowerCase().includes(normalizedTerm);
searchableFields.push(value?.toLowerCase() || '');
searchableFields.push(field.Label.toLowerCase());
}
return false;
});
// Every word must appear in at least one searchable field (order doesn't matter)
return searchWords.every(word =>
searchableFields.some(field => field.includes(word))
);
}).sort((a: Item, b: Item) => (a.Name ?? '').localeCompare(b.Name ?? ''));
}
@@ -402,10 +471,10 @@ function filterItemsBySearchTerm(items: Item[], searchTerm: string): Item[] {
* Get items filtered by URL matching (for autofill).
* Filters items in the background script before sending to reduce message payload size.
*
* @param message - Filtering parameters: currentUrl, pageTitle, matchingMode
* @param message - Filtering parameters: currentUrl, pageTitle, matchingMode, skipRecentlySelected
*/
export async function handleGetFilteredItems(
message: { currentUrl: string, pageTitle: string, matchingMode?: string }
message: { currentUrl: string, pageTitle: string, matchingMode?: string, includeRecentlySelected?: boolean }
) : Promise<messageItemsResponse> {
const encryptionKey = await handleGetEncryptionKey();
@@ -419,7 +488,14 @@ export async function handleGetFilteredItems(
const allItems = sqliteClient.items.getAll();
const filteredItems = await filterItemsByUrl(allItems, message.currentUrl, message.pageTitle, message.matchingMode);
return { success: true, items: filteredItems };
// Prioritize recently selected item for multi-step login flows (opt-in only)
let prioritizedItems = filteredItems;
if (message.includeRecentlySelected) {
const domain = extractDomain(message.currentUrl);
prioritizedItems = await prioritizeRecentlySelectedItem(filteredItems, domain, allItems);
}
return { success: true, items: prioritizedItems };
} catch (error) {
console.error('Error getting filtered items:', error);
// E-304: Item read failed
@@ -1672,7 +1748,11 @@ export async function handleGetItemsWithTotp(
// Then filter by URL matching using shared logic
const filteredItems = await filterItemsByUrl(itemsWithTotp, message.currentUrl, message.pageTitle, message.matchingMode);
return { success: true, items: filteredItems };
// Prioritize recently selected item for multi-step login flows
const domain = extractDomain(message.currentUrl);
const prioritizedItems = await prioritizeRecentlySelectedItem(filteredItems, domain, itemsWithTotp);
return { success: true, items: prioritizedItems };
} catch (error) {
console.error('Error getting items with TOTP:', error);
return { success: false, error: formatErrorWithCode(await t('common.errors.unknownError'), AppErrorCode.ITEM_READ_FAILED) };
@@ -1780,3 +1860,33 @@ export async function handleGenerateTotpCode(
return { success: false, error: formatErrorWithCode(await t('common.errors.unknownError'), AppErrorCode.ITEM_READ_FAILED) };
}
}
/**
* Set recently selected item for smart autofill.
*/
export async function handleSetRecentlySelected(
message: { itemId: string; domain: string }
): Promise<{ success: boolean }> {
try {
await RecentlySelectedItemService.setRecentlySelected(message.itemId, message.domain);
return { success: true };
} catch (error) {
console.error('Error setting recently selected item:', error);
return { success: false };
}
}
/**
* Get recently selected item for smart autofill.
*/
export async function handleGetRecentlySelected(
message: { domain: string }
): Promise<{ success: boolean; itemId?: string | null }> {
try {
const itemId = await RecentlySelectedItemService.getRecentlySelected(message.domain);
return { success: true, itemId };
} catch (error) {
console.error('Error getting recently selected item:', error);
return { success: false, itemId: null };
}
}

View File

@@ -133,6 +133,11 @@ export async function fillItem(item: Item, input: HTMLInputElement): Promise<voi
sendMessage('STORE_LAST_AUTOFILLED', lastAutofilled, 'background').catch(() => {
// Ignore errors as background script might not be ready
});
// Store recently selected item for smart autofill prioritization
sendMessage('SET_RECENTLY_SELECTED', { itemId: item.Id, domain: window.location.hostname }, 'background').catch(() => {
// Ignore errors as background script might not be ready
});
}
/**
@@ -324,6 +329,11 @@ export async function fillTotpCode(itemId: string, input: HTMLInputElement): Pro
// Trigger input events for form validation
triggerInputEvents(input);
// Store recently selected item for smart autofill prioritization
sendMessage('SET_RECENTLY_SELECTED', { itemId, domain: window.location.hostname }, 'background').catch(() => {
// Ignore errors as background script might not be ready
});
}
/**

View File

@@ -133,7 +133,8 @@ export function openAutofillPopup(input: HTMLInputElement, container: HTMLElemen
const response = await sendMessage('GET_FILTERED_ITEMS', {
currentUrl: window.location.href,
pageTitle: document.title,
matchingMode: matchingMode
matchingMode: matchingMode,
includeRecentlySelected: true // Enable for multi-step login autofill
}, 'background') as ItemsResponse;
if (response.success) {
@@ -655,7 +656,7 @@ export function createLoadingPopup(input: HTMLInputElement, message: string, roo
* @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 function updatePopupContent(items: Item[], itemList: HTMLElement | null, input: HTMLInputElement, rootContainer: HTMLElement, noMatchesText?: string) : void {
export async function updatePopupContent(items: Item[], itemList: HTMLElement | null, input: HTMLInputElement, rootContainer: HTMLElement, noMatchesText?: string) : Promise<void> {
if (!itemList) {
itemList = document.getElementById('aliasvault-credential-list') as HTMLElement;
}
@@ -721,7 +722,7 @@ export async function createAutofillPopup(input: HTMLInputElement, items: Item[]
items = [];
}
updatePopupContent(items, credentialList, input, rootContainer, noMatchesText);
await updatePopupContent(items, credentialList, input, rootContainer, noMatchesText);
// Add divider
const divider = document.createElement('div');
@@ -1118,7 +1119,7 @@ async function handleSearchInput(searchInput: HTMLInputElement, initialItems: It
if (searchTerm === '') {
// If search is empty, show the initially URL-filtered items
updatePopupContent(initialItems, itemList, input, rootContainer, noMatchesText);
await updatePopupContent(initialItems, itemList, input, rootContainer, noMatchesText);
} else {
// Search in full vault with search term
const response = await sendMessage('GET_SEARCH_ITEMS', {
@@ -1126,10 +1127,10 @@ async function handleSearchInput(searchInput: HTMLInputElement, initialItems: It
}, 'background') as ItemsResponse;
if (response.success && response.items) {
updatePopupContent(response.items, itemList, input, rootContainer, noMatchesText);
await updatePopupContent(response.items, itemList, input, rootContainer, noMatchesText);
} else {
// On error, fallback to showing initial filtered items
updatePopupContent(initialItems, itemList, input, rootContainer, noMatchesText);
await updatePopupContent(initialItems, itemList, input, rootContainer, noMatchesText);
}
}
}
@@ -1144,7 +1145,7 @@ function createItemList(items: Item[], input: HTMLInputElement, rootContainer: H
const elements: HTMLElement[] = [];
if (items.length > 0) {
items.forEach(item => {
items.forEach((item) => {
const itemElement = document.createElement('div');
itemElement.className = 'av-credential-item';

View File

@@ -21,9 +21,6 @@ export type CurrentTabMatchResult = {
/**
* Hook for matching vault items against the current browser tab.
*
* Uses the same credential matching logic as the content script autofill popup,
* respecting user's autofill matching mode settings.
*/
const useCurrentTabMatching = (): {
matchCurrentTab: () => Promise<CurrentTabMatchResult | null>;
@@ -59,11 +56,12 @@ const useCurrentTabMatching = (): {
// Get autofill matching mode from user settings
const matchingMode = await LocalPreferencesService.getAutofillMatchingMode();
// Use the same filtering logic as content script
// Use the same filtering logic as content script (without recently selected prioritization)
const response = await sendMessage('GET_FILTERED_ITEMS', {
currentUrl: activeTab.url,
pageTitle: activeTab.title || '',
matchingMode: matchingMode
// includeRecentlySelected defaults to false (recently selected is for autofill only)
}, 'background') as ItemsResponse;
if (!response.success || !response.items) {

View File

@@ -14,6 +14,7 @@ import { storage } from '#imports';
const LAST_VISITED_PAGE_KEY = 'session:lastVisitedPage';
const LAST_VISITED_TIME_KEY = 'session:lastVisitedTime';
const NAVIGATION_HISTORY_KEY = 'session:navigationHistory';
const LAST_TAB_URL_KEY = 'session:lastTabUrl';
const PAGE_MEMORY_DURATION = 120 * 1000; // 2 minutes in milliseconds
type NavigationHistoryEntry = {
@@ -67,10 +68,10 @@ const Reinitialize: React.FC = () => {
navigate('/items', { replace: true });
navigate(`/items/${matchResult.items[0].Id}`, { replace: false });
} else if (matchResult && matchResult.items.length > 1) {
// Multiple matches - navigate to items list with domain search
// Multiple matches - navigate to items list with domain search to help user find the right one
navigate(`/items?search=${encodeURIComponent(matchResult.domain)}`, { replace: true });
} else {
// No matches or matching failed - navigate to items page as default
// No matches or matching failed - navigate to items page without search (don't prefill search when there are no matches)
navigate('/items', { replace: true });
}
}, [navigate]);
@@ -84,18 +85,24 @@ const Reinitialize: React.FC = () => {
const matchResult = await matchCurrentTab();
const matchedPath = getMatchedPath(matchResult);
const [lastPage, lastVisitTime, savedHistory] = await Promise.all([
const [lastPage, lastVisitTime, savedHistory, lastTabUrl] = await Promise.all([
storage.getItem(LAST_VISITED_PAGE_KEY) as Promise<string>,
storage.getItem(LAST_VISITED_TIME_KEY) as Promise<number>,
storage.getItem(NAVIGATION_HISTORY_KEY) as Promise<NavigationHistoryEntry[]>,
storage.getItem(LAST_TAB_URL_KEY) as Promise<string>,
]);
// Check if user switched to a different tab (different URL)
const currentTabUrl = matchResult?.currentUrl;
const hasTabChanged = currentTabUrl && lastTabUrl && currentTabUrl !== lastTabUrl;
if (lastPage && lastVisitTime) {
const timeSinceLastVisit = Date.now() - lastVisitTime;
if (timeSinceLastVisit <= PAGE_MEMORY_DURATION) {
/*
* Check if user navigated away from the auto-matched page to a specific different page.
* Use fresh URL matching if:
* - Tab URL has changed (user switched tabs)
* - lastPage matches what URL matching would show AND has no search query (user stayed on auto-matched page)
* - lastPage is /items with no search query (default index page - treat as "home" state)
*
@@ -107,7 +114,7 @@ const Reinitialize: React.FC = () => {
const hasSearchQuery = lastHistoryEntry?.search && lastHistoryEntry.search.length > 0;
const isOnMatchedPage = lastPage === matchedPath && !hasSearchQuery;
const isOnDefaultIndexPage = lastPage === '/items' && !hasSearchQuery;
const shouldUseFreshMatch = isOnMatchedPage || isOnDefaultIndexPage;
const shouldUseFreshMatch = hasTabChanged || isOnMatchedPage || isOnDefaultIndexPage;
if (!shouldUseFreshMatch) {
// Restore user's navigation since they navigated away from auto-matched page
@@ -137,6 +144,11 @@ const Reinitialize: React.FC = () => {
sendMessage('CLEAR_PERSISTED_FORM_VALUES', null, 'background'),
]);
// Save current tab URL for future tab-switch detection
if (currentTabUrl) {
await storage.setItem(LAST_TAB_URL_KEY, currentTabUrl);
}
// Navigate based on URL matching
await navigateWithUrlMatching(matchResult);
}, [navigate, matchCurrentTab, getMatchedPath, navigateWithUrlMatching]);

View File

@@ -0,0 +1,106 @@
import { storage } from '#imports';
/**
* Storage key for recently selected item.
* Uses session storage (memory-only, cleared on browser restart).
*/
const RECENTLY_SELECTED_KEY = 'session:aliasvault_recently_selected_item';
/**
* Time-to-live for recently selected items (60 seconds).
*/
const TTL_MS = 60 * 1000;
/**
* Interface for the recently selected item data.
*/
export interface IRecentlySelectedItem {
itemId: string;
timestamp: number;
domain: string;
}
/**
* Service for managing the recently selected autofill item.
* This enables "smart autofill" where the most recently selected credential
* is prioritized in subsequent autofill suggestions for multi-step login flows.
*
* The recently selected item is stored in session storage with a 60-second TTL
* and is scoped to the domain to prevent cross-site leakage.
*/
export const RecentlySelectedItemService = {
/**
* Store a recently selected item with the current timestamp.
* @param itemId - The ID of the item that was selected
* @param domain - The domain where the item was used (for scoping)
*/
async setRecentlySelected(itemId: string, domain: string): Promise<void> {
const data: IRecentlySelectedItem = {
itemId,
timestamp: Date.now(),
domain,
};
await storage.setItem(RECENTLY_SELECTED_KEY, data);
},
/**
* Get the recently selected item if it exists and is not expired.
* @param domain - The current domain to check against
* @returns The item ID if valid, or null if expired or not matching domain
*/
async getRecentlySelected(domain: string): Promise<string | null> {
const data = await storage.getItem(RECENTLY_SELECTED_KEY) as IRecentlySelectedItem | null;
if (!data) {
return null;
}
// Check if expired
const age = Date.now() - data.timestamp;
if (age > TTL_MS) {
await this.clear();
return null;
}
// Check if domain matches
if (data.domain !== domain) {
return null;
}
return data.itemId;
},
/**
* Check if a recently selected item exists and is valid for the current domain.
* @param domain - The current domain to check against
* @returns True if a valid recently selected item exists
*/
async hasRecentlySelected(domain: string): Promise<boolean> {
const itemId = await this.getRecentlySelected(domain);
return itemId !== null;
},
/**
* Clear the recently selected item.
*/
async clear(): Promise<void> {
await storage.removeItem(RECENTLY_SELECTED_KEY);
},
/**
* Get the remaining TTL in milliseconds for the currently selected item.
* @returns Remaining TTL in ms, or 0 if expired or not set
*/
async getRemainingTTL(): Promise<number> {
const data = await storage.getItem(RECENTLY_SELECTED_KEY) as IRecentlySelectedItem | null;
if (!data) {
return 0;
}
const age = Date.now() - data.timestamp;
const remaining = TTL_MS - age;
return remaining > 0 ? remaining : 0;
},
};