Update CBS Mobile App codebase
This commit is contained in:
@@ -0,0 +1,245 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { StyleSheet, Switch, Text, View } from 'react-native';
|
||||
import type { NativeStackScreenProps } from '@react-navigation/native-stack';
|
||||
import Ionicons from '@react-native-vector-icons/ionicons';
|
||||
|
||||
import { Button, Card, ScreenShell } from '../../../components';
|
||||
import { useAppDispatch, useAppSelector } from '../../../app/hooks';
|
||||
import { useTheme, fontFamily } from '../../../theme';
|
||||
import { useApiErrorToast } from '../../../hooks/useApiErrorToast';
|
||||
import { biometrics } from '../../../services/security/biometrics';
|
||||
import { authActions } from '../store/authSlice';
|
||||
import { selectRegistration } from '../store/authSelectors';
|
||||
import { completeMpinSetup, rememberRegisteredProfile } from '../store/authThunk';
|
||||
import { MPIN_LENGTH, describeMpinProblem, isValidMpin } from '../utils/authValidation';
|
||||
import { CodeInput } from '../components/CodeInput';
|
||||
import type { AuthStackParamList } from '../../../navigation/types';
|
||||
|
||||
type Props = Readonly<NativeStackScreenProps<AuthStackParamList, 'CreateMpin'>>;
|
||||
|
||||
export default function CreateMpinScreen({ navigation }: Props) {
|
||||
const dispatch = useAppDispatch();
|
||||
const theme = useTheme();
|
||||
const showApiError = useApiErrorToast();
|
||||
const registration = useAppSelector(selectRegistration);
|
||||
|
||||
const [mpin, setMpin] = useState('');
|
||||
const [confirmMpin, setConfirmMpin] = useState('');
|
||||
const [rememberForBiometrics, setRememberForBiometrics] = useState(false);
|
||||
const [biometricsAvailable, setBiometricsAvailable] = useState(false);
|
||||
const [submitted, setSubmitted] = useState(false);
|
||||
|
||||
const isLoading = registration.status === 'loading';
|
||||
|
||||
useEffect(() => {
|
||||
void biometrics.isAvailable().then(setBiometricsAvailable);
|
||||
}, []);
|
||||
|
||||
// A setup token this screen never received cannot be recovered from here —
|
||||
// the five-minute window has to be reopened from the start.
|
||||
useEffect(() => {
|
||||
if (!registration.setupToken && !isLoading && !submitted) {
|
||||
dispatch(authActions.resetRegistration());
|
||||
navigation.navigate('Register');
|
||||
}
|
||||
}, [dispatch, isLoading, navigation, registration.setupToken, submitted]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!submitted || registration.status !== 'failed' || !registration.error) {
|
||||
return;
|
||||
}
|
||||
|
||||
setSubmitted(false);
|
||||
showApiError(registration.error);
|
||||
|
||||
const { code } = registration.error;
|
||||
|
||||
if (code === 'OTP_NOT_VERIFIED') {
|
||||
dispatch(authActions.resetRegistration());
|
||||
navigation.navigate('Register');
|
||||
return;
|
||||
}
|
||||
|
||||
if (code === 'MPIN_ALREADY_SET') {
|
||||
// verify-otp said this profile still owed an MPIN and the server now says
|
||||
// otherwise, so the profile has to be recorded here — nothing earlier in
|
||||
// this path stored one, and sign-in would read that as a device that has
|
||||
// never registered and send them back for another OTP.
|
||||
void dispatch(
|
||||
rememberRegisteredProfile({
|
||||
orgId: registration.orgId,
|
||||
mobileNumber: registration.mobileNumber,
|
||||
}),
|
||||
);
|
||||
dispatch(authActions.resetRegistration());
|
||||
navigation.navigate('Login');
|
||||
return;
|
||||
}
|
||||
|
||||
setMpin('');
|
||||
setConfirmMpin('');
|
||||
}, [
|
||||
dispatch,
|
||||
navigation,
|
||||
registration.error,
|
||||
registration.mobileNumber,
|
||||
registration.orgId,
|
||||
registration.status,
|
||||
showApiError,
|
||||
submitted,
|
||||
]);
|
||||
|
||||
const mpinProblem = describeMpinProblem(mpin);
|
||||
const confirmProblem =
|
||||
confirmMpin.length === MPIN_LENGTH && confirmMpin !== mpin
|
||||
? 'Both entries must match'
|
||||
: undefined;
|
||||
|
||||
const canSubmit =
|
||||
isValidMpin(mpin) && confirmMpin === mpin && !isLoading;
|
||||
|
||||
const handleSubmit = () => {
|
||||
if (!canSubmit) {
|
||||
return;
|
||||
}
|
||||
|
||||
setSubmitted(true);
|
||||
void dispatch(
|
||||
completeMpinSetup({
|
||||
mpin,
|
||||
rememberForBiometrics: rememberForBiometrics && biometricsAvailable,
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<ScreenShell
|
||||
title="Create your MPIN"
|
||||
subtitle="You will use this to sign in from now on"
|
||||
withTabBar={false}
|
||||
contentContainerStyle={styles.container}
|
||||
>
|
||||
<Card style={styles.formCard}>
|
||||
<Text style={[styles.sectionLabel, { color: theme.colors.textSecondary }]}>
|
||||
{`CHOOSE A ${MPIN_LENGTH}-DIGIT MPIN`}
|
||||
</Text>
|
||||
|
||||
<CodeInput
|
||||
value={mpin}
|
||||
onChangeText={setMpin}
|
||||
length={MPIN_LENGTH}
|
||||
secure
|
||||
autoFocus
|
||||
editable={!isLoading}
|
||||
error={mpinProblem}
|
||||
/>
|
||||
|
||||
<CodeInput
|
||||
label="Confirm MPIN"
|
||||
value={confirmMpin}
|
||||
onChangeText={setConfirmMpin}
|
||||
length={MPIN_LENGTH}
|
||||
secure
|
||||
editable={!isLoading}
|
||||
error={confirmProblem}
|
||||
onSubmit={handleSubmit}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
{biometricsAvailable && (
|
||||
<Card style={styles.biometricCard}>
|
||||
<View style={styles.biometricRow}>
|
||||
<Ionicons name="finger-print-outline" size={22} color={theme.colors.primary} />
|
||||
<View style={styles.biometricCopy}>
|
||||
<Text style={[styles.biometricTitle, { color: theme.colors.textPrimary }]}>
|
||||
Sign in with biometrics
|
||||
</Text>
|
||||
{/* Worth being straight about: the API opens a session only
|
||||
against the MPIN, so enabling this keeps the MPIN on the
|
||||
device behind the system prompt. */}
|
||||
<Text style={[styles.biometricNote, { color: theme.colors.textSecondary }]}>
|
||||
Stores your MPIN in this device's secure hardware, unlocked by your
|
||||
fingerprint or face.
|
||||
</Text>
|
||||
</View>
|
||||
<Switch
|
||||
value={rememberForBiometrics}
|
||||
onValueChange={setRememberForBiometrics}
|
||||
disabled={isLoading}
|
||||
/>
|
||||
</View>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<View style={[styles.noteCard, { backgroundColor: `${theme.colors.warning}14` }]}>
|
||||
<Ionicons name="alert-circle-outline" size={18} color={theme.colors.warning} />
|
||||
<Text style={[styles.noteText, { color: theme.colors.textSecondary }]}>
|
||||
Three wrong MPINs lock your profile, and only your branch can unlock it. Pick
|
||||
something you will remember.
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<Button
|
||||
title="Set MPIN and continue"
|
||||
onPress={handleSubmit}
|
||||
disabled={!canSubmit}
|
||||
loading={isLoading}
|
||||
style={styles.submitButton}
|
||||
/>
|
||||
</ScreenShell>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
padding: 16,
|
||||
gap: 16,
|
||||
flexGrow: 1,
|
||||
},
|
||||
formCard: {
|
||||
padding: 16,
|
||||
gap: 16,
|
||||
},
|
||||
sectionLabel: {
|
||||
fontSize: 11,
|
||||
fontFamily: fontFamily.bold,
|
||||
letterSpacing: 0.5,
|
||||
marginLeft: 4,
|
||||
},
|
||||
biometricCard: {
|
||||
padding: 16,
|
||||
},
|
||||
biometricRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 12,
|
||||
},
|
||||
biometricCopy: {
|
||||
flex: 1,
|
||||
gap: 2,
|
||||
},
|
||||
biometricTitle: {
|
||||
fontSize: 15,
|
||||
fontFamily: fontFamily.bold,
|
||||
},
|
||||
biometricNote: {
|
||||
fontSize: 12,
|
||||
lineHeight: 17,
|
||||
},
|
||||
noteCard: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'flex-start',
|
||||
gap: 10,
|
||||
padding: 14,
|
||||
borderRadius: 12,
|
||||
},
|
||||
noteText: {
|
||||
flex: 1,
|
||||
fontSize: 13,
|
||||
lineHeight: 18,
|
||||
},
|
||||
submitButton: {
|
||||
marginTop: 'auto',
|
||||
height: 54,
|
||||
},
|
||||
});
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
import React from 'react';
|
||||
import { Linking, StyleSheet, Text, View } from 'react-native';
|
||||
import type { NativeStackScreenProps } from '@react-navigation/native-stack';
|
||||
import Ionicons from '@react-native-vector-icons/ionicons';
|
||||
|
||||
import { Button, Card, ScreenShell } from '../../../components';
|
||||
import { useAppDispatch } from '../../../app/hooks';
|
||||
import { useTheme, fontFamily } from '../../../theme';
|
||||
import { appConfig } from '../../../config/appConfig';
|
||||
import { authActions } from '../store/authSlice';
|
||||
import type { AuthStackParamList } from '../../../navigation/types';
|
||||
|
||||
type Props = Readonly<NativeStackScreenProps<AuthStackParamList, 'MpinLocked'>>;
|
||||
|
||||
/**
|
||||
* A locked profile is a dead end by policy: there is no forgot-MPIN flow and no
|
||||
* in-app reset anywhere in the API. Saying so plainly beats a retry button that
|
||||
* can only ever fail.
|
||||
*/
|
||||
export default function MpinLockedScreen({ navigation }: Props) {
|
||||
const dispatch = useAppDispatch();
|
||||
const theme = useTheme();
|
||||
|
||||
const handleBackToSignIn = () => {
|
||||
dispatch(authActions.dismissMpinLock());
|
||||
navigation.navigate('Login');
|
||||
};
|
||||
|
||||
const handleContactSupport = () => {
|
||||
void Linking.openURL(`mailto:${appConfig.supportEmail}?subject=MPIN%20locked`);
|
||||
};
|
||||
|
||||
return (
|
||||
<ScreenShell
|
||||
title="MPIN locked"
|
||||
subtitle="Too many incorrect attempts"
|
||||
withTabBar={false}
|
||||
contentContainerStyle={styles.container}
|
||||
>
|
||||
<View style={[styles.iconWrap, { backgroundColor: `${theme.colors.error}14` }]}>
|
||||
<Ionicons name="lock-closed-outline" size={40} color={theme.colors.error} />
|
||||
</View>
|
||||
|
||||
<Card style={styles.card}>
|
||||
<Text style={[styles.heading, { color: theme.colors.textPrimary }]}>
|
||||
Visit your branch to unlock
|
||||
</Text>
|
||||
<Text style={[styles.body, { color: theme.colors.textSecondary }]}>
|
||||
For your security, your profile locks after three incorrect MPIN entries. There is no
|
||||
way to unlock it from the app — a branch officer has to reset it in person.
|
||||
</Text>
|
||||
<Text style={[styles.body, { color: theme.colors.textSecondary }]}>
|
||||
Take a valid photo ID with you. Once your MPIN is reset you can sign in here as usual.
|
||||
</Text>
|
||||
</Card>
|
||||
|
||||
<View style={styles.actions}>
|
||||
<Button title="Contact support" variant="secondary" onPress={handleContactSupport} />
|
||||
<Button title="Back to sign in" variant="ghost" onPress={handleBackToSignIn} />
|
||||
</View>
|
||||
</ScreenShell>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
padding: 16,
|
||||
gap: 20,
|
||||
flexGrow: 1,
|
||||
alignItems: 'stretch',
|
||||
},
|
||||
iconWrap: {
|
||||
alignSelf: 'center',
|
||||
width: 88,
|
||||
height: 88,
|
||||
borderRadius: 44,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
marginTop: 12,
|
||||
},
|
||||
card: {
|
||||
padding: 20,
|
||||
gap: 12,
|
||||
},
|
||||
heading: {
|
||||
fontSize: 18,
|
||||
fontFamily: fontFamily.bold,
|
||||
},
|
||||
body: {
|
||||
fontSize: 14,
|
||||
lineHeight: 20,
|
||||
},
|
||||
actions: {
|
||||
marginTop: 'auto',
|
||||
gap: 12,
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,733 @@
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import {
|
||||
ActivityIndicator,
|
||||
AppState,
|
||||
KeyboardAvoidingView,
|
||||
Linking,
|
||||
Platform,
|
||||
Pressable,
|
||||
ScrollView,
|
||||
StatusBar,
|
||||
StyleSheet,
|
||||
Text,
|
||||
View,
|
||||
} from 'react-native';
|
||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||
import type { NativeStackScreenProps } from '@react-navigation/native-stack';
|
||||
import Ionicons from '@react-native-vector-icons/ionicons';
|
||||
import LinearGradient from 'react-native-linear-gradient';
|
||||
import Animated, { FadeInDown, FadeInUp } from 'react-native-reanimated';
|
||||
|
||||
import { Button, InfoBanner, Input } from '../../../components';
|
||||
import { useAppDispatch, useAppSelector } from '../../../app/hooks';
|
||||
import { useTheme } from '../../../theme';
|
||||
import { useApiErrorToast } from '../../../hooks/useApiErrorToast';
|
||||
import { isValidPhone } from '../../../utils/validation';
|
||||
import { simCard, type SimInfo, type SimSubscription } from '../../../services/device/simCard';
|
||||
import { SimChooser } from '../components/SimChooser';
|
||||
import { authActions } from '../store/authSlice';
|
||||
import { selectLastProfile, selectRegistration } from '../store/authSelectors';
|
||||
import { rememberRegisteredProfile, startRegistration } from '../store/authThunk';
|
||||
import type { AuthStackParamList } from '../../../navigation/types';
|
||||
|
||||
type Props = Readonly<NativeStackScreenProps<AuthStackParamList, 'Register'>>;
|
||||
|
||||
/**
|
||||
* Three short marks of what this flow is, in the shape a bank's onboarding
|
||||
* screen usually carries them.
|
||||
*
|
||||
* Each one is literally true of this app, which is the only reason it is
|
||||
* allowed on the screen: the MPIN really is RSA-OAEP encrypted before it leaves
|
||||
* the handset, the number really is read off the SIM when the phone will share
|
||||
* it, and the profile really is bound to this device. Nothing here claims a
|
||||
* certification, an audit, or a regulator — those would be invented, and an
|
||||
* invented security claim on a bank's sign-up screen is a straightforward lie
|
||||
* to a customer.
|
||||
*/
|
||||
const TRUST_MARKS = [
|
||||
{ icon: 'shield-checkmark' as const, label: 'Secure' },
|
||||
{ icon: 'flash' as const, label: 'Seamless' },
|
||||
{ icon: 'phone-portrait' as const, label: 'Trusted device' },
|
||||
];
|
||||
|
||||
export default function RegisterScreen({ navigation }: Props) {
|
||||
const dispatch = useAppDispatch();
|
||||
const theme = useTheme();
|
||||
const showApiError = useApiErrorToast();
|
||||
const registration = useAppSelector(selectRegistration);
|
||||
const lastProfile = useAppSelector(selectLastProfile);
|
||||
|
||||
const [sim, setSim] = useState<SimInfo | null>(null);
|
||||
/** Which SIM's code request is in flight, so only that row spins. */
|
||||
const [sendingId, setSendingId] = useState<number | null>(null);
|
||||
const [detecting, setDetecting] = useState(true);
|
||||
const [submitted, setSubmitted] = useState(false);
|
||||
/** Bumped to re-read the SIMs. Never prompts — see the mount effect. */
|
||||
const [attempt, setAttempt] = useState(0);
|
||||
/**
|
||||
* Android has stopped showing the permission dialog for this app. Asking
|
||||
* again would return instantly and change nothing, so the only honest next
|
||||
* step is app settings.
|
||||
*/
|
||||
const [permissionBlocked, setPermissionBlocked] = useState(false);
|
||||
/**
|
||||
* The typed-number fallback.
|
||||
*
|
||||
* Reading the SIM covers the common case, but a carrier that never wrote the
|
||||
* MSISDN onto the card, a denied permission and a phone with no SIM in it
|
||||
* each ended the flow outright — the screen said to contact the bank,
|
||||
* because there was nowhere to put a number. It can be typed now, so none of
|
||||
* those three is a dead end.
|
||||
*/
|
||||
const [manualOpen, setManualOpen] = useState(false);
|
||||
const [manualNumber, setManualNumber] = useState('');
|
||||
|
||||
const isLoading = registration.status === 'loading';
|
||||
|
||||
const sims = sim?.sims ?? [];
|
||||
// A line whose number the carrier never provisioned cannot be tapped — there
|
||||
// is nothing to send from it. Typing the number is the way past that.
|
||||
const tappable = sims.filter((entry) => entry.phoneNumber);
|
||||
|
||||
/**
|
||||
* Reads on mount, and never prompts here.
|
||||
*
|
||||
* This used to call a `detect()` that raised the system permission dialog the
|
||||
* instant the screen appeared — before the customer had read a word of what
|
||||
* the screen was for, which is precisely how an app trains people to refuse.
|
||||
* The prompt now belongs to a button the customer presses knowing what it
|
||||
* will do.
|
||||
*/
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
setDetecting(true);
|
||||
|
||||
void simCard.read().then((info) => {
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
|
||||
setSim(info);
|
||||
setDetecting(false);
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [attempt]);
|
||||
|
||||
// Coming back from app settings is the one case where the answer changes
|
||||
// while this screen is mounted but nothing here was touched. Re-reading on
|
||||
// foreground is what makes the settings round-trip actually finish.
|
||||
useEffect(() => {
|
||||
const subscription = AppState.addEventListener('change', (state) => {
|
||||
if (state === 'active') {
|
||||
setAttempt((value) => value + 1);
|
||||
}
|
||||
});
|
||||
|
||||
return () => subscription.remove();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!submitted) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (registration.status === 'succeeded' && registration.otpSent) {
|
||||
setSubmitted(false);
|
||||
setSendingId(null);
|
||||
navigation.navigate('VerifyOtp');
|
||||
return;
|
||||
}
|
||||
|
||||
if (registration.status === 'failed' && registration.error) {
|
||||
setSubmitted(false);
|
||||
setSendingId(null);
|
||||
showApiError(registration.error);
|
||||
|
||||
// Registration is already complete for this number — what the customer
|
||||
// wants is sign-in, not another OTP.
|
||||
if (
|
||||
registration.error.code === 'ALREADY_REGISTERED' ||
|
||||
registration.error.code === 'MPIN_ALREADY_SET'
|
||||
) {
|
||||
// Recorded before the reset clears the number, and before the navigate
|
||||
// below: sign-in reads the stored profile to tell a device that has
|
||||
// completed registration from one that never has, and without it this
|
||||
// number would be sent straight back here for another OTP.
|
||||
void dispatch(
|
||||
rememberRegisteredProfile({
|
||||
orgId: registration.orgId,
|
||||
mobileNumber: registration.mobileNumber,
|
||||
}),
|
||||
);
|
||||
dispatch(authActions.resetRegistration());
|
||||
navigation.navigate('Login');
|
||||
}
|
||||
}
|
||||
}, [dispatch, navigation, registration, showApiError, submitted]);
|
||||
|
||||
/** One tap sends the code — there is no separate submit step any more. */
|
||||
const handleSelectSim = (chosen: SimSubscription) => {
|
||||
const number = chosen.phoneNumber;
|
||||
|
||||
if (!number || isLoading) {
|
||||
return;
|
||||
}
|
||||
|
||||
setSendingId(chosen.subscriptionId);
|
||||
setSubmitted(true);
|
||||
// orgId is fixed for this build and comes from config, so there is no field
|
||||
// for it. The chosen SIM is not sent either: the API takes a number alone.
|
||||
void dispatch(startRegistration({ mobileNumber: number }));
|
||||
};
|
||||
|
||||
// Ten digits or nothing — the same contract the SIM read normalises to, so a
|
||||
// typed number and a read one reach the API in one shape.
|
||||
const manualDigits = manualNumber.replace(/D/g, '');
|
||||
|
||||
/** Silent until there is something to be wrong about, as sign-in is. */
|
||||
const manualError = (() => {
|
||||
if (manualDigits.length === 0 || isValidPhone(manualDigits)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return 'Enter the 10-digit number your bank has on file.';
|
||||
})();
|
||||
|
||||
const handleManualChange = (value: string) => {
|
||||
setManualNumber(value.replace(/D/g, ''));
|
||||
};
|
||||
|
||||
const handleManualSubmit = () => {
|
||||
if (!isValidPhone(manualDigits) || isLoading) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Left null on purpose: no SIM row is sending, so none of them should spin.
|
||||
setSendingId(null);
|
||||
setSubmitted(true);
|
||||
void dispatch(startRegistration({ mobileNumber: manualDigits }));
|
||||
};
|
||||
|
||||
/**
|
||||
* Why there is nothing to tap, and what the customer can do about it. Null
|
||||
* whenever at least one line carries a readable number.
|
||||
*/
|
||||
const blocker = (() => {
|
||||
if (detecting || !sim || tappable.length > 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!sim.hasPermission) {
|
||||
return {
|
||||
icon: 'lock-closed-outline' as const,
|
||||
title: 'Allow phone access',
|
||||
text: 'Your number is read straight from the SIM, so you never have to type it. Nothing else on this phone is read.',
|
||||
action: 'Allow phone access',
|
||||
kind: 'permission' as const,
|
||||
};
|
||||
}
|
||||
|
||||
/*
|
||||
* The slots listed but no number came back, and the reason is a permission
|
||||
* this app has not been given — not the carrier.
|
||||
*
|
||||
* This case had no branch at all before, so it fell through to the carrier
|
||||
* message below and told the customer to visit a branch. On Android 13 and
|
||||
* up it is the likeliest failure of the three, because READ_PHONE_NUMBERS
|
||||
* is a separate grant from READ_PHONE_STATE.
|
||||
*/
|
||||
if (!sim.canReadNumbers) {
|
||||
return {
|
||||
icon: 'key-outline' as const,
|
||||
title: 'One more permission needed',
|
||||
text: 'This phone keeps the SIM number behind its own permission. Allow it and your number is filled in for you.',
|
||||
action: 'Allow and continue',
|
||||
kind: 'permission' as const,
|
||||
};
|
||||
}
|
||||
|
||||
if (!sim.hasSim) {
|
||||
return {
|
||||
icon: 'phone-portrait-outline' as const,
|
||||
title: 'No SIM found',
|
||||
text: 'Put the SIM whose number your bank has into this phone, then check again.',
|
||||
action: 'Check again',
|
||||
kind: 'retry' as const,
|
||||
};
|
||||
}
|
||||
|
||||
// The ordinary case: a SIM is present, the app may read numbers, and the
|
||||
// carrier still never wrote one onto the SIM — so there is nothing to read
|
||||
// and nowhere to send a code.
|
||||
return {
|
||||
icon: 'information-circle-outline' as const,
|
||||
title: 'Your carrier does not share this number',
|
||||
text:
|
||||
sims.length > 1
|
||||
? 'Neither line lets apps read its number, so there is nothing to tap. Type the number in and the code still comes through.'
|
||||
: 'This SIM does not let apps read its number, so there is nothing to tap. Type the number in and the code still comes through.',
|
||||
action: 'Check again',
|
||||
kind: 'retry' as const,
|
||||
};
|
||||
})();
|
||||
|
||||
/** True once Android has stopped offering the dialog for a permission ask. */
|
||||
const needsSettings = blocker?.kind === 'permission' && permissionBlocked;
|
||||
|
||||
const handleBlockerAction = useCallback(async () => {
|
||||
if (blocker?.kind !== 'permission') {
|
||||
setAttempt((value) => value + 1);
|
||||
return;
|
||||
}
|
||||
|
||||
if (permissionBlocked) {
|
||||
// The AppState listener above re-reads when the customer comes back.
|
||||
void Linking.openSettings();
|
||||
return;
|
||||
}
|
||||
|
||||
const { outcome, info } = await simCard.requestAndRead();
|
||||
|
||||
setSim(info);
|
||||
|
||||
if (outcome === 'blocked') {
|
||||
setPermissionBlocked(true);
|
||||
}
|
||||
}, [blocker?.kind, permissionBlocked]);
|
||||
|
||||
const numberError = registration.error?.fieldErrors?.mobileNumber;
|
||||
|
||||
const manualLink = (
|
||||
<Pressable onPress={() => setManualOpen(true)} hitSlop={6}>
|
||||
<Text
|
||||
style={[theme.text.bodySmall, styles.manualLink, { color: theme.colors.onBrandSecondary }]}
|
||||
>
|
||||
Number not showing?{' '}
|
||||
<Text style={{ color: theme.colors.onBrandPrimary }}>Type it instead</Text>
|
||||
</Text>
|
||||
</Pressable>
|
||||
);
|
||||
|
||||
/*
|
||||
A solid surface, not the translucent `onBrandSurface` the blocker sits on.
|
||||
The field inside carries the design system's light-context colours — a
|
||||
dark-grey label over half-transparent white over navy is not readable — so
|
||||
the card brings its own light surface, exactly as the InfoBanner does.
|
||||
*/
|
||||
const manualCard = (
|
||||
<View
|
||||
style={[
|
||||
styles.manual,
|
||||
{
|
||||
backgroundColor: theme.colors.surface,
|
||||
borderColor: theme.colors.cardBorder,
|
||||
borderRadius: theme.radius.lg,
|
||||
},
|
||||
]}
|
||||
>
|
||||
<Text style={[theme.text.h3, { color: theme.colors.textPrimary }]}>
|
||||
Enter your mobile number
|
||||
</Text>
|
||||
<Text style={[theme.text.bodySmall, { color: theme.colors.textSecondary }]}>
|
||||
The number your bank holds for you. We'll text a one-time code to it.
|
||||
</Text>
|
||||
|
||||
<Input
|
||||
label="Mobile number"
|
||||
prefix="+91"
|
||||
keyboardType="number-pad"
|
||||
maxLength={10}
|
||||
value={manualNumber}
|
||||
onChangeText={handleManualChange}
|
||||
onSubmitEditing={handleManualSubmit}
|
||||
returnKeyType="done"
|
||||
placeholder="98765 43210"
|
||||
autoFocus
|
||||
editable={!isLoading}
|
||||
// The server's own complaint about the number belongs on the field that
|
||||
// holds it, rather than in a banner elsewhere on the screen.
|
||||
error={manualError ?? numberError}
|
||||
/>
|
||||
|
||||
<Button
|
||||
title="Send code"
|
||||
onPress={handleManualSubmit}
|
||||
disabled={!isValidPhone(manualDigits)}
|
||||
// Only a manual send leaves `sendingId` null, so a SIM row's request
|
||||
// cannot put this button into a spin it does not own.
|
||||
loading={isLoading && sendingId === null}
|
||||
/>
|
||||
|
||||
<Pressable onPress={() => setManualOpen(false)} hitSlop={6}>
|
||||
<Text
|
||||
style={[
|
||||
theme.text.bodySmall,
|
||||
styles.centreText,
|
||||
styles.manualDismiss,
|
||||
{ color: theme.colors.textSecondary },
|
||||
]}
|
||||
>
|
||||
{tappable.length > 0 ? 'Use a SIM instead' : 'Back'}
|
||||
</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
);
|
||||
|
||||
return (
|
||||
<View style={styles.flex}>
|
||||
{/*
|
||||
The brand wash now fills the screen rather than sitting in a rounded
|
||||
banner at the top. It is the same three stops the sign-in screen uses,
|
||||
run vertically, so the two auth screens are one surface rather than two
|
||||
different navies.
|
||||
*/}
|
||||
<LinearGradient
|
||||
colors={theme.colors.brandGradient}
|
||||
style={StyleSheet.absoluteFill}
|
||||
start={{ x: 0.5, y: 0 }}
|
||||
end={{ x: 0.5, y: 1 }}
|
||||
/>
|
||||
{/* Dark full-bleed surface, so the status-bar icons have to be light. */}
|
||||
<StatusBar barStyle="light-content" backgroundColor="transparent" translucent />
|
||||
|
||||
<SafeAreaView edges={['top', 'bottom']} style={styles.flex}>
|
||||
{/* The number can be typed on this screen now, so it needs the same
|
||||
keyboard handling sign-in has — without it the field and the send
|
||||
button sit under the keyboard on a short handset. */}
|
||||
<KeyboardAvoidingView
|
||||
style={styles.flex}
|
||||
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
|
||||
>
|
||||
<ScrollView
|
||||
contentContainerStyle={styles.scroll}
|
||||
showsVerticalScrollIndicator={false}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
keyboardDismissMode="interactive"
|
||||
>
|
||||
{navigation.canGoBack() ? (
|
||||
<Pressable
|
||||
onPress={() => navigation.goBack()}
|
||||
hitSlop={10}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="Go back"
|
||||
style={[styles.back, { backgroundColor: theme.colors.onBrandSurfaceStrong }]}
|
||||
>
|
||||
<Ionicons name="chevron-back" size={20} color={theme.colors.onBrandPrimary} />
|
||||
</Pressable>
|
||||
) : null}
|
||||
|
||||
{/* ── Brand and purpose ──────────────────────────────────────────── */}
|
||||
<Animated.View entering={FadeInDown.duration(420)} style={styles.heroBody}>
|
||||
<View style={styles.brandRow}>
|
||||
<View
|
||||
style={[
|
||||
styles.mark,
|
||||
{
|
||||
backgroundColor: theme.colors.onBrandSurfaceStrong,
|
||||
borderRadius: theme.radius.sm,
|
||||
},
|
||||
]}
|
||||
>
|
||||
<Ionicons name="shield-checkmark" size={26} color={theme.colors.onBrandPrimary} />
|
||||
</View>
|
||||
<Text style={[theme.text.h1, { color: theme.colors.onBrandPrimary }]}>BankCBS</Text>
|
||||
</View>
|
||||
|
||||
<Text
|
||||
accessibilityRole="header"
|
||||
style={[theme.text.display, styles.heroTitle, { color: theme.colors.onBrandPrimary }]}
|
||||
>
|
||||
Let's set up{'\n'}your banking
|
||||
</Text>
|
||||
<Text
|
||||
style={[theme.text.body, styles.heroSub, { color: theme.colors.onBrandSecondary }]}
|
||||
>
|
||||
Tap the SIM registered with your bank. We'll read the number straight from it
|
||||
and text you a one-time code.
|
||||
</Text>
|
||||
|
||||
<View style={styles.trustRow}>
|
||||
{TRUST_MARKS.map((item) => (
|
||||
<View
|
||||
key={item.label}
|
||||
style={[
|
||||
styles.trustChip,
|
||||
{
|
||||
backgroundColor: theme.colors.onBrandSurface,
|
||||
borderColor: theme.colors.onBrandBorder,
|
||||
borderRadius: theme.radius.pill,
|
||||
},
|
||||
]}
|
||||
>
|
||||
<Ionicons name={item.icon} size={13} color={theme.colors.onBrandPrimary} />
|
||||
{/* Solid white: the chip's translucent fill lightens the wash,
|
||||
so a translucent label on it drops to 4.37:1 up here where
|
||||
the gradient is lightest. */}
|
||||
<Text style={[theme.text.caption, { color: theme.colors.onBrandPrimary }]}>
|
||||
{item.label}
|
||||
</Text>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
</Animated.View>
|
||||
|
||||
{/*
|
||||
The SIM choice, directly on the wash. It used to be wrapped in a
|
||||
white card pinned to the foot of the screen; the card was a second
|
||||
surface doing nothing the cards inside it were not already doing,
|
||||
and pinning it left a tall empty band above.
|
||||
*/}
|
||||
<Animated.View entering={FadeInUp.delay(120).duration(420)} style={styles.simBlock}>
|
||||
{/* Checked ahead of `detecting`: the AppState listener re-reads the
|
||||
SIMs every time the app is foregrounded, and a half-typed
|
||||
number must not be swapped out for a spinner about something
|
||||
the customer has already chosen to go around. */}
|
||||
{manualOpen ? (
|
||||
manualCard
|
||||
) : detecting ? (
|
||||
<View
|
||||
style={[
|
||||
styles.detecting,
|
||||
{
|
||||
backgroundColor: theme.colors.onBrandSurface,
|
||||
borderColor: theme.colors.onBrandBorder,
|
||||
borderRadius: theme.radius.md,
|
||||
},
|
||||
]}
|
||||
>
|
||||
<ActivityIndicator size="small" color={theme.colors.onBrandPrimary} />
|
||||
<Text
|
||||
style={[
|
||||
theme.text.bodySmall,
|
||||
styles.detectingText,
|
||||
// On a fill, so solid rather than translucent.
|
||||
{ color: theme.colors.onBrandPrimary },
|
||||
]}
|
||||
>
|
||||
Reading the SIMs in this phone…
|
||||
</Text>
|
||||
</View>
|
||||
) : blocker ? (
|
||||
<View
|
||||
style={[
|
||||
styles.blocker,
|
||||
{
|
||||
backgroundColor: theme.colors.onBrandSurface,
|
||||
borderColor: theme.colors.onBrandBorder,
|
||||
borderRadius: theme.radius.lg,
|
||||
},
|
||||
]}
|
||||
>
|
||||
<View
|
||||
style={[
|
||||
styles.blockerIcon,
|
||||
{ backgroundColor: theme.colors.onBrandSurfaceStrong },
|
||||
]}
|
||||
>
|
||||
<Ionicons
|
||||
name={needsSettings ? 'settings-outline' : blocker.icon}
|
||||
size={20}
|
||||
color={theme.colors.onBrandPrimary}
|
||||
/>
|
||||
</View>
|
||||
<Text
|
||||
style={[
|
||||
theme.text.h3,
|
||||
styles.centreText,
|
||||
{ color: theme.colors.onBrandPrimary },
|
||||
]}
|
||||
>
|
||||
{needsSettings ? 'Grant it in Settings' : blocker.title}
|
||||
</Text>
|
||||
<Text
|
||||
style={[
|
||||
theme.text.bodySmall,
|
||||
styles.centreText,
|
||||
// On a fill, so solid rather than translucent.
|
||||
{ color: theme.colors.onBrandPrimary },
|
||||
]}
|
||||
>
|
||||
{needsSettings
|
||||
? 'Android will not show the permission prompt again for this app. Open Settings, allow Phone, and come back — we will pick it up from there.'
|
||||
: blocker.text}
|
||||
</Text>
|
||||
{/* White fill on the wash: `secondary` here would be a white
|
||||
button on a white card, which is not the situation any more. */}
|
||||
<Button
|
||||
title={needsSettings ? 'Open Settings' : blocker.action}
|
||||
variant="inverse"
|
||||
onPress={() => {
|
||||
void handleBlockerAction();
|
||||
}}
|
||||
style={styles.retry}
|
||||
/>
|
||||
{manualLink}
|
||||
</View>
|
||||
) : (
|
||||
<>
|
||||
<SimChooser
|
||||
sims={sims}
|
||||
busyId={sendingId}
|
||||
onSelect={handleSelectSim}
|
||||
disabled={isLoading}
|
||||
onBrand
|
||||
/>
|
||||
{/* An InfoBanner rather than bare red text: `danger` is a dark
|
||||
red and would sit at roughly 1.5:1 against the navy. The
|
||||
banner brings its own light surface, so the message stays
|
||||
readable wherever it is put. */}
|
||||
{numberError ? <InfoBanner tone="danger" message={numberError} /> : null}
|
||||
{/* A line the carrier will not name still has to get a code
|
||||
somehow, and a customer registering a number that is not in
|
||||
this phone at all has no row to tap either. */}
|
||||
{manualLink}
|
||||
</>
|
||||
)}
|
||||
</Animated.View>
|
||||
|
||||
{/*
|
||||
Nothing is pinned to the foot of this screen.
|
||||
|
||||
The content is centred in the viewport instead, so the space falls
|
||||
evenly above and below the SIM cards rather than pooling under them.
|
||||
An empty band at the bottom of a screen reads as unfinished; the
|
||||
same amount of space, split, reads as breathing room.
|
||||
*/}
|
||||
<View style={styles.footer}>
|
||||
{/* Only worth offering when a profile is actually stored here —
|
||||
otherwise it leads to an MPIN screen with nothing to sign in to. */}
|
||||
{lastProfile ? (
|
||||
<Pressable onPress={() => navigation.navigate('Login')} hitSlop={6}>
|
||||
<Text
|
||||
style={[
|
||||
theme.text.bodySmall,
|
||||
styles.footerLink,
|
||||
{ color: theme.colors.onBrandSecondary },
|
||||
]}
|
||||
>
|
||||
Already registered?{' '}
|
||||
<Text style={{ color: theme.colors.onBrandPrimary }}>Sign in</Text>
|
||||
</Text>
|
||||
</Pressable>
|
||||
) : null}
|
||||
</View>
|
||||
</ScrollView>
|
||||
</KeyboardAvoidingView>
|
||||
</SafeAreaView>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
flex: {
|
||||
flex: 1,
|
||||
},
|
||||
centreText: {
|
||||
textAlign: 'center',
|
||||
},
|
||||
scroll: {
|
||||
flexGrow: 1,
|
||||
// Centred, not top-aligned: with nothing pinned to the foot, the leftover
|
||||
// height splits evenly above and below the content instead of pooling into
|
||||
// one empty band under the SIM cards.
|
||||
justifyContent: 'center',
|
||||
paddingTop: 14,
|
||||
paddingBottom: 28,
|
||||
paddingHorizontal: 22,
|
||||
gap: 30,
|
||||
},
|
||||
back: {
|
||||
width: 34,
|
||||
height: 34,
|
||||
borderRadius: 17,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
heroBody: { gap: 10 },
|
||||
brandRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 9,
|
||||
},
|
||||
mark: {
|
||||
width: 44,
|
||||
height: 44,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
heroTitle: {
|
||||
marginTop: 4,
|
||||
},
|
||||
heroSub: {
|
||||
maxWidth: 340,
|
||||
},
|
||||
trustRow: {
|
||||
flexDirection: 'row',
|
||||
flexWrap: 'wrap',
|
||||
gap: 8,
|
||||
marginTop: 6,
|
||||
},
|
||||
trustChip: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 5,
|
||||
borderWidth: 1,
|
||||
paddingVertical: 5,
|
||||
paddingHorizontal: 10,
|
||||
},
|
||||
|
||||
simBlock: {
|
||||
gap: 12,
|
||||
},
|
||||
detecting: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 10,
|
||||
borderWidth: 1,
|
||||
paddingVertical: 12,
|
||||
paddingHorizontal: 14,
|
||||
},
|
||||
detectingText: { flex: 1 },
|
||||
|
||||
blocker: {
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
borderWidth: 1,
|
||||
padding: 18,
|
||||
},
|
||||
blockerIcon: {
|
||||
width: 42,
|
||||
height: 42,
|
||||
borderRadius: 21,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
marginBottom: 2,
|
||||
},
|
||||
retry: {
|
||||
marginTop: 6,
|
||||
},
|
||||
|
||||
manual: {
|
||||
gap: 12,
|
||||
borderWidth: 1,
|
||||
padding: 18,
|
||||
},
|
||||
manualLink: {
|
||||
textAlign: 'center',
|
||||
paddingVertical: 10,
|
||||
},
|
||||
manualDismiss: {
|
||||
paddingVertical: 4,
|
||||
},
|
||||
footer: {
|
||||
gap: 4,
|
||||
},
|
||||
footerLink: {
|
||||
textAlign: 'center',
|
||||
paddingVertical: 11,
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,282 @@
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { Pressable, StyleSheet, Text, View } from 'react-native';
|
||||
import type { NativeStackScreenProps } from '@react-navigation/native-stack';
|
||||
import Ionicons from '@react-native-vector-icons/ionicons';
|
||||
|
||||
import { Button, Card, ScreenShell } from '../../../components';
|
||||
import { useAppDispatch, useAppSelector } from '../../../app/hooks';
|
||||
import { useTheme, fontFamily } from '../../../theme';
|
||||
import { useApiErrorToast } from '../../../hooks/useApiErrorToast';
|
||||
import { useToast } from '../../../hooks/useToast';
|
||||
import { authActions } from '../store/authSlice';
|
||||
import { selectRegistration } from '../store/authSelectors';
|
||||
import {
|
||||
rememberRegisteredProfile,
|
||||
startRegistration,
|
||||
verifyRegistrationOtp,
|
||||
} from '../store/authThunk';
|
||||
import { isValidOtp } from '../utils/authValidation';
|
||||
import { CodeInput } from '../components/CodeInput';
|
||||
import type { AuthStackParamList } from '../../../navigation/types';
|
||||
|
||||
type Props = Readonly<NativeStackScreenProps<AuthStackParamList, 'VerifyOtp'>>;
|
||||
|
||||
const RESEND_COOLDOWN_SECONDS = 30;
|
||||
|
||||
export default function VerifyOtpScreen({ navigation }: Props) {
|
||||
const dispatch = useAppDispatch();
|
||||
const theme = useTheme();
|
||||
const toast = useToast();
|
||||
const showApiError = useApiErrorToast();
|
||||
const registration = useAppSelector(selectRegistration);
|
||||
|
||||
const [otp, setOtp] = useState('');
|
||||
const [cooldown, setCooldown] = useState(RESEND_COOLDOWN_SECONDS);
|
||||
const pending = useRef<'verify' | 'resend' | null>(null);
|
||||
/** The deferred navigation frame — see `afterStateSettles`. */
|
||||
const navFrame = useRef<number | null>(null);
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
if (navFrame.current !== null) {
|
||||
cancelAnimationFrame(navFrame.current);
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
/**
|
||||
* Runs a navigation action one frame after the dispatches that sit beside it.
|
||||
*
|
||||
* React batches an effect's work into a single commit, and the branches below
|
||||
* each dispatch *and* move the stack. That one commit then tells Fabric to do
|
||||
* two incompatible things at once: the dispatch re-renders the Register
|
||||
* screen still mounted under this one — its footer link, its error row — while
|
||||
* the reset removes that very subtree. Mounting the result throws "The
|
||||
* specified child already has a parent", which is a red box in debug and a
|
||||
* blank screen with no way to sign in once it is dismissed.
|
||||
*
|
||||
* The navigator already froze `initialRouteName` against the same collision;
|
||||
* this is the other half of it. A frame's gap splits the work into two
|
||||
* commits that each describe one change: the state settles, then the stack
|
||||
* moves. It is not a race — `pending` is cleared before the frame is
|
||||
* queued, so nothing re-enters in between.
|
||||
*/
|
||||
const afterStateSettles = useCallback((run: () => void) => {
|
||||
if (navFrame.current !== null) {
|
||||
cancelAnimationFrame(navFrame.current);
|
||||
}
|
||||
|
||||
navFrame.current = requestAnimationFrame(() => {
|
||||
navFrame.current = null;
|
||||
run();
|
||||
});
|
||||
}, []);
|
||||
|
||||
const isLoading = registration.status === 'loading';
|
||||
const fieldErrors = registration.error?.fieldErrors;
|
||||
|
||||
useEffect(() => {
|
||||
if (cooldown <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const timer = setTimeout(() => setCooldown((value) => value - 1), 1000);
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
}, [cooldown]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!pending.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (registration.status === 'succeeded') {
|
||||
const action = pending.current;
|
||||
pending.current = null;
|
||||
|
||||
if (action === 'resend') {
|
||||
setOtp('');
|
||||
setCooldown(RESEND_COOLDOWN_SECONDS);
|
||||
toast('Code sent', 'success', 'Check your messages for a new code.');
|
||||
return;
|
||||
}
|
||||
|
||||
/**
|
||||
* Where verification lands depends on whether this profile owes an MPIN.
|
||||
*
|
||||
* `mpinSetupRequired: false` means one is already set — the customer is
|
||||
* re-binding a device or has reinstalled — so asking them to choose a new
|
||||
* MPIN would be wrong twice over: the create endpoint refuses it with
|
||||
* `MPIN_ALREADY_SET`, and offering the choice implies the old one no
|
||||
* longer works, which sends people to a branch for a reset they do not
|
||||
* need. They go to sign-in and enter the MPIN they have.
|
||||
*
|
||||
* The stack is reset rather than pushed. The setup token is spent either
|
||||
* way, so every screen behind this one is a dead end — and on the
|
||||
* already-registered path there is nothing left to go back FOR.
|
||||
*/
|
||||
if (!registration.mpinSetupRequired) {
|
||||
dispatch(authActions.resetRegistration());
|
||||
toast(
|
||||
'You are already registered',
|
||||
'info',
|
||||
'This device is set up. Sign in with your existing MPIN.',
|
||||
);
|
||||
afterStateSettles(() => navigation.reset({ index: 0, routes: [{ name: 'Login' }] }));
|
||||
return;
|
||||
}
|
||||
|
||||
// The setup token is single use and lasts five minutes, so MPIN creation
|
||||
// follows immediately rather than sitting behind another screen.
|
||||
if (registration.setupToken) {
|
||||
navigation.navigate('CreateMpin');
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (registration.status === 'failed' && registration.error) {
|
||||
pending.current = null;
|
||||
showApiError(registration.error);
|
||||
setOtp('');
|
||||
|
||||
const { code } = registration.error;
|
||||
|
||||
// Out of attempts, or the window closed — either way the OTP on this
|
||||
// screen can never succeed, so start over rather than let them keep trying.
|
||||
if (code === 'OTP_ATTEMPTS_EXCEEDED' || code === 'OTP_EXPIRED') {
|
||||
dispatch(authActions.resetRegistration());
|
||||
afterStateSettles(() => navigation.navigate('Register'));
|
||||
return;
|
||||
}
|
||||
|
||||
// Same profile, already set up — sign-in is the only step left. Recorded
|
||||
// before the reset clears the number, so sign-in asks for the MPIN
|
||||
// instead of reading a blank profile as "never registered" and sending
|
||||
// them back to the start of registration.
|
||||
if (code === 'ALREADY_REGISTERED' || code === 'MPIN_ALREADY_SET') {
|
||||
void dispatch(
|
||||
rememberRegisteredProfile({
|
||||
orgId: registration.orgId,
|
||||
mobileNumber: registration.mobileNumber,
|
||||
}),
|
||||
);
|
||||
dispatch(authActions.resetRegistration());
|
||||
afterStateSettles(() => navigation.navigate('Login'));
|
||||
}
|
||||
}
|
||||
}, [afterStateSettles, dispatch, navigation, registration, showApiError, toast]);
|
||||
|
||||
const handleVerify = () => {
|
||||
if (!isValidOtp(otp) || isLoading) {
|
||||
return;
|
||||
}
|
||||
|
||||
pending.current = 'verify';
|
||||
void dispatch(verifyRegistrationOtp({ otp }));
|
||||
};
|
||||
|
||||
const handleResend = () => {
|
||||
if (cooldown > 0 || isLoading) {
|
||||
return;
|
||||
}
|
||||
|
||||
pending.current = 'resend';
|
||||
void dispatch(
|
||||
startRegistration({
|
||||
mobileNumber: registration.mobileNumber,
|
||||
orgId: registration.orgId,
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
const maskedMobile = registration.mobileNumber
|
||||
? `••••••${registration.mobileNumber.slice(-4)}`
|
||||
: '';
|
||||
|
||||
return (
|
||||
<ScreenShell
|
||||
title="Enter the code"
|
||||
subtitle={`Sent to ${maskedMobile}`}
|
||||
withTabBar={false}
|
||||
contentContainerStyle={styles.container}
|
||||
>
|
||||
<Card style={styles.formCard}>
|
||||
<CodeInput
|
||||
label="6-digit code"
|
||||
value={otp}
|
||||
onChangeText={setOtp}
|
||||
length={6}
|
||||
autoFocus
|
||||
editable={!isLoading}
|
||||
onSubmit={handleVerify}
|
||||
error={fieldErrors?.otp}
|
||||
/>
|
||||
|
||||
<Pressable onPress={handleResend} disabled={cooldown > 0 || isLoading} hitSlop={8}>
|
||||
<Text
|
||||
style={[
|
||||
styles.resendText,
|
||||
{ color: cooldown > 0 ? theme.colors.textMuted : theme.colors.primary },
|
||||
]}
|
||||
>
|
||||
{cooldown > 0 ? `Resend code in ${cooldown}s` : 'Resend code'}
|
||||
</Text>
|
||||
</Pressable>
|
||||
</Card>
|
||||
|
||||
{registration.accountsLinked > 0 && (
|
||||
<View style={[styles.noteCard, { backgroundColor: `${theme.colors.success}0D` }]}>
|
||||
<Ionicons name="checkmark-circle-outline" size={18} color={theme.colors.success} />
|
||||
<Text style={[styles.noteText, { color: theme.colors.textSecondary }]}>
|
||||
{registration.accountsLinked === 1
|
||||
? '1 account found at your bank and ready to link.'
|
||||
: `${registration.accountsLinked} accounts found at your bank and ready to link.`}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
<Button
|
||||
title="Verify"
|
||||
onPress={handleVerify}
|
||||
disabled={!isValidOtp(otp) || isLoading}
|
||||
loading={isLoading}
|
||||
style={styles.submitButton}
|
||||
/>
|
||||
</ScreenShell>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
padding: 16,
|
||||
gap: 16,
|
||||
flexGrow: 1,
|
||||
},
|
||||
formCard: {
|
||||
padding: 16,
|
||||
gap: 16,
|
||||
},
|
||||
resendText: {
|
||||
fontSize: 14,
|
||||
fontFamily: fontFamily.semibold,
|
||||
textAlign: 'center',
|
||||
},
|
||||
noteCard: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'flex-start',
|
||||
gap: 10,
|
||||
padding: 14,
|
||||
borderRadius: 12,
|
||||
},
|
||||
noteText: {
|
||||
flex: 1,
|
||||
fontSize: 13,
|
||||
lineHeight: 18,
|
||||
},
|
||||
submitButton: {
|
||||
marginTop: 'auto',
|
||||
height: 54,
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user