Resolved conflicts in: - api/accounts/urls.py: Took main's complete URL set (verify/refresh/logout/me) - web/src/App.tsx: Took main's route structure with PublicRoute/ProtectedRoute wrappers - web/src/services/authApi.ts: Combined main's robust interceptor with PR's extractErrorMessage + getAccessToken/getRefreshToken helpers - web/src/contexts/AuthContext.tsx: Combined main's full auth flow (refreshAccessToken, getProfile, logoutUser) with PR's extractErrorMessage and user_data persistence
249 lines
6.9 KiB
TypeScript
249 lines
6.9 KiB
TypeScript
import axios, { AxiosError, type AxiosResponse, type InternalAxiosRequestConfig } from "axios";
|
|
|
|
interface RetryConfig extends InternalAxiosRequestConfig {
|
|
_retry?: boolean;
|
|
}
|
|
|
|
const apiClient = axios.create({
|
|
baseURL: import.meta.env.VITE_API_URL || "http://localhost:8000",
|
|
headers: {
|
|
"Content-Type": "application/json",
|
|
},
|
|
});
|
|
|
|
// ── 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, retry once ─────────────
|
|
|
|
let isRefreshing = false;
|
|
let failedQueue: Array<{
|
|
resolve: (token: string) => void;
|
|
reject: (err: unknown) => void;
|
|
}> = [];
|
|
|
|
function processQueue(error: unknown, token: string | null = null): void {
|
|
failedQueue.forEach((prom) => {
|
|
if (error) {
|
|
prom.reject(error);
|
|
} else if (token) {
|
|
prom.resolve(token);
|
|
}
|
|
});
|
|
failedQueue = [];
|
|
}
|
|
|
|
apiClient.interceptors.response.use(
|
|
(response: AxiosResponse) => response,
|
|
async (error: AxiosError) => {
|
|
const originalRequest = error.config as RetryConfig;
|
|
|
|
if (!originalRequest) {
|
|
return Promise.reject(error);
|
|
}
|
|
|
|
// Only handle 401s that aren't already refresh/login/register/logout attempts
|
|
if (
|
|
error.response?.status !== 401 ||
|
|
originalRequest._retry ||
|
|
originalRequest.url?.includes("/api/auth/token/refresh/") ||
|
|
originalRequest.url?.includes("/api/auth/login/") ||
|
|
originalRequest.url?.includes("/api/auth/register/") ||
|
|
originalRequest.url?.includes("/api/auth/logout/")
|
|
) {
|
|
return Promise.reject(error);
|
|
}
|
|
|
|
if (isRefreshing) {
|
|
// Queue this request until the refresh completes
|
|
return new Promise<string>((resolve, reject) => {
|
|
failedQueue.push({ resolve, reject });
|
|
}).then((token) => {
|
|
originalRequest.headers!.Authorization = `Bearer ${token}`;
|
|
return apiClient(originalRequest);
|
|
});
|
|
}
|
|
|
|
originalRequest._retry = true;
|
|
isRefreshing = true;
|
|
|
|
const refreshToken = getRefreshToken();
|
|
|
|
if (!refreshToken) {
|
|
isRefreshing = false;
|
|
clearTokens();
|
|
window.location.href = "/login";
|
|
return Promise.reject(error);
|
|
}
|
|
|
|
try {
|
|
const response = await axios.post(
|
|
`${apiClient.defaults.baseURL}/api/auth/token/refresh/`,
|
|
{ refresh: refreshToken }
|
|
);
|
|
|
|
const newAccessToken = response.data.access;
|
|
const newRefreshToken = response.data.refresh;
|
|
|
|
setTokens(newAccessToken, newRefreshToken);
|
|
|
|
processQueue(null, newAccessToken);
|
|
|
|
originalRequest.headers!.Authorization = `Bearer ${newAccessToken}`;
|
|
return apiClient(originalRequest);
|
|
} catch (refreshError) {
|
|
processQueue(refreshError, null);
|
|
clearTokens();
|
|
window.location.href = "/login";
|
|
return Promise.reject(refreshError);
|
|
} 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;
|
|
password_confirm: string;
|
|
first_name?: string;
|
|
last_name?: string;
|
|
}
|
|
|
|
export interface LoginPayload {
|
|
email: string;
|
|
password: string;
|
|
}
|
|
|
|
export interface UserProfile {
|
|
id: number;
|
|
first_name: string;
|
|
last_name: string;
|
|
}
|
|
|
|
export interface LoginResponse {
|
|
user: UserProfile;
|
|
access: string;
|
|
refresh: string;
|
|
}
|
|
|
|
// ── API functions ──────────────────────────────────────────────────────
|
|
|
|
export function registerUser(payload: RegisterPayload): Promise<UserProfile> {
|
|
return apiClient
|
|
.post<UserProfile>("/api/auth/register/", payload)
|
|
.then((res) => res.data);
|
|
}
|
|
|
|
export function loginUser(payload: LoginPayload): Promise<LoginResponse> {
|
|
return apiClient
|
|
.post<LoginResponse>("/api/auth/login/", payload)
|
|
.then((res) => res.data);
|
|
}
|
|
|
|
export function getProfile(): Promise<UserProfile> {
|
|
return apiClient
|
|
.get<UserProfile>("/api/auth/me/")
|
|
.then((res) => res.data);
|
|
}
|
|
|
|
export function logoutUser(refreshToken: string): Promise<void> {
|
|
return apiClient
|
|
.post("/api/auth/logout/", { refresh: refreshToken })
|
|
.then(() => {});
|
|
}
|
|
|
|
export function refreshAccessToken(
|
|
refreshToken: string
|
|
): Promise<{ access: string; refresh: string }> {
|
|
return apiClient
|
|
.post<{ access: string; refresh: string }>(
|
|
"/api/auth/token/refresh/",
|
|
{ refresh: refreshToken }
|
|
)
|
|
.then((res) => res.data);
|
|
}
|
|
|
|
export {
|
|
apiClient,
|
|
getAccessToken,
|
|
getRefreshToken,
|
|
setTokens,
|
|
clearTokens,
|
|
}; |