diff --git a/public/health.json b/public/health.json new file mode 100644 index 0000000..213db9a --- /dev/null +++ b/public/health.json @@ -0,0 +1,5 @@ +{ + "status": "healthy", + "mode": "production", + "build": "optimized" +} diff --git a/src/App.js b/src/App.js index d706f13..ed23527 100644 --- a/src/App.js +++ b/src/App.js @@ -1,6 +1,7 @@ import React from 'react'; import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'; import { Toaster } from './components/ui/sonner'; +import IdleSessionWarning from './components/IdleSessionWarning'; import Landing from './pages/Landing'; import Register from './pages/Register'; import Login from './pages/Login'; @@ -294,6 +295,7 @@ function App() { } /> + ); diff --git a/src/components/IdleSessionWarning.js b/src/components/IdleSessionWarning.js new file mode 100644 index 0000000..1b6763f --- /dev/null +++ b/src/components/IdleSessionWarning.js @@ -0,0 +1,231 @@ +import React, { useState, useEffect, useCallback, useRef } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { useAuth } from '../context/AuthContext'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from './ui/dialog'; +import { Button } from './ui/button'; +import { AlertTriangle, RefreshCw } from 'lucide-react'; + +/** + * IdleSessionWarning Component + * + * Monitors user activity and warns before session expiration + * - Warns 1 minute before JWT expiry (at 29 minutes if JWT is 30 min) + * - Auto-logout on expiration + * - "Stay Logged In" extends session + */ +const IdleSessionWarning = () => { + const { user, logout, refreshUser } = useAuth(); + const navigate = useNavigate(); + + // Configuration + const SESSION_DURATION = 30 * 60 * 1000; // 30 minutes in milliseconds + const WARNING_BEFORE_EXPIRY = 1 * 60 * 1000; // Warn 1 minute before expiry + const WARNING_TIME = SESSION_DURATION - WARNING_BEFORE_EXPIRY; // 29 minutes + + const [showWarning, setShowWarning] = useState(false); + const [timeRemaining, setTimeRemaining] = useState(60); // seconds + const [isExtending, setIsExtending] = useState(false); + + const activityTimeoutRef = useRef(null); + const warningTimeoutRef = useRef(null); + const countdownIntervalRef = useRef(null); + const lastActivityRef = useRef(Date.now()); + + // Reset activity timer + const resetActivityTimer = useCallback(() => { + lastActivityRef.current = Date.now(); + + // Clear existing timers + if (activityTimeoutRef.current) { + clearTimeout(activityTimeoutRef.current); + } + if (warningTimeoutRef.current) { + clearTimeout(warningTimeoutRef.current); + } + if (countdownIntervalRef.current) { + clearInterval(countdownIntervalRef.current); + } + + // Hide warning if showing + if (showWarning) { + setShowWarning(false); + } + + // Set new warning timer + warningTimeoutRef.current = setTimeout(() => { + // Show warning + setShowWarning(true); + setTimeRemaining(60); // 60 seconds until logout + + // Start countdown + countdownIntervalRef.current = setInterval(() => { + setTimeRemaining((prev) => { + if (prev <= 1) { + // Time's up - logout + handleSessionExpired(); + return 0; + } + return prev - 1; + }); + }, 1000); + + // Set auto-logout timer + activityTimeoutRef.current = setTimeout(() => { + handleSessionExpired(); + }, WARNING_BEFORE_EXPIRY); + + }, WARNING_TIME); + }, [showWarning]); + + // Handle session expiration + const handleSessionExpired = useCallback(() => { + // Clear all timers + if (activityTimeoutRef.current) clearTimeout(activityTimeoutRef.current); + if (warningTimeoutRef.current) clearTimeout(warningTimeoutRef.current); + if (countdownIntervalRef.current) clearInterval(countdownIntervalRef.current); + + setShowWarning(false); + logout(); + navigate('/login', { + state: { message: 'Your session has expired due to inactivity. Please log in again.' } + }); + }, [logout, navigate]); + + // Handle "Stay Logged In" button + const handleExtendSession = async () => { + setIsExtending(true); + try { + // Refresh user data to get new token + await refreshUser(); + + // Reset activity timer + resetActivityTimer(); + + console.log('[IdleSessionWarning] Session extended successfully'); + } catch (error) { + console.error('[IdleSessionWarning] Failed to extend session:', error); + + // If refresh fails, logout + handleSessionExpired(); + } finally { + setIsExtending(false); + } + }; + + // Track user activity + useEffect(() => { + if (!user) return; + + const activityEvents = [ + 'mousedown', + 'mousemove', + 'keypress', + 'scroll', + 'touchstart', + 'click' + ]; + + // Throttle activity detection to avoid too many resets + let throttleTimeout = null; + const handleActivity = () => { + if (throttleTimeout) return; + + throttleTimeout = setTimeout(() => { + resetActivityTimer(); + throttleTimeout = null; + }, 1000); // Throttle to once per second + }; + + // Add event listeners + activityEvents.forEach(event => { + document.addEventListener(event, handleActivity, { passive: true }); + }); + + // Initialize timer + resetActivityTimer(); + + // Cleanup + return () => { + activityEvents.forEach(event => { + document.removeEventListener(event, handleActivity); + }); + + if (activityTimeoutRef.current) clearTimeout(activityTimeoutRef.current); + if (warningTimeoutRef.current) clearTimeout(warningTimeoutRef.current); + if (countdownIntervalRef.current) clearInterval(countdownIntervalRef.current); + if (throttleTimeout) clearTimeout(throttleTimeout); + }; + }, [user, resetActivityTimer]); + + // Don't render if user is not logged in + if (!user) return null; + + return ( + { + if (!open) { + // Prevent closing dialog by clicking outside + // User must click a button + return; + } + }}> + e.preventDefault()} + onEscapeKeyDown={(e) => e.preventDefault()} + > + + + + + + + Session About to Expire + + + + Your session will expire in {timeRemaining} seconds due to inactivity. + + + + Click "Stay Logged In" to continue your session, or you will be automatically logged out. + + + + + + + + Log Out Now + + + {isExtending ? ( + <> + + Extending... + > + ) : ( + 'Stay Logged In' + )} + + + + + ); +}; + +export default IdleSessionWarning; diff --git a/src/context/AuthContext.js b/src/context/AuthContext.js index 24df9bf..f05e6d8 100644 --- a/src/context/AuthContext.js +++ b/src/context/AuthContext.js @@ -1,5 +1,6 @@ import React, { createContext, useState, useContext, useEffect } from 'react'; import axios from 'axios'; +import api from '../utils/api'; const AuthContext = createContext(); @@ -68,11 +69,11 @@ export const AuthProvider = ({ children }) => { fullUrl: `${API_URL}/api/auth/login` }); - const response = await axios.post( - `${API_URL}/api/auth/login`, + // Use api instance for retry logic + const response = await api.post( + '/auth/login', { email, password }, { - timeout: 30000, // 30 second timeout headers: { 'Content-Type': 'application/json' } @@ -87,11 +88,19 @@ export const AuthProvider = ({ children }) => { const { access_token, user: userData } = response.data; - // Store token first - localStorage.setItem('token', access_token); - console.log('[AuthContext] Token stored in localStorage'); + if (!access_token || !userData) { + throw new Error('Invalid response from server - missing token or user data'); + } - // Update state + // Store token FIRST and verify it was stored + localStorage.setItem('token', access_token); + const storedToken = localStorage.getItem('token'); + if (storedToken !== access_token) { + throw new Error('Failed to store token in localStorage'); + } + console.log('[AuthContext] Token stored and verified in localStorage'); + + // Update state in correct order setToken(access_token); setUser(userData); console.log('[AuthContext] User state updated:', { @@ -99,22 +108,21 @@ export const AuthProvider = ({ children }) => { role: userData.role }); - // Fetch user permissions (don't let this fail the login) - // Use setTimeout to defer permission fetching slightly - setTimeout(async () => { - try { - console.log('[AuthContext] Fetching permissions...'); - await fetchPermissions(access_token); - console.log('[AuthContext] Permissions fetched successfully'); - } catch (error) { - console.error('[AuthContext] Failed to fetch permissions (non-critical):', { - message: error.message, - response: error.response?.data, - status: error.response?.status - }); - // Don't throw - permissions can be fetched later if needed - } - }, 100); // Small delay to ensure state is settled + // Fetch permissions immediately and WAIT for it (but don't fail login if it fails) + try { + console.log('[AuthContext] Fetching permissions...'); + await fetchPermissions(access_token); + console.log('[AuthContext] Permissions fetched successfully'); + } catch (permError) { + console.error('[AuthContext] Failed to fetch permissions (non-critical):', { + message: permError.message, + response: permError.response?.data, + status: permError.response?.status + }); + // Set empty permissions array so hasPermission doesn't break + setPermissions([]); + // Don't throw - login succeeded even if permissions failed + } return userData; } catch (error) { @@ -131,6 +139,12 @@ export const AuthProvider = ({ children }) => { } }); + // Clear any partial state + localStorage.removeItem('token'); + setToken(null); + setUser(null); + setPermissions([]); + // Re-throw to let Login component handle the error throw error; }
+ Click "Stay Logged In" to continue your session, or you will be automatically logged out. +