Files
CBS-Mobile-App/src/hooks/useSessionTimeout.ts

183 lines
6.3 KiB
TypeScript

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<LockReason, { title: string; body: string; tone: 'error' | 'info' }> = {
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<number | null>(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]);
};