diff --git a/apps/edr-freight-web/backoffice/.env.example b/apps/edr-freight-web/backoffice/.env.example index cbbdd289f..a5e34a35d 100644 --- a/apps/edr-freight-web/backoffice/.env.example +++ b/apps/edr-freight-web/backoffice/.env.example @@ -1,2 +1,6 @@ VITE_API_URL=http://localhost:3001 VITE_BASE_API_URL=http://localhost:3001 + +# Proactive token refresh cadence (minutes). Must stay well under the 60-min +# server session window. Default: 10. +VITE_TOKEN_REFRESH_INTERVAL_MINUTES=10 diff --git a/apps/edr-freight-web/backoffice/src/auth/AuthProvider.tsx b/apps/edr-freight-web/backoffice/src/auth/AuthProvider.tsx index 66834c9f5..8266d96d7 100644 --- a/apps/edr-freight-web/backoffice/src/auth/AuthProvider.tsx +++ b/apps/edr-freight-web/backoffice/src/auth/AuthProvider.tsx @@ -16,6 +16,10 @@ import { setCookie, } from "./cookies"; import { applyTokens } from "./http"; +import { + startTokenRefreshScheduler, + stopTokenRefreshScheduler, +} from "./refreshScheduler"; import type { AuthTokens, AuthUser } from "./types"; interface LoginPayload { @@ -99,6 +103,18 @@ export const AuthProvider = ({ children }: { children: ReactNode }) => { void bootstrap(); }, []); + // Keep the server session alive while a user is logged in. Runs after + // login, MFA verification, and page-reload bootstrap alike. + useEffect(() => { + if (!user) { + stopTokenRefreshScheduler(); + return; + } + + startTokenRefreshScheduler(); + return stopTokenRefreshScheduler; + }, [user]); + const value = useMemo( () => ({ user, diff --git a/apps/edr-freight-web/backoffice/src/auth/http.ts b/apps/edr-freight-web/backoffice/src/auth/http.ts index 5aa78e2d3..2b0cf1021 100644 --- a/apps/edr-freight-web/backoffice/src/auth/http.ts +++ b/apps/edr-freight-web/backoffice/src/auth/http.ts @@ -28,6 +28,30 @@ const applyTokens = ({ token, refreshToken }: AuthTokens) => { 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 => { + const refreshToken = getCookie(REFRESH_TOKEN_COOKIE); + if (!refreshToken) { + throw new Error("missing refresh token"); + } + + refreshPromise ??= api + .post("/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); @@ -65,8 +89,7 @@ api.interceptors.response.use( return Promise.reject(error); } - const refreshToken = getCookie(REFRESH_TOKEN_COOKIE); - if (!refreshToken) { + if (!getCookie(REFRESH_TOKEN_COOKIE)) { clearSessionCookies(); return Promise.reject(error); } @@ -74,15 +97,7 @@ api.interceptors.response.use( originalRequest._retry = true; try { - refreshPromise ??= api - .post("/auth/refresh-token", { refreshToken }) - .then((response) => response.data) - .finally(() => { - refreshPromise = null; - }); - - const tokens = await refreshPromise; - applyTokens(tokens); + const tokens = await refreshSessionTokens(); originalRequest.headers = { ...originalRequest.headers, Authorization: `Bearer ${tokens.token}`, @@ -97,4 +112,4 @@ api.interceptors.response.use( }, ); -export { api, applyTokens }; +export { api, applyTokens, refreshSessionTokens }; diff --git a/apps/edr-freight-web/backoffice/src/auth/refreshScheduler.ts b/apps/edr-freight-web/backoffice/src/auth/refreshScheduler.ts new file mode 100644 index 000000000..1d2c14db2 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/auth/refreshScheduler.ts @@ -0,0 +1,82 @@ +import { isAxiosError } from "axios"; + +import { + REFRESH_TOKEN_COOKIE, + clearSessionCookies, + getCookie, +} from "./cookies"; +import { refreshSessionTokens } from "./http"; + +/** + * Proactively refreshes the token pair on a fixed cadence so the server-side + * session (a sliding 1-hour window, extended only by /auth/refresh-token) is + * kept alive while the app is open. The 401 interceptor in http.ts remains + * the reactive fallback; both share the same single-flight refresh call. + * + * The interval MUST stay well under the server session window (60 min). + */ +const DEFAULT_INTERVAL_MINUTES = 10; + +const getIntervalMs = () => { + const minutes = Number(import.meta.env.VITE_TOKEN_REFRESH_INTERVAL_MINUTES); + return ( + (Number.isFinite(minutes) && minutes > 0 + ? minutes + : DEFAULT_INTERVAL_MINUTES) * 60_000 + ); +}; + +let timerId: number | null = null; +let lastRefreshAt = 0; + +const refreshNow = async () => { + if (!getCookie(REFRESH_TOKEN_COOKIE)) { + // Logged out elsewhere; nothing to keep alive. + stopTokenRefreshScheduler(); + return; + } + + try { + await refreshSessionTokens(); + lastRefreshAt = Date.now(); + } catch (error) { + // Network hiccups are retried on the next tick; only an explicit server + // rejection means the session is dead. + if (isAxiosError(error) && error.response) { + stopTokenRefreshScheduler(); + clearSessionCookies(); + window.location.replace("/auth"); + } + } +}; + +/** + * Browsers freeze timers in background tabs — a tab waking up past its + * refresh deadline refreshes immediately instead of waiting a full interval. + */ +const onVisibilityChange = () => { + if (document.visibilityState !== "visible") return; + if (Date.now() - lastRefreshAt >= getIntervalMs()) { + void refreshNow(); + } +}; + +export const startTokenRefreshScheduler = () => { + stopTokenRefreshScheduler(); + + // Token age is unknown here (fresh login vs. hours-old page reload), so + // refresh right away to extend the session window from "now". + lastRefreshAt = 0; + void refreshNow(); + + timerId = window.setInterval(() => void refreshNow(), getIntervalMs()); + document.addEventListener("visibilitychange", onVisibilityChange); +}; + +export const stopTokenRefreshScheduler = () => { + if (timerId !== null) { + window.clearInterval(timerId); + timerId = null; + } + document.removeEventListener("visibilitychange", onVisibilityChange); +}; diff --git a/apps/edr-freight-web/backoffice/src/utils/result.ts b/apps/edr-freight-web/backoffice/src/utils/result.ts index 3e9627445..54104442c 100644 --- a/apps/edr-freight-web/backoffice/src/utils/result.ts +++ b/apps/edr-freight-web/backoffice/src/utils/result.ts @@ -8,6 +8,32 @@ export type ApiError = { statusCode?: number; }; +/** + * Backend errors arrive as snake_case i18n-style codes (e.g. + * "unable_to_log_in"). Map the known ones to friendly copy and prettify + * anything else so raw codes never reach the UI. `code` stays raw for + * programmatic checks. + */ +const API_ERROR_MESSAGES: Record = { + unable_to_log_in: "Incorrect email or password.", + invalid_refresh_token: "Your session has expired. Please sign in again.", + session_expired: "Your session has expired. Please sign in again.", + session_not_found: "Your session has expired. Please sign in again.", + user_not_found: "No account found for these credentials.", +}; + +const SNAKE_CASE_CODE = /^[a-z0-9]+(?:_[a-z0-9]+)+$/; + +function humanizeApiMessage(raw: string): string { + const known = API_ERROR_MESSAGES[raw]; + if (known) return known; + if (SNAKE_CASE_CODE.test(raw)) { + const text = raw.replaceAll("_", " "); + return `${text.charAt(0).toUpperCase()}${text.slice(1)}.`; + } + return raw; +} + export function extractApiError(err: unknown): ApiError { if (err && typeof err === "object") { const obj = err as Record; @@ -16,8 +42,12 @@ export function extractApiError(err: unknown): ApiError { const statusCode = response.status as number | undefined; const data = response.data as Record | undefined; return { - code: (data?.error as string) || (data?.message as string) || "api_error", - message: (data?.message as string) || (data?.error as string) || "An unexpected error occurred", + code: (data?.message as string) || (data?.error as string) || "api_error", + message: humanizeApiMessage( + (data?.message as string) || + (data?.error as string) || + "An unexpected error occurred", + ), statusCode, }; } diff --git a/apps/edr-freight-web/portal/src/App.tsx b/apps/edr-freight-web/portal/src/App.tsx index 7567c485d..697c2f89c 100644 --- a/apps/edr-freight-web/portal/src/App.tsx +++ b/apps/edr-freight-web/portal/src/App.tsx @@ -24,6 +24,10 @@ import OnboardingResumeBanner, { } from "./components/onboarding/OnboardingResumeBanner"; import OnboardingWizardDialog from "./components/onboarding/OnboardingWizardDialog"; import useAuth from "./hooks/useAuth"; +import { + startTokenRefreshScheduler, + stopTokenRefreshScheduler, +} from "./utils/refreshScheduler"; import EDRFreightLandingPage from "./pages/EDRFreightLandingPage"; import MyPortalPage from "./pages/MyPortalPage"; import MySignaturePage from "./pages/MySignaturePage"; @@ -212,7 +216,20 @@ const sidebarItems: SidebarItem[] = [ const App = () => { const navigate = useNavigate(); const location = useLocation(); - const { user, company, companyType, createProfileAndSwitch } = useAuth(); + const { user, company, companyType, createProfileAndSwitch, isAuthenticated } = + useAuth(); + + // Keep the server session alive while a user is logged in. Runs after + // login, signup, and page-reload bootstrap alike. + useEffect(() => { + if (!isAuthenticated) { + stopTokenRefreshScheduler(); + return; + } + + startTokenRefreshScheduler(); + return stopTokenRefreshScheduler; + }, [isAuthenticated]); const displayName = user?.name?.en || user?.username || user?.email || "User"; const userEmail = user?.email; diff --git a/apps/edr-freight-web/portal/src/utils/api.ts b/apps/edr-freight-web/portal/src/utils/api.ts index 289709906..6cc569477 100644 --- a/apps/edr-freight-web/portal/src/utils/api.ts +++ b/apps/edr-freight-web/portal/src/utils/api.ts @@ -42,21 +42,41 @@ client.interceptors.request.use((config) => { }); // Token refresh state -let isRefreshing = false; -let failedQueue: { - resolve: (token: string) => void; - reject: (error: unknown) => void; -}[] = []; +let refreshPromise: Promise | null = null; -function processQueue(error: unknown, token?: string) { - failedQueue.forEach(({ resolve, reject }) => { - if (error) { - reject(error); - } else { - resolve(token!); +/** + * 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 { + 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 & { 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; }); - failedQueue = []; + + return refreshPromise; } // Handle auth errors globally with token refresh @@ -72,54 +92,41 @@ client.interceptors.response.use( // - 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.REFRESH_TOKEN || + originalRequest.url === URL_CONSTANTS.AUTH.LOGIN || + originalRequest.url === URL_CONSTANTS.USERS.SIGN_UP ) { return Promise.reject(error); } - if (isRefreshing) { - return new Promise((resolve, reject) => { - failedQueue.push({ resolve, reject }); - }).then((token) => { - originalRequest.headers.Authorization = `Bearer ${token}`; - return client(originalRequest); - }); - } - - originalRequest._retry = true; - isRefreshing = true; - - const refreshToken = getCookie("refresh-token"); - - if (!refreshToken) { - isRefreshing = false; - clearAuthCookies(); + // 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 { data } = await client.post<{ - data: { token: string; refreshToken: string }; - }>(URL_CONSTANTS.AUTH.REFRESH_TOKEN, { refreshToken }); - const { token, refreshToken: newRefreshToken } = data.data; - setCookie("auth-token", token, 7); - setCookie("refresh-token", newRefreshToken, 7); + const token = await refreshSessionTokens(); originalRequest.headers.Authorization = `Bearer ${token}`; - processQueue(null, token); return client(originalRequest); } catch (refreshError) { - processQueue(refreshError, undefined); clearAuthCookies(); return Promise.reject(refreshError); - } finally { - isRefreshing = false; } }, ); -export { client }; +export { client, clearAuthCookies, getCookie, refreshSessionTokens }; export type { UseQueryOptions, QueryObserverOptions }; diff --git a/apps/edr-freight-web/portal/src/utils/refreshScheduler.ts b/apps/edr-freight-web/portal/src/utils/refreshScheduler.ts new file mode 100644 index 000000000..dceca856e --- /dev/null +++ b/apps/edr-freight-web/portal/src/utils/refreshScheduler.ts @@ -0,0 +1,81 @@ +import { isAxiosError } from "axios"; + +import { + clearAuthCookies, + getCookie, + refreshSessionTokens, +} from "./api"; + +/** + * Proactively refreshes the token pair on a fixed cadence so the server-side + * session (a sliding 1-hour window, extended only by /auth/refresh-token) is + * kept alive while the app is open. The 401 interceptor in api.ts remains + * the reactive fallback; both share the same single-flight refresh call. + * + * The interval MUST stay well under the server session window (60 min). + */ +const DEFAULT_INTERVAL_MINUTES = 10; + +const getIntervalMs = () => { + const minutes = Number(import.meta.env.VITE_TOKEN_REFRESH_INTERVAL_MINUTES); + return ( + (Number.isFinite(minutes) && minutes > 0 + ? minutes + : DEFAULT_INTERVAL_MINUTES) * 60_000 + ); +}; + +let timerId: number | null = null; +let lastRefreshAt = 0; + +const refreshNow = async () => { + if (!getCookie("refresh-token")) { + // Logged out elsewhere; nothing to keep alive. + stopTokenRefreshScheduler(); + return; + } + + try { + await refreshSessionTokens(); + lastRefreshAt = Date.now(); + } catch (error) { + // Network hiccups are retried on the next tick; only an explicit server + // rejection means the session is dead. + if (isAxiosError(error) && error.response) { + stopTokenRefreshScheduler(); + clearAuthCookies(); + window.location.replace("/login"); + } + } +}; + +/** + * Browsers freeze timers in background tabs — a tab waking up past its + * refresh deadline refreshes immediately instead of waiting a full interval. + */ +const onVisibilityChange = () => { + if (document.visibilityState !== "visible") return; + if (Date.now() - lastRefreshAt >= getIntervalMs()) { + void refreshNow(); + } +}; + +export const startTokenRefreshScheduler = () => { + stopTokenRefreshScheduler(); + + // Token age is unknown here (fresh login vs. hours-old page reload), so + // refresh right away to extend the session window from "now". + lastRefreshAt = 0; + void refreshNow(); + + timerId = window.setInterval(() => void refreshNow(), getIntervalMs()); + document.addEventListener("visibilitychange", onVisibilityChange); +}; + +export const stopTokenRefreshScheduler = () => { + if (timerId !== null) { + window.clearInterval(timerId); + timerId = null; + } + document.removeEventListener("visibilitychange", onVisibilityChange); +}; diff --git a/apps/edr-freight-web/portal/src/utils/result.ts b/apps/edr-freight-web/portal/src/utils/result.ts index 3e9627445..54104442c 100644 --- a/apps/edr-freight-web/portal/src/utils/result.ts +++ b/apps/edr-freight-web/portal/src/utils/result.ts @@ -8,6 +8,32 @@ export type ApiError = { statusCode?: number; }; +/** + * Backend errors arrive as snake_case i18n-style codes (e.g. + * "unable_to_log_in"). Map the known ones to friendly copy and prettify + * anything else so raw codes never reach the UI. `code` stays raw for + * programmatic checks. + */ +const API_ERROR_MESSAGES: Record = { + unable_to_log_in: "Incorrect email or password.", + invalid_refresh_token: "Your session has expired. Please sign in again.", + session_expired: "Your session has expired. Please sign in again.", + session_not_found: "Your session has expired. Please sign in again.", + user_not_found: "No account found for these credentials.", +}; + +const SNAKE_CASE_CODE = /^[a-z0-9]+(?:_[a-z0-9]+)+$/; + +function humanizeApiMessage(raw: string): string { + const known = API_ERROR_MESSAGES[raw]; + if (known) return known; + if (SNAKE_CASE_CODE.test(raw)) { + const text = raw.replaceAll("_", " "); + return `${text.charAt(0).toUpperCase()}${text.slice(1)}.`; + } + return raw; +} + export function extractApiError(err: unknown): ApiError { if (err && typeof err === "object") { const obj = err as Record; @@ -16,8 +42,12 @@ export function extractApiError(err: unknown): ApiError { const statusCode = response.status as number | undefined; const data = response.data as Record | undefined; return { - code: (data?.error as string) || (data?.message as string) || "api_error", - message: (data?.message as string) || (data?.error as string) || "An unexpected error occurred", + code: (data?.message as string) || (data?.error as string) || "api_error", + message: humanizeApiMessage( + (data?.message as string) || + (data?.error as string) || + "An unexpected error occurred", + ), statusCode, }; }