fix(auth): refresh expired access tokens automatically

This commit is contained in:
2026-04-24 22:21:46 +03:30
parent 99257ef70f
commit 57e727da19

View File

@@ -1,49 +1,142 @@
import { API_BASE_URL } from "../config/constants" import { API_BASE_URL } from "../config/constants"
export const authFetch = async (endpoint: string, options: RequestInit = {}) => { let refreshRequest: Promise<string | null> | null = null
const token = localStorage.getItem("accessToken")
const isFormData = options.body instanceof FormData const cleanBaseUrl = API_BASE_URL.replace(/\/+$/, "")
const headers: HeadersInit = { const buildUrl = (endpoint: string) => {
...(!isFormData && { "Content-Type": "application/json" }), const cleanEndpoint = endpoint.startsWith("/") ? endpoint : `/${endpoint}`
...(token ? { Authorization: `Bearer ${token}` } : {}), return `${cleanBaseUrl}${cleanEndpoint}`
...options.headers, }
}
const normalizeJsonResponse = (response: Response) => {
const cleanBaseUrl = API_BASE_URL.replace(/\/+$/, "") const originalJson = response.json.bind(response)
const cleanEndpoint = endpoint.startsWith("/") ? endpoint : `/${endpoint}`
const url = `${cleanBaseUrl}${cleanEndpoint}` response.json = async () => {
if (response.status === 204 || response.status === 205) {
const response = await fetch(url, { return {}
...options, }
headers,
}) const data = await originalJson()
if (response.status === 401) { if (data && typeof data === "object" && "items" in data && "pages_count" in data) {
localStorage.removeItem("accessToken") return {
localStorage.removeItem("refreshToken") count: data.total_items || 0,
window.location.href = "/auth" next: null,
return response previous: null,
} results: data.items || [],
_meta: {
const originalJson = response.json.bind(response) pages_count: data.pages_count,
response.json = async () => { items_per_page: data.items_per_page,
const data = await originalJson() current_page: data.current_page,
},
if (data && typeof data === "object" && "items" in data && "pages_count" in data) { }
return { }
count: data.total_items || 0,
results: data.items || [], return data
_meta: { }
pages_count: data.pages_count,
items_per_page: data.items_per_page, return response
current_page: data.current_page }
}
} const clearSessionAndRedirect = () => {
} localStorage.removeItem("accessToken")
localStorage.removeItem("refreshToken")
return data if (window.location.pathname !== "/auth") {
} window.location.href = "/auth"
}
return response }
}
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<Response> => {
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)
}