diff --git a/apps/backoffice/src/app/i18n/locales/am.ts b/apps/backoffice/src/app/i18n/locales/am.ts index dd548ed09..1393f0704 100644 --- a/apps/backoffice/src/app/i18n/locales/am.ts +++ b/apps/backoffice/src/app/i18n/locales/am.ts @@ -105,6 +105,8 @@ export const am: Translations = { common: { logout: "ውጣ", + idleLogoutTitle: "ክፍለ ጊዜው አልቋል", + idleLogoutMessage: "ለ15 ደቂቃ እንቅስቃሴ ባለማድረግዎ ምክንያት ወጥተዋል።", profile: "መገለጫ", settings: "ቅንብሮች", export: "ላክ", diff --git a/apps/backoffice/src/app/i18n/locales/en.ts b/apps/backoffice/src/app/i18n/locales/en.ts index a1d860169..b628776dc 100644 --- a/apps/backoffice/src/app/i18n/locales/en.ts +++ b/apps/backoffice/src/app/i18n/locales/en.ts @@ -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', diff --git a/apps/backoffice/src/app/layouts/BackofficeLayout.tsx b/apps/backoffice/src/app/layouts/BackofficeLayout.tsx index a4e467a75..de599a69a 100644 --- a/apps/backoffice/src/app/layouts/BackofficeLayout.tsx +++ b/apps/backoffice/src/app/layouts/BackofficeLayout.tsx @@ -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 diff --git a/libs/auth/src/index.ts b/libs/auth/src/index.ts index 5b1190836..b1d90b33a 100644 --- a/libs/auth/src/index.ts +++ b/libs/auth/src/index.ts @@ -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"; diff --git a/libs/auth/src/lib/components/AuthBootstrap.tsx b/libs/auth/src/lib/components/AuthBootstrap.tsx index 5d1f1735f..251db18c0 100644 --- a/libs/auth/src/lib/components/AuthBootstrap.tsx +++ b/libs/auth/src/lib/components/AuthBootstrap.tsx @@ -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. } } diff --git a/libs/auth/src/lib/hooks/useIdleTimer.ts b/libs/auth/src/lib/hooks/useIdleTimer.ts new file mode 100644 index 000000000..ded7cc14d --- /dev/null +++ b/libs/auth/src/lib/hooks/useIdleTimer.ts @@ -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; + 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]); +} diff --git a/libs/auth/src/lib/store/auth.slice.ts b/libs/auth/src/lib/store/auth.slice.ts index 1fb70a533..0e58b9735 100644 --- a/libs/auth/src/lib/store/auth.slice.ts +++ b/libs/auth/src/lib/store/auth.slice.ts @@ -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) { + 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; diff --git a/libs/auth/src/lib/utils/refresh-token.ts b/libs/auth/src/lib/utils/refresh-token.ts index 30ec94e1a..43f53e508 100644 --- a/libs/auth/src/lib/utils/refresh-token.ts +++ b/libs/auth/src/lib/utils/refresh-token.ts @@ -21,6 +21,17 @@ const sessionExpired = (message: string) => let inFlight: Promise | 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 | null = null; * winner just renewed. */ export function refreshAccessToken(): Promise { - 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 { + 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 { + 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 { } 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;