Update CBS Mobile App codebase
This commit is contained in:
@@ -3,6 +3,12 @@ import axios from 'axios';
|
||||
import { env } from '../../config/env';
|
||||
import { attachInterceptors } from './interceptors';
|
||||
|
||||
/**
|
||||
* Single client for the CBS backend. The response interceptor unwraps the
|
||||
* `{ success, message, data }` envelope, so callers read `response.data` as the
|
||||
* endpoint payload and a `success: false` body arrives as a thrown
|
||||
* `CbsApiError` carrying the code to switch on.
|
||||
*/
|
||||
export const apiClient = axios.create({
|
||||
baseURL: env.apiBaseUrl,
|
||||
timeout: 15000,
|
||||
|
||||
@@ -0,0 +1,271 @@
|
||||
import type { AxiosError, AxiosResponse, InternalAxiosRequestConfig } from 'axios';
|
||||
|
||||
/**
|
||||
* Dev-only request/response logging for the CBS client.
|
||||
*
|
||||
* Every request is printed as a runnable `curl`, so a failure can be reproduced
|
||||
* against the backend outside the app — which is usually the fastest way to
|
||||
* settle whether a problem is the handset, the client, or the server.
|
||||
*
|
||||
* ── WHY THIS IS BEHIND `__DEV__`, WITHOUT AN OVERRIDE ───────────────────────
|
||||
* The curl carries whatever the request carried: the `Authorization` header
|
||||
* with a live session token, and the RSA-encrypted MPIN blob on the
|
||||
* registration calls. That is exactly what makes it useful, and exactly why it
|
||||
* must never run in a release build — a bank's production logs are not a place
|
||||
* to put a working credential. There is deliberately no runtime flag to turn
|
||||
* this on outside development; `__DEV__` is compiled out, so none of this code
|
||||
* ships.
|
||||
*/
|
||||
|
||||
/** Set false to silence the log without unpicking the interceptors. */
|
||||
const ENABLED = __DEV__;
|
||||
|
||||
/**
|
||||
* Per-request bookkeeping, so a response can be matched to its request in a
|
||||
* busy log and timed.
|
||||
*
|
||||
* Keyed on the config object in a WeakMap rather than stashed on the config
|
||||
* itself: an extra property would travel with the request, need a TypeScript
|
||||
* module augmentation, and could collide with axios's own fields. The entry
|
||||
* disappears on its own once axios drops the config.
|
||||
*/
|
||||
const inFlight = new WeakMap<object, { id: string; startedAt: number }>();
|
||||
|
||||
let counter = 0;
|
||||
|
||||
const nextId = () => {
|
||||
counter += 1;
|
||||
|
||||
return counter.toString(16).padStart(3, '0');
|
||||
};
|
||||
|
||||
/** Shell-safe single-quoting: the only escape a POSIX single-quoted string has. */
|
||||
const quote = (value: string) => `'${value.replace(/'/g, "'\\''")}'`;
|
||||
|
||||
/**
|
||||
* Headers as a flat map.
|
||||
*
|
||||
* Axios 1.x hands over an `AxiosHeaders` instance whose `toJSON()` gives the
|
||||
* flattened set. Older shapes nest per-method groups (`common`, `get`, `post`)
|
||||
* that are not real headers, so anything non-primitive is dropped rather than
|
||||
* printed as `[object Object]` in a curl that would then not run.
|
||||
*/
|
||||
const readHeaders = (config: InternalAxiosRequestConfig): Record<string, string> => {
|
||||
const raw = config.headers as unknown;
|
||||
|
||||
if (raw && typeof (raw as { toJSON?: unknown }).toJSON === 'function') {
|
||||
const json = (raw as { toJSON: () => Record<string, unknown> }).toJSON();
|
||||
|
||||
return Object.fromEntries(
|
||||
Object.entries(json)
|
||||
.filter(([, value]) => typeof value === 'string' || typeof value === 'number')
|
||||
.map(([key, value]) => [key, String(value)]),
|
||||
);
|
||||
}
|
||||
|
||||
if (raw && typeof raw === 'object') {
|
||||
return Object.fromEntries(
|
||||
Object.entries(raw as Record<string, unknown>)
|
||||
.filter(([, value]) => typeof value === 'string' || typeof value === 'number')
|
||||
.map(([key, value]) => [key, String(value)]),
|
||||
);
|
||||
}
|
||||
|
||||
return {};
|
||||
};
|
||||
|
||||
/** Absolute URL, query string included, so the curl needs no editing. */
|
||||
const readUrl = (config: InternalAxiosRequestConfig): string => {
|
||||
const base = (config.baseURL ?? '').replace(/\/+$/, '');
|
||||
const path = config.url ?? '';
|
||||
const url = path.startsWith('http') ? path : `${base}/${path.replace(/^\/+/, '')}`;
|
||||
|
||||
const params = config.params as Record<string, unknown> | undefined;
|
||||
|
||||
if (!params) {
|
||||
return url;
|
||||
}
|
||||
|
||||
const query = Object.entries(params)
|
||||
.filter(([, value]) => value !== undefined && value !== null)
|
||||
.map(([key, value]) => `${encodeURIComponent(key)}=${encodeURIComponent(String(value))}`)
|
||||
.join('&');
|
||||
|
||||
return query ? `${url}?${query}` : url;
|
||||
};
|
||||
|
||||
/**
|
||||
* The body as it will go on the wire.
|
||||
*
|
||||
* At request-interceptor time axios has not run `transformRequest` yet, so
|
||||
* `config.data` is usually still the object the caller passed — hence the
|
||||
* stringify. It is already a string when a caller serialised it themselves.
|
||||
*/
|
||||
const readBody = (config: InternalAxiosRequestConfig): string | undefined => {
|
||||
const { data } = config;
|
||||
|
||||
if (data === undefined || data === null) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (typeof data === 'string') {
|
||||
return data;
|
||||
}
|
||||
|
||||
try {
|
||||
return JSON.stringify(data);
|
||||
} catch {
|
||||
return String(data);
|
||||
}
|
||||
};
|
||||
|
||||
const toCurl = (config: InternalAxiosRequestConfig): string => {
|
||||
const method = (config.method ?? 'get').toUpperCase();
|
||||
const lines = [`curl -i -X ${method} ${quote(readUrl(config))}`];
|
||||
|
||||
for (const [key, value] of Object.entries(readHeaders(config))) {
|
||||
lines.push(`-H ${quote(`${key}: ${value}`)}`);
|
||||
}
|
||||
|
||||
const body = readBody(config);
|
||||
|
||||
if (body !== undefined) {
|
||||
lines.push(`--data ${quote(body)}`);
|
||||
}
|
||||
|
||||
// Continuations, so the whole thing can be pasted into a terminal as one
|
||||
// command instead of being reassembled by hand.
|
||||
return lines.join(' \\\n ');
|
||||
};
|
||||
|
||||
/** A short label for the log header — method and path, without the origin. */
|
||||
const label = (config: InternalAxiosRequestConfig | undefined) => {
|
||||
const method = (config?.method ?? '???').toUpperCase();
|
||||
const path = config?.url ?? '?';
|
||||
|
||||
return `${method} ${path}`;
|
||||
};
|
||||
|
||||
const pretty = (value: unknown): string => {
|
||||
if (typeof value === 'string') {
|
||||
return value;
|
||||
}
|
||||
|
||||
try {
|
||||
return JSON.stringify(value, null, 2);
|
||||
} catch {
|
||||
return String(value);
|
||||
}
|
||||
};
|
||||
|
||||
const elapsed = (config: object | undefined) => {
|
||||
const entry = config ? inFlight.get(config) : undefined;
|
||||
|
||||
return entry ? `${Date.now() - entry.startedAt}ms` : '?ms';
|
||||
};
|
||||
|
||||
const idOf = (config: object | undefined) => {
|
||||
const entry = config ? inFlight.get(config) : undefined;
|
||||
|
||||
return entry ? entry.id : '---';
|
||||
};
|
||||
|
||||
export const apiLogger = {
|
||||
request(config: InternalAxiosRequestConfig) {
|
||||
if (!ENABLED) {
|
||||
return;
|
||||
}
|
||||
|
||||
const id = nextId();
|
||||
inFlight.set(config, { id, startedAt: Date.now() });
|
||||
|
||||
console.log(`\n┌── API → #${id} ${label(config)}\n${toCurl(config)}\n└──`);
|
||||
},
|
||||
|
||||
/**
|
||||
* Called with the envelope still intact — before the response interceptor
|
||||
* unwraps `data` — so what is printed is what the server actually sent.
|
||||
*/
|
||||
response(response: AxiosResponse) {
|
||||
if (!ENABLED) {
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(
|
||||
`\n┌── API ← #${idOf(response.config)} ${response.status} ${label(
|
||||
response.config as InternalAxiosRequestConfig,
|
||||
)} (${elapsed(response.config)})\n${pretty(response.data)}\n└──`,
|
||||
);
|
||||
},
|
||||
|
||||
/**
|
||||
* A response that arrived and said no: either an HTTP error, or a 2xx whose
|
||||
* envelope carried `success: false`.
|
||||
*/
|
||||
failure(response: AxiosResponse | undefined, note?: string) {
|
||||
if (!ENABLED) {
|
||||
return;
|
||||
}
|
||||
|
||||
const head = `┌── API ✗ #${idOf(response?.config)} ${response?.status ?? '???'} ${label(
|
||||
response?.config as InternalAxiosRequestConfig | undefined,
|
||||
)} (${elapsed(response?.config)})`;
|
||||
|
||||
console.warn(
|
||||
`\n${head}${note ? `\n${note}` : ''}\n${pretty(response?.data ?? '(no body)')}\n└──`,
|
||||
);
|
||||
},
|
||||
|
||||
/**
|
||||
* A call that does not go through axios at all.
|
||||
*
|
||||
* The two statement PDF endpoints answer a binary body rather than the JSON
|
||||
* envelope, so they are fetched outside `apiClient` and would otherwise be
|
||||
* the only requests in the app invisible to this log — which is the worst
|
||||
* place to have a blind spot, since a download that silently does nothing is
|
||||
* indistinguishable from a button that is not wired up.
|
||||
*
|
||||
* The curl is written by hand here rather than derived from a config,
|
||||
* because there is no axios config to derive it from. Same rule as above:
|
||||
* the header carries a live token, and none of this ships.
|
||||
*/
|
||||
rawRequest(method: string, url: string, note?: string) {
|
||||
if (!ENABLED) {
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(
|
||||
`\n┌── API → #${nextId()} ${method} ${url}` +
|
||||
`${note ? `\n${note}` : ''}\n` +
|
||||
`curl -X ${method} '${url}' -H 'Authorization: Bearer <session token>' ` +
|
||||
"-H 'Accept: application/pdf' --output out.pdf\n└──",
|
||||
);
|
||||
},
|
||||
|
||||
/** The outcome of a `rawRequest`, named the same way for a matching pair. */
|
||||
rawResponse(status: number, url: string, note: string) {
|
||||
if (!ENABLED) {
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`\n┌── API ← ${status} ${url}\n${note}\n└──`);
|
||||
},
|
||||
|
||||
/** Nothing answered at all — no status, no body. */
|
||||
networkFailure(error: AxiosError) {
|
||||
if (!ENABLED) {
|
||||
return;
|
||||
}
|
||||
|
||||
const config = error.config as InternalAxiosRequestConfig | undefined;
|
||||
|
||||
console.warn(
|
||||
`\n┌── API ✗ #${idOf(config)} no response ${label(config)} (${elapsed(config)})\n` +
|
||||
`${error.code ?? 'no code'}: ${error.message}\n` +
|
||||
'The request never got an answer. Usual causes: the backend is not running, ' +
|
||||
'it is bound to 127.0.0.1 rather than 0.0.0.0, this device is on a different ' +
|
||||
'network from the host, a firewall is closing the port, or cleartext HTTP is ' +
|
||||
'not permitted for that host in network_security_config.xml.\n└──',
|
||||
);
|
||||
},
|
||||
};
|
||||
@@ -2,11 +2,12 @@ import {
|
||||
BaseQueryFn,
|
||||
createApi,
|
||||
} from '@reduxjs/toolkit/query/react';
|
||||
import type { AxiosRequestConfig, AxiosError } from 'axios';
|
||||
import type { AxiosRequestConfig } from 'axios';
|
||||
|
||||
import { QUERY_TAGS } from '../../constants/queryTags';
|
||||
import type { ApiError } from '../../types/api';
|
||||
import { apiClient } from './apiClient';
|
||||
import { toApiError } from './serviceHelpers';
|
||||
|
||||
export interface AxiosBaseQueryArgs {
|
||||
url: string;
|
||||
@@ -30,15 +31,9 @@ const axiosBaseQuery =
|
||||
|
||||
return { data: response.data };
|
||||
} catch (error) {
|
||||
const axiosError = error as AxiosError;
|
||||
|
||||
return {
|
||||
error: {
|
||||
status: Number(axiosError.response?.status ?? 500),
|
||||
message: axiosError.message || 'Something went wrong',
|
||||
data: axiosError.response?.data,
|
||||
},
|
||||
};
|
||||
// Keeps the backend's `errorCode` and validation field map intact, so
|
||||
// components can branch on the code rather than parse a message.
|
||||
return { error: toApiError(error) };
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
import {
|
||||
CBS_ERROR_CODES,
|
||||
CbsApiError,
|
||||
describeError,
|
||||
isCbsApiError,
|
||||
isCbsErrorCode,
|
||||
isClientErrorCode,
|
||||
isRetryableError,
|
||||
} from './cbsErrors';
|
||||
|
||||
describe('error code catalogue', () => {
|
||||
it('covers all 45 documented codes, with no duplicates', () => {
|
||||
expect(CBS_ERROR_CODES).toHaveLength(45);
|
||||
expect(new Set(CBS_ERROR_CODES).size).toBe(45);
|
||||
});
|
||||
|
||||
it('knows the cheque book codes', () => {
|
||||
expect(isCbsErrorCode('CHEQUE_BOOK_NOT_AVAILABLE')).toBe(true);
|
||||
expect(isCbsErrorCode('CHEQUE_BOOK_REQUEST_DECLINED')).toBe(true);
|
||||
});
|
||||
|
||||
it('does not invite a retry on either cheque book refusal', () => {
|
||||
// Neither is transient. The account cannot take a cheque book at all, or
|
||||
// the bank understood the request and refused it — an identical retry gets
|
||||
// an identical answer, and on a paid product that is worse than useless.
|
||||
expect(isRetryableError('CHEQUE_BOOK_NOT_AVAILABLE')).toBe(false);
|
||||
expect(isRetryableError('CHEQUE_BOOK_REQUEST_DECLINED')).toBe(false);
|
||||
});
|
||||
|
||||
it('knows the statement codes', () => {
|
||||
expect(isCbsErrorCode('INVALID_DATE_RANGE')).toBe(true);
|
||||
expect(isCbsErrorCode('STATEMENT_VIEW_NOT_AVAILABLE')).toBe(true);
|
||||
});
|
||||
|
||||
it('does not invite a retry on either statement refusal', () => {
|
||||
// Both are the request's fault, not the bank's: one range is unusable and
|
||||
// the other view does not exist. Repeating the identical call gets the
|
||||
// identical answer, so the screen must offer a different range — or
|
||||
// nothing — rather than a retry.
|
||||
expect(isRetryableError('INVALID_DATE_RANGE')).toBe(false);
|
||||
expect(isRetryableError('STATEMENT_VIEW_NOT_AVAILABLE')).toBe(false);
|
||||
});
|
||||
|
||||
it('knows the payee-validate codes, which come from IMPS and not from CBS', () => {
|
||||
expect(isCbsErrorCode('IMPS_UNAVAILABLE')).toBe(true);
|
||||
expect(isCbsErrorCode('IMPS_INVALID_RESPONSE')).toBe(true);
|
||||
expect(isCbsErrorCode('IMPS_CONFIGURATION_ERROR')).toBe(true);
|
||||
});
|
||||
|
||||
it('invites a retry when the switch was unreachable, but not when it is misconfigured', () => {
|
||||
// Validate saves nothing, so a failed call is safe to repeat — unlike the
|
||||
// Positive Pay write below. A configuration fault is not the user's to fix.
|
||||
expect(isRetryableError('IMPS_UNAVAILABLE')).toBe(true);
|
||||
expect(isRetryableError('IMPS_INVALID_RESPONSE')).toBe(true);
|
||||
expect(isRetryableError('IMPS_CONFIGURATION_ERROR')).toBe(false);
|
||||
});
|
||||
|
||||
it('knows the Positive Pay codes', () => {
|
||||
expect(isCbsErrorCode('POSITIVE_PAY_NOT_AVAILABLE')).toBe(true);
|
||||
expect(isCbsErrorCode('CHEQUE_ALREADY_REGISTERED')).toBe(true);
|
||||
expect(isCbsErrorCode('POSITIVE_PAY_DECLINED')).toBe(true);
|
||||
});
|
||||
|
||||
it('knows the stop-payment codes', () => {
|
||||
expect(isCbsErrorCode('CHEQUE_NOT_FOUND')).toBe(true);
|
||||
expect(isCbsErrorCode('STOP_PAYMENT_NOT_AVAILABLE')).toBe(true);
|
||||
expect(isCbsErrorCode('CHEQUE_ALREADY_STOPPED')).toBe(true);
|
||||
expect(isCbsErrorCode('STOP_PAYMENT_DECLINED')).toBe(true);
|
||||
});
|
||||
|
||||
it('does not invite a retry on a stop-payment refusal either', () => {
|
||||
// Same reasoning as Positive Pay: stopping a cheque is a write. A second
|
||||
// attempt after a refusal changes nothing, and after a success it returns
|
||||
// CHEQUE_ALREADY_STOPPED — which reads like the customer's mistake.
|
||||
expect(isRetryableError('CHEQUE_NOT_FOUND')).toBe(false);
|
||||
expect(isRetryableError('CHEQUE_ALREADY_STOPPED')).toBe(false);
|
||||
expect(isRetryableError('STOP_PAYMENT_DECLINED')).toBe(false);
|
||||
expect(isRetryableError('STOP_PAYMENT_NOT_AVAILABLE')).toBe(false);
|
||||
});
|
||||
|
||||
it('does not invite a retry on a Positive Pay refusal', () => {
|
||||
// Registering a cheque is a write. A refusal is final, and offering "try
|
||||
// again" would only produce CHEQUE_ALREADY_REGISTERED or a duplicate.
|
||||
expect(isRetryableError('CHEQUE_ALREADY_REGISTERED')).toBe(false);
|
||||
expect(isRetryableError('POSITIVE_PAY_DECLINED')).toBe(false);
|
||||
expect(isRetryableError('POSITIVE_PAY_NOT_AVAILABLE')).toBe(false);
|
||||
});
|
||||
|
||||
it('has copy for every code, so no failure renders as a blank toast', () => {
|
||||
for (const code of CBS_ERROR_CODES) {
|
||||
const copy = describeError(code);
|
||||
|
||||
expect(copy.title.length).toBeGreaterThan(0);
|
||||
expect(copy.description.length).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
|
||||
it('recognises documented codes and rejects invented ones', () => {
|
||||
expect(isCbsErrorCode('MPIN_LOCKED')).toBe(true);
|
||||
expect(isCbsErrorCode('MPIN_UNLOCKED')).toBe(false);
|
||||
expect(isCbsErrorCode(undefined)).toBe(false);
|
||||
});
|
||||
|
||||
it('falls back to generic copy for an unknown or missing code', () => {
|
||||
expect(describeError('SOMETHING_NEW').title).toBe(describeError(undefined).title);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isRetryableError', () => {
|
||||
// Retrying these has a real chance of working: the bank was unreachable or a
|
||||
// breaker was open.
|
||||
it('marks upstream and transport failures retryable', () => {
|
||||
expect(isRetryableError('CBS_UNAVAILABLE')).toBe(true);
|
||||
expect(isRetryableError('CONNECTOR_UNAVAILABLE')).toBe(true);
|
||||
expect(isRetryableError('NETWORK_ERROR')).toBe(true);
|
||||
});
|
||||
|
||||
// Retrying these can only waste an attempt, and against MPIN_LOCKED there is
|
||||
// nothing to retry at all — the customer must visit a branch.
|
||||
it('does not mark user-input or policy failures retryable', () => {
|
||||
expect(isRetryableError('OTP_INVALID')).toBe(false);
|
||||
expect(isRetryableError('INVALID_CREDENTIALS')).toBe(false);
|
||||
expect(isRetryableError('MPIN_LOCKED')).toBe(false);
|
||||
expect(isRetryableError('VALIDATION_ERROR')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('CbsApiError', () => {
|
||||
it('carries the code and the validation field map', () => {
|
||||
const error = new CbsApiError({
|
||||
status: 400,
|
||||
code: 'VALIDATION_ERROR',
|
||||
message: 'Request validation failed',
|
||||
fieldErrors: { mobileNumber: 'must be exactly 10 digits' },
|
||||
});
|
||||
|
||||
expect(isCbsApiError(error)).toBe(true);
|
||||
expect(error.code).toBe('VALIDATION_ERROR');
|
||||
expect(error.fieldErrors?.mobileNumber).toBe('must be exactly 10 digits');
|
||||
expect(error.describe().title).toBe(describeError('VALIDATION_ERROR').title);
|
||||
});
|
||||
|
||||
it('is a real Error, so it survives being thrown and caught', () => {
|
||||
expect(() => {
|
||||
throw new CbsApiError({ status: 0, code: 'NETWORK_ERROR', message: 'offline' });
|
||||
}).toThrow('offline');
|
||||
});
|
||||
|
||||
it('does not mistake a plain object for one', () => {
|
||||
expect(isCbsApiError({ status: 400, code: 'OTP_INVALID', message: 'nope' })).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isClientErrorCode', () => {
|
||||
// These carry a transport detail, not a message anyone wrote for a customer.
|
||||
it('is true for the codes the client synthesises', () => {
|
||||
expect(isClientErrorCode('NETWORK_ERROR')).toBe(true);
|
||||
expect(isClientErrorCode('REQUEST_TIMEOUT')).toBe(true);
|
||||
expect(isClientErrorCode('UNKNOWN_ERROR')).toBe(true);
|
||||
});
|
||||
|
||||
it('is false for codes the backend names, whose message is worth showing', () => {
|
||||
expect(isClientErrorCode('OTP_INVALID')).toBe(false);
|
||||
expect(isClientErrorCode('VALIDATION_ERROR')).toBe(false);
|
||||
expect(isClientErrorCode(undefined)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,358 @@
|
||||
/**
|
||||
* The error codes the CBS backend can return, plus the copy the app shows
|
||||
* for each. `errorCode` is the stable contract — `message` is free to be
|
||||
* reworded or localised server-side — so every branch in the app switches on
|
||||
* the code and never on the message or the HTTP status.
|
||||
*/
|
||||
export const CBS_ERROR_CODES = [
|
||||
'VALIDATION_ERROR',
|
||||
'MALFORMED_REQUEST',
|
||||
'OTP_INVALID',
|
||||
'OTP_EXPIRED',
|
||||
'BENEFICIARY_DETAILS_INCOMPLETE',
|
||||
'INVALID_CREDENTIALS',
|
||||
'SESSION_INVALID',
|
||||
'DEVICE_NOT_BOUND',
|
||||
'RESOURCE_NOT_FOUND',
|
||||
'CUSTOMER_NOT_FOUND',
|
||||
'NO_ACCOUNTS_FOUND',
|
||||
'ACCOUNT_NOT_FOUND',
|
||||
'CHEQUE_NOT_FOUND',
|
||||
'STOP_PAYMENT_NOT_AVAILABLE',
|
||||
'INVALID_DATE_RANGE',
|
||||
'STATEMENT_VIEW_NOT_AVAILABLE',
|
||||
'ORG_NOT_CONFIGURED',
|
||||
'BENEFICIARY_NOT_FOUND',
|
||||
'METHOD_NOT_ALLOWED',
|
||||
'DUPLICATE_RECORD',
|
||||
'ALREADY_REGISTERED',
|
||||
'OTP_NOT_VERIFIED',
|
||||
'MPIN_ALREADY_SET',
|
||||
'MPIN_NOT_SET',
|
||||
'BENEFICIARY_ALREADY_ADDED',
|
||||
'POSITIVE_PAY_NOT_AVAILABLE',
|
||||
'CHEQUE_ALREADY_REGISTERED',
|
||||
'CHEQUE_ALREADY_STOPPED',
|
||||
'POSITIVE_PAY_DECLINED',
|
||||
'STOP_PAYMENT_DECLINED',
|
||||
'CHEQUE_BOOK_NOT_AVAILABLE',
|
||||
'CHEQUE_BOOK_REQUEST_DECLINED',
|
||||
'MPIN_LOCKED',
|
||||
'OTP_ATTEMPTS_EXCEEDED',
|
||||
'INTERNAL_ERROR',
|
||||
'CBS_CONNECTOR_NOT_SUPPORTED',
|
||||
'MMID_ALLOCATION_FAILED',
|
||||
'CBS_UNAVAILABLE',
|
||||
'CBS_ERROR',
|
||||
'IMPS_UNAVAILABLE',
|
||||
'IMPS_INVALID_RESPONSE',
|
||||
'IMPS_CONFIGURATION_ERROR',
|
||||
'SMS_DELIVERY_FAILED',
|
||||
'OTP_PROVIDER_UNAVAILABLE',
|
||||
'CONNECTOR_UNAVAILABLE',
|
||||
] as const;
|
||||
|
||||
export type CbsErrorCode = (typeof CBS_ERROR_CODES)[number];
|
||||
|
||||
/** Codes the client synthesises when the request never reached the backend. */
|
||||
export type ClientErrorCode = 'NETWORK_ERROR' | 'REQUEST_TIMEOUT' | 'UNKNOWN_ERROR';
|
||||
|
||||
export type AnyErrorCode = CbsErrorCode | ClientErrorCode;
|
||||
|
||||
const CBS_ERROR_CODE_SET = new Set<string>(CBS_ERROR_CODES);
|
||||
|
||||
export const isCbsErrorCode = (value: unknown): value is CbsErrorCode =>
|
||||
typeof value === 'string' && CBS_ERROR_CODE_SET.has(value);
|
||||
|
||||
const CLIENT_ERROR_CODES = new Set<string>([
|
||||
'NETWORK_ERROR',
|
||||
'REQUEST_TIMEOUT',
|
||||
'UNKNOWN_ERROR',
|
||||
]);
|
||||
|
||||
/**
|
||||
* True for a failure the client invented because nothing answered. The
|
||||
* attached message is then a transport detail — axios says "Network Error" —
|
||||
* which tells a customer nothing, so the UI should use our own copy instead of
|
||||
* preferring it the way it does for a message the backend actually wrote.
|
||||
*/
|
||||
export const isClientErrorCode = (code: string | undefined) =>
|
||||
code !== undefined && CLIENT_ERROR_CODES.has(code);
|
||||
|
||||
/** Field name to message, as `VALIDATION_ERROR` returns it. Paints onto a form. */
|
||||
export type FieldErrors = Record<string, string>;
|
||||
|
||||
interface ErrorCopy {
|
||||
title: string;
|
||||
description: string;
|
||||
/**
|
||||
* True when retrying the identical request has a real chance of succeeding —
|
||||
* the bank being unreachable, a circuit breaker open, a transient 500.
|
||||
*/
|
||||
retryable?: boolean;
|
||||
}
|
||||
|
||||
const ERROR_COPY: Record<AnyErrorCode, ErrorCopy> = {
|
||||
VALIDATION_ERROR: {
|
||||
title: 'Check the highlighted fields',
|
||||
description: 'Some details need correcting before we can continue.',
|
||||
},
|
||||
MALFORMED_REQUEST: {
|
||||
title: 'We could not read that request',
|
||||
description: 'Please try once more.',
|
||||
retryable: true,
|
||||
},
|
||||
OTP_INVALID: {
|
||||
title: 'That code is incorrect',
|
||||
description: 'Re-enter the code we sent to your mobile.',
|
||||
},
|
||||
OTP_EXPIRED: {
|
||||
title: 'That code has expired',
|
||||
description: 'Request a fresh code and try again.',
|
||||
},
|
||||
BENEFICIARY_DETAILS_INCOMPLETE: {
|
||||
title: 'Payee details are incomplete',
|
||||
description: 'Fill in the fields required for the payee type you picked.',
|
||||
},
|
||||
INVALID_CREDENTIALS: {
|
||||
title: 'Sign-in failed',
|
||||
description: 'Check your mobile number and MPIN, then try again.',
|
||||
},
|
||||
SESSION_INVALID: {
|
||||
title: 'Session ended',
|
||||
description: 'Please sign in again to continue securely.',
|
||||
},
|
||||
DEVICE_NOT_BOUND: {
|
||||
title: 'This device is not registered',
|
||||
description: 'Register before signing in.',
|
||||
},
|
||||
RESOURCE_NOT_FOUND: {
|
||||
title: 'Not found',
|
||||
description: 'We could not find what you asked for.',
|
||||
},
|
||||
CUSTOMER_NOT_FOUND: {
|
||||
title: 'We could not find that customer',
|
||||
description: 'Check the mobile number registered with your bank.',
|
||||
},
|
||||
NO_ACCOUNTS_FOUND: {
|
||||
title: 'No accounts to link',
|
||||
description: 'Your bank has no accounts we can link to this number.',
|
||||
},
|
||||
ACCOUNT_NOT_FOUND: {
|
||||
title: 'Account unavailable',
|
||||
description: 'That account is no longer part of your profile.',
|
||||
},
|
||||
/**
|
||||
* Nothing on file for this account and cheque number. The bank keeps no
|
||||
* separate record of issued cheques, so this also covers the common case of
|
||||
* a perfectly real cheque that was simply never declared under Positive Pay
|
||||
* — which is why the copy says what to do rather than just "not found".
|
||||
*/
|
||||
CHEQUE_NOT_FOUND: {
|
||||
title: 'We could not find that cheque',
|
||||
description:
|
||||
'Check the account and the six-digit number. Only a cheque registered under Positive Pay can be found here.',
|
||||
},
|
||||
STOP_PAYMENT_NOT_AVAILABLE: {
|
||||
title: 'Stop payment is not available',
|
||||
description: 'Your bank does not offer it on this account. Contact your branch instead.',
|
||||
},
|
||||
INVALID_DATE_RANGE: {
|
||||
title: 'That date range will not work',
|
||||
description: 'Pick a start and an end date, with the start on or before the end.',
|
||||
},
|
||||
STATEMENT_VIEW_NOT_AVAILABLE: {
|
||||
title: 'Statements are not available here',
|
||||
description: 'Your bank does not expose a ledger for this account.',
|
||||
},
|
||||
ORG_NOT_CONFIGURED: {
|
||||
title: 'Organisation unavailable',
|
||||
description: 'This organisation is unknown or has been disabled.',
|
||||
},
|
||||
BENEFICIARY_NOT_FOUND: {
|
||||
title: 'Payee not found',
|
||||
description: 'Check the account number and IFSC, or the mobile number and MMID.',
|
||||
},
|
||||
METHOD_NOT_ALLOWED: {
|
||||
title: 'Something went wrong',
|
||||
description: 'The app made a request the server does not accept.',
|
||||
},
|
||||
DUPLICATE_RECORD: {
|
||||
title: 'Already saved',
|
||||
description: 'That record was just created. Refresh to see it.',
|
||||
},
|
||||
ALREADY_REGISTERED: {
|
||||
title: 'You are already registered',
|
||||
description: 'Sign in with your MPIN instead.',
|
||||
},
|
||||
OTP_NOT_VERIFIED: {
|
||||
title: 'Verification expired',
|
||||
description: 'Your setup window has closed. Start registration again.',
|
||||
},
|
||||
MPIN_ALREADY_SET: {
|
||||
title: 'MPIN already set',
|
||||
description: 'Sign in with the MPIN you created.',
|
||||
},
|
||||
MPIN_NOT_SET: {
|
||||
title: 'Registration unfinished',
|
||||
description: 'Finish setting your MPIN to start banking.',
|
||||
},
|
||||
BENEFICIARY_ALREADY_ADDED: {
|
||||
title: 'Payee already saved',
|
||||
description: 'This payee is already in your list.',
|
||||
},
|
||||
POSITIVE_PAY_NOT_AVAILABLE: {
|
||||
title: 'Positive Pay is not available',
|
||||
description: 'Your bank does not offer it on this account.',
|
||||
},
|
||||
CHEQUE_ALREADY_REGISTERED: {
|
||||
title: 'This cheque is already registered',
|
||||
description:
|
||||
'Nothing more to do — the bank already holds these details. Check the number if you meant a different cheque.',
|
||||
},
|
||||
/**
|
||||
* A retried stop, where the first call already took effect. Nothing went
|
||||
* wrong and nothing changed — so the copy reassures rather than alarms.
|
||||
*/
|
||||
CHEQUE_ALREADY_STOPPED: {
|
||||
title: 'This cheque is already stopped',
|
||||
description:
|
||||
'Your earlier instruction went through. The bank will dishonour this cheque if it is presented.',
|
||||
},
|
||||
POSITIVE_PAY_DECLINED: {
|
||||
title: 'Your bank declined the registration',
|
||||
description: 'Contact your branch to register this cheque.',
|
||||
},
|
||||
STOP_PAYMENT_DECLINED: {
|
||||
title: 'Your bank declined the stop instruction',
|
||||
description: 'The cheque has not been stopped. Contact your branch to take this further.',
|
||||
},
|
||||
/**
|
||||
* Answers the charges quote as well as the issue itself, so this copy has to
|
||||
* make sense before the customer has committed to anything — hence no
|
||||
* "nothing was charged" reassurance, which would be confusing on a quote.
|
||||
*/
|
||||
CHEQUE_BOOK_NOT_AVAILABLE: {
|
||||
title: 'Cheque books are not available on this account',
|
||||
description: 'Your bank does not issue them on this account type. Try another account.',
|
||||
},
|
||||
CHEQUE_BOOK_REQUEST_DECLINED: {
|
||||
title: 'Your bank declined the request',
|
||||
description: 'No cheque book was issued and nothing was charged. Contact your branch.',
|
||||
},
|
||||
MPIN_LOCKED: {
|
||||
title: 'MPIN locked',
|
||||
description: 'Too many wrong attempts. Visit your branch to unlock it.',
|
||||
},
|
||||
OTP_ATTEMPTS_EXCEEDED: {
|
||||
title: 'Too many wrong codes',
|
||||
description: 'Start registration again to get a new code.',
|
||||
},
|
||||
INTERNAL_ERROR: {
|
||||
title: 'Something went wrong at the bank',
|
||||
description: 'Please try again in a moment.',
|
||||
retryable: true,
|
||||
},
|
||||
CBS_CONNECTOR_NOT_SUPPORTED: {
|
||||
title: 'Service unavailable',
|
||||
description: 'This is a server configuration issue, not a problem with your details.',
|
||||
},
|
||||
IMPS_UNAVAILABLE: {
|
||||
title: 'Could not reach the payments network',
|
||||
description: 'IMPS did not answer. Nothing was saved — please try again in a moment.',
|
||||
retryable: true,
|
||||
},
|
||||
IMPS_INVALID_RESPONSE: {
|
||||
title: 'Could not read the payment network reply',
|
||||
description:
|
||||
'The response failed its security check, so it cannot be trusted. Try again, and tell your branch if it keeps happening.',
|
||||
retryable: true,
|
||||
},
|
||||
IMPS_CONFIGURATION_ERROR: {
|
||||
title: 'Payee checks are unavailable',
|
||||
description: 'This is a server configuration issue, not a problem with the details you entered.',
|
||||
},
|
||||
MMID_ALLOCATION_FAILED: {
|
||||
title: 'We could not finish setting up your accounts',
|
||||
description: 'Please try again shortly.',
|
||||
retryable: true,
|
||||
},
|
||||
CBS_UNAVAILABLE: {
|
||||
title: 'Your bank is unreachable',
|
||||
description: 'Please try again in a moment.',
|
||||
retryable: true,
|
||||
},
|
||||
CBS_ERROR: {
|
||||
title: 'Your bank returned an error',
|
||||
description: 'Please try again in a moment.',
|
||||
retryable: true,
|
||||
},
|
||||
SMS_DELIVERY_FAILED: {
|
||||
title: 'We could not send the code',
|
||||
description: 'Please request it again.',
|
||||
retryable: true,
|
||||
},
|
||||
OTP_PROVIDER_UNAVAILABLE: {
|
||||
title: 'Verification service is down',
|
||||
description: 'Please try again in a few minutes.',
|
||||
retryable: true,
|
||||
},
|
||||
CONNECTOR_UNAVAILABLE: {
|
||||
title: 'Service temporarily unavailable',
|
||||
description: 'We are backing off from your bank. Try again shortly.',
|
||||
retryable: true,
|
||||
},
|
||||
NETWORK_ERROR: {
|
||||
title: 'No connection',
|
||||
description: 'Check your network and try again.',
|
||||
retryable: true,
|
||||
},
|
||||
REQUEST_TIMEOUT: {
|
||||
title: 'The request timed out',
|
||||
description: 'Your bank took too long to answer. Try again.',
|
||||
retryable: true,
|
||||
},
|
||||
UNKNOWN_ERROR: {
|
||||
title: 'Something went wrong',
|
||||
description: 'Please try again.',
|
||||
retryable: true,
|
||||
},
|
||||
};
|
||||
|
||||
export const describeError = (code: string | undefined): ErrorCopy =>
|
||||
(code ? ERROR_COPY[code as AnyErrorCode] : undefined) ?? ERROR_COPY.UNKNOWN_ERROR;
|
||||
|
||||
export const isRetryableError = (code: string | undefined) =>
|
||||
Boolean(describeError(code).retryable);
|
||||
|
||||
/**
|
||||
* An error the CBS backend named, or one the client synthesised when the call
|
||||
* never got an answer. Carries the code the UI switches on and, for
|
||||
* `VALIDATION_ERROR`, the field map that paints straight onto the form.
|
||||
*/
|
||||
export class CbsApiError extends Error {
|
||||
readonly status: number;
|
||||
readonly code: AnyErrorCode;
|
||||
readonly fieldErrors?: FieldErrors;
|
||||
|
||||
constructor(params: {
|
||||
status: number;
|
||||
code: AnyErrorCode;
|
||||
message: string;
|
||||
fieldErrors?: FieldErrors;
|
||||
}) {
|
||||
super(params.message);
|
||||
this.name = 'CbsApiError';
|
||||
this.status = params.status;
|
||||
this.code = params.code;
|
||||
this.fieldErrors = params.fieldErrors;
|
||||
}
|
||||
|
||||
/** Copy suitable for a toast or an inline banner. */
|
||||
describe() {
|
||||
return describeError(this.code);
|
||||
}
|
||||
}
|
||||
|
||||
export const isCbsApiError = (value: unknown): value is CbsApiError =>
|
||||
value instanceof CbsApiError;
|
||||
@@ -1,8 +1,63 @@
|
||||
export const endpoints = {
|
||||
/** Unauthenticated by necessity — between them these produce a session. */
|
||||
registration: {
|
||||
start: '/registration',
|
||||
verifyOtp: '/registration/verify-otp',
|
||||
mpin: '/registration/mpin',
|
||||
},
|
||||
crypto: {
|
||||
publicKey: '/crypto/public-key',
|
||||
},
|
||||
/**
|
||||
* Server-driven feature switches. Deliberately callable without a session —
|
||||
* the sign-in screen's own controls are gated, so this has to resolve before
|
||||
* anyone has signed in.
|
||||
*/
|
||||
features: {
|
||||
config: '/features/config',
|
||||
},
|
||||
auth: {
|
||||
login: '/auth/login',
|
||||
verifyOtp: '/auth/verify-otp',
|
||||
setupMpin: '/auth/setup-mpin',
|
||||
me: '/auth/me',
|
||||
logout: '/auth/logout',
|
||||
},
|
||||
accounts: {
|
||||
/**
|
||||
* Every account on the signed-in profile. No parameters — the profile comes
|
||||
* entirely from the session. This is the only endpoint that returns a full
|
||||
* account number rather than a masked one.
|
||||
*/
|
||||
list: '/accounts',
|
||||
mmid: '/accounts/mmid',
|
||||
/**
|
||||
* A page of ledger entries for one account. Takes the full account number
|
||||
* from `list` above, and an optional `fromDate`/`toDate` pair that the
|
||||
* bank rejects unless both are given or neither is.
|
||||
*/
|
||||
statement: '/accounts/statement',
|
||||
/**
|
||||
* The same range `statement` pages through, as one PDF. Takes the identical
|
||||
* `accountNumber` and optional `fromDate`/`toDate` pair and no
|
||||
* `page`/`size` — the whole range becomes one document. Answers
|
||||
* `application/pdf`, not the JSON envelope every other endpoint returns.
|
||||
*/
|
||||
statementExport: '/accounts/statement/export',
|
||||
/**
|
||||
* The last 10 entries as a short PDF, led by the current available balance.
|
||||
* `accountNumber` only — no date range, and no envelope.
|
||||
*/
|
||||
miniStatement: '/accounts/mini-statement',
|
||||
/**
|
||||
* One deposit's terms, by `accountNumber` query parameter. Takes the full
|
||||
* number from `list` above — the masked form every other endpoint returns
|
||||
* will not resolve.
|
||||
*/
|
||||
depositDetail: '/accounts/deposits/detail',
|
||||
},
|
||||
beneficiaries: {
|
||||
list: '/beneficiaries',
|
||||
save: '/beneficiaries',
|
||||
validate: '/beneficiaries/validate',
|
||||
},
|
||||
dashboard: {
|
||||
summary: '/dashboard/summary',
|
||||
@@ -33,6 +88,51 @@ export const endpoints = {
|
||||
requests: '/services/requests',
|
||||
chat: '/services/chat',
|
||||
},
|
||||
/**
|
||||
* Declares a cheque before it is presented. Write-only: the bank is the
|
||||
* system of record and keeps no client-side copy, so there is nothing to
|
||||
* list or fetch back — `referenceId` in the response is the only handle.
|
||||
*/
|
||||
positivePay: {
|
||||
register: '/positive-pay',
|
||||
/**
|
||||
* Checks the bank does *not* already hold this cheque, before the customer
|
||||
* fills in the full registration form. "Not found" is the happy path here
|
||||
* and answers 200 with a null `data`; a cheque already on file is refused
|
||||
* with `CHEQUE_ALREADY_REGISTERED`.
|
||||
*/
|
||||
verify: '/positive-pay/verify',
|
||||
},
|
||||
/**
|
||||
* Stopping a cheque already declared under Positive Pay.
|
||||
*
|
||||
* `verify` resolves to the same core-banking lookup as
|
||||
* `positivePay.verify` — what differs is which answer is good news. There,
|
||||
* finding the cheque is a rejection; here it is the whole point, and the
|
||||
* cheque's own detail comes back so the customer can confirm what they are
|
||||
* about to stop. A cheque that never went through `positivePay.register`
|
||||
* does not exist to either of these.
|
||||
*/
|
||||
cheque: {
|
||||
verify: '/check/verify',
|
||||
stopPayment: '/check/stop-payment',
|
||||
},
|
||||
/**
|
||||
* Cheque book issuance — the one cheque journey the bank keeps a history
|
||||
* for, unlike Positive Pay and stop payment above.
|
||||
*
|
||||
* `charges` quotes the fee before the customer commits to `issue`, the same
|
||||
* preview-then-act convention as `positivePay.verify`. `issue` is
|
||||
* synchronous: the serial range comes back in its own response, so there is
|
||||
* no pending state and nothing to poll. `issue` and `list` are the same
|
||||
* path under different methods, named apart because the screens reach for
|
||||
* one or the other and a shared constant reads like a copy-paste slip.
|
||||
*/
|
||||
chequeBook: {
|
||||
charges: '/cheque-book/charges',
|
||||
issue: '/cheque-book',
|
||||
list: '/cheque-book',
|
||||
},
|
||||
loans: {
|
||||
products: '/loans/products',
|
||||
active: '/loans/active',
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import type { AxiosError, AxiosInstance } from 'axios';
|
||||
import type { AxiosError, AxiosInstance, AxiosResponse } from 'axios';
|
||||
|
||||
import type { CbsEnvelope } from '../../types/cbs';
|
||||
import { sessionManager } from '../security/sessionManager';
|
||||
import { secureStorage } from '../storage/secureStorage';
|
||||
import { apiLogger } from './apiLogger';
|
||||
import { CbsApiError, isCbsErrorCode, type FieldErrors } from './cbsErrors';
|
||||
|
||||
const unauthorizedHandlers = new Set<() => void>();
|
||||
|
||||
@@ -12,6 +16,63 @@ export const registerUnauthorizedHandler = (handler: () => void) => {
|
||||
};
|
||||
};
|
||||
|
||||
const looksLikeEnvelope = (value: unknown): value is CbsEnvelope<unknown> =>
|
||||
typeof value === 'object' && value !== null && 'success' in value;
|
||||
|
||||
/**
|
||||
* `VALIDATION_ERROR` is the one failure that also fills `data`, with a
|
||||
* field-to-message map. Anything else in there on a failure is not a field map.
|
||||
*/
|
||||
const readFieldErrors = (envelope: CbsEnvelope<unknown>): FieldErrors | undefined => {
|
||||
if (envelope.errorCode !== 'VALIDATION_ERROR') {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const { data } = envelope;
|
||||
|
||||
if (typeof data !== 'object' || data === null) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const entries = Object.entries(data as Record<string, unknown>).filter(
|
||||
(entry): entry is [string, string] => typeof entry[1] === 'string',
|
||||
);
|
||||
|
||||
return entries.length > 0 ? Object.fromEntries(entries) : undefined;
|
||||
};
|
||||
|
||||
/** The absolute URL a failed request was aimed at, for a dev-only diagnostic. */
|
||||
const describeTarget = (error: AxiosError) => {
|
||||
const base = error.config?.baseURL ?? '';
|
||||
const path = error.config?.url ?? '';
|
||||
|
||||
return `${base}${path}` || 'the server';
|
||||
};
|
||||
|
||||
const envelopeToError = (status: number, envelope: CbsEnvelope<unknown>) =>
|
||||
new CbsApiError({
|
||||
status,
|
||||
code: isCbsErrorCode(envelope.errorCode) ? envelope.errorCode : 'UNKNOWN_ERROR',
|
||||
message: envelope.message || 'Unexpected service error',
|
||||
fieldErrors: readFieldErrors(envelope),
|
||||
});
|
||||
|
||||
/**
|
||||
* `SESSION_INVALID` means the token we hold is dead and every screen must drop
|
||||
* it. `INVALID_CREDENTIALS` shares the 401 but means a wrong MPIN on the
|
||||
* sign-in form — bouncing the user out of a screen they are not in yet would
|
||||
* be nonsense, so only the former trips the global handler.
|
||||
*/
|
||||
const handleSessionLoss = async (error: CbsApiError) => {
|
||||
if (error.code !== 'SESSION_INVALID') {
|
||||
return;
|
||||
}
|
||||
|
||||
await secureStorage.clearSession();
|
||||
sessionManager.clear();
|
||||
unauthorizedHandlers.forEach((handler) => handler());
|
||||
};
|
||||
|
||||
export const attachInterceptors = (client: AxiosInstance) => {
|
||||
client.interceptors.request.use(async (config) => {
|
||||
const token = await secureStorage.getAccessToken();
|
||||
@@ -22,18 +83,100 @@ export const attachInterceptors = (client: AxiosInstance) => {
|
||||
config.headers = nextHeaders as never;
|
||||
}
|
||||
|
||||
// Logged here, at the end of the interceptor, so the curl carries the
|
||||
// Authorization header the request will actually be sent with.
|
||||
apiLogger.request(config);
|
||||
|
||||
return config;
|
||||
});
|
||||
|
||||
client.interceptors.response.use(
|
||||
(response) => response,
|
||||
async (error: AxiosError) => {
|
||||
if (error.response?.status === 401) {
|
||||
await secureStorage.clearSession();
|
||||
unauthorizedHandlers.forEach((handler) => handler());
|
||||
async (response: AxiosResponse) => {
|
||||
apiLogger.response(response);
|
||||
|
||||
if (!looksLikeEnvelope(response.data)) {
|
||||
return response;
|
||||
}
|
||||
|
||||
return Promise.reject(error);
|
||||
const envelope = response.data as CbsEnvelope<unknown>;
|
||||
|
||||
// Branch on `success`, not on the HTTP status: a failure can arrive with
|
||||
// a 2xx, and treating it as a payload would hand callers `undefined`.
|
||||
if (!envelope.success) {
|
||||
const error = envelopeToError(response.status, envelope);
|
||||
apiLogger.failure(response, `envelope said success: false -> ${error.code}`);
|
||||
await handleSessionLoss(error);
|
||||
throw error;
|
||||
}
|
||||
|
||||
// Any authenticated call refreshes the server's idle timer, so mirror
|
||||
// that locally — otherwise the app would sign the user out while the
|
||||
// session is still very much alive.
|
||||
if (response.config.headers?.Authorization) {
|
||||
sessionManager.touch();
|
||||
}
|
||||
|
||||
// Unwrap so callers read the payload directly rather than the envelope.
|
||||
return { ...response, data: envelope.data };
|
||||
},
|
||||
async (error: AxiosError) => {
|
||||
if (error instanceof CbsApiError) {
|
||||
return Promise.reject(error);
|
||||
}
|
||||
|
||||
if (error.response) {
|
||||
apiLogger.failure(error.response, `HTTP error ${error.code ?? ''}`.trim());
|
||||
} else {
|
||||
apiLogger.networkFailure(error);
|
||||
}
|
||||
|
||||
const status = Number(error.response?.status ?? 0);
|
||||
const body = error.response?.data;
|
||||
|
||||
if (looksLikeEnvelope(body)) {
|
||||
const cbsError = envelopeToError(status, body);
|
||||
await handleSessionLoss(cbsError);
|
||||
|
||||
return Promise.reject(cbsError);
|
||||
}
|
||||
|
||||
if (error.code === 'ECONNABORTED' || error.code === 'ETIMEDOUT') {
|
||||
return Promise.reject(
|
||||
new CbsApiError({
|
||||
status: 0,
|
||||
code: 'REQUEST_TIMEOUT',
|
||||
message: 'The request timed out',
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
// No response at all: nothing answered on the other end. The usual causes
|
||||
// are the backend not running, it being bound to 127.0.0.1 instead of
|
||||
// 0.0.0.0, the handset being on a different network from the host, a
|
||||
// firewall on the host port, or cleartext HTTP blocked in a release build.
|
||||
//
|
||||
// Naming the URL that failed matters more than it looks: without it this
|
||||
// is indistinguishable from a server-side fault, and the address the
|
||||
// phone needs is often not the one that works in the laptop's browser.
|
||||
if (!error.response) {
|
||||
return Promise.reject(
|
||||
new CbsApiError({
|
||||
status: 0,
|
||||
code: 'NETWORK_ERROR',
|
||||
message: __DEV__
|
||||
? `Could not reach ${describeTarget(error)}. Check the backend is running and that this device can reach that address.`
|
||||
: error.message || 'Could not reach the server',
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
return Promise.reject(
|
||||
new CbsApiError({
|
||||
status,
|
||||
code: 'UNKNOWN_ERROR',
|
||||
message: error.message || 'Unexpected service error',
|
||||
}),
|
||||
);
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { AxiosError } from 'axios';
|
||||
import { appConfig } from '../../config/appConfig';
|
||||
import type { ApiError } from '../../types/api';
|
||||
import { sleep } from '../../utils/async';
|
||||
import { isCbsApiError } from './cbsErrors';
|
||||
|
||||
const isApiError = (value: unknown): value is ApiError =>
|
||||
typeof value === 'object' &&
|
||||
@@ -11,6 +12,17 @@ const isApiError = (value: unknown): value is ApiError =>
|
||||
'message' in value;
|
||||
|
||||
export const toApiError = (error: unknown): ApiError => {
|
||||
// A named backend failure. Keep the code and the field map — they are what
|
||||
// the screens branch on and paint onto forms.
|
||||
if (isCbsApiError(error)) {
|
||||
return {
|
||||
status: error.status,
|
||||
message: error.message,
|
||||
code: error.code,
|
||||
fieldErrors: error.fieldErrors,
|
||||
};
|
||||
}
|
||||
|
||||
if (isApiError(error)) {
|
||||
return error;
|
||||
}
|
||||
@@ -20,10 +32,17 @@ export const toApiError = (error: unknown): ApiError => {
|
||||
return {
|
||||
status: Number(axiosError.response?.status ?? 500),
|
||||
message: axiosError.message || 'Unexpected service error',
|
||||
code: 'UNKNOWN_ERROR',
|
||||
data: axiosError.response?.data,
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Fixture-backed resolution for the modules that have no backend behind them
|
||||
* yet. Endpoints the CBS API actually implements must not go through here —
|
||||
* they call `apiClient` directly so a failure surfaces instead of being
|
||||
* papered over with demo data.
|
||||
*/
|
||||
export const resolveWithMock = async <T>({
|
||||
mockData,
|
||||
request,
|
||||
@@ -50,3 +69,14 @@ export const resolveWithMock = async <T>({
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
/** Same contract as `resolveWithMock`, but always live. */
|
||||
export const resolveLive = async <T>(
|
||||
request: () => Promise<T>,
|
||||
): Promise<{ data: T } | { error: ApiError }> => {
|
||||
try {
|
||||
return { data: await request() };
|
||||
} catch (error) {
|
||||
return { error: toApiError(error) };
|
||||
}
|
||||
};
|
||||
|
||||
@@ -0,0 +1,243 @@
|
||||
import { NativeModules, PermissionsAndroid, Platform } from 'react-native';
|
||||
|
||||
/**
|
||||
* One populated SIM slot.
|
||||
*
|
||||
* `phoneNumber` is null far more often than not — see the note on `simCard`.
|
||||
* The slot and carrier are still worth reporting without it: on a dual-SIM
|
||||
* handset they are how a customer recognises which of their two lines the bank
|
||||
* has, which is the question the registration screen needs answered.
|
||||
*/
|
||||
export interface SimSubscription {
|
||||
/** Android's subscription id. Stable while the SIM stays in the phone. */
|
||||
subscriptionId: number;
|
||||
/** Zero-based slot. Presented to the customer as SIM 1 / SIM 2. */
|
||||
slotIndex: number;
|
||||
carrierName: string | null;
|
||||
/** The label the customer gave this SIM in Android settings, if any. */
|
||||
displayName: string | null;
|
||||
/** Ten digits, or null when the carrier never provisioned the number. */
|
||||
phoneNumber: string | null;
|
||||
}
|
||||
|
||||
export interface SimInfo {
|
||||
/** A SIM is present and ready. False on a Wi-Fi-only tablet or with no SIM. */
|
||||
hasSim: boolean;
|
||||
/**
|
||||
* READ_PHONE_STATE is granted, so the slots can be enumerated at all.
|
||||
*
|
||||
* This is NOT enough to read the numbers on Android 13 and up — see
|
||||
* `canReadNumbers`. Conflating the two is what used to send customers to a
|
||||
* branch over a permission the app had simply never asked for.
|
||||
*/
|
||||
hasPermission: boolean;
|
||||
/**
|
||||
* The MSISDN is readable, permission-wise. From Android 13 that means
|
||||
* READ_PHONE_NUMBERS specifically; below it, either phone permission will do.
|
||||
*
|
||||
* False here with `hasPermission` true is a real and common state: the slots
|
||||
* list fine and every number comes back null.
|
||||
*/
|
||||
canReadNumbers: boolean;
|
||||
/** Ten digits, or null when the carrier never provisioned the number. */
|
||||
phoneNumber: string | null;
|
||||
carrierName: string | null;
|
||||
/**
|
||||
* Every active subscription. Empty without the phone-state permission.
|
||||
* More than one entry means the customer should be asked which line is theirs
|
||||
* rather than the app picking for them.
|
||||
*/
|
||||
sims: SimSubscription[];
|
||||
}
|
||||
|
||||
/**
|
||||
* What came of asking.
|
||||
*
|
||||
* `blocked` is the case worth handling separately: Android stops showing the
|
||||
* dialog after two refusals, so from then on requesting again returns
|
||||
* immediately and nothing visible happens. A button wired straight to a
|
||||
* re-request looks broken forever. The only way forward is app settings.
|
||||
*/
|
||||
export type PermissionOutcome = 'granted' | 'denied' | 'blocked';
|
||||
|
||||
const EMPTY: SimInfo = {
|
||||
hasSim: false,
|
||||
hasPermission: false,
|
||||
canReadNumbers: false,
|
||||
phoneNumber: null,
|
||||
carrierName: null,
|
||||
sims: [],
|
||||
};
|
||||
|
||||
/**
|
||||
* Deliberately loose: the payload is validated field by field below, and
|
||||
* declaring it as `SimInfo` would let TypeScript vouch for keys an older
|
||||
* native build never sends.
|
||||
*/
|
||||
interface SimCardNativeModule {
|
||||
getSimInfo(): Promise<Record<string, unknown> | null>;
|
||||
}
|
||||
|
||||
/** Ten digits or nothing — the same contract the native side normalises to. */
|
||||
const cleanNumber = (value: unknown): string | null => {
|
||||
if (typeof value !== 'string') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const digits = value.replace(/\D/g, '');
|
||||
|
||||
return digits.length === 10 ? digits : null;
|
||||
};
|
||||
|
||||
const toSubscription = (raw: unknown): SimSubscription | null => {
|
||||
if (typeof raw !== 'object' || raw === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const value = raw as Record<string, unknown>;
|
||||
const slotIndex = Number(value.slotIndex);
|
||||
|
||||
return {
|
||||
subscriptionId: Number(value.subscriptionId) || 0,
|
||||
slotIndex: Number.isFinite(slotIndex) ? slotIndex : 0,
|
||||
carrierName: typeof value.carrierName === 'string' ? value.carrierName : null,
|
||||
displayName: typeof value.displayName === 'string' ? value.displayName : null,
|
||||
phoneNumber: cleanNumber(value.phoneNumber),
|
||||
};
|
||||
};
|
||||
|
||||
const native = NativeModules.SimCard as SimCardNativeModule | undefined;
|
||||
|
||||
/** READ_PHONE_NUMBERS is the gate on the MSISDN from Android 13 onward. */
|
||||
const NUMBERS_IS_REQUIRED = Platform.OS === 'android' && Number(Platform.Version) >= 33;
|
||||
|
||||
/** The permission exists from Android 8, so there is no point asking below it. */
|
||||
const NUMBERS_EXISTS = Platform.OS === 'android' && Number(Platform.Version) >= 26;
|
||||
|
||||
/**
|
||||
* Reads the handset's own number so registration can offer it rather than make
|
||||
* the customer type it.
|
||||
*
|
||||
* A null `phoneNumber` is the ordinary case, not a failure: the MSISDN is only
|
||||
* readable if the carrier wrote it onto the SIM, and most Indian operators do
|
||||
* not. The caller must always keep a path that does not depend on it.
|
||||
*/
|
||||
export const simCard = {
|
||||
/**
|
||||
* Reads what the handset will say, and never prompts.
|
||||
*
|
||||
* Kept prompt-free on purpose: the registration screen calls this on mount,
|
||||
* and a permission dialog thrown up before the customer has read what the
|
||||
* screen is even for is the reason people refuse it.
|
||||
*/
|
||||
async read(): Promise<SimInfo> {
|
||||
if (Platform.OS !== 'android' || !native) {
|
||||
return EMPTY;
|
||||
}
|
||||
|
||||
try {
|
||||
const info = await native.getSimInfo();
|
||||
const sims = Array.isArray(info?.sims)
|
||||
? info.sims
|
||||
.map(toSubscription)
|
||||
.filter((entry): entry is SimSubscription => entry !== null)
|
||||
// Slot order, so "SIM 1" in the UI is the handset's SIM 1. The
|
||||
// platform does not guarantee the list comes back ordered.
|
||||
.sort((a, b) => a.slotIndex - b.slotIndex)
|
||||
: [];
|
||||
|
||||
const hasPermission = Boolean(info?.hasPermission);
|
||||
|
||||
return {
|
||||
hasSim: Boolean(info?.hasSim),
|
||||
hasPermission,
|
||||
/*
|
||||
* A native build older than this field simply omits it, which happens
|
||||
* on any JS-only reload against an installed APK.
|
||||
*
|
||||
* Reading a missing key as `false` would be worse than useless: the
|
||||
* screen would show "one more permission needed", the button would ask
|
||||
* for a permission already granted, the re-read would come back false
|
||||
* again, and the customer would be stuck in that loop with no way out.
|
||||
* Falling back to `hasPermission` reproduces the old behaviour instead,
|
||||
* which is wrong in the same way it was always wrong but never traps
|
||||
* anyone.
|
||||
*/
|
||||
canReadNumbers:
|
||||
typeof info?.canReadNumbers === 'boolean' ? info.canReadNumbers : hasPermission,
|
||||
phoneNumber: cleanNumber(info?.phoneNumber),
|
||||
carrierName: typeof info?.carrierName === 'string' ? info.carrierName : null,
|
||||
sims,
|
||||
};
|
||||
} catch {
|
||||
return EMPTY;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Asks for both phone permissions and reports what happened.
|
||||
*
|
||||
* Both are requested together, always. Asking for only what is missing
|
||||
* sounds tidier but produces a second dialog on a later run, and two separate
|
||||
* phone-permission prompts in one flow is what makes an app look like it is
|
||||
* fishing.
|
||||
*
|
||||
* "Granted" means the app can do the job the customer agreed to: enumerate
|
||||
* the slots, and — on Android 13 and up, where it is a separate grant — read
|
||||
* the numbers. A partial grant is reported as `denied`, not success, because
|
||||
* a slot list with every number null is not a usable registration screen.
|
||||
*/
|
||||
async requestPermission(): Promise<PermissionOutcome> {
|
||||
if (Platform.OS !== 'android') {
|
||||
return 'denied';
|
||||
}
|
||||
|
||||
try {
|
||||
const wanted: string[] = [PermissionsAndroid.PERMISSIONS.READ_PHONE_STATE];
|
||||
|
||||
if (NUMBERS_EXISTS) {
|
||||
wanted.push(PermissionsAndroid.PERMISSIONS.READ_PHONE_NUMBERS);
|
||||
}
|
||||
|
||||
const result = await PermissionsAndroid.requestMultiple(
|
||||
wanted as Parameters<typeof PermissionsAndroid.requestMultiple>[0],
|
||||
);
|
||||
|
||||
const stateStatus = result[PermissionsAndroid.PERMISSIONS.READ_PHONE_STATE];
|
||||
const numbersStatus = NUMBERS_EXISTS
|
||||
? result[PermissionsAndroid.PERMISSIONS.READ_PHONE_NUMBERS]
|
||||
: PermissionsAndroid.RESULTS.GRANTED;
|
||||
|
||||
const needed = NUMBERS_IS_REQUIRED ? [stateStatus, numbersStatus] : [stateStatus];
|
||||
|
||||
if (needed.every((status) => status === PermissionsAndroid.RESULTS.GRANTED)) {
|
||||
return 'granted';
|
||||
}
|
||||
|
||||
// Checked across both, not only the ones we strictly need: once either is
|
||||
// in never-ask-again the dialog stops appearing, and a "try again" button
|
||||
// would be dead from then on.
|
||||
if (
|
||||
[stateStatus, numbersStatus].some(
|
||||
(status) => status === PermissionsAndroid.RESULTS.NEVER_ASK_AGAIN,
|
||||
)
|
||||
) {
|
||||
return 'blocked';
|
||||
}
|
||||
|
||||
return 'denied';
|
||||
} catch {
|
||||
return 'denied';
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Prompts, then re-reads. For a button the customer pressed knowing what it
|
||||
* would do — not for mount.
|
||||
*/
|
||||
async requestAndRead(): Promise<{ outcome: PermissionOutcome; info: SimInfo }> {
|
||||
const outcome = await this.requestPermission();
|
||||
|
||||
return { outcome, info: await this.read() };
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,216 @@
|
||||
import { Platform } from 'react-native';
|
||||
import ReactNativeBlobUtil from 'react-native-blob-util';
|
||||
|
||||
import { env } from '../../config/env';
|
||||
import { apiLogger } from '../api/apiLogger';
|
||||
import { CbsApiError, isCbsErrorCode } from '../api/cbsErrors';
|
||||
import { sessionManager } from '../security/sessionManager';
|
||||
import { secureStorage } from '../storage/secureStorage';
|
||||
import type { CbsPdfDocument } from '../../types/cbs';
|
||||
|
||||
/**
|
||||
* The two statement endpoints that answer a PDF rather than the JSON envelope.
|
||||
*
|
||||
* Everything else in the app goes through `apiClient`, whose response
|
||||
* interceptor unwraps that envelope — which is exactly why these cannot: axios
|
||||
* on React Native has no usable binary response, and the interceptor would try
|
||||
* to read a PDF as one of ours. So this module speaks to the backend directly
|
||||
* and reimplements the three things the interceptor would otherwise have done:
|
||||
* attach the bearer token, touch the idle timer on success, and turn a
|
||||
* `success: false` body into a `CbsApiError` the screens already know how to
|
||||
* render.
|
||||
*
|
||||
* The file lands in the app's own cache directory. That needs no runtime
|
||||
* permission on either platform, is not visible to other apps, and goes away
|
||||
* with the app — which is the right home for a document the customer is about
|
||||
* to view or share, and the wrong one for anything they expect to keep, hence
|
||||
* `shareDocument` below rather than a silent write to Downloads.
|
||||
*/
|
||||
|
||||
/** `CONTENT-DISPOSITION` casing varies by server; match on the lowercased key. */
|
||||
const readFileName = (headers: Record<string, string>) => {
|
||||
const entry = Object.entries(headers).find(
|
||||
([key]) => key.toLowerCase() === 'content-disposition',
|
||||
);
|
||||
|
||||
if (!entry) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const match = /filename\*?=(?:UTF-8'')?"?([^";]+)"?/i.exec(entry[1]);
|
||||
|
||||
return match?.[1] ? decodeURIComponent(match[1]) : null;
|
||||
};
|
||||
|
||||
const readContentType = (headers: Record<string, string>) => {
|
||||
const entry = Object.entries(headers).find(([key]) => key.toLowerCase() === 'content-type');
|
||||
|
||||
return entry?.[1]?.toLowerCase() ?? '';
|
||||
};
|
||||
|
||||
/**
|
||||
* A failure body that arrived where a PDF was expected.
|
||||
*
|
||||
* The bank answers its ordinary envelope for every refusal on these endpoints
|
||||
* — `ACCOUNT_NOT_FOUND`, `INVALID_DATE_RANGE`, a dead session — so the body
|
||||
* has to be read as JSON before it can be trusted as a document. A response
|
||||
* that is neither a PDF nor a readable envelope is reported as
|
||||
* `UNKNOWN_ERROR` rather than handed on as a file that will not open.
|
||||
*/
|
||||
const toApiFailure = async (
|
||||
status: number,
|
||||
response: Awaited<ReturnType<ReturnType<typeof ReactNativeBlobUtil.config>['fetch']>>,
|
||||
) => {
|
||||
try {
|
||||
const envelope = JSON.parse(await response.text()) as {
|
||||
message?: string;
|
||||
errorCode?: string;
|
||||
};
|
||||
|
||||
return new CbsApiError({
|
||||
status,
|
||||
code: isCbsErrorCode(envelope.errorCode) ? envelope.errorCode : 'UNKNOWN_ERROR',
|
||||
message: envelope.message || 'The document could not be prepared',
|
||||
});
|
||||
} catch {
|
||||
return new CbsApiError({
|
||||
status,
|
||||
code: status === 401 ? 'SESSION_INVALID' : 'UNKNOWN_ERROR',
|
||||
message: 'The document could not be prepared',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const toQueryString = (params: Record<string, string | undefined>) => {
|
||||
const pairs = Object.entries(params).filter(
|
||||
(entry): entry is [string, string] => entry[1] !== undefined && entry[1] !== '',
|
||||
);
|
||||
|
||||
return pairs
|
||||
.map(([key, value]) => `${encodeURIComponent(key)}=${encodeURIComponent(value)}`)
|
||||
.join('&');
|
||||
};
|
||||
|
||||
/**
|
||||
* Fetches an authenticated PDF straight to a file on disk.
|
||||
*
|
||||
* Streamed to the file rather than buffered in JS: a statement for a wide date
|
||||
* range is the one response in this app that can run to megabytes, and holding
|
||||
* it as a base64 string would triple that in memory for no gain.
|
||||
*/
|
||||
export const downloadPdf = async ({
|
||||
path,
|
||||
params,
|
||||
fallbackFileName,
|
||||
}: {
|
||||
/** Endpoint path, relative to the API prefix. */
|
||||
path: string;
|
||||
params: Record<string, string | undefined>;
|
||||
/** Used when the server sends no `Content-Disposition`. */
|
||||
fallbackFileName: string;
|
||||
}): Promise<CbsPdfDocument> => {
|
||||
const token = await secureStorage.getAccessToken();
|
||||
|
||||
if (!token) {
|
||||
// Nothing to send. Reported as the same code the interceptor raises for a
|
||||
// token the server has rejected, so the global 401 handling applies here
|
||||
// too rather than this one call failing in its own private way.
|
||||
throw new CbsApiError({
|
||||
status: 401,
|
||||
code: 'SESSION_INVALID',
|
||||
message: 'Your session has ended',
|
||||
});
|
||||
}
|
||||
|
||||
const query = toQueryString(params);
|
||||
const url = `${env.apiBaseUrl}${path}${query ? `?${query}` : ''}`;
|
||||
|
||||
apiLogger.rawRequest('GET', url, 'Binary download — outside apiClient.');
|
||||
|
||||
const response = await ReactNativeBlobUtil.config({
|
||||
fileCache: true,
|
||||
appendExt: 'pdf',
|
||||
}).fetch('GET', url, {
|
||||
Authorization: `Bearer ${token}`,
|
||||
Accept: 'application/pdf',
|
||||
});
|
||||
|
||||
const status = response.info().status;
|
||||
const headers = (response.info().headers ?? {}) as Record<string, string>;
|
||||
const contentType = readContentType(headers);
|
||||
|
||||
// Branch on the payload, not on the status: this backend can answer a
|
||||
// failure envelope with a 200, exactly as it does everywhere else.
|
||||
if (status >= 400 || !contentType.includes('application/pdf')) {
|
||||
const failure = await toApiFailure(status, response);
|
||||
|
||||
apiLogger.rawResponse(
|
||||
status,
|
||||
url,
|
||||
`${failure.code}: ${failure.message} (content-type: ${contentType || 'none'})`,
|
||||
);
|
||||
|
||||
// The cached file is a JSON error body, not a document. Drop it here
|
||||
// rather than leaving it to be "opened" as a PDF later.
|
||||
await response.flush();
|
||||
|
||||
if (failure.code === 'SESSION_INVALID') {
|
||||
await secureStorage.clearSession();
|
||||
sessionManager.clear();
|
||||
}
|
||||
|
||||
throw failure;
|
||||
}
|
||||
|
||||
// A successful authenticated call refreshes the server's idle window, and
|
||||
// this one bypassed the interceptor that normally mirrors that locally.
|
||||
sessionManager.touch();
|
||||
|
||||
const document = {
|
||||
path: response.path(),
|
||||
fileName: readFileName(headers) ?? fallbackFileName,
|
||||
};
|
||||
|
||||
apiLogger.rawResponse(status, url, `${document.fileName} → ${document.path}`);
|
||||
|
||||
return document;
|
||||
};
|
||||
|
||||
/**
|
||||
* Hands the document to the OS so the customer can read it, print it, or save
|
||||
* it wherever they actually keep things.
|
||||
*
|
||||
* Deliberately not a write into Downloads or the Files app. The app's own
|
||||
* cache needs no storage permission and no scoped-storage dance, and the share
|
||||
* sheet is where a customer chooses what happens to their statement — mail it,
|
||||
* keep it, send it to an accountant — rather than the app deciding for them.
|
||||
*/
|
||||
export const shareDocument = async (document: CbsPdfDocument) => {
|
||||
if (Platform.OS === 'ios') {
|
||||
await ReactNativeBlobUtil.ios.presentOptionsMenu(document.path);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// `actionViewIntent` needs a content:// URI, which the library's FileProvider
|
||||
// produces from a path inside the app's own directories — the cache dir
|
||||
// `downloadPdf` writes to qualifies.
|
||||
await ReactNativeBlobUtil.android.actionViewIntent(document.path, 'application/pdf');
|
||||
};
|
||||
|
||||
/**
|
||||
* Opens the document for reading, without the share sheet in the way.
|
||||
*
|
||||
* On Android this is the same intent as sharing — the OS picks a viewer — so
|
||||
* the two differ only on iOS, where a preview is a document controller and a
|
||||
* share is an activity sheet.
|
||||
*/
|
||||
export const previewDocument = async (document: CbsPdfDocument) => {
|
||||
if (Platform.OS === 'ios') {
|
||||
await ReactNativeBlobUtil.ios.openDocument(document.path);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
await ReactNativeBlobUtil.android.actionViewIntent(document.path, 'application/pdf');
|
||||
};
|
||||
@@ -0,0 +1,80 @@
|
||||
import 'react-native-get-random-values';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import * as Keychain from 'react-native-keychain';
|
||||
|
||||
const DEVICE_SERVICE = 'com.bankcbs.mobile.deviceId';
|
||||
|
||||
/**
|
||||
* Kept in memory as well so the many calls that need it during one flow do not
|
||||
* each hit the Keystore.
|
||||
*/
|
||||
let cachedDeviceId: string | null = null;
|
||||
let inflight: Promise<string> | null = null;
|
||||
|
||||
const persist = async (deviceId: string) => {
|
||||
await Keychain.setGenericPassword('deviceId', deviceId, {
|
||||
service: DEVICE_SERVICE,
|
||||
accessible: Keychain.ACCESSIBLE.AFTER_FIRST_UNLOCK_THIS_DEVICE_ONLY,
|
||||
securityLevel: Keychain.SECURITY_LEVEL.SECURE_SOFTWARE,
|
||||
});
|
||||
};
|
||||
|
||||
const load = async (): Promise<string> => {
|
||||
const stored = await Keychain.getGenericPassword({ service: DEVICE_SERVICE });
|
||||
|
||||
if (stored && stored.password) {
|
||||
return stored.password;
|
||||
}
|
||||
|
||||
const created = uuidv4();
|
||||
await persist(created);
|
||||
|
||||
return created;
|
||||
};
|
||||
|
||||
/**
|
||||
* The profile the customer registers is bound to this value. Registration and
|
||||
* every later sign-in must send the *same* one — a different value comes back
|
||||
* as `DEVICE_NOT_BOUND`, which reads like a server fault but is a client bug.
|
||||
* So it is generated exactly once, on first launch, and never regenerated
|
||||
* except by an explicit `reset()`.
|
||||
*
|
||||
* `AFTER_FIRST_UNLOCK_THIS_DEVICE_ONLY` rather than `WHEN_UNLOCKED_*`: the id
|
||||
* is not a secret, and sign-in must read it before the user has authenticated.
|
||||
*/
|
||||
export const deviceIdentity = {
|
||||
async get(): Promise<string> {
|
||||
if (cachedDeviceId) {
|
||||
return cachedDeviceId;
|
||||
}
|
||||
|
||||
// Concurrent callers on a cold start would otherwise each mint a UUID and
|
||||
// the last write would win, silently unbinding the device.
|
||||
inflight = inflight ?? load();
|
||||
|
||||
try {
|
||||
cachedDeviceId = await inflight;
|
||||
return cachedDeviceId;
|
||||
} finally {
|
||||
inflight = null;
|
||||
}
|
||||
},
|
||||
|
||||
/** In-memory peek for code that cannot await. Null before the first `get()`. */
|
||||
peek() {
|
||||
return cachedDeviceId;
|
||||
},
|
||||
|
||||
/**
|
||||
* Mints a fresh id, unbinding this install from its registered profile. Only
|
||||
* for a deliberate "Register again" action — the customer will
|
||||
* have to go through registration once more.
|
||||
*/
|
||||
async reset(): Promise<string> {
|
||||
const next = uuidv4();
|
||||
await persist(next);
|
||||
cachedDeviceId = next;
|
||||
|
||||
return next;
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,90 @@
|
||||
import 'react-native-get-random-values';
|
||||
import forge from 'node-forge';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
|
||||
import { apiClient } from '../api/apiClient';
|
||||
import { endpoints } from '../api/endpoints';
|
||||
import type { PublicKeyResult } from '../../types/cbs';
|
||||
|
||||
/**
|
||||
* The server's key is configured rather than generated at startup, so it
|
||||
* survives restarts and can be pinned. It is only ever used as a fallback for
|
||||
* when the key fetch itself fails — a developer running their own backend gets
|
||||
* the fetched key, which is the safer default.
|
||||
*/
|
||||
export const PINNED_PUBLIC_KEY =
|
||||
'MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAxhaxPpoewSn+NCHJuICPVmMCO9f3VXN8zMUFryUv5kcn9rCwQMF023o+xtQCljyJdDaJE805lkp9FMZRupSro9auwmGGHTP5fgX5arn5p21LjbcITBxVIh+Uvj1tNKdxhd69xbTf98LfWGK9DspbWd9ZPk6m00La13eMLFR3iBfljzyT9pkENQ5WSvtPrN3+6JS8K9DsCgj/3WbIwwWTPlIN1rXV6gQumFaGl5CVsuibom2tA9pHTjAJi80LLFWP3I7mMkCihvNZT0/eFIBf1WCiOixm4Rt7Xk+Yh05daazMWAJhav+btknQd1gV/1oLolb5uegaobKK5g+xyzA33wIDAQAB';
|
||||
|
||||
let cachedKey: forge.pki.rsa.PublicKey | null = null;
|
||||
|
||||
const importKey = (base64Der: string): forge.pki.rsa.PublicKey => {
|
||||
const der = forge.util.decode64(base64Der);
|
||||
const asn1 = forge.asn1.fromDer(forge.util.createBuffer(der));
|
||||
|
||||
return forge.pki.publicKeyFromAsn1(asn1) as forge.pki.rsa.PublicKey;
|
||||
};
|
||||
|
||||
const fetchKey = async (): Promise<forge.pki.rsa.PublicKey> => {
|
||||
try {
|
||||
const response = await apiClient.get<PublicKeyResult>(endpoints.crypto.publicKey);
|
||||
const publicKey = response.data?.publicKey;
|
||||
|
||||
if (publicKey) {
|
||||
return importKey(publicKey);
|
||||
}
|
||||
} catch {
|
||||
// Fall through to the pinned key.
|
||||
}
|
||||
|
||||
return importKey(PINNED_PUBLIC_KEY);
|
||||
};
|
||||
|
||||
export const mpinCrypto = {
|
||||
async getPublicKey(): Promise<forge.pki.rsa.PublicKey> {
|
||||
cachedKey = cachedKey ?? (await fetchKey());
|
||||
|
||||
return cachedKey;
|
||||
},
|
||||
|
||||
/**
|
||||
* Drop the cached key so the next encryption re-fetches it. Call this after a
|
||||
* `MALFORMED_REQUEST`, which is what a stale or wrong key looks like from the
|
||||
* client side — the server cannot say "your key is wrong" without leaking
|
||||
* whether the profile exists.
|
||||
*/
|
||||
invalidate() {
|
||||
cachedKey = null;
|
||||
},
|
||||
|
||||
/**
|
||||
* The MPIN never leaves the device in the clear. RSA-OAEP with the server's
|
||||
* public key, and the ciphertext travels as `encryptedMpin`.
|
||||
*
|
||||
* Three things break integrations here, all of them silently:
|
||||
*
|
||||
* - MGF1 must be SHA-256, and is passed explicitly. node-forge happens to
|
||||
* default it to whatever `md` is (verified against 1.4.0, `lib/pkcs1.js`),
|
||||
* but Java's `OAEPWithSHA-256AndMGF1Padding` notoriously does not — it
|
||||
* leaves MGF1 on SHA-1. Relying on a library default here produces
|
||||
* ciphertext the server cannot decrypt, and the error says nothing useful.
|
||||
* - `ts` is in *seconds*. Milliseconds read as ~55,000 years in the future
|
||||
* and the request is rejected.
|
||||
* - `nonce` must be fresh on every single call, or it is refused as a replay.
|
||||
*/
|
||||
async encryptMpin(mpin: string): Promise<string> {
|
||||
const publicKey = await this.getPublicKey();
|
||||
|
||||
const payload = JSON.stringify({
|
||||
mpin,
|
||||
nonce: uuidv4(),
|
||||
ts: Math.floor(Date.now() / 1000),
|
||||
});
|
||||
|
||||
return forge.util.encode64(
|
||||
publicKey.encrypt(payload, 'RSA-OAEP', {
|
||||
md: forge.md.sha256.create(),
|
||||
mgf1: { md: forge.md.sha256.create() },
|
||||
}),
|
||||
);
|
||||
},
|
||||
};
|
||||
@@ -1,17 +1,162 @@
|
||||
import { appConfig } from '../../config/appConfig';
|
||||
|
||||
let lastActivityAt = Date.now();
|
||||
/**
|
||||
* Two independent limits, and the session dies at whichever comes first:
|
||||
* an idle window the server resets on every authenticated call, and an
|
||||
* absolute ceiling it never extends however active the user is.
|
||||
*/
|
||||
interface SessionWindow {
|
||||
idleTimeoutMs: number;
|
||||
/** Epoch ms of the server's absolute expiry, or null when unknown. */
|
||||
absoluteExpiryAt: number | null;
|
||||
}
|
||||
|
||||
export type SessionExpiryReason = 'idle' | 'absolute';
|
||||
|
||||
/**
|
||||
* When we last exchanged an authenticated request with the server. This — not
|
||||
* anything the user did — is what the server's idle window is measured from,
|
||||
* so it is what expiry has to be judged on.
|
||||
*/
|
||||
let lastServerContactAt = Date.now();
|
||||
|
||||
/**
|
||||
* When the user last touched the screen. Tracked separately because most of
|
||||
* this app reads from fixtures: someone can tap around for five minutes without
|
||||
* a single authenticated call, and treating that as idle would sign them out
|
||||
* mid-use. Interaction cannot reset the server's timer by itself — only a call
|
||||
* can — so this drives a keep-alive rather than the expiry clock.
|
||||
*/
|
||||
let lastInteractionAt = Date.now();
|
||||
|
||||
let window: SessionWindow = {
|
||||
idleTimeoutMs: appConfig.security.sessionTimeoutMs,
|
||||
absoluteExpiryAt: null,
|
||||
};
|
||||
|
||||
export const sessionManager = {
|
||||
/**
|
||||
* Adopt the limits the server reported at sign-in. `idleTimeoutSeconds` and
|
||||
* `expiresAt` come straight off the login response, so the app never has to
|
||||
* guess and never drifts from what the backend will enforce.
|
||||
*/
|
||||
configure({
|
||||
idleTimeoutSeconds,
|
||||
expiresAt,
|
||||
}: {
|
||||
idleTimeoutSeconds?: number;
|
||||
expiresAt?: string;
|
||||
}) {
|
||||
const absolute = expiresAt ? Date.parse(expiresAt) : NaN;
|
||||
|
||||
window = {
|
||||
idleTimeoutMs:
|
||||
typeof idleTimeoutSeconds === 'number' && idleTimeoutSeconds > 0
|
||||
? idleTimeoutSeconds * 1000
|
||||
: appConfig.security.sessionTimeoutMs,
|
||||
absoluteExpiryAt: Number.isFinite(absolute) ? absolute : null,
|
||||
};
|
||||
|
||||
lastServerContactAt = Date.now();
|
||||
lastInteractionAt = Date.now();
|
||||
},
|
||||
|
||||
clear() {
|
||||
window = {
|
||||
idleTimeoutMs: appConfig.security.sessionTimeoutMs,
|
||||
absoluteExpiryAt: null,
|
||||
};
|
||||
lastServerContactAt = Date.now();
|
||||
lastInteractionAt = Date.now();
|
||||
},
|
||||
|
||||
/** An authenticated call completed, so the server's idle window just reset. */
|
||||
touch() {
|
||||
lastActivityAt = Date.now();
|
||||
lastServerContactAt = Date.now();
|
||||
lastInteractionAt = Date.now();
|
||||
},
|
||||
|
||||
/**
|
||||
* The user touched the screen. Deliberately does not reset the expiry clock:
|
||||
* the server has not heard from us, so pretending otherwise would leave the
|
||||
* app believing it is signed in while the next real request 401s. It only
|
||||
* makes `shouldKeepAlive()` true, and the keep-alive call is what actually
|
||||
* moves the server's window.
|
||||
*/
|
||||
noteInteraction() {
|
||||
lastInteractionAt = Date.now();
|
||||
},
|
||||
|
||||
/**
|
||||
* Whether to spend a keep-alive call now.
|
||||
*
|
||||
* True only when the user has done something since we last spoke to the
|
||||
* server and the idle window is over halfway gone. The halfway threshold is
|
||||
* what keeps this to roughly one request per two and a half minutes instead
|
||||
* of one per tap.
|
||||
*/
|
||||
shouldKeepAlive() {
|
||||
if (lastInteractionAt <= lastServerContactAt) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Nothing to preserve if the session is already gone, and no point
|
||||
// extending the idle window when the absolute cap is what will bite.
|
||||
if (this.hasExpired() || this.getAbsoluteRemainingMs() <= window.idleTimeoutMs / 2) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return this.getIdleDurationMs() >= window.idleTimeoutMs / 2;
|
||||
},
|
||||
|
||||
/**
|
||||
* Which limit will end the session first. The warning copy depends on it:
|
||||
* against the idle window carrying on using the app helps, and against the
|
||||
* absolute cap nothing does, so telling the user to "tap to stay signed in"
|
||||
* would be a lie.
|
||||
*/
|
||||
nearestLimit(): SessionExpiryReason {
|
||||
return this.getAbsoluteRemainingMs() <= this.getIdleRemainingMs() ? 'absolute' : 'idle';
|
||||
},
|
||||
|
||||
getIdleTimeoutMs() {
|
||||
return window.idleTimeoutMs;
|
||||
},
|
||||
|
||||
getIdleDurationMs() {
|
||||
return Date.now() - lastActivityAt;
|
||||
return Date.now() - lastServerContactAt;
|
||||
},
|
||||
|
||||
getIdleRemainingMs() {
|
||||
return Math.max(0, window.idleTimeoutMs - this.getIdleDurationMs());
|
||||
},
|
||||
|
||||
getAbsoluteRemainingMs() {
|
||||
if (window.absoluteExpiryAt === null) {
|
||||
return Number.POSITIVE_INFINITY;
|
||||
}
|
||||
|
||||
return Math.max(0, window.absoluteExpiryAt - Date.now());
|
||||
},
|
||||
|
||||
/** Milliseconds until the session dies, whichever limit bites first. */
|
||||
getRemainingMs() {
|
||||
return Math.min(this.getIdleRemainingMs(), this.getAbsoluteRemainingMs());
|
||||
},
|
||||
|
||||
expiryReason(): SessionExpiryReason | null {
|
||||
if (this.getAbsoluteRemainingMs() <= 0) {
|
||||
return 'absolute';
|
||||
}
|
||||
|
||||
if (this.getIdleRemainingMs() <= 0) {
|
||||
return 'idle';
|
||||
}
|
||||
|
||||
return null;
|
||||
},
|
||||
|
||||
hasExpired() {
|
||||
return this.getIdleDurationMs() >= appConfig.security.sessionTimeoutMs;
|
||||
return this.expiryReason() !== null;
|
||||
},
|
||||
};
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||
|
||||
import { featureRemoteConfig } from '../../features/featureRemoteConfig';
|
||||
import { mpinCrypto } from '../security/mpinCrypto';
|
||||
import { sessionManager } from '../security/sessionManager';
|
||||
import { secureStorage } from './secureStorage';
|
||||
|
||||
/**
|
||||
* The only AsyncStorage keys that survive a sign-out.
|
||||
*
|
||||
* The policy is deliberately inverted: everything is cleared unless it is named
|
||||
* here, rather than a list of things to clear. A key added next month is then
|
||||
* wiped by default instead of quietly outliving the customer who created it —
|
||||
* which is the whole point of the guarantee, and the failure mode a
|
||||
* clear-this-list approach has every time someone forgets to update it.
|
||||
*
|
||||
* None of the three exemptions is a property of a person:
|
||||
*
|
||||
* - the theme is a display choice made on this device, and flipping someone
|
||||
* back to light mode because they signed out is a bug, not privacy;
|
||||
* - the feature overrides are a tester's switches, and `featureOverrides` has
|
||||
* always documented that they outlive a sign-out. They are configuration,
|
||||
* and a bank kill switch still overrides anything set there;
|
||||
* - the cached feature config is that kill switch. It is cached so a handset
|
||||
* that starts offline still honours a feature the bank killed during an
|
||||
* incident, and deleting it would make signing out a way to turn those
|
||||
* features back on. Its one customer-scoped half, `roleOverrides`, is
|
||||
* stripped out below instead — see `forgetRoleScope`.
|
||||
*/
|
||||
const KEPT_KEYS: readonly string[] = [
|
||||
'@bankcbs/theme-mode',
|
||||
'bankcbs.featureOverrides',
|
||||
'bankcbs.featureConfig.lastKnown',
|
||||
];
|
||||
|
||||
/**
|
||||
* Removes everything this install holds about the person who was signed in.
|
||||
*
|
||||
* What it covers, and why each one had to be named:
|
||||
*
|
||||
* - the Keychain session, the remembered profile (their mobile number, org and
|
||||
* name) and the **MPIN itself**, which biometric sign-in stores behind the
|
||||
* device prompt. Only the session was being cleared before, so the other
|
||||
* two survived every sign-out;
|
||||
* - every AsyncStorage key but the three above — the biometric flag, payee
|
||||
* favourites, and awareness and scheme bookmarks;
|
||||
* - the customer's entitlements, in memory *and* in the config cache, which
|
||||
* would otherwise be re-applied over whoever signs in next;
|
||||
* - the in-memory caches, which no amount of storage clearing reaches: the
|
||||
* session window and the fetched RSA key.
|
||||
*
|
||||
* What it deliberately does not touch is the device id. That is this install's
|
||||
* identity rather than the customer's — the profile is bound to it at
|
||||
* verify-otp, and every later sign-in has to send the same value or the server
|
||||
* answers `DEVICE_NOT_BOUND`. Clearing it would not protect anyone; it would
|
||||
* lock the handset out of the account it is registered to.
|
||||
* `deviceIdentity.reset()` exists for the deliberate "register again" case.
|
||||
*
|
||||
* Nothing here may throw. A sign-out that fails halfway is worse than one that
|
||||
* skips a key, so each step is isolated and the rest still runs.
|
||||
*/
|
||||
export const purgeUserData = async () => {
|
||||
await Promise.allSettled([
|
||||
secureStorage.clearSession(),
|
||||
secureStorage.clearLastProfile(),
|
||||
secureStorage.clearBiometricMpin(),
|
||||
clearAsyncStorage(),
|
||||
]);
|
||||
|
||||
// After the sweep, not beside it: it rewrites a key the sweep deliberately
|
||||
// keeps, and the two racing would decide the file's contents by luck.
|
||||
await featureRemoteConfig.forgetRoleScope().catch(() => {});
|
||||
|
||||
sessionManager.clear();
|
||||
mpinCrypto.invalidate();
|
||||
};
|
||||
|
||||
const clearAsyncStorage = async () => {
|
||||
const keys = await AsyncStorage.getAllKeys();
|
||||
const doomed = keys.filter((key) => !KEPT_KEYS.includes(key));
|
||||
|
||||
if (doomed.length > 0) {
|
||||
await AsyncStorage.multiRemove(doomed);
|
||||
}
|
||||
};
|
||||
@@ -1,45 +1,164 @@
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||
import * as Keychain from 'react-native-keychain';
|
||||
|
||||
import type { AuthTokens } from '../../types/app';
|
||||
import type { AuthSession } from '../../types/app';
|
||||
|
||||
const BIOMETRIC_KEY = 'bankcbs.biometricEnabled';
|
||||
const SESSION_SERVICE = 'com.bankcbs.mobile.session';
|
||||
/** The number and org last signed in with, so sign-in can prefill them. */
|
||||
const PROFILE_SERVICE = 'com.bankcbs.mobile.lastProfile';
|
||||
/**
|
||||
* The MPIN, held only when the customer opts into biometric sign-in. The API
|
||||
* has no refresh token — a session can only be opened by encrypting the MPIN —
|
||||
* so biometric sign-in is impossible without keeping it. It lives behind the
|
||||
* device's own biometric prompt and never in AsyncStorage.
|
||||
*/
|
||||
const BIOMETRIC_MPIN_SERVICE = 'com.bankcbs.mobile.biometricMpin';
|
||||
|
||||
export interface LastProfile {
|
||||
orgId: string;
|
||||
mobileNumber: string;
|
||||
fullName?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* The request interceptor needs the token on every single call, and a Keystore
|
||||
* read costs tens of milliseconds on Android. Held in memory alongside the
|
||||
* Keychain copy, which stays the durable one across launches.
|
||||
*/
|
||||
let cachedSession: AuthSession | null = null;
|
||||
let sessionCacheLoaded = false;
|
||||
|
||||
export const secureStorage = {
|
||||
async saveSession(tokens: AuthTokens) {
|
||||
await Keychain.setGenericPassword('session', JSON.stringify(tokens), {
|
||||
async saveSession(session: AuthSession) {
|
||||
await Keychain.setGenericPassword('session', JSON.stringify(session), {
|
||||
service: SESSION_SERVICE,
|
||||
accessible: Keychain.ACCESSIBLE.WHEN_UNLOCKED_THIS_DEVICE_ONLY,
|
||||
securityLevel: Keychain.SECURITY_LEVEL.SECURE_SOFTWARE,
|
||||
});
|
||||
|
||||
cachedSession = session;
|
||||
sessionCacheLoaded = true;
|
||||
},
|
||||
|
||||
async getSession(): Promise<AuthTokens | null> {
|
||||
async getSession(): Promise<AuthSession | null> {
|
||||
if (sessionCacheLoaded) {
|
||||
return cachedSession;
|
||||
}
|
||||
|
||||
const credentials = await Keychain.getGenericPassword({
|
||||
service: SESSION_SERVICE,
|
||||
});
|
||||
|
||||
if (!credentials) {
|
||||
cachedSession = null;
|
||||
sessionCacheLoaded = true;
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
cachedSession = JSON.parse(credentials.password) as AuthSession;
|
||||
sessionCacheLoaded = true;
|
||||
|
||||
return cachedSession;
|
||||
} catch {
|
||||
// A session written by an older build of the app. Treat it as absent
|
||||
// rather than crashing the bootstrap.
|
||||
await this.clearSession();
|
||||
|
||||
return null;
|
||||
}
|
||||
},
|
||||
|
||||
async getAccessToken() {
|
||||
const session = await this.getSession();
|
||||
return session?.sessionToken ?? null;
|
||||
},
|
||||
|
||||
async clearSession() {
|
||||
cachedSession = null;
|
||||
sessionCacheLoaded = true;
|
||||
|
||||
await Keychain.resetGenericPassword({
|
||||
service: SESSION_SERVICE,
|
||||
});
|
||||
},
|
||||
|
||||
async saveLastProfile(profile: LastProfile) {
|
||||
await Keychain.setGenericPassword('lastProfile', JSON.stringify(profile), {
|
||||
service: PROFILE_SERVICE,
|
||||
accessible: Keychain.ACCESSIBLE.AFTER_FIRST_UNLOCK_THIS_DEVICE_ONLY,
|
||||
securityLevel: Keychain.SECURITY_LEVEL.SECURE_SOFTWARE,
|
||||
});
|
||||
},
|
||||
|
||||
async getLastProfile(): Promise<LastProfile | null> {
|
||||
const credentials = await Keychain.getGenericPassword({
|
||||
service: PROFILE_SERVICE,
|
||||
});
|
||||
|
||||
if (!credentials) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return JSON.parse(credentials.password) as AuthTokens;
|
||||
try {
|
||||
return JSON.parse(credentials.password) as LastProfile;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
},
|
||||
|
||||
async getAccessToken() {
|
||||
const session = await this.getSession();
|
||||
return session?.accessToken ?? null;
|
||||
async clearLastProfile() {
|
||||
await Keychain.resetGenericPassword({ service: PROFILE_SERVICE });
|
||||
},
|
||||
|
||||
async clearSession() {
|
||||
await Keychain.resetGenericPassword({
|
||||
service: SESSION_SERVICE,
|
||||
/**
|
||||
* Stored under the platform's biometric access control, so reading it back
|
||||
* raises the fingerprint / Face ID prompt rather than the app faking one.
|
||||
*/
|
||||
async saveBiometricMpin(mpin: string) {
|
||||
await Keychain.setGenericPassword('mpin', mpin, {
|
||||
service: BIOMETRIC_MPIN_SERVICE,
|
||||
accessible: Keychain.ACCESSIBLE.WHEN_UNLOCKED_THIS_DEVICE_ONLY,
|
||||
accessControl: Keychain.ACCESS_CONTROL.BIOMETRY_CURRENT_SET_OR_DEVICE_PASSCODE,
|
||||
securityLevel: Keychain.SECURITY_LEVEL.SECURE_SOFTWARE,
|
||||
});
|
||||
},
|
||||
|
||||
async getBiometricMpin(promptTitle: string): Promise<string | null> {
|
||||
try {
|
||||
const credentials = await Keychain.getGenericPassword({
|
||||
service: BIOMETRIC_MPIN_SERVICE,
|
||||
authenticationPrompt: { title: promptTitle },
|
||||
});
|
||||
|
||||
return credentials ? credentials.password : null;
|
||||
} catch {
|
||||
// Cancelled prompt, or no enrolled biometric any more.
|
||||
return null;
|
||||
}
|
||||
},
|
||||
|
||||
async hasBiometricMpin() {
|
||||
try {
|
||||
const services = await Keychain.getAllGenericPasswordServices();
|
||||
return services.includes(BIOMETRIC_MPIN_SERVICE);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
|
||||
async clearBiometricMpin() {
|
||||
await Keychain.resetGenericPassword({ service: BIOMETRIC_MPIN_SERVICE });
|
||||
},
|
||||
|
||||
async setBiometricEnabled(value: boolean) {
|
||||
await AsyncStorage.setItem(BIOMETRIC_KEY, JSON.stringify(value));
|
||||
|
||||
if (!value) {
|
||||
await this.clearBiometricMpin();
|
||||
}
|
||||
},
|
||||
|
||||
async getBiometricEnabled() {
|
||||
|
||||
Reference in New Issue
Block a user