Files
job-tracker/web/src/services/authApi.ts
T
Marko (Hermes Implementer) 2e71eb73b8 Merge branch 'main' into fix/user-auth-error-handling
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
2026-05-29 02:24:51 +00:00

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,
};