fix: Mobile login redirects to homepage without showing Auth0 login page (#188) #193

Merged
egullickson merged 9 commits from issue-188-fix-mobile-login-redirect into main 2026-02-15 15:36:38 +00:00
3 changed files with 58 additions and 102 deletions
Showing only changes of commit b5b82db532 - Show all commits

View File

@@ -4,7 +4,7 @@
import React, { useState, useEffect, useTransition, useCallback, lazy } from 'react';
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 { useIsAuthInitialized } from './core/auth/auth-gate';
import { motion, AnimatePresence } from 'framer-motion';
@@ -310,11 +310,11 @@ const EditVehicleScreen: React.FC<EditVehicleScreenProps> = ({ vehicle, onBack,
};
function App() {
const { isLoading, isAuthenticated, user } = useAuth0();
const { isLoading, isAuthenticated, user, error: authError } = useAuth0();
const location = useLocation();
const navigate = useNavigate();
const isAuthGateReady = useIsAuthInitialized();
const [_isPending, startTransition] = useTransition();
console.log('[DEBUG App] Render check - isLoading:', isLoading, 'isAuthenticated:', isAuthenticated, 'isAuthGateReady:', isAuthGateReady);
// Initialize data synchronization
const { prefetchForNavigation } = useDataSync();
@@ -486,7 +486,6 @@ function App() {
}
}, [navigateToScreen, navigateToVehicleSubScreen]);
console.log('MotoVaultPro status:', { isLoading, isAuthenticated, mobileMode, activeScreen, vehicleSubScreen, userAgent: navigator.userAgent });
const isGarageRoute = location.pathname === '/garage' || location.pathname.startsWith('/garage/');
const isCallbackRoute = location.pathname === '/callback';
@@ -568,6 +567,21 @@ function App() {
}
if (isCallbackRoute) {
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>
@@ -669,7 +683,6 @@ function App() {
// 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
if (!isAuthGateReady) {
console.log('[DEBUG App] Auth gate not ready yet, showing loading state');
if (mobileMode) {
return (
<ThemeProvider>

View File

@@ -8,6 +8,7 @@ import { useNavigate } from 'react-router-dom';
import { apiClient, setAuthReady } from '../api/client';
import { createIndexedDBAdapter } from '../utils/indexeddb-storage';
import { setAuthInitialized } from './auth-gate';
import logger from '../../utils/logger';
interface Auth0ProviderProps {
children: React.ReactNode;
@@ -20,12 +21,8 @@ export const Auth0Provider: React.FC<Auth0ProviderProps> = ({ children }) => {
const clientId = import.meta.env.VITE_AUTH0_CLIENT_ID;
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 }) => {
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
// Pass the intended destination as state for after status check
navigate('/callback', {
@@ -57,19 +54,10 @@ export const Auth0Provider: React.FC<Auth0ProviderProps> = ({ children }) => {
// Component to inject token into API client with mobile support
const TokenInjector: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const { getAccessTokenSilently, isAuthenticated, isLoading, user, logout } = useAuth0();
const { getAccessTokenSilently, isAuthenticated, isLoading, logout } = useAuth0();
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
const getTokenWithRetry = async (maxRetries = 5, delayMs = 300): Promise<any> => {
for (let attempt = 0; attempt < maxRetries; attempt++) {
@@ -94,26 +82,21 @@ const TokenInjector: React.FC<{ children: React.ReactNode }> = ({ children }) =>
}
const token = await getAccessTokenSilently(tokenOptions);
console.log(`[Mobile Auth] Token acquired successfully on attempt ${attempt + 1}`, {
cacheMode: tokenOptions.cacheMode,
timeout: tokenOptions.timeoutInSeconds
});
logger.debug(`Token acquired on attempt ${attempt + 1}`);
return token;
} catch (error: any) {
console.warn(`[Mobile Auth] Attempt ${attempt + 1}/${maxRetries} failed:`, {
error: error.message || error,
cacheMode: attempt <= 2 ? 'on' : 'off'
logger.warn(`Token attempt ${attempt + 1}/${maxRetries} failed`, {
error: error.message || String(error),
});
// Mobile-specific: longer delays and more attempts
if (attempt < maxRetries - 1) {
const delay = delayMs * Math.pow(1.5, attempt); // Gentler exponential backoff
console.log(`[Mobile Auth] Waiting ${Math.round(delay)}ms before retry...`);
const delay = delayMs * Math.pow(1.5, attempt);
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;
};
@@ -130,7 +113,7 @@ const TokenInjector: React.FC<{ children: React.ReactNode }> = ({ children }) =>
const errorType = error?.error || error?.message || '';
if (errorType.includes('login_required') || errorType.includes('consent_required') ||
errorType.includes('invalid_grant')) {
console.warn('[Auth] Stale token detected, clearing auth state');
logger.warn('Stale token detected, clearing auth state');
const { indexedDBStorage } = await import('../utils/indexeddb-storage');
await indexedDBStorage.clearAll();
logout({ openUrl: false });
@@ -143,55 +126,6 @@ const TokenInjector: React.FC<{ children: React.ReactNode }> = ({ children }) =>
validateToken();
}, [isAuthenticated, isLoading, getAccessTokenSilently, logout]);
// Force authentication check for devices when user seems logged in but isAuthenticated is false
React.useEffect(() => {
const isMobile = /Android|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent);
// Debug current state
console.log('[Auth Debug] State check:', {
isMobile,
isLoading,
isAuthenticated,
pathname: window.location.pathname,
userAgent: navigator.userAgent.substring(0, 50) + '...'
});
// Trigger for mobile devices OR any device on protected route without authentication
if (!isLoading && !isAuthenticated && window.location.pathname !== '/') {
console.log('[Auth Debug] User on protected route but not authenticated, forcing token check...');
// 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);
}
};
forceAuthCheck();
}
}, [isLoading, isAuthenticated, getAccessTokenSilently]);
React.useEffect(() => {
let interceptorId: number | undefined;
@@ -202,34 +136,30 @@ const TokenInjector: React.FC<{ children: React.ReactNode }> = ({ children }) =>
try {
const { indexedDBStorage } = await import('../utils/indexeddb-storage');
await indexedDBStorage.waitForReady();
console.log('[Auth] IndexedDB storage is ready');
logger.debug('IndexedDB storage is ready');
} 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)
const isMobile = /Android|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent);
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));
} else {
console.log('[Auth] Initializing token cache (desktop, no delay)');
}
try {
const token = await getTokenWithRetry();
if (token) {
console.log('[Mobile Auth] Token pre-warming successful');
logger.debug('Token pre-warming successful');
setRetryCount(0);
setAuthReady(true);
setAuthInitialized(true); // Signal that auth is fully ready
} 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);
}
} catch (error) {
console.error('[Mobile Auth] Token initialization failed:', error);
logger.error('Token initialization failed', { error: String(error) });
setRetryCount(prev => prev + 1);
}
};
@@ -248,11 +178,11 @@ const TokenInjector: React.FC<{ children: React.ReactNode }> = ({ children }) =>
config.headers.Authorization = `Bearer ${token}`;
setAuthReady(true);
} 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
}
} 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
}
return config;

