Files
CBS-Mobile-App/src/modules/auth/screens/LoginScreen.tsx
T

632 lines
23 KiB
TypeScript

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<NativeStackScreenProps<AuthStackParamList, 'Login'>>;
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<LoginRole>('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<string | undefined>();
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 = (
<>
<LinearGradient
colors={theme.colors.brandGradient}
style={StyleSheet.absoluteFill}
// A vertical wash. Two of the three stops carry the brand navy itself,
// so the screen still reads as one colour rather than as a fade.
start={{ x: 0.5, y: 0 }}
end={{ x: 0.5, y: 1 }}
/>
{/* The wash is dark, so the status bar icons have to be light — the
opposite of every other screen in the app. */}
<StatusBar barStyle="light-content" backgroundColor="transparent" translucent />
</>
);
// Deciding between the MPIN screen and registration needs the stored profile.
// Rendering either one first would flash the wrong screen.
if (!contextLoaded) {
return (
<View style={styles.flex}>
{gradient}
<SafeAreaView edges={['top']} style={[styles.flex, styles.centre]}>
<Loader />
</SafeAreaView>
</View>
);
}
return (
<View style={styles.flex}>
{gradient}
<SafeAreaView edges={['top', 'bottom']} style={styles.flex}>
<KeyboardAvoidingView
style={styles.flex}
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
>
<ScrollView
contentContainerStyle={styles.scroll}
showsVerticalScrollIndicator={false}
keyboardShouldPersistTaps="handled"
keyboardDismissMode="interactive"
>
{/* ── brand lock-up ─────────────────────────────────────────── */}
<LoginHero />
{/* ── who is signing in ─────────────────────────────────────── */}
<LoginRoleTabs
activeRole={activeRole}
onRoleChange={setActiveRole}
disabled={isLoading}
/>
{/* ── greeting ──────────────────────────────────────────────── */}
<View style={styles.greeting}>
{isReturningCustomer && lastProfile?.fullName ? (
<>
<Text style={[theme.text.bodySmall, { color: theme.colors.onBrandSecondary }]}>
Welcome back
</Text>
<Text
numberOfLines={2}
style={[theme.text.h1, styles.centreText, { color: theme.colors.onBrandPrimary }]}
>
{lastProfile.fullName}
</Text>
{maskedMobile ? (
<Text style={[theme.text.code, { color: theme.colors.onBrandMuted }]}>
{maskedMobile}
</Text>
) : null}
</>
) : (
<>
<Text
style={[
theme.text.h1,
styles.centreText,
{ color: theme.colors.onBrandPrimary },
]}
>
{activeRole === 'Agent' ? 'Agent sign-in' : 'Welcome'}
</Text>
{/*
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 ? (
<Text style={[theme.text.code, { color: theme.colors.onBrandMuted }]}>
{maskedMobile}
</Text>
) : null}
</>
)}
</View>
{/* ── the way in ────────────────────────────────────────────── */}
{biometricsLead ? (
<Animated.View entering={FadeIn.duration(320)} style={styles.heroBlock}>
<BiometricNode
variant="hero"
onPress={handleBiometricLogin}
disabled={isLoading}
caption="Tap to sign in"
/>
</Animated.View>
) : null}
{formVisible ? (
<Animated.View entering={FadeInUp.duration(280)} style={styles.form}>
{!isReturningCustomer ? (
<Input
label="Registered mobile number"
prefix="+91"
keyboardType="number-pad"
maxLength={10}
value={mobile}
onChangeText={handleMobileChange}
placeholder="98765 43210"
error={mobileError}
editable={!isLoading}
/>
) : null}
<CodeInput
label="Enter MPIN"
value={mpin}
onChangeText={handleMpinChange}
length={MPIN_LENGTH}
secure
autoFocus={mpinRequested || isReturningCustomer}
editable={!isLoading}
onSubmit={handleSignIn}
/>
{formError ? <InfoBanner tone="danger" message={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 ? (
<View style={styles.rememberRow}>
<Text
style={[
theme.text.bodySmall,
styles.rememberLabel,
{ color: theme.colors.onBrandSecondary },
]}
>
Enable biometric sign-in next time
</Text>
<Switch
value={rememberForBiometrics}
onValueChange={setRememberForBiometrics}
disabled={isLoading}
accessibilityLabel="Enable biometric sign-in next time"
// The thumb stays white in both states. A navy thumb on
// the translucent-white track composites to roughly
// 1.7:1 against it and effectively disappears on the
// navy wash.
trackColor={{
false: theme.colors.onBrandSurfaceStrong,
true: theme.colors.success,
}}
thumbColor={theme.colors.onBrandPrimary}
/>
</View>
) : null}
{/* White fill, navy label — `primary` here would be navy on
navy and disappear into the wash. */}
<Button
title="Sign in"
variant="inverse"
loading={isLoading}
disabled={!canSubmit}
onPress={handleSignIn}
/>
{/* An agent, and a customer whose biometrics are enrolled but
not yet bound to a stored MPIN, never see the hero target
above — so their biometric path lives here instead, or they
would have none at all. */}
{canUseBiometrics && biometricsGranted && !biometricsLead ? (
<>
<View style={styles.dividerRow}>
<View
style={[styles.divider, { backgroundColor: theme.colors.onBrandBorder }]}
/>
<Text style={[theme.text.eyebrow, { color: theme.colors.onBrandMuted }]}>
or sign in with
</Text>
<View
style={[styles.divider, { backgroundColor: theme.colors.onBrandBorder }]}
/>
</View>
<BiometricNode onPress={handleBiometricLogin} disabled={isLoading} />
</>
) : null}
</Animated.View>
) : (
<Button
title="Sign in with MPIN"
variant="onBrand"
onPress={handleUseMpin}
disabled={isLoading}
rightAccessory={
<Ionicons
name="chevron-forward"
size={16}
color={theme.colors.onBrandPrimary}
/>
}
style={styles.mpinSwitch}
/>
)}
{/* ── footnotes ─────────────────────────────────────────────── */}
<View style={styles.footnotes}>
<FeatureGate feature={FeatureKey.AUTH_REGISTER}>
<Text style={[theme.text.bodySmall, { color: theme.colors.onBrandSecondary }]}>
{isReturningCustomer ? 'Not you? ' : 'New to BankCBS? '}
<Text
accessibilityRole="link"
style={{ color: theme.colors.onBrandPrimary }}
onPress={handleRegisterAgain}
>
Register
</Text>
</Text>
</FeatureGate>
{/* There is no forgot-MPIN or reset flow in the API — by policy,
not by omission — so the app says so instead of offering a
dead link. */}
<Text
style={[theme.text.caption, styles.centreText, { color: theme.colors.onBrandMuted }]}
>
Forgot your MPIN? Visit your branch to have it reset.
</Text>
</View>
</ScrollView>
</KeyboardAvoidingView>
{/*
Both of these are hidden while the keyboard is up. They are the
least urgent things on the screen, and the MPIN field needs the
height more than a legal line does.
*/}
{!keyboardUp ? (
<View style={styles.actions}>
<PreLoginActions onOpenAccount={handleRegisterAgain} />
<LoginFooter />
</View>
) : null}
</SafeAreaView>
</View>
);
}
const styles = StyleSheet.create({
flex: {
flex: 1,
},
centre: {
alignItems: 'center',
justifyContent: 'center',
},
centreText: {
textAlign: 'center',
},
scroll: {
flexGrow: 1,
alignItems: 'center',
justifyContent: 'center',
gap: 26,
paddingHorizontal: 24,
paddingVertical: 28,
},
greeting: {
alignItems: 'center',
gap: 4,
},
heroBlock: {
alignItems: 'center',
paddingVertical: 4,
},
form: {
width: '100%',
gap: 16,
},
mpinSwitch: {
// Narrower than the full gutter width, so the pill reads as one option
// among several rather than as the screen's committed action.
width: '88%',
},
rememberRow: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
gap: 12,
},
rememberLabel: {
flex: 1,
},
dividerRow: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
gap: 12,
},
divider: {
flex: 1,
height: 1,
},
footnotes: {
alignItems: 'center',
gap: 8,
},
actions: {
gap: 14,
paddingBottom: 8,
},
});