diff --git a/src/api/client.ts b/src/api/client.ts index 7f21a66..7fa5fb6 100644 --- a/src/api/client.ts +++ b/src/api/client.ts @@ -1,49 +1,142 @@ -import { API_BASE_URL } from "../config/constants" - -export const authFetch = async (endpoint: string, options: RequestInit = {}) => { - const token = localStorage.getItem("accessToken") - const isFormData = options.body instanceof FormData - - const headers: HeadersInit = { - ...(!isFormData && { "Content-Type": "application/json" }), - ...(token ? { Authorization: `Bearer ${token}` } : {}), - ...options.headers, - } - - const cleanBaseUrl = API_BASE_URL.replace(/\/+$/, "") - const cleanEndpoint = endpoint.startsWith("/") ? endpoint : `/${endpoint}` - const url = `${cleanBaseUrl}${cleanEndpoint}` - - const response = await fetch(url, { - ...options, - headers, - }) - - if (response.status === 401) { - localStorage.removeItem("accessToken") - localStorage.removeItem("refreshToken") - window.location.href = "/auth" - return response - } - - const originalJson = response.json.bind(response) - response.json = async () => { - const data = await originalJson() - - if (data && typeof data === "object" && "items" in data && "pages_count" in data) { - return { - count: data.total_items || 0, - results: data.items || [], - _meta: { - pages_count: data.pages_count, - items_per_page: data.items_per_page, - current_page: data.current_page - } - } - } - - return data - } - - return response -} +import { API_BASE_URL } from "../config/constants" + +let refreshRequest: Promise | null = null + +const cleanBaseUrl = API_BASE_URL.replace(/\/+$/, "") + +const buildUrl = (endpoint: string) => { + const cleanEndpoint = endpoint.startsWith("/") ? endpoint : `/${endpoint}` + return `${cleanBaseUrl}${cleanEndpoint}` +} + +const normalizeJsonResponse = (response: Response) => { + const originalJson = response.json.bind(response) + + response.json = async () => { + if (response.status === 204 || response.status === 205) { + return {} + } + + const data = await originalJson() + + if (data && typeof data === "object" && "items" in data && "pages_count" in data) { + return { + count: data.total_items || 0, + next: null, + previous: null, + results: data.items || [], + _meta: { + pages_count: data.pages_count, + items_per_page: data.items_per_page, + current_page: data.current_page, + }, + } + } + + return data + } + + return response +} + +const clearSessionAndRedirect = () => { + localStorage.removeItem("accessToken") + localStorage.removeItem("refreshToken") + if (window.location.pathname !== "/auth") { + window.location.href = "/auth" + } +} + +const shouldAttemptRefresh = (endpoint: string) => { + const normalizedEndpoint = endpoint.startsWith("/") ? endpoint : `/${endpoint}` + return ![ + "/api/users/login/", + "/api/users/otp/send/", + "/api/users/otp/login/", + "/api/users/token/refresh/", + ].includes(normalizedEndpoint) +} + +const refreshAccessToken = async () => { + const refreshToken = localStorage.getItem("refreshToken") + if (!refreshToken) return null + + if (!refreshRequest) { + refreshRequest = (async () => { + const response = await fetch(buildUrl("/api/users/token/refresh/"), { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ refresh: refreshToken }), + }) + + if (!response.ok) { + return null + } + + const data = await response.json() + const nextAccessToken = typeof data?.access === "string" ? data.access : null + const nextRefreshToken = typeof data?.refresh === "string" ? data.refresh : null + + if (!nextAccessToken) { + return null + } + + localStorage.setItem("accessToken", nextAccessToken) + if (nextRefreshToken) { + localStorage.setItem("refreshToken", nextRefreshToken) + } + + return nextAccessToken + })().finally(() => { + refreshRequest = null + }) + } + + return refreshRequest +} + +export const authFetch = async (endpoint: string, options: RequestInit = {}, allowRetry = true): Promise => { + const token = localStorage.getItem("accessToken") + const isFormData = options.body instanceof FormData + + const headers: HeadersInit = { + ...(!isFormData && { "Content-Type": "application/json" }), + ...(token ? { Authorization: `Bearer ${token}` } : {}), + ...options.headers, + } + + const response = await fetch(buildUrl(endpoint), { + ...options, + headers, + }) + + if (response.status === 401 && allowRetry && shouldAttemptRefresh(endpoint)) { + const nextAccessToken = await refreshAccessToken() + + if (nextAccessToken) { + return authFetch( + endpoint, + { + ...options, + headers: { + ...options.headers, + Authorization: `Bearer ${nextAccessToken}`, + }, + }, + false, + ) + } + + clearSessionAndRedirect() + return response + } + + if (response.status === 401 && shouldAttemptRefresh(endpoint)) { + clearSessionAndRedirect() + return response + } + + return normalizeJsonResponse(response) +}