mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-29 19:58:11 +00:00
fix: session expiry during usage and also login error message
This commit is contained in:
@@ -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<AuthContextValue>(
|
||||
() => ({
|
||||
user,
|
||||
|
||||
@@ -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<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) => {
|
||||
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<AuthTokens>("/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 };
|
||||
|
||||
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;
|
||||
};
|
||||
|
||||
/**
|
||||
* 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,
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user