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
@@ -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,
},