Update CBS Mobile App codebase

This commit is contained in:
Abin Toppo
2026-09-21 13:21:25 +05:30
parent 58af541536
commit 2dcb10775c
339 changed files with 26750 additions and 16767 deletions
@@ -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&apos;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,
},
});
+566 -183
View File
@@ -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,
},
});
+733
View File
@@ -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&apos;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&apos;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&apos;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,
},
});