Files
vontor-cz/frontend/src/api/Client.ts
2025-10-31 13:32:39 +01:00

276 lines
7.6 KiB
TypeScript

import axios from "axios";
// --- ENV CONFIG ---
const API_BASE_URL =
import.meta.env.VITE_API_BASE_URL || "http://localhost:8000";
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, // cookies
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();
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);
}
);
function Logout() {
try {
const LogOutResponse = apiAuth.post("/api/logout/");
if (LogOutResponse.body.detail != "Logout successful") {
throw new Error("Logout failed");
}
ClearTokens();
} catch (error) {
console.error("Error during logout:", error);
}
}
function ClearTokens(){
document.cookie = "access_token=; Max-Age=0; path=/";
document.cookie = "refresh_token=; Max-Age=0; path=/";
}
// --- EXPORT DEFAULT API WRAPPER ---
const Client = {
// Axios instances
auth: apiAuth,
public: apiPublic,
Logout,
// 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"
*/