Refactor navigation in browser extension to follow mobile app reinitialize structure (#957)

This commit is contained in:
Leendert de Borst
2025-06-24 18:03:57 +02:00
committed by Leendert de Borst
parent 0617ccb42e
commit 19b89cbfda
11 changed files with 205 additions and 155 deletions

View File

@@ -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: <Home />, showBackButton: false },
{ path: '/', element: <Index />, showBackButton: false },
{ path: '/reinitialize', element: <Reinitialize />, 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 },
@@ -92,7 +93,6 @@ const App: React.FC = () => {
</div>
)}
<GlobalStateChangeHandler />
<Header
routes={routes}
rightButtons={headerButtons}

View File

@@ -1,41 +0,0 @@
import React, { useEffect, useRef } from 'react';
import { useNavigate } from 'react-router-dom';
import { useAuth } from '@/entrypoints/popup/context/AuthContext';
/**
* Global state change handler component which listens for global state changes and e.g. redirects user to login
* page if login state changes.
*/
const GlobalStateChangeHandler: 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;

View File

@@ -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) {

View File

@@ -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]);

View File

@@ -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<void>;
restoreLastPage: () => Promise<void>;
isFullyInitialized: boolean;
requiresAuth: boolean;
};
@@ -29,29 +25,25 @@ type NavigationContextType = {
const NavigationContext = createContext<NavigationContextType | undefined>(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<void> => {
// 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<void> => {
// 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) {
// 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 (
<NavigationContext.Provider value={contextValue}>

View File

@@ -14,7 +14,7 @@ const Home: React.FC = () => {
return null;
}
return <Navigate to="/credentials" replace />;
return <Navigate to="/reinitialize" replace />;
};
export default Home;

View File

@@ -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('');

View File

@@ -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<void> => {
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) {
// 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;

View File

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

View File

@@ -33,7 +33,6 @@ export class WebApiService {
return apiUrl.replace(/\/$/, '') + '/v1/';
}
/**
* Check if the current server is self-hosted.
*/

View File

@@ -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 <Redirect href={'/credentials'} />
return <Redirect href={'/reinitialize'} />
}