246 lines
7.2 KiB
TypeScript
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'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,
|
|
},
|
|
});
|