commit
This commit is contained in:
268
frontend/src/api/Client.ts
Normal file
268
frontend/src/api/Client.ts
Normal file
@@ -0,0 +1,268 @@
|
||||
import axios from "axios";
|
||||
|
||||
// --- ENV CONFIG ---
|
||||
const API_BASE_URL =
|
||||
import.meta.env.VITE_API_BASE_URL || "http://localhost:8000";
|
||||
const REFRESH_URL =
|
||||
import.meta.env.VITE_API_REFRESH_URL || "/api/token/refresh/";
|
||||
const LOGIN_PATH = import.meta.env.VITE_LOGIN_PATH || "/login";
|
||||
|
||||
|
||||
// --- ERROR EVENT BUS ---
|
||||
const ERROR_EVENT = "api:error";
|
||||
type ApiErrorDetail = {
|
||||
message: string;
|
||||
status?: number;
|
||||
url?: string;
|
||||
data?: unknown;
|
||||
};
|
||||
|
||||
// Use interface instead of arrow function types for readability
|
||||
interface ApiErrorHandler {
|
||||
(e: CustomEvent<ApiErrorDetail>): void;
|
||||
}
|
||||
|
||||
function notifyError(detail: ApiErrorDetail) {
|
||||
window.dispatchEvent(new CustomEvent<ApiErrorDetail>(ERROR_EVENT, { detail }));
|
||||
// eslint-disable-next-line no-console
|
||||
console.error("[API ERROR]", detail);
|
||||
}
|
||||
function onError(handler: ApiErrorHandler) {
|
||||
const wrapped = handler as EventListener;
|
||||
window.addEventListener(ERROR_EVENT, wrapped as EventListener);
|
||||
return () => window.removeEventListener(ERROR_EVENT, wrapped);
|
||||
}
|
||||
|
||||
// --- AXIOS INSTANCES ---
|
||||
// Always send cookies. Django will set auth cookies; browser will include them automatically.
|
||||
function createAxios(baseURL: string): any {
|
||||
const instance = axios.create({
|
||||
baseURL,
|
||||
withCredentials: true, // <-- always true
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
timeout: 20000,
|
||||
});
|
||||
return instance;
|
||||
}
|
||||
|
||||
// Use a single behavior for both: cookies are always sent
|
||||
const apiPublic = createAxios(API_BASE_URL);
|
||||
const apiAuth = createAxios(API_BASE_URL);
|
||||
|
||||
// --- REQUEST INTERCEPTOR (PUBLIC) ---
|
||||
// Ensure no Authorization header is ever sent by the public client
|
||||
apiPublic.interceptors.request.use(function (config: any) {
|
||||
if (config?.headers && (config.headers as any).Authorization) {
|
||||
delete (config.headers as any).Authorization;
|
||||
}
|
||||
return config;
|
||||
});
|
||||
|
||||
// --- REQUEST INTERCEPTOR (AUTH) ---
|
||||
// Do not attach Authorization header; rely on cookies set by Django.
|
||||
apiAuth.interceptors.request.use(function (config: any) {
|
||||
(config as any)._retryCount = (config as any)._retryCount || 0;
|
||||
return config;
|
||||
});
|
||||
|
||||
// --- RESPONSE INTERCEPTOR (AUTH) ---
|
||||
// Simplified: on 401, redirect to login. Server manages refresh via cookies.
|
||||
apiAuth.interceptors.response.use(
|
||||
function (response: any) {
|
||||
return response;
|
||||
},
|
||||
async function (error: any) {
|
||||
if (!error.response) {
|
||||
alert("Backend connection is unavailable. Please check your network.");
|
||||
notifyError({
|
||||
message: "Network error or backend unavailable",
|
||||
url: error.config?.url,
|
||||
});
|
||||
return Promise.reject(error);
|
||||
}
|
||||
|
||||
const status = error.response.status;
|
||||
if (status === 401) {
|
||||
clearTokens(); // optional: clear cookies client-side
|
||||
window.location.assign(LOGIN_PATH);
|
||||
return Promise.reject(error);
|
||||
}
|
||||
|
||||
notifyError({
|
||||
message:
|
||||
(error.response.data as any)?.detail ||
|
||||
(error.response.data as any)?.message ||
|
||||
`Request failed with status ${status}`,
|
||||
status,
|
||||
url: error.config?.url,
|
||||
data: error.response.data,
|
||||
});
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
|
||||
// --- PUBLIC CLIENT: still emits errors and alerts on network failure ---
|
||||
apiPublic.interceptors.response.use(
|
||||
function (response: any) {
|
||||
return response;
|
||||
},
|
||||
async function (error: any) {
|
||||
if (!error.response) {
|
||||
alert("Backend connection is unavailable. Please check your network.");
|
||||
notifyError({
|
||||
message: "Network error or backend unavailable",
|
||||
url: error.config?.url,
|
||||
});
|
||||
return Promise.reject(error);
|
||||
}
|
||||
notifyError({
|
||||
message:
|
||||
(error.response.data as any)?.detail ||
|
||||
(error.response.data as any)?.message ||
|
||||
`Request failed with status ${error.response.status}`,
|
||||
status: error.response.status,
|
||||
url: error.config?.url,
|
||||
data: error.response.data,
|
||||
});
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
|
||||
// --- TOKEN HELPERS (NO-OPS) ---
|
||||
// Django sets/rotates cookies server-side. Keep API surface to avoid breaking imports.
|
||||
function setTokens(_access?: string, _refresh?: string) {
|
||||
// no-op: cookies are managed by Django
|
||||
}
|
||||
function clearTokens() {
|
||||
// optional: try to clear auth cookies client-side; server should also clear on logout
|
||||
try {
|
||||
document.cookie = "access_token=; Max-Age=0; path=/";
|
||||
document.cookie = "refresh_token=; Max-Age=0; path=/";
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
function getAccessToken(): string | null {
|
||||
// no Authorization header is used; rely purely on cookies
|
||||
return null;
|
||||
}
|
||||
|
||||
// --- EXPORT DEFAULT API WRAPPER ---
|
||||
const Client = {
|
||||
// Axios instances
|
||||
auth: apiAuth,
|
||||
public: apiPublic,
|
||||
|
||||
// Token helpers (kept for compatibility; now no-ops)
|
||||
setTokens,
|
||||
clearTokens,
|
||||
getAccessToken,
|
||||
|
||||
// Error subscription
|
||||
onError,
|
||||
};
|
||||
|
||||
export default Client;
|
||||
|
||||
/**
|
||||
USAGE EXAMPLES (TypeScript/React)
|
||||
|
||||
Import the client
|
||||
--------------------------------------------------
|
||||
import Client from "@/api/Client";
|
||||
|
||||
|
||||
Login: obtain tokens and persist to cookies
|
||||
--------------------------------------------------
|
||||
async function login(username: string, password: string) {
|
||||
// SimpleJWT default login endpoint (adjust if your backend differs)
|
||||
// Example backend endpoint: POST /api/token/ -> { access, refresh }
|
||||
const res = await Client.public.post("/api/token/", { username, password });
|
||||
const { access, refresh } = res.data;
|
||||
Client.setTokens(access, refresh);
|
||||
// After this, Client.auth will automatically attach Authorization header
|
||||
// and refresh when receiving a 401 (up to 2 retries).
|
||||
}
|
||||
|
||||
|
||||
Public request (no cookies, no Authorization)
|
||||
--------------------------------------------------
|
||||
// The public client does NOT send cookies or Authorization.
|
||||
async function listPublicItems() {
|
||||
const res = await Client.public.get("/api/public/items/");
|
||||
return res.data;
|
||||
}
|
||||
|
||||
|
||||
Authenticated requests (auto Bearer header + refresh on 401)
|
||||
--------------------------------------------------
|
||||
async function fetchProfile() {
|
||||
const res = await Client.auth.get("/api/users/me/");
|
||||
return res.data;
|
||||
}
|
||||
|
||||
async function updateProfile(payload: { first_name?: string; last_name?: string }) {
|
||||
const res = await Client.auth.patch("/api/users/me/", payload);
|
||||
return res.data;
|
||||
}
|
||||
|
||||
|
||||
Global error handling (UI notifications)
|
||||
--------------------------------------------------
|
||||
import { useEffect } from "react";
|
||||
|
||||
function useApiErrors(showToast: (msg: string) => void) {
|
||||
useEffect(function () {
|
||||
const unsubscribe = Client.onError(function (e) {
|
||||
const { message, status } = e.detail;
|
||||
showToast(status ? String(status) + ": " + message : message);
|
||||
});
|
||||
return unsubscribe;
|
||||
}, [showToast]);
|
||||
}
|
||||
|
||||
// Note: Network connectivity issues trigger an alert and also dispatch api:error.
|
||||
// All errors are logged to console for developers.
|
||||
|
||||
|
||||
Logout
|
||||
--------------------------------------------------
|
||||
function logout() {
|
||||
Client.clearTokens();
|
||||
window.location.assign("/login");
|
||||
}
|
||||
|
||||
|
||||
Route protection (PrivateRoute)
|
||||
--------------------------------------------------
|
||||
// If you created src/routes/PrivateRoute.tsx, wrap your protected routes with it.
|
||||
// PrivateRoute checks for "access_token" cookie presence and redirects to /login if missing.
|
||||
|
||||
// Example:
|
||||
// <Routes>
|
||||
// <Route element={<PrivateRoute />} >
|
||||
// <Route element={<MainLayout />}>
|
||||
// <Route path="/" element={<Dashboard />} />
|
||||
// <Route path="/profile" element={<Profile />} />
|
||||
// </Route>
|
||||
// </Route>
|
||||
// <Route path="/login" element={<Login />} />
|
||||
// </Routes>
|
||||
|
||||
|
||||
Refresh and retry flow (what happens on 401)
|
||||
--------------------------------------------------
|
||||
// 1) Client.auth request receives 401 from backend
|
||||
// 2) Client tries to refresh access token using refresh_token cookie
|
||||
// 3) If refresh succeeds, original request is retried (max 2 times)
|
||||
// 4) If still 401 (or no refresh token), tokens are cleared and user is redirected to /login
|
||||
|
||||
|
||||
Environment variables (optional overrides)
|
||||
--------------------------------------------------
|
||||
// VITE_API_BASE_URL default: "http://localhost:8000"
|
||||
// VITE_API_REFRESH_URL default: "/api/token/refresh/"
|
||||
// VITE_LOGIN_PATH default: "/login"
|
||||
*/
|
||||
Reference in New Issue
Block a user