Open matched credential in browser extension by default when opening popup (#1740)

This commit is contained in:
Leendert de Borst
2026-02-19 11:03:00 +01:00
committed by Leendert de Borst
parent 57e325f98e
commit 10a676447b
4 changed files with 174 additions and 30 deletions

View File

@@ -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<CurrentTabMatchResult | null>;
} => {
/**
* Match vault items against the current browser tab.
*
* @returns Promise resolving to match result, or null if matching fails
*/
const matchCurrentTab = useCallback(async (): Promise<CurrentTabMatchResult | null> => {
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;

View File

@@ -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<ServiceDetectionResult>;

View File

@@ -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<void> => {
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<void> => {
// 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<string>,
storage.getItem(LAST_VISITED_TIME_KEY) as Promise<number>,
@@ -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(() => {
/**

View File

@@ -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<Item[]>([]);
const [searchTerm, setSearchTerm] = useState('');
// Initialize searchTerm from URL query parameter if present
const [searchTerm, setSearchTerm] = useState(() => searchParams.get('search') || '');
const [filterType, setFilterType] = useState<FilterType>(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).
*/