Files
edr-platform/apps/edr-freight-web/backoffice/src/auth/http.ts
Marshal 603537a20b add yard distances management to rule engine
- Introduced new yard distances resource with CRUD operations.
- Created migration for yard distances table with necessary constraints.
- Implemented service and repository for yard distances handling.
- Added controller for API endpoints to manage yard distances.
- Updated rule engine configuration to include yard distances.
- Enhanced rule engine resource page to support yard distance selection.
- Updated contracts and train builder pages to handle new yard distance logic.
- Added error handling utility for better error message extraction.
2026-07-21 08:50:50 +00:00

156 lines
4.7 KiB
TypeScript

import axios from "axios";
import { API_BASE_URL } from "@/constants/apiConfig";
import {
emitApiError,
extractApiErrorPayload,
} from "@/components/errors/ApiErrorModal";
import { captureApiError } from "@/lib/posthog";
import {
AUTH_TOKEN_COOKIE,
REFRESH_TOKEN_COOKIE,
clearSessionCookies,
getCookie,
setCookie,
} from "./cookies";
import type { AuthTokens } from "./types";
declare module "axios" {
export interface AxiosRequestConfig {
/**
* When true, the response interceptor does NOT raise the global error modal
* for this request's failure. For calls the caller handles itself — e.g. a
* probe that is expected to 404 before falling back (GL clearance detail
* tries /contracts/:id then /bookings/:id). The rejection still propagates.
*/
suppressErrorModal?: boolean;
}
}
type RetriableRequest = {
_retry?: boolean;
headers?: Record<string, string>;
url?: string;
suppressErrorModal?: boolean;
};
const api = axios.create({
baseURL: `${API_BASE_URL}/api`,
withCredentials: true,
});
let refreshPromise: Promise<AuthTokens> | null = null;
const applyTokens = ({ token, refreshToken }: AuthTokens) => {
setCookie(AUTH_TOKEN_COOKIE, token);
setCookie(REFRESH_TOKEN_COOKIE, refreshToken);
};
/**
* 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.
*/
const refreshSessionTokens = async (): Promise<AuthTokens> => {
const refreshToken = getCookie(REFRESH_TOKEN_COOKIE);
if (!refreshToken) {
throw new Error("missing refresh token");
}
refreshPromise ??= api
.post<AuthTokens>("/auth/refresh-token", { refreshToken })
.then((response) => response.data)
.finally(() => {
refreshPromise = null;
});
const tokens = await refreshPromise;
applyTokens(tokens);
return tokens;
};
api.interceptors.request.use((config) => {
const token = getCookie(AUTH_TOKEN_COOKIE);
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
});
api.interceptors.response.use(
(response) => {
if (
response.data &&
typeof response.data === "object" &&
"success" in response.data &&
"data" in response.data
) {
response.data = response.data.data;
}
return response;
},
async (error) => {
const originalRequest = error.config as RetriableRequest | undefined;
// 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);
}
if (
error.response?.status !== 401 ||
!originalRequest ||
originalRequest._retry ||
originalRequest.url?.includes("/auth/login") ||
originalRequest.url?.includes("/auth/mfa-verify") ||
originalRequest.url?.includes("/auth/refresh-token")
) {
// Surface the server's actual error message in the global error modal
// (401s are handled by the session-refresh flow, so skip them). A request
// may opt out via `suppressErrorModal` when it handles the failure itself.
if (error.response && error.response.status !== 401) {
const payload = extractApiErrorPayload(error);
// Normalize the error's own `message` to the SERVER's actual message so
// every downstream `toast.error(err.message)` / MutationCache handler
// shows the real cause instead of "Request failed with status code NNN".
// Applies even on suppressErrorModal paths — only the modal is opted out.
if (payload?.messages.length) {
(error as { message?: string }).message = payload.messages.join("\n");
}
if (payload && !originalRequest?.suppressErrorModal) emitApiError(payload);
}
return Promise.reject(error);
}
if (!getCookie(REFRESH_TOKEN_COOKIE)) {
clearSessionCookies();
return Promise.reject(error);
}
originalRequest._retry = true;
try {
const tokens = await refreshSessionTokens();
originalRequest.headers = {
...originalRequest.headers,
Authorization: `Bearer ${tokens.token}`,
};
return api(originalRequest);
} catch (refreshError) {
clearSessionCookies();
window.location.replace("/auth");
return Promise.reject(refreshError);
}
},
);
export { api, applyTokens, refreshSessionTokens };