feat: implement cross-tab idle session timeout and add request collapsing to token refresh logic.

This commit is contained in:
estifanos
2026-08-19 09:29:02 +00:00
parent 439a4963ec
commit 4e2f63d4df
8 changed files with 147 additions and 14 deletions

View File

@@ -105,6 +105,8 @@ export const am: Translations = {
common: { common: {
logout: "ውጣ", logout: "ውጣ",
idleLogoutTitle: "ክፍለ ጊዜው አልቋል",
idleLogoutMessage: "ለ15 ደቂቃ እንቅስቃሴ ባለማድረግዎ ምክንያት ወጥተዋል።",
profile: "መገለጫ", profile: "መገለጫ",
settings: "ቅንብሮች", settings: "ቅንብሮች",
export: "ላክ", export: "ላክ",

View File

@@ -103,6 +103,8 @@ export const en = {
common: { common: {
logout: 'Log out', logout: 'Log out',
idleLogoutTitle: 'Session ended',
idleLogoutMessage: 'You were signed out after 15 minutes of inactivity.',
profile: 'Profile', profile: 'Profile',
settings: 'Settings', settings: 'Settings',
export: 'Export', export: 'Export',

View File

@@ -3,7 +3,7 @@ import { AppShell, Drawer } from '@mantine/core';
import { useDisclosure } from '@mantine/hooks'; import { useDisclosure } from '@mantine/hooks';
import { Outlet, useLocation, useNavigate } from 'react-router-dom'; import { Outlet, useLocation, useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { BrandMark, logout } from '@ema-platform/auth'; import { BrandMark, logout, useIdleTimer } from '@ema-platform/auth';
import { AppHeader, AppSidebar } from '@ema-platform/ui'; import { AppHeader, AppSidebar } from '@ema-platform/ui';
import type { NavItem, NavSection } from '@ema-platform/ui'; import type { NavItem, NavSection } from '@ema-platform/ui';
import { notify } from '@ema-platform/ui'; import { notify } from '@ema-platform/ui';
@@ -26,6 +26,13 @@ const BADGE_POLL_MS = 60_000;
const HEADER_HEIGHT = 116; const HEADER_HEIGHT = 116;
/**
* A desk left unlocked with a license-review or medical-record screen open is
* the actual threat model here, not a slow token. 15 minutes of no mouse,
* key, scroll, or touch activity signs the officer out automatically.
*/
const IDLE_TIMEOUT_MS = 15 * 60 * 1000;
export function BackofficeLayout() { export function BackofficeLayout() {
const { t } = useTranslation(); const { t } = useTranslation();
const navigate = useNavigate(); const navigate = useNavigate();
@@ -37,15 +44,6 @@ export function BackofficeLayout() {
const layoutMode = useAppSelector((state) => state.preferences.layoutMode); const layoutMode = useAppSelector((state) => state.preferences.layoutMode);
const { permissions: granted, known } = usePermissions(); const { permissions: granted, known } = usePermissions();
// TEMPORARY diagnostic — remove once the sidebar is confirmed working.
// eslint-disable-next-line no-console
console.log(
'[NAV] known=', known,
'granted=', granted.length,
'| cookies:', document.cookie.split('; ').map((c) => c.split('=')[0]).filter((n) => n.includes('token')),
'| token tail:', (document.cookie.match(/ema-backoffice-auth-token=([^;]+)/)?.[1] ?? 'NONE').slice(-12),
);
// Badges reflect real pending work. One grouped request on a timer, shared // Badges reflect real pending work. One grouped request on a timer, shared
// by the sidebar and the top bar via the RTK cache. // by the sidebar and the top bar via the RTK cache.
const { data: counts } = useGetQueueCountsQuery(undefined, { const { data: counts } = useGetQueueCountsQuery(undefined, {
@@ -92,6 +90,17 @@ export function BackofficeLayout() {
navigate("/"); navigate("/");
}, [dispatch, navigate]); }, [dispatch, navigate]);
useIdleTimer(IDLE_TIMEOUT_MS, () => {
notify.info(
t(
'common.idleLogoutMessage',
'You were signed out after 15 minutes of inactivity.',
),
t('common.idleLogoutTitle', 'Session ended'),
);
handleLogout();
});
const segments = location.pathname.split('/').filter(Boolean); const segments = location.pathname.split('/').filter(Boolean);
// Label each crumb from the nav item it corresponds to, falling back to a // Label each crumb from the nav item it corresponds to, falling back to a
// readable form of the path segment. Every crumb was previously labelled // readable form of the path segment. Every crumb was previously labelled

View File

@@ -3,6 +3,7 @@ export type { AuthConfigValue } from "./lib/AuthConfig";
export { AuthShell, BrandMark } from "./lib/components/AuthShell"; export { AuthShell, BrandMark } from "./lib/components/AuthShell";
export { ProtectedRoute } from "./lib/components/ProtectedRoute"; export { ProtectedRoute } from "./lib/components/ProtectedRoute";
export { AuthBootstrap } from "./lib/components/AuthBootstrap"; export { AuthBootstrap } from "./lib/components/AuthBootstrap";
export { useIdleTimer } from "./lib/hooks/useIdleTimer";
export { LoginPage } from "./lib/pages/LoginPage"; export { LoginPage } from "./lib/pages/LoginPage";
export { SignupPage } from "./lib/pages/SignupPage"; export { SignupPage } from "./lib/pages/SignupPage";
export { ForgotPasswordPage } from "./lib/pages/ForgotPasswordPage"; export { ForgotPasswordPage } from "./lib/pages/ForgotPasswordPage";

View File

@@ -54,9 +54,13 @@ export function AuthBootstrap({ children }: { children: ReactNode }) {
response = await fetch(`${BASE_API_URL}/auth/me`, { response = await fetch(`${BASE_API_URL}/auth/me`, {
headers: { Authorization: `Bearer ${fresh}` }, headers: { Authorization: `Bearer ${fresh}` },
}); });
} catch { } catch (err) {
// No refresh token, or the server rejected it — fall through to // Only the server rejecting the refresh token ends the session —
// the logout below. // same rule as the API layer's 401 handler. A 502 or a network
// blip during refresh keeps the stored session; the screens
// surface their own errors.
if (!(err as { sessionExpired?: boolean })?.sessionExpired) return;
// Rejected — fall through to the logout below.
} }
} }

View File

@@ -0,0 +1,76 @@
import { useEffect, useRef } from 'react';
const ACTIVITY_EVENTS = [
'mousedown',
'mousemove',
'keydown',
'scroll',
'touchstart',
'click',
] as const;
/**
* Fires `onIdle` once no activity event has fired for `timeoutMs` — the
* "left the desk" auto-logout for a govt app handling sensitive records.
*
* `onIdle` is read through a ref rather than a `useEffect` dependency: the
* caller typically passes a fresh closure every render (it captures
* `dispatch`, `navigate`, current user), and depending on it directly would
* tear down and re-add six window listeners — and rearm the timer to a full
* 15 minutes — on every unrelated re-render, not just real activity.
*/
export function useIdleTimer(timeoutMs: number, onIdle: () => void) {
const onIdleRef = useRef(onIdle);
onIdleRef.current = onIdle;
useEffect(() => {
let timer: ReturnType<typeof setTimeout>;
let lastReset = 0;
// localStorage is origin-scoped, so every tab of this app shares it and
// the sibling app (other port/domain) does not.
const ACTIVITY_KEY = 'ema-last-activity';
function fire() {
// This tab sat idle, but a sibling tab may have been busy the whole
// time — logging out here would clear the shared cookies and kill that
// tab mid-work. Trust the newest activity stamp any tab wrote.
let last = 0;
try {
last = Number(localStorage.getItem(ACTIVITY_KEY)) || 0;
} catch {
/* storage blocked — fall back to this tab's own timer */
}
const remaining = last + timeoutMs - Date.now();
if (remaining > 1000) {
timer = setTimeout(fire, remaining);
} else {
onIdleRef.current();
}
}
function reset() {
// mousemove fires dozens of times a second; only rearm once a second
// so it isn't clearing/setting a timeout on every pixel of movement.
const now = Date.now();
if (now - lastReset < 1000) return;
lastReset = now;
try {
localStorage.setItem(ACTIVITY_KEY, String(now));
} catch {
/* ignore */
}
clearTimeout(timer);
timer = setTimeout(fire, timeoutMs);
}
reset();
ACTIVITY_EVENTS.forEach((event) => window.addEventListener(event, reset));
return () => {
ACTIVITY_EVENTS.forEach((event) =>
window.removeEventListener(event, reset),
);
clearTimeout(timer);
};
}, [timeoutMs]);
}

View File

@@ -6,6 +6,7 @@ import type {
LoginPayload, LoginPayload,
} from "../types/auth.types"; } from "../types/auth.types";
import { authStorage } from "../utils/auth-storage"; import { authStorage } from "../utils/auth-storage";
import { setSignedOut } from "../utils/refresh-token";
const initialState: AuthState = { const initialState: AuthState = {
user: null, user: null,
@@ -19,6 +20,7 @@ const authSlice = createSlice({
initialState, initialState,
reducers: { reducers: {
loginSuccess(state, action: PayloadAction<LoginPayload>) { loginSuccess(state, action: PayloadAction<LoginPayload>) {
setSignedOut(false);
state.token = action.payload.token; state.token = action.payload.token;
state.isAuthenticated = true; state.isAuthenticated = true;
authStorage.setToken(action.payload.token); authStorage.setToken(action.payload.token);
@@ -37,6 +39,8 @@ const authSlice = createSlice({
authStorage.removeProfile(); authStorage.removeProfile();
}, },
logout(state) { logout(state) {
// Before clearing storage, so an in-flight refresh can't repopulate it.
setSignedOut(true);
state.user = null; state.user = null;
state.token = null; state.token = null;
state.isAuthenticated = false; state.isAuthenticated = false;

View File

@@ -21,6 +21,17 @@ const sessionExpired = (message: string) =>
let inFlight: Promise<string> | null = null; let inFlight: Promise<string> | null = null;
let signedOut = false;
/**
* Set on logout, cleared on login. A refresh that was already in flight when
* the user (or the idle timer) signed out must not write its response back
* into storage — that would silently re-authenticate an unattended desk.
*/
export function setSignedOut(v: boolean) {
signedOut = v;
}
/** /**
* Concurrent 401s must share one refresh. A page fires several requests at * Concurrent 401s must share one refresh. A page fires several requests at
* once; without this each one POSTs the same refresh token, the server rotates * once; without this each one POSTs the same refresh token, the server rotates
@@ -28,13 +39,34 @@ let inFlight: Promise<string> | null = null;
* winner just renewed. * winner just renewed.
*/ */
export function refreshAccessToken(): Promise<string> { export function refreshAccessToken(): Promise<string> {
inFlight ??= runRefresh().finally(() => { inFlight ??= acquireAndRefresh().finally(() => {
inFlight = null; inFlight = null;
}); });
return inFlight; return inFlight;
} }
/**
* Cross-tab guard on top of the in-tab one: cookies are shared per origin, so
* two tabs expiring together would both POST the same rotating refresh token
* and the loser would tear down the session the winner just renewed. A Web
* Lock makes the second tab wait; if the first tab already refreshed while it
* waited, the fresh token is sitting in storage and no request is needed.
*/
async function acquireAndRefresh(): Promise<string> {
if (typeof navigator === "undefined" || !navigator.locks) {
// Old Safari / test env — in-tab de-dup still applies.
return runRefresh();
}
const tokenBefore = authStorage.getToken();
return navigator.locks.request("ema-token-refresh", async () => {
const current = authStorage.getToken();
if (current && current !== tokenBefore) return current;
return runRefresh();
});
}
async function runRefresh(): Promise<string> { async function runRefresh(): Promise<string> {
if (signedOut) throw new Error("Signed out");
const refreshToken = authStorage.getRefreshToken(); const refreshToken = authStorage.getRefreshToken();
if (!refreshToken) throw sessionExpired("No refresh token available"); if (!refreshToken) throw sessionExpired("No refresh token available");
@@ -61,6 +93,9 @@ async function runRefresh(): Promise<string> {
} }
const data: RefreshResponse = await response.json(); const data: RefreshResponse = await response.json();
// Deliberately NOT sessionExpired: the user already signed out, so there is
// no session left to end — just refuse to resurrect it.
if (signedOut) throw new Error("Signed out during refresh");
authStorage.setToken(data.token); authStorage.setToken(data.token);
if (data.refreshToken) authStorage.setRefreshToken(data.refreshToken); if (data.refreshToken) authStorage.setRefreshToken(data.refreshToken);
return data.token; return data.token;