From f1def81eb74be92599fd6b1781bd31c5586c87f1 Mon Sep 17 00:00:00 2001 From: Abin Toppo Date: Tue, 29 Sep 2026 17:54:43 +0530 Subject: [PATCH] Add TPIN creation, TPIN-authorised transfers, MPIN reset and transfer history - Create TPIN: current MPIN -> OTP -> encrypted TPIN via /tpin/create/*, from Services and from the transfer screen on TPIN_NOT_SET - IMPS initiate now sends encryptedTpin instead of encryptedMpin; handles INVALID_TPIN and TPIN_NOT_SET - Share one RSA-OAEP encryption and key-retry path for MPIN and TPIN - Forgot MPIN on sign-in and Reset MPIN in the Security Center - Fund transfer history list and detail, reachable from Home - Brand logo on sign-in and palette updates Co-Authored-By: Claude Opus 5.5 --- src/assets/images/logo.jpg | Bin 0 -> 23338 bytes src/components/Input/index.tsx | 10 +- src/config/env.ts | 2 +- src/constants/featureKeys.ts | 1 + src/features/featureConfig.ts | 1 + .../auth/components/BrandLogo/index.tsx | 52 ++ src/modules/auth/components/CodeInput.tsx | 36 +- .../auth/components/LoginHero/index.tsx | 39 +- src/modules/auth/components/MpinResetFlow.tsx | 452 +++++++++++++++ src/modules/auth/screens/CreateMpinScreen.tsx | 4 +- src/modules/auth/screens/ForgotMpinScreen.tsx | 43 ++ src/modules/auth/screens/LoginScreen.tsx | 30 +- src/modules/auth/screens/MpinLockedScreen.tsx | 7 +- src/modules/auth/screens/RegisterScreen.tsx | 27 +- src/modules/auth/services/authApi.ts | 73 ++- src/modules/auth/utils/authValidation.ts | 23 +- src/modules/cards/components/CardCarousel.tsx | 10 +- .../history/hooks/useFundTransferHistory.ts | 137 +++++ .../history/screens/TransferDetailScreen.tsx | 236 ++++++++ .../history/screens/TransferHistoryScreen.tsx | 445 ++++++++++++++ src/modules/history/utils/transferStatus.ts | 70 +++ src/modules/home/hooks/useStatement.ts | 28 +- src/modules/home/screens/HomeScreen.tsx | 16 + .../payments/components/TpinCreateFlow.tsx | 545 ++++++++++++++++++ .../payments/screens/CreateTpinScreen.tsx | 45 ++ .../screens/PaymentConfirmationScreen.tsx | 107 ++-- src/modules/payments/services/paymentsApi.ts | 82 ++- src/modules/payments/services/tpinService.ts | 62 ++ .../payments/utils/tpinValidation.test.ts | 67 +++ src/modules/payments/utils/tpinValidation.ts | 57 ++ .../profile/navigation/ProfileNavigator.tsx | 2 + .../profile/screens/ProfileMainScreen.tsx | 2 +- .../profile/screens/ResetMpinScreen.tsx | 64 ++ .../profile/screens/SecurityScreen.tsx | 12 + src/modules/search/searchCatalog.ts | 22 + .../services/screens/ServicesHubScreen.tsx | 4 +- .../services/screens/SetTpinScreen.tsx | 168 +----- src/navigation/AuthNavigator.tsx | 3 + src/navigation/HomeNavigator.tsx | 4 + src/navigation/PaymentsNavigator.tsx | 2 + src/navigation/featureRoutes.ts | 4 + src/navigation/types.ts | 6 + src/services/api/cbsErrors.test.ts | 23 +- src/services/api/cbsErrors.ts | 20 + src/services/api/endpoints.ts | 36 ++ src/services/security/mpinCrypto.ts | 49 +- src/theme/palette.ts | 109 ++-- src/types/cbs.ts | 121 +++- src/utils/validation.test.ts | 32 + src/utils/validation.ts | 34 ++ 50 files changed, 3039 insertions(+), 385 deletions(-) create mode 100644 src/assets/images/logo.jpg create mode 100644 src/modules/auth/components/BrandLogo/index.tsx create mode 100644 src/modules/auth/components/MpinResetFlow.tsx create mode 100644 src/modules/auth/screens/ForgotMpinScreen.tsx create mode 100644 src/modules/history/hooks/useFundTransferHistory.ts create mode 100644 src/modules/history/screens/TransferDetailScreen.tsx create mode 100644 src/modules/history/screens/TransferHistoryScreen.tsx create mode 100644 src/modules/history/utils/transferStatus.ts create mode 100644 src/modules/payments/components/TpinCreateFlow.tsx create mode 100644 src/modules/payments/screens/CreateTpinScreen.tsx create mode 100644 src/modules/payments/services/tpinService.ts create mode 100644 src/modules/payments/utils/tpinValidation.test.ts create mode 100644 src/modules/payments/utils/tpinValidation.ts create mode 100644 src/modules/profile/screens/ResetMpinScreen.tsx diff --git a/src/assets/images/logo.jpg b/src/assets/images/logo.jpg new file mode 100644 index 0000000000000000000000000000000000000000..c437407d7d0f3931da1c85e78ac71c193b961ab8 GIT binary patch literal 23338 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 literal 0 HcmV?d00001 diff --git a/src/components/Input/index.tsx b/src/components/Input/index.tsx index 94a4748..bf9e2e1 100644 --- a/src/components/Input/index.tsx +++ b/src/components/Input/index.tsx @@ -29,6 +29,8 @@ interface InputProps extends TextInputProps { // on the TextInput overwrote it — which is why SupportScreen's description // box had been rendering as a single line despite asking for four. style?: TextInputProps['style']; + /** Set when the field sits directly on the brand wash, so the label reads as white. */ + onBrand?: boolean; } export const Input = ({ @@ -44,6 +46,7 @@ export const Input = ({ editable = true, onFocus, onBlur, + onBrand = false, ...props }: InputProps) => { const theme = useTheme(); @@ -90,7 +93,12 @@ export const Input = ({ return ( {label ? ( - + {label} {required ? * : null} diff --git a/src/config/env.ts b/src/config/env.ts index 2f77f5e..48c5196 100644 --- a/src/config/env.ts +++ b/src/config/env.ts @@ -32,7 +32,7 @@ export const env = { /** Every documented endpoint lives under this prefix. */ apiBaseUrl: `${API_ORIGIN}/api/v1`, /** Organisation the app is branded for; sent at registration and at sign-in. */ - orgId: '107295', + orgId: '107295', /** * Modules with no backend behind them yet — cards, loans, insurance, deposits, * the dashboard and the agent app — still resolve from fixtures. Registration, diff --git a/src/constants/featureKeys.ts b/src/constants/featureKeys.ts index dbb6fcc..5e3e9e7 100644 --- a/src/constants/featureKeys.ts +++ b/src/constants/featureKeys.ts @@ -229,6 +229,7 @@ export enum FeatureKey { PROFILE_SECURITY = 'profile.security', PROFILE_SECURITY_CHANGE_PIN = 'profile.security.changePin', + PROFILE_SECURITY_RESET_MPIN = 'profile.security.resetMpin', PROFILE_SECURITY_CHANGE_PASSWORD = 'profile.security.changePassword', PROFILE_SECURITY_BIOMETRIC = 'profile.security.biometric', PROFILE_SECURITY_TWO_FACTOR = 'profile.security.twoFactor', diff --git a/src/features/featureConfig.ts b/src/features/featureConfig.ts index 4eb1085..d536c01 100644 --- a/src/features/featureConfig.ts +++ b/src/features/featureConfig.ts @@ -239,6 +239,7 @@ export const DEFAULT_FEATURE_CONFIG: Record = { [FeatureKey.PROFILE_SECURITY]: { enabled: true }, [FeatureKey.PROFILE_SECURITY_CHANGE_PIN]: { enabled: true }, + [FeatureKey.PROFILE_SECURITY_RESET_MPIN]: { enabled: true }, [FeatureKey.PROFILE_SECURITY_CHANGE_PASSWORD]: { enabled: true }, [FeatureKey.PROFILE_SECURITY_BIOMETRIC]: { enabled: true }, [FeatureKey.PROFILE_SECURITY_TWO_FACTOR]: { enabled: true }, diff --git a/src/modules/auth/components/BrandLogo/index.tsx b/src/modules/auth/components/BrandLogo/index.tsx new file mode 100644 index 0000000..d50af5c --- /dev/null +++ b/src/modules/auth/components/BrandLogo/index.tsx @@ -0,0 +1,52 @@ +import React, { memo } from 'react'; +import { Image, StyleSheet, View } from 'react-native'; + +import { useTheme } from '../../../../theme'; + +const LOGO = require('../../../../assets/images/logo.jpg'); + +/** The source file is 354x99; the lock-up keeps that ratio at any width. */ +const ASPECT_RATIO = 354 / 99; + +interface BrandLogoProps { + /** Rendered width of the logo itself, excluding the plate's padding. */ + width?: number; +} + +/** + * The bank's logo, set on a white plate. + * + * The logo is a JPG with a white ground and no transparency, so it cannot sit + * directly on the red brand wash — it would show as a hard-edged white box. + * The plate makes that white deliberate: a rounded card the logo belongs to. + * It uses `white`, not `surface`, because the logo's own ground is literal + * white in both themes and a dark-mode surface would frame it in charcoal. + */ +const BrandLogoComponent = ({ width = 200 }: BrandLogoProps) => { + const theme = useTheme(); + + return ( + + + + ); +}; + +const styles = StyleSheet.create({ + plate: { + alignSelf: 'center', + paddingHorizontal: 10, + paddingVertical: 6, + }, +}); + +export const BrandLogo = memo(BrandLogoComponent); diff --git a/src/modules/auth/components/CodeInput.tsx b/src/modules/auth/components/CodeInput.tsx index 37958e6..25eb10b 100644 --- a/src/modules/auth/components/CodeInput.tsx +++ b/src/modules/auth/components/CodeInput.tsx @@ -16,6 +16,12 @@ interface CodeInputProps { autoFocus?: boolean; onSubmit?: () => void; editable?: boolean; + /** + * Set when the field sits directly on the red brand wash (sign-in). The + * label and error text switch to on-brand white, and the focus ring to + * brass — a `primary` red ring would vanish against the red around it. + */ + onBrand?: boolean; } /** @@ -33,6 +39,7 @@ export const CodeInput = ({ autoFocus = false, onSubmit, editable = true, + onBrand = false, }: CodeInputProps) => { const theme = useTheme(); const inputRef = useRef(null); @@ -58,7 +65,14 @@ export const CodeInput = ({ return ( {label ? ( - {label} + + {label} + ) : null} {/* The hidden input is scoped to this wrapper rather than the whole @@ -86,11 +100,13 @@ export const CodeInput = ({ borderColor: error ? theme.colors.danger : isActive - ? theme.colors.primary + ? onBrand + ? theme.colors.accentFill + : theme.colors.primary : filled ? theme.colors.cardBorderStrong : theme.colors.cardBorder, - borderWidth: isActive ? 1.5 : 1, + borderWidth: isActive ? (onBrand ? 2 : 1.5) : 1, backgroundColor: error ? theme.colors.dangerSurface : editable @@ -136,8 +152,18 @@ export const CodeInput = ({ {error ? ( - - + + {error} diff --git a/src/modules/auth/components/LoginHero/index.tsx b/src/modules/auth/components/LoginHero/index.tsx index d4ca7b5..0a50039 100644 --- a/src/modules/auth/components/LoginHero/index.tsx +++ b/src/modules/auth/components/LoginHero/index.tsx @@ -1,19 +1,10 @@ import React, { memo } from 'react'; -import { StyleSheet, Text, View } from 'react-native'; -import Ionicons from '@react-native-vector-icons/ionicons'; -import { useTheme } from '../../../../theme'; +import { BrandLogo } from '../BrandLogo'; /** - * The brand lock-up at the top of the sign-in screen. - * - * There is no logo file in the repo, so the mark is drawn from the Ionicons - * `shield-checkmark` glyph — a shield with a tick, which is the mark the design - * system specifies. - * - * It sits on the navy wash, so it is drawn in `onBrand` white (7.12:1 at the - * lightest point of the gradient) rather than in `primary`, which would be - * navy on navy. + * The brand lock-up at the top of the sign-in screen: the bank's logo on its + * white plate. * * Deliberately a single quiet row rather than a stacked plate-and-headline. * The sign-in screen's large type belongs to the customer's own name, and a @@ -22,28 +13,6 @@ import { useTheme } from '../../../../theme'; * this screen is a lock, and marketing copy on a lock erodes the trust it * needs. */ -const LoginHeroComponent = () => { - const theme = useTheme(); - - return ( - - - - BankCBS - - - ); -}; - -const styles = StyleSheet.create({ - container: { - flexDirection: 'row', - alignItems: 'center', - gap: 8, - }, -}); +const LoginHeroComponent = () => ; export const LoginHero = memo(LoginHeroComponent); diff --git a/src/modules/auth/components/MpinResetFlow.tsx b/src/modules/auth/components/MpinResetFlow.tsx new file mode 100644 index 0000000..8d1d00e --- /dev/null +++ b/src/modules/auth/components/MpinResetFlow.tsx @@ -0,0 +1,452 @@ +import React, { useCallback, useEffect, useRef, useState } from 'react'; +import { StyleSheet, Text, View } from 'react-native'; + +import { Button, Card, InfoBanner, Input, Stepper } from '../../../components'; +import { useAppDispatch } from '../../../app/hooks'; +import { useTheme } from '../../../theme'; +import { useToast } from '../../../hooks/useToast'; +import { describeError } from '../../../services/api/cbsErrors'; +import { toApiError } from '../../../services/api/serviceHelpers'; +import type { ApiError } from '../../../types/api'; +import { authService } from '../services/authApi'; +import { disableBiometricSignIn } from '../store/authThunk'; +import { CodeInput } from './CodeInput'; +import { + MPIN_LENGTH, + describeMpinProblem, + isAcceptableNewMpin, + isValidOtp, + isValidPhone, +} from '../utils/authValidation'; + +interface MpinResetFlowProps { + orgId: string; + /** Prefills the mobile step. */ + initialMobile?: string; + /** + * Fixes the mobile number to `initialMobile`. Set when a signed-in customer + * resets from Profile — they may reset their own MPIN, not anyone's. + */ + lockMobile?: boolean; + /** Shown above the mobile step, e.g. that the reset will sign them out. */ + notice?: string; + /** Runs once the new MPIN is saved and every secret here is cleared. */ + onSuccess: () => void; + /** The profile never set an MPIN, so this is a registration, not a reset. */ + onMpinNotSet?: () => void; +} + +type Step = 'mobile' | 'otp' | 'newMpin'; + +/** How long before "send a new code" is offered. There is no resend endpoint. */ +const RESTART_COOLDOWN_SECONDS = 30; + +const STEPS: ReadonlyArray<{ key: Step; label: string }> = [ + { key: 'mobile', label: 'Verify' }, + { key: 'otp', label: 'OTP' }, + { key: 'newMpin', label: 'New MPIN' }, +]; + +const secondsUntil = (iso: string | null | undefined) => + iso ? Math.max(0, Math.round((new Date(iso).getTime() - Date.now()) / 1000)) : null; + +const formatCountdown = (seconds: number) => + `${Math.floor(seconds / 60)}:${`${seconds % 60}`.padStart(2, '0')}`; + +/** + * The MPIN reset, end to end: customer check → OTP → new MPIN. + * + * Shared by sign-in's Forgot MPIN and Profile's Reset MPIN, which differ only + * in what happens around it — so the screens own the navigation, and this owns + * the flow. + * + * ── WHY LOCAL STATE, AND WHY NO REDUX ─────────────────────────────────────── + * The `resetId` and the one-time `resetToken` must live in memory only and be + * gone on back-navigation, restart and success. Holding them in this + * component's state gives exactly that for free — leaving the screen unmounts + * it and they are gone — and keeps them out of the Redux store, where dev + * tooling would record them. Nothing here is persisted or logged. + */ +export const MpinResetFlow = ({ + orgId, + initialMobile = '', + lockMobile = false, + notice, + onSuccess, + onMpinNotSet, +}: MpinResetFlowProps) => { + const theme = useTheme(); + const toast = useToast(); + const dispatch = useAppDispatch(); + const [step, setStep] = useState('mobile'); + const [mobile, setMobile] = useState(initialMobile); + const [otp, setOtp] = useState(''); + const [mpin, setMpin] = useState(''); + const [confirmMpin, setConfirmMpin] = useState(''); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + + // Secrets of the flow. Screen state only — see the note above. + const [resetId, setResetId] = useState(null); + const [otpExpiresAt, setOtpExpiresAt] = useState(null); + const [attemptsRemaining, setAttemptsRemaining] = useState(null); + const [resetToken, setResetToken] = useState(null); + const [resetTokenExpiresAt, setResetTokenExpiresAt] = useState(null); + + // Only its setter is used: each tick re-renders, and the countdown below + // reads the clock afresh. + const [, setNow] = useState(() => Date.now()); + const [cooldown, setCooldown] = useState(0); + const mounted = useRef(true); + + useEffect( + () => () => { + mounted.current = false; + }, + [], + ); + + // One tick drives both the OTP expiry countdown and the restart cooldown. + useEffect(() => { + if (step !== 'otp') { + return; + } + + const timer = setInterval(() => { + setNow(Date.now()); + setCooldown((value) => (value > 0 ? value - 1 : 0)); + }, 1000); + + return () => clearInterval(timer); + }, [step]); + + /** Throws the whole reset away and returns to the first step. */ + const restart = useCallback((message?: string) => { + setResetId(null); + setOtpExpiresAt(null); + setAttemptsRemaining(null); + setResetToken(null); + setResetTokenExpiresAt(null); + setOtp(''); + setMpin(''); + setConfirmMpin(''); + setStep('mobile'); + setError(message ?? null); + }, []); + + const messageFor = (apiError: ApiError) => + apiError.message || describeError(apiError.code).description; + + /* ── Step 1: initiate ─────────────────────────────────────────────────── */ + + const handleInitiate = useCallback(async () => { + const mobileNumber = mobile.replace(/\D/g, ''); + + if (!isValidPhone(mobileNumber) || busy) { + return; + } + + setBusy(true); + setError(null); + + try { + const result = await authService.initiateMpinReset(mobileNumber, orgId); + + if (!mounted.current) return; + + setResetId(result.resetId); + setOtpExpiresAt(result.otpExpiresAt); + setAttemptsRemaining(result.attemptsRemaining); + setOtp(''); + setCooldown(RESTART_COOLDOWN_SECONDS); + setNow(Date.now()); + setStep('otp'); + toast('Code sent', 'success', 'Enter the 6-digit code we sent to your mobile.'); + } catch (caught) { + if (!mounted.current) return; + + const apiError = toApiError(caught); + + // Never registered an MPIN: this is a registration, not a reset. + if (apiError.code === 'MPIN_NOT_SET' && onMpinNotSet) { + toast(describeError(apiError.code).title, 'info', messageFor(apiError)); + onMpinNotSet(); + return; + } + + // CUSTOMER_NOT_FOUND, NO_ACCOUNTS_FOUND, CBS_UNAVAILABLE, CBS_ERROR and + // the rest all stay here, with the server's message and a retry. + setError(messageFor(apiError)); + } finally { + if (mounted.current) setBusy(false); + } + }, [busy, mobile, onMpinNotSet, orgId, toast]); + + /* ── Step 2: verify OTP ───────────────────────────────────────────────── */ + + const handleVerifyOtp = useCallback(async () => { + if (!resetId || !isValidOtp(otp) || busy) { + return; + } + + setBusy(true); + setError(null); + + try { + const result = await authService.verifyMpinResetOtp({ resetId, otp }); + + if (!mounted.current) return; + + // Only the token goes forward; the OTP and the reset id are done with. + setOtp(''); + setResetId(null); + setResetToken(result.resetToken); + setResetTokenExpiresAt(result.resetTokenExpiresAt); + setStep('newMpin'); + } catch (caught) { + if (!mounted.current) return; + + const apiError = toApiError(caught); + setOtp(''); + + // An expired or exhausted OTP cannot be retried — a fresh request can. + if (apiError.code === 'OTP_EXPIRED' || apiError.code === 'OTP_ATTEMPTS_EXCEEDED') { + restart(messageFor(apiError)); + return; + } + + if (attemptsRemaining !== null) { + setAttemptsRemaining(Math.max(0, attemptsRemaining - 1)); + } + + setError(messageFor(apiError)); + } finally { + if (mounted.current) setBusy(false); + } + }, [attemptsRemaining, busy, otp, resetId, restart]); + + /* ── Steps 3 + 4: encrypt and confirm ─────────────────────────────────── */ + + const handleConfirm = useCallback(async () => { + if (!resetToken || !isAcceptableNewMpin(mpin) || confirmMpin !== mpin || busy) { + return; + } + + // An expired token is refused anyway; say so without the round trip. + if (secondsUntil(resetTokenExpiresAt) === 0) { + restart('Your reset window has closed. Start again to get a new code.'); + return; + } + + setBusy(true); + setError(null); + + try { + // Fetches the current public key and encrypts {mpin, nonce, ts} fresh. + const result = await authService.confirmMpinReset({ resetToken, mpin }); + + if (!mounted.current) return; + + setResetToken(null); + setResetTokenExpiresAt(null); + setMpin(''); + setConfirmMpin(''); + + // A biometric sign-in holds the OLD MPIN behind the fingerprint, and it + // would now fail every time. Drop it; it can be re-enabled at sign-in. + await dispatch(disableBiometricSignIn()); + + toast( + 'MPIN reset', + 'success', + result.status === 'ACTIVE' + ? 'Please sign in with your new MPIN.' + : 'Sign in with your new MPIN.', + ); + onSuccess(); + } catch (caught) { + if (!mounted.current) return; + + const apiError = toApiError(caught); + + // A spent or expired token, or a different device: the reset is over. + if (apiError.code === 'OTP_NOT_VERIFIED' || apiError.code === 'DEVICE_NOT_BOUND') { + restart(messageFor(apiError)); + return; + } + + // VALIDATION_ERROR carries the rule that was broken — history, DOB, + // mobile digits. Stay here so a different MPIN can be tried. + setMpin(''); + setConfirmMpin(''); + setError(messageFor(apiError)); + } finally { + if (mounted.current) setBusy(false); + } + }, [busy, confirmMpin, dispatch, mpin, onSuccess, resetToken, resetTokenExpiresAt, restart, toast]); + + /* ── render ───────────────────────────────────────────────────────────── */ + + const otpSecondsLeft = step === 'otp' ? secondsUntil(otpExpiresAt) : null; + const otpExpired = otpSecondsLeft === 0; + + const mpinProblem = describeMpinProblem(mpin, mobile.replace(/\D/g, '')); + const confirmProblem = + confirmMpin.length === MPIN_LENGTH && confirmMpin !== mpin ? 'Both entries must match' : undefined; + + const stepIndex = STEPS.findIndex((item) => item.key === step); + + return ( + + item.label)} current={stepIndex} /> + + {error ? : null} + + {step === 'mobile' ? ( + <> + {notice ? : null} + + + { + setMobile(value.replace(/\D/g, '')); + setError(null); + }} + placeholder="98765 43210" + editable={!busy && !lockMobile} + /> + + Your bank checks this number, then texts a one-time code to it. + + + +