mirror of
https://github.com/Tria-plc/emaui.git
synced 2026-08-26 19:12: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: {
|
common: {
|
||||||
logout: "ውጣ",
|
logout: "ውጣ",
|
||||||
|
idleLogoutTitle: "ክፍለ ጊዜው አልቋል",
|
||||||
|
idleLogoutMessage: "ለ15 ደቂቃ እንቅስቃሴ ባለማድረግዎ ምክንያት ወጥተዋል።",
|
||||||
profile: "መገለጫ",
|
profile: "መገለጫ",
|
||||||
settings: "ቅንብሮች",
|
settings: "ቅንብሮች",
|
||||||
export: "ላክ",
|
export: "ላክ",
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|||||||
@@ -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.
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
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,
|
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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user