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

246 lines
7.2 KiB
TypeScript

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,
},
});