mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-07 18:55:42 +00:00
fix: session expiry during usage and also login error message
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
|||||||
82
apps/edr-freight-web/backoffice/src/auth/refreshScheduler.ts
Normal file
82
apps/edr-freight-web/backoffice/src/auth/refreshScheduler.ts
Normal 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);
|
||||||
|
};
|
||||||
@@ -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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
|||||||
81
apps/edr-freight-web/portal/src/utils/refreshScheduler.ts
Normal file
81
apps/edr-freight-web/portal/src/utils/refreshScheduler.ts
Normal 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);
|
||||||
|
};
|
||||||
@@ -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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user