From 9861deb49fb6fbfb5c0a7531b68bf830e38012b5 Mon Sep 17 00:00:00 2001 From: Steve White Date: Wed, 30 Oct 2024 11:15:12 -0500 Subject: [PATCH] added loading skeletons and fixed some minor stuff along the way. --- package-lock.json | 10 ++++ package.json | 1 + src/App.js | 56 ++++++++++++++---- src/components/Items.js | 37 ++++++++++-- src/services/errorHandler.js | 112 +++++++++++++++++++++++++++-------- 5 files changed, 174 insertions(+), 42 deletions(-) diff --git a/package-lock.json b/package-lock.json index 2520035..8ba6077 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,6 +16,7 @@ "@testing-library/react": "^13.4.0", "@testing-library/user-event": "^13.5.0", "axios": "^1.7.7", + "jwt-decode": "^4.0.0", "react": "^18.3.1", "react-dom": "^18.3.1", "react-router-dom": "^6.26.2", @@ -13779,6 +13780,15 @@ "node": ">=4.0" } }, + "node_modules/jwt-decode": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/jwt-decode/-/jwt-decode-4.0.0.tgz", + "integrity": "sha512-+KJGIyHgkGuIq3IEBNftfhW/LfWhXUIY6OmyVWjliu5KH1y0fw7VQ8YndE2O4qZdMSd9SqbnC8GOcZEy0Om7sA==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/keyv": { "version": "4.5.4", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", diff --git a/package.json b/package.json index e09f994..ee62f88 100644 --- a/package.json +++ b/package.json @@ -11,6 +11,7 @@ "@testing-library/react": "^13.4.0", "@testing-library/user-event": "^13.5.0", "axios": "^1.7.7", + "jwt-decode": "^4.0.0", "react": "^18.3.1", "react-dom": "^18.3.1", "react-router-dom": "^6.26.2", diff --git a/src/App.js b/src/App.js index 52848b8..b5216af 100644 --- a/src/App.js +++ b/src/App.js @@ -1,14 +1,14 @@ // src/App.js import React, { useState, useEffect } from 'react'; -import { BrowserRouter as Router, Route, Routes, Navigate } from 'react-router-dom'; +import { BrowserRouter as Router, Route, Routes, Navigate, useNavigate } from 'react-router-dom'; import { useParams } from 'react-router-dom'; +import { jwtDecode } from 'jwt-decode'; import Login from './components/Login'; import Boxes from './components/Boxes'; import Items from './components/Items'; -import Navbar from './components/Navbar'; // Correct import here -import Admin from './components/Admin'; // Correct import here +import Navbar from './components/Navbar'; +import Admin from './components/Admin'; import { createContext } from 'react'; -import ErrorBoundary from './components/ErrorBoundary'; import './styles.css' export const AppContext = createContext(); @@ -16,6 +16,40 @@ export const PRIMARY_COLOR = '#333'; export const SECONDARY_COLOR = '#ffffff'; export const BACKGROUND_COLOR = '#dddddd'; +// New component for token expiration check +function TokenExpirationCheck({ setToken }) { + const navigate = useNavigate(); + + useEffect(() => { + const checkTokenExpiration = () => { + const token = localStorage.getItem('token'); + if (!token) return; + + try { + const decodedToken = jwtDecode(token); + if (Date.now() >= decodedToken.exp * 1000) { + setToken(null); + localStorage.removeItem('token'); + navigate('/api/v1/login'); + } + } catch (error) { + localStorage.removeItem('token'); + navigate('/api/v1/login'); + } + }; + + window.addEventListener('focus', checkTokenExpiration); + const interval = setInterval(checkTokenExpiration, 60000); + + return () => { + window.removeEventListener('focus', checkTokenExpiration); + clearInterval(interval); + }; + }, [navigate, setToken]); + + return null; +} + function App() { const [token, setToken] = useState(localStorage.getItem('token')); @@ -27,18 +61,17 @@ function App() { return ( - - - - - - + + + + + ); } function AppRoutes({ token, setToken }) { - const { id } = useParams(); // Move useParams here + const { id } = useParams(); return ( <> @@ -65,4 +98,5 @@ function AppRoutes({ token, setToken }) { ); } + export default App; \ No newline at end of file diff --git a/src/components/Items.js b/src/components/Items.js index 8af44ee..af50482 100644 --- a/src/components/Items.js +++ b/src/components/Items.js @@ -18,7 +18,8 @@ import { DialogContent, DialogActions, Alert, - CircularProgress + CircularProgress, + Skeleton } from '@mui/material'; import { Delete as DeleteIcon, Edit as EditIcon } from '@mui/icons-material'; import { useParams, useLocation } from 'react-router-dom'; @@ -51,6 +52,26 @@ const Item = React.memo(({ item, onDelete, onEdit, itemImages }) => ( )); +const LoadingSkeleton = () => ( + + + + + + + + + + + + + + + + + +); + export default function Items({ token }) { const { id: boxId } = useParams(); const [items, setItems] = useState([]); @@ -293,8 +314,13 @@ export default function Items({ token }) { Actions - - {filteredItems.map((item) => ( + + {loadingItems ? ( + [...Array(5)].map((_, i) => ( + + )) + ) : ( + filteredItems.map((item) => ( - ))} - + )) + )} + )} diff --git a/src/services/errorHandler.js b/src/services/errorHandler.js index 96cd27f..eba8a59 100644 --- a/src/services/errorHandler.js +++ b/src/services/errorHandler.js @@ -1,5 +1,6 @@ -// src/utils/errorHandler.js +// src/services/errorHandler.js import axios from 'axios'; +import { jwtDecode } from 'jwt-decode'; export class ApiError extends Error { constructor(message, status, details = {}) { @@ -7,30 +8,89 @@ export class ApiError extends Error { this.status = status; this.details = details; } - } +} - // Enhanced API client with error handling - export const createApiClient = () => { - const client = axios.create({ - baseURL: process.env.REACT_APP_API_URL - }); - - client.interceptors.response.use( - response => response, - error => { - if (error.response?.status === 401) { - localStorage.removeItem('token'); - window.location.href = '/login'; - return Promise.reject(new ApiError('Session expired', 401)); - } - - return Promise.reject(new ApiError( - error.response?.data?.message || 'An error occurred', - error.response?.status, - error.response?.data - )); +export const createApiClient = () => { + const client = axios.create({ + baseURL: process.env.REACT_APP_API_URL, + headers: { + 'X-Content-Type-Options': 'nosniff', + 'X-Frame-Options': 'DENY', + 'X-XSS-Protection': '1; mode=block' + } + }); + + // Add request interceptor for token validation + client.interceptors.request.use(async (config) => { + const token = localStorage.getItem('token'); + if (!token) return config; + + try { + const decodedToken = jwtDecode(token); + const currentTime = Date.now() / 1000; + + if (decodedToken.exp < currentTime) { + localStorage.removeItem('token'); + window.location.href = '/api/v1/login'; + throw new ApiError('Session expired', 401); } - ); - - return client; - }; + + // Refresh token if expiring soon + if (decodedToken.exp - currentTime < 300) { + try { + const response = await axios.post('/api/v1/refresh-token', { token }); + localStorage.setItem('token', response.data.token); + config.headers.Authorization = `Bearer ${response.data.token}`; + } catch (error) { + localStorage.removeItem('token'); + window.location.href = '/api/v1/login'; + throw new ApiError('Failed to refresh token', 401); + } + } + + config.headers.Authorization = `Bearer ${token}`; + } catch (error) { + localStorage.removeItem('token'); + window.location.href = '/api/v1/login'; + throw new ApiError('Invalid token', 401); + } + + // Add CSRF token if available + const csrfToken = document.querySelector('meta[name="csrf-token"]')?.content; + if (csrfToken) { + config.headers['X-CSRF-Token'] = csrfToken; + } + + return config; + }); + + // Enhanced response interceptor + client.interceptors.response.use( + response => response, + error => { + if (error.response?.status === 401) { + localStorage.removeItem('token'); + window.location.href = '/api/v1/login'; + return Promise.reject(new ApiError('Session expired', 401)); + } + + // Handle rate limiting + if (error.response?.status === 429) { + return Promise.reject(new ApiError('Too many requests. Please try again later.', 429)); + } + + // Handle server errors + if (error.response?.status >= 500) { + return Promise.reject(new ApiError('Server error. Please try again later.', error.response.status)); + } + + return Promise.reject(new ApiError( + error.response?.data?.message || 'An error occurred', + error.response?.status, + error.response?.data + )); + } + ); + + return client; +}; \ No newline at end of file