Fix Auth Errors

This commit is contained in:
Eric Gullickson
2025-09-22 10:27:10 -05:00
parent 3588372cef
commit 8fd7973656
19 changed files with 1342 additions and 174 deletions

View File

@@ -3,25 +3,70 @@
* @ai-context Handles auth tokens and error responses
*/
import axios, { AxiosInstance, InternalAxiosRequestConfig } from 'axios';
import axios, { InternalAxiosRequestConfig } from 'axios';
import toast from 'react-hot-toast';
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || '/api';
export const apiClient: AxiosInstance = axios.create({
baseURL: API_BASE_URL,
timeout: 10000,
headers: {
'Content-Type': 'application/json',
},
});
// Will be replaced by createQueuedAxios below
// Auth readiness flag to avoid noisy 401 toasts during mobile auth initialization
let authReady = false;
export const setAuthReady = (ready: boolean) => { authReady = ready; };
export const isAuthReady = () => authReady;
// Request interceptor for auth token with mobile debugging
// Create a wrapper around axios that queues requests until auth is ready
const createQueuedAxios = () => {
const queuedClient = axios.create({
baseURL: API_BASE_URL,
timeout: 10000,
headers: {
'Content-Type': 'application/json',
},
});
// Store original methods
const originalRequest = queuedClient.request.bind(queuedClient);
const originalGet = queuedClient.get.bind(queuedClient);
const originalPost = queuedClient.post.bind(queuedClient);
const originalPut = queuedClient.put.bind(queuedClient);
const originalDelete = queuedClient.delete.bind(queuedClient);
const originalPatch = queuedClient.patch.bind(queuedClient);
// Create wrapper function for auth queue checking
const wrapWithAuthQueue = (originalMethod: any, methodName: string) => {
return async (...args: any[]) => {
try {
const { queueRequest, isAuthInitialized } = await import('../auth/auth-gate');
if (!isAuthInitialized()) {
console.log(`[API Client] Queuing ${methodName} request until auth ready`);
return queueRequest(() => originalMethod(...args));
}
return originalMethod(...args);
} catch (error) {
console.warn(`[API Client] Auth gate import failed for ${methodName}, proceeding with request:`, error);
return originalMethod(...args);
}
};
};
// Override all HTTP methods
queuedClient.request = wrapWithAuthQueue(originalRequest, 'REQUEST');
queuedClient.get = wrapWithAuthQueue(originalGet, 'GET');
queuedClient.post = wrapWithAuthQueue(originalPost, 'POST');
queuedClient.put = wrapWithAuthQueue(originalPut, 'PUT');
queuedClient.delete = wrapWithAuthQueue(originalDelete, 'DELETE');
queuedClient.patch = wrapWithAuthQueue(originalPatch, 'PATCH');
return queuedClient;
};
// Replace the basic axios instance with the queued version
export const apiClient = createQueuedAxios();
// Request interceptor for token injection and logging
apiClient.interceptors.request.use(
async (config: InternalAxiosRequestConfig) => {
// Token will be added by Auth0 wrapper

View File

@@ -6,6 +6,8 @@ import React from 'react';
import { Auth0Provider as BaseAuth0Provider, useAuth0 } from '@auth0/auth0-react';
import { useNavigate } from 'react-router-dom';
import { apiClient, setAuthReady } from '../api/client';
import { createIndexedDBAdapter } from '../utils/indexeddb-storage';
import { setAuthInitialized } from './auth-gate';
interface Auth0ProviderProps {
children: React.ReactNode;
@@ -38,8 +40,8 @@ export const Auth0Provider: React.FC<Auth0ProviderProps> = ({ children }) => {
scope: 'openid profile email offline_access',
}}
onRedirectCallback={onRedirectCallback}
// Mobile Safari/ITP: use localstorage + refresh tokens to avoid thirdparty cookie silent auth failures
cacheLocation="localstorage"
// Mobile-optimized: use IndexedDB for better mobile compatibility
cache={createIndexedDBAdapter()}
useRefreshTokens={true}
useRefreshTokensFallback={true}
>
@@ -162,8 +164,17 @@ const TokenInjector: React.FC<{ children: React.ReactNode }> = ({ children }) =>
let interceptorId: number | undefined;
if (isAuthenticated) {
// Enhanced pre-warm token cache for mobile devices
// Enhanced pre-warm token cache for mobile devices with IndexedDB wait
const initializeToken = async () => {
// Wait for IndexedDB to be ready first
try {
const { indexedDBStorage } = await import('../utils/indexeddb-storage');
await indexedDBStorage.waitForReady();
console.log('[Auth] IndexedDB storage is ready');
} catch (error) {
console.warn('[Auth] IndexedDB not ready, proceeding anyway:', error);
}
// Give Auth0 more time to fully initialize on mobile devices
const isMobile = /Android|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent);
const initDelay = isMobile ? 500 : 100; // Longer delay for mobile
@@ -177,6 +188,7 @@ const TokenInjector: React.FC<{ children: React.ReactNode }> = ({ children }) =>
console.log('[Mobile Auth] 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');
setRetryCount(prev => prev + 1);
@@ -189,9 +201,13 @@ const TokenInjector: React.FC<{ children: React.ReactNode }> = ({ children }) =>
initializeToken();
// Add token to all API requests with enhanced error handling
// Add token to all API requests with enhanced error handling and IndexedDB wait
interceptorId = apiClient.interceptors.request.use(async (config) => {
try {
// Ensure IndexedDB is ready before getting tokens
const { indexedDBStorage } = await import('../utils/indexeddb-storage');
await indexedDBStorage.waitForReady();
const token = await getTokenWithRetry();
if (token) {
config.headers.Authorization = `Bearer ${token}`;
@@ -209,6 +225,7 @@ const TokenInjector: React.FC<{ children: React.ReactNode }> = ({ children }) =>
} else {
setRetryCount(0);
setAuthReady(false);
setAuthInitialized(false); // Reset auth gate when not authenticated
}
// Cleanup function to remove interceptor

View File

@@ -0,0 +1,102 @@
/**
* @ai-summary Authentication gate to ensure API requests wait for auth initialization
* @ai-context Prevents race conditions between IndexedDB init and API calls
*/
// Global authentication readiness state
let authInitialized = false;
let authInitPromise: Promise<void> | null = null;
let resolveAuthInit: (() => void) | null = null;
// Debug logging
console.log('[Auth Gate] Module loaded, authInitialized:', authInitialized);
// Request queue to hold requests until auth is ready
interface QueuedRequest {
resolve: (value: any) => void;
reject: (error: any) => void;
requestFn: () => Promise<any>;
}
let requestQueue: QueuedRequest[] = [];
let isProcessingQueue = false;
export const waitForAuthInit = (): Promise<void> => {
if (authInitialized) {
return Promise.resolve();
}
if (!authInitPromise) {
authInitPromise = new Promise((resolve) => {
resolveAuthInit = resolve;
});
}
return authInitPromise;
};
export const setAuthInitialized = (initialized: boolean) => {
authInitialized = initialized;
if (initialized) {
console.log('[Auth Gate] Authentication fully initialized');
// Resolve the auth promise
if (resolveAuthInit) {
resolveAuthInit();
resolveAuthInit = null;
}
// Process any queued requests
processRequestQueue();
} else {
// Reset state when auth becomes unavailable
authInitPromise = null;
resolveAuthInit = null;
requestQueue = [];
}
};
export const isAuthInitialized = () => authInitialized;
// Queue a request until auth is ready
export const queueRequest = <T>(requestFn: () => Promise<T>): Promise<T> => {
if (authInitialized) {
return requestFn();
}
return new Promise((resolve, reject) => {
requestQueue.push({
resolve,
reject,
requestFn
});
console.log(`[Auth Gate] Queued request, ${requestQueue.length} total in queue`);
});
};
// Process all queued requests
const processRequestQueue = async () => {
if (isProcessingQueue || requestQueue.length === 0) {
return;
}
isProcessingQueue = true;
console.log(`[Auth Gate] Processing ${requestQueue.length} queued requests`);
const queueToProcess = [...requestQueue];
requestQueue = [];
for (const { resolve, reject, requestFn } of queueToProcess) {
try {
const result = await requestFn();
resolve(result);
} catch (error) {
reject(error);
}
}
isProcessingQueue = false;
console.log('[Auth Gate] Finished processing queued requests');
};

View File

@@ -1,5 +1,6 @@
import { create } from 'zustand';
import { persist, createJSONStorage } from 'zustand/middleware';
import { safeStorage } from '../utils/safe-storage';
interface UserPreferences {
unitSystem: 'imperial' | 'metric';
@@ -90,7 +91,7 @@ export const useUserStore = create<UserState>()(
}),
{
name: 'motovaultpro-user-context',
storage: createJSONStorage(() => localStorage),
storage: createJSONStorage(() => safeStorage),
partialize: (state) => ({
userProfile: state.userProfile,
preferences: state.preferences,

View File

@@ -0,0 +1,211 @@
/**
* @ai-summary IndexedDB storage adapter for Auth0 and Zustand persistence
* @ai-context Replaces localStorage with IndexedDB for mobile browser compatibility
*/
interface StorageAdapter {
getItem(key: string): string | null;
setItem(key: string, value: string): void;
removeItem(key: string): void;
clear(): void;
key(index: number): string | null;
readonly length: number;
}
interface Auth0Cache {
get(key: string): Promise<any>;
set(key: string, value: any): Promise<void>;
remove(key: string): Promise<void>;
}
class IndexedDBStorage implements StorageAdapter, Auth0Cache {
private dbName = 'motovaultpro-storage';
private dbVersion = 1;
private storeName = 'keyvalue';
private db: IDBDatabase | null = null;
private memoryCache = new Map<string, string>();
private initPromise: Promise<void>;
private isReady = false;
constructor() {
this.initPromise = this.initialize();
}
private async initialize(): Promise<void> {
try {
this.db = await this.openDatabase();
await this.loadCacheFromDB();
this.isReady = true;
console.log('[IndexedDB] Storage initialized successfully');
} catch (error) {
console.error('[IndexedDB] Initialization failed, using memory only:', error);
this.isReady = false;
}
}
private openDatabase(): Promise<IDBDatabase> {
return new Promise((resolve) => {
const request = indexedDB.open(this.dbName, this.dbVersion);
request.onerror = () => {
console.error(`IndexedDB open failed: ${request.error?.message}`);
resolve(null as any); // Fallback to memory-only mode
};
request.onsuccess = () => {
resolve(request.result);
};
request.onupgradeneeded = (event) => {
const db = (event.target as IDBOpenDBRequest).result;
if (!db.objectStoreNames.contains(this.storeName)) {
db.createObjectStore(this.storeName);
}
};
});
}
private async loadCacheFromDB(): Promise<void> {
if (!this.db) return;
return new Promise((resolve) => {
const transaction = this.db!.transaction([this.storeName], 'readonly');
const store = transaction.objectStore(this.storeName);
const request = store.getAll();
request.onsuccess = () => {
const results = request.result;
this.memoryCache.clear();
for (const item of results) {
if (item.key && typeof item.value === 'string') {
this.memoryCache.set(item.key, item.value);
}
}
console.log(`[IndexedDB] Loaded ${this.memoryCache.size} items into cache`);
resolve();
};
request.onerror = () => {
console.warn('[IndexedDB] Failed to load cache from DB:', request.error);
resolve(); // Don't fail initialization
};
});
}
private async persistToDB(key: string, value: string | null): Promise<void> {
if (!this.db) return;
return new Promise((resolve) => {
const transaction = this.db!.transaction([this.storeName], 'readwrite');
const store = transaction.objectStore(this.storeName);
if (value === null) {
const request = store.delete(key);
request.onsuccess = () => resolve();
request.onerror = () => {
console.warn(`[IndexedDB] Failed to delete ${key}:`, request.error);
resolve();
};
} else {
const request = store.put(value, key);
request.onsuccess = () => resolve();
request.onerror = () => {
console.warn(`[IndexedDB] Failed to persist ${key}:`, request.error);
resolve();
};
}
});
}
// Synchronous Storage interface (uses memory cache)
getItem(key: string): string | null {
return this.memoryCache.get(key) || null;
}
setItem(key: string, value: string): void {
this.memoryCache.set(key, value);
// Async persist to IndexedDB (non-blocking)
if (this.isReady) {
this.persistToDB(key, value).catch(error => {
console.warn(`[IndexedDB] Background persist failed for ${key}:`, error);
});
}
}
removeItem(key: string): void {
this.memoryCache.delete(key);
// Async remove from IndexedDB (non-blocking)
if (this.isReady) {
this.persistToDB(key, null).catch(error => {
console.warn(`[IndexedDB] Background removal failed for ${key}:`, error);
});
}
}
clear(): void {
this.memoryCache.clear();
// Async clear IndexedDB (non-blocking)
if (this.db) {
const transaction = this.db.transaction([this.storeName], 'readwrite');
const store = transaction.objectStore(this.storeName);
store.clear();
}
}
key(index: number): string | null {
const keys = Array.from(this.memoryCache.keys());
return keys[index] || null;
}
get length(): number {
return this.memoryCache.size;
}
// Auth0 Cache interface implementation
async get(key: string): Promise<any> {
await this.initPromise;
const value = this.getItem(key);
return value ? JSON.parse(value) : undefined;
}
async set(key: string, value: any): Promise<void> {
await this.initPromise;
this.setItem(key, JSON.stringify(value));
}
async remove(key: string): Promise<void> {
await this.initPromise;
this.removeItem(key);
}
// Additional methods for enhanced functionality
async waitForReady(): Promise<void> {
return this.initPromise;
}
get isInitialized(): boolean {
return this.isReady;
}
// For debugging
getStats() {
return {
cacheSize: this.memoryCache.size,
isReady: this.isReady,
hasDB: !!this.db
};
}
}
// Create singleton instance
export const indexedDBStorage = new IndexedDBStorage();
// For Auth0 compatibility - ensure storage is ready before use
export const createIndexedDBAdapter = () => {
return indexedDBStorage;
};

View File

@@ -1,107 +1,9 @@
/**
* @ai-summary Safe localStorage wrapper for mobile browsers
* @ai-context Prevents errors when localStorage is blocked in mobile browsers
* @ai-summary IndexedDB storage wrapper for mobile browsers
* @ai-context Replaces localStorage with IndexedDB for better mobile compatibility
*/
// Safe localStorage wrapper that won't crash on mobile browsers
const createSafeStorage = () => {
let isAvailable = false;
import { indexedDBStorage } from './indexeddb-storage';
// Test localStorage availability
try {
const testKey = '__motovaultpro_storage_test__';
localStorage.setItem(testKey, 'test');
localStorage.removeItem(testKey);
isAvailable = true;
} catch (error) {
console.warn('[Storage] localStorage not available, using memory fallback:', error);
isAvailable = false;
}
// Memory fallback when localStorage is blocked
const memoryStorage = new Map<string, string>();
return {
getItem: (key: string): string | null => {
try {
if (isAvailable) {
return localStorage.getItem(key);
} else {
return memoryStorage.get(key) || null;
}
} catch (error) {
console.warn('[Storage] getItem failed, using memory fallback:', error);
return memoryStorage.get(key) || null;
}
},
setItem: (key: string, value: string): void => {
try {
if (isAvailable) {
localStorage.setItem(key, value);
} else {
memoryStorage.set(key, value);
}
} catch (error) {
console.warn('[Storage] setItem failed, using memory fallback:', error);
memoryStorage.set(key, value);
}
},
removeItem: (key: string): void => {
try {
if (isAvailable) {
localStorage.removeItem(key);
} else {
memoryStorage.delete(key);
}
} catch (error) {
console.warn('[Storage] removeItem failed, using memory fallback:', error);
memoryStorage.delete(key);
}
},
// For zustand createJSONStorage compatibility
key: (index: number): string | null => {
try {
if (isAvailable) {
return localStorage.key(index);
} else {
const keys = Array.from(memoryStorage.keys());
return keys[index] || null;
}
} catch (error) {
console.warn('[Storage] key access failed:', error);
return null;
}
},
get length(): number {
try {
if (isAvailable) {
return localStorage.length;
} else {
return memoryStorage.size;
}
} catch (error) {
console.warn('[Storage] length access failed:', error);
return 0;
}
},
clear: (): void => {
try {
if (isAvailable) {
localStorage.clear();
} else {
memoryStorage.clear();
}
} catch (error) {
console.warn('[Storage] clear failed:', error);
memoryStorage.clear();
}
}
};
};
export const safeStorage = createSafeStorage();
// Export IndexedDB storage as the safe storage implementation
export const safeStorage = indexedDBStorage;