diff --git a/web/src/api/index.ts b/web/src/api/index.ts index f740036..2848e04 100644 --- a/web/src/api/index.ts +++ b/web/src/api/index.ts @@ -4,65 +4,100 @@ import { useAuth } from "@/store/auth"; import Axios, { type AxiosResponse } from "axios"; import { refreshTokenApi } from "./refresh"; +import { isExpired } from "react-jwt"; + export const axios = Axios.create({ headers: { "Content-Type": "application/json", }, }); +let isRefreshing = false; +let refreshQueue: ((token: string | null) => void)[] = []; + +const waitForTokenRefresh = () => { + return new Promise((resolve) => { + refreshQueue.push((token: string | null) => resolve(token)); + }); +}; + +const processRefreshQueue = async (token: string | null) => { + refreshQueue.forEach((resolve) => resolve(token)); + refreshQueue = []; +}; + +const refreshToken = async ( + accountId: string, + refreshToken: string +): Promise<{ access: string; refresh: string }> => { + const db = useDbStore.getState().db; + const loadAccounts = useAuth.getState().loadAccounts; + const requireSignIn = useAuth.getState().requireSignIn; + + if (!db) { + console.log("No database connection available."); + return Promise.reject("No database connection available."); + } + + try { + const response = await refreshTokenApi(refreshToken); + + await updateAccountTokens(db, { + accountId: accountId, + access: response.access, + refresh: response.refresh, + }); + + processRefreshQueue(response.access); + + return { access: response.access, refresh: response.refresh }; + } catch (err) { + console.error("Token refresh failed:", err); + await deleteAccount(db, accountId); + requireSignIn?.(); + processRefreshQueue(null); + throw err; + } finally { + localStorage.removeItem("refreshing"); + loadAccounts?.(); + isRefreshing = false; + } +}; + axios.interceptors.request.use( - (request) => { + async (request) => { const account = useAuth.getState().activeAccount; - if (account?.access) { - request.headers["Authorization"] = `Bearer ${account.access}`; + let token: string | null = account?.access ?? null; + + if (!token || !isExpired(token)) { + request.headers["Authorization"] = `Bearer ${token}`; + return request; } + if (!isRefreshing) { + isRefreshing = true; + try { + const { access } = await refreshToken( + account!.accountId, + account!.refresh + ); + token = access; + } catch (err) { + console.error("Token refresh failed:", err); + throw err; + } + } else { + token = await waitForTokenRefresh(); + } + + if (!token) { + throw new Error("No token available"); + } + + request.headers["Authorization"] = `Bearer ${token}`; return request; }, - (error) => { - return Promise.reject(error); - } -); - -axios.interceptors.response.use( - (response) => response, - async (error) => { - const originalRequest = error.config; - - if (error.response.status === 401 && !originalRequest._retry) { - originalRequest._retry = true; - - const account = useAuth.getState().activeAccount; - if (!account?.refresh) { - return Promise.reject(new Error("Unauthorized. No refresh token")); - } - - const db = useDbStore.getState().db; - if (!db) { - return Promise.reject(new Error("No database connection")); - } - - try { - const response = await refreshTokenApi(account.refresh); - - updateAccountTokens(db, { - accountId: account.accountId, - access: response.access, - refresh: response.refresh, - }); - } catch (err) { - console.error("token refresh failed:", err); - await deleteAccount(db, account.accountId); - const loadAccounts = useAuth.getState().loadAccounts; - loadAccounts?.(); - const requireSignIn = useAuth.getState().requireSignIn; - requireSignIn?.(); - return Promise.reject(err); - } - } - - return Promise.reject(error); - } + (error) => Promise.reject(error) ); export const handleApiError = async (response: AxiosResponse) => {