mirror of
https://github.com/Tria-plc/emaui.git
synced 2026-08-26 13:02:50 +00:00
feat: implement cross-tab idle session timeout and add request collapsing to token refresh logic.
This commit is contained in:
@@ -105,6 +105,8 @@ export const am: Translations = {
|
||||
|
||||
common: {
|
||||
logout: "ውጣ",
|
||||
idleLogoutTitle: "ክፍለ ጊዜው አልቋል",
|
||||
idleLogoutMessage: "ለ15 ደቂቃ እንቅስቃሴ ባለማድረግዎ ምክንያት ወጥተዋል።",
|
||||
profile: "መገለጫ",
|
||||
settings: "ቅንብሮች",
|
||||
export: "ላክ",
|
||||
|
||||
@@ -103,6 +103,8 @@ export const en = {
|
||||
|
||||
common: {
|
||||
logout: 'Log out',
|
||||
idleLogoutTitle: 'Session ended',
|
||||
idleLogoutMessage: 'You were signed out after 15 minutes of inactivity.',
|
||||
profile: 'Profile',
|
||||
settings: 'Settings',
|
||||
export: 'Export',
|
||||
|
||||
@@ -3,7 +3,7 @@ import { AppShell, Drawer } from '@mantine/core';
|
||||
import { useDisclosure } from '@mantine/hooks';
|
||||
import { Outlet, useLocation, useNavigate } from 'react-router-dom';
|
||||
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 type { NavItem, NavSection } from '@ema-platform/ui';
|
||||
import { notify } from '@ema-platform/ui';
|
||||
@@ -26,6 +26,13 @@ const BADGE_POLL_MS = 60_000;
|
||||
|
||||
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() {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
@@ -37,15 +44,6 @@ export function BackofficeLayout() {
|
||||
const layoutMode = useAppSelector((state) => state.preferences.layoutMode);
|
||||
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
|
||||
// by the sidebar and the top bar via the RTK cache.
|
||||
const { data: counts } = useGetQueueCountsQuery(undefined, {
|
||||
@@ -92,6 +90,17 @@ export function BackofficeLayout() {
|
||||
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);
|
||||
// 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
|
||||
|
||||
@@ -3,6 +3,7 @@ export type { AuthConfigValue } from "./lib/AuthConfig";
|
||||
export { AuthShell, BrandMark } from "./lib/components/AuthShell";
|
||||
export { ProtectedRoute } from "./lib/components/ProtectedRoute";
|
||||
export { AuthBootstrap } from "./lib/components/AuthBootstrap";
|
||||
export { useIdleTimer } from "./lib/hooks/useIdleTimer";
|
||||
export { LoginPage } from "./lib/pages/LoginPage";
|
||||
export { SignupPage } from "./lib/pages/SignupPage";
|
||||
export { ForgotPasswordPage } from "./lib/pages/ForgotPasswordPage";
|
||||
|
||||
@@ -54,9 +54,13 @@ export function AuthBootstrap({ children }: { children: ReactNode }) {
|
||||
response = await fetch(`${BASE_API_URL}/auth/me`, {
|
||||
headers: { Authorization: `Bearer ${fresh}` },
|
||||
});
|
||||
} catch {
|
||||
// No refresh token, or the server rejected it — fall through to
|
||||
// the logout below.
|
||||
} catch (err) {
|
||||
// Only the server rejecting the refresh token ends the session —
|
||||
// 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.
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
76
libs/auth/src/lib/hooks/useIdleTimer.ts
Normal file
76
libs/auth/src/lib/hooks/useIdleTimer.ts
Normal 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]);
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import type {
|
||||
LoginPayload,
|
||||
} from "../types/auth.types";
|
||||
import { authStorage } from "../utils/auth-storage";
|
||||
import { setSignedOut } from "../utils/refresh-token";
|
||||
|
||||
const initialState: AuthState = {
|
||||
user: null,
|
||||
@@ -19,6 +20,7 @@ const authSlice = createSlice({
|
||||
initialState,
|
||||
reducers: {
|
||||
loginSuccess(state, action: PayloadAction<LoginPayload>) {
|
||||
setSignedOut(false);
|
||||
state.token = action.payload.token;
|
||||
state.isAuthenticated = true;
|
||||
authStorage.setToken(action.payload.token);
|
||||
@@ -37,6 +39,8 @@ const authSlice = createSlice({
|
||||
authStorage.removeProfile();
|
||||
},
|
||||
logout(state) {
|
||||
// Before clearing storage, so an in-flight refresh can't repopulate it.
|
||||
setSignedOut(true);
|
||||
state.user = null;
|
||||
state.token = null;
|
||||
state.isAuthenticated = false;
|
||||
|
||||
@@ -21,6 +21,17 @@ const sessionExpired = (message: string) =>
|
||||
|
||||
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
|
||||
* 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.
|
||||
*/
|
||||
export function refreshAccessToken(): Promise<string> {
|
||||
inFlight ??= runRefresh().finally(() => {
|
||||
inFlight ??= acquireAndRefresh().finally(() => {
|
||||
inFlight = null;
|
||||
});
|
||||
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> {
|
||||
if (signedOut) throw new Error("Signed out");
|
||||
const refreshToken = authStorage.getRefreshToken();
|
||||
if (!refreshToken) throw sessionExpired("No refresh token available");
|
||||
|
||||
@@ -61,6 +93,9 @@ async function runRefresh(): Promise<string> {
|
||||
}
|
||||
|
||||
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);
|
||||
if (data.refreshToken) authStorage.setRefreshToken(data.refreshToken);
|
||||
return data.token;
|
||||
|
||||
Reference in New Issue
Block a user