diff --git a/apps/browser-extension/src/entrypoints/popup/App.tsx b/apps/browser-extension/src/entrypoints/popup/App.tsx index 1e0f6096f..3353b8d36 100644 --- a/apps/browser-extension/src/entrypoints/popup/App.tsx +++ b/apps/browser-extension/src/entrypoints/popup/App.tsx @@ -1,7 +1,6 @@ import React, { useState, useEffect } from 'react'; import { HashRouter as Router, Routes, Route } from 'react-router-dom'; -import GlobalStateChangeHandler from '@/entrypoints/popup/components/GlobalStateChangeHandler'; import BottomNav from '@/entrypoints/popup/components/Layout/BottomNav'; import Header from '@/entrypoints/popup/components/Layout/Header'; import LoadingSpinner from '@/entrypoints/popup/components/LoadingSpinner'; @@ -15,9 +14,10 @@ import CredentialDetails from '@/entrypoints/popup/pages/CredentialDetails'; 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 Index from '@/entrypoints/popup/pages/Index'; import Login from '@/entrypoints/popup/pages/Login'; import Logout from '@/entrypoints/popup/pages/Logout'; +import Reinitialize from '@/entrypoints/popup/pages/Reinitialize'; import Settings from '@/entrypoints/popup/pages/Settings'; import Unlock from '@/entrypoints/popup/pages/Unlock'; import UnlockSuccess from '@/entrypoints/popup/pages/UnlockSuccess'; @@ -49,7 +49,8 @@ const App: React.FC = () => { // Add these route configurations const routes: RouteConfig[] = [ - { path: '/', element: , showBackButton: false }, + { path: '/', element: , showBackButton: false }, + { path: '/reinitialize', element: , showBackButton: false }, { path: '/login', element: , showBackButton: false }, { path: '/unlock', element: , showBackButton: false }, { path: '/unlock-success', element: window.location.search = ''} />, showBackButton: false }, @@ -92,7 +93,6 @@ const App: React.FC = () => { )} -
{ - const authContext = useAuth(); - const navigate = useNavigate(); - const lastLoginState = useRef(authContext.isLoggedIn); - const initialRender = useRef(true); - - /** - * Listen for auth logged in changes and redirect to home page if logged in state changes to handle logins and logouts. - */ - useEffect(() => { - // Only navigate when auth state is different from the last state we acted on. - if (lastLoginState.current !== authContext.isLoggedIn) { - lastLoginState.current = authContext.isLoggedIn; - - /** - * Skip the first auth state change to avoid redirecting when popup opens for the first time - * which already causes the auth state to change from false to true. - */ - if (initialRender.current) { - initialRender.current = false; - return; - } - - // Redirect to home page if logged in state changes. - navigate('/'); - } - }, [authContext.isLoggedIn]); // eslint-disable-line react-hooks/exhaustive-deps - - return null; -}; - -export default GlobalStateChangeHandler; \ No newline at end of file diff --git a/apps/browser-extension/src/entrypoints/popup/components/Layout/BottomNav.tsx b/apps/browser-extension/src/entrypoints/popup/components/Layout/BottomNav.tsx index 7eb53a882..f7a953779 100644 --- a/apps/browser-extension/src/entrypoints/popup/components/Layout/BottomNav.tsx +++ b/apps/browser-extension/src/entrypoints/popup/components/Layout/BottomNav.tsx @@ -31,9 +31,8 @@ const BottomNav: React.FC = () => { navigate(`/${tab}`); }; - // Auth pages that don't show bottom navigation but still show header - const authPages = ['/login', '/unlock', '/unlock-success', '/upgrade']; + const authPages = ['/login', '/auth-settings', '/unlock', '/unlock-success', '/upgrade']; const isAuthPage = authPages.includes(location.pathname); if (isAuthPage) { diff --git a/apps/browser-extension/src/entrypoints/popup/context/AuthContext.tsx b/apps/browser-extension/src/entrypoints/popup/context/AuthContext.tsx index 2f904ad76..8c31ad89e 100644 --- a/apps/browser-extension/src/entrypoints/popup/context/AuthContext.tsx +++ b/apps/browser-extension/src/entrypoints/popup/context/AuthContext.tsx @@ -40,17 +40,20 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children * @returns object containing whether the user is logged in. */ const initializeAuth = useCallback(async () : Promise<{ isLoggedIn: boolean }> => { + let isLoggedIn = false; + const accessToken = await storage.getItem('local:accessToken') as string; const refreshToken = await storage.getItem('local:refreshToken') as string; const username = await storage.getItem('local:username') as string; if (accessToken && refreshToken && username) { setUsername(username); setIsLoggedIn(true); + isLoggedIn = true; } setIsInitialized(true); return { isLoggedIn }; - }, [setUsername, setIsLoggedIn, isLoggedIn]); + }, [setUsername, setIsLoggedIn]); /** * Check for tokens in browser local storage on initial load when this context is mounted. @@ -94,6 +97,7 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children } setUsername(null); + console.log('setting isLoggedIn to false'); setIsLoggedIn(false); }, [dbContext]); diff --git a/apps/browser-extension/src/entrypoints/popup/context/NavigationContext.tsx b/apps/browser-extension/src/entrypoints/popup/context/NavigationContext.tsx index b59b20644..4eb5fa164 100644 --- a/apps/browser-extension/src/entrypoints/popup/context/NavigationContext.tsx +++ b/apps/browser-extension/src/entrypoints/popup/context/NavigationContext.tsx @@ -1,17 +1,14 @@ -import React, { createContext, useContext, useEffect, useState, useMemo, useCallback } from 'react'; -import { useLocation, useNavigate } from 'react-router-dom'; -import { sendMessage } from 'webext-bridge/popup'; +import React, { createContext, useContext, useEffect, useMemo, useCallback } from 'react'; +import { useLocation } 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 { 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; @@ -21,7 +18,6 @@ type NavigationHistoryEntry = { type NavigationContextType = { storeCurrentPage: () => Promise; - restoreLastPage: () => Promise; isFullyInitialized: boolean; requiresAuth: boolean; }; @@ -29,29 +25,25 @@ type NavigationContextType = { const NavigationContext = createContext(undefined); /** - * Navigation provider component that handles storing and restoring the last visited page, - * as well as managing initialization and auth state redirects. + * Navigation provider component that handles storing the last visited page. */ export const NavigationProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => { const location = useLocation(); - const navigate = useNavigate(); - const [isInitialized, setIsInitialized] = useState(false); - const { setIsInitialLoading } = useLoading(); // Auth and DB state const { isInitialized: authInitialized, isLoggedIn } = useAuth(); - const { dbInitialized, dbAvailable } = useDb(); + const { dbInitialized, dbAvailable, upgradeRequired } = useDb(); // Derived state const isFullyInitialized = authInitialized && dbInitialized; - const requiresAuth = isFullyInitialized && (!isLoggedIn || !dbAvailable); + const requiresAuth = isFullyInitialized && (!isLoggedIn || (!dbAvailable && !upgradeRequired)); /** * Store the current page path, timestamp, and navigation history in storage. */ const storeCurrentPage = useCallback(async (): Promise => { // Pages that are not allowed to be stored as these are auth conditional pages. - const notAllowedPaths = ['/', '/login', '/unlock', '/unlock-success', '/auth-settings']; + const notAllowedPaths = ['/', '/reinitialize', '/login', '/unlock', '/unlock-success', '/auth-settings', '/upgrade']; // Only store the page if we're fully initialized and don't need auth if (isFullyInitialized && !requiresAuth && !notAllowedPaths.includes(location.pathname)) { @@ -77,101 +69,18 @@ export const NavigationProvider: React.FC<{ children: React.ReactNode }> = ({ ch } }, [location, isFullyInitialized, requiresAuth]); - /** - * Restore the last visited page and navigation history if it was visited within the memory duration. - */ - const restoreLastPage = useCallback(async (): Promise => { - // 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) { - // Restore the navigation history - if (savedHistory?.length) { - // First navigate to credentials page as the base - navigate('/credentials', { replace: true }); - - // 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; - } - } - - // 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'; - - 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(() => { - if (isInitialized) { + if (isFullyInitialized) { storeCurrentPage(); } - }, [location.pathname, location.search, location.hash, isInitialized, storeCurrentPage]); + }, [location.pathname, location.search, location.hash, isFullyInitialized, storeCurrentPage]); const contextValue = useMemo(() => ({ storeCurrentPage, - restoreLastPage, isFullyInitialized, requiresAuth - }), [storeCurrentPage, restoreLastPage, isFullyInitialized, requiresAuth]); + }), [storeCurrentPage, isFullyInitialized, requiresAuth]); return ( diff --git a/apps/browser-extension/src/entrypoints/popup/pages/Home.tsx b/apps/browser-extension/src/entrypoints/popup/pages/Index.tsx similarity index 86% rename from apps/browser-extension/src/entrypoints/popup/pages/Home.tsx rename to apps/browser-extension/src/entrypoints/popup/pages/Index.tsx index b500c123b..d015b6fe8 100644 --- a/apps/browser-extension/src/entrypoints/popup/pages/Home.tsx +++ b/apps/browser-extension/src/entrypoints/popup/pages/Index.tsx @@ -14,7 +14,7 @@ const Home: React.FC = () => { return null; } - return ; + return ; }; export default Home; \ No newline at end of file diff --git a/apps/browser-extension/src/entrypoints/popup/pages/Login.tsx b/apps/browser-extension/src/entrypoints/popup/pages/Login.tsx index bcfebb609..d42de9e57 100644 --- a/apps/browser-extension/src/entrypoints/popup/pages/Login.tsx +++ b/apps/browser-extension/src/entrypoints/popup/pages/Login.tsx @@ -1,6 +1,7 @@ import { Buffer } from 'buffer'; import React, { useEffect, useState } from 'react'; +import { useNavigate } from 'react-router-dom'; import Button from '@/entrypoints/popup/components/Button'; import HeaderButton from '@/entrypoints/popup/components/HeaderButton'; @@ -27,6 +28,7 @@ import { storage } from '#imports'; * Login page */ const Login: React.FC = () => { + const navigate = useNavigate(); const authContext = useAuth(); const dbContext = useDb(); const { setHeaderButtons } = useHeaderButtons(); @@ -155,11 +157,21 @@ const Login: React.FC = () => { await authContext.setAuthTokens(ConversionUtility.normalizeUsername(credentials.username), validationResponse.token.token, validationResponse.token.refreshToken); // Initialize the SQLite context with the new vault data. - await dbContext.initializeDatabase(vaultResponseJson, passwordHashBase64); + const sqliteClient = await dbContext.initializeDatabase(vaultResponseJson, passwordHashBase64); // Set logged in status to true which refreshes the app. await authContext.login(); + // If there are pending migrations, redirect to the upgrade page. + if (await sqliteClient.hasPendingMigrations()) { + navigate('/upgrade', { replace: true }); + hideLoading(); + return; + } + + // Navigate to credentials page after successful login + navigate('/credentials', { replace: true }); + // Show app. hideLoading(); } catch (err) { @@ -222,11 +234,20 @@ const Login: React.FC = () => { await authContext.setAuthTokens(ConversionUtility.normalizeUsername(credentials.username), validationResponse.token.token, validationResponse.token.refreshToken); // Initialize the SQLite context with the new vault data. - await dbContext.initializeDatabase(vaultResponseJson, passwordHashBase64); + const sqliteClient = await dbContext.initializeDatabase(vaultResponseJson, passwordHashBase64); // Set logged in status to true which refreshes the app. await authContext.login(); + // If there are pending migrations, redirect to the upgrade page. + if (await sqliteClient.hasPendingMigrations()) { + navigate('/upgrade', { replace: true }); + return; + } + + // Navigate to credentials page after successful login + navigate('/credentials', { replace: true }); + // Reset 2FA state and login response as it's no longer needed setTwoFactorRequired(false); setTwoFactorCode(''); diff --git a/apps/browser-extension/src/entrypoints/popup/pages/Reinitialize.tsx b/apps/browser-extension/src/entrypoints/popup/pages/Reinitialize.tsx new file mode 100644 index 000000000..92beded0f --- /dev/null +++ b/apps/browser-extension/src/entrypoints/popup/pages/Reinitialize.tsx @@ -0,0 +1,161 @@ +import React, { useCallback, useEffect, useRef } from 'react'; +import { 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 { useVaultSync } from '@/entrypoints/popup/hooks/useVaultSync'; + +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; +}; + +/** + * Initialize component that handles initial application setup, authentication checks, + * vault synchronization, and state restoration. + */ +const Reinitialize: React.FC = () => { + const navigate = useNavigate(); + const { setIsInitialLoading } = useLoading(); + const { syncVault } = useVaultSync(); + const hasInitialized = useRef(false); + + // Auth and DB state + const { isInitialized: authInitialized, isLoggedIn } = useAuth(); + const { dbInitialized, dbAvailable, upgradeRequired } = useDb(); + + // Derived state + const isFullyInitialized = authInitialized && dbInitialized; + const requiresAuth = isFullyInitialized && (!isLoggedIn || (!dbAvailable && !upgradeRequired)); + const requiresUpgrade = isFullyInitialized && isLoggedIn && upgradeRequired; + + /** + * Restore the last visited page and navigation history if it was visited within the memory duration. + */ + const restoreLastPage = useCallback(async (): Promise => { + 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) { + // Restore the navigation history + if (savedHistory?.length) { + // First navigate to credentials page as the base + navigate('/credentials', { replace: true }); + + // 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; + } + } + + // 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]); + + useEffect(() => { + // Check for inline unlock mode + const urlParams = new URLSearchParams(window.location.search); + const inlineUnlock = urlParams.get('mode') === 'inline_unlock'; + + if (isFullyInitialized) { + // Prevent multiple vault syncs (only run sync once) + const shouldRunSync = !hasInitialized.current; + + if (requiresUpgrade) { + // Upgrade is required, navigate to upgrade page + navigate('/upgrade', { replace: true }); + // Keep loading active until upgrade navigation completes + setTimeout(() => { + setIsInitialLoading(false); + }, 50); + } else if (requiresAuth) { + setIsInitialLoading(false); + + // Determine which auth page to show + if (!isLoggedIn) { + navigate('/login', { replace: true }); + } else if (!dbAvailable) { + navigate('/unlock', { replace: true }); + } + } else if (inlineUnlock) { + setIsInitialLoading(false); + navigate('/unlock-success', { replace: true }); + } else if (shouldRunSync) { + // Only perform vault sync once during initialization + hasInitialized.current = true; + + // Perform vault sync and restore state + syncVault({ + initialSync: true, + /** + * Handle successful vault sync. + */ + onSuccess: async () => { + // After successful sync, try to restore last page or go to credentials + await restoreLastPage(); + setIsInitialLoading(false); + }, + /** + * Handle vault sync error. + * @param error Error message + */ + onError: (error) => { + console.error('Vault sync error during initialization:', error); + // Even if sync fails, continue with initialization + restoreLastPage().then(() => { + setIsInitialLoading(false); + }); + }, + /** + * Handle upgrade required. + */ + onUpgradeRequired: () => { + // This shouldn't happen anymore since we check in DbContext + navigate('/upgrade', { replace: true }); + setIsInitialLoading(false); + } + }); + } else { + // User is logged in and db is available, navigate to appropriate page + setIsInitialLoading(false); + restoreLastPage(); + } + } + }, [isFullyInitialized, requiresAuth, requiresUpgrade, isLoggedIn, dbAvailable, upgradeRequired, navigate, setIsInitialLoading, syncVault, restoreLastPage]); + + // This component doesn't render anything visible - it just handles initialization + return null; +}; + +export default Reinitialize; diff --git a/apps/browser-extension/src/utils/AppInfo.ts b/apps/browser-extension/src/utils/AppInfo.ts index 6581c8cad..6f09bddd7 100644 --- a/apps/browser-extension/src/utils/AppInfo.ts +++ b/apps/browser-extension/src/utils/AppInfo.ts @@ -14,7 +14,6 @@ export class AppInfo { */ public static readonly MIN_SERVER_VERSION = '0.12.0-dev'; - /** * The client name to use in the X-AliasVault-Client header. * Detects the specific browser being used. @@ -57,7 +56,6 @@ export class AppInfo { */ private constructor() {} - /** * Checks if a given server version is supported * @param serverVersion The version to check diff --git a/apps/browser-extension/src/utils/WebApiService.ts b/apps/browser-extension/src/utils/WebApiService.ts index 5f1b9f39a..b87a00af5 100644 --- a/apps/browser-extension/src/utils/WebApiService.ts +++ b/apps/browser-extension/src/utils/WebApiService.ts @@ -33,7 +33,6 @@ export class WebApiService { return apiUrl.replace(/\/$/, '') + '/v1/'; } - /** * Check if the current server is self-hosted. */ diff --git a/apps/mobile-app/app/index.tsx b/apps/mobile-app/app/index.tsx index 0238fa4ec..adab62aad 100644 --- a/apps/mobile-app/app/index.tsx +++ b/apps/mobile-app/app/index.tsx @@ -5,5 +5,5 @@ import { Redirect } from 'expo-router'; * redirect to the login screen if the user is not logged in or to the main tabs screen if the user is logged in. */ export default function AppIndex() : React.ReactNode { - return + return } \ No newline at end of file