From 10a676447bc47310791bc2b436663e831b8bca28 Mon Sep 17 00:00:00 2001 From: Leendert de Borst Date: Thu, 19 Feb 2026 11:03:00 +0100 Subject: [PATCH] Open matched credential in browser extension by default when opening popup (#1740) --- .../popup/hooks/useCurrentTabMatching.ts | 89 +++++++++++++++++++ .../popup/hooks/useServiceDetection.ts | 14 --- .../entrypoints/popup/pages/Reinitialize.tsx | 84 ++++++++++++++--- .../popup/pages/items/ItemsList.tsx | 17 +++- 4 files changed, 174 insertions(+), 30 deletions(-) create mode 100644 apps/browser-extension/src/entrypoints/popup/hooks/useCurrentTabMatching.ts diff --git a/apps/browser-extension/src/entrypoints/popup/hooks/useCurrentTabMatching.ts b/apps/browser-extension/src/entrypoints/popup/hooks/useCurrentTabMatching.ts new file mode 100644 index 000000000..cf9eb25c8 --- /dev/null +++ b/apps/browser-extension/src/entrypoints/popup/hooks/useCurrentTabMatching.ts @@ -0,0 +1,89 @@ +import { useCallback } from 'react'; +import { sendMessage } from 'webext-bridge/popup'; + +import type { Item } from '@/utils/dist/core/models/vault'; +import { LocalPreferencesService } from '@/utils/LocalPreferencesService'; +import type { ItemsResponse } from '@/utils/types/messaging/ItemsResponse'; + +import { browser } from '#imports'; + +/** + * Result of current tab matching. + */ +export type CurrentTabMatchResult = { + /** Matched items for the current tab */ + items: Item[]; + /** Current tab URL (for prefilling search) */ + currentUrl: string; + /** Current tab domain (for display/search) */ + domain: string; +}; + +/** + * 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; +} => { + /** + * Match vault items against the current browser tab. + * + * @returns Promise resolving to match result, or null if matching fails + */ + const matchCurrentTab = useCallback(async (): Promise => { + try { + // Get the current active tab + const [activeTab] = await browser.tabs.query({ active: true, currentWindow: true }); + + if (!activeTab?.url) { + return null; + } + + // Skip non-http(s) URLs (like chrome://, about:, etc.) + if (!activeTab.url.startsWith('http://') && !activeTab.url.startsWith('https://')) { + return null; + } + + // Extract domain for search prefill + let domain = ''; + try { + const url = new URL(activeTab.url); + domain = url.hostname.replace(/^www\./, ''); + } catch { + return null; + } + + // Get autofill matching mode from user settings + const matchingMode = await LocalPreferencesService.getAutofillMatchingMode(); + + // Use the same filtering logic as content script + const response = await sendMessage('GET_FILTERED_ITEMS', { + currentUrl: activeTab.url, + pageTitle: activeTab.title || '', + matchingMode: matchingMode + }, 'background') as ItemsResponse; + + if (!response.success || !response.items) { + return null; + } + + return { + items: response.items, + currentUrl: activeTab.url, + domain: domain + }; + } catch (error) { + console.error('Error matching current tab:', error); + return null; + } + }, []); + + return { + matchCurrentTab, + }; +}; + +export default useCurrentTabMatching; diff --git a/apps/browser-extension/src/entrypoints/popup/hooks/useServiceDetection.ts b/apps/browser-extension/src/entrypoints/popup/hooks/useServiceDetection.ts index 806f76410..6bd2a4624 100644 --- a/apps/browser-extension/src/entrypoints/popup/hooks/useServiceDetection.ts +++ b/apps/browser-extension/src/entrypoints/popup/hooks/useServiceDetection.ts @@ -21,20 +21,6 @@ type ServiceDetectionResult = { * Service detection sources (in priority order): * 1. URL parameters (serviceName, serviceUrl, currentUrl) - e.g., from content script popout * 2. Active browser tab - for dashboard/popup opened directly - * - * @example - * ```tsx - * const { detectService } = useServiceDetection(); - * - * useEffect(() => { - * const init = async () => { - * const { serviceName, serviceUrl } = await detectService(itemNameParam); - * setItem({ ...item, Name: serviceName }); - * setFieldValues(prev => ({ ...prev, 'login.url': serviceUrl })); - * }; - * init(); - * }, []); - * ``` */ const useServiceDetection = (): { detectService: (fallbackName?: string | null) => Promise; diff --git a/apps/browser-extension/src/entrypoints/popup/pages/Reinitialize.tsx b/apps/browser-extension/src/entrypoints/popup/pages/Reinitialize.tsx index 948b7e05d..238fec88e 100644 --- a/apps/browser-extension/src/entrypoints/popup/pages/Reinitialize.tsx +++ b/apps/browser-extension/src/entrypoints/popup/pages/Reinitialize.tsx @@ -5,6 +5,7 @@ import { sendMessage } from 'webext-bridge/popup'; import { useApp } from '@/entrypoints/popup/context/AppContext'; import { useDb } from '@/entrypoints/popup/context/DbContext'; import { useLoading } from '@/entrypoints/popup/context/LoadingContext'; +import useCurrentTabMatching from '@/entrypoints/popup/hooks/useCurrentTabMatching'; import { consumePendingRedirectUrl } from '@/entrypoints/popup/hooks/useVaultLockRedirect'; import { useVaultSync } from '@/entrypoints/popup/hooks/useVaultSync'; @@ -29,6 +30,7 @@ const Reinitialize: React.FC = () => { const navigate = useNavigate(); const { setIsInitialLoading } = useLoading(); const { syncVault } = useVaultSync(); + const { matchCurrentTab } = useCurrentTabMatching(); const hasInitialized = useRef(false); // Auth and DB state @@ -39,10 +41,49 @@ const Reinitialize: React.FC = () => { const isFullyInitialized = authInitialized && dbInitialized; const requiresAuth = isFullyInitialized && (!isLoggedIn || !dbAvailable); + /** + * Get the expected navigation path based on URL matching result. + */ + const getMatchedPath = useCallback((matchResult: { items: { Id: string }[]; domain: string } | null): string => { + if (matchResult && matchResult.items.length === 1) { + return `/items/${matchResult.items[0].Id}`; + } else if (matchResult && matchResult.items.length > 1) { + /* + * For multiple matches, we navigate to /items with search param, + * but the saved lastPage won't have the param, so we just check against /items + */ + return '/items'; + } else { + return '/items'; + } + }, []); + + /** + * Navigate based on URL matching for the current tab. + */ + const navigateWithUrlMatching = useCallback(async (matchResult: { items: { Id: string }[]; domain: string } | null): Promise => { + if (matchResult && matchResult.items.length === 1) { + // Single match - navigate to items first, then to the item (for back button support) + 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 + navigate(`/items?search=${encodeURIComponent(matchResult.domain)}`, { replace: true }); + } else { + // No matches or matching failed - navigate to items page as default + navigate('/items', { replace: true }); + } + }, [navigate]); + /** * Restore the last visited page and navigation history if it was visited within the memory duration. + * Compares with URL matching result - if user navigated away from matched page, restore their navigation. */ const restoreLastPage = useCallback(async (): Promise => { + // First, run URL matching to see what we would auto-navigate to + const matchResult = await matchCurrentTab(); + const matchedPath = getMatchedPath(matchResult); + const [lastPage, lastVisitTime, savedHistory] = await Promise.all([ storage.getItem(LAST_VISITED_PAGE_KEY) as Promise, storage.getItem(LAST_VISITED_TIME_KEY) as Promise, @@ -52,21 +93,36 @@ const Reinitialize: React.FC = () => { if (lastPage && lastVisitTime) { const timeSinceLastVisit = Date.now() - lastVisitTime; if (timeSinceLastVisit <= PAGE_MEMORY_DURATION) { - // For nested routes, build up the navigation history properly - if (savedHistory?.length > 1) { - // Navigate to the base route first - navigate(savedHistory[0].pathname, { replace: true }); - // Then navigate to the final destination - navigate(lastPage, { replace: false }); - } else { - // Simple navigation for non-nested routes - navigate(lastPage, { replace: true }); + /* + * Check if user navigated away from the auto-matched page to a specific different page. + * Use fresh URL matching if: + * - lastPage matches what URL matching would show (user stayed on auto-matched page) + * - lastPage is /items (default index page - treat as "home" state) + * + * Restore user's navigation only if they navigated to a specific different page like: + * - Settings, add/edit forms, a different item, folder view, etc. + */ + const isOnMatchedPage = lastPage === matchedPath; + const isOnDefaultIndexPage = lastPage === '/items'; + const shouldUseFreshMatch = isOnMatchedPage || isOnDefaultIndexPage; + + if (!shouldUseFreshMatch) { + // Restore user's navigation since they navigated away from auto-matched page + if (savedHistory?.length > 1) { + // Navigate to the base route first + navigate(savedHistory[0].pathname, { replace: true }); + // Then navigate to the final destination + navigate(lastPage, { replace: false }); + } else { + // Simple navigation for non-nested routes + navigate(lastPage, { replace: true }); + } + return; } - return; } } - // Duration has expired, clear all stored navigation data + // Clear stored navigation data since we're using fresh URL matching await Promise.all([ storage.removeItem(LAST_VISITED_PAGE_KEY), storage.removeItem(LAST_VISITED_TIME_KEY), @@ -74,9 +130,9 @@ const Reinitialize: React.FC = () => { sendMessage('CLEAR_PERSISTED_FORM_VALUES', null, 'background'), ]); - // Navigate to the items page as default entry page - navigate('/items', { replace: true }); - }, [navigate]); + // Navigate based on URL matching + await navigateWithUrlMatching(matchResult); + }, [navigate, matchCurrentTab, getMatchedPath, navigateWithUrlMatching]); useEffect(() => { /** diff --git a/apps/browser-extension/src/entrypoints/popup/pages/items/ItemsList.tsx b/apps/browser-extension/src/entrypoints/popup/pages/items/ItemsList.tsx index c96e5f5d4..e3e638588 100644 --- a/apps/browser-extension/src/entrypoints/popup/pages/items/ItemsList.tsx +++ b/apps/browser-extension/src/entrypoints/popup/pages/items/ItemsList.tsx @@ -1,6 +1,6 @@ import React, { useState, useEffect, useCallback, useMemo } from 'react'; import { useTranslation } from 'react-i18next'; -import { useLocation, useNavigate, useParams } from 'react-router-dom'; +import { useLocation, useNavigate, useParams, useSearchParams } from 'react-router-dom'; import DeleteFolderModal from '@/entrypoints/popup/components/Folders/DeleteFolderModal'; import FolderModal from '@/entrypoints/popup/components/Folders/FolderModal'; @@ -110,6 +110,7 @@ const ItemsList: React.FC = () => { const { t } = useTranslation(); const { folderId: folderIdParam } = useParams<{ folderId?: string }>(); const location = useLocation(); + const [searchParams] = useSearchParams(); const dbContext = useDb(); const app = useApp(); const navigate = useNavigate(); @@ -117,7 +118,8 @@ const ItemsList: React.FC = () => { const { executeVaultMutationAsync } = useVaultMutate(); const { setHeaderButtons } = useHeaderButtons(); const [items, setItems] = useState([]); - const [searchTerm, setSearchTerm] = useState(''); + // Initialize searchTerm from URL query parameter if present + const [searchTerm, setSearchTerm] = useState(() => searchParams.get('search') || ''); const [filterType, setFilterType] = useState(getStoredFilter()); const [showFilterMenu, setShowFilterMenu] = useState(false); const [showFolderModal, setShowFolderModal] = useState(false); @@ -155,6 +157,17 @@ const ItemsList: React.FC = () => { */ const [isLoading, setIsLoading] = useMinDurationLoading(true, 100); + /** + * Clear search param from URL after reading it (to prevent it from persisting in browser history). + */ + useEffect(() => { + const searchFromUrl = searchParams.get('search'); + if (searchFromUrl) { + // Remove the search param from URL while preserving the current path + navigate(location.pathname, { replace: true }); + } + }, []); // eslint-disable-line react-hooks/exhaustive-deps -- Only run once on mount + /** * Reset search and filter when navigating via the vault tab (with resetFilters state). */