Update CBS Mobile App codebase
This commit is contained in:
@@ -1,169 +1,566 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Pressable, StyleSheet, Text, View } from 'react-native';
|
||||
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 Animated, { FadeInDown, FadeInUp } from 'react-native-reanimated';
|
||||
|
||||
import { Button, Card, Input, ScreenShell } from '../../../components';
|
||||
import { useAppDispatch } from '../../../app/hooks';
|
||||
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 { secureStorage } from '../../../services/storage/secureStorage';
|
||||
import { authActions } from '../store/authSlice';
|
||||
import { useLoginMutation } from '../services/authApi';
|
||||
import { isValidPhone } from '../utils/authValidation';
|
||||
import { LoginHero } from '../components/LoginHero';
|
||||
import { NeuralGrid } from '../components/NeuralGrid';
|
||||
import { BiometricNode } from '../components/BiometricNode';
|
||||
import { useBiometricPrompt } from '../hooks/useBiometricPrompt';
|
||||
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(props: Readonly<Props>) {
|
||||
export default function LoginScreen({ navigation }: Props) {
|
||||
const dispatch = useAppDispatch();
|
||||
const theme = useTheme();
|
||||
const toast = useToast();
|
||||
const showApiError = useApiErrorToast();
|
||||
const promptBiometric = useBiometricPrompt();
|
||||
const [phone, setPhone] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
|
||||
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 [showPassword, setShowPassword] = useState(false);
|
||||
const [login, loginState] = useLoginMutation();
|
||||
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 handleForgotPassword = () => {
|
||||
haptics.selection();
|
||||
toast('Resetting your password', 'info', 'We are taking you to reset your password.');
|
||||
};
|
||||
|
||||
const handleSignUp = () => {
|
||||
haptics.selection();
|
||||
toast('Creating your account', 'info', 'We are taking you to open a new account.');
|
||||
};
|
||||
const userRoleEnabled = useFeature(FeatureKey.AUTH_LOGIN_USER_ROLE);
|
||||
const agentRoleEnabled = useFeature(FeatureKey.AUTH_LOGIN_AGENT_ROLE);
|
||||
|
||||
const handleContinue = async () => {
|
||||
try {
|
||||
const tokens = {
|
||||
accessToken: 'mock-access-token',
|
||||
refreshToken: 'mock-refresh-token',
|
||||
expiresAt: new Date(Date.now() + 3600000).toISOString(),
|
||||
role: activeRole,
|
||||
};
|
||||
await secureStorage.saveSession(tokens);
|
||||
dispatch(authActions.authenticate(tokens));
|
||||
haptics.success();
|
||||
toast('Signed in', 'success', 'Welcome back to your account.');
|
||||
return;
|
||||
} catch {
|
||||
haptics.error();
|
||||
toast('Sign-in failed', 'error', 'We could not verify your details. Please check and try again.');
|
||||
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();
|
||||
const success = await promptBiometric();
|
||||
|
||||
if (!success) {
|
||||
toast('Biometric sign-in cancelled', 'info', 'You can try again or sign in with your password.');
|
||||
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;
|
||||
}
|
||||
|
||||
const tokens = {
|
||||
accessToken: 'biometric-access-token',
|
||||
refreshToken: 'biometric-refresh-token',
|
||||
expiresAt: new Date(Date.now() + 1000 * 60 * 60 * 24).toISOString(),
|
||||
role: activeRole,
|
||||
};
|
||||
|
||||
await secureStorage.saveSession(tokens);
|
||||
dispatch(authActions.authenticate(tokens));
|
||||
haptics.success();
|
||||
toast('Signed in', 'success', 'Your identity was confirmed. Welcome back.');
|
||||
// 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}>
|
||||
<ScreenShell scrollable={true} contentContainerStyle={styles.shellContainer}>
|
||||
<View style={styles.content}>
|
||||
<View style={styles.heroWrapper}>
|
||||
<LoginHero />
|
||||
</View>
|
||||
{gradient}
|
||||
|
||||
<Animated.View entering={FadeInUp.delay(400).duration(800).springify()}>
|
||||
<LoginRoleTabs activeRole={activeRole} onRoleChange={setActiveRole} />
|
||||
<Card
|
||||
style={[
|
||||
styles.formCard,
|
||||
activeRole === 'User' ? { borderTopLeftRadius: 0 } : {}
|
||||
]}
|
||||
>
|
||||
<View style={styles.inputGap}>
|
||||
<Text style={[styles.inputLabel, { color: theme.colors.textSecondary }]}>EMAIL OR PHONE</Text>
|
||||
<Input
|
||||
keyboardType="default"
|
||||
value={phone}
|
||||
onChangeText={setPhone}
|
||||
placeholder="name@example.com"
|
||||
/>
|
||||
</View>
|
||||
|
||||
<View style={styles.inputGap}>
|
||||
<Text style={[styles.inputLabel, { color: theme.colors.textSecondary }]}>PASSWORD</Text>
|
||||
<Input
|
||||
secureTextEntry={!showPassword}
|
||||
value={password}
|
||||
onChangeText={setPassword}
|
||||
placeholder="••••••••"
|
||||
rightAccessory={
|
||||
<Pressable onPress={() => setShowPassword(!showPassword)} hitSlop={12}>
|
||||
<Ionicons
|
||||
name={showPassword ? 'eye-off-outline' : 'eye-outline'}
|
||||
size={20}
|
||||
color={theme.colors.textMuted}
|
||||
/>
|
||||
</Pressable>
|
||||
}
|
||||
/>
|
||||
</View>
|
||||
|
||||
<View style={styles.forgotPasswordRow}>
|
||||
<Pressable onPress={handleForgotPassword} hitSlop={12}>
|
||||
<Text style={[styles.linkText, { color: theme.colors.primary }]}>Forgot password?</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
|
||||
<Button
|
||||
title="Sign In"
|
||||
loading={loginState.isLoading}
|
||||
onPress={handleContinue}
|
||||
style={styles.mainButton}
|
||||
/>
|
||||
|
||||
<View style={styles.dividerRow}>
|
||||
<View style={[styles.divider, { backgroundColor: theme.colors.cardBorder }]} />
|
||||
<Text style={[styles.dividerText, { color: theme.colors.textMuted }]}>OR SIGN IN WITH</Text>
|
||||
<View style={[styles.divider, { backgroundColor: theme.colors.cardBorder }]} />
|
||||
</View>
|
||||
|
||||
<BiometricNode onPress={handleBiometricLogin} />
|
||||
</Card>
|
||||
</Animated.View>
|
||||
|
||||
<Animated.View
|
||||
entering={FadeInDown.delay(600).duration(800)}
|
||||
style={styles.bottomLinks}
|
||||
<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"
|
||||
>
|
||||
<Text style={[styles.signUpText, { color: theme.colors.textSecondary }]}>
|
||||
New to BankCBS?{' '}
|
||||
<Text style={[styles.signUpLink, { color: theme.colors.primary }]} onPress={handleSignUp}>Create account</Text>
|
||||
</Text>
|
||||
</Animated.View>
|
||||
</View>
|
||||
</ScreenShell>
|
||||
{/* ── 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>
|
||||
);
|
||||
}
|
||||
@@ -172,77 +569,63 @@ const styles = StyleSheet.create({
|
||||
flex: {
|
||||
flex: 1,
|
||||
},
|
||||
shellContainer: {
|
||||
flexGrow: 1,
|
||||
paddingHorizontal: 20,
|
||||
},
|
||||
content: {
|
||||
flex: 1,
|
||||
centre: {
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
paddingBottom: 40,
|
||||
},
|
||||
heroWrapper: {
|
||||
marginBottom: 8,
|
||||
centreText: {
|
||||
textAlign: 'center',
|
||||
},
|
||||
formCard: {
|
||||
gap: 20,
|
||||
padding: 24,
|
||||
borderRadius: 24,
|
||||
backgroundColor: '#FFFFFF',
|
||||
// We'll rely on the Card component's internal variant/shadow or override
|
||||
scroll: {
|
||||
flexGrow: 1,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: 26,
|
||||
paddingHorizontal: 24,
|
||||
paddingVertical: 28,
|
||||
},
|
||||
inputGap: {
|
||||
gap: 8,
|
||||
greeting: {
|
||||
alignItems: 'center',
|
||||
gap: 4,
|
||||
},
|
||||
inputLabel: {
|
||||
fontSize: 12,
|
||||
fontWeight: '700',
|
||||
letterSpacing: 0.5,
|
||||
heroBlock: {
|
||||
alignItems: 'center',
|
||||
paddingVertical: 4,
|
||||
},
|
||||
forgotPasswordRow: {
|
||||
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',
|
||||
justifyContent: 'flex-end',
|
||||
marginTop: -8,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
gap: 12,
|
||||
},
|
||||
linkText: {
|
||||
fontSize: 14,
|
||||
fontWeight: '600',
|
||||
},
|
||||
mainButton: {
|
||||
height: 54,
|
||||
borderRadius: 12,
|
||||
rememberLabel: {
|
||||
flex: 1,
|
||||
},
|
||||
dividerRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: 12,
|
||||
marginVertical: 4,
|
||||
},
|
||||
divider: {
|
||||
flex: 1,
|
||||
height: 1,
|
||||
},
|
||||
dividerText: {
|
||||
fontSize: 11,
|
||||
fontWeight: '600',
|
||||
letterSpacing: 0.5,
|
||||
},
|
||||
bottomLinks: {
|
||||
gap: 16,
|
||||
footnotes: {
|
||||
alignItems: 'center',
|
||||
marginTop: 32,
|
||||
gap: 8,
|
||||
},
|
||||
signUpText: {
|
||||
fontSize: 15,
|
||||
fontWeight: '500',
|
||||
},
|
||||
signUpLink: {
|
||||
fontWeight: '700',
|
||||
actions: {
|
||||
gap: 14,
|
||||
paddingBottom: 8,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user