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

@@ -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<string> | 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<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
@@ -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<string>((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 };

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;
};
/**
* 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 {
if (err && typeof err === "object") {
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 data = response.data as Record<string, unknown> | 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,
};
}