import React, { useEffect, useMemo, useState } from 'react'; import { Keyboard, KeyboardAvoidingView, Platform, ScrollView, StatusBar, StyleSheet, Switch, Text, View, } from 'react-native'; import { SafeAreaView } from 'react-native-safe-area-context'; import LinearGradient from 'react-native-linear-gradient'; import type { NativeStackScreenProps } from '@react-navigation/native-stack'; import Animated, { FadeIn, FadeInUp } from 'react-native-reanimated'; import Ionicons from '@react-native-vector-icons/ionicons'; import { Button, InfoBanner, Input, Loader } from '../../../components'; import { useAppDispatch, useAppSelector } from '../../../app/hooks'; import { useTheme } from '../../../theme'; import { useApiErrorToast } from '../../../hooks/useApiErrorToast'; import { useToast } from '../../../hooks/useToast'; import { haptics } from '../../../services/haptics/haptics'; import { biometrics } from '../../../services/security/biometrics'; import { authActions } from '../store/authSlice'; import { selectBiometricReady, selectLastProfile, selectMpinLocked, selectSignInContextLoaded, selectSignInError, selectSignInStatus, } from '../store/authSelectors'; import { loadSignInContext, signIn, signInAsAgent, signInWithBiometrics, } from '../store/authThunk'; import { MPIN_LENGTH, isValidMpin, isValidPhone } from '../utils/authValidation'; import { LoginHero } from '../components/LoginHero'; import { BiometricNode } from '../components/BiometricNode'; import { CodeInput } from '../components/CodeInput'; import { PreLoginActions } from '../components/PreLoginActions'; import { LoginFooter } from '../components/LoginFooter'; import { useBiometricPrompt } from '../hooks/useBiometricPrompt'; import type { AuthStackParamList } from '../../../navigation/types'; import type { LoginRole } from '../../../types/app'; import { LoginRoleTabs } from '../components/LoginRoleTabs'; import { FeatureKey } from '../../../constants/featureKeys'; import { FeatureGate, useFeature } from '../../../features'; type Props = Readonly>; export default function LoginScreen({ navigation }: Props) { const dispatch = useAppDispatch(); const theme = useTheme(); const toast = useToast(); const showApiError = useApiErrorToast(); const promptBiometric = useBiometricPrompt(); const lastProfile = useAppSelector(selectLastProfile); const contextLoaded = useAppSelector(selectSignInContextLoaded); const biometricReady = useAppSelector(selectBiometricReady); const signInStatus = useAppSelector(selectSignInStatus); const signInError = useAppSelector(selectSignInError); const mpinLocked = useAppSelector(selectMpinLocked); const [mobile, setMobile] = useState(''); const [mpin, setMpin] = useState(''); const [activeRole, setActiveRole] = useState('User'); const [rememberForBiometrics, setRememberForBiometrics] = useState(false); const [biometricsAvailable, setBiometricsAvailable] = useState(false); /** Set once the customer chooses the MPIN path over the biometric one. */ const [mpinRequested, setMpinRequested] = useState(false); const [keyboardUp, setKeyboardUp] = useState(false); /** * The last failure, held for display on the form itself. The toast that * `showApiError` raises is gone in a few seconds, and the effect below clears * the MPIN boxes at the same time — so without this, a wrong MPIN looks like * the app simply did nothing. */ const [formError, setFormError] = useState(); const userRoleEnabled = useFeature(FeatureKey.AUTH_LOGIN_USER_ROLE); const agentRoleEnabled = useFeature(FeatureKey.AUTH_LOGIN_AGENT_ROLE); const isLoading = signInStatus === 'loading'; /** * A profile stored on this device means registration already completed here, * so the customer only owes us an MPIN — the number is not theirs to retype. * The agent app has no stored profile, so it keeps the full form. */ const isReturningCustomer = activeRole === 'User' && Boolean(lastProfile); useEffect(() => { void biometrics.isAvailable().then(setBiometricsAvailable); }, []); // The public action row is pinned to the foot of the screen, which is exactly // where the keyboard lands. Hiding it while the keyboard is up keeps it from // being squeezed into a sliver above the keys. useEffect(() => { const showEvent = Platform.OS === 'ios' ? 'keyboardWillShow' : 'keyboardDidShow'; const hideEvent = Platform.OS === 'ios' ? 'keyboardWillHide' : 'keyboardDidHide'; const shown = Keyboard.addListener(showEvent, () => setKeyboardUp(true)); const hidden = Keyboard.addListener(hideEvent, () => setKeyboardUp(false)); return () => { shown.remove(); hidden.remove(); }; }, []); // Several routes reach this screen after the store was wiped — an ordinary // logout, or a stored session found dead at launch. Re-reading the remembered // profile here covers all of them rather than one at a time. useEffect(() => { if (!contextLoaded) { void dispatch(loadSignInContext()); } // Mount-only: once loaded, re-running would loop. // eslint-disable-next-line react-hooks/exhaustive-deps }, [dispatch]); // First launch is routed straight to registration by AuthNavigator, so this // only catches the late case: a logout wiped the store and the re-read found // no profile (app data cleared, or the device was unbound). By then the // navigator is long since ready, so the action is safe to dispatch here. useEffect(() => { if (contextLoaded && !lastProfile && activeRole === 'User') { navigation.replace('Register'); } }, [activeRole, contextLoaded, lastProfile, navigation]); useEffect(() => { if (lastProfile) { setMobile((current) => current || lastProfile.mobileNumber); } }, [lastProfile]); // Keep the selected role on something that is actually offered — otherwise a // disabled role could still be submitted via the default state. useEffect(() => { if (activeRole === 'User' && !userRoleEnabled && agentRoleEnabled) { setActiveRole('Agent'); } else if (activeRole === 'Agent' && !agentRoleEnabled && userRoleEnabled) { setActiveRole('User'); } }, [activeRole, agentRoleEnabled, userRoleEnabled]); // A locked profile has no in-app path at all, so it gets its own screen // rather than a toast the user can dismiss and keep hammering the form. useEffect(() => { if (mpinLocked) { navigation.navigate('MpinLocked'); } }, [mpinLocked, navigation]); useEffect(() => { if (signInStatus !== 'failed' || !signInError) { return; } // MPIN_LOCKED gets the dedicated screen above; a cancelled biometric // prompt is a user decision, not a failure worth shouting about. if (signInError.code === 'MPIN_LOCKED') { return; } if (signInError.code === 'BIOMETRIC_CANCELLED') { toast('Biometric sign-in cancelled', 'info', 'You can sign in with your MPIN instead.'); dispatch(authActions.clearSignInError()); return; } showApiError(signInError); // A device that was never bound, or a profile with no MPIN yet, cannot sign // in whatever the MPIN — registration is the only way forward. if (signInError.code === 'DEVICE_NOT_BOUND' || signInError.code === 'MPIN_NOT_SET') { navigation.navigate('Register'); } else { // Every other failure leaves the customer on this screen, so the reason // has to stay on this screen with them — and the MPIN field comes out, // since a biometric failure needs a way through that is not biometrics. setFormError(signInError.message); setMpinRequested(true); } setMpin(''); dispatch(authActions.clearSignInError()); }, [dispatch, navigation, showApiError, signInError, signInStatus, toast]); const mobileError = useMemo(() => { if (mobile.length === 0 || isValidPhone(mobile)) { return undefined; } return 'Enter the 10-digit number registered with your bank'; }, [mobile]); const canSubmit = isValidPhone(mobile) && isValidMpin(mpin) && !isLoading; // Only worth offering once a profile on this device has actually stored an // MPIN behind the device prompt — otherwise the button can only ever fail. const canUseBiometrics = activeRole === 'Agent' || biometricReady; const biometricsGranted = useFeature(FeatureKey.AUTH_LOGIN_BIOMETRIC); /** * Biometrics lead only for a returning customer who has them ready: the * device knows who they are, so one tap is the whole sign-in. Everyone else — * an agent, or a customer with nothing enrolled — needs the form anyway, and * hiding it behind a button would just add a tap to the only way in. */ const biometricsLead = canUseBiometrics && biometricsGranted && isReturningCustomer; const formVisible = !biometricsLead || mpinRequested; // Switching role changes which of the two paths applies, so the disclosure // resets with it rather than leaving the form open from the previous role. useEffect(() => { setMpinRequested(false); setFormError(undefined); setMpin(''); }, [activeRole]); const handleMobileChange = (value: string) => { setFormError(undefined); setMobile(value.replace(/\D/g, '')); }; const handleMpinChange = (value: string) => { // Retyping is the customer acting on the error, so the notice goes away as // soon as they start — leaving it up would read as a second failure. setFormError(undefined); setMpin(value); }; const handleSignIn = () => { if (!canSubmit) { return; } if (activeRole === 'Agent') { // The field-agent app has no CBS backend behind it yet. void dispatch(signInAsAgent({ mobileNumber: mobile })); return; } void dispatch( signIn({ mobileNumber: mobile, mpin, orgId: lastProfile?.orgId, // This sign-in is the only place the MPIN is verified, so it is the // only safe moment to store it for biometric use. rememberForBiometrics: rememberForBiometrics && biometricsAvailable, }), ); }; const handleBiometricLogin = async () => { haptics.selection(); if (activeRole === 'Agent') { const confirmed = await promptBiometric(); if (!confirmed) { toast('Biometric sign-in cancelled', 'info', 'You can sign in with your MPIN instead.'); return; } void dispatch(signInAsAgent({ mobileNumber: mobile || '0000000000' })); return; } // Reading the stored MPIN raises the platform's own prompt, so there is no // separate biometric check to run first. void dispatch(signInWithBiometrics()); }; const handleRegisterAgain = () => { haptics.selection(); dispatch(authActions.resetRegistration()); navigation.navigate('Register'); }; const handleUseMpin = () => { haptics.selection(); setMpinRequested(true); }; /** * Indian mobile grouping is 5 + 5. Masking the first six digits and grouping * what is left the same way keeps the number recognisable in the shape the * customer knows it in, without printing it in full on a locked screen. */ const maskedMobile = lastProfile?.mobileNumber ? `+91 ••••• •${lastProfile.mobileNumber.slice(-4)}` : undefined; const gradient = ( <> {/* The wash is dark, so the status bar icons have to be light — the opposite of every other screen in the app. */} ); // Deciding between the MPIN screen and registration needs the stored profile. // Rendering either one first would flash the wrong screen. if (!contextLoaded) { return ( {gradient} ); } return ( {gradient} {/* ── brand lock-up ─────────────────────────────────────────── */} {/* ── who is signing in ─────────────────────────────────────── */} {/* ── greeting ──────────────────────────────────────────────── */} {isReturningCustomer && lastProfile?.fullName ? ( <> Welcome back {lastProfile.fullName} {maskedMobile ? ( {maskedMobile} ) : null} ) : ( <> {activeRole === 'Agent' ? 'Agent sign-in' : 'Welcome'} {/* A returning customer whose name we do not hold yet still gets told which profile they are signing into. That is the device-rebind path: verify-otp stores the mobile number but no name, because it does not return one — so without this the screen asks for an MPIN and names nothing at all. */} {maskedMobile ? ( {maskedMobile} ) : null} )} {/* ── the way in ────────────────────────────────────────────── */} {biometricsLead ? ( ) : null} {formVisible ? ( {!isReturningCustomer ? ( ) : null} {formError ? : null} {/* Offered only where it can actually be turned on: biometrics enrolled, a customer sign-in, and nothing stored yet. */} {activeRole === 'User' && biometricsAvailable && !biometricReady ? ( Enable biometric sign-in next time ) : null} {/* White fill, navy label — `primary` here would be navy on navy and disappear into the wash. */}