diff --git a/.DS_Store b/.DS_Store index 8fbbb0f0..f32eb597 100644 Binary files a/.DS_Store and b/.DS_Store differ diff --git a/.gitignore b/.gitignore index 19d3a29d..56acdbe8 100644 --- a/.gitignore +++ b/.gitignore @@ -1,7 +1,180 @@ +# Dependencies +node_modules/ venv/ +env/ + +# Build outputs dist/ +build/ +*.tsbuildinfo + +# Environment variables +.env +.env.local +.env.development.local +.env.test.local +.env.production.local + +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +# Runtime data +pids +*.pid +*.seed +*.pid.lock + +# Coverage directory used by tools like istanbul +coverage/ +*.lcov + +# nyc test coverage +.nyc_output + +# Dependency directories +node_modules/ +jspm_packages/ + +# Optional npm cache directory +.npm + +# Optional eslint cache +.eslintcache + +# Optional REPL history +.node_repl_history + +# Output of 'npm pack' +*.tgz + +# Yarn Integrity file +.yarn-integrity + +# TypeScript cache +*.tsbuildinfo + +# Optional eslint cache +.eslintcache + +# Microbundle cache +.rpt2_cache/ +.rts2_cache_cjs/ +.rts2_cache_es/ +.rts2_cache_umd/ + +# Optional REPL history +.node_repl_history # Ignore Python cache files __pycache__/ *.py[cod] -*pycache* +*.pyo +*.pyd +.Python +*.so +.pytest_cache/ + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +*.manifest +*.spec + +# Unit test / coverage reports +htmlcov/ +.tox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +.hypothesis/ +.pytest_cache/ + +# Jupyter Notebook +.ipynb_checkpoints + +# pyenv +.python-version + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# IDE and editor files +.vscode/ +.idea/ +*.swp +*.swo +*~ +.DS_Store +Thumbs.db + +# Temporary files +*.tmp +*.temp +temp/ +tmp/ +uploads/ + +# OS generated files +.DS_Store +.DS_Store? +._* +.Spotlight-V100 +.Trashes +ehthumbs.db +Thumbs.db + +# Database files +*.db +*.sqlite +*.sqlite3 + +# Backup files +*.bak +*.backup diff --git a/backend/.DS_Store b/backend/.DS_Store index 76609246..b2dc3ede 100644 Binary files a/backend/.DS_Store and b/backend/.DS_Store differ diff --git a/backend/app/routes/__pycache__/focus_group_ai.cpython-313.pyc b/backend/app/routes/__pycache__/focus_group_ai.cpython-313.pyc index 0067947a..e416ec31 100644 Binary files a/backend/app/routes/__pycache__/focus_group_ai.cpython-313.pyc and b/backend/app/routes/__pycache__/focus_group_ai.cpython-313.pyc differ diff --git a/backend/app/routes/focus_group_ai.py b/backend/app/routes/focus_group_ai.py index 0fd177dc..67014c70 100644 --- a/backend/app/routes/focus_group_ai.py +++ b/backend/app/routes/focus_group_ai.py @@ -888,7 +888,7 @@ async def get_autonomous_conversation_status(focus_group_id): @focus_group_ai_bp.route('/conversation/state/', methods=['GET']) @jwt_required(optional=True) -def get_conversation_state(focus_group_id): +async def get_conversation_state(focus_group_id): """ Get the current conversation state for a focus group. @@ -900,7 +900,7 @@ def get_conversation_state(focus_group_id): state_manager = ConversationStateManager(focus_group_id) # Get state - state = state_manager.get_conversation_state() + state = await state_manager.get_conversation_state() if "error" in state: return jsonify(state), 404 if "not found" in state["error"] else 500 @@ -920,7 +920,7 @@ def get_conversation_state(focus_group_id): @focus_group_ai_bp.route('/conversation/analytics/', methods=['GET']) @jwt_required(optional=True) -def get_conversation_analytics(focus_group_id): +async def get_conversation_analytics(focus_group_id): """ Get detailed conversation analytics for a focus group. @@ -932,7 +932,7 @@ def get_conversation_analytics(focus_group_id): state_manager = ConversationStateManager(focus_group_id) # Get analytics - analytics = state_manager.get_conversation_analytics() + analytics = await state_manager.get_conversation_analytics() if "error" in analytics: return jsonify(analytics), 404 if "not found" in analytics["error"] else 500 diff --git a/backend/app/websocket_manager_async.py b/backend/app/websocket_manager_async.py index 4a58284a..378a6385 100644 --- a/backend/app/websocket_manager_async.py +++ b/backend/app/websocket_manager_async.py @@ -43,18 +43,15 @@ class AsyncWebSocketManager: print(f"🔌 ASYNC PROCESS DEBUG - Connection handler PID: {process_id}, Thread: {thread_id}") logger.info(f"WebSocket connection attempt from {sid}") - # Validate JWT token from auth data (temporarily allow without token for testing) + # Validate JWT token from auth data - require authentication if not auth or 'token' not in auth: - logger.warning(f"WebSocket connection without auth token - allowing for testing") - # Temporarily allow connections without tokens for testing - self.user_sessions[sid] = { - 'user_id': 'test_user', # Default user for testing - 'connected_at': datetime.utcnow(), - 'focus_groups': set() - } - logger.info(f"WebSocket connected without auth - Session: {sid}") - await self.sio.emit('connected', {'status': 'success', 'session_id': sid}, to=sid) - return True + logger.warning(f"WebSocket connection without auth token - rejecting") + await self.sio.emit('auth_error', { + 'error': 'missing_token', + 'message': 'Authentication token required' + }, to=sid) + await self.sio.disconnect(sid) + return False try: # Decode and validate JWT token with better error handling @@ -84,15 +81,14 @@ class AsyncWebSocketManager: logger.warning(f"JWT decode failed: {jwt_error}") logger.warning(f"Token format: {len(token_parts)} segments, first 20 chars: {token[:20]}...") - # During migration, allow connection with test user instead of disconnecting - logger.info(f"Allowing WebSocket connection with test user due to JWT transition") - self.user_sessions[sid] = { - 'user_id': 'test_user', # Default user during JWT transition - 'connected_at': datetime.utcnow(), - 'focus_groups': set() - } - await self.sio.emit('connected', {'status': 'success', 'session_id': sid, 'auth': 'fallback'}, to=sid) - return True + # Reject connection for expired or invalid tokens + await self.sio.emit('auth_error', { + 'error': 'invalid_token', + 'message': f'Token validation failed: {str(jwt_error)}', + 'expired': 'expired' in str(jwt_error).lower() + }, to=sid) + await self.sio.disconnect(sid) + return False # Store user session info self.user_sessions[sid] = { @@ -108,6 +104,10 @@ class AsyncWebSocketManager: except Exception as e: logger.error(f"Connection authentication failed: {e}") + await self.sio.emit('auth_error', { + 'error': 'authentication_failed', + 'message': 'Authentication error occurred' + }, to=sid) await self.sio.disconnect(sid) return False diff --git a/backend/requirements.txt b/backend/requirements.txt index 424ee1af..741f6678 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -1,17 +1,33 @@ -flask -werkzeug -flask-cors -pymongo -python-dotenv -flask-jwt-extended -bcrypt -pydantic +# Web Framework (Async) +quart +quart-cors hypercorn +werkzeug + +# Database (Async) +motor +pymongo + +# Authentication & Security +bcrypt +PyJWT +msal + +# AI & LLM Services google-genai openai -requests llama-cloud-services -msal -PyJWT -flask-socketio -eventlet \ No newline at end of file + +# WebSocket & Real-time +python-socketio + +# HTTP Clients +httpx +requests + +# Data Validation & Processing +pydantic +pillow + +# Configuration & Utilities +python-dotenv \ No newline at end of file diff --git a/dist/index.html b/dist/index.html index 6c1eb4a8..081a98f6 100644 --- a/dist/index.html +++ b/dist/index.html @@ -7,7 +7,7 @@ - + diff --git a/src/contexts/AuthContext.tsx b/src/contexts/AuthContext.tsx index 00737b5a..40e8cef8 100644 --- a/src/contexts/AuthContext.tsx +++ b/src/contexts/AuthContext.tsx @@ -49,19 +49,56 @@ export function AuthProvider({ children }: { children: ReactNode }) { } // For all other auth errors, clear the session and redirect - setToken(null); - setUser(null); + clearAuthData(); toast.error('Session expired', { description: 'Please log in again' }); navigate('/login'); }; + // Listen for WebSocket authentication errors + const handleWebSocketAuthError = (event: Event) => { + const customEvent = event as CustomEvent; + const errorData = customEvent.detail || {}; + + console.log('WebSocket authentication error:', errorData); + + // Clear auth data and redirect to login + clearAuthData(); + toast.error('Session expired', { + description: errorData.expired ? 'Your session has expired. Please log in again.' : 'Authentication failed. Please log in again.' + }); + navigate('/login'); + }; + window.addEventListener(AUTH_ERROR_EVENT, handleAuthError); + window.addEventListener('ws:auth_error', handleWebSocketAuthError); return () => { window.removeEventListener(AUTH_ERROR_EVENT, handleAuthError); + window.removeEventListener('ws:auth_error', handleWebSocketAuthError); }; }, [navigate]); + // Helper function to check if JWT token is expired + const isTokenExpired = (token: string): boolean => { + try { + const payload = JSON.parse(atob(token.split('.')[1])); + const currentTime = Date.now() / 1000; + return payload.exp < currentTime; + } catch (error) { + console.error('Error parsing JWT token:', error); + return true; // Treat malformed tokens as expired + } + }; + + // Helper function to clear authentication data + const clearAuthData = () => { + localStorage.removeItem('auth_token'); + localStorage.removeItem('user'); + localStorage.removeItem('auth_type'); + setToken(null); + setUser(null); + }; + useEffect(() => { // Check if user is already logged in const storedToken = localStorage.getItem('auth_token'); @@ -73,14 +110,20 @@ export function AuthProvider({ children }: { children: ReactNode }) { }); if (storedToken && storedUser) { - try { - setToken(storedToken); - setUser(JSON.parse(storedUser)); - console.log('User session restored from localStorage'); - } catch (error) { - console.error('Failed to parse stored user data:', error); - localStorage.removeItem('auth_token'); - localStorage.removeItem('user'); + // Check if token is expired + if (isTokenExpired(storedToken)) { + console.log('Stored token is expired, clearing authentication data'); + clearAuthData(); + toast.error('Session expired', { description: 'Please log in again' }); + } else { + try { + setToken(storedToken); + setUser(JSON.parse(storedUser)); + console.log('User session restored from localStorage'); + } catch (error) { + console.error('Failed to parse stored user data:', error); + clearAuthData(); + } } } else { console.log('No stored authentication data found'); @@ -114,12 +157,11 @@ export function AuthProvider({ children }: { children: ReactNode }) { }) .catch(error => { if (error.response && error.response.status === 401) { - // Handle unauthorized - invalid token - console.error('Token invalid (401):', error); - localStorage.removeItem('auth_token'); - localStorage.removeItem('user'); - setToken(null); - setUser(null); + // Handle unauthorized - invalid or expired token + console.error('Token invalid or expired (401):', error); + clearAuthData(); + toast.error('Session expired', { description: 'Please log in again' }); + navigate('/login'); } else { console.warn('Profile validation error (not clearing token):', error); // Mark as validated anyway to prevent repeated retries @@ -219,12 +261,8 @@ export function AuthProvider({ children }: { children: ReactNode }) { const logout = async () => { const authType = localStorage.getItem('auth_type'); - // Clear local storage - localStorage.removeItem('auth_token'); - localStorage.removeItem('user'); - localStorage.removeItem('auth_type'); - setToken(null); - setUser(null); + // Clear local storage using helper function + clearAuthData(); // If user was authenticated with Microsoft, also sign out from Microsoft if (authType === 'microsoft' && accounts.length > 0) { diff --git a/src/contexts/NavigationContext.tsx b/src/contexts/NavigationContext.tsx index daeb4021..cccd8c11 100644 --- a/src/contexts/NavigationContext.tsx +++ b/src/contexts/NavigationContext.tsx @@ -6,6 +6,7 @@ export interface NavigationState { focusGroupTab?: 'setup' | 'review' | 'participants'; isNewFocusGroup?: boolean; focusGroupData?: any; + folderId?: string; // For persona list folder context } interface NavigationContextType { diff --git a/src/hooks/usePersonaDetails.ts b/src/hooks/usePersonaDetails.ts index e7e7f166..3ff1e2c4 100644 --- a/src/hooks/usePersonaDetails.ts +++ b/src/hooks/usePersonaDetails.ts @@ -645,7 +645,16 @@ export function usePersonaDetails() { } // Clear navigation state after using it clearNavigationState(); - } else if (isFromReview) { + } + // Check if we came from synthetic users with a specific folder context + else if (navigationState.previousRoute === '/synthetic-users' && navigationState.folderId) { + // Navigate back to synthetic users with the folder filter applied + // We'll use URL state to restore the folder selection + navigate(`/synthetic-users?folder=${navigationState.folderId}`); + // Clear navigation state after using it + clearNavigationState(); + } + else if (isFromReview) { // Legacy behavior for review mode navigate('/synthetic-users?mode=create&tab=ai&step=review'); } else { diff --git a/src/lib/api.ts b/src/lib/api.ts index 731daf19..410e4bc5 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -14,11 +14,37 @@ const api = axios.create({ timeout: 600000 // 10 minutes default timeout for AI operations }); +// Helper function to check if JWT token is expired +const isTokenExpired = (token: string): boolean => { + try { + const payload = JSON.parse(atob(token.split('.')[1])); + const currentTime = Date.now() / 1000; + return payload.exp < currentTime; + } catch (error) { + console.error('Error parsing JWT token:', error); + return true; // Treat malformed tokens as expired + } +}; + // Request interceptor to add JWT token api.interceptors.request.use( (config) => { const token = localStorage.getItem('auth_token'); if (token) { + // Check if token is expired before making request + if (isTokenExpired(token)) { + console.log('Token expired, clearing auth data before request'); + localStorage.removeItem('auth_token'); + localStorage.removeItem('user'); + localStorage.removeItem('auth_type'); + + // Dispatch auth error to trigger redirect + dispatchAuthError({ source: config.url }); + + // Reject the request + return Promise.reject(new Error('Token expired')); + } + config.headers.Authorization = `Bearer ${token}`; } diff --git a/src/pages/SyntheticUsers.tsx b/src/pages/SyntheticUsers.tsx index 31534eac..1b0ec035 100644 --- a/src/pages/SyntheticUsers.tsx +++ b/src/pages/SyntheticUsers.tsx @@ -78,7 +78,7 @@ const SyntheticUsers = () => { const navigate = useNavigate(); const [searchParams] = useSearchParams(); const { loadPersonas } = usePersonaStorage(); - const { clearNavigationState } = useNavigation(); + const { clearNavigationState, setPreviousRoute } = useNavigation(); const [mode, setMode] = useState<'view' | 'create'>('view'); const [creationMode, setCreationMode] = useState<'manual' | 'ai'>('ai'); @@ -88,12 +88,18 @@ const SyntheticUsers = () => { const [isCreatingFolder, setIsCreatingFolder] = useState(false); const [newFolderName, setNewFolderName] = useState(''); - // Handle URL parameters to set mode + // Handle URL parameters to set mode and folder useEffect(() => { const modeParam = searchParams.get('mode'); if (modeParam === 'view' || modeParam === 'create') { setMode(modeParam); } + + // Handle folder parameter to restore folder selection + const folderParam = searchParams.get('folder'); + if (folderParam) { + setSelectedFolder(folderParam); + } }, [searchParams]); const [allPersonas, setAllPersonas] = useState([]); const [folders, setFolders] = useState([]); @@ -150,6 +156,16 @@ const SyntheticUsers = () => { // Navigate to persona details navigate(`/synthetic-users/${persona._id || persona.id}`); }; + + // Handle navigation to persona details with folder context + const handlePersonaViewDetails = (persona: Persona) => { + // Set navigation context to remember which folder we came from + setPreviousRoute('/synthetic-users', { + folderId: selectedFolder !== DEFAULT_FOLDER_ID ? selectedFolder : undefined + }); + // Navigate to persona details + navigate(`/synthetic-users/${persona._id || persona.id}`); + }; // Function to collect unique filter options from personas const getFilterOptions = (personas: Persona[]) => { @@ -1416,6 +1432,7 @@ const SyntheticUsers = () => { e.stopPropagation(); togglePersonaSelection(persona.id); }} + onViewDetails={handlePersonaViewDetails} showAddToFolderButton={false} folders={folders} /> diff --git a/src/services/websocketServiceNew.ts b/src/services/websocketServiceNew.ts index 4aefb617..09c3df8b 100644 --- a/src/services/websocketServiceNew.ts +++ b/src/services/websocketServiceNew.ts @@ -111,6 +111,11 @@ export function initSocket(getToken: () => string): Socket { case 'error': console.error('🔧 [GPT-5] *** ROUTING error from onAny ***', payload); break; + + case 'auth_error': + console.error('🔧 [GPT-5] *** ROUTING auth_error from onAny ***', payload); + window.dispatchEvent(new CustomEvent("ws:auth_error", { detail: payload })); + break; } }); @@ -251,6 +256,11 @@ function bindCoreListeners() { console.log('🔧 [GPT-5] mode_event_update:', payload); window.dispatchEvent(new CustomEvent("ws:mode_event_update", { detail: payload })); }; + + const onAuthError = (payload: any) => { + console.error('🔧 [GPT-5] auth_error:', payload); + window.dispatchEvent(new CustomEvent("ws:auth_error", { detail: payload })); + }; // Bind all listeners console.log('🔧 [GPT-5] BINDING specific listeners to socket'); @@ -262,6 +272,7 @@ function bindCoreListeners() { socket.on("theme_update", onTheme); socket.on("focus_group_update", onFG); socket.on("mode_event_update", onModeEvent); + socket.on("auth_error", onAuthError); console.log('🔧 [GPT-5] BOUND specific listeners to socket'); // GPT-5 DEBUG: Verify listeners are actually attached