fix: proper Axios error extraction, token refresh, auth state restoration, ProtectedRoute

This commit is contained in:
Marko (Hermes Implementer)
2026-05-29 00:13:31 +00:00
parent a0b3ae7e34
commit 84c6262b3d
7 changed files with 469 additions and 24 deletions
+152 -4
View File
@@ -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 };