mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
152 lines
4.9 KiB
TypeScript
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 };
|