Compare commits
10 Commits
templates
...
66c2bedbed
| Author | SHA1 | Date | |
|---|---|---|---|
| 66c2bedbed | |||
|
|
180eb1ce85 | ||
|
|
5377a0f465 | ||
|
|
c54eb23689 | ||
| 9f7367ceeb | |||
| d94ea7b6d5 | |||
| 24519a7080 | |||
| b1b9a05d4f | |||
| a2070b4e4e | |||
| 6a21d32319 |
5
public/health.json
Normal file
5
public/health.json
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"status": "healthy",
|
||||||
|
"mode": "production",
|
||||||
|
"build": "optimized"
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
|
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
|
||||||
import { Toaster } from './components/ui/sonner';
|
import { Toaster } from './components/ui/sonner';
|
||||||
|
import IdleSessionWarning from './components/IdleSessionWarning';
|
||||||
import Landing from './pages/Landing';
|
import Landing from './pages/Landing';
|
||||||
import Register from './pages/Register';
|
import Register from './pages/Register';
|
||||||
import Login from './pages/Login';
|
import Login from './pages/Login';
|
||||||
@@ -294,6 +295,7 @@ function App() {
|
|||||||
<Route path="*" element={<NotFound />} />
|
<Route path="*" element={<NotFound />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
<Toaster position="top-right" />
|
<Toaster position="top-right" />
|
||||||
|
<IdleSessionWarning />
|
||||||
</BrowserRouter>
|
</BrowserRouter>
|
||||||
</AuthProvider>
|
</AuthProvider>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -277,9 +277,9 @@ const AdminSidebar = ({ isOpen, onToggle, isMobile }) => {
|
|||||||
<h2 className="text-xl font-semibold text-[#422268]" style={{ fontFamily: "'Inter', sans-serif" }}>
|
<h2 className="text-xl font-semibold text-[#422268]" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||||
Admin
|
Admin
|
||||||
</h2>
|
</h2>
|
||||||
<p className="text-xs text-[#664fa3] group-hover:text-[#ff9e77] transition-colors" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
{/* <p className="text-xs text-[#664fa3] group-hover:text-[#ff9e77] transition-colors" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||||
View Public Site
|
View Public Site
|
||||||
</p>
|
</p> */}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</Link>
|
</Link>
|
||||||
|
|||||||
232
src/components/IdleSessionWarning.js
Normal file
232
src/components/IdleSessionWarning.js
Normal file
@@ -0,0 +1,232 @@
|
|||||||
|
import React, { useState, useEffect, useCallback, useRef } from 'react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import { useAuth } from '../context/AuthContext';
|
||||||
|
import logger from '../utils/logger';
|
||||||
|
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();
|
||||||
|
|
||||||
|
logger.log('[IdleSessionWarning] Session extended successfully');
|
||||||
|
} catch (error) {
|
||||||
|
logger.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 (
|
||||||
|
<Dialog open={showWarning} onOpenChange={(open) => {
|
||||||
|
if (!open) {
|
||||||
|
// Prevent closing dialog by clicking outside
|
||||||
|
// User must click a button
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}}>
|
||||||
|
<DialogContent
|
||||||
|
className="max-w-md"
|
||||||
|
onPointerDownOutside={(e) => e.preventDefault()}
|
||||||
|
onEscapeKeyDown={(e) => e.preventDefault()}
|
||||||
|
>
|
||||||
|
<DialogHeader>
|
||||||
|
<div className="flex items-center gap-3 mb-2">
|
||||||
|
<div className="bg-[#ff9e77]/10 p-3 rounded-full">
|
||||||
|
<AlertTriangle className="h-6 w-6 text-[#ff9e77]" />
|
||||||
|
</div>
|
||||||
|
<DialogTitle className="text-[#422268]">
|
||||||
|
Session About to Expire
|
||||||
|
</DialogTitle>
|
||||||
|
</div>
|
||||||
|
<DialogDescription className="text-[#664fa3]">
|
||||||
|
Your session will expire in <strong className="text-[#422268] text-lg">{timeRemaining}</strong> seconds due to inactivity.
|
||||||
|
|
||||||
|
<div className="mt-4 p-4 bg-[#f1eef9] rounded-lg border border-[#ddd8eb]">
|
||||||
|
<p className="text-sm text-[#422268]">
|
||||||
|
Click <strong>"Stay Logged In"</strong> to continue your session, or you will be automatically logged out.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<DialogFooter className="flex-col sm:flex-row gap-3 mt-4">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={handleSessionExpired}
|
||||||
|
className="border-[#ddd8eb] text-[#664fa3] hover:bg-[#f1eef9]"
|
||||||
|
>
|
||||||
|
Log Out Now
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={handleExtendSession}
|
||||||
|
disabled={isExtending}
|
||||||
|
className="bg-[#664fa3] hover:bg-[#422268] text-white"
|
||||||
|
>
|
||||||
|
{isExtending ? (
|
||||||
|
<>
|
||||||
|
<RefreshCw className="h-4 w-4 mr-2 animate-spin" />
|
||||||
|
Extending...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
'Stay Logged In'
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default IdleSessionWarning;
|
||||||
@@ -1,12 +1,14 @@
|
|||||||
import React, { createContext, useState, useContext, useEffect } from 'react';
|
import React, { createContext, useState, useContext, useEffect } from 'react';
|
||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
|
import api from '../utils/api';
|
||||||
|
import logger from '../utils/logger';
|
||||||
|
|
||||||
const AuthContext = createContext();
|
const AuthContext = createContext();
|
||||||
|
|
||||||
const API_URL = process.env.REACT_APP_BACKEND_URL || window.location.origin;
|
const API_URL = process.env.REACT_APP_BACKEND_URL || window.location.origin;
|
||||||
|
|
||||||
// Log environment on module load for debugging
|
// Log environment on module load for debugging
|
||||||
console.log('[AuthContext] Module initialized with:', {
|
logger.log('[AuthContext] Module initialized with:', {
|
||||||
REACT_APP_BACKEND_URL: process.env.REACT_APP_BACKEND_URL,
|
REACT_APP_BACKEND_URL: process.env.REACT_APP_BACKEND_URL,
|
||||||
REACT_APP_BASENAME: process.env.REACT_APP_BASENAME,
|
REACT_APP_BASENAME: process.env.REACT_APP_BASENAME,
|
||||||
API_URL: API_URL
|
API_URL: API_URL
|
||||||
@@ -55,31 +57,31 @@ export const AuthProvider = ({ children }) => {
|
|||||||
});
|
});
|
||||||
setPermissions(response.data.permissions || []);
|
setPermissions(response.data.permissions || []);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to fetch permissions:', error);
|
logger.error('Failed to fetch permissions:', error);
|
||||||
setPermissions([]);
|
setPermissions([]);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const login = async (email, password) => {
|
const login = async (email, password) => {
|
||||||
try {
|
try {
|
||||||
console.log('[AuthContext] Starting login request...', {
|
logger.log('[AuthContext] Starting login request...', {
|
||||||
API_URL: API_URL,
|
API_URL: API_URL,
|
||||||
envBackendUrl: process.env.REACT_APP_BACKEND_URL,
|
envBackendUrl: process.env.REACT_APP_BACKEND_URL,
|
||||||
fullUrl: `${API_URL}/api/auth/login`
|
fullUrl: `${API_URL}/api/auth/login`
|
||||||
});
|
});
|
||||||
|
|
||||||
const response = await axios.post(
|
// Use api instance for retry logic
|
||||||
`${API_URL}/api/auth/login`,
|
const response = await api.post(
|
||||||
|
'/auth/login',
|
||||||
{ email, password },
|
{ email, password },
|
||||||
{
|
{
|
||||||
timeout: 30000, // 30 second timeout
|
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json'
|
'Content-Type': 'application/json'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
console.log('[AuthContext] Login response received:', {
|
logger.log('[AuthContext] Login response received:', {
|
||||||
status: response.status,
|
status: response.status,
|
||||||
hasToken: !!response.data?.access_token,
|
hasToken: !!response.data?.access_token,
|
||||||
hasUser: !!response.data?.user
|
hasUser: !!response.data?.user
|
||||||
@@ -87,39 +89,46 @@ export const AuthProvider = ({ children }) => {
|
|||||||
|
|
||||||
const { access_token, user: userData } = response.data;
|
const { access_token, user: userData } = response.data;
|
||||||
|
|
||||||
// Store token first
|
if (!access_token || !userData) {
|
||||||
localStorage.setItem('token', access_token);
|
throw new Error('Invalid response from server - missing token or user data');
|
||||||
console.log('[AuthContext] Token stored in localStorage');
|
}
|
||||||
|
|
||||||
// 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');
|
||||||
|
}
|
||||||
|
logger.log('[AuthContext] Token stored and verified in localStorage');
|
||||||
|
|
||||||
|
// Update state in correct order
|
||||||
setToken(access_token);
|
setToken(access_token);
|
||||||
setUser(userData);
|
setUser(userData);
|
||||||
console.log('[AuthContext] User state updated:', {
|
logger.log('[AuthContext] User state updated:', {
|
||||||
email: userData.email,
|
email: userData.email,
|
||||||
role: userData.role
|
role: userData.role
|
||||||
});
|
});
|
||||||
|
|
||||||
// Fetch user permissions (don't let this fail the login)
|
// Fetch permissions immediately and WAIT for it (but don't fail login if it fails)
|
||||||
// Use setTimeout to defer permission fetching slightly
|
try {
|
||||||
setTimeout(async () => {
|
logger.log('[AuthContext] Fetching permissions...');
|
||||||
try {
|
await fetchPermissions(access_token);
|
||||||
console.log('[AuthContext] Fetching permissions...');
|
logger.log('[AuthContext] Permissions fetched successfully');
|
||||||
await fetchPermissions(access_token);
|
} catch (permError) {
|
||||||
console.log('[AuthContext] Permissions fetched successfully');
|
logger.error('[AuthContext] Failed to fetch permissions (non-critical):', {
|
||||||
} catch (error) {
|
message: permError.message,
|
||||||
console.error('[AuthContext] Failed to fetch permissions (non-critical):', {
|
response: permError.response?.data,
|
||||||
message: error.message,
|
status: permError.response?.status
|
||||||
response: error.response?.data,
|
});
|
||||||
status: error.response?.status
|
// Set empty permissions array so hasPermission doesn't break
|
||||||
});
|
setPermissions([]);
|
||||||
// Don't throw - permissions can be fetched later if needed
|
// Don't throw - login succeeded even if permissions failed
|
||||||
}
|
}
|
||||||
}, 100); // Small delay to ensure state is settled
|
|
||||||
|
|
||||||
return userData;
|
return userData;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// Enhanced error logging
|
// Enhanced error logging
|
||||||
console.error('[AuthContext] Login failed:', {
|
logger.error('[AuthContext] Login failed:', {
|
||||||
message: error.message,
|
message: error.message,
|
||||||
response: error.response?.data,
|
response: error.response?.data,
|
||||||
status: error.response?.status,
|
status: error.response?.status,
|
||||||
@@ -131,6 +140,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
|
// Re-throw to let Login component handle the error
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
@@ -160,7 +175,7 @@ export const AuthProvider = ({ children }) => {
|
|||||||
setUser(response.data);
|
setUser(response.data);
|
||||||
return response.data;
|
return response.data;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to refresh user:', error);
|
logger.error('Failed to refresh user:', error);
|
||||||
// If token expired, logout
|
// If token expired, logout
|
||||||
if (error.response?.status === 401) {
|
if (error.response?.status === 401) {
|
||||||
logout();
|
logout();
|
||||||
|
|||||||
66
src/utils/logger.js
Normal file
66
src/utils/logger.js
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
/**
|
||||||
|
* Production-safe logging utility
|
||||||
|
*
|
||||||
|
* In production (NODE_ENV=production), logs are disabled by default
|
||||||
|
* to prevent exposing sensitive information in browser console.
|
||||||
|
*
|
||||||
|
* In development, all logs are shown for debugging.
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* import logger from '../utils/logger';
|
||||||
|
* logger.log('[Component]', 'message', data);
|
||||||
|
* logger.error('[Component]', 'error message', error);
|
||||||
|
* logger.warn('[Component]', 'warning message');
|
||||||
|
*/
|
||||||
|
|
||||||
|
const isDevelopment = process.env.NODE_ENV === 'development';
|
||||||
|
|
||||||
|
// Force enable logs with REACT_APP_DEBUG_LOGS=true in .env
|
||||||
|
const debugEnabled = process.env.REACT_APP_DEBUG_LOGS === 'true';
|
||||||
|
|
||||||
|
const shouldLog = isDevelopment || debugEnabled;
|
||||||
|
|
||||||
|
const logger = {
|
||||||
|
log: (...args) => {
|
||||||
|
if (shouldLog) {
|
||||||
|
console.log(...args);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
error: (...args) => {
|
||||||
|
// Always log errors, but sanitize in production
|
||||||
|
if (shouldLog) {
|
||||||
|
console.error(...args);
|
||||||
|
} else {
|
||||||
|
// In production, only log error type without details
|
||||||
|
console.error('An error occurred. Enable debug logs for details.');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
warn: (...args) => {
|
||||||
|
if (shouldLog) {
|
||||||
|
console.warn(...args);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
info: (...args) => {
|
||||||
|
if (shouldLog) {
|
||||||
|
console.info(...args);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
debug: (...args) => {
|
||||||
|
if (shouldLog) {
|
||||||
|
console.debug(...args);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// Special method for sensitive data - NEVER logs in production
|
||||||
|
sensitive: (...args) => {
|
||||||
|
if (isDevelopment) {
|
||||||
|
console.log('[SENSITIVE]', ...args);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export default logger;
|
||||||
Reference in New Issue
Block a user