From ee6b40dd3db6ae58339fcf00fbbce0448608199f Mon Sep 17 00:00:00 2001 From: Leendert de Borst Date: Wed, 18 Jun 2025 12:37:02 +0200 Subject: [PATCH] Refactor navigation logic from Home.tsx to NavigationContext (#935) --- .../src/entrypoints/popup/App.tsx | 8 +- .../popup/context/NavigationContext.tsx | 155 +++++++++++++++--- .../popup/pages/CredentialAddEdit.tsx | 31 ++-- .../src/entrypoints/popup/pages/Home.tsx | 56 +------ 4 files changed, 163 insertions(+), 87 deletions(-) diff --git a/apps/browser-extension/src/entrypoints/popup/App.tsx b/apps/browser-extension/src/entrypoints/popup/App.tsx index b7b737b2d..24a94cfea 100644 --- a/apps/browser-extension/src/entrypoints/popup/App.tsx +++ b/apps/browser-extension/src/entrypoints/popup/App.tsx @@ -8,6 +8,7 @@ import LoadingSpinner from '@/entrypoints/popup/components/LoadingSpinner'; import { useAuth } from '@/entrypoints/popup/context/AuthContext'; import { useHeaderButtons } from '@/entrypoints/popup/context/HeaderButtonsContext'; import { useLoading } from '@/entrypoints/popup/context/LoadingContext'; +import { NavigationProvider } from '@/entrypoints/popup/context/NavigationContext'; import AuthSettings from '@/entrypoints/popup/pages/AuthSettings'; import CredentialAddEdit from '@/entrypoints/popup/pages/CredentialAddEdit'; import CredentialDetails from '@/entrypoints/popup/pages/CredentialDetails'; @@ -15,13 +16,15 @@ import CredentialsList from '@/entrypoints/popup/pages/CredentialsList'; import EmailDetails from '@/entrypoints/popup/pages/EmailDetails'; import EmailsList from '@/entrypoints/popup/pages/EmailsList'; import Home from '@/entrypoints/popup/pages/Home'; +import Login from '@/entrypoints/popup/pages/Login'; import Logout from '@/entrypoints/popup/pages/Logout'; import Settings from '@/entrypoints/popup/pages/Settings'; +import Unlock from '@/entrypoints/popup/pages/Unlock'; +import UnlockSuccess from '@/entrypoints/popup/pages/UnlockSuccess'; import { useMinDurationLoading } from '@/hooks/useMinDurationLoading'; import '@/entrypoints/popup/style.css'; -import { NavigationProvider } from './context/NavigationContext'; /** * Route configuration. @@ -46,6 +49,9 @@ const App: React.FC = () => { // Add these route configurations const routes: RouteConfig[] = [ { path: '/', element: , showBackButton: false }, + { path: '/login', element: , showBackButton: false }, + { path: '/unlock', element: , showBackButton: false }, + { path: '/unlock-success', element: window.location.search = ''} />, showBackButton: false }, { path: '/auth-settings', element: , showBackButton: true, title: 'Settings' }, { path: '/credentials', element: , showBackButton: false }, { path: '/credentials/add', element: , showBackButton: true, title: 'Add credential' }, diff --git a/apps/browser-extension/src/entrypoints/popup/context/NavigationContext.tsx b/apps/browser-extension/src/entrypoints/popup/context/NavigationContext.tsx index 977464116..7fb2a78de 100644 --- a/apps/browser-extension/src/entrypoints/popup/context/NavigationContext.tsx +++ b/apps/browser-extension/src/entrypoints/popup/context/NavigationContext.tsx @@ -2,56 +2,165 @@ import React, { createContext, useContext, useEffect, useState, useMemo, useCall import { useLocation, useNavigate } from 'react-router-dom'; import { sendMessage } from 'webext-bridge/popup'; +import { useAuth } from '@/entrypoints/popup/context/AuthContext'; +import { useDb } from '@/entrypoints/popup/context/DbContext'; +import { useLoading } from '@/entrypoints/popup/context/LoadingContext'; + 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 PAGE_MEMORY_DURATION = 120 * 1000; // 2 minutes in milliseconds +type NavigationHistoryEntry = { + pathname: string; + search: string; + hash: string; +}; + type NavigationContextType = { storeCurrentPage: () => Promise; restoreLastPage: () => Promise; + isFullyInitialized: boolean; + requiresAuth: boolean; }; const NavigationContext = createContext(undefined); /** - * Navigation provider component that handles storing and restoring the last visited page. + * Navigation provider component that handles storing and restoring the last visited page, + * as well as managing initialization and auth state redirects. */ export const NavigationProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => { const location = useLocation(); const navigate = useNavigate(); const [isInitialized, setIsInitialized] = useState(false); + const [isInlineUnlockMode, setIsInlineUnlockMode] = useState(false); + const { setIsInitialLoading } = useLoading(); + + // Auth and DB state + const { isInitialized: authInitialized, isLoggedIn } = useAuth(); + const { dbInitialized, dbAvailable } = useDb(); + + // Derived state + const isFullyInitialized = authInitialized && dbInitialized; + const requiresAuth = isFullyInitialized && (!isLoggedIn || !dbAvailable || isInlineUnlockMode); /** - * Store the current page path and timestamp in storage. + * Store the current page path, timestamp, and navigation history in storage. */ const storeCurrentPage = useCallback(async (): Promise => { - await storage.setItem(LAST_VISITED_PAGE_KEY, location.pathname); - await storage.setItem(LAST_VISITED_TIME_KEY, Date.now()); - }, [location.pathname]); + // Pages that are not allowed to be stored as these are auth conditional pages. + const notAllowedPaths = ['/', '/login', '/unlock', '/unlock-success', '/auth-settings']; + + // Only store the page if we're fully initialized and don't need auth + if (isFullyInitialized && !requiresAuth && !notAllowedPaths.includes(location.pathname)) { + // Get the current history entries from the session history + const historyEntries: NavigationHistoryEntry[] = []; + if (window.history.state?.usr?.history) { + historyEntries.push(...window.history.state.usr.history); + } + // Add current location if not already in history + const currentEntry = { + pathname: location.pathname, + search: location.search, + hash: location.hash, + }; + if (!historyEntries.some(entry => entry.pathname === currentEntry.pathname)) { + historyEntries.push(currentEntry); + } + + await Promise.all([ + storage.setItem(LAST_VISITED_PAGE_KEY, location.pathname), + storage.setItem(LAST_VISITED_TIME_KEY, Date.now()), + storage.setItem(NAVIGATION_HISTORY_KEY, historyEntries), + ]); + } + }, [location, isFullyInitialized, requiresAuth]); /** - * Restore the last visited page if it was visited within the memory duration. + * Restore the last visited page and navigation history if it was visited within the memory duration. */ const restoreLastPage = useCallback(async (): Promise => { - const lastPage = await storage.getItem(LAST_VISITED_PAGE_KEY) as string; - const lastVisitTime = await storage.getItem(LAST_VISITED_TIME_KEY) as number; + // Only restore if we're fully initialized and don't need auth + if (!isFullyInitialized || requiresAuth) { + return; + } + + const [lastPage, lastVisitTime, savedHistory] = await Promise.all([ + storage.getItem(LAST_VISITED_PAGE_KEY) as Promise, + storage.getItem(LAST_VISITED_TIME_KEY) as Promise, + storage.getItem(NAVIGATION_HISTORY_KEY) as Promise, + ]); if (lastPage && lastVisitTime) { const timeSinceLastVisit = Date.now() - lastVisitTime; if (timeSinceLastVisit <= PAGE_MEMORY_DURATION) { - navigate(lastPage); - } else { - // Duration has expired, clear the last visited page and time. - await storage.removeItem(LAST_VISITED_PAGE_KEY); - await storage.removeItem(LAST_VISITED_TIME_KEY); + // Restore the navigation history + if (savedHistory?.length) { + // First navigate to credentials page as the base + navigate('/credentials', { replace: true }); - // Clear persisted form values if they exist. - await sendMessage('CLEAR_PERSISTED_FORM_VALUES', null, 'background'); + // Then restore the history stack + for (const entry of savedHistory) { + navigate(entry.pathname + entry.search + entry.hash); + } + return; + } + + // Fallback to simple navigation if no history + navigate('/credentials', { replace: true }); + navigate(lastPage, { replace: true }); + return; } } - }, [navigate]); + + // Duration has expired, clear all stored navigation data + await Promise.all([ + storage.removeItem(LAST_VISITED_PAGE_KEY), + storage.removeItem(LAST_VISITED_TIME_KEY), + storage.removeItem(NAVIGATION_HISTORY_KEY), + sendMessage('CLEAR_PERSISTED_FORM_VALUES', null, 'background'), + ]); + + // Navigate to the credentials page as default entry page. + navigate('/credentials', { replace: true }); + }, [navigate, isFullyInitialized, requiresAuth]); + + // Handle initialization and auth state changes + useEffect(() => { + // Check for inline unlock mode + const urlParams = new URLSearchParams(window.location.search); + const inlineUnlock = urlParams.get('mode') === 'inline_unlock'; + setIsInlineUnlockMode(inlineUnlock); + + if (isFullyInitialized) { + setIsInitialLoading(false); + + if (requiresAuth) { + const allowedPaths = ['/login', '/unlock', '/unlock-success', '/auth-settings']; + if (allowedPaths.includes(location.pathname)) { + // Do not override the navigation if the current path is in the allowed paths. + return; + } + + // Determine which auth page to show + if (!isLoggedIn) { + navigate('/login', { replace: true }); + } else if (!dbAvailable) { + navigate('/unlock', { replace: true }); + } else if (inlineUnlock) { + navigate('/unlock-success', { replace: true }); + } + } else if (!isInitialized) { + // First initialization, try to restore last page or go to credentials + restoreLastPage().then(() => { + setIsInitialized(true); + }); + } + } + }, [isFullyInitialized, requiresAuth, isLoggedIn, dbAvailable, isInitialized, navigate, restoreLastPage, setIsInitialLoading, location.pathname]); // Store the current page whenever it changes useEffect(() => { @@ -60,18 +169,12 @@ export const NavigationProvider: React.FC<{ children: React.ReactNode }> = ({ ch } }, [location.pathname, isInitialized, storeCurrentPage]); - // Restore the last page on initial load - useEffect(() => { - if (!isInitialized) { - restoreLastPage(); - setIsInitialized(true); - } - }, [isInitialized, restoreLastPage]); - const contextValue = useMemo(() => ({ storeCurrentPage, - restoreLastPage - }), [storeCurrentPage, restoreLastPage]); + restoreLastPage, + isFullyInitialized, + requiresAuth + }), [storeCurrentPage, restoreLastPage, isFullyInitialized, requiresAuth]); return ( diff --git a/apps/browser-extension/src/entrypoints/popup/pages/CredentialAddEdit.tsx b/apps/browser-extension/src/entrypoints/popup/pages/CredentialAddEdit.tsx index 2f6c429f5..7e79bd46d 100644 --- a/apps/browser-extension/src/entrypoints/popup/pages/CredentialAddEdit.tsx +++ b/apps/browser-extension/src/entrypoints/popup/pages/CredentialAddEdit.tsx @@ -102,9 +102,6 @@ const CredentialAddEdit: React.FC = () => { } }); - // If we received an ID, we're in edit mode - const isEditMode = id !== undefined && id.length > 0; - /** * Persists the current form values to storage * @returns Promise that resolves when the form values are persisted @@ -127,6 +124,26 @@ const CredentialAddEdit: React.FC = () => { await sendMessage('PERSIST_FORM_VALUES', JSON.stringify(persistedData), 'background'); }, [watch, id, mode, localLoading]); + /** + * Watch for mode changes and persist form values + */ + useEffect(() => { + if (!localLoading) { + void persistFormValues(); + } + }, [mode, persistFormValues, localLoading]); + + // Watch for form changes and persist them + useEffect(() => { + const subscription = watch(() => { + void persistFormValues(); + }); + return (): void => subscription.unsubscribe(); + }, [watch, persistFormValues]); + + // If we received an ID, we're in edit mode + const isEditMode = id !== undefined && id.length > 0; + /** * Loads persisted form values from storage. This is used to keep track of form changes * and restore them when the page is reloaded. The browser extension popup will close @@ -233,14 +250,6 @@ const CredentialAddEdit: React.FC = () => { } }, [dbContext.sqliteClient, id, navigate, setIsInitialLoading, setValue, loadPersistedValues]); - // Watch for form changes and persist them - useEffect(() => { - const subscription = watch(() => { - void persistFormValues(); - }); - return (): void => subscription.unsubscribe(); - }, [watch, persistFormValues]); - /** * Handle the delete button click. */ diff --git a/apps/browser-extension/src/entrypoints/popup/pages/Home.tsx b/apps/browser-extension/src/entrypoints/popup/pages/Home.tsx index 823b4d9ea..b500c123b 100644 --- a/apps/browser-extension/src/entrypoints/popup/pages/Home.tsx +++ b/apps/browser-extension/src/entrypoints/popup/pages/Home.tsx @@ -1,62 +1,20 @@ -import React, { useState, useEffect } from 'react'; -import { useNavigate } from 'react-router-dom'; +import React from 'react'; +import { Navigate } from 'react-router-dom'; -import { useAuth } from '@/entrypoints/popup/context/AuthContext'; -import { useDb } from '@/entrypoints/popup/context/DbContext'; -import { useLoading } from '@/entrypoints/popup/context/LoadingContext'; -import Login from '@/entrypoints/popup/pages/Login'; -import Unlock from '@/entrypoints/popup/pages/Unlock'; -import UnlockSuccess from '@/entrypoints/popup/pages/UnlockSuccess'; +import { useNavigation } from '@/entrypoints/popup/context/NavigationContext'; /** * Home page that shows the correct page based on the user's authentication state. + * Most of the navigation logic is now handled by NavigationContext. */ const Home: React.FC = () => { - const authContext = useAuth(); - const dbContext = useDb(); - const navigate = useNavigate(); - const { setIsInitialLoading } = useLoading(); - const [isInlineUnlockMode, setIsInlineUnlockMode] = useState(false); + const { isFullyInitialized } = useNavigation(); - // Initialization state. - const isFullyInitialized = authContext.isInitialized && dbContext.dbInitialized; - const isAuthenticated = authContext.isLoggedIn; - const isDatabaseAvailable = dbContext.dbAvailable; - const requireLoginOrUnlock = isFullyInitialized && (!isAuthenticated || !isDatabaseAvailable || isInlineUnlockMode); - - useEffect(() => { - // Detect if the user is coming from the unlock page with mode=inline_unlock. - const urlParams = new URLSearchParams(window.location.search); - const isInlineUnlockMode = urlParams.get('mode') === 'inline_unlock'; - setIsInlineUnlockMode(isInlineUnlockMode); - - // Redirect to credentials if fully initialized and doesn't need unlock. - if (isFullyInitialized && !requireLoginOrUnlock) { - navigate('/credentials', { replace: true }); - } - }, [isFullyInitialized, requireLoginOrUnlock, isInlineUnlockMode, navigate]); - - // Show loading state if not fully initialized or when about to redirect to credentials. - if (!isFullyInitialized || (isFullyInitialized && !requireLoginOrUnlock)) { - // Global loading spinner will be shown by the parent component. + if (!isFullyInitialized) { return null; } - setIsInitialLoading(false); - - if (!isAuthenticated) { - return ; - } - - if (!isDatabaseAvailable) { - return ; - } - - if (isInlineUnlockMode) { - return setIsInlineUnlockMode(false)} />; - } - - return null; + return ; }; export default Home; \ No newline at end of file