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,
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user