183 lines
6.3 KiB
TypeScript
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]);
|
|
};
|