added loading skeletons and fixed some minor stuff along the way.

This commit is contained in:
Steve White 2024-10-30 11:15:12 -05:00
parent c400ccc400
commit 9861deb49f
5 changed files with 174 additions and 42 deletions

10
package-lock.json generated
View File

@ -16,6 +16,7 @@
"@testing-library/react": "^13.4.0", "@testing-library/react": "^13.4.0",
"@testing-library/user-event": "^13.5.0", "@testing-library/user-event": "^13.5.0",
"axios": "^1.7.7", "axios": "^1.7.7",
"jwt-decode": "^4.0.0",
"react": "^18.3.1", "react": "^18.3.1",
"react-dom": "^18.3.1", "react-dom": "^18.3.1",
"react-router-dom": "^6.26.2", "react-router-dom": "^6.26.2",
@ -13779,6 +13780,15 @@
"node": ">=4.0" "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": { "node_modules/keyv": {
"version": "4.5.4", "version": "4.5.4",
"resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",

View File

@ -11,6 +11,7 @@
"@testing-library/react": "^13.4.0", "@testing-library/react": "^13.4.0",
"@testing-library/user-event": "^13.5.0", "@testing-library/user-event": "^13.5.0",
"axios": "^1.7.7", "axios": "^1.7.7",
"jwt-decode": "^4.0.0",
"react": "^18.3.1", "react": "^18.3.1",
"react-dom": "^18.3.1", "react-dom": "^18.3.1",
"react-router-dom": "^6.26.2", "react-router-dom": "^6.26.2",

View File

@ -1,14 +1,14 @@
// src/App.js // src/App.js
import React, { useState, useEffect } from 'react'; 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 { useParams } from 'react-router-dom';
import { jwtDecode } from 'jwt-decode';
import Login from './components/Login'; import Login from './components/Login';
import Boxes from './components/Boxes'; import Boxes from './components/Boxes';
import Items from './components/Items'; import Items from './components/Items';
import Navbar from './components/Navbar'; // Correct import here import Navbar from './components/Navbar';
import Admin from './components/Admin'; // Correct import here import Admin from './components/Admin';
import { createContext } from 'react'; import { createContext } from 'react';
import ErrorBoundary from './components/ErrorBoundary';
import './styles.css' import './styles.css'
export const AppContext = createContext(); export const AppContext = createContext();
@ -16,6 +16,40 @@ export const PRIMARY_COLOR = '#333';
export const SECONDARY_COLOR = '#ffffff'; export const SECONDARY_COLOR = '#ffffff';
export const BACKGROUND_COLOR = '#dddddd'; 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() { function App() {
const [token, setToken] = useState(localStorage.getItem('token')); const [token, setToken] = useState(localStorage.getItem('token'));
@ -27,18 +61,17 @@ function App() {
return ( return (
<AppContext.Provider value={{ token, setToken }}> <AppContext.Provider value={{ token, setToken }}>
<ErrorBoundary> <Router>
<Router> <TokenExpirationCheck setToken={setToken} />
<Navbar /> <Navbar />
<AppRoutes token={token} setToken={setToken} /> <AppRoutes token={token} setToken={setToken} />
</Router> </Router>
</ErrorBoundary>
</AppContext.Provider> </AppContext.Provider>
); );
} }
function AppRoutes({ token, setToken }) { function AppRoutes({ token, setToken }) {
const { id } = useParams(); // Move useParams here const { id } = useParams();
return ( return (
<> <>
@ -65,4 +98,5 @@ function AppRoutes({ token, setToken }) {
</> </>
); );
} }
export default App; export default App;

View File

@ -18,7 +18,8 @@ import {
DialogContent, DialogContent,
DialogActions, DialogActions,
Alert, Alert,
CircularProgress CircularProgress,
Skeleton
} from '@mui/material'; } from '@mui/material';
import { Delete as DeleteIcon, Edit as EditIcon } from '@mui/icons-material'; import { Delete as DeleteIcon, Edit as EditIcon } from '@mui/icons-material';
import { useParams, useLocation } from 'react-router-dom'; import { useParams, useLocation } from 'react-router-dom';
@ -51,6 +52,26 @@ const Item = React.memo(({ item, onDelete, onEdit, itemImages }) => (
</TableRow> </TableRow>
)); ));
const LoadingSkeleton = () => (
<TableRow>
<TableCell>
<Skeleton variant="circular" width={40} height={40} />
</TableCell>
<TableCell>
<Skeleton variant="text" width={100} />
</TableCell>
<TableCell>
<Skeleton variant="text" width="80%" />
</TableCell>
<TableCell>
<Box display="flex" gap={1}>
<Skeleton variant="circular" width={30} height={30} />
<Skeleton variant="circular" width={30} height={30} />
</Box>
</TableCell>
</TableRow>
);
export default function Items({ token }) { export default function Items({ token }) {
const { id: boxId } = useParams(); const { id: boxId } = useParams();
const [items, setItems] = useState([]); const [items, setItems] = useState([]);
@ -293,8 +314,13 @@ export default function Items({ token }) {
<TableCell>Actions</TableCell> <TableCell>Actions</TableCell>
</TableRow> </TableRow>
</TableHead> </TableHead>
<TableBody> <TableBody>
{filteredItems.map((item) => ( {loadingItems ? (
[...Array(5)].map((_, i) => (
<LoadingSkeleton key={i} />
))
) : (
filteredItems.map((item) => (
<Item <Item
key={item.ID} key={item.ID}
item={item} item={item}
@ -302,8 +328,9 @@ export default function Items({ token }) {
onDelete={handleDeleteItem} onDelete={handleDeleteItem}
onEdit={handleEditItem} onEdit={handleEditItem}
/> />
))} ))
</TableBody> )}
</TableBody>
</Table> </Table>
</TableContainer> </TableContainer>
)} )}

View File

@ -1,5 +1,6 @@
// src/utils/errorHandler.js // src/services/errorHandler.js
import axios from 'axios'; import axios from 'axios';
import { jwtDecode } from 'jwt-decode';
export class ApiError extends Error { export class ApiError extends Error {
constructor(message, status, details = {}) { constructor(message, status, details = {}) {
@ -7,30 +8,89 @@ export class ApiError extends Error {
this.status = status; this.status = status;
this.details = details; this.details = details;
} }
} }
// Enhanced API client with error handling export const createApiClient = () => {
export const createApiClient = () => { const client = axios.create({
const client = axios.create({ baseURL: process.env.REACT_APP_API_URL,
baseURL: process.env.REACT_APP_API_URL headers: {
}); 'X-Content-Type-Options': 'nosniff',
'X-Frame-Options': 'DENY',
client.interceptors.response.use( 'X-XSS-Protection': '1; mode=block'
response => response, }
error => { });
if (error.response?.status === 401) {
localStorage.removeItem('token'); // Add request interceptor for token validation
window.location.href = '/login'; client.interceptors.request.use(async (config) => {
return Promise.reject(new ApiError('Session expired', 401)); const token = localStorage.getItem('token');
} if (!token) return config;
return Promise.reject(new ApiError( try {
error.response?.data?.message || 'An error occurred', const decodedToken = jwtDecode(token);
error.response?.status, const currentTime = Date.now() / 1000;
error.response?.data
)); if (decodedToken.exp < currentTime) {
localStorage.removeItem('token');
window.location.href = '/api/v1/login';
throw new ApiError('Session expired', 401);
} }
);
// Refresh token if expiring soon
return client; 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;
};