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