View File

@@ -3,6 +3,8 @@
* @ai-context Replaces localStorage with IndexedDB for mobile browser compatibility
*/
import logger from '../../utils/logger';
interface StorageAdapter {
getItem(key: string): string | null;
setItem(key: string, value: string): void;
@@ -36,9 +38,9 @@ class IndexedDBStorage implements StorageAdapter, Auth0Cache {
this.db = await this.openDatabase();
await this.loadCacheFromDB();
this.isReady = true;
console.log('[IndexedDB] Storage initialized successfully');
logger.debug('IndexedDB storage initialized successfully');
} 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;
}
}
@@ -48,7 +50,7 @@ class IndexedDBStorage implements StorageAdapter, Auth0Cache {
const request = indexedDB.open(this.dbName, this.dbVersion);
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
};
@@ -84,13 +86,13 @@ class IndexedDBStorage implements StorageAdapter, Auth0Cache {
}
cursor.continue();
} else {
console.log(`[IndexedDB] Loaded ${this.memoryCache.size} items into cache`);
logger.debug(`IndexedDB loaded ${this.memoryCache.size} items into cache`);
resolve();
}
};
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();
};
});
@@ -107,14 +109,14 @@ class IndexedDBStorage implements StorageAdapter, Auth0Cache {
const request = store.delete(key);
request.onsuccess = () => resolve();
request.onerror = () => {
console.warn(`[IndexedDB] Failed to delete ${key}:`, request.error);
logger.warn(`IndexedDB failed to delete ${key}`, { error: String(request.error) });
resolve();
};
} else {
const request = store.put(value, key);
request.onsuccess = () => resolve();
request.onerror = () => {
console.warn(`[IndexedDB] Failed to persist ${key}:`, request.error);
logger.warn(`IndexedDB failed to persist ${key}`, { error: String(request.error) });
resolve();
};
}
@@ -132,7 +134,7 @@ class IndexedDBStorage implements StorageAdapter, Auth0Cache {
// Async persist to IndexedDB (non-blocking)
if (this.isReady) {
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) });
});
}
}
@@ -143,7 +145,7 @@ class IndexedDBStorage implements StorageAdapter, Auth0Cache {
// Async remove from IndexedDB (non-blocking)
if (this.isReady) {
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) });
});
}
}
@@ -193,6 +195,11 @@ class IndexedDBStorage implements StorageAdapter, Auth0Cache {
}
// 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> {
await this.initPromise;
const value = this.getItem(key);
@@ -203,13 +210,19 @@ class IndexedDBStorage implements StorageAdapter, Auth0Cache {
await this.initPromise;
const stringValue = JSON.stringify(value);
this.memoryCache.set(key, stringValue);
await this.persistToDB(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> {
await this.initPromise;
this.memoryCache.delete(key);
await this.persistToDB(key, null);
// 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