import { useCallback, useEffect, useRef } from 'react'; import { AppState, type AppStateStatus } from 'react-native'; import { useAppDispatch, useAppSelector } from '../app/hooks'; import { appConfig } from '../config/appConfig'; import { env } from '../config/env'; import { logoutSession, refreshSessionIdentity } from '../modules/auth/store/authThunk'; import { selectHasLiveSession, selectIsAuthenticated, } from '../modules/auth/store/authSelectors'; import { uiActions } from '../modules/ui/store/uiSlice'; import { sessionManager } from '../services/security/sessionManager'; import { useToast } from './useToast'; const TICK_MS = 5_000; type LockReason = 'idle' | 'absolute' | 'background'; const LOCK_COPY: Record = { absolute: { title: 'Session expired', body: 'Sessions end after an hour. Please sign in again.', tone: 'error', }, idle: { title: 'Session expired', body: 'You were inactive for a while. Please sign in again.', tone: 'error', }, // Not a failure — the app asked for the MPIN again on purpose, so it should // not be dressed up as something going wrong. background: { title: 'Welcome back', body: 'Enter your MPIN to continue.', tone: 'info', }, }; /** * Ends the session when it should end, and asks for the MPIN again when it * should be asked for. Three triggers: * * - the idle window the server reported, which any authenticated call resets; * - the absolute ceiling it never extends; * - returning to the foreground after the app was away long enough to count as * being opened again. * * The first two run on a timer rather than only on app-state changes: the * server starts answering 401 the moment the idle window closes, and a customer * halfway through a transfer should be warned beforehand rather than discover it * as a failed action. */ export const useSessionTimeout = () => { const dispatch = useAppDispatch(); const toast = useToast(); const isAuthenticated = useAppSelector(selectIsAuthenticated); const hasLiveSession = useAppSelector(selectHasLiveSession); const warnedRef = useRef(false); /** When the app last left the foreground, for the background lock below. */ const leftForegroundAt = useRef(null); const expire = useCallback( (reason: LockReason) => { // Only the timeouts are an expiry the user should be warned about; a // deliberate re-lock is not, and flagging it would train them to ignore // the banner that matters. if (reason !== 'background') { dispatch(uiActions.setSessionExpired(true)); } // 'expired', not a sign-out: the customer stepped away, they did not // leave. Wiping the remembered profile here would cost them an OTP and a // device re-bind to get back into an account they are still using. void dispatch(logoutSession('expired')); const copy = LOCK_COPY[reason]; toast(copy.title, copy.tone, copy.body); }, [dispatch, toast], ); useEffect(() => { if (!isAuthenticated) { warnedRef.current = false; return; } const interval = setInterval(() => { const reason = sessionManager.expiryReason(); if (reason) { clearInterval(interval); expire(reason); return; } // Someone using the app should not be signed out for being idle. Only an // authenticated call moves the server's window, and most screens here // read from fixtures, so the keep-alive has to be sent deliberately. if (hasLiveSession && sessionManager.shouldKeepAlive()) { void dispatch(refreshSessionIdentity()); } const remaining = sessionManager.getRemainingMs(); if (!warnedRef.current && remaining <= env.sessionWarningMs) { warnedRef.current = true; // Against the idle window, carrying on using the app genuinely helps. // Against the absolute cap nothing does, so saying "tap to stay signed // in" there would be a lie the user finds out about a minute later. const isAbsolute = sessionManager.nearestLimit() === 'absolute'; toast( isAbsolute ? 'Session ending soon' : 'Still there?', 'info', isAbsolute ? 'Sessions end an hour after signing in. Finish up and sign in again.' : 'Your session is about to end. Tap anywhere to stay signed in.', ); } if (warnedRef.current && remaining > env.sessionWarningMs) { // The idle timer was reset by an authenticated call, so the next // approach to the cut-off deserves its own warning. warnedRef.current = false; } }, TICK_MS); return () => clearInterval(interval); }, [dispatch, expire, hasLiveSession, isAuthenticated, toast]); useEffect(() => { const handleAppStateChange = (nextState: AppStateStatus) => { if (nextState === 'background' || nextState === 'inactive') { leftForegroundAt.current = Date.now(); return; } if (nextState !== 'active') { return; } const awayMs = leftForegroundAt.current === null ? 0 : Date.now() - leftForegroundAt.current; leftForegroundAt.current = null; const reason = sessionManager.expiryReason(); if (reason) { expire(reason); return; } // The Android process usually survives backgrounding, so re-opening the // app does not re-run bootstrap. Without this, "ask for the MPIN on every // open" would hold only for a cold start. if ( isAuthenticated && appConfig.security.requireMpinOnLaunch && awayMs >= appConfig.security.lockAfterBackgroundMs ) { expire('background'); return; } // Coming back from the background is also the cheapest moment to notice // a session revoked elsewhere: one active session per profile, so signing // in on another device signed this one out. Only meaningful for a live // session — the local agent one has no server to ask. if (hasLiveSession) { void dispatch(refreshSessionIdentity()); } }; const subscription = AppState.addEventListener('change', handleAppStateChange); return () => subscription.remove(); }, [dispatch, expire, hasLiveSession, isAuthenticated]); };