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