fix: proper Axios error extraction, token refresh, auth state restoration, ProtectedRoute
This commit is contained in:
+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