Compare commits

...

10 Commits

Author SHA1 Message Date
8d6434f166 Merge pull request 'fix: Mobile login redirects to homepage without showing Auth0 login page (#188)' (#193) from issue-188-fix-mobile-login-redirect into main
All checks were successful
Deploy to Staging / Build Images (push) Successful in 36s
Deploy to Staging / Deploy to Staging (push) Successful in 51s
Deploy to Staging / Verify Staging (push) Successful in 8s
Deploy to Staging / Notify Staging Ready (push) Successful in 7s
Deploy to Staging / Notify Staging Failure (push) Has been skipped
Reviewed-on: #193
2026-02-15 15:36:37 +00:00
Eric Gullickson
850f713310 fix: prevent URL sync effects from stripping Auth0 callback params (refs #188)
All checks were successful
Deploy to Staging / Build Images (pull_request) Successful in 3m21s
Deploy to Staging / Deploy to Staging (pull_request) Successful in 51s
Deploy to Staging / Verify Staging (pull_request) Successful in 9s
Deploy to Staging / Notify Staging Ready (pull_request) Successful in 8s
Deploy to Staging / Notify Staging Failure (pull_request) Has been skipped
Root cause: React fires child effects before parent effects. App's URL
sync effect called history.replaceState() on /callback, stripping the
?code= and &state= query params before Auth0Provider's useEffect could
read them via hasAuthParams(). The SDK fell through to checkSession()
instead of handleRedirectCallback(), silently failing with no error.

Guard both URL sync effects to skip on /callback, /signup, /verify-email.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 09:24:56 -06:00
Eric Gullickson
b5b82db532 fix: resolve auth callback failure from IndexedDB cache issues (refs #188)
All checks were successful
Deploy to Staging / Build Images (pull_request) Successful in 3m23s
Deploy to Staging / Deploy to Staging (pull_request) Successful in 52s
Deploy to Staging / Verify Staging (pull_request) Successful in 9s
Deploy to Staging / Notify Staging Ready (pull_request) Successful in 7s
Deploy to Staging / Notify Staging Failure (pull_request) Has been skipped
Add allKeys() to IndexedDBStorage to eliminate Auth0 CacheKeyManifest
fallback, revert set()/remove() to non-blocking persist, add auth error
display on callback route, remove leaky force-auth-check interceptor,
and migrate debug console calls to centralized logger.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 09:06:40 -06:00
Eric Gullickson
da59168d7b fix: IndexedDB cache broken on page reload - root cause of mobile login failure (refs #190)
All checks were successful
Deploy to Staging / Build Images (pull_request) Successful in 3m25s
Deploy to Staging / Deploy to Staging (pull_request) Successful in 51s
Deploy to Staging / Verify Staging (pull_request) Successful in 8s
Deploy to Staging / Notify Staging Ready (pull_request) Successful in 7s
Deploy to Staging / Notify Staging Failure (pull_request) Has been skipped
loadCacheFromDB used store.getAll() which returns raw values, not
key-value pairs. The item.key check always failed, so memoryCache
was empty after every page reload. Auth0 SDK state stored before
redirect was lost on mobile Safari (no bfcache).

Also fixed set()/remove() to await IDB persistence so Auth0 state
is fully written before loginWithRedirect() navigates away.

Added 10s timeout on callback loading state as safety net.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-14 22:20:34 -06:00
Eric Gullickson
38debaad5d fix: skip stale token validation during callback code exchange (refs #190)
All checks were successful
Deploy to Staging / Build Images (pull_request) Successful in 3m28s
Deploy to Staging / Deploy to Staging (pull_request) Successful in 22s
Deploy to Staging / Verify Staging (pull_request) Successful in 8s
Deploy to Staging / Notify Staging Ready (pull_request) Successful in 7s
Deploy to Staging / Notify Staging Failure (pull_request) Has been skipped
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-14 22:09:09 -06:00
Eric Gullickson
db127eb24c fix: address QR review findings for token validation and clearAll reliability (refs #190)
All checks were successful
Deploy to Staging / Build Images (pull_request) Successful in 3m32s
Deploy to Staging / Deploy to Staging (pull_request) Successful in 22s
Deploy to Staging / Verify Staging (pull_request) Successful in 8s
Deploy to Staging / Notify Staging Ready (pull_request) Successful in 7s
Deploy to Staging / Notify Staging Failure (pull_request) Has been skipped
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-14 21:59:31 -06:00
Eric Gullickson
15128bfd50 fix: add missing hook dependencies for stale token effect (refs #190)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-14 21:57:28 -06:00
Eric Gullickson
723e25e1a7 fix: add pre-auth session clear mechanism on HomePage (refs #192)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-14 21:56:24 -06:00
Eric Gullickson
6e493e9bc7 fix: detect and clear stale IndexedDB auth tokens (refs #190)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-14 21:55:54 -06:00
Eric Gullickson
a195fa9231 fix: allow callback route to complete Auth0 code exchange (refs #189)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-14 21:55:24 -06:00
4 changed files with 199 additions and 119 deletions

View File

@@ -4,7 +4,7 @@
import React, { useState, useEffect, useTransition, useCallback, lazy } from 'react'; import React, { useState, useEffect, useTransition, useCallback, lazy } from 'react';
import { useQueryClient } from '@tanstack/react-query'; import { useQueryClient } from '@tanstack/react-query';
import { Routes, Route, Navigate, useLocation } from 'react-router-dom'; import { Routes, Route, Navigate, useLocation, useNavigate } from 'react-router-dom';
import { useAuth0 } from '@auth0/auth0-react'; import { useAuth0 } from '@auth0/auth0-react';
import { useIsAuthInitialized } from './core/auth/auth-gate'; import { useIsAuthInitialized } from './core/auth/auth-gate';
import { motion, AnimatePresence } from 'framer-motion'; import { motion, AnimatePresence } from 'framer-motion';
@@ -310,11 +310,11 @@ const EditVehicleScreen: React.FC<EditVehicleScreenProps> = ({ vehicle, onBack,
}; };
function App() { function App() {
const { isLoading, isAuthenticated, user } = useAuth0(); const { isLoading, isAuthenticated, user, error: authError } = useAuth0();
const location = useLocation(); const location = useLocation();
const navigate = useNavigate();
const isAuthGateReady = useIsAuthInitialized(); const isAuthGateReady = useIsAuthInitialized();
const [_isPending, startTransition] = useTransition(); const [_isPending, startTransition] = useTransition();
console.log('[DEBUG App] Render check - isLoading:', isLoading, 'isAuthenticated:', isAuthenticated, 'isAuthGateReady:', isAuthGateReady);
// Initialize data synchronization // Initialize data synchronization
const { prefetchForNavigation } = useDataSync(); const { prefetchForNavigation } = useDataSync();
@@ -365,17 +365,24 @@ function App() {
const [showAddVehicle, setShowAddVehicle] = useState(false); const [showAddVehicle, setShowAddVehicle] = useState(false);
// Sync browser URL to Zustand screen state on mount (enables direct URL navigation on mobile) // Sync browser URL to Zustand screen state on mount (enables direct URL navigation on mobile)
// Skip on auth routes -- their query params must survive until Auth0 SDK processes them
useEffect(() => { useEffect(() => {
const screen = routeToScreen[window.location.pathname]; const path = window.location.pathname;
if (path === '/callback' || path === '/signup' || path === '/verify-email') return;
const screen = routeToScreen[path];
if (screen && screen !== activeScreen) { if (screen && screen !== activeScreen) {
navigateToScreen(screen, { source: 'url-sync' }); navigateToScreen(screen, { source: 'url-sync' });
} }
}, []); // eslint-disable-line react-hooks/exhaustive-deps -- intentionally runs once on mount }, []); // eslint-disable-line react-hooks/exhaustive-deps -- intentionally runs once on mount
// Sync Zustand screen changes back to browser URL (enables bookmarks and URL sharing) // Sync Zustand screen changes back to browser URL (enables bookmarks and URL sharing)
// Skip on auth routes -- replaceState would strip ?code= and &state= params that
// Auth0 SDK needs for handleRedirectCallback (child effects fire before parent effects)
useEffect(() => { useEffect(() => {
const path = window.location.pathname;
if (path === '/callback' || path === '/signup' || path === '/verify-email') return;
const targetPath = screenToRoute[activeScreen]; const targetPath = screenToRoute[activeScreen];
if (targetPath && window.location.pathname !== targetPath) { if (targetPath && path !== targetPath) {
window.history.replaceState(null, '', targetPath); window.history.replaceState(null, '', targetPath);
} }
}, [activeScreen]); }, [activeScreen]);
@@ -486,7 +493,6 @@ function App() {
} }
}, [navigateToScreen, navigateToVehicleSubScreen]); }, [navigateToScreen, navigateToVehicleSubScreen]);
console.log('MotoVaultPro status:', { isLoading, isAuthenticated, mobileMode, activeScreen, vehicleSubScreen, userAgent: navigator.userAgent });
const isGarageRoute = location.pathname === '/garage' || location.pathname.startsWith('/garage/'); const isGarageRoute = location.pathname === '/garage' || location.pathname.startsWith('/garage/');
const isCallbackRoute = location.pathname === '/callback'; const isCallbackRoute = location.pathname === '/callback';
@@ -496,6 +502,16 @@ function App() {
const isAuthRoute = isSignupRoute || isVerifyEmailRoute || isOnboardingRoute; const isAuthRoute = isSignupRoute || isVerifyEmailRoute || isOnboardingRoute;
const shouldShowHomePage = !isGarageRoute && !isCallbackRoute && !isAuthRoute; const shouldShowHomePage = !isGarageRoute && !isCallbackRoute && !isAuthRoute;
const [callbackTimedOut, setCallbackTimedOut] = useState(false);
useEffect(() => {
if (isCallbackRoute && !isAuthenticated && !isLoading) {
const timer = setTimeout(() => setCallbackTimedOut(true), 10000);
return () => clearTimeout(timer);
}
setCallbackTimedOut(false);
return undefined;
}, [isCallbackRoute, isAuthenticated, isLoading]);
// Enhanced navigation handlers for mobile // Enhanced navigation handlers for mobile
const handleVehicleSelect = useCallback((vehicle: Vehicle) => { const handleVehicleSelect = useCallback((vehicle: Vehicle) => {
setSelectedVehicle(vehicle); setSelectedVehicle(vehicle);
@@ -557,18 +573,55 @@ function App() {
); );
} }
// Callback route requires authentication - handled by CallbackPage component if (isCallbackRoute) {
if (isCallbackRoute && isAuthenticated) { if (authError) {
return (
<ThemeProvider>
<div className="flex flex-col items-center justify-center min-h-screen gap-4 px-4">
<div className="text-lg text-red-600 text-center">Login failed: {authError.message}</div>
<button
onClick={() => navigate('/')}
className="px-6 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
>
Return to Homepage
</button>
</div>
</ThemeProvider>
);
}
if (isAuthenticated) {
return (
<ThemeProvider>
<React.Suspense fallback={
<div className="flex items-center justify-center min-h-screen">
<div className="text-lg">Processing login...</div>
</div>
}>
{mobileMode ? <CallbackMobileScreen /> : <CallbackPage />}
</React.Suspense>
<DebugInfo />
</ThemeProvider>
);
}
if (callbackTimedOut) {
return <Navigate to="/" replace />;
}
if (mobileMode) {
return (
<ThemeProvider>
<Layout mobileMode={true}>
<div className="flex items-center justify-center h-64">
<div className="text-slate-500">Processing login...</div>
</div>
</Layout>
</ThemeProvider>
);
}
return ( return (
<ThemeProvider> <ThemeProvider>
<React.Suspense fallback={ <div className="flex items-center justify-center min-h-screen">
<div className="flex items-center justify-center min-h-screen"> <div className="text-lg">Processing login...</div>
<div className="text-lg">Processing login...</div> </div>
</div>
}>
{mobileMode ? <CallbackMobileScreen /> : <CallbackPage />}
</React.Suspense>
<DebugInfo />
</ThemeProvider> </ThemeProvider>
); );
} }
@@ -637,7 +690,6 @@ function App() {
// Wait for auth gate to be ready before rendering protected routes // Wait for auth gate to be ready before rendering protected routes
// This prevents a race condition where the page renders before the auth token is ready // This prevents a race condition where the page renders before the auth token is ready
if (!isAuthGateReady) { if (!isAuthGateReady) {
console.log('[DEBUG App] Auth gate not ready yet, showing loading state');
if (mobileMode) { if (mobileMode) {
return ( return (
<ThemeProvider> <ThemeProvider>

View File

@@ -8,6 +8,7 @@ import { useNavigate } from 'react-router-dom';
import { apiClient, setAuthReady } from '../api/client'; import { apiClient, setAuthReady } from '../api/client';
import { createIndexedDBAdapter } from '../utils/indexeddb-storage'; import { createIndexedDBAdapter } from '../utils/indexeddb-storage';
import { setAuthInitialized } from './auth-gate'; import { setAuthInitialized } from './auth-gate';
import logger from '../../utils/logger';
interface Auth0ProviderProps { interface Auth0ProviderProps {
children: React.ReactNode; children: React.ReactNode;
@@ -20,12 +21,8 @@ export const Auth0Provider: React.FC<Auth0ProviderProps> = ({ children }) => {
const clientId = import.meta.env.VITE_AUTH0_CLIENT_ID; const clientId = import.meta.env.VITE_AUTH0_CLIENT_ID;
const audience = import.meta.env.VITE_AUTH0_AUDIENCE; const audience = import.meta.env.VITE_AUTH0_AUDIENCE;
// Basic component loading debug
console.log('[Auth0Provider] Component loaded', { domain, clientId, audience });
const onRedirectCallback = (appState?: { returnTo?: string }) => { const onRedirectCallback = (appState?: { returnTo?: string }) => {
console.log('[Auth0Provider] Redirect callback triggered', { appState, returnTo: appState?.returnTo }); logger.debug('Auth0 redirect callback triggered', { returnTo: appState?.returnTo });
// Route to callback page which will check user status and redirect appropriately // Route to callback page which will check user status and redirect appropriately
// Pass the intended destination as state for after status check // Pass the intended destination as state for after status check
navigate('/callback', { navigate('/callback', {
@@ -57,17 +54,9 @@ export const Auth0Provider: React.FC<Auth0ProviderProps> = ({ children }) => {
// Component to inject token into API client with mobile support // Component to inject token into API client with mobile support
const TokenInjector: React.FC<{ children: React.ReactNode }> = ({ children }) => { const TokenInjector: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const { getAccessTokenSilently, isAuthenticated, isLoading, user } = useAuth0(); const { getAccessTokenSilently, isAuthenticated, isLoading, logout } = useAuth0();
const [retryCount, setRetryCount] = React.useState(0); const [retryCount, setRetryCount] = React.useState(0);
const validatingRef = React.useRef(false);
// Basic component loading debug
console.log('[TokenInjector] Component loaded');
// Debug mobile authentication state
React.useEffect(() => {
const isMobile = /Android|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent);
console.log(`[Auth Debug] Mobile: ${isMobile}, Loading: ${isLoading}, Authenticated: ${isAuthenticated}, User: ${user ? 'present' : 'null'}`);
}, [isAuthenticated, isLoading, user]);
// Helper function to get token with enhanced retry logic for mobile devices // Helper function to get token with enhanced retry logic for mobile devices
const getTokenWithRetry = async (maxRetries = 5, delayMs = 300): Promise<any> => { const getTokenWithRetry = async (maxRetries = 5, delayMs = 300): Promise<any> => {
@@ -93,77 +82,49 @@ const TokenInjector: React.FC<{ children: React.ReactNode }> = ({ children }) =>
} }
const token = await getAccessTokenSilently(tokenOptions); const token = await getAccessTokenSilently(tokenOptions);
console.log(`[Mobile Auth] Token acquired successfully on attempt ${attempt + 1}`, { logger.debug(`Token acquired on attempt ${attempt + 1}`);
cacheMode: tokenOptions.cacheMode,
timeout: tokenOptions.timeoutInSeconds
});
return token; return token;
} catch (error: any) { } catch (error: any) {
console.warn(`[Mobile Auth] Attempt ${attempt + 1}/${maxRetries} failed:`, { logger.warn(`Token attempt ${attempt + 1}/${maxRetries} failed`, {
error: error.message || error, error: error.message || String(error),
cacheMode: attempt <= 2 ? 'on' : 'off'
}); });
// Mobile-specific: longer delays and more attempts // Mobile-specific: longer delays and more attempts
if (attempt < maxRetries - 1) { if (attempt < maxRetries - 1) {
const delay = delayMs * Math.pow(1.5, attempt); // Gentler exponential backoff const delay = delayMs * Math.pow(1.5, attempt);
console.log(`[Mobile Auth] Waiting ${Math.round(delay)}ms before retry...`);
await new Promise(resolve => setTimeout(resolve, delay)); await new Promise(resolve => setTimeout(resolve, delay));
} }
} }
} }
console.error('[Mobile Auth] All token acquisition attempts failed - authentication may be broken'); logger.error('All token acquisition attempts failed');
return null; return null;
}; };
// Force authentication check for devices when user seems logged in but isAuthenticated is false // Prevent stale session state when cached token is no longer valid
React.useEffect(() => { React.useEffect(() => {
const isMobile = /Android|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent); if (!isAuthenticated || isLoading || validatingRef.current) return;
if (window.location.pathname === '/callback') return;
// Debug current state const validateToken = async () => {
console.log('[Auth Debug] State check:', { validatingRef.current = true;
isMobile, try {
isLoading, await getAccessTokenSilently({ cacheMode: 'off', timeoutInSeconds: 10 });
isAuthenticated, } catch (error: any) {
pathname: window.location.pathname, const errorType = error?.error || error?.message || '';
userAgent: navigator.userAgent.substring(0, 50) + '...' if (errorType.includes('login_required') || errorType.includes('consent_required') ||
}); errorType.includes('invalid_grant')) {
logger.warn('Stale token detected, clearing auth state');
// Trigger for mobile devices OR any device on protected route without authentication const { indexedDBStorage } = await import('../utils/indexeddb-storage');
if (!isLoading && !isAuthenticated && window.location.pathname !== '/') { await indexedDBStorage.clearAll();
console.log('[Auth Debug] User on protected route but not authenticated, forcing token check...'); logout({ openUrl: false });
// Aggressive token check
const forceAuthCheck = async () => {
try {
// Try multiple approaches to get token
const token = await getAccessTokenSilently({
cacheMode: 'off' as const,
timeoutInSeconds: 10
});
console.log('[Auth Debug] Force auth successful, token acquired');
// Manually add to API client since isAuthenticated might still be false
if (token) {
console.log('[Auth Debug] Manually adding token to API client');
// Force add the token to subsequent requests
apiClient.interceptors.request.use((config) => {
if (!config.headers.Authorization) {
config.headers.Authorization = `Bearer ${token}`;
console.log('[Auth Debug] Token manually added to request');
}
return config;
});
setAuthReady(true);
}
} catch (error: any) {
console.log('[Auth Debug] Force auth failed:', error.message);
} }
}; } finally {
validatingRef.current = false;
}
};
forceAuthCheck(); validateToken();
} }, [isAuthenticated, isLoading, getAccessTokenSilently, logout]);
}, [isLoading, isAuthenticated, getAccessTokenSilently]);
React.useEffect(() => { React.useEffect(() => {
let interceptorId: number | undefined; let interceptorId: number | undefined;
@@ -175,34 +136,30 @@ const TokenInjector: React.FC<{ children: React.ReactNode }> = ({ children }) =>
try { try {
const { indexedDBStorage } = await import('../utils/indexeddb-storage'); const { indexedDBStorage } = await import('../utils/indexeddb-storage');
await indexedDBStorage.waitForReady(); await indexedDBStorage.waitForReady();
console.log('[Auth] IndexedDB storage is ready'); logger.debug('IndexedDB storage is ready');
} catch (error) { } catch (error) {
console.warn('[Auth] IndexedDB not ready, proceeding anyway:', error); logger.warn('IndexedDB not ready, proceeding anyway', { error: String(error) });
} }
// Minimal delay only for mobile devices (desktop needs no delay since IndexedDB is already ready) // Minimal delay only for mobile devices (desktop needs no delay since IndexedDB is already ready)
const isMobile = /Android|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent); const isMobile = /Android|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent);
if (isMobile) { if (isMobile) {
// Small delay for mobile browsers to settle after IndexedDB init
console.log('[Mobile Auth] Initializing token cache (mobile: true, delay: 50ms)');
await new Promise(resolve => setTimeout(resolve, 50)); await new Promise(resolve => setTimeout(resolve, 50));
} else {
console.log('[Auth] Initializing token cache (desktop, no delay)');
} }
try { try {
const token = await getTokenWithRetry(); const token = await getTokenWithRetry();
if (token) { if (token) {
console.log('[Mobile Auth] Token pre-warming successful'); logger.debug('Token pre-warming successful');
setRetryCount(0); setRetryCount(0);
setAuthReady(true); setAuthReady(true);
setAuthInitialized(true); // Signal that auth is fully ready setAuthInitialized(true); // Signal that auth is fully ready
} else { } else {
console.error('[Mobile Auth] Failed to acquire token after retries - will retry on API calls'); logger.error('Failed to acquire token after retries');
setRetryCount(prev => prev + 1); setRetryCount(prev => prev + 1);
} }
} catch (error) { } catch (error) {
console.error('[Mobile Auth] Token initialization failed:', error); logger.error('Token initialization failed', { error: String(error) });
setRetryCount(prev => prev + 1); setRetryCount(prev => prev + 1);
} }
}; };
@@ -221,11 +178,11 @@ const TokenInjector: React.FC<{ children: React.ReactNode }> = ({ children }) =>
config.headers.Authorization = `Bearer ${token}`; config.headers.Authorization = `Bearer ${token}`;
setAuthReady(true); setAuthReady(true);
} else { } else {
console.error('No token available for request to:', config.url); logger.error('No token available for request', { url: config.url });
// Allow request to proceed - backend will return 401 if needed // Allow request to proceed - backend will return 401 if needed
} }
} catch (error: any) { } catch (error: any) {
console.error('Failed to get access token for request:', error.message || error); logger.error('Failed to get access token for request', { error: error.message || String(error) });
// Allow request to proceed - backend will return 401 if needed // Allow request to proceed - backend will return 401 if needed
} }
return config; return config;

View File

@@ -3,6 +3,8 @@
* @ai-context Replaces localStorage with IndexedDB for mobile browser compatibility * @ai-context Replaces localStorage with IndexedDB for mobile browser compatibility
*/ */
import logger from '../../utils/logger';
interface StorageAdapter { interface StorageAdapter {
getItem(key: string): string | null; getItem(key: string): string | null;
setItem(key: string, value: string): void; setItem(key: string, value: string): void;
@@ -36,9 +38,9 @@ class IndexedDBStorage implements StorageAdapter, Auth0Cache {
this.db = await this.openDatabase(); this.db = await this.openDatabase();
await this.loadCacheFromDB(); await this.loadCacheFromDB();
this.isReady = true; this.isReady = true;
console.log('[IndexedDB] Storage initialized successfully'); logger.debug('IndexedDB storage initialized successfully');
} catch (error) { } catch (error) {
console.error('[IndexedDB] Initialization failed, using memory only:', error); logger.error('IndexedDB initialization failed, using memory only', { error: String(error) });
this.isReady = false; this.isReady = false;
} }
} }
@@ -48,7 +50,7 @@ class IndexedDBStorage implements StorageAdapter, Auth0Cache {
const request = indexedDB.open(this.dbName, this.dbVersion); const request = indexedDB.open(this.dbName, this.dbVersion);
request.onerror = () => { request.onerror = () => {
console.error(`IndexedDB open failed: ${request.error?.message}`); logger.error(`IndexedDB open failed: ${request.error?.message}`);
resolve(null as any); // Fallback to memory-only mode resolve(null as any); // Fallback to memory-only mode
}; };
@@ -71,25 +73,27 @@ class IndexedDBStorage implements StorageAdapter, Auth0Cache {
return new Promise((resolve) => { return new Promise((resolve) => {
const transaction = this.db!.transaction([this.storeName], 'readonly'); const transaction = this.db!.transaction([this.storeName], 'readonly');
const store = transaction.objectStore(this.storeName); const store = transaction.objectStore(this.storeName);
const request = store.getAll(); const request = store.openCursor();
this.memoryCache.clear();
request.onsuccess = () => { request.onsuccess = () => {
const results = request.result; const cursor = request.result;
this.memoryCache.clear(); if (cursor) {
const key = cursor.key as string;
for (const item of results) { const value = cursor.value;
if (item.key && typeof item.value === 'string') { if (typeof key === 'string' && typeof value === 'string') {
this.memoryCache.set(item.key, item.value); this.memoryCache.set(key, value);
} }
cursor.continue();
} else {
logger.debug(`IndexedDB loaded ${this.memoryCache.size} items into cache`);
resolve();
} }
console.log(`[IndexedDB] Loaded ${this.memoryCache.size} items into cache`);
resolve();
}; };
request.onerror = () => { request.onerror = () => {
console.warn('[IndexedDB] Failed to load cache from DB:', request.error); logger.warn('IndexedDB failed to load cache from DB', { error: String(request.error) });
resolve(); // Don't fail initialization resolve();
}; };
}); });
} }
@@ -105,14 +109,14 @@ class IndexedDBStorage implements StorageAdapter, Auth0Cache {
const request = store.delete(key); const request = store.delete(key);
request.onsuccess = () => resolve(); request.onsuccess = () => resolve();
request.onerror = () => { request.onerror = () => {
console.warn(`[IndexedDB] Failed to delete ${key}:`, request.error); logger.warn(`IndexedDB failed to delete ${key}`, { error: String(request.error) });
resolve(); resolve();
}; };
} else { } else {
const request = store.put(value, key); const request = store.put(value, key);
request.onsuccess = () => resolve(); request.onsuccess = () => resolve();
request.onerror = () => { request.onerror = () => {
console.warn(`[IndexedDB] Failed to persist ${key}:`, request.error); logger.warn(`IndexedDB failed to persist ${key}`, { error: String(request.error) });
resolve(); resolve();
}; };
} }
@@ -130,7 +134,7 @@ class IndexedDBStorage implements StorageAdapter, Auth0Cache {
// Async persist to IndexedDB (non-blocking) // Async persist to IndexedDB (non-blocking)
if (this.isReady) { if (this.isReady) {
this.persistToDB(key, value).catch(error => { this.persistToDB(key, value).catch(error => {
console.warn(`[IndexedDB] Background persist failed for ${key}:`, error); logger.warn(`IndexedDB background persist failed for ${key}`, { error: String(error) });
}); });
} }
} }
@@ -141,7 +145,7 @@ class IndexedDBStorage implements StorageAdapter, Auth0Cache {
// Async remove from IndexedDB (non-blocking) // Async remove from IndexedDB (non-blocking)
if (this.isReady) { if (this.isReady) {
this.persistToDB(key, null).catch(error => { this.persistToDB(key, null).catch(error => {
console.warn(`[IndexedDB] Background removal failed for ${key}:`, error); logger.warn(`IndexedDB background removal failed for ${key}`, { error: String(error) });
}); });
} }
} }
@@ -157,6 +161,30 @@ class IndexedDBStorage implements StorageAdapter, Auth0Cache {
} }
} }
async clearAll(): Promise<void> {
await this.initPromise;
if (!this.db) {
this.memoryCache.clear();
return;
}
const tx = this.db.transaction(this.storeName, 'readwrite');
tx.objectStore(this.storeName).clear();
await new Promise<void>((resolve, reject) => {
tx.oncomplete = () => {
this.memoryCache.clear();
resolve();
};
tx.onerror = () => {
this.memoryCache.clear();
reject(tx.error);
};
tx.onabort = () => {
this.memoryCache.clear();
reject(new Error('Transaction aborted'));
};
});
}
key(index: number): string | null { key(index: number): string | null {
const keys = Array.from(this.memoryCache.keys()); const keys = Array.from(this.memoryCache.keys());
return keys[index] || null; return keys[index] || null;
@@ -167,6 +195,11 @@ class IndexedDBStorage implements StorageAdapter, Auth0Cache {
} }
// Auth0 Cache interface implementation // Auth0 Cache interface implementation
// allKeys() eliminates Auth0 SDK's CacheKeyManifest fallback (auth0-spa-js line 2319)
allKeys(): string[] {
return Array.from(this.memoryCache.keys());
}
async get(key: string): Promise<any> { async get(key: string): Promise<any> {
await this.initPromise; await this.initPromise;
const value = this.getItem(key); const value = this.getItem(key);
@@ -175,12 +208,21 @@ class IndexedDBStorage implements StorageAdapter, Auth0Cache {
async set(key: string, value: any): Promise<void> { async set(key: string, value: any): Promise<void> {
await this.initPromise; await this.initPromise;
this.setItem(key, JSON.stringify(value)); const stringValue = JSON.stringify(value);
this.memoryCache.set(key, stringValue);
// Fire-and-forget: persist to IndexedDB for page reload survival
this.persistToDB(key, stringValue).catch(error => {
logger.warn(`IndexedDB background persist failed for ${key}`, { error: String(error) });
});
} }
async remove(key: string): Promise<void> { async remove(key: string): Promise<void> {
await this.initPromise; await this.initPromise;
this.removeItem(key); this.memoryCache.delete(key);
// Fire-and-forget: remove from IndexedDB
this.persistToDB(key, null).catch(error => {
logger.warn(`IndexedDB background removal failed for ${key}`, { error: String(error) });
});
} }
// Additional methods for enhanced functionality // Additional methods for enhanced functionality

View File

@@ -6,9 +6,10 @@ import { FeaturesGrid } from './HomePage/FeaturesGrid';
import { motion } from 'framer-motion'; import { motion } from 'framer-motion';
export const HomePage = () => { export const HomePage = () => {
const { loginWithRedirect, isAuthenticated } = useAuth0(); const { loginWithRedirect, isAuthenticated, logout } = useAuth0();
const [mobileMenuOpen, setMobileMenuOpen] = useState(false); const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
const [isScrolled, setIsScrolled] = useState(false); const [isScrolled, setIsScrolled] = useState(false);
const [sessionCleared, setSessionCleared] = useState(false);
const navigate = useNavigate(); const navigate = useNavigate();
useEffect(() => { useEffect(() => {
@@ -41,6 +42,22 @@ export const HomePage = () => {
navigate('/signup'); navigate('/signup');
}; };
const handleClearSession = async () => {
try {
const { indexedDBStorage } = await import('../core/utils/indexeddb-storage');
await indexedDBStorage.clearAll();
Object.keys(localStorage).forEach(key => {
if (key.startsWith('@@auth0')) localStorage.removeItem(key);
});
logout({ openUrl: false });
setSessionCleared(true);
setTimeout(() => setSessionCleared(false), 3000);
} catch (error) {
console.error('[HomePage] Failed to clear session:', error);
window.location.reload();
}
};
return ( return (
<div className="min-h-screen bg-nero text-avus"> <div className="min-h-screen bg-nero text-avus">
{/* Navigation Bar */} {/* Navigation Bar */}
@@ -84,6 +101,12 @@ export const HomePage = () => {
> >
Login Login
</button> </button>
<button
onClick={handleClearSession}
className="text-white/40 hover:text-white/70 text-xs transition-colors min-h-[44px] min-w-[44px] flex items-center"
>
{sessionCleared ? 'Session cleared' : 'Trouble logging in?'}
</button>
</div> </div>
{/* Mobile Menu Button */} {/* Mobile Menu Button */}
@@ -149,6 +172,12 @@ export const HomePage = () => {
> >
Login Login
</button> </button>
<button
onClick={handleClearSession}
className="w-full text-white/40 hover:text-white/70 text-xs py-2 min-h-[44px] transition-colors"
>
{sessionCleared ? 'Session cleared' : 'Trouble logging in?'}
</button>
</motion.div> </motion.div>
)} )}
</div> </div>