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
This commit is contained in:
Marko (Hermes Implementer)
2026-05-29 02:24:51 +00:00
21 changed files with 1016 additions and 116 deletions
+83 -37
View File
@@ -1,10 +1,5 @@
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;
}
@@ -46,34 +41,52 @@ apiClient.interceptors.request.use((config: InternalAxiosRequestConfig) => {
return config;
});
// ── Response interceptor: auto-refresh on 401 ─────────────────────────
// ── Response interceptor: auto-refresh on 401, retry once ─────────────
let isRefreshing = false;
let pendingRequests: QueuedRequest[] = [];
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 | undefined;
const originalRequest = error.config as RetryConfig;
// Only attempt refresh if it's a 401, not already retried, and we have a refresh token
if (!originalRequest) {
return Promise.reject(error);
}
// Only handle 401s that aren't already refresh/login/register/logout attempts
if (
!originalRequest ||
error.response?.status !== 401 ||
originalRequest._retry ||
!getRefreshToken()
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 already refreshing, queue this request
if (isRefreshing) {
// Queue this request until the refresh completes
return new Promise<string>((resolve, reject) => {
pendingRequests.push({ resolve, reject });
failedQueue.push({ resolve, reject });
}).then((token) => {
if (originalRequest.headers) {
originalRequest.headers.Authorization = `Bearer ${token}`;
}
originalRequest.headers!.Authorization = `Bearer ${token}`;
return apiClient(originalRequest);
});
}
@@ -81,34 +94,39 @@ apiClient.interceptors.response.use(
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: getRefreshToken() },
{ refresh: refreshToken }
);
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 = [];
const newAccessToken = response.data.access;
const newRefreshToken = response.data.refresh;
if (originalRequest.headers) {
originalRequest.headers.Authorization = `Bearer ${newAccess}`;
}
setTokens(newAccessToken, newRefreshToken);
processQueue(null, newAccessToken);
originalRequest.headers!.Authorization = `Bearer ${newAccessToken}`;
return apiClient(originalRequest);
} catch {
// Refresh failed — clear tokens and reject all queued requests
} catch (refreshError) {
processQueue(refreshError, null);
clearTokens();
pendingRequests.forEach((p) =>
p.reject(new Error("Session expired. Please sign in again.")),
);
pendingRequests = [];
return Promise.reject(error);
window.location.href = "/login";
return Promise.reject(refreshError);
} finally {
isRefreshing = false;
}
},
}
);
// ── Error extraction from Axios responses ─────────────────────────────
@@ -158,7 +176,7 @@ export function extractErrorMessage(err: unknown): string {
return "An unexpected error occurred. Please try again.";
}
// ── Types ─────────────────────────────────────────────────────────────
// ── Types ─────────────────────────────────────────────────────────────
export interface RegisterPayload {
email: string;
@@ -175,7 +193,6 @@ export interface LoginPayload {
export interface UserProfile {
id: number;
email: string;
first_name: string;
last_name: string;
}
@@ -186,7 +203,7 @@ export interface LoginResponse {
refresh: string;
}
// ── API functions ─────────────────────────────────────────────────────
// ── API functions ─────────────────────────────────────────────────────
export function registerUser(payload: RegisterPayload): Promise<UserProfile> {
return apiClient
@@ -200,4 +217,33 @@ export function loginUser(payload: LoginPayload): Promise<LoginResponse> {
.then((res) => res.data);
}
export { apiClient, setTokens, clearTokens, getAccessToken, getRefreshToken };
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,
};