Add TPIN reset via OTP, with server-clock countdowns and redacted API logs
- Reset TPIN: empty-body initiate -> OTP -> encrypted new TPIN via /tpin/reset/*, from a Services tile, search, and "Forgot your TPIN?" on the transfer's TPIN sheet (returns to the same transfer review) - Handle every documented code: SESSION_INVALID signs out, TPIN_NOT_SET offers Create TPIN, CBS customer/account/org refusals stop the flow, outages retry from the start, expired or spent OTP/token restarts, policy VALIDATION_ERROR stays on the new-TPIN step - Secrets live in screen state only and are dropped as each step ends - Countdowns use the envelope timestamp, kept aside by the interceptor, so a wrong device clock cannot skew OTP or token expiry - New TPINs refuse any three-digit run (1235, 5987), for create too - Dev API logger redacts the session token, OTPs, PINs, encrypted payloads and one-time tokens - Tests: request bodies, RSA-OAEP SHA-256 payload decrypted with Node, fresh nonce on key retry, server-clock deadlines, log redaction Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
f1def81eb7
commit
5fbd13ba12
@@ -184,6 +184,7 @@ export enum FeatureKey {
|
|||||||
SERVICES_MINI_STATEMENT = 'services.miniStatement',
|
SERVICES_MINI_STATEMENT = 'services.miniStatement',
|
||||||
SERVICES_MPIN_CHANGE = 'services.mpinChange',
|
SERVICES_MPIN_CHANGE = 'services.mpinChange',
|
||||||
SERVICES_TPIN_CHANGE = 'services.tpinChange',
|
SERVICES_TPIN_CHANGE = 'services.tpinChange',
|
||||||
|
SERVICES_TPIN_RESET = 'services.tpinReset',
|
||||||
SERVICES_FATE_OF_CHEQUE = 'services.fateOfCheque',
|
SERVICES_FATE_OF_CHEQUE = 'services.fateOfCheque',
|
||||||
|
|
||||||
// --------------------------------------------------------------- Loans ----
|
// --------------------------------------------------------------- Loans ----
|
||||||
|
|||||||
@@ -191,6 +191,7 @@ export const DEFAULT_FEATURE_CONFIG: Record<FeatureKey, FeatureDefinition> = {
|
|||||||
},
|
},
|
||||||
[FeatureKey.SERVICES_FATE_OF_CHEQUE]: { enabled: true },
|
[FeatureKey.SERVICES_FATE_OF_CHEQUE]: { enabled: true },
|
||||||
[FeatureKey.SERVICES_TPIN_CHANGE]: { enabled: true },
|
[FeatureKey.SERVICES_TPIN_CHANGE]: { enabled: true },
|
||||||
|
[FeatureKey.SERVICES_TPIN_RESET]: { enabled: true, description: 'Reset a forgotten TPIN with an OTP' },
|
||||||
// Service-desk entry points to screens the home and profile stacks already
|
// Service-desk entry points to screens the home and profile stacks already
|
||||||
// own. Switching one off takes the tile away, not the screen.
|
// own. Switching one off takes the tile away, not the screen.
|
||||||
[FeatureKey.SERVICES_MINI_STATEMENT]: { enabled: true },
|
[FeatureKey.SERVICES_MINI_STATEMENT]: { enabled: true },
|
||||||
|
|||||||
@@ -0,0 +1,700 @@
|
|||||||
|
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 { secondsUntilDeadline } from '../../../services/api/serverClock';
|
||||||
|
import { toApiError } from '../../../services/api/serviceHelpers';
|
||||||
|
import type { ApiError } from '../../../types/api';
|
||||||
|
import { isValidOtp } from '../../../utils/validation';
|
||||||
|
import { CodeInput } from '../../auth/components/CodeInput';
|
||||||
|
import { tpinService } from '../services/tpinService';
|
||||||
|
import { TPIN_LENGTH, describeTpinProblem, isAcceptableNewTpin } from '../utils/tpinValidation';
|
||||||
|
|
||||||
|
interface TpinResetFlowProps {
|
||||||
|
/**
|
||||||
|
* The session's registered mobile. Feeds the "not the last four digits of
|
||||||
|
* your mobile" rule and the masked 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 new TPIN is active and every secret here is cleared. */
|
||||||
|
onSuccess: () => void;
|
||||||
|
/** Leaves the flow when it cannot go on — no TPIN to reset, or the bank refused. */
|
||||||
|
onExit: () => void;
|
||||||
|
/**
|
||||||
|
* TPIN_NOT_SET: there is nothing to reset, so offer to create one. Left out
|
||||||
|
* where Create TPIN is not reachable, and the customer gets Done instead.
|
||||||
|
*/
|
||||||
|
onCreateTpin?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
type Step = 'start' | 'otp' | 'tpin' | 'notSet' | 'stopped';
|
||||||
|
|
||||||
|
/** How long before "send a new code" is offered. There is no resend endpoint. */
|
||||||
|
const RESEND_COOLDOWN_SECONDS = 30;
|
||||||
|
|
||||||
|
const OTP_LENGTH = 6;
|
||||||
|
|
||||||
|
const STEPS: ReadonlyArray<{ key: Step; label: string }> = [
|
||||||
|
{ key: 'start', label: 'Start' },
|
||||||
|
{ key: 'otp', label: 'OTP' },
|
||||||
|
{ key: 'tpin', label: 'New TPIN' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const COPY = {
|
||||||
|
otpExpired: 'That code expired. Send a new code to continue.',
|
||||||
|
attemptsExceeded: 'Too many wrong codes. Send a new code to try again.',
|
||||||
|
requestInvalid: 'This reset request is no longer valid. Send a new code to start again.',
|
||||||
|
tokenSpent: 'Your time to set a new TPIN ran out. Send a new code to start again.',
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The initiate refusals that end the flow for good. The shared copy for these
|
||||||
|
* is written for registration ("No accounts to link"), so a reset gets its
|
||||||
|
* own; the server's message, when there is one, still takes precedence.
|
||||||
|
*/
|
||||||
|
const STOP_COPY: Record<string, { title: string; description: string }> = {
|
||||||
|
CUSTOMER_NOT_FOUND: {
|
||||||
|
title: 'We could not verify you with your bank',
|
||||||
|
description:
|
||||||
|
"Your bank's records do not match this profile, so your TPIN cannot be reset here. Contact your branch.",
|
||||||
|
},
|
||||||
|
NO_ACCOUNTS_FOUND: {
|
||||||
|
title: 'No accounts found',
|
||||||
|
description:
|
||||||
|
'Your bank returned no accounts for this profile, so your TPIN cannot be reset here. Contact your branch.',
|
||||||
|
},
|
||||||
|
ORG_NOT_CONFIGURED: {
|
||||||
|
title: 'TPIN reset is unavailable',
|
||||||
|
description: 'Your bank has not set this service up yet. Contact your branch.',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatCountdown = (seconds: number) =>
|
||||||
|
`${Math.floor(seconds / 60)}:${`${seconds % 60}`.padStart(2, '0')}`;
|
||||||
|
|
||||||
|
const messageFor = (apiError: ApiError) =>
|
||||||
|
apiError.message || describeError(apiError.code).description;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resetting the TPIN, end to end: send OTP → verify OTP → new TPIN.
|
||||||
|
*
|
||||||
|
* Unlike creating one, a reset needs no MPIN: the signed-in session is the
|
||||||
|
* first factor, and initiate goes out with an empty body — the server takes
|
||||||
|
* org, mobile and device from the session and verifies the customer with the
|
||||||
|
* bank before any OTP is sent. The session must stay the same for all three
|
||||||
|
* calls; SESSION_INVALID anywhere signs the customer out (the interceptor does
|
||||||
|
* that) and this flow drops everything it holds.
|
||||||
|
*
|
||||||
|
* Shared by Services' Reset TPIN and the transfer sheet's "Forgot TPIN?",
|
||||||
|
* 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 OTP, the `tpinResetId`, the one-time `tpinResetToken` 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 dropped the moment its step is done with it: the
|
||||||
|
* reset id once the OTP is verified, the token once it is spent or expires.
|
||||||
|
* Nothing here is persisted, logged or sent to analytics.
|
||||||
|
*
|
||||||
|
* ── TIME ────────────────────────────────────────────────────────────────────
|
||||||
|
* Both countdowns run to deadlines worked out from the server's expiry and the
|
||||||
|
* server's own clock (see `serverClock`), not the handset's idea of the time.
|
||||||
|
* Reaching either one ends the attempt here, without waiting for the server to
|
||||||
|
* refuse a code or token that is already dead.
|
||||||
|
*/
|
||||||
|
export const TpinResetFlow = ({
|
||||||
|
mobileNumber,
|
||||||
|
notice,
|
||||||
|
onSuccess,
|
||||||
|
onExit,
|
||||||
|
onCreateTpin,
|
||||||
|
}: TpinResetFlowProps) => {
|
||||||
|
const theme = useTheme();
|
||||||
|
const toast = useToast();
|
||||||
|
const [step, setStep] = useState<Step>('start');
|
||||||
|
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);
|
||||||
|
/** Why the flow stopped for good, on the `stopped` step. */
|
||||||
|
const [stopped, setStopped] = useState<{ title: string; message: string } | null>(null);
|
||||||
|
|
||||||
|
// Secrets of the flow. Screen state only — see the note above.
|
||||||
|
const [tpinResetId, setTpinResetId] = useState<string | null>(null);
|
||||||
|
const [otpDeadline, setOtpDeadline] = useState<number | null>(null);
|
||||||
|
const [attemptsRemaining, setAttemptsRemaining] = useState<number | null>(null);
|
||||||
|
const [tpinResetToken, setTpinResetToken] = useState<string | null>(null);
|
||||||
|
const [tokenDeadline, setTokenDeadline] = useState<number | null>(null);
|
||||||
|
|
||||||
|
const [now, setNow] = useState(() => Date.now());
|
||||||
|
const [cooldown, setCooldown] = useState(0);
|
||||||
|
const mounted = useRef(true);
|
||||||
|
/**
|
||||||
|
* Set synchronously when a call goes out, so a double tap cannot send two
|
||||||
|
* before `busy` has re-rendered the button disabled.
|
||||||
|
*/
|
||||||
|
const inFlight = useRef(false);
|
||||||
|
|
||||||
|
useEffect(
|
||||||
|
() => () => {
|
||||||
|
mounted.current = false;
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
// One tick drives both countdowns and the resend cooldown.
|
||||||
|
useEffect(() => {
|
||||||
|
if (step !== 'otp' && step !== 'tpin') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const timer = setInterval(() => {
|
||||||
|
setNow(Date.now());
|
||||||
|
setCooldown((value) => (value > 0 ? value - 1 : 0));
|
||||||
|
}, 1000);
|
||||||
|
|
||||||
|
return () => clearInterval(timer);
|
||||||
|
}, [step]);
|
||||||
|
|
||||||
|
const clearSecrets = useCallback(() => {
|
||||||
|
setTpinResetId(null);
|
||||||
|
setOtpDeadline(null);
|
||||||
|
setAttemptsRemaining(null);
|
||||||
|
setTpinResetToken(null);
|
||||||
|
setTokenDeadline(null);
|
||||||
|
setOtp('');
|
||||||
|
setTpin('');
|
||||||
|
setConfirmTpin('');
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Throws the whole attempt away and returns to the start. An OTP can only be
|
||||||
|
* reissued by a fresh initiate, which is one tap from there.
|
||||||
|
*/
|
||||||
|
const restart = useCallback(
|
||||||
|
(message?: string) => {
|
||||||
|
clearSecrets();
|
||||||
|
setFieldError(null);
|
||||||
|
setStep('start');
|
||||||
|
setError(message ?? null);
|
||||||
|
},
|
||||||
|
[clearSecrets],
|
||||||
|
);
|
||||||
|
|
||||||
|
/** TPIN_NOT_SET: nothing to reset, so the flow stops and offers Create. */
|
||||||
|
const endAsNotSet = useCallback(() => {
|
||||||
|
clearSecrets();
|
||||||
|
setFieldError(null);
|
||||||
|
setError(null);
|
||||||
|
setStep('notSet');
|
||||||
|
}, [clearSecrets]);
|
||||||
|
|
||||||
|
/** The bank cannot match or serve this customer; retrying will not change that. */
|
||||||
|
const endAsStopped = useCallback(
|
||||||
|
(apiError: ApiError) => {
|
||||||
|
const copy = (apiError.code && STOP_COPY[apiError.code]) || describeError(apiError.code);
|
||||||
|
|
||||||
|
clearSecrets();
|
||||||
|
setFieldError(null);
|
||||||
|
setError(null);
|
||||||
|
setStopped({ title: copy.title, message: apiError.message || copy.description });
|
||||||
|
setStep('stopped');
|
||||||
|
},
|
||||||
|
[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));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// A code or token that has run out is dead whatever the server would say, so
|
||||||
|
// the attempt ends here. Never mid-call: a confirm sent a moment before the
|
||||||
|
// deadline may still be succeeding, and its answer decides what happens.
|
||||||
|
useEffect(() => {
|
||||||
|
if (inFlight.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (step === 'otp' && secondsUntilDeadline(otpDeadline, now) === 0) {
|
||||||
|
restart(COPY.otpExpired);
|
||||||
|
} else if (step === 'tpin' && secondsUntilDeadline(tokenDeadline, now) === 0) {
|
||||||
|
restart(COPY.tokenSpent);
|
||||||
|
}
|
||||||
|
}, [now, otpDeadline, restart, step, tokenDeadline]);
|
||||||
|
|
||||||
|
/* ── Step 1: initiate — also the resend ───────────────────────────────── */
|
||||||
|
|
||||||
|
const handleInitiate = useCallback(async () => {
|
||||||
|
if (inFlight.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
inFlight.current = true;
|
||||||
|
setBusy(true);
|
||||||
|
setError(null);
|
||||||
|
setFieldError(null);
|
||||||
|
// A new code replaces any code already sent.
|
||||||
|
setOtp('');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await tpinService.initiateReset();
|
||||||
|
|
||||||
|
if (!mounted.current) return;
|
||||||
|
|
||||||
|
setTpinResetId(result.tpinResetId);
|
||||||
|
setOtpDeadline(result.otpDeadline);
|
||||||
|
setAttemptsRemaining(result.attemptsRemaining);
|
||||||
|
setCooldown(RESEND_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);
|
||||||
|
|
||||||
|
switch (apiError.code) {
|
||||||
|
case 'SESSION_INVALID':
|
||||||
|
// The interceptor has already signed the customer out.
|
||||||
|
clearSecrets();
|
||||||
|
return;
|
||||||
|
case 'TPIN_NOT_SET':
|
||||||
|
endAsNotSet();
|
||||||
|
return;
|
||||||
|
case 'CUSTOMER_NOT_FOUND':
|
||||||
|
case 'NO_ACCOUNTS_FOUND':
|
||||||
|
case 'ORG_NOT_CONFIGURED':
|
||||||
|
endAsStopped(apiError);
|
||||||
|
return;
|
||||||
|
default:
|
||||||
|
// CBS_UNAVAILABLE, CBS_ERROR, CONNECTOR_UNAVAILABLE,
|
||||||
|
// OTP_PROVIDER_UNAVAILABLE, SMS_DELIVERY_FAILED and the rest: back to
|
||||||
|
// the start with the server's message, where Send code retries. A
|
||||||
|
// failed resend lands here too — the code it replaced is abandoned.
|
||||||
|
clearSecrets();
|
||||||
|
setStep('start');
|
||||||
|
setError(messageFor(apiError));
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
inFlight.current = false;
|
||||||
|
if (mounted.current) setBusy(false);
|
||||||
|
}
|
||||||
|
}, [clearSecrets, endAsNotSet, endAsStopped, toast]);
|
||||||
|
|
||||||
|
/* ── Step 2: verify OTP ───────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
const handleVerifyOtp = useCallback(async () => {
|
||||||
|
if (!tpinResetId || !isValidOtp(otp) || inFlight.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (secondsUntilDeadline(otpDeadline) === 0) {
|
||||||
|
restart(COPY.otpExpired);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
inFlight.current = true;
|
||||||
|
setBusy(true);
|
||||||
|
setError(null);
|
||||||
|
setFieldError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await tpinService.verifyResetOtp({ tpinResetId, otp });
|
||||||
|
|
||||||
|
if (!mounted.current) return;
|
||||||
|
|
||||||
|
// Only the token goes forward; the OTP and the reset id are done with.
|
||||||
|
setOtp('');
|
||||||
|
setTpinResetId(null);
|
||||||
|
setOtpDeadline(null);
|
||||||
|
setAttemptsRemaining(null);
|
||||||
|
setTpinResetToken(result.tpinResetToken);
|
||||||
|
setTokenDeadline(result.tokenDeadline);
|
||||||
|
setNow(Date.now());
|
||||||
|
setStep('tpin');
|
||||||
|
} catch (caught) {
|
||||||
|
if (!mounted.current) return;
|
||||||
|
|
||||||
|
const apiError = toApiError(caught);
|
||||||
|
setOtp('');
|
||||||
|
|
||||||
|
switch (apiError.code) {
|
||||||
|
case 'SESSION_INVALID':
|
||||||
|
clearSecrets();
|
||||||
|
return;
|
||||||
|
case 'TPIN_NOT_SET':
|
||||||
|
endAsNotSet();
|
||||||
|
return;
|
||||||
|
// An expired, exhausted or voided request cannot be retried — a fresh
|
||||||
|
// initiate can.
|
||||||
|
case 'OTP_EXPIRED':
|
||||||
|
restart(COPY.otpExpired);
|
||||||
|
return;
|
||||||
|
case 'OTP_ATTEMPTS_EXCEEDED':
|
||||||
|
restart(COPY.attemptsExceeded);
|
||||||
|
return;
|
||||||
|
case 'OTP_NOT_VERIFIED':
|
||||||
|
restart(COPY.requestInvalid);
|
||||||
|
return;
|
||||||
|
case 'OTP_INVALID': {
|
||||||
|
const remaining =
|
||||||
|
attemptsRemaining === null ? null : Math.max(0, attemptsRemaining - 1);
|
||||||
|
|
||||||
|
// Retry only while attempts remain; with none left the request is
|
||||||
|
// spent and only a new code will do.
|
||||||
|
if (remaining === 0) {
|
||||||
|
restart(COPY.attemptsExceeded);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setAttemptsRemaining(remaining);
|
||||||
|
setFieldError(
|
||||||
|
remaining
|
||||||
|
? `That code is incorrect. ${remaining} ${remaining === 1 ? 'attempt' : 'attempts'} left.`
|
||||||
|
: 'That code is incorrect.',
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
// VALIDATION_ERROR paints its `otp` entry under the field.
|
||||||
|
showFailure(apiError, 'otp');
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
inFlight.current = false;
|
||||||
|
if (mounted.current) setBusy(false);
|
||||||
|
}
|
||||||
|
}, [
|
||||||
|
attemptsRemaining,
|
||||||
|
clearSecrets,
|
||||||
|
endAsNotSet,
|
||||||
|
otp,
|
||||||
|
otpDeadline,
|
||||||
|
restart,
|
||||||
|
showFailure,
|
||||||
|
tpinResetId,
|
||||||
|
]);
|
||||||
|
|
||||||
|
/* ── Step 3: encrypt the new TPIN and confirm ─────────────────────────── */
|
||||||
|
|
||||||
|
const handleConfirm = useCallback(async () => {
|
||||||
|
if (
|
||||||
|
!tpinResetToken ||
|
||||||
|
!isAcceptableNewTpin(tpin, mobileNumber) ||
|
||||||
|
confirmTpin !== tpin ||
|
||||||
|
inFlight.current
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// An expired token is refused anyway; say so without the round trip.
|
||||||
|
if (secondsUntilDeadline(tokenDeadline) === 0) {
|
||||||
|
restart(COPY.tokenSpent);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
inFlight.current = true;
|
||||||
|
setBusy(true);
|
||||||
|
setError(null);
|
||||||
|
setFieldError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Fetches the current public key and encrypts {tpin, nonce, ts} fresh.
|
||||||
|
await tpinService.confirmReset({ tpinResetToken, tpin });
|
||||||
|
|
||||||
|
if (!mounted.current) return;
|
||||||
|
|
||||||
|
clearSecrets();
|
||||||
|
toast('TPIN reset', 'success', 'Use your new TPIN to authorise fund transfers.');
|
||||||
|
onSuccess();
|
||||||
|
} catch (caught) {
|
||||||
|
if (!mounted.current) return;
|
||||||
|
|
||||||
|
const apiError = toApiError(caught);
|
||||||
|
setTpin('');
|
||||||
|
setConfirmTpin('');
|
||||||
|
|
||||||
|
switch (apiError.code) {
|
||||||
|
case 'SESSION_INVALID':
|
||||||
|
clearSecrets();
|
||||||
|
return;
|
||||||
|
case 'TPIN_NOT_SET':
|
||||||
|
endAsNotSet();
|
||||||
|
return;
|
||||||
|
// A spent or expired token: this attempt is over.
|
||||||
|
case 'OTP_NOT_VERIFIED':
|
||||||
|
case 'OTP_EXPIRED':
|
||||||
|
case 'OTP_ATTEMPTS_EXCEEDED':
|
||||||
|
restart(COPY.tokenSpent);
|
||||||
|
return;
|
||||||
|
case 'INTERNAL_ERROR':
|
||||||
|
// Nothing sensitive is kept past an unexplained failure — token
|
||||||
|
// included. The customer starts again with a new code.
|
||||||
|
restart(messageFor(apiError));
|
||||||
|
return;
|
||||||
|
case 'VALIDATION_ERROR':
|
||||||
|
// A policy refusal — a sequence, the mobile's last four, the date of
|
||||||
|
// birth, the current or a recent TPIN — comes as a message with no
|
||||||
|
// field map. It is about the TPIN, so it goes under the TPIN, and the
|
||||||
|
// same token takes a different one.
|
||||||
|
if (!apiError.fieldErrors) {
|
||||||
|
setFieldError(messageFor(apiError));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
showFailure(apiError, 'encryptedTpin');
|
||||||
|
return;
|
||||||
|
default:
|
||||||
|
// MALFORMED_REQUEST has already been retried once with a new key and
|
||||||
|
// payload; Reset TPIN again builds another. The token stays.
|
||||||
|
showFailure(apiError, 'encryptedTpin');
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
inFlight.current = false;
|
||||||
|
if (mounted.current) setBusy(false);
|
||||||
|
}
|
||||||
|
}, [
|
||||||
|
clearSecrets,
|
||||||
|
confirmTpin,
|
||||||
|
endAsNotSet,
|
||||||
|
mobileNumber,
|
||||||
|
onSuccess,
|
||||||
|
restart,
|
||||||
|
showFailure,
|
||||||
|
toast,
|
||||||
|
tokenDeadline,
|
||||||
|
tpin,
|
||||||
|
tpinResetToken,
|
||||||
|
]);
|
||||||
|
|
||||||
|
/* ── render ───────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
if (step === 'notSet') {
|
||||||
|
return (
|
||||||
|
<View style={styles.container}>
|
||||||
|
<InfoBanner
|
||||||
|
tone="info"
|
||||||
|
title="You don't have a TPIN yet"
|
||||||
|
message="There is nothing to reset. Create a TPIN to authorise your fund transfers."
|
||||||
|
/>
|
||||||
|
<View style={styles.actions}>
|
||||||
|
{onCreateTpin ? <Button title="Create TPIN" onPress={onCreateTpin} /> : null}
|
||||||
|
<Button
|
||||||
|
title={onCreateTpin ? 'Not now' : 'Done'}
|
||||||
|
variant={onCreateTpin ? 'ghost' : 'primary'}
|
||||||
|
onPress={onExit}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (step === 'stopped') {
|
||||||
|
return (
|
||||||
|
<View style={styles.container}>
|
||||||
|
<InfoBanner
|
||||||
|
tone="danger"
|
||||||
|
title={stopped?.title}
|
||||||
|
message={stopped?.message ?? describeError(undefined).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' ? secondsUntilDeadline(otpDeadline, now) : null;
|
||||||
|
const otpExpired = otpSecondsLeft === 0;
|
||||||
|
const tokenSecondsLeft = step === 'tpin' ? secondsUntilDeadline(tokenDeadline, now) : null;
|
||||||
|
|
||||||
|
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 === 'start' ? (
|
||||||
|
<>
|
||||||
|
{notice ? <InfoBanner tone="info" message={notice} /> : null}
|
||||||
|
|
||||||
|
<Card style={styles.card}>
|
||||||
|
<Text style={[theme.text.bodyStrong, { color: theme.colors.textPrimary }]}>
|
||||||
|
Forgot your TPIN?
|
||||||
|
</Text>
|
||||||
|
<Text style={[theme.text.body, { color: theme.colors.textSecondary }]}>
|
||||||
|
{sentTo
|
||||||
|
? `We'll confirm your details with your bank, then text a one-time code to ${sentTo}. Enter it to set a new ${TPIN_LENGTH}-digit TPIN.`
|
||||||
|
: `We'll confirm your details with your bank, then text a one-time code to your registered mobile. Enter it to set a new ${TPIN_LENGTH}-digit TPIN.`}
|
||||||
|
</Text>
|
||||||
|
<Text style={[theme.text.caption, { color: theme.colors.textMuted }]}>
|
||||||
|
Your current TPIN stays active until the new one is set.
|
||||||
|
</Text>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
title="Send code"
|
||||||
|
onPress={handleInitiate}
|
||||||
|
disabled={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={handleInitiate}
|
||||||
|
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 new TPIN"
|
||||||
|
value={confirmTpin}
|
||||||
|
onChangeText={(value) => {
|
||||||
|
setConfirmTpin(value);
|
||||||
|
setError(null);
|
||||||
|
}}
|
||||||
|
length={TPIN_LENGTH}
|
||||||
|
secure
|
||||||
|
editable={!busy}
|
||||||
|
error={confirmProblem}
|
||||||
|
onSubmit={handleConfirm}
|
||||||
|
/>
|
||||||
|
{tokenSecondsLeft !== null ? (
|
||||||
|
<Text style={[theme.text.caption, { color: theme.colors.textSecondary }]}>
|
||||||
|
{`Set your new TPIN within ${formatCountdown(tokenSecondsLeft)}`}
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<InfoBanner
|
||||||
|
tone="info"
|
||||||
|
message="Avoid runs like 123 or 987, your date of birth and the last four digits of your mobile. It must also differ from your current TPIN and your last three."
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
title="Reset 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',
|
||||||
|
},
|
||||||
|
actions: {
|
||||||
|
marginTop: 'auto',
|
||||||
|
gap: 12,
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -194,6 +194,15 @@ export function PaymentConfirmationScreen({ navigation, route }: Props) {
|
|||||||
navigation.navigate('CreateTpin');
|
navigation.navigate('CreateTpin');
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** "Forgot TPIN?" — the same round trip as above, to reset one instead. */
|
||||||
|
const openResetTpin = () => {
|
||||||
|
requestIdRef.current = null;
|
||||||
|
setTpin('');
|
||||||
|
setTpinError(undefined);
|
||||||
|
setStep('review');
|
||||||
|
navigation.navigate('ResetTpin');
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Saving the payee is deliberately *after* the transfer succeeds, and
|
* Saving the payee is deliberately *after* the transfer succeeds, and
|
||||||
* deliberately not allowed to fail it: a payee the customer asked to keep is
|
* deliberately not allowed to fail it: a payee the customer asked to keep is
|
||||||
@@ -537,6 +546,12 @@ export function PaymentConfirmationScreen({ navigation, route }: Props) {
|
|||||||
disabled={!isValidTpinEntry(tpin) || isInitiating}
|
disabled={!isValidTpinEntry(tpin) || isInitiating}
|
||||||
loading={isInitiating}
|
loading={isInitiating}
|
||||||
/>
|
/>
|
||||||
|
<Button
|
||||||
|
title="Forgot your TPIN? Reset it"
|
||||||
|
variant="ghost"
|
||||||
|
onPress={openResetTpin}
|
||||||
|
disabled={isInitiating}
|
||||||
|
/>
|
||||||
<Button
|
<Button
|
||||||
title="Don't have a TPIN? Create one"
|
title="Don't have a TPIN? Create one"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
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 { TpinResetFlow } from '../components/TpinResetFlow';
|
||||||
|
|
||||||
|
type Props = Readonly<NativeStackScreenProps<PaymentsStackParamList, 'ResetTpin'>>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reset TPIN, reached from the transfer screen's "Forgot TPIN?".
|
||||||
|
*
|
||||||
|
* 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 set.
|
||||||
|
*/
|
||||||
|
export default function ResetTpinScreen({ navigation }: Props) {
|
||||||
|
const session = useAppSelector((state) => state.auth.session);
|
||||||
|
const lastProfile = useAppSelector((state) => state.auth.lastProfile);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ScreenShell
|
||||||
|
title="Reset TPIN"
|
||||||
|
subtitle="Set a new TPIN, then confirm your transfer"
|
||||||
|
contentContainerStyle={styles.container}
|
||||||
|
>
|
||||||
|
<TpinResetFlow
|
||||||
|
mobileNumber={session?.mobileNumber ?? lastProfile?.mobileNumber}
|
||||||
|
notice="Your transfer is waiting. Once your new TPIN is set, you'll return to it to confirm."
|
||||||
|
onSuccess={() => navigation.goBack()}
|
||||||
|
onExit={() => navigation.goBack()}
|
||||||
|
// Swapped in place, so going back from Create still lands on the review.
|
||||||
|
onCreateTpin={() => navigation.replace('CreateTpin')}
|
||||||
|
/>
|
||||||
|
</ScreenShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: {
|
||||||
|
padding: 16,
|
||||||
|
flexGrow: 1,
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,187 @@
|
|||||||
|
import { constants, generateKeyPairSync, privateDecrypt } from 'crypto';
|
||||||
|
|
||||||
|
import { apiClient } from '../../../services/api/apiClient';
|
||||||
|
import { CbsApiError } from '../../../services/api/cbsErrors';
|
||||||
|
import { rememberServerTime } from '../../../services/api/serverClock';
|
||||||
|
import { tpinService } from './tpinService';
|
||||||
|
|
||||||
|
jest.mock('../../../services/api/apiClient', () => ({
|
||||||
|
apiClient: { get: jest.fn(), post: jest.fn() },
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Node supplies crypto.getRandomValues; the native polyfill has nothing to do here.
|
||||||
|
jest.mock('react-native-get-random-values', () => ({}));
|
||||||
|
|
||||||
|
// The react-native preset resolves uuid to its ES-module browser build, which
|
||||||
|
// Jest will not parse. Node's own v4 generator stands in for it.
|
||||||
|
jest.mock('uuid', () => ({ v4: () => jest.requireActual('crypto').randomUUID() }));
|
||||||
|
|
||||||
|
const get = apiClient.get as jest.Mock;
|
||||||
|
const post = apiClient.post as jest.Mock;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A throwaway key pair standing in for the server's. Decrypting with Node's
|
||||||
|
* own OAEP, SHA-256 for both the digest and MGF1, is an independent check of
|
||||||
|
* the contract the backend decrypts with — not the same library agreeing with
|
||||||
|
* itself.
|
||||||
|
*/
|
||||||
|
const { publicKey, privateKey } = generateKeyPairSync('rsa', { modulusLength: 2048 });
|
||||||
|
const publicKeyBase64 = publicKey.export({ type: 'spki', format: 'der' }).toString('base64');
|
||||||
|
|
||||||
|
const decrypt = (encrypted: string) =>
|
||||||
|
JSON.parse(
|
||||||
|
privateDecrypt(
|
||||||
|
{ key: privateKey, padding: constants.RSA_PKCS1_OAEP_PADDING, oaepHash: 'sha256' },
|
||||||
|
Buffer.from(encrypted, 'base64'),
|
||||||
|
).toString('utf8'),
|
||||||
|
);
|
||||||
|
|
||||||
|
/** A response as the interceptor would hand it back: unwrapped, timestamp filed. */
|
||||||
|
const answer = <T,>(data: T, timestamp?: string) => {
|
||||||
|
const response = { data };
|
||||||
|
rememberServerTime(response, timestamp);
|
||||||
|
return response;
|
||||||
|
};
|
||||||
|
|
||||||
|
const UUID_V4 = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
get.mockReset();
|
||||||
|
post.mockReset();
|
||||||
|
get.mockResolvedValue({
|
||||||
|
data: {
|
||||||
|
algorithm: 'RSA',
|
||||||
|
transformation: 'RSA/ECB/OAEPWithSHA-256AndMGF1Padding',
|
||||||
|
publicKey: publicKeyBase64,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('tpinService.initiateReset', () => {
|
||||||
|
it('sends an empty body — org, mobile and device come from the session', async () => {
|
||||||
|
post.mockResolvedValue(
|
||||||
|
answer(
|
||||||
|
{
|
||||||
|
tpinResetId: 'e79eb452-32c9-4690-955b-81d8bb223ec3',
|
||||||
|
status: 'PENDING_OTP',
|
||||||
|
otpExpiresAt: '2026-09-30T13:50:31.1653828+05:30',
|
||||||
|
attemptsRemaining: 3,
|
||||||
|
},
|
||||||
|
'2026-09-30T13:45:31.1653828+05:30',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
const before = Date.now();
|
||||||
|
const result = await tpinService.initiateReset();
|
||||||
|
|
||||||
|
expect(post).toHaveBeenCalledTimes(1);
|
||||||
|
expect(post).toHaveBeenCalledWith('/tpin/reset/initiate', {});
|
||||||
|
expect(result.tpinResetId).toBe('e79eb452-32c9-4690-955b-81d8bb223ec3');
|
||||||
|
expect(result.attemptsRemaining).toBe(3);
|
||||||
|
// Five minutes by the server's clock, from now on this one — whatever
|
||||||
|
// the date on this machine.
|
||||||
|
expect(result.otpDeadline).toBeGreaterThanOrEqual(before + 5 * 60 * 1000);
|
||||||
|
expect(result.otpDeadline).toBeLessThanOrEqual(Date.now() + 5 * 60 * 1000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('passes a refusal straight through for the flow to branch on', async () => {
|
||||||
|
post.mockRejectedValue(
|
||||||
|
new CbsApiError({ status: 404, code: 'TPIN_NOT_SET', message: 'No active TPIN' }),
|
||||||
|
);
|
||||||
|
|
||||||
|
await expect(tpinService.initiateReset()).rejects.toMatchObject({ code: 'TPIN_NOT_SET' });
|
||||||
|
expect(post).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('tpinService.verifyResetOtp', () => {
|
||||||
|
it('sends the reset id and the OTP, and nothing else', async () => {
|
||||||
|
post.mockResolvedValue(
|
||||||
|
answer(
|
||||||
|
{
|
||||||
|
tpinResetToken: 'one-time-reset-token',
|
||||||
|
status: 'OTP_VERIFIED',
|
||||||
|
tokenExpiresAt: '2026-09-30T13:51:02.766123+05:30',
|
||||||
|
},
|
||||||
|
'2026-09-30T13:46:02.766123+05:30',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
const before = Date.now();
|
||||||
|
const result = await tpinService.verifyResetOtp({
|
||||||
|
tpinResetId: 'e79eb452-32c9-4690-955b-81d8bb223ec3',
|
||||||
|
otp: '123456',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(post).toHaveBeenCalledWith('/tpin/reset/verify-otp', {
|
||||||
|
tpinResetId: 'e79eb452-32c9-4690-955b-81d8bb223ec3',
|
||||||
|
otp: '123456',
|
||||||
|
});
|
||||||
|
expect(result.tpinResetToken).toBe('one-time-reset-token');
|
||||||
|
expect(result.tokenDeadline).toBeGreaterThanOrEqual(before + 5 * 60 * 1000);
|
||||||
|
expect(result.tokenDeadline).toBeLessThanOrEqual(Date.now() + 5 * 60 * 1000);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('tpinService.confirmReset', () => {
|
||||||
|
it('sends the token and the TPIN encrypted as {tpin, nonce, ts} — never the TPIN itself', async () => {
|
||||||
|
post.mockResolvedValue(answer({ status: 'ACTIVE', resetAt: '2026-09-30T13:47:34+05:30' }));
|
||||||
|
|
||||||
|
const before = Math.floor(Date.now() / 1000);
|
||||||
|
const result = await tpinService.confirmReset({
|
||||||
|
tpinResetToken: 'one-time-reset-token',
|
||||||
|
tpin: '5826',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.status).toBe('ACTIVE');
|
||||||
|
// The key is fetched fresh, immediately before encrypting.
|
||||||
|
expect(get).toHaveBeenCalledWith('/crypto/public-key');
|
||||||
|
|
||||||
|
const [path, body] = post.mock.calls[0];
|
||||||
|
expect(path).toBe('/tpin/reset/confirm');
|
||||||
|
expect(Object.keys(body).sort()).toEqual(['encryptedTpin', 'tpinResetToken']);
|
||||||
|
expect(body.tpinResetToken).toBe('one-time-reset-token');
|
||||||
|
expect(body.encryptedTpin).not.toContain('5826');
|
||||||
|
|
||||||
|
const payload = decrypt(body.encryptedTpin);
|
||||||
|
expect(Object.keys(payload).sort()).toEqual(['nonce', 'tpin', 'ts']);
|
||||||
|
expect(payload.tpin).toBe('5826');
|
||||||
|
expect(payload.nonce).toMatch(UUID_V4);
|
||||||
|
// Seconds, not milliseconds.
|
||||||
|
expect(payload.ts).toBeGreaterThanOrEqual(before);
|
||||||
|
expect(payload.ts).toBeLessThanOrEqual(Math.floor(Date.now() / 1000));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('retries MALFORMED_REQUEST once with a fresh key and a fresh payload', async () => {
|
||||||
|
post
|
||||||
|
.mockRejectedValueOnce(
|
||||||
|
new CbsApiError({ status: 400, code: 'MALFORMED_REQUEST', message: 'Payload expired' }),
|
||||||
|
)
|
||||||
|
.mockResolvedValueOnce(answer({ status: 'ACTIVE', resetAt: null }));
|
||||||
|
|
||||||
|
await tpinService.confirmReset({ tpinResetToken: 'one-time-reset-token', tpin: '5826' });
|
||||||
|
|
||||||
|
expect(get).toHaveBeenCalledTimes(2);
|
||||||
|
expect(post).toHaveBeenCalledTimes(2);
|
||||||
|
|
||||||
|
const first = post.mock.calls[0][1].encryptedTpin;
|
||||||
|
const second = post.mock.calls[1][1].encryptedTpin;
|
||||||
|
expect(second).not.toBe(first);
|
||||||
|
expect(decrypt(second).nonce).not.toBe(decrypt(first).nonce);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not retry a policy refusal — the customer picks a different TPIN', async () => {
|
||||||
|
post.mockRejectedValue(
|
||||||
|
new CbsApiError({
|
||||||
|
status: 400,
|
||||||
|
code: 'VALIDATION_ERROR',
|
||||||
|
message: 'TPIN cannot contain sequential digits',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
tpinService.confirmReset({ tpinResetToken: 'one-time-reset-token', tpin: '1235' }),
|
||||||
|
).rejects.toMatchObject({ code: 'VALIDATION_ERROR' });
|
||||||
|
expect(post).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,20 +1,25 @@
|
|||||||
import { apiClient } from '../../../services/api/apiClient';
|
import { apiClient } from '../../../services/api/apiClient';
|
||||||
import { endpoints } from '../../../services/api/endpoints';
|
import { endpoints } from '../../../services/api/endpoints';
|
||||||
|
import { serverTimeOf, toLocalDeadline } from '../../../services/api/serverClock';
|
||||||
import { mpinCrypto, withKeyRetry } from '../../../services/security/mpinCrypto';
|
import { mpinCrypto, withKeyRetry } from '../../../services/security/mpinCrypto';
|
||||||
import type {
|
import type {
|
||||||
TpinCreateConfirmResult,
|
TpinCreateConfirmResult,
|
||||||
TpinCreateInitiateResult,
|
TpinCreateInitiateResult,
|
||||||
TpinCreateVerifyResult,
|
TpinCreateVerifyResult,
|
||||||
|
TpinResetConfirmResult,
|
||||||
|
TpinResetInitiateResult,
|
||||||
|
TpinResetVerifyResult,
|
||||||
} from '../../../types/cbs';
|
} from '../../../types/cbs';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Creating the TPIN. Every call rides on the signed-in session — the
|
* Creating and resetting the TPIN. Every call rides on the signed-in session —
|
||||||
* interceptor attaches the Bearer token, and the server derives org, customer,
|
* the interceptor attaches the Bearer token, and the server derives org,
|
||||||
* mobile, device and profile from it, so none of those are sent here.
|
* 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
|
* 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
|
* store, where dev tooling records them, and the create and reset ids, the
|
||||||
* and the OTP must live in screen state only. See `TpinCreateFlow`.
|
* one-time tokens and the OTP must live in screen state only. See
|
||||||
|
* `TpinCreateFlow` and `TpinResetFlow`.
|
||||||
*/
|
*/
|
||||||
export const tpinService = {
|
export const tpinService = {
|
||||||
/**
|
/**
|
||||||
@@ -59,4 +64,62 @@ export const tpinService = {
|
|||||||
return response.data;
|
return response.data;
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/* ── Reset ──────────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reset step 1: the bank re-verifies the signed-in customer and an OTP goes
|
||||||
|
* to the session's mobile. The body is empty on purpose — org, mobile and
|
||||||
|
* device come from the session and the server will not take them from the
|
||||||
|
* client. There is no resend endpoint; calling this again is the resend.
|
||||||
|
*
|
||||||
|
* `otpDeadline` is the OTP's expiry on this device's clock, worked out from
|
||||||
|
* the server's own `timestamp` so a wrong handset clock cannot skew it.
|
||||||
|
*/
|
||||||
|
async initiateReset() {
|
||||||
|
const response = await apiClient.post<TpinResetInitiateResult>(
|
||||||
|
endpoints.tpin.resetInitiate,
|
||||||
|
{},
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
...response.data,
|
||||||
|
otpDeadline: toLocalDeadline(response.data.otpExpiresAt, serverTimeOf(response)),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reset step 2: swaps the OTP for a one-time reset token. `tokenDeadline` is
|
||||||
|
* the token's expiry on this device's clock, as above.
|
||||||
|
*/
|
||||||
|
async verifyResetOtp({ tpinResetId, otp }: { tpinResetId: string; otp: string }) {
|
||||||
|
const response = await apiClient.post<TpinResetVerifyResult>(
|
||||||
|
endpoints.tpin.resetVerifyOtp,
|
||||||
|
{ tpinResetId, otp },
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
...response.data,
|
||||||
|
tokenDeadline: toLocalDeadline(response.data.tokenExpiresAt, serverTimeOf(response)),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reset step 3: spends the token on the new TPIN. The public key is fetched
|
||||||
|
* and `{tpin, nonce, ts}` encrypted afresh inside the retry, so the one
|
||||||
|
* MALFORMED_REQUEST retry — a rotated key, a payload past its 120 seconds —
|
||||||
|
* goes out with a new key, nonce and timestamp, never a reused ciphertext.
|
||||||
|
*/
|
||||||
|
async confirmReset({ tpinResetToken, tpin }: { tpinResetToken: string; tpin: string }) {
|
||||||
|
return withKeyRetry(async () => {
|
||||||
|
const encryptedTpin = await mpinCrypto.encryptTpin(tpin);
|
||||||
|
|
||||||
|
const response = await apiClient.post<TpinResetConfirmResult>(
|
||||||
|
endpoints.tpin.resetConfirm,
|
||||||
|
{ tpinResetToken, encryptedTpin },
|
||||||
|
);
|
||||||
|
|
||||||
|
return response.data;
|
||||||
|
});
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import {
|
import {
|
||||||
TPIN_LENGTH,
|
TPIN_LENGTH,
|
||||||
|
containsSequentialRun,
|
||||||
describeTpinProblem,
|
describeTpinProblem,
|
||||||
isAcceptableNewTpin,
|
isAcceptableNewTpin,
|
||||||
isValidTpinEntry,
|
isValidTpinEntry,
|
||||||
@@ -38,6 +39,22 @@ describe('describeTpinProblem', () => {
|
|||||||
expect(describeTpinProblem('6789')).toMatch(/run of digits/);
|
expect(describeTpinProblem('6789')).toMatch(/run of digits/);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// The policy refuses "a sequence of three digits", so a run of three inside
|
||||||
|
// the four counts as much as one spanning all of them.
|
||||||
|
it('refuses a run of three anywhere in the TPIN', () => {
|
||||||
|
expect(describeTpinProblem('1235')).toMatch(/run of digits/);
|
||||||
|
expect(describeTpinProblem('5987')).toMatch(/run of digits/);
|
||||||
|
expect(describeTpinProblem('0124')).toMatch(/run of digits/);
|
||||||
|
expect(describeTpinProblem('7210')).toMatch(/run of digits/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('allows a TPIN with no run of three', () => {
|
||||||
|
expect(describeTpinProblem('1243')).toBeUndefined();
|
||||||
|
expect(describeTpinProblem('1357')).toBeUndefined();
|
||||||
|
// No wrap-around from 9 to 0.
|
||||||
|
expect(describeTpinProblem('8901')).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
it('refuses the same digit repeated', () => {
|
it('refuses the same digit repeated', () => {
|
||||||
expect(describeTpinProblem('1111')).toMatch(/same digit/);
|
expect(describeTpinProblem('1111')).toMatch(/same digit/);
|
||||||
});
|
});
|
||||||
@@ -65,3 +82,16 @@ describe('isAcceptableNewTpin', () => {
|
|||||||
expect(isAcceptableNewTpin('0101', '9300000101')).toBe(false);
|
expect(isAcceptableNewTpin('0101', '9300000101')).toBe(false);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('containsSequentialRun', () => {
|
||||||
|
it('finds a run of three at the start, the end, or spanning the whole value', () => {
|
||||||
|
expect(containsSequentialRun('1239')).toBe(true);
|
||||||
|
expect(containsSequentialRun('9321')).toBe(true);
|
||||||
|
expect(containsSequentialRun('3456')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not count a run of two', () => {
|
||||||
|
expect(containsSequentialRun('1290')).toBe(false);
|
||||||
|
expect(containsSequentialRun('12')).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -3,6 +3,26 @@ import { isRepeatedDigits, isSequentialDigits } from '../../../utils/validation'
|
|||||||
/** A TPIN is always exactly four digits. */
|
/** A TPIN is always exactly four digits. */
|
||||||
export const TPIN_LENGTH = 4;
|
export const TPIN_LENGTH = 4;
|
||||||
|
|
||||||
|
/** The shortest run up or down the backend's TPIN policy refuses. */
|
||||||
|
const SEQUENCE_RUN_LENGTH = 3;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* True when any three neighbouring digits run straight up or down — 1235 (123),
|
||||||
|
* 5987 (987) and 1234 alike. The TPIN policy refuses "a sequence of three
|
||||||
|
* digits" and answers "TPIN cannot contain sequential digits", so a run
|
||||||
|
* anywhere counts, not only one spanning all four. No wrap-around: 8901 is not
|
||||||
|
* a run.
|
||||||
|
*/
|
||||||
|
export const containsSequentialRun = (value: string) => {
|
||||||
|
for (let start = 0; start + SEQUENCE_RUN_LENGTH <= value.length; start += 1) {
|
||||||
|
if (isSequentialDigits(value.slice(start, start + SEQUENCE_RUN_LENGTH))) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Any four digits — for ENTERING the TPIN on a transfer. Deliberately loose:
|
* 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.
|
* whether they are the right four is the server's to say, with INVALID_TPIN.
|
||||||
@@ -19,8 +39,9 @@ export const isValidTpinEntry = (value: string) =>
|
|||||||
* than after one.
|
* than after one.
|
||||||
*
|
*
|
||||||
* The backend stays the final authority. It also refuses a TPIN matching the
|
* 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
|
* customer's date of birth, which the app never holds, and — on a reset — the
|
||||||
* arrive as a VALIDATION_ERROR on confirm.
|
* current TPIN or any of the previous three, which only the server's history
|
||||||
|
* knows. Those can only arrive as a VALIDATION_ERROR on confirm.
|
||||||
*/
|
*/
|
||||||
export const describeTpinProblem = (value: string, mobileNumber?: string): string | undefined => {
|
export const describeTpinProblem = (value: string, mobileNumber?: string): string | undefined => {
|
||||||
if (value.length === 0) {
|
if (value.length === 0) {
|
||||||
@@ -39,8 +60,8 @@ export const describeTpinProblem = (value: string, mobileNumber?: string): strin
|
|||||||
return 'Pick a TPIN that is not the same digit repeated';
|
return 'Pick a TPIN that is not the same digit repeated';
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isSequentialDigits(value)) {
|
if (containsSequentialRun(value)) {
|
||||||
return 'Pick a TPIN that is not a run of digits like 1234 or 4321';
|
return 'Pick a TPIN without a run of digits like 123 or 987';
|
||||||
}
|
}
|
||||||
|
|
||||||
const mobileDigits = mobileNumber?.replace(/\D/g, '') ?? '';
|
const mobileDigits = mobileNumber?.replace(/\D/g, '') ?? '';
|
||||||
|
|||||||
@@ -272,6 +272,17 @@ export function buildUserSearchCatalog(
|
|||||||
keywords: ['tpin', 'transaction pin', 'set tpin', 'transfer pin', 'payment pin'],
|
keywords: ['tpin', 'transaction pin', 'set tpin', 'transfer pin', 'payment pin'],
|
||||||
navigate: (nav) => nav.navigate('Services', { screen: 'SetTpin' }),
|
navigate: (nav) => nav.navigate('Services', { screen: 'SetTpin' }),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: 'services-reset-tpin',
|
||||||
|
feature: FeatureKey.SERVICES_TPIN_RESET,
|
||||||
|
title: 'Reset TPIN',
|
||||||
|
subtitle: 'Set a new TPIN with an OTP',
|
||||||
|
module: 'Services',
|
||||||
|
icon: 'refresh-circle-outline',
|
||||||
|
hue: MODULE_HUES.services,
|
||||||
|
keywords: ['tpin', 'reset tpin', 'forgot tpin', 'new tpin', 'change tpin', 'transaction pin'],
|
||||||
|
navigate: (nav) => nav.navigate('Services', { screen: 'ResetTpin' }),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: 'services-stop-payment',
|
id: 'services-stop-payment',
|
||||||
feature: FeatureKey.SERVICES_CHEQUE_STOP_PAYMENT,
|
feature: FeatureKey.SERVICES_CHEQUE_STOP_PAYMENT,
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
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 { FeatureKey } from '../../../constants/featureKeys';
|
||||||
|
import { useFeature } from '../../../features';
|
||||||
|
import type { ServicesStackParamList } from '../../../navigation/types';
|
||||||
|
import { TpinResetFlow } from '../../payments/components/TpinResetFlow';
|
||||||
|
|
||||||
|
type Props = Readonly<NativeStackScreenProps<ServicesStackParamList, 'ResetTpin'>>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resets a forgotten transaction PIN, from the service desk.
|
||||||
|
*
|
||||||
|
* The signed-in session stands in for the MPIN that creating a TPIN asks for;
|
||||||
|
* the bank re-verifies the customer and an OTP to the registered mobile is the
|
||||||
|
* second factor.
|
||||||
|
*/
|
||||||
|
export default function ResetTpinScreen({ navigation }: Props) {
|
||||||
|
const session = useAppSelector((state) => state.auth.session);
|
||||||
|
const lastProfile = useAppSelector((state) => state.auth.lastProfile);
|
||||||
|
// Create TPIN is offered on TPIN_NOT_SET only where its route exists.
|
||||||
|
const canCreateTpin = useFeature(FeatureKey.SERVICES_TPIN_CHANGE);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ScreenShell
|
||||||
|
title="Reset TPIN"
|
||||||
|
subtitle="Set a new PIN for authorising fund transfers"
|
||||||
|
contentContainerStyle={styles.container}
|
||||||
|
>
|
||||||
|
<TpinResetFlow
|
||||||
|
mobileNumber={session?.mobileNumber ?? lastProfile?.mobileNumber}
|
||||||
|
onSuccess={() => navigation.goBack()}
|
||||||
|
onExit={() => navigation.goBack()}
|
||||||
|
onCreateTpin={canCreateTpin ? () => navigation.replace('SetTpin') : undefined}
|
||||||
|
/>
|
||||||
|
</ScreenShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: {
|
||||||
|
padding: 16,
|
||||||
|
flexGrow: 1,
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -88,6 +88,16 @@ export default function ServicesHubScreen({ navigation }: Props) {
|
|||||||
iconColor: theme.colors.category.pink.tone,
|
iconColor: theme.colors.category.pink.tone,
|
||||||
onPress: () => navigation.navigate('SetTpin'),
|
onPress: () => navigation.navigate('SetTpin'),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: 'tpin_reset',
|
||||||
|
feature: FeatureKey.SERVICES_TPIN_RESET,
|
||||||
|
// For a TPIN that exists but is forgotten; the flow sends anyone
|
||||||
|
// without one on to Create TPIN.
|
||||||
|
title: 'Reset TPIN',
|
||||||
|
icon: 'refresh-circle-outline',
|
||||||
|
iconColor: theme.colors.category.pink.tone,
|
||||||
|
onPress: () => navigation.navigate('ResetTpin'),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: 'fate_of_cheque',
|
id: 'fate_of_cheque',
|
||||||
feature: FeatureKey.SERVICES_FATE_OF_CHEQUE,
|
feature: FeatureKey.SERVICES_FATE_OF_CHEQUE,
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import TransferToOwnAccountScreen from '../modules/payments/screens/TransferToOw
|
|||||||
import ImpsMmidScreen from '../modules/payments/screens/ImpsMmidScreen';
|
import ImpsMmidScreen from '../modules/payments/screens/ImpsMmidScreen';
|
||||||
import { PaymentConfirmationScreen } from '../modules/payments/screens/PaymentConfirmationScreen';
|
import { PaymentConfirmationScreen } from '../modules/payments/screens/PaymentConfirmationScreen';
|
||||||
import CreateTpinScreen from '../modules/payments/screens/CreateTpinScreen';
|
import CreateTpinScreen from '../modules/payments/screens/CreateTpinScreen';
|
||||||
|
import ResetTpinScreen from '../modules/payments/screens/ResetTpinScreen';
|
||||||
import { TransactionSuccessScreen } from '../modules/payments/screens/TransactionSuccessScreen';
|
import { TransactionSuccessScreen } from '../modules/payments/screens/TransactionSuccessScreen';
|
||||||
|
|
||||||
const Stack = createNativeStackNavigator<PaymentsStackParamList>();
|
const Stack = createNativeStackNavigator<PaymentsStackParamList>();
|
||||||
@@ -54,6 +55,7 @@ export const PaymentsNavigator = () => {
|
|||||||
<Stack.Screen name="PaymentConfirmation" component={PaymentConfirmationScreen} />
|
<Stack.Screen name="PaymentConfirmation" component={PaymentConfirmationScreen} />
|
||||||
)}
|
)}
|
||||||
{canRoute('CreateTpin') && <Stack.Screen name="CreateTpin" component={CreateTpinScreen} />}
|
{canRoute('CreateTpin') && <Stack.Screen name="CreateTpin" component={CreateTpinScreen} />}
|
||||||
|
{canRoute('ResetTpin') && <Stack.Screen name="ResetTpin" component={ResetTpinScreen} />}
|
||||||
{canRoute('TransactionSuccess') && (
|
{canRoute('TransactionSuccess') && (
|
||||||
<Stack.Screen
|
<Stack.Screen
|
||||||
name="TransactionSuccess"
|
name="TransactionSuccess"
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import ServicesHubScreen from '../modules/services/screens/ServicesHubScreen';
|
|||||||
import BalanceEnquiryScreen from '../modules/services/screens/BalanceEnquiryScreen';
|
import BalanceEnquiryScreen from '../modules/services/screens/BalanceEnquiryScreen';
|
||||||
import FateOfChequeScreen from '../modules/services/screens/FateOfChequeScreen';
|
import FateOfChequeScreen from '../modules/services/screens/FateOfChequeScreen';
|
||||||
import SetTpinScreen from '../modules/services/screens/SetTpinScreen';
|
import SetTpinScreen from '../modules/services/screens/SetTpinScreen';
|
||||||
|
import ResetTpinScreen from '../modules/services/screens/ResetTpinScreen';
|
||||||
|
|
||||||
// New Modules
|
// New Modules
|
||||||
import ChequeServicesHubScreen from '../modules/services/screens/ChequeServicesHubScreen';
|
import ChequeServicesHubScreen from '../modules/services/screens/ChequeServicesHubScreen';
|
||||||
@@ -55,6 +56,7 @@ export const ServicesNavigator = () => {
|
|||||||
{canRoute('BalanceEnquiry') && <Stack.Screen name="BalanceEnquiry" component={BalanceEnquiryScreen} />}
|
{canRoute('BalanceEnquiry') && <Stack.Screen name="BalanceEnquiry" component={BalanceEnquiryScreen} />}
|
||||||
{canRoute('FateOfCheque') && <Stack.Screen name="FateOfCheque" component={FateOfChequeScreen} />}
|
{canRoute('FateOfCheque') && <Stack.Screen name="FateOfCheque" component={FateOfChequeScreen} />}
|
||||||
{canRoute('SetTpin') && <Stack.Screen name="SetTpin" component={SetTpinScreen} />}
|
{canRoute('SetTpin') && <Stack.Screen name="SetTpin" component={SetTpinScreen} />}
|
||||||
|
{canRoute('ResetTpin') && <Stack.Screen name="ResetTpin" component={ResetTpinScreen} />}
|
||||||
|
|
||||||
{/* Cheque Services */}
|
{/* Cheque Services */}
|
||||||
{canRoute('ChequeServicesHub') && <Stack.Screen name="ChequeServicesHub" component={ChequeServicesHubScreen} />}
|
{canRoute('ChequeServicesHub') && <Stack.Screen name="ChequeServicesHub" component={ChequeServicesHubScreen} />}
|
||||||
|
|||||||
@@ -70,6 +70,9 @@ export const PAYMENTS_ROUTE_FEATURES: RouteFeatureMap<PaymentsStackParamList> =
|
|||||||
// Part of the transfer, not of Services: a transfer that needs a TPIN must
|
// 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.
|
// be able to create one even with the Services entry switched off.
|
||||||
CreateTpin: FeatureKey.PAYMENTS_TRANSFER,
|
CreateTpin: FeatureKey.PAYMENTS_TRANSFER,
|
||||||
|
// Likewise: a customer who has forgotten the TPIN mid-transfer must be able
|
||||||
|
// to reset it there, whatever the service desk shows.
|
||||||
|
ResetTpin: FeatureKey.PAYMENTS_TRANSFER,
|
||||||
TransactionSuccess: FeatureKey.PAYMENTS_TRANSFER,
|
TransactionSuccess: FeatureKey.PAYMENTS_TRANSFER,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -90,6 +93,7 @@ export const SERVICES_ROUTE_FEATURES: RouteFeatureMap<ServicesStackParamList> =
|
|||||||
BalanceEnquiry: FeatureKey.SERVICES_BALANCE_ENQUIRY,
|
BalanceEnquiry: FeatureKey.SERVICES_BALANCE_ENQUIRY,
|
||||||
FateOfCheque: FeatureKey.SERVICES_FATE_OF_CHEQUE,
|
FateOfCheque: FeatureKey.SERVICES_FATE_OF_CHEQUE,
|
||||||
SetTpin: FeatureKey.SERVICES_TPIN_CHANGE,
|
SetTpin: FeatureKey.SERVICES_TPIN_CHANGE,
|
||||||
|
ResetTpin: FeatureKey.SERVICES_TPIN_RESET,
|
||||||
ChequeServicesHub: FeatureKey.SERVICES_CHEQUE_HUB,
|
ChequeServicesHub: FeatureKey.SERVICES_CHEQUE_HUB,
|
||||||
ChequeBookRequest: FeatureKey.SERVICES_CHEQUE_BOOK_REQUEST,
|
ChequeBookRequest: FeatureKey.SERVICES_CHEQUE_BOOK_REQUEST,
|
||||||
StopPayment: FeatureKey.SERVICES_CHEQUE_STOP_PAYMENT,
|
StopPayment: FeatureKey.SERVICES_CHEQUE_STOP_PAYMENT,
|
||||||
|
|||||||
@@ -110,6 +110,8 @@ export type PaymentsStackParamList = {
|
|||||||
};
|
};
|
||||||
/** Reached from PaymentConfirmation on TPIN_NOT_SET; returns there when done. */
|
/** Reached from PaymentConfirmation on TPIN_NOT_SET; returns there when done. */
|
||||||
CreateTpin: undefined;
|
CreateTpin: undefined;
|
||||||
|
/** Reached from the TPIN sheet's "Forgot TPIN?"; returns there when done. */
|
||||||
|
ResetTpin: undefined;
|
||||||
TransactionSuccess: {
|
TransactionSuccess: {
|
||||||
transactionId: string;
|
transactionId: string;
|
||||||
amount: number;
|
amount: number;
|
||||||
@@ -138,6 +140,7 @@ export type ServicesStackParamList = {
|
|||||||
BalanceEnquiry: undefined;
|
BalanceEnquiry: undefined;
|
||||||
FateOfCheque: undefined;
|
FateOfCheque: undefined;
|
||||||
SetTpin: undefined;
|
SetTpin: undefined;
|
||||||
|
ResetTpin: undefined;
|
||||||
ChequeServicesHub: undefined;
|
ChequeServicesHub: undefined;
|
||||||
ChequeBookRequest: undefined;
|
ChequeBookRequest: undefined;
|
||||||
StopPayment: undefined;
|
StopPayment: undefined;
|
||||||
|
|||||||
@@ -0,0 +1,131 @@
|
|||||||
|
import type { AxiosResponse, InternalAxiosRequestConfig } from 'axios';
|
||||||
|
|
||||||
|
import { apiLogger, redactSecrets } from './apiLogger';
|
||||||
|
|
||||||
|
describe('redactSecrets', () => {
|
||||||
|
it('hides OTPs, PINs, encrypted payloads and every kind of token', () => {
|
||||||
|
expect(
|
||||||
|
redactSecrets({
|
||||||
|
otp: '123456',
|
||||||
|
debugOtp: '123456',
|
||||||
|
mpin: '2580',
|
||||||
|
tpin: '5826',
|
||||||
|
encryptedMpin: 'base64…',
|
||||||
|
encryptedTpin: 'base64…',
|
||||||
|
sessionToken: 'live-session',
|
||||||
|
setupToken: 'setup',
|
||||||
|
resetToken: 'mpin-reset',
|
||||||
|
tpinCreateToken: 'create',
|
||||||
|
tpinResetToken: 'reset',
|
||||||
|
}),
|
||||||
|
).toEqual({
|
||||||
|
otp: '<redacted>',
|
||||||
|
debugOtp: '<redacted>',
|
||||||
|
mpin: '<redacted>',
|
||||||
|
tpin: '<redacted>',
|
||||||
|
encryptedMpin: '<redacted>',
|
||||||
|
encryptedTpin: '<redacted>',
|
||||||
|
sessionToken: '<redacted>',
|
||||||
|
setupToken: '<redacted>',
|
||||||
|
resetToken: '<redacted>',
|
||||||
|
tpinCreateToken: '<redacted>',
|
||||||
|
tpinResetToken: '<redacted>',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps the fields that help debugging — expiries, statuses, ids', () => {
|
||||||
|
const data = {
|
||||||
|
tpinResetId: 'e79eb452-32c9-4690-955b-81d8bb223ec3',
|
||||||
|
status: 'PENDING_OTP',
|
||||||
|
otpExpiresAt: '2026-09-30T13:50:31+05:30',
|
||||||
|
tokenExpiresAt: '2026-09-30T13:51:02+05:30',
|
||||||
|
resetTokenExpiresAt: '2026-09-30T13:51:02+05:30',
|
||||||
|
attemptsRemaining: 3,
|
||||||
|
mpinStatus: 'ACTIVE',
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(redactSecrets(data)).toEqual(data);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reaches into the envelope and into arrays', () => {
|
||||||
|
expect(
|
||||||
|
redactSecrets({
|
||||||
|
success: true,
|
||||||
|
data: { tpinResetToken: 'reset', items: [{ otp: '1' }, { name: 'x' }] },
|
||||||
|
}),
|
||||||
|
).toEqual({
|
||||||
|
success: true,
|
||||||
|
data: { tpinResetToken: '<redacted>', items: [{ otp: '<redacted>' }, { name: 'x' }] },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('leaves an absent secret visibly absent, and non-objects alone', () => {
|
||||||
|
expect(redactSecrets({ debugOtp: null })).toEqual({ debugOtp: null });
|
||||||
|
expect(redactSecrets('(no body)')).toBe('(no body)');
|
||||||
|
expect(redactSecrets(undefined)).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('apiLogger', () => {
|
||||||
|
let log: jest.SpyInstance;
|
||||||
|
let warn: jest.SpyInstance;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
log = jest.spyOn(console, 'log').mockImplementation(() => undefined);
|
||||||
|
warn = jest.spyOn(console, 'warn').mockImplementation(() => undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
log.mockRestore();
|
||||||
|
warn.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
const config = (data: unknown) =>
|
||||||
|
({
|
||||||
|
method: 'post',
|
||||||
|
baseURL: 'https://bank.example/api/v1',
|
||||||
|
url: '/tpin/reset/confirm',
|
||||||
|
headers: { Authorization: 'Bearer live-session-token', 'Content-Type': 'application/json' },
|
||||||
|
data,
|
||||||
|
}) as unknown as InternalAxiosRequestConfig;
|
||||||
|
|
||||||
|
it('prints the curl without the session token or the secrets in the body', () => {
|
||||||
|
apiLogger.request(config({ tpinResetToken: 'one-time-reset-token', encryptedTpin: 'cipher' }));
|
||||||
|
|
||||||
|
const printed = log.mock.calls.map((call) => String(call[0])).join('\n');
|
||||||
|
expect(printed).toContain('/tpin/reset/confirm');
|
||||||
|
expect(printed).toContain('Authorization: Bearer <session token>');
|
||||||
|
expect(printed).not.toContain('live-session-token');
|
||||||
|
expect(printed).not.toContain('one-time-reset-token');
|
||||||
|
expect(printed).not.toContain('cipher');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('redacts a body the caller serialised themselves', () => {
|
||||||
|
apiLogger.request(config(JSON.stringify({ tpinResetId: 'id-1', otp: '654321' })));
|
||||||
|
|
||||||
|
const printed = log.mock.calls.map((call) => String(call[0])).join('\n');
|
||||||
|
expect(printed).toContain('id-1');
|
||||||
|
expect(printed).not.toContain('654321');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('redacts secrets in what came back, success or failure', () => {
|
||||||
|
const request = config({});
|
||||||
|
apiLogger.response({
|
||||||
|
status: 200,
|
||||||
|
config: request,
|
||||||
|
data: { success: true, data: { tpinResetToken: 'one-time-reset-token' } },
|
||||||
|
} as AxiosResponse);
|
||||||
|
apiLogger.failure({
|
||||||
|
status: 400,
|
||||||
|
config: request,
|
||||||
|
data: { success: false, data: { otp: '654321' }, errorCode: 'VALIDATION_ERROR' },
|
||||||
|
} as AxiosResponse);
|
||||||
|
|
||||||
|
const printed = [...log.mock.calls, ...warn.mock.calls]
|
||||||
|
.map((call) => String(call[0]))
|
||||||
|
.join('\n');
|
||||||
|
expect(printed).toContain('VALIDATION_ERROR');
|
||||||
|
expect(printed).not.toContain('one-time-reset-token');
|
||||||
|
expect(printed).not.toContain('654321');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -3,23 +3,88 @@ import type { AxiosError, AxiosResponse, InternalAxiosRequestConfig } from 'axio
|
|||||||
/**
|
/**
|
||||||
* Dev-only request/response logging for the CBS client.
|
* Dev-only request/response logging for the CBS client.
|
||||||
*
|
*
|
||||||
* Every request is printed as a runnable `curl`, so a failure can be reproduced
|
* Every request is printed as a `curl`, so a failure can be reproduced against
|
||||||
* against the backend outside the app — which is usually the fastest way to
|
* the backend outside the app — which is usually the fastest way to settle
|
||||||
* settle whether a problem is the handset, the client, or the server.
|
* whether a problem is the handset, the client, or the server.
|
||||||
*
|
*
|
||||||
* ── WHY THIS IS BEHIND `__DEV__`, WITHOUT AN OVERRIDE ───────────────────────
|
* ── SECRETS ARE REDACTED, EVEN HERE ─────────────────────────────────────────
|
||||||
* The curl carries whatever the request carried: the `Authorization` header
|
* The bank's integration contracts forbid logging the session token, OTPs,
|
||||||
* with a live session token, and the RSA-encrypted MPIN blob on the
|
* PINs, encrypted PIN payloads and one-time tokens anywhere — the console
|
||||||
* registration calls. That is exactly what makes it useful, and exactly why it
|
* included — and Metro logs get pasted into tickets and chats. So the
|
||||||
* must never run in a release build — a bank's production logs are not a place
|
* `Authorization` header prints as a placeholder, and any body field named like
|
||||||
* to put a working credential. There is deliberately no runtime flag to turn
|
* a secret prints as `<redacted>`, on the way out and on the way back. The rest
|
||||||
* this on outside development; `__DEV__` is compiled out, so none of this code
|
* of the curl still runs: paste a session token in place of the placeholder.
|
||||||
* ships.
|
*
|
||||||
|
* ── AND IT IS BEHIND `__DEV__`, WITHOUT AN OVERRIDE ─────────────────────────
|
||||||
|
* Redaction is by field name, so it is a safety net rather than a guarantee.
|
||||||
|
* The log must still never run in a release build: there is deliberately no
|
||||||
|
* runtime flag to turn it on outside development, and `__DEV__` is compiled
|
||||||
|
* out, so none of this code ships.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/** Set false to silence the log without unpicking the interceptors. */
|
/** Set false to silence the log without unpicking the interceptors. */
|
||||||
const ENABLED = __DEV__;
|
const ENABLED = __DEV__;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Body fields never printed: OTPs (a dev `debugOtp` included), plain and
|
||||||
|
* encrypted PINs, and every token — session, setup, MPIN reset, TPIN create
|
||||||
|
* and reset. Their `…ExpiresAt` companions stay: when a code expired is often
|
||||||
|
* the whole question.
|
||||||
|
*/
|
||||||
|
const SECRET_FIELD = /^(otp|debugOtp|mpin|tpin|password)$|^encrypted|token$/i;
|
||||||
|
|
||||||
|
const REDACTED = '<redacted>';
|
||||||
|
|
||||||
|
/** What an `Authorization` header prints as. Paste a real token to run the curl. */
|
||||||
|
const AUTHORIZATION_PLACEHOLDER = 'Bearer <session token>';
|
||||||
|
|
||||||
|
const isPlainObject = (value: unknown): value is Record<string, unknown> => {
|
||||||
|
if (typeof value !== 'object' || value === null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const prototype = Object.getPrototypeOf(value);
|
||||||
|
|
||||||
|
return prototype === Object.prototype || prototype === null;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A copy of `value` with every secret-named field replaced, at any depth.
|
||||||
|
* Anything that is not a plain object or array — FormData, a Blob — is left
|
||||||
|
* as it is.
|
||||||
|
*/
|
||||||
|
export const redactSecrets = (value: unknown): unknown => {
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
return value.map(redactSecrets);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isPlainObject(value)) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Object.fromEntries(
|
||||||
|
Object.entries(value).map(([key, field]) => [
|
||||||
|
key,
|
||||||
|
SECRET_FIELD.test(key) && field !== null && field !== undefined
|
||||||
|
? REDACTED
|
||||||
|
: redactSecrets(field),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
/** A JSON string, redacted; any other string as it came. */
|
||||||
|
const redactString = (value: string): string => {
|
||||||
|
try {
|
||||||
|
const parsed: unknown = JSON.parse(value);
|
||||||
|
|
||||||
|
return typeof parsed === 'object' && parsed !== null
|
||||||
|
? JSON.stringify(redactSecrets(parsed))
|
||||||
|
: value;
|
||||||
|
} catch {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Per-request bookkeeping, so a response can be matched to its request in a
|
* Per-request bookkeeping, so a response can be matched to its request in a
|
||||||
* busy log and timed.
|
* busy log and timed.
|
||||||
@@ -109,11 +174,11 @@ const readBody = (config: InternalAxiosRequestConfig): string | undefined => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (typeof data === 'string') {
|
if (typeof data === 'string') {
|
||||||
return data;
|
return redactString(data);
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
return JSON.stringify(data);
|
return JSON.stringify(redactSecrets(data));
|
||||||
} catch {
|
} catch {
|
||||||
return String(data);
|
return String(data);
|
||||||
}
|
}
|
||||||
@@ -124,7 +189,8 @@ const toCurl = (config: InternalAxiosRequestConfig): string => {
|
|||||||
const lines = [`curl -i -X ${method} ${quote(readUrl(config))}`];
|
const lines = [`curl -i -X ${method} ${quote(readUrl(config))}`];
|
||||||
|
|
||||||
for (const [key, value] of Object.entries(readHeaders(config))) {
|
for (const [key, value] of Object.entries(readHeaders(config))) {
|
||||||
lines.push(`-H ${quote(`${key}: ${value}`)}`);
|
const shown = key.toLowerCase() === 'authorization' ? AUTHORIZATION_PLACEHOLDER : value;
|
||||||
|
lines.push(`-H ${quote(`${key}: ${shown}`)}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const body = readBody(config);
|
const body = readBody(config);
|
||||||
@@ -194,7 +260,7 @@ export const apiLogger = {
|
|||||||
console.log(
|
console.log(
|
||||||
`\n┌── API ← #${idOf(response.config)} ${response.status} ${label(
|
`\n┌── API ← #${idOf(response.config)} ${response.status} ${label(
|
||||||
response.config as InternalAxiosRequestConfig,
|
response.config as InternalAxiosRequestConfig,
|
||||||
)} (${elapsed(response.config)})\n${pretty(response.data)}\n└──`,
|
)} (${elapsed(response.config)})\n${pretty(redactSecrets(response.data))}\n└──`,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -212,7 +278,7 @@ export const apiLogger = {
|
|||||||
)} (${elapsed(response?.config)})`;
|
)} (${elapsed(response?.config)})`;
|
||||||
|
|
||||||
console.warn(
|
console.warn(
|
||||||
`\n${head}${note ? `\n${note}` : ''}\n${pretty(response?.data ?? '(no body)')}\n└──`,
|
`\n${head}${note ? `\n${note}` : ''}\n${pretty(redactSecrets(response?.data ?? '(no body)'))}\n└──`,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@@ -41,11 +41,20 @@ export const endpoints = {
|
|||||||
* token; `createConfirm` spends that token on the new, encrypted TPIN. There
|
* token; `createConfirm` spends that token on the new, encrypted TPIN. There
|
||||||
* is no resend endpoint: a fresh `createInitiate` — MPIN and all — is the
|
* is no resend endpoint: a fresh `createInitiate` — MPIN and all — is the
|
||||||
* resend.
|
* resend.
|
||||||
|
*
|
||||||
|
* Resetting it follows the same three steps under `/tpin/reset`, with two
|
||||||
|
* differences: `resetInitiate` takes an empty body — the session alone
|
||||||
|
* identifies the customer, and the bank re-verifies them before the OTP goes
|
||||||
|
* out — and it needs a TPIN to already be active. The same session must carry
|
||||||
|
* all three calls; the server refuses a flow that changes user or device.
|
||||||
*/
|
*/
|
||||||
tpin: {
|
tpin: {
|
||||||
createInitiate: '/tpin/create/initiate',
|
createInitiate: '/tpin/create/initiate',
|
||||||
createVerifyOtp: '/tpin/create/verify-otp',
|
createVerifyOtp: '/tpin/create/verify-otp',
|
||||||
createConfirm: '/tpin/create/confirm',
|
createConfirm: '/tpin/create/confirm',
|
||||||
|
resetInitiate: '/tpin/reset/initiate',
|
||||||
|
resetVerifyOtp: '/tpin/reset/verify-otp',
|
||||||
|
resetConfirm: '/tpin/reset/confirm',
|
||||||
},
|
},
|
||||||
accounts: {
|
accounts: {
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { sessionManager } from '../security/sessionManager';
|
|||||||
import { secureStorage } from '../storage/secureStorage';
|
import { secureStorage } from '../storage/secureStorage';
|
||||||
import { apiLogger } from './apiLogger';
|
import { apiLogger } from './apiLogger';
|
||||||
import { CbsApiError, isCbsErrorCode, type FieldErrors } from './cbsErrors';
|
import { CbsApiError, isCbsErrorCode, type FieldErrors } from './cbsErrors';
|
||||||
|
import { rememberServerTime } from './serverClock';
|
||||||
|
|
||||||
const unauthorizedHandlers = new Set<() => void>();
|
const unauthorizedHandlers = new Set<() => void>();
|
||||||
|
|
||||||
@@ -83,8 +84,9 @@ export const attachInterceptors = (client: AxiosInstance) => {
|
|||||||
config.headers = nextHeaders as never;
|
config.headers = nextHeaders as never;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Logged here, at the end of the interceptor, so the curl carries the
|
// Logged here, at the end of the interceptor, so the curl shows the
|
||||||
// Authorization header the request will actually be sent with.
|
// headers the request will actually be sent with — the token itself as a
|
||||||
|
// placeholder.
|
||||||
apiLogger.request(config);
|
apiLogger.request(config);
|
||||||
|
|
||||||
return config;
|
return config;
|
||||||
@@ -117,7 +119,12 @@ export const attachInterceptors = (client: AxiosInstance) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Unwrap so callers read the payload directly rather than the envelope.
|
// Unwrap so callers read the payload directly rather than the envelope.
|
||||||
return { ...response, data: envelope.data };
|
// The server's `timestamp` is kept aside for anything counting down to
|
||||||
|
// a server expiry — see `serverClock`.
|
||||||
|
const unwrapped = { ...response, data: envelope.data };
|
||||||
|
rememberServerTime(unwrapped, envelope.timestamp);
|
||||||
|
|
||||||
|
return unwrapped;
|
||||||
},
|
},
|
||||||
async (error: AxiosError) => {
|
async (error: AxiosError) => {
|
||||||
if (error instanceof CbsApiError) {
|
if (error instanceof CbsApiError) {
|
||||||
|
|||||||
@@ -0,0 +1,81 @@
|
|||||||
|
import {
|
||||||
|
parseServerInstant,
|
||||||
|
rememberServerTime,
|
||||||
|
secondsUntilDeadline,
|
||||||
|
serverTimeOf,
|
||||||
|
toLocalDeadline,
|
||||||
|
} from './serverClock';
|
||||||
|
|
||||||
|
describe('parseServerInstant', () => {
|
||||||
|
it('reads the backend timestamp, seven fractional digits and an offset included', () => {
|
||||||
|
expect(parseServerInstant('2026-09-30T13:50:31.1653828+05:30')).toBe(
|
||||||
|
Date.UTC(2026, 8, 30, 8, 20, 31, 165),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reads timestamps with fewer fractional digits, none, or a Z', () => {
|
||||||
|
expect(parseServerInstant('2026-09-30T08:20:31.5Z')).toBe(Date.UTC(2026, 8, 30, 8, 20, 31, 500));
|
||||||
|
expect(parseServerInstant('2026-09-30T13:50:31+05:30')).toBe(Date.UTC(2026, 8, 30, 8, 20, 31));
|
||||||
|
expect(parseServerInstant('2026-09-30T08:20:31.123Z')).toBe(Date.UTC(2026, 8, 30, 8, 20, 31, 123));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('gives null for nothing, or for something that is not a date', () => {
|
||||||
|
expect(parseServerInstant(null)).toBeNull();
|
||||||
|
expect(parseServerInstant(undefined)).toBeNull();
|
||||||
|
expect(parseServerInstant('')).toBeNull();
|
||||||
|
expect(parseServerInstant('soon')).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('toLocalDeadline', () => {
|
||||||
|
// The initiate response from the TPIN reset guide: five minutes to go.
|
||||||
|
const serverNow = '2026-09-30T13:45:31.1653828+05:30';
|
||||||
|
const otpExpiresAt = '2026-09-30T13:50:31.1653828+05:30';
|
||||||
|
|
||||||
|
it('counts the server’s time left from when the answer arrived, ignoring the device clock', () => {
|
||||||
|
// A handset an hour and a bit fast. The deadline still lands five minutes
|
||||||
|
// after the answer arrived, not five minutes after 13:45 server time.
|
||||||
|
const receivedAt = Date.UTC(2026, 8, 30, 9, 30, 0);
|
||||||
|
|
||||||
|
expect(toLocalDeadline(otpExpiresAt, serverNow, receivedAt)).toBe(receivedAt + 5 * 60 * 1000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('takes the expiry at face value when the server sent no timestamp', () => {
|
||||||
|
expect(toLocalDeadline(otpExpiresAt, undefined, 0)).toBe(parseServerInstant(otpExpiresAt));
|
||||||
|
expect(toLocalDeadline(otpExpiresAt, 'garbage', 0)).toBe(parseServerInstant(otpExpiresAt));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('has no deadline without an expiry', () => {
|
||||||
|
expect(toLocalDeadline(null, serverNow)).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('secondsUntilDeadline', () => {
|
||||||
|
it('counts down in whole seconds and stops at zero', () => {
|
||||||
|
expect(secondsUntilDeadline(10_000, 0)).toBe(10);
|
||||||
|
expect(secondsUntilDeadline(10_000, 9_700)).toBe(0);
|
||||||
|
expect(secondsUntilDeadline(10_000, 60_000)).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is unknown when the deadline is', () => {
|
||||||
|
expect(secondsUntilDeadline(null, 0)).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('rememberServerTime', () => {
|
||||||
|
it('files the envelope timestamp against the response it came with', () => {
|
||||||
|
const response = {};
|
||||||
|
rememberServerTime(response, '2026-09-30T13:45:31+05:30');
|
||||||
|
|
||||||
|
expect(serverTimeOf(response)).toBe('2026-09-30T13:45:31+05:30');
|
||||||
|
expect(serverTimeOf({})).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ignores a missing or non-string timestamp', () => {
|
||||||
|
const response = {};
|
||||||
|
rememberServerTime(response, undefined);
|
||||||
|
rememberServerTime(response, 1790000000);
|
||||||
|
|
||||||
|
expect(serverTimeOf(response)).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
/**
|
||||||
|
* The server's clock, as each response reported it.
|
||||||
|
*
|
||||||
|
* Every envelope carries a `timestamp` — the server's "now" when it answered.
|
||||||
|
* The interceptor unwraps the envelope, so it files that reading here, keyed on
|
||||||
|
* the unwrapped response it hands back, rather than adding a field to axios's
|
||||||
|
* response type. The entry goes when axios drops the response.
|
||||||
|
*
|
||||||
|
* Why it matters: an OTP or a one-time token comes back with an absolute
|
||||||
|
* `expiresAt`. Counting down to that against the handset's clock is wrong by
|
||||||
|
* however far the handset's clock is off — minutes, on a phone with automatic
|
||||||
|
* time switched off. The gap between `expiresAt` and the server's own
|
||||||
|
* `timestamp` is the true time left, whatever the device thinks the time is.
|
||||||
|
*/
|
||||||
|
const serverTimes = new WeakMap<object, string>();
|
||||||
|
|
||||||
|
/** Called by the interceptor with the envelope's `timestamp`. */
|
||||||
|
export const rememberServerTime = (response: object, timestamp: unknown) => {
|
||||||
|
if (typeof timestamp === 'string' && timestamp.length > 0) {
|
||||||
|
serverTimes.set(response, timestamp);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/** The server's `timestamp` for this response, when it sent one. */
|
||||||
|
export const serverTimeOf = (response: object): string | undefined => serverTimes.get(response);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Epoch milliseconds from the backend's ISO-8601 offset timestamps.
|
||||||
|
*
|
||||||
|
* Java writes up to nine fractional digits (`13:50:31.1653828+05:30`), and the
|
||||||
|
* ECMAScript date format only promises three. V8 is lenient about the rest;
|
||||||
|
* other engines need not be — so the fraction is cut to exactly three first.
|
||||||
|
*/
|
||||||
|
export const parseServerInstant = (iso: string | null | undefined): number | null => {
|
||||||
|
if (!iso) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalised = iso.replace(/\.(\d+)/, (_match, fraction: string) =>
|
||||||
|
`.${`${fraction}00`.slice(0, 3)}`,
|
||||||
|
);
|
||||||
|
const ms = Date.parse(normalised);
|
||||||
|
|
||||||
|
return Number.isFinite(ms) ? ms : null;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A server expiry as a deadline on this device's clock: the time the server
|
||||||
|
* says is left, counted from when the answer arrived. Only the elapsed time is
|
||||||
|
* measured locally, so a wrong device clock does not move the deadline.
|
||||||
|
*
|
||||||
|
* Without the server's `timestamp` the expiry is taken at face value — which is
|
||||||
|
* right exactly when the device clock is.
|
||||||
|
*/
|
||||||
|
export const toLocalDeadline = (
|
||||||
|
expiresAt: string | null | undefined,
|
||||||
|
serverNow: string | null | undefined,
|
||||||
|
receivedAt: number = Date.now(),
|
||||||
|
): number | null => {
|
||||||
|
const expiry = parseServerInstant(expiresAt);
|
||||||
|
|
||||||
|
if (expiry === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = parseServerInstant(serverNow);
|
||||||
|
|
||||||
|
return now === null ? expiry : receivedAt + (expiry - now);
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Whole seconds until a local deadline, never below zero. `null` when unknown. */
|
||||||
|
export const secondsUntilDeadline = (deadline: number | null, now: number = Date.now()) =>
|
||||||
|
deadline === null ? null : Math.max(0, Math.round((deadline - now) / 1000));
|
||||||
@@ -106,7 +106,7 @@ export const mpinCrypto = {
|
|||||||
return this.encryptPin('mpin', mpin);
|
return this.encryptPin('mpin', mpin);
|
||||||
},
|
},
|
||||||
|
|
||||||
/** Travels as `encryptedTpin`: TPIN creation and every fund transfer. */
|
/** Travels as `encryptedTpin`: TPIN creation and reset, and every fund transfer. */
|
||||||
encryptTpin(tpin: string): Promise<string> {
|
encryptTpin(tpin: string): Promise<string> {
|
||||||
return this.encryptPin('tpin', tpin);
|
return this.encryptPin('tpin', tpin);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -792,3 +792,34 @@ export interface TpinCreateConfirmResult {
|
|||||||
status: string;
|
status: string;
|
||||||
createdAt: string | null;
|
createdAt: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── TPIN reset ────────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `POST /tpin/reset/initiate`, sent with an empty body. The bank re-verified
|
||||||
|
* the signed-in customer and an OTP is on its way to the session's mobile.
|
||||||
|
*/
|
||||||
|
export interface TpinResetInitiateResult {
|
||||||
|
/** Held in screen state only, and only until the OTP is verified. */
|
||||||
|
tpinResetId: string;
|
||||||
|
/** `PENDING_OTP`. */
|
||||||
|
status: string;
|
||||||
|
otpExpiresAt: string | null;
|
||||||
|
attemptsRemaining: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `POST /tpin/reset/verify-otp`. */
|
||||||
|
export interface TpinResetVerifyResult {
|
||||||
|
/** One-time. Never stored, logged or put in Redux. */
|
||||||
|
tpinResetToken: string;
|
||||||
|
/** `OTP_VERIFIED`. */
|
||||||
|
status: string;
|
||||||
|
tokenExpiresAt: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `POST /tpin/reset/confirm`. */
|
||||||
|
export interface TpinResetConfirmResult {
|
||||||
|
/** `ACTIVE` — the new TPIN authorises transfers from now on. */
|
||||||
|
status: string;
|
||||||
|
resetAt: string | null;
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user