mirror of
https://github.com/aliasvault/aliasvault.git
synced 2026-03-19 07:07:59 -04:00
Refactor navigation logic from Home.tsx to NavigationContext (#935)
This commit is contained in:
committed by
Leendert de Borst
parent
3ca4c0a78d
commit
ee6b40dd3d
@@ -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: <Home />, showBackButton: false },
|
||||
{ path: '/login', element: <Login />, showBackButton: false },
|
||||
{ path: '/unlock', element: <Unlock />, showBackButton: false },
|
||||
{ path: '/unlock-success', element: <UnlockSuccess onClose={() => window.location.search = ''} />, showBackButton: false },
|
||||
{ path: '/auth-settings', element: <AuthSettings />, showBackButton: true, title: 'Settings' },
|
||||
{ path: '/credentials', element: <CredentialsList />, showBackButton: false },
|
||||
{ path: '/credentials/add', element: <CredentialAddEdit />, showBackButton: true, title: 'Add credential' },
|
||||
|
||||
@@ -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<void>;
|
||||
restoreLastPage: () => Promise<void>;
|
||||
isFullyInitialized: boolean;
|
||||
requiresAuth: boolean;
|
||||
};
|
||||
|
||||
const NavigationContext = createContext<NavigationContextType | undefined>(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<void> => {
|
||||
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<void> => {
|
||||
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<string>,
|
||||
storage.getItem(LAST_VISITED_TIME_KEY) as Promise<number>,
|
||||
storage.getItem(NAVIGATION_HISTORY_KEY) as Promise<NavigationHistoryEntry[]>,
|
||||
]);
|
||||
|
||||
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 (
|
||||
<NavigationContext.Provider value={contextValue}>
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -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 <Login />;
|
||||
}
|
||||
|
||||
if (!isDatabaseAvailable) {
|
||||
return <Unlock />;
|
||||
}
|
||||
|
||||
if (isInlineUnlockMode) {
|
||||
return <UnlockSuccess onClose={() => setIsInlineUnlockMode(false)} />;
|
||||
}
|
||||
|
||||
return null;
|
||||
return <Navigate to="/credentials" replace />;
|
||||
};
|
||||
|
||||
export default Home;
|
||||
Reference in New Issue
Block a user