fix: session expiry during usage and also login error message

This commit is contained in:
Nathnael
2026-07-07 08:52:35 +00:00
parent ff0fa6bb9e
commit 63f638d1bb
9 changed files with 340 additions and 58 deletions

View File

@@ -1,2 +1,6 @@
VITE_API_URL=http://localhost:3001 VITE_API_URL=http://localhost:3001
VITE_BASE_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

View File

@@ -16,6 +16,10 @@ import {
setCookie, setCookie,
} from "./cookies"; } from "./cookies";
import { applyTokens } from "./http"; import { applyTokens } from "./http";
import {
startTokenRefreshScheduler,
stopTokenRefreshScheduler,
} from "./refreshScheduler";
import type { AuthTokens, AuthUser } from "./types"; import type { AuthTokens, AuthUser } from "./types";
interface LoginPayload { interface LoginPayload {
@@ -99,6 +103,18 @@ export const AuthProvider = ({ children }: { children: ReactNode }) => {
void bootstrap(); 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<AuthContextValue>( const value = useMemo<AuthContextValue>(
() => ({ () => ({
user, user,

View File

@@ -28,6 +28,30 @@ const applyTokens = ({ token, refreshToken }: AuthTokens) => {
setCookie(REFRESH_TOKEN_COOKIE, refreshToken); 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) => { api.interceptors.request.use((config) => {
const token = getCookie(AUTH_TOKEN_COOKIE); const token = getCookie(AUTH_TOKEN_COOKIE);
@@ -65,8 +89,7 @@ api.interceptors.response.use(
return Promise.reject(error); return Promise.reject(error);
} }
const refreshToken = getCookie(REFRESH_TOKEN_COOKIE); if (!getCookie(REFRESH_TOKEN_COOKIE)) {
if (!refreshToken) {
clearSessionCookies(); clearSessionCookies();
return Promise.reject(error); return Promise.reject(error);
} }
@@ -74,15 +97,7 @@ api.interceptors.response.use(
originalRequest._retry = true; originalRequest._retry = true;
try { try {
refreshPromise ??= api const tokens = await refreshSessionTokens();
.post<AuthTokens>("/auth/refresh-token", { refreshToken })
.then((response) => response.data)
.finally(() => {
refreshPromise = null;
});
const tokens = await refreshPromise;
applyTokens(tokens);
originalRequest.headers = { originalRequest.headers = {
...originalRequest.headers, ...originalRequest.headers,
Authorization: `Bearer ${tokens.token}`, Authorization: `Bearer ${tokens.token}`,
@@ -97,4 +112,4 @@ api.interceptors.response.use(
}, },
); );
export { api, applyTokens }; export { api, applyTokens, refreshSessionTokens };

View File

@@ -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);
};

View File

@@ -8,6 +8,32 @@ export type ApiError = {
statusCode?: number; 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<string, string> = {
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 { export function extractApiError(err: unknown): ApiError {
if (err && typeof err === "object") { if (err && typeof err === "object") {
const obj = err as Record<string, unknown>; const obj = err as Record<string, unknown>;
@@ -16,8 +42,12 @@ export function extractApiError(err: unknown): ApiError {
const statusCode = response.status as number | undefined; const statusCode = response.status as number | undefined;
const data = response.data as Record<string, unknown> | undefined; const data = response.data as Record<string, unknown> | undefined;
return { return {
code: (data?.error as string) || (data?.message as string) || "api_error", code: (data?.message as string) || (data?.error as string) || "api_error",
message: (data?.message as string) || (data?.error as string) || "An unexpected error occurred", message: humanizeApiMessage(
(data?.message as string) ||
(data?.error as string) ||
"An unexpected error occurred",
),
statusCode, statusCode,
}; };
} }

View File

@@ -24,6 +24,10 @@ import OnboardingResumeBanner, {
} from "./components/onboarding/OnboardingResumeBanner"; } from "./components/onboarding/OnboardingResumeBanner";
import OnboardingWizardDialog from "./components/onboarding/OnboardingWizardDialog"; import OnboardingWizardDialog from "./components/onboarding/OnboardingWizardDialog";
import useAuth from "./hooks/useAuth"; import useAuth from "./hooks/useAuth";
import {
startTokenRefreshScheduler,
stopTokenRefreshScheduler,
} from "./utils/refreshScheduler";
import EDRFreightLandingPage from "./pages/EDRFreightLandingPage"; import EDRFreightLandingPage from "./pages/EDRFreightLandingPage";
import MyPortalPage from "./pages/MyPortalPage"; import MyPortalPage from "./pages/MyPortalPage";
import MySignaturePage from "./pages/MySignaturePage"; import MySignaturePage from "./pages/MySignaturePage";
@@ -212,7 +216,20 @@ const sidebarItems: SidebarItem[] = [
const App = () => { const App = () => {
const navigate = useNavigate(); const navigate = useNavigate();
const location = useLocation(); 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 displayName = user?.name?.en || user?.username || user?.email || "User";
const userEmail = user?.email; const userEmail = user?.email;

View File

@@ -42,21 +42,41 @@ client.interceptors.request.use((config) => {
}); });
// Token refresh state // Token refresh state
let isRefreshing = false; let refreshPromise: Promise<string> | null = null;
let failedQueue: {
resolve: (token: string) => void;
reject: (error: unknown) => void;
}[] = [];
function processQueue(error: unknown, token?: string) { /**
failedQueue.forEach(({ resolve, reject }) => { * Single-flight token refresh: concurrent callers (the 401 interceptor and
if (error) { * the proactive scheduler) share one in-flight request so the refresh token
reject(error); * is only rotated once. Throws if no refresh token is stored or the server
} else { * rejects it — callers decide how to end the session.
resolve(token!); */
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;
}); });
failedQueue = [];
return refreshPromise;
} }
// Handle auth errors globally with token refresh // Handle auth errors globally with token refresh
@@ -72,54 +92,41 @@ client.interceptors.response.use(
// - status is not 401 // - status is not 401
// - already retried // - already retried
// - it's the refresh endpoint itself // - 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 ( if (
!error.response || !error.response ||
error.response.status !== 401 || error.response.status !== 401 ||
originalRequest._retry || 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); return Promise.reject(error);
} }
if (isRefreshing) { // Nothing to refresh with (e.g. not logged in yet) — surface the
return new Promise<string>((resolve, reject) => { // original error instead of a confusing refresh failure.
failedQueue.push({ resolve, reject }); if (!getCookie("refresh-token")) {
}).then((token) => { if (getCookie("auth-token")) {
originalRequest.headers.Authorization = `Bearer ${token}`; // Half-broken cookie state; reset it.
return client(originalRequest); clearAuthCookies();
}); }
}
originalRequest._retry = true;
isRefreshing = true;
const refreshToken = getCookie("refresh-token");
if (!refreshToken) {
isRefreshing = false;
clearAuthCookies();
return Promise.reject(error); return Promise.reject(error);
} }
originalRequest._retry = true;
try { try {
const { data } = await client.post<{ const token = await refreshSessionTokens();
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);
originalRequest.headers.Authorization = `Bearer ${token}`; originalRequest.headers.Authorization = `Bearer ${token}`;
processQueue(null, token);
return client(originalRequest); return client(originalRequest);
} catch (refreshError) { } catch (refreshError) {
processQueue(refreshError, undefined);
clearAuthCookies(); clearAuthCookies();
return Promise.reject(refreshError); return Promise.reject(refreshError);
} finally {
isRefreshing = false;
} }
}, },
); );
export { client }; export { client, clearAuthCookies, getCookie, refreshSessionTokens };
export type { UseQueryOptions, QueryObserverOptions }; export type { UseQueryOptions, QueryObserverOptions };

View File

@@ -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);
};

View File

@@ -8,6 +8,32 @@ export type ApiError = {
statusCode?: number; 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<string, string> = {
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 { export function extractApiError(err: unknown): ApiError {
if (err && typeof err === "object") { if (err && typeof err === "object") {
const obj = err as Record<string, unknown>; const obj = err as Record<string, unknown>;
@@ -16,8 +42,12 @@ export function extractApiError(err: unknown): ApiError {
const statusCode = response.status as number | undefined; const statusCode = response.status as number | undefined;
const data = response.data as Record<string, unknown> | undefined; const data = response.data as Record<string, unknown> | undefined;
return { return {
code: (data?.error as string) || (data?.message as string) || "api_error", code: (data?.message as string) || (data?.error as string) || "api_error",
message: (data?.message as string) || (data?.error as string) || "An unexpected error occurred", message: humanizeApiMessage(
(data?.message as string) ||
(data?.error as string) ||
"An unexpected error occurred",
),
statusCode, statusCode,
}; };
} }