Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion frontend/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import PublicLayout from "./pages/public/PublicLayout";
import Dashboard from "./features/dashboard/pages/Dashboard";
import TrainingPlanner from "./features/training/pages/TrainingPlanner";
import TrainingAttendance from "./features/training/pages/TrainingAttendance";
import NotFound from "./pages/NotFound";


export default function App(){
Expand Down Expand Up @@ -43,7 +44,7 @@ export default function App(){
</Route>

{/* catch-all for 404 */}
<Route path="*" element={<h1>Page Not Found</h1>} />
<Route path="*" element={<NotFound />} />
</Routes>
</>
)
Expand Down
47 changes: 28 additions & 19 deletions frontend/src/api/axios.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,21 +35,25 @@ api.interceptors.request.use(
// ------ Response: handle 401 with single-flight refresh & retry ------
let refreshingPromise: Promise<string | null> | null = null;

async function refreshAccess(): Promise<string | null> | null{
// De-dupe concurrent refresh attempts
const AUTH_ENDPOINTS = ["/auth/login", "/auth/register", "/auth/refresh"];

function isAuthEndpoint(url?: string): boolean {
if (!url) return false;
return AUTH_ENDPOINTS.some((endpoint) => url.includes(endpoint));
}

async function refreshAccess(): Promise<string | null> {
if (!refreshingPromise) {
refreshingPromise = (async () => {
try {
const { data } = await api.post<{ access: string}>("/auth/refresh", {});// cookie travels
const { data } = await api.post<{ access: string }>("/auth/refresh", {});
const token = data?.access ?? null;
useAuthStore.getState().setAccessToken(token);
return token;
} catch (error) {
// refresh failed — clear auth
useAuthStore.getState().logout();
return null;
} finally {
// important: release the lock *after* microtask turn
const p = refreshingPromise;
setTimeout(() => {
if (refreshingPromise === p) refreshingPromise = null;
Expand All @@ -59,31 +63,36 @@ async function refreshAccess(): Promise<string | null> | null{
}
return refreshingPromise;
}

api.interceptors.response.use(
(response: AxiosResponse) => response,
async (error: AxiosError) => {
const original = error.config as AxiosRequestConfig & { _retry?: boolean};
const config = error.config as AxiosRequestConfig & { _retry?: boolean };
const status = error.response?.status;

// Only try once per request
if (status === 401 && !original?._retry) {
original._retry = true;
if (
status === 401 &&
!config?._retry &&
config &&
!isAuthEndpoint(config.url) &&
useAuthStore.getState().accessToken
) {
config._retry = true;

const token = await refreshAccess();
if (token) {
// Update header and retry the original request
original.headers = { ...(original.headers || {}), Authorization: `Bearer ${token}`};
return api.request(original);
if (config.headers) {
config.headers.Authorization = `Bearer ${token}`;
} else {
config.headers = { Authorization: `Bearer ${token}` };
}
return api.request(config);
} else {
window.location.href = "/login"; // or use navigate()
window.location.href = "/login";
return Promise.reject(error);
}
}
// if (error.response) {
// if (error.response.status === 401) {
// console.warn("Unauthorized. Redirecting to login...");
// window.location.href = "/login"; // or use navigate()
// }
// }

return Promise.reject(error);
}
);
Expand Down
77 changes: 77 additions & 0 deletions frontend/src/pages/NotFound.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
import { Box, Button, Container, Typography, useTheme } from "@mui/material";
import { useNavigate } from "react-router-dom";

export default function NotFound() {
const navigate = useNavigate();
const theme = useTheme();

return (
<Container maxWidth="md">
<Box
sx={{
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
minHeight: "80vh",
textAlign: "center",
gap: 3,
}}
>
<Typography
variant="h1"
sx={{
fontSize: { xs: "3rem", sm: "5rem", md: "6rem" },
fontWeight: 700,
color: theme.palette.primary.main,
margin: 0,
}}
>
404
</Typography>

<Typography
variant="h3"
sx={{
fontWeight: 600,
color: theme.palette.text.primary,
marginTop: -2,
}}
>
Page Not Found
</Typography>

<Typography
variant="body1"
sx={{
fontSize: "1.1rem",
color: theme.palette.text.secondary,
maxWidth: "500px",
}}
>
Sorry, the page you're looking for doesn't exist. It might have been
moved or deleted.
</Typography>

<Box sx={{ display: "flex", gap: 2, flexWrap: "wrap", justifyContent: "center" }}>
<Button
variant="contained"
color="primary"
size="large"
onClick={() => navigate("/")}
>
Go to Homepage
</Button>
<Button
variant="outlined"
color="primary"
size="large"
onClick={() => navigate(-1)}
>
Go Back
</Button>
</Box>
</Box>
</Container>
);
}