mirror of
https://github.com/aliasvault/aliasvault.git
synced 2026-03-29 20:12:32 -04:00
Add recently selected item logic to browser extension to make autofilling multi-step forms easier (#1756)
This commit is contained in:
@@ -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! }));
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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]);
|
||||
|
||||
106
apps/browser-extension/src/utils/RecentlySelectedItemService.ts
Normal file
106
apps/browser-extension/src/utils/RecentlySelectedItemService.ts
Normal 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;
|
||||
},
|
||||
};
|
||||
Reference in New Issue
Block a user