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

283 lines
9.1 KiB
TypeScript

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