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 <noreply@anthropic.com>
This commit is contained in:
Abin Toppo
2026-09-29 17:54:43 +05:30
co-authored by Claude Opus 5.5
parent abb14d5768
commit f1def81eb7
50 changed files with 3039 additions and 385 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

+9 -1
View File
@@ -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 (
<View style={styles.container}>
{label ? (
<Text style={[theme.text.eyebrow, { color: theme.colors.textSecondary }]}>
<Text
style={[
theme.text.eyebrow,
{ color: onBrand ? theme.colors.onBrandPrimary : theme.colors.textSecondary },
]}
>
{label}
{required ? <Text style={{ color: theme.colors.danger }}> *</Text> : null}
</Text>
+1 -1
View File
@@ -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,
+1
View File
@@ -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',
+1
View File
@@ -239,6 +239,7 @@ export const DEFAULT_FEATURE_CONFIG: Record<FeatureKey, FeatureDefinition> = {
[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 },
@@ -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 (
<View
accessible
accessibilityRole="image"
accessibilityLabel="Apna Sahakari Bank Ltd."
style={[styles.plate, { backgroundColor: theme.colors.white, borderRadius: theme.radius.sm }]}
>
<Image
source={LOGO}
resizeMode="contain"
style={{ width, height: width / ASPECT_RATIO }}
/>
</View>
);
};
const styles = StyleSheet.create({
plate: {
alignSelf: 'center',
paddingHorizontal: 10,
paddingVertical: 6,
},
});
export const BrandLogo = memo(BrandLogoComponent);
+31 -5
View File
@@ -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<TextInput>(null);
@@ -58,7 +65,14 @@ export const CodeInput = ({
return (
<View style={styles.container}>
{label ? (
<Text style={[theme.text.eyebrow, { color: theme.colors.textSecondary }]}>{label}</Text>
<Text
style={[
theme.text.eyebrow,
{ color: onBrand ? theme.colors.onBrandPrimary : theme.colors.textSecondary },
]}
>
{label}
</Text>
) : 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 ? (
<View style={styles.message}>
<Ionicons name="alert-circle" size={14} color={theme.colors.danger} />
<Text style={[theme.text.caption, styles.messageText, { color: theme.colors.danger }]}>
<Ionicons
name="alert-circle"
size={14}
color={onBrand ? theme.colors.onBrandPrimary : theme.colors.danger}
/>
<Text
style={[
theme.text.caption,
styles.messageText,
{ color: onBrand ? theme.colors.onBrandPrimary : theme.colors.danger },
]}
>
{error}
</Text>
</View>
@@ -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 (
<View style={styles.container}>
<Ionicons name="shield-checkmark" size={26} color={theme.colors.onBrandPrimary} />
<Text
accessibilityRole="header"
style={[theme.text.h2, { color: theme.colors.onBrandPrimary }]}
>
BankCBS
</Text>
</View>
);
};
const styles = StyleSheet.create({
container: {
flexDirection: 'row',
alignItems: 'center',
gap: 8,
},
});
const LoginHeroComponent = () => <BrandLogo width={230} />;
export const LoginHero = memo(LoginHeroComponent);
@@ -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<Step>('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<string | null>(null);
// Secrets of the flow. Screen state only — see the note above.
const [resetId, setResetId] = useState<string | null>(null);
const [otpExpiresAt, setOtpExpiresAt] = useState<string | null>(null);
const [attemptsRemaining, setAttemptsRemaining] = useState<number | null>(null);
const [resetToken, setResetToken] = useState<string | null>(null);
const [resetTokenExpiresAt, setResetTokenExpiresAt] = useState<string | null>(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 (
<View style={styles.container}>
<Stepper steps={STEPS.map((item) => item.label)} current={stepIndex} />
{error ? <InfoBanner tone="danger" message={error} /> : null}
{step === 'mobile' ? (
<>
{notice ? <InfoBanner tone="info" message={notice} /> : null}
<Card style={styles.card}>
<Input
label="Registered mobile number"
prefix="+91"
keyboardType="number-pad"
maxLength={10}
value={mobile}
onChangeText={(value) => {
setMobile(value.replace(/\D/g, ''));
setError(null);
}}
placeholder="98765 43210"
editable={!busy && !lockMobile}
/>
<Text style={[theme.text.caption, { color: theme.colors.textMuted }]}>
Your bank checks this number, then texts a one-time code to it.
</Text>
</Card>
<Button
title="Continue"
onPress={handleInitiate}
disabled={!isValidPhone(mobile) || busy}
loading={busy}
style={styles.submit}
/>
</>
) : null}
{step === 'otp' ? (
<>
<Card style={styles.card}>
<CodeInput
label="6-digit code"
value={otp}
onChangeText={(value) => {
setOtp(value);
setError(null);
}}
length={6}
autoFocus
editable={!busy && !otpExpired}
onSubmit={handleVerifyOtp}
/>
<View style={styles.metaRow}>
<Text style={[theme.text.caption, { color: theme.colors.textSecondary }]}>
{otpSecondsLeft === null
? `Sent to +91 ${mobile}`
: otpExpired
? 'This code has expired'
: `Expires in ${formatCountdown(otpSecondsLeft)}`}
</Text>
{attemptsRemaining !== null ? (
<Text style={[theme.text.caption, { color: theme.colors.textSecondary }]}>
{`${attemptsRemaining} ${attemptsRemaining === 1 ? 'attempt' : 'attempts'} left`}
</Text>
) : null}
</View>
</Card>
<Button
title="Verify code"
onPress={handleVerifyOtp}
disabled={!isValidOtp(otp) || busy || otpExpired}
loading={busy}
/>
{/* No resend endpoint exists; a fresh initiate is the resend. */}
<Button
title={cooldown > 0 && !otpExpired ? `Send a new code in ${cooldown}s` : 'Send a new code'}
variant="ghost"
onPress={handleInitiate}
disabled={(cooldown > 0 && !otpExpired) || busy}
/>
</>
) : null}
{step === 'newMpin' ? (
<>
<Card style={styles.card}>
<CodeInput
label={`New ${MPIN_LENGTH}-digit MPIN`}
value={mpin}
onChangeText={(value) => {
setMpin(value);
setError(null);
}}
length={MPIN_LENGTH}
secure
autoFocus
editable={!busy}
error={mpinProblem}
/>
<CodeInput
label="Confirm new MPIN"
value={confirmMpin}
onChangeText={(value) => {
setConfirmMpin(value);
setError(null);
}}
length={MPIN_LENGTH}
secure
editable={!busy}
error={confirmProblem}
onSubmit={handleConfirm}
/>
</Card>
<InfoBanner
tone="info"
message="Avoid repeated or sequential digits, the last four digits of your mobile, and your last three MPINs. You'll be signed out everywhere after the reset."
/>
<Button
title="Reset MPIN"
onPress={handleConfirm}
disabled={!isAcceptableNewMpin(mpin) || confirmMpin !== mpin || busy}
loading={busy}
style={styles.submit}
/>
</>
) : null}
</View>
);
};
const styles = StyleSheet.create({
container: {
gap: 16,
flexGrow: 1,
},
card: {
padding: 16,
gap: 14,
},
metaRow: {
flexDirection: 'row',
justifyContent: 'space-between',
},
submit: {
marginTop: 'auto',
},
});
@@ -11,7 +11,7 @@ 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 { MPIN_LENGTH, describeMpinProblem, isAcceptableNewMpin } from '../utils/authValidation';
import { CodeInput } from '../components/CodeInput';
import type { AuthStackParamList } from '../../../navigation/types';
@@ -96,7 +96,7 @@ export default function CreateMpinScreen({ navigation }: Props) {
: undefined;
const canSubmit =
isValidMpin(mpin) && confirmMpin === mpin && !isLoading;
isAcceptableNewMpin(mpin) && confirmMpin === mpin && !isLoading;
const handleSubmit = () => {
if (!canSubmit) {
@@ -0,0 +1,43 @@
import React from 'react';
import { StyleSheet } from 'react-native';
import type { NativeStackScreenProps } from '@react-navigation/native-stack';
import { ScreenShell } from '../../../components';
import { useAppSelector } from '../../../app/hooks';
import { env } from '../../../config/env';
import { FeatureKey } from '../../../constants/featureKeys';
import { useFeature } from '../../../features';
import type { AuthStackParamList } from '../../../navigation/types';
import { selectLastProfile } from '../store/authSelectors';
import { MpinResetFlow } from '../components/MpinResetFlow';
type Props = Readonly<NativeStackScreenProps<AuthStackParamList, 'ForgotMpin'>>;
/** Forgot MPIN from sign-in: signed out, so success simply returns there. */
export default function ForgotMpinScreen({ navigation }: Props) {
const lastProfile = useAppSelector(selectLastProfile);
const registrationEnabled = useFeature(FeatureKey.AUTH_REGISTER);
return (
<ScreenShell
title="Forgot MPIN"
subtitle="Reset it with a code sent to your registered mobile"
withTabBar={false}
contentContainerStyle={styles.container}
>
<MpinResetFlow
orgId={lastProfile?.orgId ?? env.orgId}
initialMobile={lastProfile?.mobileNumber}
onSuccess={() => navigation.navigate('Login')}
onMpinNotSet={registrationEnabled ? () => navigation.navigate('Register') : undefined}
/>
</ScreenShell>
);
}
const styles = StyleSheet.create({
container: {
padding: 16,
flexGrow: 1,
},
});
+22 -8
View File
@@ -86,6 +86,7 @@ export default function LoginScreen({ navigation }: Props) {
const userRoleEnabled = useFeature(FeatureKey.AUTH_LOGIN_USER_ROLE);
const agentRoleEnabled = useFeature(FeatureKey.AUTH_LOGIN_AGENT_ROLE);
const forgotMpinEnabled = useFeature(FeatureKey.AUTH_LOGIN_FORGOT_PASSWORD);
const isLoading = signInStatus === 'loading';
@@ -420,6 +421,7 @@ export default function LoginScreen({ navigation }: Props) {
{!isReturningCustomer ? (
<Input
label="Registered mobile number"
onBrand
leftAccessory={<SimIcon size={18} color={theme.colors.textSecondary} />}
prefix="+91"
keyboardType="number-pad"
@@ -434,6 +436,7 @@ export default function LoginScreen({ navigation }: Props) {
<CodeInput
label="Enter MPIN"
onBrand
value={mpin}
onChangeText={handleMpinChange}
length={MPIN_LENGTH}
@@ -540,14 +543,25 @@ export default function LoginScreen({ navigation }: Props) {
</Text>
</FeatureGate>
{/* There is no forgot-MPIN or reset flow in the API — by policy,
not by omission — so the app says so instead of offering a
dead link. */}
<Text
style={[theme.text.caption, styles.centreText, { color: theme.colors.onBrandMuted }]}
>
Forgot your MPIN? Visit your branch to have it reset.
</Text>
{/* Customers only — an agent's credentials are the bank's to
reset. Switching the feature off falls back to the branch. */}
{activeRole === 'User' ? (
forgotMpinEnabled ? (
<Text
accessibilityRole="link"
onPress={() => navigation.navigate('ForgotMpin')}
style={[theme.text.bodySmall, styles.centreText, { color: theme.colors.onBrandPrimary }]}
>
Forgot MPIN?
</Text>
) : (
<Text
style={[theme.text.caption, styles.centreText, { color: theme.colors.onBrandMuted }]}
>
Forgot your MPIN? Visit your branch to have it reset.
</Text>
)
) : null}
</View>
</ScrollView>
</KeyboardAvoidingView>
@@ -13,9 +13,10 @@ import type { AuthStackParamList } from '../../../navigation/types';
type Props = Readonly<NativeStackScreenProps<AuthStackParamList, 'MpinLocked'>>;
/**
* A locked profile is a dead end by policy: there is no forgot-MPIN flow and no
* in-app reset anywhere in the API. Saying so plainly beats a retry button that
* can only ever fail.
* A locked profile is a dead end by policy. The forgot-MPIN flow is for a
* customer who does not know their MPIN; whether a reset also lifts a lock is
* the bank's rule, not the app's, so it is not offered here. Saying so plainly
* beats a retry button that can only ever fail.
*/
export default function MpinLockedScreen({ navigation }: Props) {
const dispatch = useAppDispatch();
+2 -25
View File
@@ -24,6 +24,7 @@ import { useTheme } from '../../../theme';
import { useApiErrorToast } from '../../../hooks/useApiErrorToast';
import { isValidPhone } from '../../../utils/validation';
import { simCard, type SimInfo, type SimSubscription } from '../../../services/device/simCard';
import { BrandLogo } from '../components/BrandLogo';
import { SimChooser } from '../components/SimChooser';
import { authActions } from '../store/authSlice';
import { selectLastProfile, selectRegistration } from '../store/authSelectors';
@@ -422,20 +423,7 @@ export default function RegisterScreen({ navigation }: Props) {
{/* ── Brand and purpose ──────────────────────────────────────────── */}
<Animated.View entering={FadeInDown.duration(420)} style={styles.heroBody}>
<View style={styles.brandRow}>
<View
style={[
styles.mark,
{
backgroundColor: theme.colors.onBrandSurfaceStrong,
borderRadius: theme.radius.sm,
},
]}
>
<Ionicons name="shield-checkmark" size={26} color={theme.colors.onBrandPrimary} />
</View>
<Text style={[theme.text.h1, { color: theme.colors.onBrandPrimary }]}>BankCBS</Text>
</View>
<BrandLogo width={270} />
<Text
accessibilityRole="header"
@@ -648,17 +636,6 @@ const styles = StyleSheet.create({
justifyContent: 'center',
},
heroBody: { gap: 10 },
brandRow: {
flexDirection: 'row',
alignItems: 'center',
gap: 9,
},
mark: {
width: 44,
height: 44,
alignItems: 'center',
justifyContent: 'center',
},
heroTitle: {
marginTop: 4,
},
+52 -21
View File
@@ -1,36 +1,19 @@
import { env } from '../../../config/env';
import { apiClient } from '../../../services/api/apiClient';
import { isCbsApiError } from '../../../services/api/cbsErrors';
import { endpoints } from '../../../services/api/endpoints';
import { deviceIdentity } from '../../../services/security/deviceIdentity';
import { mpinCrypto } from '../../../services/security/mpinCrypto';
import { mpinCrypto, withKeyRetry } from '../../../services/security/mpinCrypto';
import type {
CreateMpinResult,
LoginResult,
MpinResetConfirmResult,
MpinResetInitiateResult,
MpinResetVerifyResult,
SessionIdentity,
StartRegistrationResult,
VerifyOtpResult,
} from '../../../types/cbs';
/**
* `MALFORMED_REQUEST` on an MPIN call is what a stale or wrong public key looks
* like from the client — the server will not say more, because saying more
* would leak whether the profile exists. The key is fetched fresh on every
* encryption, so a retry picks up a rotated key; try exactly once more.
* A second failure is a real problem worth surfacing.
*/
const withKeyRetry = async <T>(call: () => Promise<T>): Promise<T> => {
try {
return await call();
} catch (error) {
if (isCbsApiError(error) && error.code === 'MALFORMED_REQUEST') {
return call();
}
throw error;
}
};
export const authService = {
/**
* Looks the customer up in their bank's core system, links every account it
@@ -127,6 +110,54 @@ export const authService = {
});
},
/**
* Forgot MPIN, step 1: checks the customer with their bank and sends an OTP.
* The device id sent here is the one `confirmMpinReset` must repeat —
* `deviceIdentity` is stable per install, so both read the same value.
*/
async initiateMpinReset(mobileNumber: string, orgId: string = env.orgId) {
const deviceId = await deviceIdentity.get();
const response = await apiClient.post<MpinResetInitiateResult>(endpoints.mpinReset.initiate, {
orgId,
mobileNumber,
deviceId,
});
return response.data;
},
/** Step 2: swaps the OTP for a short-lived, one-time reset token. */
async verifyMpinResetOtp({ resetId, otp }: { resetId: string; otp: string }) {
const response = await apiClient.post<MpinResetVerifyResult>(endpoints.mpinReset.verifyOtp, {
resetId,
otp,
});
return response.data;
},
/**
* Steps 3 and 4: fetches the current key, encrypts the new MPIN and spends
* the reset token on it. Encrypted inside the retry so a MALFORMED_REQUEST
* (rotated key, stale `ts`, reused nonce) is retried once with a fresh key
* and a fresh payload — never the same ciphertext twice.
*/
async confirmMpinReset({ resetToken, mpin }: { resetToken: string; mpin: string }) {
return withKeyRetry(async () => {
const deviceId = await deviceIdentity.get();
const encryptedMpin = await mpinCrypto.encryptMpin(mpin);
const response = await apiClient.post<MpinResetConfirmResult>(endpoints.mpinReset.confirm, {
resetToken,
deviceId,
encryptedMpin,
});
return response.data;
});
},
/** Who the current session belongs to. Doubles as a keep-alive. */
async getIdentity() {
const response = await apiClient.get<SessionIdentity>(endpoints.auth.me);
+20 -3
View File
@@ -1,15 +1,24 @@
import { MPIN_LENGTH } from '../../../utils/validation';
import { MPIN_LENGTH, isSequentialDigits } from '../../../utils/validation';
export {
MPIN_LENGTH,
isAcceptableNewMpin,
isRepeatedDigits,
isSequentialDigits,
isValidMpin,
isValidOtp,
isValidPhone,
} from '../../../utils/validation';
/** Inline copy for why an MPIN was rejected before it ever left the device. */
export const describeMpinProblem = (value: string): string | undefined => {
/**
* Inline copy for why a NEW MPIN was rejected before it ever left the device —
* for choosing one (creation, reset), not for entering one at sign-in.
*
* `mobileNumber`, when known, adds the backend's "not the last four digits of
* your mobile" rule, so the customer is told before a round trip rather than
* after one.
*/
export const describeMpinProblem = (value: string, mobileNumber?: string): string | undefined => {
if (value.length === 0) {
return undefined;
}
@@ -26,5 +35,13 @@ export const describeMpinProblem = (value: string): string | undefined => {
return 'Pick an MPIN that is not the same digit repeated';
}
if (isSequentialDigits(value)) {
return 'Pick an MPIN that is not a run of digits like 1234 or 4321';
}
if (mobileNumber && MPIN_LENGTH === 4 && mobileNumber.slice(-4) === value) {
return 'Pick an MPIN that is not the last four digits of your mobile number';
}
return undefined;
};
@@ -11,6 +11,7 @@ import Animated, {
useSharedValue,
} from 'react-native-reanimated';
import { useTheme } from '../../../theme';
import type { BankCard } from '../../../types/app';
import { CardItem } from './CardItem';
@@ -32,6 +33,7 @@ const CardCarouselComponent = ({
selectedIndex,
onSnapToCard,
}: CardCarouselProps) => {
const theme = useTheme();
const scrollX = useSharedValue(selectedIndex * ITEM_SIZE);
const initialScrollIndex = useMemo(
@@ -97,7 +99,9 @@ const CardCarouselComponent = ({
key={card.id}
style={[
styles.dot,
index === selectedIndex ? styles.dotActive : styles.dotInactive,
index === selectedIndex
? [styles.dotActive, { backgroundColor: theme.colors.primary }]
: { backgroundColor: theme.colors.cardBorderStrong },
]}
/>
))}
@@ -130,10 +134,6 @@ const styles = StyleSheet.create({
},
dotActive: {
width: 24,
backgroundColor: '#124577',
},
dotInactive: {
backgroundColor: 'rgba(15, 23, 42, 0.14)',
},
});
@@ -0,0 +1,137 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useGetFundTransferHistoryQuery } from '../../payments/services/paymentsApi';
import type {
BeneficiaryMode,
FundTransferHistoryItem,
FundTransferStatus,
} from '../../../types/cbs';
export const HISTORY_PAGE_SIZE = 10;
export interface HistoryFilters {
status: FundTransferStatus | 'ALL';
mode: BeneficiaryMode | 'ALL';
/** Inclusive, `yyyy-MM-dd`, sent only as a pair. */
fromDate?: string;
toDate?: string;
}
/**
* The transfer history, accumulated page by page — the same shape as
* `useStatement`, for the same reason: RTK Query caches one entry per page, and
* the list needs every page walked so far.
*
* Any change of filter starts again from page 0, as the guide requires; page 2
* of the old filter has no meaning under the new one. The next page is asked
* for only while the server says `last` is false.
*/
export const useFundTransferHistory = (filters: HistoryFilters) => {
const queryKey = `${filters.status}|${filters.mode}|${filters.fromDate ?? ''}|${filters.toDate ?? ''}`;
const [page, setPage] = useState(0);
const [pages, setPages] = useState<Record<number, FundTransferHistoryItem[]>>({});
const loadedKey = useRef(queryKey);
const { data, isLoading, isFetching, error, refetch } = useGetFundTransferHistoryQuery(
{ ...filters, page, size: HISTORY_PAGE_SIZE },
{ refetchOnMountOrArgChange: true },
);
// Reset before the new filter's first response lands, so the list is never
// briefly the old filter's rows under the new filter's chips.
if (loadedKey.current !== queryKey) {
loadedKey.current = queryKey;
if (page !== 0) {
setPage(0);
}
if (Object.keys(pages).length > 0) {
setPages({});
}
}
useEffect(() => {
if (!data) {
return;
}
setPages((current) =>
current[data.number] === data.content ? current : { ...current, [data.number]: data.content },
);
}, [data]);
/**
* Flattened in page order with repeats dropped. The server caches a page for
* about 30 seconds, so a row can shift across a page boundary mid-browse;
* `transferId` is the stable key that stops it appearing twice.
*/
const transfers = useMemo(() => {
const seen = new Set<string>();
const ordered: FundTransferHistoryItem[] = [];
Object.keys(pages)
.map(Number)
.sort((left, right) => left - right)
.forEach((index) => {
(pages[index] ?? []).forEach((item) => {
if (!seen.has(item.transferId)) {
seen.add(item.transferId);
ordered.push(item);
}
});
});
return ordered;
}, [pages]);
const hasMore = data ? !data.last : false;
const loadMore = useCallback(() => {
if (!hasMore || isFetching) {
return;
}
setPage((current) => current + 1);
}, [hasMore, isFetching]);
/**
* Pull-to-refresh: start again from page 0.
*
* The fresh page is written straight from the refetch's own result, not left
* to the `[data]` effect above. The server caches a page for ~30 seconds, so a
* refresh usually returns exactly what it returned before — and RTK Query's
* structural sharing then hands back the SAME `data` object, the effect never
* fires, and a list cleared before the request stayed empty. The current rows
* also stay on screen until the fresh page lands, rather than blanking.
*/
const reload = useCallback(() => {
if (page !== 0) {
// A different cache entry, so `data` genuinely changes and the effect
// refills the list.
setPages({});
setPage(0);
return;
}
refetch()
.unwrap()
.then((fresh) => setPages({ [fresh.number]: fresh.content }))
.catch(() => {
// Surfaced through `error`; the rows already on screen are kept.
});
}, [page, refetch]);
return {
transfers,
totalElements: data?.totalElements ?? 0,
hasMore,
isLoading: isLoading || (isFetching && transfers.length === 0),
isLoadingMore: isFetching && page > 0,
isRefreshing: isFetching && page === 0 && transfers.length > 0,
error,
loadMore,
reload,
};
};
@@ -0,0 +1,236 @@
import React, { useEffect } from 'react';
import { RefreshControl, ScrollView, StyleSheet, Text, View } from 'react-native';
import type { CompositeScreenProps } from '@react-navigation/native';
import type { BottomTabScreenProps } from '@react-navigation/bottom-tabs';
import type { NativeStackScreenProps } from '@react-navigation/native-stack';
import Ionicons from '@react-native-vector-icons/ionicons';
import { Button, Card, EmptyState, InfoBanner, Loader, ScreenShell } from '../../../components';
import { useTheme } from '../../../theme';
import { formatCurrency, formatDate, formatTime } from '../../../utils/formatters';
import type { HomeStackParamList, MainTabParamList } from '../../../navigation/types';
import type { ApiError } from '../../../types/api';
import { describeError } from '../../../services/api/cbsErrors';
import { useApiErrorToast } from '../../../hooks/useApiErrorToast';
import { useGetFundTransferDetailQuery } from '../../payments/services/paymentsApi';
import { MODE_LABEL, humaniseReason, statusCopy, toneColors } from '../utils/transferStatus';
type Props = CompositeScreenProps<
NativeStackScreenProps<HomeStackParamList, 'TransferDetail'>,
BottomTabScreenProps<MainTabParamList>
>;
const when = (iso: string | null) => (iso ? `${formatDate(iso)}, ${formatTime(iso)}` : null);
export default function TransferDetailScreen({ navigation, route }: Props) {
const theme = useTheme();
const showError = useApiErrorToast();
const { transferId } = route.params;
// Always fetched fresh on open: reopening the screen is one of the two ways
// the guide says a pending transfer should re-check its state.
const { data, isLoading, isFetching, error, refetch } = useGetFundTransferDetailQuery(transferId, {
refetchOnMountOrArgChange: true,
});
const apiError = error as ApiError | undefined;
// A transfer that is not on this profile: close the detail and say why.
useEffect(() => {
if (apiError?.code === 'TRANSACTION_NOT_FOUND') {
showError(apiError);
navigation.goBack();
}
}, [apiError, navigation, showError]);
if (isLoading) {
return (
<ScreenShell title="Transfer details">
<Loader />
</ScreenShell>
);
}
if (!data) {
const copy = describeError(apiError?.code);
return (
<ScreenShell title="Transfer details">
<EmptyState
title={copy.title}
description={apiError?.message || copy.description}
actionLabel="Try again"
onAction={refetch}
/>
</ScreenShell>
);
}
const status = statusCopy(data.status);
const colors = toneColors(status.tone, theme.colors);
const showReason =
Boolean(data.failureReason) && (data.status === 'FAILED' || data.status === 'MANUAL_REVIEW');
const rows: Array<[string, string | null | undefined]> = [
['Paid to', data.payeeName],
[data.mode === 'P2P' ? 'Mobile' : 'Account', data.payeeDisplay],
['IFSC', data.beneficiaryIfsc],
['MMID', data.beneficiaryMmid],
['Type', `IMPS · ${MODE_LABEL[data.mode] ?? data.mode}`],
['Remark', data.remark],
['Initiated', when(data.createdAt)],
['Last updated', when(data.updatedAt)],
];
return (
<ScreenShell title="Transfer details" scrollable={false}>
<ScrollView
showsVerticalScrollIndicator={false}
contentContainerStyle={styles.content}
refreshControl={
<RefreshControl
refreshing={isFetching}
onRefresh={refetch}
tintColor={theme.colors.primary}
colors={[theme.colors.primary]}
/>
}
>
{/* ── the outcome ───────────────────────────────────────────────── */}
<Card style={styles.hero}>
<View style={[styles.statusIcon, { backgroundColor: colors.surface }]}>
<Ionicons name={status.icon} size={30} color={colors.tone} />
</View>
<Text style={[theme.text.balance, { color: theme.colors.textPrimary }]}>
{formatCurrency(data.amount)}
</Text>
<View style={[styles.badge, { backgroundColor: colors.surface, borderColor: colors.border }]}>
{/* `simple` break strategy: Android's default `highQuality` measures
a custom font as fitting on one line, then wraps it on draw, so
the last word ("pending") was clipped outside the pill. */}
<Text
textBreakStrategy="simple"
style={[theme.text.bodyStrong, styles.badgeText, { color: colors.tone }]}
>
{status.label}
</Text>
</View>
{showReason ? (
<Text style={[theme.text.body, styles.centre, { color: theme.colors.danger }]}>
{humaniseReason(data.failureReason as string)}
</Text>
) : null}
</Card>
{data.status === 'PENDING_RECONCILIATION' || data.status === 'PROCESSING' ? (
<InfoBanner
tone="warning"
message="Your bank is still confirming this transfer. Don't send it again — refresh in a little while to see the final status."
/>
) : null}
{data.status === 'PENDING_OTP' ? (
<InfoBanner
tone="info"
message="This transfer was never confirmed with an OTP, so no money has left your account."
/>
) : null}
{data.status === 'MANUAL_REVIEW' ? (
<InfoBanner
tone="warning"
message="Your bank is reviewing this transfer. Contact support and quote the reference below if you need help."
/>
) : null}
{/* ── the reference the customer quotes ─────────────────────────── */}
<Card style={styles.section}>
<Text style={[theme.text.eyebrow, { color: theme.colors.textMuted }]}>Reference</Text>
<Text selectable style={[theme.text.code, { color: theme.colors.textPrimary }]}>
{data.referenceId}
</Text>
<Text style={[theme.text.caption, { color: theme.colors.textMuted }]}>
Press and hold to copy
</Text>
</Card>
<Card style={styles.section}>
{rows
.filter(([, value]) => Boolean(value))
.map(([label, value]) => (
<View key={label} style={styles.detailRow}>
<Text style={[theme.text.body, { color: theme.colors.textSecondary }]}>{label}</Text>
<Text
style={[theme.text.bodyStrong, styles.detailValue, { color: theme.colors.textPrimary }]}
>
{value}
</Text>
</View>
))}
</Card>
{status.refreshable ? (
<Button
title={isFetching ? 'Checking…' : 'Refresh status'}
variant="secondary"
onPress={refetch}
disabled={isFetching}
/>
) : null}
{data.status === 'MANUAL_REVIEW' || data.status === 'FAILED' ? (
<Button
title="Get help"
variant="ghost"
onPress={() => navigation.navigate('Profile', { screen: 'HelpSupport' })}
/>
) : null}
</ScrollView>
</ScreenShell>
);
}
const styles = StyleSheet.create({
content: {
gap: 14,
paddingBottom: 24,
},
hero: {
alignItems: 'center',
gap: 10,
paddingVertical: 24,
},
statusIcon: {
width: 60,
height: 60,
borderRadius: 30,
alignItems: 'center',
justifyContent: 'center',
},
badgeText: {
textAlign: 'center',
},
badge: {
maxWidth: '100%',
paddingHorizontal: 12,
paddingVertical: 4,
borderRadius: 999,
borderWidth: 1,
},
centre: {
textAlign: 'center',
},
section: {
gap: 6,
padding: 16,
},
detailRow: {
flexDirection: 'row',
justifyContent: 'space-between',
gap: 16,
paddingVertical: 6,
},
detailValue: {
flex: 1,
textAlign: 'right',
},
});
@@ -0,0 +1,445 @@
import React, { useCallback, useMemo, useState } from 'react';
import {
ActivityIndicator,
FlatList,
Pressable,
RefreshControl,
ScrollView,
StyleSheet,
Text,
View,
} from 'react-native';
import type { NativeStackScreenProps } from '@react-navigation/native-stack';
import Ionicons from '@react-native-vector-icons/ionicons';
import DatePicker from 'react-native-date-picker';
import {
AppModal as Modal,
Button,
Card,
EmptyState,
Loader,
ScreenShell,
SegmentedControl,
} from '../../../components';
import { useTheme } from '../../../theme';
import { formatCurrency, formatDate, formatTime } from '../../../utils/formatters';
import type { HomeStackParamList } from '../../../navigation/types';
import type { ApiError } from '../../../types/api';
import type { BeneficiaryMode, FundTransferHistoryItem, FundTransferStatus } from '../../../types/cbs';
import { describeError } from '../../../services/api/cbsErrors';
import { toStatementDate } from '../../home/hooks/useStatement';
import { useFundTransferHistory, type HistoryFilters } from '../hooks/useFundTransferHistory';
import { humaniseReason, statusCopy, toneColors } from '../utils/transferStatus';
type Props = NativeStackScreenProps<HomeStackParamList, 'TransferHistory'>;
const STATUS_FILTERS: ReadonlyArray<{ value: FundTransferStatus | 'ALL'; label: string }> = [
{ value: 'ALL', label: 'All' },
{ value: 'SUCCESS', label: 'Successful' },
{ value: 'FAILED', label: 'Failed' },
{ value: 'PROCESSING', label: 'In progress' },
{ value: 'PENDING_RECONCILIATION', label: 'Pending' },
{ value: 'MANUAL_REVIEW', label: 'Under review' },
];
const MODE_FILTERS: ReadonlyArray<{ value: BeneficiaryMode | 'ALL'; label: string }> = [
{ value: 'ALL', label: 'All' },
{ value: 'P2A', label: 'Account' },
{ value: 'P2P', label: 'Mobile' },
];
function TransferRow({
item,
onPress,
}: {
item: FundTransferHistoryItem;
onPress: (item: FundTransferHistoryItem) => void;
}) {
const theme = useTheme();
const copy = statusCopy(item.status);
const colors = toneColors(copy.tone, theme.colors);
// Shown only for failed and review states, and only when there is one.
const showReason =
Boolean(item.failureReason) && (item.status === 'FAILED' || item.status === 'MANUAL_REVIEW');
return (
<Pressable
accessibilityRole="button"
accessibilityLabel={`${item.payeeName ?? 'Transfer'}, ${formatCurrency(item.amount)}, ${copy.label}`}
onPress={() => onPress(item)}
>
<Card style={styles.card}>
<View style={styles.row}>
<View style={[styles.iconWrap, { backgroundColor: colors.surface }]}>
<Ionicons name={copy.icon} size={20} color={colors.tone} />
</View>
<View style={styles.copy}>
<Text
numberOfLines={1}
style={[theme.text.bodyStrong, { color: theme.colors.textPrimary }]}
>
{item.payeeName || 'Payee'}
</Text>
{item.payeeDisplay ? (
<Text style={[theme.text.caption, { color: theme.colors.textSecondary }]}>
{item.payeeDisplay}
</Text>
) : null}
<Text style={[theme.text.caption, { color: theme.colors.textMuted }]}>
{`${formatDate(item.createdAt)} · ${formatTime(item.createdAt)}`}
</Text>
{showReason ? (
<Text style={[theme.text.caption, { color: theme.colors.danger }]}>
{humaniseReason(item.failureReason as string)}
</Text>
) : null}
</View>
<View style={styles.trailing}>
<Text style={[theme.text.bodyStrong, { color: theme.colors.textPrimary }]}>
{formatCurrency(item.amount)}
</Text>
<View
style={[
styles.badge,
{ backgroundColor: colors.surface, borderColor: colors.border },
]}
>
{/* See TransferDetailScreen: `simple` stops Android clipping the
last word of a label drawn in a custom font. */}
<Text
textBreakStrategy="simple"
style={[theme.text.caption, styles.badgeText, { color: colors.tone }]}
>
{copy.label}
</Text>
</View>
</View>
</View>
</Card>
</Pressable>
);
}
export default function TransferHistoryScreen({ navigation }: Props) {
const theme = useTheme();
const [filters, setFilters] = useState<HistoryFilters>({ status: 'ALL', mode: 'ALL' });
const [rangeOpen, setRangeOpen] = useState(false);
const [pickerOpen, setPickerOpen] = useState<'from' | 'to' | null>(null);
const [tempFrom, setTempFrom] = useState(() => {
const date = new Date();
date.setDate(date.getDate() - 30);
return date;
});
const [tempTo, setTempTo] = useState(() => new Date());
const [rangeError, setRangeError] = useState<string | null>(null);
const { transfers, totalElements, hasMore, isLoading, isLoadingMore, isRefreshing, error, loadMore, reload } =
useFundTransferHistory(filters);
const apiError = error as ApiError | undefined;
const hasRange = Boolean(filters.fromDate && filters.toDate);
const openDetail = useCallback(
(item: FundTransferHistoryItem) => navigation.navigate('TransferDetail', { transferId: item.transferId }),
[navigation],
);
const applyRange = useCallback(() => {
if (tempFrom.getTime() > tempTo.getTime()) {
setRangeError('The start date cannot be after the end date.');
return;
}
setRangeError(null);
setRangeOpen(false);
setFilters((current) => ({
...current,
fromDate: toStatementDate(tempFrom),
toDate: toStatementDate(tempTo),
}));
}, [tempFrom, tempTo]);
const clearRange = useCallback(() => {
setRangeOpen(false);
setRangeError(null);
setFilters((current) => ({ ...current, fromDate: undefined, toDate: undefined }));
}, []);
const subtitle = useMemo(() => {
if (isLoading) return 'Loading your transfers';
return `${totalElements} ${totalElements === 1 ? 'transfer' : 'transfers'}`;
}, [isLoading, totalElements]);
const listHeader = (
<View style={styles.filters}>
<ScrollView horizontal showsHorizontalScrollIndicator={false} contentContainerStyle={styles.chips}>
{STATUS_FILTERS.map((option) => {
const active = filters.status === option.value;
return (
<Pressable
key={option.value}
accessibilityRole="button"
accessibilityState={{ selected: active }}
onPress={() => setFilters((current) => ({ ...current, status: option.value }))}
style={[
styles.chip,
{
borderRadius: theme.radius.pill,
backgroundColor: active ? theme.colors.primary : theme.colors.surface,
borderColor: active ? theme.colors.primary : theme.colors.cardBorder,
},
]}
>
<Text
style={[
theme.text.bodySmall,
{ color: active ? theme.colors.textOnBrand : theme.colors.textPrimary },
]}
>
{option.label}
</Text>
</Pressable>
);
})}
</ScrollView>
<SegmentedControl
options={MODE_FILTERS}
value={filters.mode}
onChange={(mode) => setFilters((current) => ({ ...current, mode }))}
accessibilityLabel="Transfer type"
/>
<Pressable
accessibilityRole="button"
onPress={() => setRangeOpen(true)}
style={[
styles.rangeButton,
{
backgroundColor: theme.colors.surface,
borderColor: hasRange ? theme.colors.primary : theme.colors.cardBorder,
borderRadius: theme.radius.md,
},
]}
>
<Ionicons name="calendar-outline" size={18} color={theme.colors.primary} />
<Text style={[theme.text.body, styles.rangeText, { color: theme.colors.textPrimary }]}>
{hasRange
? `${formatDate(filters.fromDate as string)} – ${formatDate(filters.toDate as string)}`
: 'Any date'}
</Text>
<Ionicons name="chevron-down" size={16} color={theme.colors.textMuted} />
</Pressable>
</View>
);
const renderEmpty = () => {
if (isLoading) {
return <Loader />;
}
if (apiError) {
const copy = describeError(apiError.code);
return (
<EmptyState
title={copy.title}
description={apiError.message || copy.description}
actionLabel="Try again"
onAction={reload}
/>
);
}
return (
<EmptyState
title="No transfers found"
description={
filters.status === 'ALL' && filters.mode === 'ALL' && !hasRange
? 'Transfers you make from the app will appear here.'
: 'Nothing matches these filters. Try widening them.'
}
/>
);
};
return (
<ScreenShell title="History" subtitle={subtitle} scrollable={false}>
<FlatList
data={transfers}
keyExtractor={(item) => item.transferId}
renderItem={({ item }) => <TransferRow item={item} onPress={openDetail} />}
ListHeaderComponent={listHeader}
ListEmptyComponent={renderEmpty}
ListFooterComponent={
isLoadingMore ? (
<ActivityIndicator style={styles.footer} color={theme.colors.primary} />
) : null
}
ItemSeparatorComponent={Separator}
onEndReached={hasMore ? loadMore : undefined}
onEndReachedThreshold={0.4}
refreshControl={
<RefreshControl
refreshing={isRefreshing}
onRefresh={reload}
tintColor={theme.colors.primary}
colors={[theme.colors.primary]}
/>
}
showsVerticalScrollIndicator={false}
contentContainerStyle={styles.listContent}
/>
<Modal visible={rangeOpen} onClose={() => setRangeOpen(false)} title="Filter by date">
<View style={styles.modalBody}>
{(['from', 'to'] as const).map((which) => (
<View key={which} style={styles.dateField}>
<Text style={[theme.text.eyebrow, { color: theme.colors.textSecondary }]}>
{which === 'from' ? 'From' : 'To'}
</Text>
<Pressable
accessibilityRole="button"
onPress={() => setPickerOpen(which)}
style={[
styles.rangeButton,
{
backgroundColor: theme.colors.surfaceMuted,
borderColor: theme.colors.cardBorder,
borderRadius: theme.radius.md,
},
]}
>
<Ionicons name="calendar-outline" size={18} color={theme.colors.primary} />
<Text style={[theme.text.body, styles.rangeText, { color: theme.colors.textPrimary }]}>
{formatDate(toStatementDate(which === 'from' ? tempFrom : tempTo))}
</Text>
</Pressable>
</View>
))}
{rangeError ? (
<Text style={[theme.text.caption, { color: theme.colors.danger }]}>{rangeError}</Text>
) : null}
<View style={styles.modalActions}>
<Button title="Apply" onPress={applyRange} style={styles.modalButton} />
<Button
title={hasRange ? 'Clear dates' : 'Cancel'}
variant="secondary"
onPress={hasRange ? clearRange : () => setRangeOpen(false)}
style={styles.modalButton}
/>
</View>
</View>
<DatePicker
modal
open={pickerOpen !== null}
date={pickerOpen === 'to' ? tempTo : tempFrom}
maximumDate={new Date()}
mode="date"
title={pickerOpen === 'to' ? 'Select end date' : 'Select start date'}
onConfirm={(date) => {
if (pickerOpen === 'to') setTempTo(date);
else setTempFrom(date);
setPickerOpen(null);
setRangeError(null);
}}
onCancel={() => setPickerOpen(null)}
/>
</Modal>
</ScreenShell>
);
}
const Separator = () => <View style={styles.separator} />;
const styles = StyleSheet.create({
listContent: {
paddingBottom: 24,
flexGrow: 1,
},
filters: {
gap: 12,
marginBottom: 16,
},
chips: {
gap: 8,
},
chip: {
paddingHorizontal: 14,
paddingVertical: 8,
borderWidth: 1,
},
rangeButton: {
flexDirection: 'row',
alignItems: 'center',
gap: 10,
paddingHorizontal: 14,
paddingVertical: 12,
borderWidth: 1,
},
rangeText: {
flex: 1,
},
separator: {
height: 10,
},
card: {
padding: 14,
},
row: {
flexDirection: 'row',
alignItems: 'flex-start',
gap: 12,
},
iconWrap: {
width: 40,
height: 40,
borderRadius: 20,
alignItems: 'center',
justifyContent: 'center',
},
copy: {
flex: 1,
gap: 2,
},
trailing: {
alignItems: 'flex-end',
gap: 6,
},
badgeText: {
textAlign: 'right',
},
badge: {
maxWidth: 150,
paddingHorizontal: 8,
paddingVertical: 2,
borderRadius: 999,
borderWidth: 1,
},
footer: {
marginVertical: 16,
},
modalBody: {
gap: 14,
},
dateField: {
gap: 6,
},
modalActions: {
flexDirection: 'row',
gap: 10,
marginTop: 6,
},
modalButton: {
flex: 1,
},
});
@@ -0,0 +1,70 @@
import type Ionicons from '@react-native-vector-icons/ionicons';
import type React from 'react';
import type { FundTransferStatus } from '../../../types/cbs';
import type { useTheme } from '../../../theme';
type IconName = React.ComponentProps<typeof Ionicons>['name'];
type Colors = ReturnType<typeof useTheme>['colors'];
export type StatusTone = 'success' | 'danger' | 'warning' | 'info';
interface StatusCopy {
label: string;
icon: IconName;
tone: StatusTone;
/**
* True for a state the server may still move on its own — the detail screen
* offers a refresh for these, and never shows them as final.
*/
refreshable: boolean;
}
/**
* The labels are the backend guide's own recommended wording. A pending
* reconciliation deliberately reads as neither success nor failure: the money
* may or may not have moved, and only the bank's ReqChkTxn can say which.
*/
export const TRANSFER_STATUS: Record<FundTransferStatus, StatusCopy> = {
PENDING_VALIDATION: { label: 'Validating beneficiary', icon: 'hourglass-outline', tone: 'info', refreshable: true },
PENDING_OTP: { label: 'Awaiting OTP confirmation', icon: 'key-outline', tone: 'info', refreshable: true },
PROCESSING: { label: 'Transfer in progress', icon: 'sync-outline', tone: 'info', refreshable: true },
SUCCESS: { label: 'Transfer successful', icon: 'checkmark-circle-outline', tone: 'success', refreshable: false },
FAILED: { label: 'Transfer failed', icon: 'close-circle-outline', tone: 'danger', refreshable: false },
PENDING_RECONCILIATION: { label: 'Transaction status pending', icon: 'time-outline', tone: 'warning', refreshable: true },
MANUAL_REVIEW: { label: 'Under bank review', icon: 'shield-outline', tone: 'warning', refreshable: false },
};
/** An unknown status the server added later still renders, as a neutral pending one. */
export const statusCopy = (status: string): StatusCopy =>
TRANSFER_STATUS[status as FundTransferStatus] ?? {
label: status.replace(/_/g, ' ').toLowerCase(),
icon: 'help-circle-outline',
tone: 'info',
refreshable: true,
};
export const toneColors = (tone: StatusTone, colors: Colors) => {
switch (tone) {
case 'success':
return { tone: colors.success, surface: colors.successSurface, border: colors.successBorder };
case 'danger':
return { tone: colors.danger, surface: colors.dangerSurface, border: colors.dangerBorder };
case 'warning':
return { tone: colors.warning, surface: colors.warningSurface, border: colors.warningBorder };
default:
return { tone: colors.info, surface: colors.infoSurface, border: colors.infoBorder };
}
};
/** `PAYEE_NOT_VERIFIED` → "Payee not verified". */
export const humaniseReason = (reason: string) => {
const words = reason.replace(/_/g, ' ').toLowerCase();
return words.charAt(0).toUpperCase() + words.slice(1);
};
export const MODE_LABEL: Record<string, string> = {
P2A: 'To account',
P2P: 'To mobile',
};
+24 -4
View File
@@ -129,11 +129,31 @@ export const useStatement = ({ accountNumber, fromDate, toDate }: UseStatementAr
setPage((current) => current + 1);
}, [hasMore, isFetching]);
/**
* Start again from page 0.
*
* On page 0 the fresh rows are written from the refetch's own result rather
* than left to the `[data]` effect: an identical response comes back as the
* SAME `data` object under RTK Query's structural sharing, the effect never
* fires, and a list cleared first would stay empty. Off page 0, moving back
* to it is a different cache entry, so the effect refills the list — and
* refetching here would only re-request the page being left.
*/
const reload = useCallback(() => {
setPages({});
setPage(0);
void refetch();
}, [refetch]);
if (page !== 0) {
setPages({});
setPage(0);
return;
}
refetch()
.unwrap()
.then((fresh) => setPages({ [fresh.page]: fresh.content }))
.catch(() => {
// Surfaced through `error`; whatever is on screen is kept.
});
}, [page, refetch]);
return {
transactions,
+16
View File
@@ -282,6 +282,22 @@ export default function HomeScreen({ navigation }: Props) {
</Pressable>
</FeatureGate>
<Pressable
accessibilityRole="button"
accessibilityLabel="Transfer history"
style={[
styles.iconButton,
theme.elevation.level1,
{
backgroundColor: theme.colors.surface,
borderColor: theme.colors.cardBorder,
},
]}
onPress={() => navigation.navigate('TransferHistory')}
>
<Ionicons name="time-outline" size={20} color={theme.colors.textPrimary} />
</Pressable>
<FeatureGate feature={FeatureKey.SEARCH_GLOBAL}>
<Pressable
accessibilityRole="button"
@@ -0,0 +1,545 @@
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { StyleSheet, Text, View } from 'react-native';
import { Button, Card, InfoBanner, Stepper } from '../../../components';
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 { MPIN_LENGTH, isValidOtp } from '../../../utils/validation';
import { CodeInput } from '../../auth/components/CodeInput';
import { tpinService } from '../services/tpinService';
import { TPIN_LENGTH, describeTpinProblem, isAcceptableNewTpin } from '../utils/tpinValidation';
interface TpinCreateFlowProps {
/**
* The session's registered mobile. Feeds the "not the last four digits of
* your mobile" rule and the OTP copy; never sent — the server reads it from
* the session.
*/
mobileNumber?: string;
/** Shown above the first step, e.g. why a transfer sent the customer here. */
notice?: string;
/** Runs once the TPIN is active and every secret here is cleared. */
onSuccess: () => void;
/** Leaves the flow after TPIN_ALREADY_SET, when there is nothing to create. */
onExit: () => void;
}
type Step = 'mpin' | 'otp' | 'tpin' | 'exists';
/** How long before "send a new code" is offered. There is no resend endpoint. */
const RESTART_COOLDOWN_SECONDS = 30;
const OTP_LENGTH = 6;
const STEPS: ReadonlyArray<{ key: Step; label: string }> = [
{ key: 'mpin', label: 'Verify MPIN' },
{ key: 'otp', label: 'OTP' },
{ key: 'tpin', label: 'New TPIN' },
];
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')}`;
const messageFor = (apiError: ApiError) =>
apiError.message || describeError(apiError.code).description;
/**
* Creating the TPIN, end to end: current MPIN → OTP → new TPIN.
*
* Shared by Services' Create TPIN and the transfer screen's TPIN_NOT_SET
* detour, which differ only in where the customer goes afterwards — so the
* screens own the navigation, and this owns the flow.
*
* ── WHY LOCAL STATE, AND WHY NO REDUX ───────────────────────────────────────
* The MPIN, the OTP, the `tpinCreateId`, the one-time `tpinCreateToken` and
* the TPIN itself must live in memory only, and be gone on back-navigation,
* restart, failure and success. Component state gives exactly that — leaving
* the screen unmounts it — and keeps them out of the Redux store, where dev
* tooling would record them. Each is also cleared the moment its step is done
* with it. Nothing here is persisted or logged.
*/
export const TpinCreateFlow = ({ mobileNumber, notice, onSuccess, onExit }: TpinCreateFlowProps) => {
const theme = useTheme();
const toast = useToast();
const [step, setStep] = useState<Step>('mpin');
const [mpin, setMpin] = useState('');
const [otp, setOtp] = useState('');
const [tpin, setTpin] = useState('');
const [confirmTpin, setConfirmTpin] = useState('');
const [busy, setBusy] = useState(false);
/** A failure about the flow as a whole, shown as a banner. */
const [error, setError] = useState<string | null>(null);
/** A failure about the field on screen, shown under it. */
const [fieldError, setFieldError] = useState<string | null>(null);
// Secrets of the flow. Screen state only — see the note above.
const [tpinCreateId, setTpinCreateId] = useState<string | null>(null);
const [otpExpiresAt, setOtpExpiresAt] = useState<string | null>(null);
const [attemptsRemaining, setAttemptsRemaining] = useState<number | null>(null);
const [tpinCreateToken, setTpinCreateToken] = useState<string | null>(null);
const [tokenExpiresAt, setTokenExpiresAt] = useState<string | null>(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]);
const clearSecrets = useCallback(() => {
setTpinCreateId(null);
setOtpExpiresAt(null);
setAttemptsRemaining(null);
setTpinCreateToken(null);
setTokenExpiresAt(null);
setMpin('');
setOtp('');
setTpin('');
setConfirmTpin('');
}, []);
/**
* Throws the whole attempt away and returns to the MPIN. An OTP can only be
* reissued by a fresh initiate, and initiate needs the MPIN every time.
*/
const restart = useCallback(
(message?: string) => {
clearSecrets();
setFieldError(null);
setStep('mpin');
setError(message ?? null);
},
[clearSecrets],
);
/** TPIN_ALREADY_SET: nothing to create, so the flow stops for good. */
const endAsExisting = useCallback(() => {
clearSecrets();
setFieldError(null);
setError(null);
setStep('exists');
}, [clearSecrets]);
/**
* The default failure path. VALIDATION_ERROR's field map is split: the entry
* for the field on screen goes under it, the rest go in the banner. Any
* other failure is a banner with the server's message.
*/
const showFailure = useCallback((apiError: ApiError, field: string) => {
const fields = apiError.fieldErrors ?? {};
const own = fields[field];
const others = Object.entries(fields)
.filter(([key]) => key !== field)
.map(([, message]) => message);
setFieldError(own ?? null);
setError(others.length > 0 ? others.join('\n') : own ? null : messageFor(apiError));
}, []);
/* ── Step 1: current MPIN → initiate ──────────────────────────────────── */
const handleInitiate = useCallback(async () => {
if (mpin.length !== MPIN_LENGTH || busy) {
return;
}
setBusy(true);
setError(null);
setFieldError(null);
try {
// Fetches the current public key and encrypts {mpin, nonce, ts} fresh.
const result = await tpinService.initiateCreate(mpin);
if (!mounted.current) return;
setMpin('');
setTpinCreateId(result.tpinCreateId);
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 registered mobile.');
} catch (caught) {
if (!mounted.current) return;
const apiError = toApiError(caught);
// The MPIN is spent on this attempt whatever the answer.
setMpin('');
switch (apiError.code) {
case 'SESSION_INVALID':
// The interceptor has already signed the customer out.
return;
case 'TPIN_ALREADY_SET':
endAsExisting();
return;
case 'INVALID_CREDENTIALS':
setFieldError('That MPIN is incorrect. Enter the MPIN you sign in with.');
return;
default:
// MPIN_LOCKED, CUSTOMER_NOT_FOUND, CBS_UNAVAILABLE and the rest stay
// here, with the server's message and a retry.
showFailure(apiError, 'encryptedMpin');
}
} finally {
if (mounted.current) setBusy(false);
}
}, [busy, endAsExisting, mpin, showFailure, toast]);
/* ── Step 2: verify OTP ───────────────────────────────────────────────── */
const handleVerifyOtp = useCallback(async () => {
if (!tpinCreateId || !isValidOtp(otp) || busy) {
return;
}
setBusy(true);
setError(null);
setFieldError(null);
try {
const result = await tpinService.verifyCreateOtp({ tpinCreateId, otp });
if (!mounted.current) return;
// Only the token goes forward; the OTP and the create id are done with.
setOtp('');
setTpinCreateId(null);
setOtpExpiresAt(null);
setAttemptsRemaining(null);
setTpinCreateToken(result.tpinCreateToken);
setTokenExpiresAt(result.tokenExpiresAt);
setStep('tpin');
} catch (caught) {
if (!mounted.current) return;
const apiError = toApiError(caught);
setOtp('');
switch (apiError.code) {
case 'SESSION_INVALID':
return;
case 'TPIN_ALREADY_SET':
endAsExisting();
return;
// An expired or exhausted OTP cannot be retried — a fresh request can.
case 'OTP_EXPIRED':
restart('That code expired. Enter your MPIN again to get a new one.');
return;
case 'OTP_ATTEMPTS_EXCEEDED':
restart('Too many wrong codes. Enter your MPIN again to get a new one.');
return;
case 'OTP_INVALID': {
// The OTP screen stays, one attempt lighter.
const remaining =
attemptsRemaining === null ? null : Math.max(0, attemptsRemaining - 1);
setAttemptsRemaining(remaining);
setFieldError(
remaining
? `That code is incorrect. ${remaining} ${remaining === 1 ? 'attempt' : 'attempts'} left.`
: 'That code is incorrect.',
);
return;
}
default:
showFailure(apiError, 'otp');
}
} finally {
if (mounted.current) setBusy(false);
}
}, [attemptsRemaining, busy, endAsExisting, otp, restart, showFailure, tpinCreateId]);
/** No resend endpoint: a new code means a new initiate, MPIN and all. */
const handleNewCode = useCallback(() => {
restart();
toast('Enter your MPIN again', 'info', 'We need it each time we send you a new code.');
}, [restart, toast]);
/* ── Step 3: encrypt the new TPIN and confirm ─────────────────────────── */
const handleConfirm = useCallback(async () => {
if (
!tpinCreateToken ||
!isAcceptableNewTpin(tpin, mobileNumber) ||
confirmTpin !== tpin ||
busy
) {
return;
}
// An expired token is refused anyway; say so without the round trip.
if (secondsUntil(tokenExpiresAt) === 0) {
restart('Your time to set a TPIN ran out. Enter your MPIN again to get a new code.');
return;
}
setBusy(true);
setError(null);
setFieldError(null);
try {
// Fetches the current public key and encrypts {tpin, nonce, ts} fresh.
await tpinService.confirmCreate({ tpinCreateToken, tpin });
if (!mounted.current) return;
clearSecrets();
toast('TPIN created', 'success', 'Use it to authorise your fund transfers.');
onSuccess();
} catch (caught) {
if (!mounted.current) return;
const apiError = toApiError(caught);
setTpin('');
setConfirmTpin('');
switch (apiError.code) {
case 'SESSION_INVALID':
return;
case 'TPIN_ALREADY_SET':
endAsExisting();
return;
// A spent or expired token: this attempt is over.
case 'OTP_NOT_VERIFIED':
case 'OTP_EXPIRED':
case 'OTP_ATTEMPTS_EXCEEDED':
restart(messageFor(apiError));
return;
default:
// VALIDATION_ERROR carries the rule that was broken — date of birth,
// mobile digits, a sequence. Stay here so a different TPIN can be
// tried against the same token.
showFailure(apiError, 'encryptedTpin');
}
} finally {
if (mounted.current) setBusy(false);
}
}, [
busy,
clearSecrets,
confirmTpin,
endAsExisting,
mobileNumber,
onSuccess,
restart,
showFailure,
toast,
tokenExpiresAt,
tpin,
tpinCreateToken,
]);
/* ── render ───────────────────────────────────────────────────────────── */
if (step === 'exists') {
const copy = describeError('TPIN_ALREADY_SET');
return (
<View style={styles.container}>
<InfoBanner tone="success" title={copy.title} message={copy.description} />
<Button title="Done" onPress={onExit} style={styles.submit} />
</View>
);
}
const mobileDigits = mobileNumber?.replace(/\D/g, '') ?? '';
const sentTo = mobileDigits.length >= 4 ? `+91 XXXXXX${mobileDigits.slice(-4)}` : null;
const otpSecondsLeft = step === 'otp' ? secondsUntil(otpExpiresAt) : null;
const otpExpired = otpSecondsLeft === 0;
const tpinProblem = describeTpinProblem(tpin, mobileNumber);
const confirmProblem =
confirmTpin.length === TPIN_LENGTH && confirmTpin !== tpin ? 'Both entries must match' : undefined;
const stepIndex = STEPS.findIndex((item) => item.key === step);
return (
<View style={styles.container}>
<Stepper steps={STEPS.map((item) => item.label)} current={stepIndex} />
{error ? <InfoBanner tone="danger" message={error} /> : null}
{step === 'mpin' ? (
<>
{notice ? <InfoBanner tone="info" message={notice} /> : null}
<Card style={styles.card}>
<CodeInput
label="Current MPIN"
value={mpin}
onChangeText={(value) => {
setMpin(value);
setError(null);
setFieldError(null);
}}
length={MPIN_LENGTH}
secure
autoFocus
editable={!busy}
error={fieldError ?? undefined}
onSubmit={handleInitiate}
/>
<Text style={[theme.text.caption, { color: theme.colors.textMuted }]}>
{sentTo
? `We check your MPIN, then text a one-time code to ${sentTo}.`
: 'We check your MPIN, then text a one-time code to your registered mobile.'}
</Text>
</Card>
<Button
title="Continue"
onPress={handleInitiate}
disabled={mpin.length !== MPIN_LENGTH || busy}
loading={busy}
style={styles.submit}
/>
</>
) : null}
{step === 'otp' ? (
<>
<Card style={styles.card}>
<CodeInput
label="6-digit code"
value={otp}
onChangeText={(value) => {
setOtp(value);
setError(null);
setFieldError(null);
}}
length={OTP_LENGTH}
autoFocus
editable={!busy && !otpExpired}
error={fieldError ?? undefined}
onSubmit={handleVerifyOtp}
/>
<View style={styles.metaRow}>
<Text style={[theme.text.caption, { color: theme.colors.textSecondary }]}>
{otpSecondsLeft === null
? `Sent to ${sentTo ?? 'your registered mobile'}`
: otpExpired
? 'This code has expired'
: `Expires in ${formatCountdown(otpSecondsLeft)}`}
</Text>
{attemptsRemaining !== null ? (
<Text style={[theme.text.caption, { color: theme.colors.textSecondary }]}>
{`${attemptsRemaining} ${attemptsRemaining === 1 ? 'attempt' : 'attempts'} left`}
</Text>
) : null}
</View>
</Card>
<Button
title="Verify code"
onPress={handleVerifyOtp}
disabled={!isValidOtp(otp) || busy || otpExpired}
loading={busy}
/>
<Button
title={cooldown > 0 && !otpExpired ? `Send a new code in ${cooldown}s` : 'Send a new code'}
variant="ghost"
onPress={handleNewCode}
disabled={(cooldown > 0 && !otpExpired) || busy}
/>
</>
) : null}
{step === 'tpin' ? (
<>
<Card style={styles.card}>
<CodeInput
label={`New ${TPIN_LENGTH}-digit TPIN`}
value={tpin}
onChangeText={(value) => {
setTpin(value);
setError(null);
setFieldError(null);
}}
length={TPIN_LENGTH}
secure
autoFocus
editable={!busy}
error={fieldError ?? tpinProblem}
/>
<CodeInput
label="Confirm TPIN"
value={confirmTpin}
onChangeText={(value) => {
setConfirmTpin(value);
setError(null);
}}
length={TPIN_LENGTH}
secure
editable={!busy}
error={confirmProblem}
onSubmit={handleConfirm}
/>
</Card>
<InfoBanner
tone="info"
message="Your TPIN authorises fund transfers. Avoid sequences like 1234, your date of birth and the last four digits of your mobile, and keep it different from your MPIN."
/>
<Button
title="Create TPIN"
onPress={handleConfirm}
disabled={!isAcceptableNewTpin(tpin, mobileNumber) || confirmTpin !== tpin || busy}
loading={busy}
style={styles.submit}
/>
</>
) : null}
</View>
);
};
const styles = StyleSheet.create({
container: {
gap: 16,
flexGrow: 1,
},
card: {
padding: 16,
gap: 14,
},
metaRow: {
flexDirection: 'row',
justifyContent: 'space-between',
},
submit: {
marginTop: 'auto',
},
});
@@ -0,0 +1,45 @@
import React from 'react';
import { StyleSheet } from 'react-native';
import type { NativeStackScreenProps } from '@react-navigation/native-stack';
import { ScreenShell } from '../../../components';
import { useAppSelector } from '../../../app/hooks';
import type { PaymentsStackParamList } from '../../../navigation/types';
import { TpinCreateFlow } from '../components/TpinCreateFlow';
type Props = Readonly<NativeStackScreenProps<PaymentsStackParamList, 'CreateTpin'>>;
/**
* Create TPIN, reached from the transfer screen when initiate answers
* TPIN_NOT_SET.
*
* Registered in the payments stack rather than borrowed from Services, so
* going back lands on the same transfer review — amount, payee and all — and
* the customer can confirm it again with the TPIN they just made.
*/
export default function CreateTpinScreen({ navigation }: Props) {
const session = useAppSelector((state) => state.auth.session);
const lastProfile = useAppSelector((state) => state.auth.lastProfile);
return (
<ScreenShell
title="Create TPIN"
subtitle="You need one before you can send money"
contentContainerStyle={styles.container}
>
<TpinCreateFlow
mobileNumber={session?.mobileNumber ?? lastProfile?.mobileNumber}
notice="Fund transfers are authorised with a TPIN. Create yours now, then confirm the transfer again."
onSuccess={() => navigation.goBack()}
onExit={() => navigation.goBack()}
/>
</ScreenShell>
);
}
const styles = StyleSheet.create({
container: {
padding: 16,
flexGrow: 1,
},
});
@@ -26,7 +26,7 @@ import { FeatureGate, guardFeature, isFeatureEnabled } from '../../../features';
import { beneficiariesActions, selectBeneficiaryById } from '../store/beneficiariesSlice';
import { useAccounts } from '../../home/hooks/useAccounts';
import { CodeInput } from '../../auth/components/CodeInput';
import { MPIN_LENGTH } from '../../../utils/validation';
import { TPIN_LENGTH, isValidTpinEntry } from '../utils/tpinValidation';
import { describeError } from '../../../services/api/cbsErrors';
import { useApiErrorToast } from '../../../hooks/useApiErrorToast';
import { useTheme, fontFamily } from '../../../theme';
@@ -37,7 +37,7 @@ type Props = NativeStackScreenProps<PaymentsStackParamList, 'PaymentConfirmation
const OTP_LENGTH = 6;
/** Which modal, if any, is in front of the review. */
type Step = 'review' | 'mpin' | 'otp';
type Step = 'review' | 'tpin' | 'otp';
/**
* A payee addressed by mobile is P2P, by account number P2A. The mode decides
@@ -125,7 +125,9 @@ export function PaymentConfirmationScreen({ navigation, route }: Props) {
const [validateBeneficiary] = useValidateBeneficiaryMutation();
const [step, setStep] = useState<Step>('review');
const [mpin, setMpin] = useState('');
/** Screen state only, and cleared the moment initiate answers either way. */
const [tpin, setTpin] = useState('');
const [tpinError, setTpinError] = useState<string | undefined>();
const [otp, setOtp] = useState('');
const [otpError, setOtpError] = useState<string | undefined>();
/** A failure that ended this attempt, kept on the review screen. */
@@ -163,7 +165,8 @@ export function PaymentConfirmationScreen({ navigation, route }: Props) {
requestIdRef.current = null;
setTransfer(null);
setOtp('');
setMpin('');
setTpin('');
setTpinError(undefined);
setOtpError(undefined);
setStep('review');
setNotice(message);
@@ -173,10 +176,24 @@ export function PaymentConfirmationScreen({ navigation, route }: Props) {
setNotice(undefined);
setOtp('');
setOtpError(undefined);
// IMPS needs the MPIN first; the fixture rails go straight to their OTP.
setStep(impsTarget ? 'mpin' : 'otp');
setTpin('');
setTpinError(undefined);
// IMPS needs the TPIN first; the fixture rails go straight to their OTP.
setStep(impsTarget ? 'tpin' : 'otp');
});
/**
* Off to create a TPIN, from the sheet or on TPIN_NOT_SET. This screen stays
* underneath, so coming back lands on the same review to confirm again.
*/
const openCreateTpin = () => {
requestIdRef.current = null;
setTpin('');
setTpinError(undefined);
setStep('review');
navigation.navigate('CreateTpin');
};
/**
* Saving the payee is deliberately *after* the transfer succeeds, and
* deliberately not allowed to fail it: a payee the customer asked to keep is
@@ -216,30 +233,33 @@ export function PaymentConfirmationScreen({ navigation, route }: Props) {
}
};
/* ── IMPS: step one — MPIN, name inquiry, OTP ──────────────────────────── */
/* ── IMPS: step one — TPIN, name inquiry, OTP ──────────────────────────── */
const handleInitiate = async () => {
// No source-account check: the payer is fixed in paymentsApi for now.
if (!impsTarget || mpin.length !== MPIN_LENGTH) {
if (!impsTarget || !isValidTpinEntry(tpin) || isInitiating) {
return;
}
if (!isFeatureEnabled(FeatureKey.PAYMENTS_TRANSFER_SUBMIT)) return;
setTpinError(undefined);
try {
const result = await initiateTransfer({
requestId: requestId(),
mode: impsTarget.mode,
amount: Math.round(amount * 100) / 100,
remark: remarks,
mpin,
// Encrypted fresh inside the mutation; the plain TPIN never leaves.
tpin,
payer: { accountNumber: sourceAccountNumber ?? '' },
// The backend's reference request sends SAVINGS for both modes; the
// forms do not ask for the payee's account type.
payee: { ...impsTarget.payee, accountType: 'SAVINGS' },
}).unwrap();
setMpin('');
setTpin('');
if (result.status !== 'PENDING_OTP') {
// IMPS did not recognise the payee. No OTP was issued and nothing
@@ -257,28 +277,34 @@ export function PaymentConfirmationScreen({ navigation, route }: Props) {
setStep('otp');
} catch (error) {
const apiError = error as ApiError;
setMpin('');
// A wrong MPIN is the customer's to retry, so the MPIN sheet stays up.
// Everything else closes it: nothing was debited, but the attempt is over.
if (apiError.code === 'INVALID_CREDENTIALS') {
showApiError(apiError);
return;
}
if (apiError.code === 'MPIN_LOCKED') {
abandonAttempt(describeError('MPIN_LOCKED').description);
return;
}
setTpin('');
// A network failure keeps the requestId, so trying again returns this
// same transfer instead of starting a second one.
// same transfer instead of starting a second one. Any definite answer —
// a wrong TPIN included — ends it, so the next try is a new transfer
// rather than a replay of one the server already refused.
if (apiError.code !== 'NETWORK_ERROR' && apiError.code !== 'REQUEST_TIMEOUT') {
requestIdRef.current = null;
}
setStep('review');
showApiError(apiError);
switch (apiError.code) {
case 'SESSION_INVALID':
// The interceptor has already signed the customer out.
return;
case 'INVALID_TPIN':
// The customer's to retry, so the TPIN sheet stays up.
setTpinError(apiError.message || describeError('INVALID_TPIN').description);
return;
case 'TPIN_NOT_SET':
// Nothing was checked or debited. Create one, then confirm again.
openCreateTpin();
return;
default:
// Everything else closes the sheet: nothing was debited, but the
// attempt is over.
setStep('review');
showApiError(apiError);
}
}
};
@@ -423,7 +449,8 @@ export function PaymentConfirmationScreen({ navigation, route }: Props) {
// answer, not cancel it.
if (busy) return;
setMpin('');
setTpin('');
setTpinError(undefined);
setStep('review');
};
@@ -484,28 +511,38 @@ export function PaymentConfirmationScreen({ navigation, route }: Props) {
</Text>
</View>
{/* ── MPIN (IMPS only) ─────────────────────────────────────────────── */}
<AppModal visible={step === 'mpin'} onClose={closeModal} title="Enter your MPIN">
{/* ── TPIN (IMPS only) ─────────────────────────────────────────────── */}
<AppModal visible={step === 'tpin'} onClose={closeModal} title="Enter your TPIN">
<View style={styles.modalBody}>
<Text style={[styles.modalCopy, { color: theme.colors.textSecondary }]}>
{`We'll verify ${payeeName} with IMPS and send a code to your registered mobile. Nothing is debited yet.`}
{`We'll check your TPIN, verify ${payeeName} with IMPS and send a code to your registered mobile. Nothing is debited yet.`}
</Text>
<CodeInput
label="MPIN"
value={mpin}
onChangeText={setMpin}
length={MPIN_LENGTH}
label="TPIN"
value={tpin}
onChangeText={(value) => {
setTpinError(undefined);
setTpin(value);
}}
length={TPIN_LENGTH}
secure
autoFocus
error={tpinError}
editable={!isInitiating}
onSubmit={handleInitiate}
/>
<Button
title="Verify payee & send OTP"
onPress={handleInitiate}
disabled={mpin.length !== MPIN_LENGTH || isInitiating}
disabled={!isValidTpinEntry(tpin) || isInitiating}
loading={isInitiating}
/>
<Button
title="Don't have a TPIN? Create one"
variant="ghost"
onPress={openCreateTpin}
disabled={isInitiating}
/>
</View>
</AppModal>
+61 -21
View File
@@ -8,12 +8,14 @@ import type {
BeneficiaryValidateRequest,
BeneficiaryValidateResult,
CbsBeneficiary,
FundTransferDetail,
FundTransferHistoryPage,
FundTransferHistoryQuery,
FundTransferInitiateRequest,
FundTransferInitiateResult,
FundTransferVerifyResult,
} from '../../../types/cbs';
import { isCbsApiError } from '../../../services/api/cbsErrors';
import { mpinCrypto } from '../../../services/security/mpinCrypto';
import { mpinCrypto, withKeyRetry } from '../../../services/security/mpinCrypto';
import { favoriteStore, toBeneficiary } from '../utils/beneficiaryMapper';
/**
@@ -106,7 +108,11 @@ export const paymentsApi = baseApi.injectEndpoints({
}),
/**
* Step one of an IMPS transfer: IMPS name inquiry, then an OTP. No debit.
* Step one of an IMPS transfer: TPIN check, IMPS name inquiry, then an
* OTP. No debit.
*
* Authorised by the TPIN — the MPIN only opens the session now. The TPIN
* is encrypted fresh on every send, so no ciphertext is ever reused.
*
* Retried once with a fresh key on MALFORMED_REQUEST, the same as sign-in.
* That is safe here and nowhere near ReqPay: the `requestId` makes a
@@ -114,32 +120,22 @@ export const paymentsApi = baseApi.injectEndpoints({
*/
initiateFundTransfer: builder.mutation<
FundTransferInitiateResult,
Omit<FundTransferInitiateRequest, 'encryptedMpin'> & { mpin: string }
Omit<FundTransferInitiateRequest, 'encryptedTpin'> & { tpin: string }
>({
queryFn: ({ mpin, ...request }) =>
resolveLive(async () => {
const send = async () => {
const encryptedMpin = await mpinCrypto.encryptMpin(mpin);
queryFn: ({ tpin, ...request }) =>
resolveLive(() =>
withKeyRetry(async () => {
const encryptedTpin = await mpinCrypto.encryptTpin(tpin);
const response = await apiClient.post<FundTransferInitiateResult>(
endpoints.fundTransfers.initiate,
// See HARDCODED_PAYER_ACCOUNT — the caller's payer is discarded,
// so this is also the account that gets debited.
{ ...request, payer: { accountNumber: HARDCODED_PAYER_ACCOUNT }, encryptedMpin },
{ ...request, payer: { accountNumber: HARDCODED_PAYER_ACCOUNT }, encryptedTpin },
);
return response.data;
};
try {
return await send();
} catch (error) {
if (isCbsApiError(error) && error.code === 'MALFORMED_REQUEST') {
return send();
}
throw error;
}
}),
}),
),
}),
/**
@@ -163,6 +159,48 @@ export const paymentsApi = baseApi.injectEndpoints({
invalidatesTags: ['Payments', 'Transactions'],
}),
/**
* One page of the customer's transfer history. Filters equal to `ALL` and an
* incomplete date pair are left off rather than sent, so the server applies
* its own defaults. Never cached here: the server is the status authority
* and a pending transfer can settle between two visits.
*/
getFundTransferHistory: builder.query<FundTransferHistoryPage, FundTransferHistoryQuery>({
queryFn: ({ page, size, status, mode, fromDate, toDate }) =>
resolveLive(async () => {
const response = await apiClient.get<FundTransferHistoryPage>(
endpoints.fundTransferHistory.list,
{
params: {
...(page === undefined ? {} : { page }),
...(size === undefined ? {} : { size }),
...(status && status !== 'ALL' ? { status } : {}),
...(mode && mode !== 'ALL' ? { mode } : {}),
...(fromDate && toDate ? { fromDate, toDate } : {}),
},
},
);
return response.data;
}),
providesTags: ['Payments'],
keepUnusedDataFor: 0,
}),
/** One transfer, by the `transferId` from the history list. */
getFundTransferDetail: builder.query<FundTransferDetail, string>({
queryFn: (transferId) =>
resolveLive(async () => {
const response = await apiClient.get<FundTransferDetail>(
endpoints.fundTransferHistory.detail(transferId),
);
return response.data;
}),
providesTags: ['Payments'],
keepUnusedDataFor: 0,
}),
/** NEFT, RTGS and own-account have no endpoint yet, so this stays on fixtures. */
submitBankTransfer: builder.mutation<BankTransferResponse, BankTransferRequest>({
queryFn: (req) =>
@@ -187,4 +225,6 @@ export const {
useSubmitBankTransferMutation,
useInitiateFundTransferMutation,
useVerifyFundTransferOtpMutation,
useGetFundTransferHistoryQuery,
useGetFundTransferDetailQuery,
} = paymentsApi;
@@ -0,0 +1,62 @@
import { apiClient } from '../../../services/api/apiClient';
import { endpoints } from '../../../services/api/endpoints';
import { mpinCrypto, withKeyRetry } from '../../../services/security/mpinCrypto';
import type {
TpinCreateConfirmResult,
TpinCreateInitiateResult,
TpinCreateVerifyResult,
} from '../../../types/cbs';
/**
* Creating the TPIN. Every call rides on the signed-in session — the
* interceptor attaches the Bearer token, and the server derives org, customer,
* mobile, device and profile from it, so none of those are sent here.
*
* Deliberately not RTK Query: a mutation keeps its arguments and result in the
* store, where dev tooling records them, and the create id, the one-time token
* and the OTP must live in screen state only. See `TpinCreateFlow`.
*/
export const tpinService = {
/**
* Step 1: re-authenticates with the current MPIN, checks the customer with
* the bank and sends an OTP. Encrypted inside the retry, so a
* MALFORMED_REQUEST (rotated key, stale `ts`) is retried once with a fresh
* key and a fresh payload — never the same ciphertext twice.
*/
async initiateCreate(mpin: string) {
return withKeyRetry(async () => {
const encryptedMpin = await mpinCrypto.encryptMpin(mpin);
const response = await apiClient.post<TpinCreateInitiateResult>(
endpoints.tpin.createInitiate,
{ encryptedMpin },
);
return response.data;
});
},
/** Step 2: swaps the OTP for a short-lived, one-time create token. */
async verifyCreateOtp({ tpinCreateId, otp }: { tpinCreateId: string; otp: string }) {
const response = await apiClient.post<TpinCreateVerifyResult>(
endpoints.tpin.createVerifyOtp,
{ tpinCreateId, otp },
);
return response.data;
},
/** Step 3: spends the token on the new TPIN, encrypted fresh for this call. */
async confirmCreate({ tpinCreateToken, tpin }: { tpinCreateToken: string; tpin: string }) {
return withKeyRetry(async () => {
const encryptedTpin = await mpinCrypto.encryptTpin(tpin);
const response = await apiClient.post<TpinCreateConfirmResult>(
endpoints.tpin.createConfirm,
{ tpinCreateToken, encryptedTpin },
);
return response.data;
});
},
};
@@ -0,0 +1,67 @@
import {
TPIN_LENGTH,
describeTpinProblem,
isAcceptableNewTpin,
isValidTpinEntry,
} from './tpinValidation';
describe('isValidTpinEntry', () => {
it('accepts exactly four digits', () => {
expect(TPIN_LENGTH).toBe(4);
expect(isValidTpinEntry('2580')).toBe(true);
expect(isValidTpinEntry('258')).toBe(false);
expect(isValidTpinEntry('25801')).toBe(false);
expect(isValidTpinEntry('25a0')).toBe(false);
});
// On a transfer the server decides whether the digits are right, so an
// entry the create rules would refuse must still be typeable.
it('does not apply the choosing rules to an entry', () => {
expect(isValidTpinEntry('1234')).toBe(true);
expect(isValidTpinEntry('1111')).toBe(true);
});
});
describe('describeTpinProblem', () => {
it('says nothing about an empty field', () => {
expect(describeTpinProblem('')).toBeUndefined();
});
it('asks for four digits until there are four', () => {
expect(describeTpinProblem('25')).toBe('A TPIN is 4 digits');
expect(describeTpinProblem('2a')).toBe('Digits only');
});
it('refuses ascending and descending runs', () => {
expect(describeTpinProblem('1234')).toMatch(/run of digits/);
expect(describeTpinProblem('4321')).toMatch(/run of digits/);
expect(describeTpinProblem('6789')).toMatch(/run of digits/);
});
it('refuses the same digit repeated', () => {
expect(describeTpinProblem('1111')).toMatch(/same digit/);
});
it('refuses the last four digits of the registered mobile', () => {
expect(describeTpinProblem('0101', '9300000101')).toMatch(/last four digits/);
expect(describeTpinProblem('0101', '+91 93000 00101')).toMatch(/last four digits/);
expect(describeTpinProblem('0102', '9300000101')).toBeUndefined();
});
it('skips the mobile rule when the number is unknown', () => {
expect(describeTpinProblem('0101')).toBeUndefined();
expect(describeTpinProblem('0101', '')).toBeUndefined();
});
});
describe('isAcceptableNewTpin', () => {
it('accepts a TPIN that breaks no rule', () => {
expect(isAcceptableNewTpin('2580', '9300000101')).toBe(true);
});
it('refuses one that breaks any rule, or is incomplete', () => {
expect(isAcceptableNewTpin('258')).toBe(false);
expect(isAcceptableNewTpin('1234')).toBe(false);
expect(isAcceptableNewTpin('0101', '9300000101')).toBe(false);
});
});
@@ -0,0 +1,57 @@
import { isRepeatedDigits, isSequentialDigits } from '../../../utils/validation';
/** A TPIN is always exactly four digits. */
export const TPIN_LENGTH = 4;
/**
* Any four digits — for ENTERING the TPIN on a transfer. Deliberately loose:
* whether they are the right four is the server's to say, with INVALID_TPIN.
*/
export const isValidTpinEntry = (value: string) =>
new RegExp(`^[0-9]{${TPIN_LENGTH}}$`).test(value);
/**
* Inline copy for why a NEW TPIN was refused before it ever left the device —
* for choosing one, not for entering one on a transfer.
*
* `mobileNumber`, when known, adds the "not the last four digits of your
* registered mobile" rule, so the customer is told before a round trip rather
* than after one.
*
* The backend stays the final authority. It also refuses a TPIN matching the
* customer's date of birth, which the app never holds — so that one can only
* arrive as a VALIDATION_ERROR on confirm.
*/
export const describeTpinProblem = (value: string, mobileNumber?: string): string | undefined => {
if (value.length === 0) {
return undefined;
}
if (!/^[0-9]*$/.test(value)) {
return 'Digits only';
}
if (value.length !== TPIN_LENGTH) {
return `A TPIN is ${TPIN_LENGTH} digits`;
}
if (isRepeatedDigits(value)) {
return 'Pick a TPIN that is not the same digit repeated';
}
if (isSequentialDigits(value)) {
return 'Pick a TPIN that is not a run of digits like 1234 or 4321';
}
const mobileDigits = mobileNumber?.replace(/\D/g, '') ?? '';
if (mobileDigits.length >= TPIN_LENGTH && mobileDigits.slice(-TPIN_LENGTH) === value) {
return 'Pick a TPIN that is not the last four digits of your mobile number';
}
return undefined;
};
/** The stricter check for CHOOSING a TPIN. */
export const isAcceptableNewTpin = (value: string, mobileNumber?: string) =>
isValidTpinEntry(value) && describeTpinProblem(value, mobileNumber) === undefined;
@@ -12,6 +12,7 @@ import NotificationSettingsScreen from '../screens/NotificationSettingsScreen';
import PrivacySettingsScreen from '../screens/PrivacySettingsScreen';
import AboutAppScreen from '../screens/AboutAppScreen';
import ChangePinScreen from '../screens/ChangePinScreen';
import ResetMpinScreen from '../screens/ResetMpinScreen';
import ChangePasswordScreen from '../screens/ChangePasswordScreen';
import FeatureFlagsScreen from '../screens/FeatureFlagsScreen';
@@ -40,6 +41,7 @@ export const ProfileNavigator = () => {
{canRoute('PrivacySettings') && <Stack.Screen name="PrivacySettings" component={PrivacySettingsScreen} />}
{canRoute('AboutApp') && <Stack.Screen name="AboutApp" component={AboutAppScreen} />}
{canRoute('ChangePin') && <Stack.Screen name="ChangePin" component={ChangePinScreen} />}
{canRoute('ResetMpin') && <Stack.Screen name="ResetMpin" component={ResetMpinScreen} />}
{canRoute('ChangePassword') && <Stack.Screen name="ChangePassword" component={ChangePasswordScreen} />}
{canRoute('FeatureFlags') && <Stack.Screen name="FeatureFlags" component={FeatureFlagsScreen} />}
</Stack.Navigator>
@@ -177,7 +177,7 @@ export default function ProfileMainScreen({ navigation }: Props) {
feature: FeatureKey.PROFILE_SECURITY,
icon: 'lock-closed-outline',
title: 'Security Center',
subtitle: 'Password, PIN and 2FA',
subtitle: 'MPIN reset, password and 2FA',
onPress: () => navigation.navigate('SecuritySettings'),
},
{
@@ -0,0 +1,64 @@
import React, { useCallback } from 'react';
import { StyleSheet } from 'react-native';
import type { NativeStackScreenProps } from '@react-navigation/native-stack';
import { ScreenShell } from '../../../components';
import { useAppDispatch, useAppSelector } from '../../../app/hooks';
import { env } from '../../../config/env';
import type { ProfileStackParamList } from '../../../navigation/types';
import { MpinResetFlow } from '../../auth/components/MpinResetFlow';
import { logoutSession } from '../../auth/store/authThunk';
type Props = NativeStackScreenProps<ProfileStackParamList, 'ResetMpin'>;
/**
* Reset MPIN from Profile → Security Center, for a signed-in customer.
*
* The same flow as sign-in's Forgot MPIN, with two differences that follow from
* being signed in:
*
* - the mobile number is prefilled from the session;
* - a successful reset revokes every session server-side, including this one,
* so the app signs out locally straight after. `expired`, not `user`: the
* customer has not left, and wiping the remembered profile would cost them
* an OTP and a device re-bind just to sign back in with the new MPIN.
*/
export default function ResetMpinScreen(_props: Props) {
const dispatch = useAppDispatch();
const session = useAppSelector((state) => state.auth.session);
const lastProfile = useAppSelector((state) => state.auth.lastProfile);
const mobile = session?.mobileNumber ?? lastProfile?.mobileNumber;
const handleSuccess = useCallback(() => {
void dispatch(logoutSession('expired'));
}, [dispatch]);
return (
<ScreenShell
title="Reset MPIN"
subtitle="Set a new MPIN with a code sent to your mobile"
// The tab bar stays up on Profile's inner screens, so the shell has to
// pad for it — without that, the bottom-pinned Continue button sat
// underneath the bar.
contentContainerStyle={styles.container}
>
<MpinResetFlow
orgId={session?.orgId ?? lastProfile?.orgId ?? env.orgId}
// Prefilled, but editable: the OTP goes to whatever number is entered
// and confirm is bound to this device, so letting it be typed opens
// nothing — and a locked field read as a broken one.
initialMobile={mobile}
notice="Resetting your MPIN signs you out on every device, including this one. You'll sign in again with the new MPIN."
onSuccess={handleSuccess}
/>
</ScreenShell>
);
}
const styles = StyleSheet.create({
container: {
padding: 16,
flexGrow: 1,
},
});
@@ -32,6 +32,18 @@ export default function SecurityScreen({ navigation }: Props) {
/>
<View style={[styles.divider, { backgroundColor: theme.colors.cardBorder }]} />
</FeatureGate>
{/* Beside Change Login PIN, where customers look for anything to
do with their credentials. Real, API-backed — it signs the
customer out when done, because the reset revokes sessions. */}
<FeatureGate feature={FeatureKey.PROFILE_SECURITY_RESET_MPIN}>
<SettingRow
icon="refresh-circle-outline"
title="Reset MPIN"
subtitle="Set a new MPIN with an OTP to your mobile"
onPress={() => navigation.navigate('ResetMpin')}
/>
<View style={[styles.divider, { backgroundColor: theme.colors.cardBorder }]} />
</FeatureGate>
<FeatureGate feature={FeatureKey.PROFILE_SECURITY_CHANGE_PASSWORD}>
<SettingRow
icon="lock-open-outline"
+22
View File
@@ -261,6 +261,17 @@ export function buildUserSearchCatalog(
keywords: ['chequebook', 'new cheque book', 'order cheques'],
navigate: (nav) => nav.navigate('Services', { screen: 'ChequeBookRequest' }),
},
{
id: 'services-create-tpin',
feature: FeatureKey.SERVICES_TPIN_CHANGE,
title: 'Create TPIN',
subtitle: 'Set the PIN that authorises transfers',
module: 'Services',
icon: 'lock-closed-outline',
hue: MODULE_HUES.services,
keywords: ['tpin', 'transaction pin', 'set tpin', 'transfer pin', 'payment pin'],
navigate: (nav) => nav.navigate('Services', { screen: 'SetTpin' }),
},
{
id: 'services-stop-payment',
feature: FeatureKey.SERVICES_CHEQUE_STOP_PAYMENT,
@@ -586,6 +597,17 @@ export function buildUserSearchCatalog(
keywords: ['pin', 'reset pin', 'mpin'],
navigate: (nav) => nav.navigate('Profile', { screen: 'ChangePin' }),
},
{
id: 'profile-reset-mpin',
feature: FeatureKey.PROFILE_SECURITY_RESET_MPIN,
title: 'Reset MPIN',
subtitle: 'Set a new MPIN with an OTP',
module: 'Profile',
icon: 'refresh-circle-outline',
hue: MODULE_HUES.profile,
keywords: ['mpin', 'reset mpin', 'forgot mpin', 'new mpin', 'pin'],
navigate: (nav) => nav.navigate('Profile', { screen: 'ResetMpin' }),
},
{
id: 'profile-change-password',
feature: FeatureKey.PROFILE_SECURITY_CHANGE_PASSWORD,
@@ -81,7 +81,9 @@ export default function ServicesHubScreen({ navigation }: Props) {
{
id: 'tpin_change',
feature: FeatureKey.SERVICES_TPIN_CHANGE,
title: 'TPIN Change',
// The backend creates a TPIN but has no change endpoint, so the tile
// promises only what the screen can do.
title: 'Create TPIN',
icon: 'lock-closed-outline',
iconColor: theme.colors.category.pink.tone,
onPress: () => navigation.navigate('SetTpin'),
+23 -145
View File
@@ -1,166 +1,44 @@
import React, { useMemo, useState } from 'react';
import { ScrollView, StyleSheet, Text, View } from 'react-native';
import React from 'react';
import { StyleSheet } from 'react-native';
import type { NativeStackScreenProps } from '@react-navigation/native-stack';
import Ionicons from '@react-native-vector-icons/ionicons';
import { Button, Card, Input, ScreenShell } from '../../../components';
import { useToast } from '../../../hooks/useToast';
import { useTheme } from '../../../theme';
import { ScreenShell } from '../../../components';
import { useAppSelector } from '../../../app/hooks';
import type { ServicesStackParamList } from '../../../navigation/types';
import { TpinCreateFlow } from '../../payments/components/TpinCreateFlow';
type Props = Readonly<NativeStackScreenProps<ServicesStackParamList, 'SetTpin'>>;
const TPIN_LENGTH = 4;
/** Rejected outright: a TPIN nobody has to guess is not a second factor. */
const TRIVIAL_TPINS = new Set(['0000', '1111', '2222', '3333', '4444', '5555', '6666', '7777', '8888', '9999', '1234', '4321']);
/**
* Sets the transaction PIN — the second factor on a payment, separate from the
* MPIN that opens the app.
* Creates the transaction PIN — the second factor on a fund transfer,
* separate from the MPIN that opens the app.
*
* The whole point of the TPIN is that it is *not* the MPIN: one unlocks the
* app, the other authorises money leaving it, and a customer who sets them the
* same has collapsed two factors into one. This screen cannot check that
* directly — it never sees the MPIN, which lives behind the Keystore — so it
* says so plainly instead and refuses the guessable choices it can see.
* app, the other authorises money leaving it. Creating one needs the current
* MPIN and an OTP first, so a phone left unlocked is not enough to set it.
*/
export default function SetTpinScreen({ navigation }: Props) {
const theme = useTheme();
const toast = useToast();
const [tpin, setTpin] = useState('');
const [confirmTpin, setConfirmTpin] = useState('');
const [saving, setSaving] = useState(false);
const tpinProblem = useMemo(() => {
if (tpin.length === 0) {
return undefined;
}
if (tpin.length < TPIN_LENGTH) {
return `A TPIN is ${TPIN_LENGTH} digits`;
}
if (TRIVIAL_TPINS.has(tpin)) {
return 'Too easy to guess — avoid repeated or sequential digits';
}
return undefined;
}, [tpin]);
const confirmProblem =
confirmTpin.length === TPIN_LENGTH && confirmTpin !== tpin
? 'Both entries must match'
: undefined;
const canSubmit =
tpin.length === TPIN_LENGTH && !tpinProblem && confirmTpin === tpin && !saving;
const digitsOnly = (value: string) => value.replace(/\D/g, '').slice(0, TPIN_LENGTH);
const handleSubmit = async () => {
if (!canSubmit) {
return;
}
setSaving(true);
try {
// No CBS endpoint for the TPIN yet. When one lands it belongs here, and
// the PIN must be encrypted the same way the MPIN is rather than sent in
// the clear — see `authApi` for the RSA-OAEP path this should reuse.
await new Promise((resolve) => setTimeout(resolve, 600));
toast('TPIN set', 'success', 'Use it to authorise payments from now on.');
navigation.goBack();
} finally {
setSaving(false);
}
};
const session = useAppSelector((state) => state.auth.session);
const lastProfile = useAppSelector((state) => state.auth.lastProfile);
return (
<ScreenShell title="Set TPIN" subtitle="Your payment authorisation PIN">
<ScrollView showsVerticalScrollIndicator={false} contentContainerStyle={styles.scroll}>
<View style={styles.container}>
<View style={styles.iconWrap}>
<View style={[styles.circle, { backgroundColor: theme.colors.surfaceMuted }]}>
<Ionicons name="shield-half" size={32} color={theme.colors.primary} />
</View>
</View>
<Card style={styles.card}>
<Input
label="TPIN"
placeholder="XXXX"
keyboardType="number-pad"
maxLength={TPIN_LENGTH}
secureTextEntry
value={tpin}
onChangeText={(value) => setTpin(digitsOnly(value))}
error={tpinProblem}
editable={!saving}
/>
<Input
label="Confirm TPIN"
placeholder="XXXX"
keyboardType="number-pad"
maxLength={TPIN_LENGTH}
secureTextEntry
value={confirmTpin}
onChangeText={(value) => setConfirmTpin(digitsOnly(value))}
error={confirmProblem}
editable={!saving}
/>
</Card>
<Button
title="Set TPIN"
onPress={handleSubmit}
disabled={!canSubmit}
loading={saving}
style={styles.btn}
/>
<Text style={[styles.hint, { color: theme.colors.primary }]}>
Your TPIN authorises payments. Keep it different from your MPIN and never share it —
bank staff will never ask for it.
</Text>
</View>
</ScrollView>
<ScreenShell
title="Create TPIN"
subtitle="Your PIN for authorising fund transfers"
contentContainerStyle={styles.container}
>
<TpinCreateFlow
mobileNumber={session?.mobileNumber ?? lastProfile?.mobileNumber}
onSuccess={() => navigation.goBack()}
onExit={() => navigation.goBack()}
/>
</ScreenShell>
);
}
const styles = StyleSheet.create({
scroll: {
paddingBottom: 40,
},
container: {
gap: 24,
paddingTop: 10,
},
iconWrap: {
alignItems: 'center',
marginVertical: 10,
},
circle: {
width: 80,
height: 80,
borderRadius: 40,
alignItems: 'center',
justifyContent: 'center',
},
card: {
gap: 16,
},
btn: {
marginTop: 8,
},
hint: {
fontSize: 15,
lineHeight: 22,
textAlign: 'center',
paddingHorizontal: 16,
padding: 16,
flexGrow: 1,
},
});
+3
View File
@@ -14,6 +14,7 @@ import RegisterScreen from '../modules/auth/screens/RegisterScreen';
import VerifyOtpScreen from '../modules/auth/screens/VerifyOtpScreen';
import CreateMpinScreen from '../modules/auth/screens/CreateMpinScreen';
import MpinLockedScreen from '../modules/auth/screens/MpinLockedScreen';
import ForgotMpinScreen from '../modules/auth/screens/ForgotMpinScreen';
const Stack = createNativeStackNavigator<AuthStackParamList>();
@@ -45,6 +46,7 @@ export const AuthNavigator = () => {
* the app should say nothing rather than offer a flow that will be refused.
*/
const registrationEnabled = useFeature(FeatureKey.AUTH_REGISTER);
const forgotMpinEnabled = useFeature(FeatureKey.AUTH_LOGIN_FORGOT_PASSWORD);
/**
* Read once, on the navigator's first render, and frozen from then on.
@@ -98,6 +100,7 @@ export const AuthNavigator = () => {
component={CreateMpinScreen}
options={{ gestureEnabled: false }}
/>
{forgotMpinEnabled && <Stack.Screen name="ForgotMpin" component={ForgotMpinScreen} />}
<Stack.Screen
name="MpinLocked"
component={MpinLockedScreen}
+4
View File
@@ -3,6 +3,8 @@ import { createNativeStackNavigator } from '@react-navigation/native-stack';
import HomeScreen from '../modules/home/screens/HomeScreen';
import GlobalSearchScreen from '../modules/search/screens/GlobalSearchScreen';
import NotificationsScreen from '../modules/notifications/screens/NotificationsScreen';
import TransferHistoryScreen from '../modules/history/screens/TransferHistoryScreen';
import TransferDetailScreen from '../modules/history/screens/TransferDetailScreen';
import MiniStatementScreen from '../modules/home/screens/MiniStatementScreen';
import AccountDetailsScreen from '../modules/home/screens/AccountDetailsScreen';
import ReKycScreen from '../modules/home/screens/ReKycScreen';
@@ -24,6 +26,8 @@ export const HomeNavigator = () => {
{canRoute('HomeMain') && <Stack.Screen name="HomeMain" component={HomeScreen} />}
{canRoute('GlobalSearch') && <Stack.Screen name="GlobalSearch" component={GlobalSearchScreen} />}
{canRoute('Notifications') && <Stack.Screen name="Notifications" component={NotificationsScreen} />}
{canRoute('TransferHistory') && <Stack.Screen name="TransferHistory" component={TransferHistoryScreen} />}
{canRoute('TransferDetail') && <Stack.Screen name="TransferDetail" component={TransferDetailScreen} />}
{canRoute('MiniStatement') && <Stack.Screen name="MiniStatement" component={MiniStatementScreen} />}
{canRoute('AccountDetails') && <Stack.Screen name="AccountDetails" component={AccountDetailsScreen} />}
{canRoute('ReKyc') && <Stack.Screen name="ReKyc" component={ReKycScreen} />}
+2
View File
@@ -12,6 +12,7 @@ import TransferToAccountScreen from '../modules/payments/screens/TransferToAccou
import TransferToOwnAccountScreen from '../modules/payments/screens/TransferToOwnAccountScreen';
import ImpsMmidScreen from '../modules/payments/screens/ImpsMmidScreen';
import { PaymentConfirmationScreen } from '../modules/payments/screens/PaymentConfirmationScreen';
import CreateTpinScreen from '../modules/payments/screens/CreateTpinScreen';
import { TransactionSuccessScreen } from '../modules/payments/screens/TransactionSuccessScreen';
const Stack = createNativeStackNavigator<PaymentsStackParamList>();
@@ -52,6 +53,7 @@ export const PaymentsNavigator = () => {
{canRoute('PaymentConfirmation') && (
<Stack.Screen name="PaymentConfirmation" component={PaymentConfirmationScreen} />
)}
{canRoute('CreateTpin') && <Stack.Screen name="CreateTpin" component={CreateTpinScreen} />}
{canRoute('TransactionSuccess') && (
<Stack.Screen
name="TransactionSuccess"
+4
View File
@@ -67,6 +67,9 @@ export const PAYMENTS_ROUTE_FEATURES: RouteFeatureMap<PaymentsStackParamList> =
TransferToOwnAccount: FeatureKey.PAYMENTS_TRANSFER_OWN,
ImpsMmid: FeatureKey.PAYMENTS_MMID,
PaymentConfirmation: FeatureKey.PAYMENTS_TRANSFER,
// Part of the transfer, not of Services: a transfer that needs a TPIN must
// be able to create one even with the Services entry switched off.
CreateTpin: FeatureKey.PAYMENTS_TRANSFER,
TransactionSuccess: FeatureKey.PAYMENTS_TRANSFER,
};
@@ -148,6 +151,7 @@ export const PROFILE_ROUTE_FEATURES: RouteFeatureMap<ProfileStackParamList> = {
PrivacySettings: FeatureKey.PROFILE_SETTINGS_PRIVACY,
AboutApp: FeatureKey.PROFILE_ABOUT,
ChangePin: FeatureKey.PROFILE_SECURITY_CHANGE_PIN,
ResetMpin: FeatureKey.PROFILE_SECURITY_RESET_MPIN,
ChangePassword: FeatureKey.PROFILE_SECURITY_CHANGE_PASSWORD,
SecuritySettings: FeatureKey.PROFILE_SECURITY,
FeatureFlags: FeatureKey.PROFILE_FEATURE_FLAGS,
+6
View File
@@ -10,6 +10,7 @@ export type AuthStackParamList = {
VerifyOtp: undefined;
CreateMpin: undefined;
MpinLocked: undefined;
ForgotMpin: undefined;
};
import type { LoanCategory } from '../types/app';
@@ -32,6 +33,8 @@ export type HomeStackParamList = {
HomeMain: undefined;
GlobalSearch: undefined;
Notifications: undefined;
TransferHistory: undefined;
TransferDetail: { transferId: string };
MiniStatement: { accountNumber: string };
AccountDetails: { accountNumber?: string } | undefined;
ReKyc: { accountNumber: string };
@@ -105,6 +108,8 @@ export type PaymentsStackParamList = {
/** The "save as beneficiary" tick, actioned once the transfer succeeds. */
saveRecipient?: boolean;
};
/** Reached from PaymentConfirmation on TPIN_NOT_SET; returns there when done. */
CreateTpin: undefined;
TransactionSuccess: {
transactionId: string;
amount: number;
@@ -169,6 +174,7 @@ export type ProfileStackParamList = {
PrivacySettings: undefined;
AboutApp: undefined;
ChangePin: undefined;
ResetMpin: undefined;
ChangePassword: undefined;
SecuritySettings: undefined;
FeatureFlags: undefined;
+20 -3
View File
@@ -9,9 +9,26 @@ import {
} from './cbsErrors';
describe('error code catalogue', () => {
it('covers all 48 documented codes, with no duplicates', () => {
expect(CBS_ERROR_CODES).toHaveLength(48);
expect(new Set(CBS_ERROR_CODES).size).toBe(48);
it('covers all 52 documented codes, with no duplicates', () => {
expect(CBS_ERROR_CODES).toHaveLength(52);
expect(new Set(CBS_ERROR_CODES).size).toBe(52);
});
it('knows the TPIN codes, so none of them collapses into UNKNOWN_ERROR', () => {
// The transfer and create-TPIN screens branch on each of these: a wrong
// TPIN keeps the sheet up, a missing one sends the customer to create it,
// and an existing one ends the create flow.
expect(isCbsErrorCode('INVALID_TPIN')).toBe(true);
expect(isCbsErrorCode('TPIN_NOT_SET')).toBe(true);
expect(isCbsErrorCode('TPIN_ALREADY_SET')).toBe(true);
});
it('does not invite a retry on a TPIN refusal', () => {
// A wrong TPIN is the customer's to re-enter, not the identical request's
// to repeat; the other two are states a retry cannot change.
expect(isRetryableError('INVALID_TPIN')).toBe(false);
expect(isRetryableError('TPIN_NOT_SET')).toBe(false);
expect(isRetryableError('TPIN_ALREADY_SET')).toBe(false);
});
it('does not invite a retry when the bank withholds a balance', () => {
+20
View File
@@ -11,6 +11,7 @@ export const CBS_ERROR_CODES = [
'OTP_EXPIRED',
'BENEFICIARY_DETAILS_INCOMPLETE',
'INVALID_CREDENTIALS',
'INVALID_TPIN',
'SESSION_INVALID',
'DEVICE_NOT_BOUND',
'RESOURCE_NOT_FOUND',
@@ -30,6 +31,8 @@ export const CBS_ERROR_CODES = [
'OTP_NOT_VERIFIED',
'MPIN_ALREADY_SET',
'MPIN_NOT_SET',
'TPIN_ALREADY_SET',
'TPIN_NOT_SET',
'BENEFICIARY_ALREADY_ADDED',
'POSITIVE_PAY_NOT_AVAILABLE',
'CHEQUE_ALREADY_REGISTERED',
@@ -40,6 +43,7 @@ export const CBS_ERROR_CODES = [
'CHEQUE_BOOK_REQUEST_DECLINED',
'MPIN_LOCKED',
'OTP_ATTEMPTS_EXCEEDED',
'TRANSACTION_NOT_FOUND',
'INTERNAL_ERROR',
'CBS_CONNECTOR_NOT_SUPPORTED',
'MMID_ALLOCATION_FAILED',
@@ -121,6 +125,10 @@ const ERROR_COPY: Record<AnyErrorCode, ErrorCopy> = {
title: 'Sign-in failed',
description: 'Check your mobile number and MPIN, then try again.',
},
INVALID_TPIN: {
title: 'Incorrect TPIN',
description: 'That TPIN is not right. Check it and try again.',
},
SESSION_INVALID: {
title: 'Session ended',
description: 'Please sign in again to continue securely.',
@@ -209,6 +217,14 @@ const ERROR_COPY: Record<AnyErrorCode, ErrorCopy> = {
title: 'Registration unfinished',
description: 'Finish setting your MPIN to start banking.',
},
TPIN_ALREADY_SET: {
title: 'You already have a TPIN',
description: 'Your TPIN is active. Use it to authorise fund transfers.',
},
TPIN_NOT_SET: {
title: 'Create your TPIN first',
description: 'Transfers are authorised with a TPIN. Create one, then try the transfer again.',
},
BENEFICIARY_ALREADY_ADDED: {
title: 'Payee already saved',
description: 'This payee is already in your list.',
@@ -260,6 +276,10 @@ const ERROR_COPY: Record<AnyErrorCode, ErrorCopy> = {
title: 'Too many wrong codes',
description: 'Start again to get a new code.',
},
TRANSACTION_NOT_FOUND: {
title: 'Transaction not found',
description: 'This transfer is not on your profile any more. Pull to refresh your history.',
},
INTERNAL_ERROR: {
title: 'Something went wrong at the bank',
description: 'Please try again in a moment.',
+36
View File
@@ -8,6 +8,18 @@ export const endpoints = {
crypto: {
publicKey: '/crypto/public-key',
},
/**
* Forgot MPIN. Unauthenticated, like registration — the customer has no MPIN
* to open a session with. `initiate` checks the customer with the CBS and
* sends an OTP; `verifyOtp` swaps the OTP for a one-time reset token;
* `confirm` spends that token on the new, encrypted MPIN and revokes every
* open session. There is no resend endpoint: a fresh `initiate` is the resend.
*/
mpinReset: {
initiate: '/mpin-reset/initiate',
verifyOtp: '/mpin-reset/verify-otp',
confirm: '/mpin-reset/confirm',
},
/**
* Server-driven feature switches. Deliberately callable without a session —
* the sign-in screen's own controls are gated, so this has to resolve before
@@ -21,6 +33,20 @@ export const endpoints = {
me: '/auth/me',
logout: '/auth/logout',
},
/**
* Creating the Transaction PIN that authorises fund transfers. Signed-in
* only: org, customer, mobile, device and profile all come from the session,
* so none of them go in a body. `createInitiate` re-checks the current MPIN
* and sends an OTP; `createVerifyOtp` swaps the OTP for a one-time create
* token; `createConfirm` spends that token on the new, encrypted TPIN. There
* is no resend endpoint: a fresh `createInitiate` — MPIN and all — is the
* resend.
*/
tpin: {
createInitiate: '/tpin/create/initiate',
createVerifyOtp: '/tpin/create/verify-otp',
createConfirm: '/tpin/create/confirm',
},
accounts: {
/**
* Every account on the signed-in profile. No parameters — the profile comes
@@ -78,6 +104,16 @@ export const endpoints = {
initiate: '/fund-transfers/initiate',
verifyOtp: (transferId: string) => `/fund-transfers/${transferId}/verify-otp`,
},
/**
* The customer's own IMPS transfers, as the server recorded them — the status
* authority for how each one ended. The list is paginated and filterable by
* status, mode and an inclusive `fromDate`/`toDate`; the detail takes a
* `transferId` from the list. Payee identifiers arrive already masked.
*/
fundTransferHistory: {
list: '/fund-transfer-history',
detail: (transferId: string) => `/fund-transfer-history/${transferId}`,
},
dashboard: {
summary: '/dashboard/summary',
},
+41 -8
View File
@@ -3,7 +3,7 @@ import forge from 'node-forge';
import { v4 as uuidv4 } from 'uuid';
import { apiClient } from '../api/apiClient';
import { CbsApiError } from '../api/cbsErrors';
import { CbsApiError, isCbsApiError } from '../api/cbsErrors';
import { endpoints } from '../api/endpoints';
import type { PublicKeyResult } from '../../types/cbs';
@@ -15,6 +15,28 @@ const importKey = (base64Der: string): forge.pki.rsa.PublicKey => {
return forge.pki.publicKeyFromAsn1(asn1) as forge.pki.rsa.PublicKey;
};
/**
* `MALFORMED_REQUEST` on an MPIN or TPIN call is what a stale or wrong public
* key looks like from the client — the server will not say more, because
* saying more would leak whether the profile exists. The key is fetched fresh
* on every encryption, so a retry picks up a rotated key; try exactly once
* more. A second failure is a real problem worth surfacing.
*
* `call` must encrypt inside itself, so the retry sends a fresh ciphertext
* rather than the same one twice.
*/
export const withKeyRetry = async <T>(call: () => Promise<T>): Promise<T> => {
try {
return await call();
} catch (error) {
if (isCbsApiError(error) && error.code === 'MALFORMED_REQUEST') {
return call();
}
throw error;
}
};
export const mpinCrypto = {
/**
* The server's *current* key, fetched fresh on every call — the backend
@@ -35,7 +57,7 @@ export const mpinCrypto = {
throw new CbsApiError({
status: 0,
code: 'UNKNOWN_ERROR',
message: 'Could not fetch MPIN encryption key',
message: 'Could not fetch the PIN encryption key',
});
}
@@ -43,8 +65,9 @@ export const mpinCrypto = {
},
/**
* The MPIN never leaves the device in the clear. RSA-OAEP with the server's
* public key, and the ciphertext travels as `encryptedMpin`.
* Neither PIN ever leaves the device in the clear. Both are wrapped as
* `{ [field]: pin, nonce, ts }` and RSA-OAEP encrypted to the server's
* public key — one path for both, so the rules below cannot drift apart.
*
* Three things break integrations here, all of them silently:
*
@@ -58,14 +81,14 @@ export const mpinCrypto = {
* - `nonce` must be fresh on every single call, or it is refused as a replay.
*
* The result is single-use and must be sent within 120 seconds: call this
* immediately before each request, and never reuse one ciphertext for a
* login and a transfer.
* immediately before each request, and never reuse one ciphertext for two
* requests — even when the digits are the same.
*/
async encryptMpin(mpin: string): Promise<string> {
async encryptPin(field: 'mpin' | 'tpin', pin: string): Promise<string> {
const publicKey = await this.getPublicKey();
const payload = JSON.stringify({
mpin,
[field]: pin,
nonce: uuidv4(),
ts: Math.floor(Date.now() / 1000),
});
@@ -77,4 +100,14 @@ export const mpinCrypto = {
}),
);
},
/** Travels as `encryptedMpin`: sign-in, MPIN setup and reset, TPIN creation. */
encryptMpin(mpin: string): Promise<string> {
return this.encryptPin('mpin', mpin);
},
/** Travels as `encryptedTpin`: TPIN creation and every fund transfer. */
encryptTpin(tpin: string): Promise<string> {
return this.encryptPin('tpin', tpin);
},
};
+54 -55
View File
@@ -177,54 +177,53 @@ export type Palette = {
* ══════════════════════════════════════════════════════════════════════════ */
export const light: Palette = {
/* Cool neutrals, tinted toward the brand hue (~211°) at 15% saturation so
* the greys read as part of the navy family rather than as flat web grey.
/* Warm neutrals, tinted toward the brand hue (~2°) at 14% saturation so
* the greys read as part of the red family rather than as flat web grey.
* Each step keeps the luminance of the navy-tinted ramp it replaced, so
* every text-on-surface ratio in this file is unchanged.
* Steps are spaced by CIE L*, which is the honest metric between two
* near-white fills — a WCAG ratio is not meaningful there. */
background: '#EEF2F7', // L* 95.3 app background
background: '#F4F0F0', // L* 95.3 app background
backgroundElevated: '#FFFFFF',
backgroundGradient: ['#FFFFFF', '#F7F9FC', '#EEF2F7'],
backgroundGradient: ['#FFFFFF', '#FAF9F8', '#F4F0F0'],
surface: '#FFFFFF', // L* 100.0 cards
surfaceMuted: '#E2EAF2', // L* 92.3 pressed rows, wells
surfaceSunk: '#D5E0EA', // L* 88.6 non-text only
border: '#D3DDE7', // 1.32:1 on a card, so it is actually visible
borderStrong: '#B4C3D2',
surfaceMuted: '#EDE8E7', // L* 92.3 pressed rows, wells
surfaceSunk: '#E5DCDC', // L* 88.6 non-text only
border: '#E3DAD9', // 1.32:1 on a card, so it is actually visible
borderStrong: '#CDBDBD',
textPrimary: '#0D1B2A', // 17.39 / 15.47 / 14.32
textSecondary: '#48596B', // 7.20 / 6.40 / 5.93 — a neutral slate, NOT
// the brand navy, which is what it used to be
// and why the whole app read as one flat colour
textMuted: '#5A697A', // 5.62 / 5.00 / 4.63
textOnFill: '#FFFFFF', // white on brand navy is 7.12:1
textOnFill: '#FFFFFF', // white on brand red is 5.25:1
brand: '#124577', // ◀ LOCKED IDENTITY
brandLight: '#1B5A96',
brandDark: '#0D3459',
brandDeep: '#092440',
brandSurface: '#E9F0F7',
brandBorder: '#B9CDE0',
// Stops at brandDark rather than brandDeep: the deepest navy reads as
// near-black at the foot of a phone screen, and the wash should still be
// recognisably the brand colour where content sits on it.
brandGradient: ['#1B5A96', '#124577', '#0D3459'],
brand: '#CF2927', // ◀ LOCKED IDENTITY — 5.25 / 4.67 / 4.32. Below
// AA on surfaceMuted, so body text on a muted
// well should use textPrimary, not brand
brandLight: '#E0453F', // decorative only — too light for white text
brandDark: '#A81F1D', // pressed fill; white on it 7.29:1
brandDeep: '#6E1312',
brandSurface: '#FDF0EF', // brand on it 4.72:1; separation from a card 1.11
brandBorder: '#F0B9B6', // 1.70:1 against a card
// Deliberately DEEPER than `brand`. #CF2927 is too light a red to carry the
// translucent on-brand tiers (white @0.75 on it is 3.53:1), so the wash
// starts at a darker red, where every tier below clears AA.
brandGradient: ['#A81E1C', '#8F1918', '#731413'],
/* On the navy wash. Each tier is validated against the LIGHTEST stop
* (#1B5A96), which is the worst case.
/* On the red wash. Each tier is validated against the LIGHTEST stop
* (#A81E1C), which is the worst case.
*
* THE TIERS DO NOT STACK. A translucent fill lightens the wash beneath it,
* so a translucent tone on a translucent fill has less to work with:
*
* white @0.85 on the bare wash 5.64:1 AA
* white @0.85 on an @0.12 fill 4.37:1 FAILS
* white (solid) on an @0.12 fill 5.36:1 AA
*
* so a translucent tone on a translucent fill has less to work with.
* Text directly on the wash may use any tier. Text on a FILL takes the
* solid tone, always. */
onBrandPrimary: '#FFFFFF', // 7.12:1 on the lightest stop
onBrandPrimary: '#FFFFFF', // 7.32:1 on the lightest stop
onBrandSecondary: 'rgba(255, 255, 255, 0.85)', // 5.64:1 — body-safe
onBrandMuted: 'rgba(255, 255, 255, 0.75)', // 4.80:1 — still AA
onBrandSurface: 'rgba(255, 255, 255, 0.12)', // white on it 5.36:1
onBrandSurfaceStrong: 'rgba(255, 255, 255, 0.18)', // white on it 4.66:1 —
onBrandMuted: 'rgba(255, 255, 255, 0.75)', // 4.69:1 — still AA
onBrandSurface: 'rgba(255, 255, 255, 0.12)', // white on it 5.94:1
onBrandSurfaceStrong: 'rgba(255, 255, 255, 0.18)', // white on it 5.22:1 —
// this is the ceiling
onBrandBorder: 'rgba(255, 255, 255, 0.24)',
@@ -266,9 +265,9 @@ export const light: Palette = {
indigo: { tone: '#4F46E5', surface: '#ECEAFE' }, // 5.85 / 5.07 / 1.13
},
overlay: 'rgba(9, 36, 64, 0.55)',
overlay: 'rgba(13, 27, 42, 0.55)',
white: '#FFFFFF',
shadow: '#092440',
shadow: '#0D1B2A',
};
/* ════════════════════════════════════════════════════════════════════════════
@@ -285,43 +284,43 @@ export const light: Palette = {
* borders below — which is why each step is measured against its
* neighbour and not in isolation.
*
* The ground is navy-tinted rather than neutral black: it keeps the app in the
* The ground is warm red-tinted rather than neutral black: it keeps the app in the
* brand family, and pure #000 under light text is the classic recipe for
* halation on an OLED panel.
* ══════════════════════════════════════════════════════════════════════════ */
export const dark: Palette = {
background: '#0B1420',
backgroundElevated: '#131F2C',
backgroundGradient: ['#131F2C', '#0F1A26', '#0B1420'],
surface: '#131F2C',
surfaceMuted: '#1B2937',
surfaceSunk: '#243343', // non-text; only textPrimary 11.31, textSecondary
background: '#171312',
backgroundElevated: '#241C1C',
backgroundGradient: ['#241C1C', '#1E1817', '#171312'],
surface: '#241C1C',
surfaceMuted: '#2F2625',
surfaceSunk: '#3B2F2E', // non-text; only textPrimary 11.31, textSecondary
// 7.39 and brand 4.93 are legible on it
border: '#2E4053', // 1.57:1 against a card
borderStrong: '#3F556D', // 2.17:1
border: '#4A3A3A', // 1.57:1 against a card
borderStrong: '#634F4E', // 2.17:1
textPrimary: '#EAF1F8', // 14.63 / 16.24 / 12.99
textSecondary: '#B6C6D6', // 9.55 / 10.60 / 8.48
textMuted: '#93A6B9', // 6.66 / 7.40 / 5.92
textOnFill: '#0B1420', // ink on `brand` 7.08:1 — white would be 2.61:1
textOnFill: '#0B1420', // ink on `brand` 7.44:1 — white would fail
brand: '#6BA5DC', // 6.38 / 7.08 / 5.67
brandLight: '#8CBEE9',
brandDark: '#4A80B5',
brandDeep: '#0A1E33',
brandSurface: '#17304A', // brand on it 5.16:1; separation from a card 1.24
brandBorder: '#2D4B6B', // 1.85:1 against a card
brand: '#F28580', // 6.70 / 7.44 / 5.95
brandLight: '#F7A9A5',
brandDark: '#D9564F',
brandDeep: '#2A0E0E',
brandSurface: '#3A1E24', // brand on it 6.07:1; separation from a card 1.10
brandBorder: '#663038', // 1.62:1 against a card
// The wash DARKENS in dark mode rather than lightening. White on the
// lightest stop is 10.89:1, so every on-brand tier below clears AA with
// lightest stop is 11.24:1, so every on-brand tier below clears AA with
// room to spare — they were calibrated against the far lighter light-mode
// wash, which is the harder case.
brandGradient: ['#173F63', '#102D49', '#0A1E33'],
brandGradient: ['#6E1E1F', '#521616', '#380F0F'],
onBrandPrimary: '#FFFFFF', // 10.89:1 on the lightest stop
onBrandSecondary: 'rgba(255, 255, 255, 0.85)', // 8.35:1
onBrandMuted: 'rgba(255, 255, 255, 0.75)', // 6.72:1
onBrandSurface: 'rgba(255, 255, 255, 0.12)', // white on it 7.68:1
onBrandPrimary: '#FFFFFF', // 11.24:1 on the lightest stop
onBrandSecondary: 'rgba(255, 255, 255, 0.85)', // 8.48:1
onBrandMuted: 'rgba(255, 255, 255, 0.75)', // 6.96:1
onBrandSurface: 'rgba(255, 255, 255, 0.12)', // white on it 8.24:1
onBrandSurfaceStrong: 'rgba(255, 255, 255, 0.18)',
onBrandBorder: 'rgba(255, 255, 255, 0.24)',
+118 -3
View File
@@ -422,8 +422,8 @@ export type FundTransferStatus =
| 'MANUAL_REVIEW';
/**
* `POST /fund-transfers/initiate`. Runs the IMPS name inquiry and issues an
* OTP; it moves no money.
* `POST /fund-transfers/initiate`. Verifies the TPIN, runs the IMPS name
* inquiry and issues an OTP; it moves no money.
*
* `requestId` is the idempotency key: orgId + userId + requestId is unique, so
* a retry with the same id returns the same transfer rather than a second one.
@@ -434,7 +434,11 @@ export interface FundTransferInitiateRequest {
mode: BeneficiaryMode;
amount: number;
remark?: string;
encryptedMpin: string;
/**
* Transfers are authorised by the TPIN, not the MPIN — the MPIN only opens
* the session. Answers `TPIN_NOT_SET` for a profile that has not created one.
*/
encryptedTpin: string;
payer: { accountNumber: string };
payee: BeneficiaryPayee & { accountType?: string };
}
@@ -677,3 +681,114 @@ export interface FeatureConfigResult {
/** Switches for this session's role and tenant. Applied to the `tenant` layer. */
roleOverrides?: Record<string, boolean>;
}
/* ── Fund transfer history ─────────────────────────────────────────────────── */
/** Filters for `GET /fund-transfer-history`. `ALL` is the server's default. */
export interface FundTransferHistoryQuery {
page?: number;
/** 1–100; the server defaults to 20. */
size?: number;
status?: FundTransferStatus | 'ALL';
mode?: BeneficiaryMode | 'ALL';
/** Inclusive, `yyyy-MM-dd`. */
fromDate?: string;
toDate?: string;
}
/**
* One row of the history list. `payeeDisplay` is already masked by the server
* and is shown exactly as received — never rebuilt into a full number.
*/
export interface FundTransferHistoryItem {
transferId: string;
referenceId: string;
mode: BeneficiaryMode;
amount: number;
status: FundTransferStatus;
/** Extra state for an IMPS outcome the bank has not confirmed yet. */
reconciliationStatus: string | null;
payeeName: string | null;
payeeDisplay: string | null;
failureReason: string | null;
/** UTC. */
createdAt: string;
}
/** Spring's page envelope, as the list endpoint returns it. */
export interface FundTransferHistoryPage {
content: FundTransferHistoryItem[];
number: number;
size: number;
totalElements: number;
totalPages: number;
first: boolean;
last: boolean;
empty: boolean;
}
/** `GET /fund-transfer-history/{transferId}`. Any field after `status` may be null. */
export interface FundTransferDetail extends FundTransferHistoryItem {
requestId: string | null;
remark: string | null;
beneficiaryIfsc: string | null;
beneficiaryMmid: string | null;
impsResponseCode: string | null;
checkTxnAttempts: number | null;
updatedAt: string | null;
reqPayDispatchedAt: string | null;
lastCheckTxnAt: string | null;
}
/* ── MPIN reset ────────────────────────────────────────────────────────────── */
/** `POST /mpin-reset/initiate`. */
export interface MpinResetInitiateResult {
/** Held in screen state only; the OTP step needs it. */
resetId: string;
status: string;
otpExpiresAt: string | null;
attemptsRemaining: number | null;
}
/** `POST /mpin-reset/verify-otp`. */
export interface MpinResetVerifyResult {
/** One-time. Never stored, logged or put in Redux. */
resetToken: string;
status: string;
resetTokenExpiresAt: string | null;
}
/** `POST /mpin-reset/confirm`. */
export interface MpinResetConfirmResult {
status: string;
resetAt: string | null;
}
/* ── TPIN creation ─────────────────────────────────────────────────────────── */
/** `POST /tpin/create/initiate`. The current MPIN checked out and an OTP is on its way. */
export interface TpinCreateInitiateResult {
/** Held in screen state only; the OTP step needs it. */
tpinCreateId: string;
/** `PENDING_OTP`. */
status: string;
otpExpiresAt: string | null;
attemptsRemaining: number | null;
}
/** `POST /tpin/create/verify-otp`. */
export interface TpinCreateVerifyResult {
/** One-time. Never stored, logged or put in Redux. */
tpinCreateToken: string;
/** `OTP_VERIFIED`. */
status: string;
tokenExpiresAt: string | null;
}
/** `POST /tpin/create/confirm`. */
export interface TpinCreateConfirmResult {
/** `ACTIVE` — the TPIN authorises transfers from now on. */
status: string;
createdAt: string | null;
}
+32
View File
@@ -1,5 +1,7 @@
import {
MPIN_LENGTH,
isAcceptableNewMpin,
isSequentialDigits,
isRepeatedDigits,
isValidAccountNumber,
isValidIfsc,
@@ -94,3 +96,33 @@ describe('payee routing fields', () => {
expect(isValidMmid('10727301')).toBe(false);
});
});
describe('isSequentialDigits', () => {
it('flags straight runs up and down', () => {
expect(isSequentialDigits('1234')).toBe(true);
expect(isSequentialDigits('6789')).toBe(true);
expect(isSequentialDigits('4321')).toBe(true);
expect(isSequentialDigits('987654')).toBe(true);
});
it('passes anything that is not one steady step', () => {
expect(isSequentialDigits('1243')).toBe(false);
expect(isSequentialDigits('1357')).toBe(false);
expect(isSequentialDigits('1111')).toBe(false);
expect(isSequentialDigits('9012')).toBe(false);
expect(isSequentialDigits('')).toBe(false);
});
});
describe('isAcceptableNewMpin', () => {
it('refuses runs and repeats when choosing an MPIN', () => {
expect(isAcceptableNewMpin('1234')).toBe(false);
expect(isAcceptableNewMpin('4321')).toBe(false);
expect(isAcceptableNewMpin('1111')).toBe(false);
expect(isAcceptableNewMpin('4271')).toBe(true);
});
it('leaves sign-in able to enter an old sequential MPIN', () => {
expect(isValidMpin('1234')).toBe(true);
});
});
+34
View File
@@ -21,6 +21,40 @@ export const isValidMpin = (value: string) =>
export const isRepeatedDigits = (value: string) =>
value.length > 0 && /^(\d)\1*$/.test(value);
/**
* A straight run up or down — 1234, 6789, 4321. The backend refuses these
* when an MPIN is SET, in the same message-only way as repeated digits.
*/
export const isSequentialDigits = (value: string) => {
if (value.length < 2 || !/^[0-9]+$/.test(value)) {
return false;
}
const step = Number(value[1]) - Number(value[0]);
if (step !== 1 && step !== -1) {
return false;
}
for (let index = 1; index < value.length; index += 1) {
if (Number(value[index]) - Number(value[index - 1]) !== step) {
return false;
}
}
return true;
};
/**
* The stricter check for CHOOSING an MPIN — creation and reset.
*
* Deliberately separate from `isValidMpin`, which sign-in also uses: an MPIN
* like 1234 set before the backend began refusing runs still has to be
* enterable at sign-in, or its owner is locked out of their own profile.
*/
export const isAcceptableNewMpin = (value: string) =>
isValidMpin(value) && !isSequentialDigits(value);
/** Constraints as the API documents them, not as any one bank formats them. */
export const isValidAccountNumber = (value: string) => /^[0-9]{5,20}$/.test(value);