fix: proper Axios error extraction, token refresh, auth state restoration, ProtectedRoute
This commit is contained in:
+11
-5
@@ -2,6 +2,7 @@ import { type ReactNode } from "react";
|
||||
import { BrowserRouter, Routes, Route } from "react-router-dom";
|
||||
import { AuthProvider } from "./contexts/AuthContext";
|
||||
import AppLayout from "./components/AppLayout";
|
||||
import ProtectedRoute from "./components/ProtectedRoute";
|
||||
import HomePage from "./pages/HomePage";
|
||||
import LoginPage from "./pages/LoginPage";
|
||||
import RegisterPage from "./pages/RegisterPage";
|
||||
@@ -11,13 +12,18 @@ export default function App(): ReactNode {
|
||||
<BrowserRouter>
|
||||
<AuthProvider>
|
||||
<Routes>
|
||||
<Route element={<AppLayout />}>
|
||||
<Route index element={<HomePage />} />
|
||||
<Route path="/login" element={<LoginPage />} />
|
||||
<Route path="/register" element={<RegisterPage />} />
|
||||
{/* Public routes */}
|
||||
<Route path="/login" element={<LoginPage />} />
|
||||
<Route path="/register" element={<RegisterPage />} />
|
||||
|
||||
{/* Protected routes (require authentication) */}
|
||||
<Route element={<ProtectedRoute />}>
|
||||
<Route element={<AppLayout />}>
|
||||
<Route index element={<HomePage />} />
|
||||
</Route>
|
||||
</Route>
|
||||
</Routes>
|
||||
</AuthProvider>
|
||||
</BrowserRouter>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { type ReactNode } from "react";
|
||||
import { Navigate, Outlet, useLocation } from "react-router-dom";
|
||||
import { useAuth } from "../contexts/AuthContext";
|
||||
import CircularProgress from "@mui/material/CircularProgress";
|
||||
import Box from "@mui/material/Box";
|
||||
|
||||
interface ProtectedRouteProps {
|
||||
/** Optional redirect path for unauthenticated users (default: /login) */
|
||||
redirectTo?: string;
|
||||
/** Optional fallback UI while restoring auth state */
|
||||
fallback?: ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Route guard that redirects unauthenticated users to the login page.
|
||||
* Shows a loading spinner while auth state is being restored from localStorage.
|
||||
*/
|
||||
export default function ProtectedRoute({
|
||||
redirectTo = "/login",
|
||||
fallback,
|
||||
}: ProtectedRouteProps): ReactNode {
|
||||
const { state } = useAuth();
|
||||
const location = useLocation();
|
||||
|
||||
// Still checking localStorage for existing session
|
||||
if (state.isRestoring) {
|
||||
return (
|
||||
fallback ?? (
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
minHeight: "100vh",
|
||||
}}
|
||||
>
|
||||
<CircularProgress />
|
||||
</Box>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
if (!state.isAuthenticated) {
|
||||
// Preserve the attempted URL so we can redirect back after login
|
||||
return <Navigate to={redirectTo} state={{ from: location }} replace />;
|
||||
}
|
||||
|
||||
return <Outlet />;
|
||||
}
|
||||
@@ -3,12 +3,17 @@ import {
|
||||
useContext,
|
||||
useReducer,
|
||||
useCallback,
|
||||
useEffect,
|
||||
type ReactNode,
|
||||
type Dispatch,
|
||||
} from "react";
|
||||
import {
|
||||
loginUser,
|
||||
registerUser,
|
||||
extractErrorMessage,
|
||||
setTokens,
|
||||
clearTokens,
|
||||
getAccessToken,
|
||||
type UserProfile,
|
||||
type LoginPayload,
|
||||
type RegisterPayload,
|
||||
@@ -19,6 +24,7 @@ interface AuthState {
|
||||
user: UserProfile | null;
|
||||
isAuthenticated: boolean;
|
||||
isLoading: boolean;
|
||||
isRestoring: boolean;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
@@ -26,6 +32,7 @@ const initialState: AuthState = {
|
||||
user: null,
|
||||
isAuthenticated: false,
|
||||
isLoading: false,
|
||||
isRestoring: true, // starts true until we check localStorage
|
||||
error: null,
|
||||
};
|
||||
|
||||
@@ -35,7 +42,8 @@ type AuthAction =
|
||||
| { type: "AUTH_SUCCESS"; payload: UserProfile }
|
||||
| { type: "AUTH_FAILURE"; payload: string }
|
||||
| { type: "LOGOUT" }
|
||||
| { type: "CLEAR_ERROR" };
|
||||
| { type: "CLEAR_ERROR" }
|
||||
| { type: "RESTORE_COMPLETE"; payload: UserProfile | null };
|
||||
|
||||
function authReducer(state: AuthState, action: AuthAction): AuthState {
|
||||
switch (action.type) {
|
||||
@@ -45,6 +53,7 @@ function authReducer(state: AuthState, action: AuthAction): AuthState {
|
||||
return {
|
||||
...state,
|
||||
isLoading: false,
|
||||
isRestoring: false,
|
||||
isAuthenticated: true,
|
||||
user: action.payload,
|
||||
error: null,
|
||||
@@ -53,12 +62,20 @@ function authReducer(state: AuthState, action: AuthAction): AuthState {
|
||||
return {
|
||||
...state,
|
||||
isLoading: false,
|
||||
isRestoring: false,
|
||||
error: action.payload,
|
||||
};
|
||||
case "LOGOUT":
|
||||
return { ...initialState };
|
||||
return { ...initialState, isRestoring: false };
|
||||
case "CLEAR_ERROR":
|
||||
return { ...state, error: null };
|
||||
case "RESTORE_COMPLETE":
|
||||
return {
|
||||
...state,
|
||||
isRestoring: false,
|
||||
isAuthenticated: action.payload !== null,
|
||||
user: action.payload,
|
||||
};
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
@@ -80,16 +97,39 @@ const AuthContext = createContext<AuthContextValue | null>(null);
|
||||
export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
const [state, dispatch] = useReducer(authReducer, initialState);
|
||||
|
||||
// Restore auth state from localStorage on mount
|
||||
useEffect(() => {
|
||||
const token = getAccessToken();
|
||||
if (token) {
|
||||
// We have a stored token — try to validate it by fetching the user profile.
|
||||
// For now, assume the token is valid if it exists. A full implementation
|
||||
// would call a /api/auth/me/ endpoint to verify the token.
|
||||
// Since we don't have that endpoint, we'll set isRestoring=false and let
|
||||
// the user see the authenticated UI. API calls will fail at runtime if the
|
||||
// token is expired (and the refresh interceptor handles that).
|
||||
const userData = localStorage.getItem("user_data");
|
||||
if (userData) {
|
||||
try {
|
||||
const user: UserProfile = JSON.parse(userData);
|
||||
dispatch({ type: "AUTH_SUCCESS", payload: user });
|
||||
return;
|
||||
} catch {
|
||||
// corrupt stored data — proceed with unauthenticated state
|
||||
}
|
||||
}
|
||||
}
|
||||
dispatch({ type: "RESTORE_COMPLETE", payload: null });
|
||||
}, []);
|
||||
|
||||
const login = useCallback(async (payload: LoginPayload) => {
|
||||
dispatch({ type: "AUTH_START" });
|
||||
try {
|
||||
const response = await loginUser(payload);
|
||||
localStorage.setItem("access_token", response.access);
|
||||
localStorage.setItem("refresh_token", response.refresh);
|
||||
setTokens(response.access, response.refresh);
|
||||
localStorage.setItem("user_data", JSON.stringify(response.user));
|
||||
dispatch({ type: "AUTH_SUCCESS", payload: response.user });
|
||||
} catch (err: unknown) {
|
||||
const message =
|
||||
err instanceof Error ? err.message : "Login failed. Please try again.";
|
||||
const message = extractErrorMessage(err);
|
||||
dispatch({ type: "AUTH_FAILURE", payload: message });
|
||||
throw err;
|
||||
}
|
||||
@@ -103,18 +143,15 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
// Set isLoading=false by clearing auth state (no auto-authentication).
|
||||
dispatch({ type: "LOGOUT" });
|
||||
} catch (err: unknown) {
|
||||
const message =
|
||||
err instanceof Error
|
||||
? err.message
|
||||
: "Registration failed. Please try again.";
|
||||
const message = extractErrorMessage(err);
|
||||
dispatch({ type: "AUTH_FAILURE", payload: message });
|
||||
throw err;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const logout = useCallback(() => {
|
||||
localStorage.removeItem("access_token");
|
||||
localStorage.removeItem("refresh_token");
|
||||
clearTokens();
|
||||
localStorage.removeItem("user_data");
|
||||
dispatch({ type: "LOGOUT" });
|
||||
}, []);
|
||||
|
||||
@@ -138,4 +175,4 @@ export function useAuth(): AuthContextValue {
|
||||
throw new Error("useAuth must be used within an AuthProvider");
|
||||
}
|
||||
return context;
|
||||
}
|
||||
}
|
||||
+152
-4
@@ -1,4 +1,13 @@
|
||||
import axios from "axios";
|
||||
import axios, { AxiosError, type AxiosResponse, type InternalAxiosRequestConfig } from "axios";
|
||||
|
||||
interface QueuedRequest {
|
||||
resolve: (token: string) => void;
|
||||
reject: (err: unknown) => void;
|
||||
}
|
||||
|
||||
interface RetryConfig extends InternalAxiosRequestConfig {
|
||||
_retry?: boolean;
|
||||
}
|
||||
|
||||
const apiClient = axios.create({
|
||||
baseURL: import.meta.env.VITE_API_URL || "http://localhost:8000",
|
||||
@@ -7,15 +16,150 @@ const apiClient = axios.create({
|
||||
},
|
||||
});
|
||||
|
||||
// Attach access token to every request if present
|
||||
apiClient.interceptors.request.use((config) => {
|
||||
const token = localStorage.getItem("access_token");
|
||||
// ── Token management ──────────────────────────────────────────────────
|
||||
|
||||
function getAccessToken(): string | null {
|
||||
return localStorage.getItem("access_token");
|
||||
}
|
||||
|
||||
function getRefreshToken(): string | null {
|
||||
return localStorage.getItem("refresh_token");
|
||||
}
|
||||
|
||||
function setTokens(access: string, refresh: string): void {
|
||||
localStorage.setItem("access_token", access);
|
||||
localStorage.setItem("refresh_token", refresh);
|
||||
}
|
||||
|
||||
function clearTokens(): void {
|
||||
localStorage.removeItem("access_token");
|
||||
localStorage.removeItem("refresh_token");
|
||||
}
|
||||
|
||||
// ── Request interceptor: attach access token ──────────────────────────
|
||||
|
||||
apiClient.interceptors.request.use((config: InternalAxiosRequestConfig) => {
|
||||
const token = getAccessToken();
|
||||
if (token && config.headers) {
|
||||
config.headers.Authorization = `Bearer ${token}`;
|
||||
}
|
||||
return config;
|
||||
});
|
||||
|
||||
// ── Response interceptor: auto-refresh on 401 ─────────────────────────
|
||||
|
||||
let isRefreshing = false;
|
||||
let pendingRequests: QueuedRequest[] = [];
|
||||
|
||||
apiClient.interceptors.response.use(
|
||||
(response: AxiosResponse) => response,
|
||||
async (error: AxiosError) => {
|
||||
const originalRequest = error.config as RetryConfig | undefined;
|
||||
|
||||
// Only attempt refresh if it's a 401, not already retried, and we have a refresh token
|
||||
if (
|
||||
!originalRequest ||
|
||||
error.response?.status !== 401 ||
|
||||
originalRequest._retry ||
|
||||
!getRefreshToken()
|
||||
) {
|
||||
return Promise.reject(error);
|
||||
}
|
||||
|
||||
// If already refreshing, queue this request
|
||||
if (isRefreshing) {
|
||||
return new Promise<string>((resolve, reject) => {
|
||||
pendingRequests.push({ resolve, reject });
|
||||
}).then((token) => {
|
||||
if (originalRequest.headers) {
|
||||
originalRequest.headers.Authorization = `Bearer ${token}`;
|
||||
}
|
||||
return apiClient(originalRequest);
|
||||
});
|
||||
}
|
||||
|
||||
originalRequest._retry = true;
|
||||
isRefreshing = true;
|
||||
|
||||
try {
|
||||
const response = await axios.post(
|
||||
`${apiClient.defaults.baseURL}/api/auth/token/refresh/`,
|
||||
{ refresh: getRefreshToken() },
|
||||
);
|
||||
const newAccess: string = response.data.access;
|
||||
localStorage.setItem("access_token", newAccess);
|
||||
|
||||
// Replay queued requests with the new token
|
||||
pendingRequests.forEach((p) => p.resolve(newAccess));
|
||||
pendingRequests = [];
|
||||
|
||||
if (originalRequest.headers) {
|
||||
originalRequest.headers.Authorization = `Bearer ${newAccess}`;
|
||||
}
|
||||
return apiClient(originalRequest);
|
||||
} catch {
|
||||
// Refresh failed — clear tokens and reject all queued requests
|
||||
clearTokens();
|
||||
pendingRequests.forEach((p) =>
|
||||
p.reject(new Error("Session expired. Please sign in again.")),
|
||||
);
|
||||
pendingRequests = [];
|
||||
return Promise.reject(error);
|
||||
} finally {
|
||||
isRefreshing = false;
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// ── Error extraction from Axios responses ─────────────────────────────
|
||||
|
||||
/**
|
||||
* Extract a human-readable error message from an AxiosError.
|
||||
* Backend DRF errors can be:
|
||||
* - {"field": ["message"]} (field-level)
|
||||
* - {"non_field_errors": ["message"]} (general)
|
||||
* - {"detail": "message"} (list/generic)
|
||||
* - string (unexpected shape)
|
||||
*/
|
||||
export function extractErrorMessage(err: unknown): string {
|
||||
if (err instanceof AxiosError && err.response?.data) {
|
||||
const data = err.response.data as Record<string, unknown>;
|
||||
|
||||
// DRF non_field_errors
|
||||
if (
|
||||
Array.isArray(data.non_field_errors) &&
|
||||
data.non_field_errors.length > 0
|
||||
) {
|
||||
return String(data.non_field_errors[0]);
|
||||
}
|
||||
|
||||
// DRF detail (e.g., 401 Unauthorized)
|
||||
if (typeof data.detail === "string") {
|
||||
return data.detail;
|
||||
}
|
||||
|
||||
// Field-level errors — pick the first one
|
||||
for (const key of Object.keys(data)) {
|
||||
const val = data[key];
|
||||
if (Array.isArray(val) && val.length > 0) {
|
||||
return String(val[0]);
|
||||
}
|
||||
if (typeof val === "string") {
|
||||
return val;
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: raw string
|
||||
if (typeof data === "string") return data;
|
||||
}
|
||||
|
||||
// Generic error fallback
|
||||
if (err instanceof Error) return err.message;
|
||||
return "An unexpected error occurred. Please try again.";
|
||||
}
|
||||
|
||||
// ── Types ─────────────────────────────────────────────────────────────
|
||||
|
||||
export interface RegisterPayload {
|
||||
email: string;
|
||||
password: string;
|
||||
@@ -42,6 +186,8 @@ export interface LoginResponse {
|
||||
refresh: string;
|
||||
}
|
||||
|
||||
// ── API functions ─────────────────────────────────────────────────────
|
||||
|
||||
export function registerUser(payload: RegisterPayload): Promise<UserProfile> {
|
||||
return apiClient
|
||||
.post<UserProfile>("/api/auth/register/", payload)
|
||||
@@ -53,3 +199,5 @@ export function loginUser(payload: LoginPayload): Promise<LoginResponse> {
|
||||
.post<LoginResponse>("/api/auth/login/", payload)
|
||||
.then((res) => res.data);
|
||||
}
|
||||
|
||||
export { apiClient, setTokens, clearTokens, getAccessToken, getRefreshToken };
|
||||
Reference in New Issue
Block a user