Files
edr-platform/apps/edr-freight-web/portal/src/utils/api.ts
2026-07-16 08:52:29 +00:00

152 lines
4.9 KiB
TypeScript

import { UseQueryOptions, QueryObserverOptions } from "@tanstack/react-query";
import axios, { AxiosError, InternalAxiosRequestConfig } from "axios";
import { URL_CONSTANTS } from "@/constants/URLS";
import { API_BASE_URL } from "@/constants/apiConfig";
import {
emitApiError,
extractApiErrorPayload,
} from "@/components/errors/ApiErrorModal";
import { captureApiError } from "@/lib/posthog";
const client = axios.create({
baseURL: API_BASE_URL,
});
function getCookie(name: string): string | undefined {
return document.cookie
.split("; ")
.find((row) => row.startsWith(`${name}=`))
?.split("=")[1];
}
function setCookie(name: string, value: string, days: number) {
const expires = new Date();
expires.setDate(expires.getDate() + days);
document.cookie = `${name}=${value}; path=/; expires=${expires.toUTCString()}; SameSite=Lax`;
}
function clearAuthCookies() {
[
"auth-token",
"refresh-token",
"auth-user",
"current-position-id",
"selected-position-id",
].forEach((name) => {
document.cookie = `${name}=; Max-Age=0; path=/`;
});
}
// Attach auth token to every request
client.interceptors.request.use((config) => {
const token = getCookie("auth-token");
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
});
// Token refresh state
let refreshPromise: Promise<string> | null = null;
/**
* Single-flight token refresh: concurrent callers (the 401 interceptor and
* the proactive scheduler) share one in-flight request so the refresh token
* is only rotated once. Throws if no refresh token is stored or the server
* rejects it — callers decide how to end the session.
*/
async function refreshSessionTokens(): Promise<string> {
refreshPromise ??= (async () => {
const refreshToken = getCookie("refresh-token");
if (!refreshToken) {
throw new Error("missing refresh token");
}
type TokenPair = { token: string; refreshToken: string };
const { data } = await client.post<Partial<TokenPair> & { data?: TokenPair }>(
URL_CONSTANTS.AUTH.REFRESH_TOKEN,
{ refreshToken },
);
// The API returns the pair flat ({ success, token, refreshToken }); accept
// a { data: { ... } }-wrapped shape too so a transform change can't
// silently break refresh again.
const payload = data.data ?? data;
if (!payload.token || !payload.refreshToken) {
throw new Error("malformed refresh-token response");
}
setCookie("auth-token", payload.token, 7);
setCookie("refresh-token", payload.refreshToken, 7);
return payload.token;
})().finally(() => {
refreshPromise = null;
});
return refreshPromise;
}
// Handle auth errors globally with token refresh
client.interceptors.response.use(
(response) => response,
async (error: AxiosError) => {
const originalRequest = error.config as InternalAxiosRequestConfig & {
_retry?: boolean;
};
// Report the failure to PostHog. Hooked here rather than inside
// `emitApiError`, which stays silent on suppressed paths (warehouse /
// mile / onboarding) — those failures still need reporting.
// 401s are skipped: an expired session is refreshed below, not a defect.
if (!error.response || error.response.status !== 401) {
captureApiError(error);
}
// Don't intercept if:
// - no response (network error)
// - status is not 401
// - already retried
// - it's the refresh endpoint itself
// - it's a credential endpoint (401 there = wrong credentials, not an
// expired session — refreshing would mask the real error)
if (
!error.response ||
error.response.status !== 401 ||
originalRequest._retry ||
originalRequest.url === URL_CONSTANTS.AUTH.REFRESH_TOKEN ||
originalRequest.url === URL_CONSTANTS.AUTH.LOGIN ||
originalRequest.url === URL_CONSTANTS.USERS.SIGN_UP
) {
// Surface the server's actual error message in the global error modal
// (401s are handled by the session flow below/redirects, so skip them).
if (error.response && error.response.status !== 401) {
const payload = extractApiErrorPayload(error);
if (payload) emitApiError(payload);
}
return Promise.reject(error);
}
// Nothing to refresh with (e.g. not logged in yet) — surface the
// original error instead of a confusing refresh failure.
if (!getCookie("refresh-token")) {
if (getCookie("auth-token")) {
// Half-broken cookie state; reset it.
clearAuthCookies();
}
return Promise.reject(error);
}
originalRequest._retry = true;
try {
const token = await refreshSessionTokens();
originalRequest.headers.Authorization = `Bearer ${token}`;
return client(originalRequest);
} catch (refreshError) {
clearAuthCookies();
return Promise.reject(refreshError);
}
},
);
export { client, clearAuthCookies, getCookie, refreshSessionTokens };
export type { UseQueryOptions, QueryObserverOptions };