Update CBS Mobile App codebase
This commit is contained in:
+162
-17
@@ -1,37 +1,182 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useCallback, useEffect, useRef } from 'react';
|
||||
import { AppState, type AppStateStatus } from 'react-native';
|
||||
|
||||
import { useAppDispatch } from '../app/hooks';
|
||||
import { logoutSession } from '../modules/auth/store/authThunk';
|
||||
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 === 'active') {
|
||||
if (sessionManager.hasExpired()) {
|
||||
dispatch(uiActions.setSessionExpired(true));
|
||||
dispatch(logoutSession());
|
||||
toast('Session expired', 'error', 'Please log in again to continue securely.');
|
||||
}
|
||||
|
||||
sessionManager.touch();
|
||||
if (nextState === 'background' || nextState === 'inactive') {
|
||||
leftForegroundAt.current = Date.now();
|
||||
return;
|
||||
}
|
||||
|
||||
if (nextState === 'background' || nextState === 'inactive') {
|
||||
sessionManager.touch();
|
||||
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, toast]);
|
||||
return () => subscription.remove();
|
||||
}, [dispatch, expire, hasLiveSession, isAuthenticated]);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user