@myazahq/kyc-sdk-react-native 2.0.0 → 2.0.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +305 -51
- package/package.json +1 -1
- package/src/__tests__/resolveUrl.test.ts +3 -3
- package/src/components/CameraPermissionView.tsx +39 -3
- package/src/config/idTypes.ts +2 -0
- package/src/screens/DocumentCaptureStep.tsx +34 -13
- package/src/screens/IdInputStep.tsx +6 -4
- package/src/screens/LivenessStep.tsx +26 -20
- package/src/services/deviceMetadata.ts +1 -1
- package/src/services/resolveUrl.ts +2 -1
- package/src/services/validators.ts +3 -0
- package/src/types/config.ts +6 -1
package/README.md
CHANGED
|
@@ -1,39 +1,67 @@
|
|
|
1
1
|
# @myazahq/kyc-sdk-react-native
|
|
2
2
|
|
|
3
3
|
Myaza KYC SDK for **React Native (Expo)** — ID verification, document capture, and
|
|
4
|
-
active liveness
|
|
4
|
+
active **on-device** liveness. Mirrors the [web](https://www.npmjs.com/package/@myazahq/kyc-sdk-react)
|
|
5
5
|
and Flutter SDKs feature-for-feature and calls the same Myaza KYC API server.
|
|
6
6
|
|
|
7
7
|
The SDK is a **thin UI layer**: it captures the user's data (ID number, document
|
|
8
8
|
photos, a live selfie), uploads the media, and submits a verification request.
|
|
9
9
|
All verification (OCR, facial comparison, gov-DB checks) happens server-side and
|
|
10
|
-
is delivered asynchronously via webhook — the SDK is fire-and-forget
|
|
10
|
+
is delivered asynchronously via webhook — the SDK is **fire-and-forget**.
|
|
11
|
+
|
|
12
|
+
## Requirements
|
|
13
|
+
|
|
14
|
+
This library ships **native code** (an Apple Vision + Google ML Kit face detector,
|
|
15
|
+
built as a [react-native-vision-camera](https://react-native-vision-camera.com) v5 /
|
|
16
|
+
Nitro module), so it needs a **custom native build** and **does not run in Expo Go**.
|
|
17
|
+
|
|
18
|
+
| Requirement | Minimum |
|
|
19
|
+
| --- | --- |
|
|
20
|
+
| **iOS** deployment target | **15.1** |
|
|
21
|
+
| **Android** `minSdkVersion` | **24** (Android 7.0) · `compileSdk` 34 · NDK 27.1 |
|
|
22
|
+
| **Expo SDK** | **56** (React 19, React Native 0.85) |
|
|
23
|
+
| **React Native** | **0.83+**, with the **New Architecture enabled** (VisionCamera v5 / Nitro requires it; Expo SDK 56 enables it by default) |
|
|
24
|
+
| **Build toolchain** | Xcode + CocoaPods (iOS) · **JDK 17** for Android Gradle builds |
|
|
25
|
+
| **Runtime** | A **dev client** or bare build — **not Expo Go** |
|
|
26
|
+
|
|
27
|
+
**Peer dependencies** to install in your app:
|
|
28
|
+
|
|
29
|
+
| Package | Range | Purpose |
|
|
30
|
+
| --- | --- | --- |
|
|
31
|
+
| `expo` | `>=56` | Expo module runtime (the SDK uses several `expo-*` modules) |
|
|
32
|
+
| `react` / `react-native` | `>=19` / `>=0.83` | — |
|
|
33
|
+
| `react-native-vision-camera` | `^5` | Camera preview + capture |
|
|
34
|
+
| `react-native-vision-camera-worklets` | `>=5` | Frame-processor worklet runtime |
|
|
35
|
+
| `react-native-worklets` | `>=0.8` | Worklet `runOnJS` bridge for liveness |
|
|
36
|
+
| `react-native-nitro-modules` | `>=0.35` | Native module runtime for the face detector |
|
|
37
|
+
| `react-native-nitro-image` | `>=0.15` | Frame → image interop used by the detector |
|
|
38
|
+
| `react-native-safe-area-context` | `>=4` | Safe-area insets for the modal |
|
|
39
|
+
| `react-native-svg` | `>=15` | Liveness/overlay graphics |
|
|
11
40
|
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
This library ships **native code** (an Apple Vision + Google ML Kit face-detector
|
|
15
|
-
VisionCamera frame-processor plugin), so it needs a custom native build. It does
|
|
16
|
-
**not** run in Expo Go. Pick your setup below.
|
|
41
|
+
> Voice guidance is **text-to-speech output only** — the SDK never records audio,
|
|
42
|
+
> so **no microphone permission** is requested or required.
|
|
17
43
|
|
|
18
|
-
|
|
19
|
-
`react-native-worklets-core`, `react-native-svg`, and `expo` (the SDK uses several
|
|
20
|
-
`expo-*` modules — see *Bare React Native* if you're not on a managed Expo app).
|
|
44
|
+
## Installation
|
|
21
45
|
|
|
22
46
|
### Expo app (managed / prebuild — recommended)
|
|
23
47
|
|
|
24
48
|
```sh
|
|
25
49
|
npx expo install @myazahq/kyc-sdk-react-native \
|
|
26
|
-
react-native-vision-camera react-native-worklets
|
|
50
|
+
react-native-vision-camera react-native-vision-camera-worklets \
|
|
51
|
+
react-native-worklets react-native-nitro-modules react-native-nitro-image \
|
|
52
|
+
react-native-safe-area-context react-native-svg
|
|
27
53
|
```
|
|
28
54
|
|
|
29
|
-
Add the config plugins to `app.json
|
|
55
|
+
Add the config plugins to `app.json`. The SDK's plugin adds the iOS camera usage
|
|
30
56
|
string + Android `CAMERA`/`INTERNET` permissions; the VisionCamera plugin wires up
|
|
31
|
-
the camera + frame processors
|
|
57
|
+
the camera + frame processors. Make sure the **New Architecture** is on (it is by
|
|
58
|
+
default on Expo SDK 56):
|
|
32
59
|
|
|
33
60
|
```jsonc
|
|
34
61
|
// app.json
|
|
35
62
|
{
|
|
36
63
|
"expo": {
|
|
64
|
+
"newArchEnabled": true,
|
|
37
65
|
"plugins": [
|
|
38
66
|
["react-native-vision-camera", { "enableMicrophonePermission": false }],
|
|
39
67
|
"@myazahq/kyc-sdk-react-native"
|
|
@@ -46,7 +74,8 @@ Then build a dev client (regenerates the native projects):
|
|
|
46
74
|
|
|
47
75
|
```sh
|
|
48
76
|
npx expo prebuild
|
|
49
|
-
npx expo run:ios
|
|
77
|
+
npx expo run:ios # iOS
|
|
78
|
+
JAVA_HOME=/path/to/jdk-17 npx expo run:android # Android — needs JDK 17
|
|
50
79
|
```
|
|
51
80
|
|
|
52
81
|
> The SDK plugin accepts an optional custom camera prompt:
|
|
@@ -63,7 +92,9 @@ npx install-expo-modules@latest
|
|
|
63
92
|
|
|
64
93
|
# 2. Install the SDK + peers:
|
|
65
94
|
npm install @myazahq/kyc-sdk-react-native \
|
|
66
|
-
react-native-vision-camera react-native-
|
|
95
|
+
react-native-vision-camera react-native-vision-camera-worklets \
|
|
96
|
+
react-native-worklets react-native-nitro-modules react-native-nitro-image \
|
|
97
|
+
react-native-safe-area-context react-native-svg \
|
|
67
98
|
expo expo-image-manipulator expo-image-picker expo-speech expo-font \
|
|
68
99
|
expo-glass-effect expo-application expo-crypto expo-device expo-localization
|
|
69
100
|
|
|
@@ -84,57 +115,284 @@ prebuild):
|
|
|
84
115
|
<uses-permission android:name="android.permission.CAMERA" />
|
|
85
116
|
<uses-permission android:name="android.permission.INTERNET" />
|
|
86
117
|
```
|
|
87
|
-
-
|
|
88
|
-
|
|
118
|
+
- Ensure the **New Architecture** is enabled (`newArchEnabled=true` in
|
|
119
|
+
`android/gradle.properties`; `RCT_NEW_ARCH_ENABLED=1` for the iOS Podfile install),
|
|
120
|
+
and enable VisionCamera frame processors / the worklets babel plugin — see the
|
|
89
121
|
[VisionCamera setup docs](https://react-native-vision-camera.com/docs/guides).
|
|
90
122
|
|
|
91
|
-
The face-detector native module (
|
|
92
|
-
|
|
93
|
-
autolinking — no manual linking required.
|
|
94
|
-
|
|
95
|
-
> Voice guidance is **text-to-speech output only** — the SDK never records audio,
|
|
96
|
-
> so **no microphone permission** is requested or required
|
|
97
|
-
> (`enableMicrophonePermission: false`).
|
|
123
|
+
The face-detector native module (Nitro HybridObject + Android lib loader) is
|
|
124
|
+
autolinked via React Native / Expo autolinking — no manual linking required.
|
|
98
125
|
|
|
99
126
|
## Usage
|
|
100
127
|
|
|
128
|
+
`<MyazaKYC />` renders a "Verify Identity" trigger plus the full modal flow. Pass
|
|
129
|
+
`children` (a string) to relabel it. For a fully custom trigger, use the
|
|
130
|
+
[`useMyazaKYC()` hook](#trigger-component--hook).
|
|
131
|
+
|
|
101
132
|
```tsx
|
|
102
133
|
import { MyazaKYC } from '@myazahq/kyc-sdk-react-native';
|
|
103
134
|
|
|
104
|
-
export default function
|
|
135
|
+
export default function VerifyScreen() {
|
|
105
136
|
return (
|
|
106
137
|
<MyazaKYC
|
|
107
|
-
apiKey="
|
|
138
|
+
apiKey="pk_live_xxx" // prefix selects the env: pk_test_ → sandbox
|
|
108
139
|
country="NG"
|
|
109
|
-
idTypes={['bvn', 'nin', '
|
|
140
|
+
idTypes={['passport', 'drivers-license', 'bvn', 'nin', 'pvc']}
|
|
141
|
+
userData={{ firstName: 'Jane', lastName: 'Doe' }}
|
|
142
|
+
enableSelfie
|
|
110
143
|
enableDocumentCapture
|
|
111
144
|
enableLiveness
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
145
|
+
showThemeToggle
|
|
146
|
+
appearance={{
|
|
147
|
+
primaryColor: '#5645F5',
|
|
148
|
+
companyName: 'Myaza',
|
|
149
|
+
logo: 'default',
|
|
150
|
+
theme: 'dark',
|
|
151
|
+
}}
|
|
152
|
+
consent={{
|
|
153
|
+
title: 'Welcome, {firstName}',
|
|
154
|
+
description: "A quick check to confirm it's really you.",
|
|
155
|
+
}}
|
|
156
|
+
success={{
|
|
157
|
+
title: "You're all set, {firstName}!",
|
|
158
|
+
description: "We'll email you once your verification is reviewed.",
|
|
159
|
+
}}
|
|
160
|
+
metadata={{ userId: 'test_user_123' }}
|
|
161
|
+
onStart={() => console.log('KYC started')}
|
|
162
|
+
onStepChange={(step) => console.log('Step:', step)}
|
|
163
|
+
onSubmit={(submission) => {
|
|
164
|
+
// Fires as soon as the server accepts the request.
|
|
165
|
+
// submission.status is always 'pending' — the result arrives later via
|
|
166
|
+
// webhook to your backend (or poll GET /api/kyc/status/:id).
|
|
167
|
+
console.log('Submitted!', submission.verificationId);
|
|
168
|
+
}}
|
|
169
|
+
onClose={() => console.log('Modal closed')}
|
|
170
|
+
onError={(err) => console.warn('SDK error:', err.code, err.message)}
|
|
171
|
+
>
|
|
172
|
+
Verify my identity
|
|
173
|
+
</MyazaKYC>
|
|
116
174
|
);
|
|
117
175
|
}
|
|
118
176
|
```
|
|
119
177
|
|
|
120
|
-
|
|
178
|
+
## Props
|
|
179
|
+
|
|
180
|
+
| Prop | Type | Default | Description |
|
|
181
|
+
| ----------------------- | ----------------------------------------- | ------------------- | -------------------------------------------------------------------------------------------------------------------- |
|
|
182
|
+
| `apiKey` | `string` | — | **Required.** Sent as `Authorization: Bearer`. The **environment is derived from the key prefix** (`pk_test_…` → sandbox, `pk_live_…` → production); an unrecognized prefix throws. |
|
|
183
|
+
| `country` | `'NG' \| 'GH' \| 'KE' \| 'ZA' \| 'CI'` | — | **Required.** Country whose ID types are offered. |
|
|
184
|
+
| `idTypes` | `IdType[]` | all allowed for org | Subset of ID types to offer; must be valid for `country`. |
|
|
185
|
+
| `userData` | `{ firstName?, lastName?, dateOfBirth? }` | — | Pre-fills the user's details. |
|
|
186
|
+
| `enableSelfie` | `boolean` | `true` | Capture a selfie during liveness. |
|
|
187
|
+
| `enableDocumentCapture` | `boolean` | `true` | Enable the document-scan step for document IDs. |
|
|
188
|
+
| `allowDocumentUpload` | `boolean` | `true` | Allow picking a document photo from the gallery as an alternative to the camera. `false` hides every "upload instead" affordance (it's still offered on the camera-permission-denied screen as an escape hatch). |
|
|
189
|
+
| `enableLiveness` | `boolean` | `true` | Run the liveness challenge step. The server can still disable it per ID type. |
|
|
190
|
+
| `voiceGuidance` | `boolean \| { enabled?, language? }` | `true` | Spoken liveness instructions (accessibility, TTS **output** — no microphone). `false` mutes it; pass `{ language: 'fr-FR' }` to set the voice. See [Robustness & error handling](#robustness--error-handling). |
|
|
191
|
+
| `showThemeToggle` | `boolean` | `true` | Show a light/dark toggle inside the modal header. When `false`, the flow stays on `appearance.theme`. |
|
|
192
|
+
| `disableClose` | `boolean` | `false` | Hide the close (X) and block **all** user dismissal (X, Android back, iOS swipe-down). The flow can then only be closed programmatically via `useMyazaKYC().close()`. |
|
|
193
|
+
| `appearance` | `KYCAppearance` | brand defaults | Brand & theme the modal — colors, logo, light/dark. See [Appearance & theming](#appearance--theming). |
|
|
194
|
+
| `consent` | `KYCConsentContent` | built-in copy | Override the consent/welcome screen `title` and `description`. See [Consent screen copy](#consent-screen-copy). |
|
|
195
|
+
| `success` | `KYCSuccessContent` | built-in copy | Override the success/submitted screen `title` and `description`. See [Success screen copy](#success-screen-copy). |
|
|
196
|
+
| `metadata` | `Record<string, string>` | — | Forwarded with every verify request. |
|
|
197
|
+
| `onStart` | `() => void` | — | Called when the flow opens. |
|
|
198
|
+
| `onStepChange` | `(step: KYCStep) => void` | — | Called on each step transition. |
|
|
199
|
+
| `onSubmit` | `(submission: KYCSubmission) => void` | — | Called when the server accepts the verification. `status` is always `'pending'`. |
|
|
200
|
+
| `onError` | `(error: KYCError) => void` | — | Called for **technical** errors only. Receives a typed [`KYCError`](#robustness--error-handling). Verification outcomes never come through here. |
|
|
201
|
+
| `onClose` | `() => void` | — | Called when the user closes the flow. |
|
|
202
|
+
| `children` | `string` | `Verify Identity` | Trigger label. Defaults to `Verify with {companyName}` when `companyName` is set, else `Verify Identity`. |
|
|
203
|
+
| `disabled` | `boolean` | `false` | Disable the trigger. |
|
|
204
|
+
|
|
205
|
+
## Environment
|
|
206
|
+
|
|
207
|
+
There is **no `environment` prop** — the SDK derives the environment (and the
|
|
208
|
+
base URL) from the API key prefix, the single source of truth:
|
|
209
|
+
|
|
210
|
+
| Prefix | Environment | Base URL |
|
|
211
|
+
| ---------- | ----------- | --------------------------------- |
|
|
212
|
+
| `pk_test_` | sandbox | `https://identity.myaza.app` |
|
|
213
|
+
| `pk_live_` | production | `https://identity.myaza.app` |
|
|
214
|
+
|
|
215
|
+
An unrecognized or malformed key throws at setup (it never silently defaults).
|
|
216
|
+
|
|
217
|
+
## Trigger component & hook
|
|
218
|
+
|
|
219
|
+
`<MyazaKYC />` renders a styled trigger that opens the modal. Beyond the config
|
|
220
|
+
props it accepts `children` (a string label) and `disabled`:
|
|
221
|
+
|
|
222
|
+
```tsx
|
|
223
|
+
<MyazaKYC {...config} disabled={!ready}>
|
|
224
|
+
Start verification
|
|
225
|
+
</MyazaKYC>
|
|
226
|
+
```
|
|
227
|
+
|
|
228
|
+
For a **fully custom** trigger (your own `Pressable`, an icon, a list row, or to
|
|
229
|
+
open the flow programmatically), use the `useMyazaKYC()` hook:
|
|
121
230
|
|
|
122
231
|
```tsx
|
|
232
|
+
import { Pressable, Text } from 'react-native';
|
|
123
233
|
import { useMyazaKYC } from '@myazahq/kyc-sdk-react-native';
|
|
124
234
|
|
|
125
|
-
|
|
235
|
+
function CustomTrigger() {
|
|
236
|
+
const { open, close, isOpen, currentStep } = useMyazaKYC({
|
|
237
|
+
apiKey: 'pk_live_xxx',
|
|
238
|
+
country: 'NG',
|
|
239
|
+
onSubmit: (s) => console.log('submitted', s.verificationId),
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
return (
|
|
243
|
+
<Pressable onPress={open}>
|
|
244
|
+
<Text>Verify your identity</Text>
|
|
245
|
+
</Pressable>
|
|
246
|
+
);
|
|
247
|
+
}
|
|
126
248
|
```
|
|
127
249
|
|
|
128
|
-
|
|
250
|
+
`useMyazaKYC(config)` returns `{ open, close, isOpen, currentStep }`. `close()` is
|
|
251
|
+
the only way to dismiss the flow when `disableClose` is set.
|
|
129
252
|
|
|
130
|
-
|
|
131
|
-
`environment` prop:
|
|
253
|
+
## Appearance & theming
|
|
132
254
|
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
|
255
|
+
Pass an `appearance` object to brand the flow. Because the UI is token-driven,
|
|
256
|
+
setting one color cascades to all of its shades (hover/selected/focus states
|
|
257
|
+
included). Unset colors keep the built-in light/dark defaults.
|
|
258
|
+
|
|
259
|
+
| Field | Type | Description |
|
|
260
|
+
| ------------------ | ------------------- | -------------------------------------------------------------------------------- |
|
|
261
|
+
| `primaryColor` | `string` | Brand color — buttons, selected states, progress, the shield hero. Defaults to `#5645F5`. |
|
|
262
|
+
| `primaryTextColor` | `string` | Text/icons rendered on top of `primaryColor` (e.g. button labels). |
|
|
263
|
+
| `accentColor` | `string` | Subtle hover/active surfaces. |
|
|
264
|
+
| `backgroundColor` | `string` | Modal background. |
|
|
265
|
+
| `surfaceColor` | `string` | Cards & panels. |
|
|
266
|
+
| `borderColor` | `string` | Borders and input outlines. |
|
|
267
|
+
| `textColor` | `string` | Primary text color. |
|
|
268
|
+
| `companyName` | `string` | Used on the verify trigger ("Verify with …") and the persistent header. |
|
|
269
|
+
| `logo` | `string` | Image URL, or `'default'` to use your org's logo. See below. |
|
|
270
|
+
| `theme` | `'light' \| 'dark'` | Initial mode (defaults to `'light'`). With `showThemeToggle`, users can flip it. |
|
|
271
|
+
|
|
272
|
+
### Logo
|
|
273
|
+
|
|
274
|
+
The org logo renders as a small circular avatar in the modal header (top-left),
|
|
275
|
+
persistent on every step, alongside `companyName`.
|
|
276
|
+
|
|
277
|
+
- `logo: 'https://…/logo.png'` — uses that image directly.
|
|
278
|
+
- `logo: 'default'` — pulls your organization's logo configured in the **Myaza dashboard**
|
|
279
|
+
(returned by the server on mount). If your org has no logo set, or the image fails
|
|
280
|
+
to load, the avatar is hidden.
|
|
281
|
+
- omitted — no header logo.
|
|
282
|
+
|
|
283
|
+
```tsx
|
|
284
|
+
appearance={{
|
|
285
|
+
primaryColor: '#0F7B6C',
|
|
286
|
+
primaryTextColor: '#FFFFFF',
|
|
287
|
+
surfaceColor: '#F4F7F6',
|
|
288
|
+
borderColor: '#D7E3E0',
|
|
289
|
+
logo: 'default',
|
|
290
|
+
theme: 'light',
|
|
291
|
+
}}
|
|
292
|
+
```
|
|
293
|
+
|
|
294
|
+
## Consent screen copy
|
|
295
|
+
|
|
296
|
+
The welcome/consent step shows a heading and a short description. Override either
|
|
297
|
+
through the `consent` prop:
|
|
298
|
+
|
|
299
|
+
| Field | Type | Description |
|
|
300
|
+
| ------------- | -------- | ----------------------------------------------------------------------------------------------------- |
|
|
301
|
+
| `title` | `string` | Heading. Defaults to `Welcome, {firstName}` when a first name is known, else `Identity Verification`. |
|
|
302
|
+
| `description` | `string` | Sub-text under the heading. Defaults to the built-in regulatory copy. |
|
|
303
|
+
|
|
304
|
+
Both fields support `{firstName}` and `{lastName}` tokens, replaced with the values
|
|
305
|
+
from `userData` (empty string when absent), so a custom title can still greet the
|
|
306
|
+
user by name.
|
|
307
|
+
|
|
308
|
+
```tsx
|
|
309
|
+
consent={{
|
|
310
|
+
title: 'Welcome, {firstName}',
|
|
311
|
+
description: "We just need to confirm it's really you. This takes about a minute.",
|
|
312
|
+
}}
|
|
313
|
+
```
|
|
314
|
+
|
|
315
|
+
## Success screen copy
|
|
316
|
+
|
|
317
|
+
After the user submits, the final screen shows a confirmation heading and
|
|
318
|
+
description. Override either through the `success` prop:
|
|
319
|
+
|
|
320
|
+
| Field | Type | Description |
|
|
321
|
+
| ------------- | -------- | -------------------------------------------------------------------------- |
|
|
322
|
+
| `title` | `string` | Heading. Defaults to `Verification Submitted!`. |
|
|
323
|
+
| `description` | `string` | Sub-text under the heading. Defaults to the built-in "submitted for review" copy. |
|
|
324
|
+
|
|
325
|
+
Both fields support the same `{firstName}` / `{lastName}` tokens as `consent`.
|
|
326
|
+
|
|
327
|
+
```tsx
|
|
328
|
+
success={{
|
|
329
|
+
title: "You're all set, {firstName}!",
|
|
330
|
+
description: "We'll email you once your verification is reviewed.",
|
|
331
|
+
}}
|
|
332
|
+
```
|
|
333
|
+
|
|
334
|
+
## Robustness & error handling
|
|
335
|
+
|
|
336
|
+
The SDK is resilient to flaky networks, denied permissions, and poor capture
|
|
337
|
+
conditions, and reports technical failures through `onError` with a typed code.
|
|
338
|
+
|
|
339
|
+
### Typed errors (`onError`)
|
|
340
|
+
|
|
341
|
+
`onError` receives a `KYCError` with a typed `code`, a human-readable `message`,
|
|
342
|
+
and optional `details`. The codes are **identical to the web and Flutter SDKs**:
|
|
343
|
+
|
|
344
|
+
```tsx
|
|
345
|
+
import { MyazaKYC, type KYCError } from '@myazahq/kyc-sdk-react-native';
|
|
346
|
+
|
|
347
|
+
<MyazaKYC
|
|
348
|
+
{...config}
|
|
349
|
+
onError={(error: KYCError) => {
|
|
350
|
+
switch (error.code) {
|
|
351
|
+
case 'camera_permission_denied': /* ask the user to allow the camera */ break;
|
|
352
|
+
case 'insufficient_credits': /* error.details = { required, balance, currency } */ break;
|
|
353
|
+
case 'network_error':
|
|
354
|
+
case 'upload_failed': /* shown only after automatic retries */ break;
|
|
355
|
+
}
|
|
356
|
+
}}
|
|
357
|
+
/>
|
|
358
|
+
```
|
|
359
|
+
|
|
360
|
+
| `code` | When it fires |
|
|
361
|
+
| -------------------------- | ------------------------------------------------------------------- |
|
|
362
|
+
| `network_error` | Connection failure / timeout, **after retries are exhausted**. |
|
|
363
|
+
| `invalid_api_key` | Server returned `401`. |
|
|
364
|
+
| `insufficient_credits` | Server returned `402`. `details = { required, balance, currency }`. |
|
|
365
|
+
| `upload_failed` | A media upload failed, **after retries are exhausted**. |
|
|
366
|
+
| `camera_permission_denied` | The user denied (or the OS blocks) camera access. |
|
|
367
|
+
| `feature_disabled` | Server returned `403` (ID type / feature not enabled for the org). |
|
|
368
|
+
| `unknown` | Anything else. |
|
|
369
|
+
|
|
370
|
+
> Verification *outcomes* (identity not found, document mismatch, …) never come
|
|
371
|
+
> through `onError` — they arrive asynchronously via webhook / `GET /api/kyc/status/:id`.
|
|
372
|
+
|
|
373
|
+
### Network resilience
|
|
374
|
+
|
|
375
|
+
Media uploads and the verify submission are wrapped in exponential-backoff retry
|
|
376
|
+
(with jitter), retrying only *transient* failures (network / timeout / `5xx`);
|
|
377
|
+
terminal `4xx` surface immediately. The UI shows a top toast while retrying, and
|
|
378
|
+
`onError` fires **only after retries are exhausted** (`upload_failed` for uploads,
|
|
379
|
+
`network_error` for connectivity).
|
|
380
|
+
|
|
381
|
+
### Camera permission
|
|
382
|
+
|
|
383
|
+
If the user denies camera access, the SDK shows a clear "camera access needed"
|
|
384
|
+
screen (with an **Open Settings** action) instead of hanging, and reports
|
|
385
|
+
`camera_permission_denied` to `onError`. Document capture additionally offers a
|
|
386
|
+
gallery-upload fallback unless `allowDocumentUpload` is `false`.
|
|
387
|
+
|
|
388
|
+
### Liveness quality guards
|
|
389
|
+
|
|
390
|
+
- **Multiple faces** — if more than one face is in frame, the challenge pauses
|
|
391
|
+
("Make sure only your face is visible") and resumes automatically when only one
|
|
392
|
+
face remains. This guards capture quality and a class of spoofing.
|
|
393
|
+
- **Lighting** — too-dark *and* too-bright (glare) conditions are detected live
|
|
394
|
+
during liveness; the SDK shows guidance ("Move to a brighter area" / "Too bright
|
|
395
|
+
— reduce glare") and blocks auto-capture until lighting is acceptable.
|
|
138
396
|
|
|
139
397
|
## Liveness
|
|
140
398
|
|
|
@@ -147,18 +405,14 @@ per session). Face detection runs **on-device, natively**:
|
|
|
147
405
|
simulators).
|
|
148
406
|
|
|
149
407
|
Both run as a [react-native-vision-camera](https://github.com/mrousavy/react-native-vision-camera)
|
|
150
|
-
frame
|
|
151
|
-
is **auto-captured** once challenges pass (anti-spoofing
|
|
152
|
-
video is recorded and uploaded best-effort.
|
|
408
|
+
v5 Nitro frame processor: the camera frame never crosses the JS bridge. The selfie
|
|
409
|
+
is **auto-captured** once challenges pass (anti-spoofing — never user-triggered),
|
|
410
|
+
and a short liveness video is recorded and uploaded best-effort.
|
|
153
411
|
|
|
154
|
-
##
|
|
412
|
+
## Documentation
|
|
155
413
|
|
|
156
|
-
|
|
157
|
-
(`status: 'pending'`). `onError(KYCError)` fires for **technical** errors only
|
|
158
|
-
(`network_error`, `invalid_api_key`, `insufficient_credits`, `upload_failed`,
|
|
159
|
-
`camera_permission_denied`, `feature_disabled`). Verification *outcomes* arrive
|
|
160
|
-
asynchronously via webhook / `GET /api/kyc/status/:id` — never through `onError`.
|
|
414
|
+
Full documentation, configuration options, and webhook setup: **[identity.myaza.co/documentation/sdks](https://identity.myaza.co/documentation/sdks)**.
|
|
161
415
|
|
|
162
416
|
## License
|
|
163
417
|
|
|
164
|
-
|
|
418
|
+
MIT © Flitstack Technologies Inc.
|
package/package.json
CHANGED
|
@@ -19,8 +19,8 @@ describe('detectEnvironment', () => {
|
|
|
19
19
|
|
|
20
20
|
describe('resolveBaseUrl', () => {
|
|
21
21
|
it('uses the hardcoded sandbox/production URLs and ignores devUrl', () => {
|
|
22
|
-
expect(resolveBaseUrl('pk_test_abc')).toBe('https://
|
|
23
|
-
expect(resolveBaseUrl('pk_test_abc', 'http://example.test')).toBe('https://
|
|
22
|
+
expect(resolveBaseUrl('pk_test_abc')).toBe('https://identity.myaza.app');
|
|
23
|
+
expect(resolveBaseUrl('pk_test_abc', 'http://example.test')).toBe('https://identity.myaza.app');
|
|
24
24
|
expect(resolveBaseUrl('pk_live_abc')).toBe('https://identity.myaza.app');
|
|
25
25
|
});
|
|
26
26
|
|
|
@@ -53,7 +53,7 @@ describe('normalizeDevAssetUrl', () => {
|
|
|
53
53
|
'https://cdn.myaza.app/logo.png',
|
|
54
54
|
);
|
|
55
55
|
// https base (sandbox/prod) → never rewrites, even a localhost asset.
|
|
56
|
-
expect(normalizeDevAssetUrl('http://localhost:3001/logo.png', 'https://
|
|
56
|
+
expect(normalizeDevAssetUrl('http://localhost:3001/logo.png', 'https://identity.myaza.app')).toBe(
|
|
57
57
|
'http://localhost:3001/logo.png',
|
|
58
58
|
);
|
|
59
59
|
});
|
|
@@ -21,11 +21,14 @@ interface ScaffoldProps {
|
|
|
21
21
|
message: string;
|
|
22
22
|
/** When true (default), vertically centers; false flows top-down below a heading. */
|
|
23
23
|
centered?: boolean;
|
|
24
|
+
/** Accent color for the icon + its circle (defaults to the error tint). */
|
|
25
|
+
tint?: string;
|
|
24
26
|
children: React.ReactNode; // action buttons
|
|
25
27
|
}
|
|
26
28
|
|
|
27
|
-
function CameraErrorScaffold({ icon, title, message, centered = true, children }: ScaffoldProps): React.ReactElement {
|
|
29
|
+
function CameraErrorScaffold({ icon, title, message, centered = true, tint, children }: ScaffoldProps): React.ReactElement {
|
|
28
30
|
const { colors } = useTheme();
|
|
31
|
+
const accent = tint ?? colors.error;
|
|
29
32
|
return (
|
|
30
33
|
<View
|
|
31
34
|
style={
|
|
@@ -39,12 +42,12 @@ function CameraErrorScaffold({ icon, title, message, centered = true, children }
|
|
|
39
42
|
width: 80,
|
|
40
43
|
height: 80,
|
|
41
44
|
borderRadius: radius.full,
|
|
42
|
-
backgroundColor: `${
|
|
45
|
+
backgroundColor: `${accent}1A`,
|
|
43
46
|
alignItems: 'center',
|
|
44
47
|
justifyContent: 'center',
|
|
45
48
|
}}
|
|
46
49
|
>
|
|
47
|
-
<Icon name={icon} size={38} color={
|
|
50
|
+
<Icon name={icon} size={38} color={accent} />
|
|
48
51
|
</View>
|
|
49
52
|
<View style={{ height: spacing.lg }} />
|
|
50
53
|
<MyazaText variant="heading2" style={{ textAlign: 'center' }}>
|
|
@@ -96,6 +99,39 @@ export function CameraPermissionView({ message, onRetry, onUpload, centered = tr
|
|
|
96
99
|
);
|
|
97
100
|
}
|
|
98
101
|
|
|
102
|
+
// ── Permission primer (before the OS prompt) ─────────────────────────────────
|
|
103
|
+
|
|
104
|
+
const DEFAULT_PRIMING_MESSAGE =
|
|
105
|
+
'When prompted, allow camera access to continue your verification.';
|
|
106
|
+
|
|
107
|
+
export interface CameraPermissionPrimingViewProps {
|
|
108
|
+
message?: string;
|
|
109
|
+
/** Fires when the user taps "Grant access" — triggers the real OS prompt. */
|
|
110
|
+
onGrant: () => void;
|
|
111
|
+
centered?: boolean;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* "Allow camera access" priming screen, shown right before the native OS camera
|
|
116
|
+
* permission prompt (mirrors Stripe Identity). The actual `requestPermission()`
|
|
117
|
+
* only fires once the user taps "Grant access". Distinct from
|
|
118
|
+
* `CameraPermissionView`, which is shown *after* a denial.
|
|
119
|
+
*/
|
|
120
|
+
export function CameraPermissionPrimingView({ message, onGrant, centered = true }: CameraPermissionPrimingViewProps): React.ReactElement {
|
|
121
|
+
const { colors } = useTheme();
|
|
122
|
+
return (
|
|
123
|
+
<CameraErrorScaffold
|
|
124
|
+
icon="camera"
|
|
125
|
+
title="Allow camera access"
|
|
126
|
+
message={message ?? DEFAULT_PRIMING_MESSAGE}
|
|
127
|
+
centered={centered}
|
|
128
|
+
tint={colors.primary}
|
|
129
|
+
>
|
|
130
|
+
<MyazaButton label="Grant access" onPress={onGrant} />
|
|
131
|
+
</CameraErrorScaffold>
|
|
132
|
+
);
|
|
133
|
+
}
|
|
134
|
+
|
|
99
135
|
// ── Camera unavailable (no device / init failure) ────────────────────────────
|
|
100
136
|
|
|
101
137
|
const DEFAULT_UNAVAILABLE_MESSAGE =
|
package/src/config/idTypes.ts
CHANGED
|
@@ -8,6 +8,8 @@ import type { IdTypesByCountry } from '../types/config';
|
|
|
8
8
|
export const ID_TYPES: IdTypesByCountry = {
|
|
9
9
|
NG: [
|
|
10
10
|
{ key: 'bvn', label: 'BVN', digits: 11, requiresDocumentCapture: false },
|
|
11
|
+
{ key: 'bvn-premium', label: 'BVN Premium', digits: 11, requiresDocumentCapture: false },
|
|
12
|
+
{ key: 'tax-id', label: 'Tax ID', inputLabel: 'NIN', digits: 11, requiresDocumentCapture: false },
|
|
11
13
|
{ key: 'nin', label: 'NIN', digits: 11, requiresDocumentCapture: false },
|
|
12
14
|
{ key: 'vnin', label: 'Virtual NIN (vNIN)', digits: 16, requiresDocumentCapture: false },
|
|
13
15
|
{ key: 'passport', label: 'International Passport', pattern: /^[A-Z]\d{8}$/, requiresDocumentCapture: true, scanSides: 'front_only' },
|
|
@@ -17,7 +17,7 @@ import { MyazaButton } from '../components/MyazaButton';
|
|
|
17
17
|
import { useToast } from '../components/toast';
|
|
18
18
|
import { MyazaPulseLoader } from '../components/MyazaPulseLoader';
|
|
19
19
|
import { CameraViewfinder } from '../components/CameraViewfinder';
|
|
20
|
-
import { CameraPermissionView, CameraUnavailableView } from '../components/CameraPermissionView';
|
|
20
|
+
import { CameraPermissionView, CameraUnavailableView, CameraPermissionPrimingView } from '../components/CameraPermissionView';
|
|
21
21
|
import { DocumentCropper } from '../components/DocumentCropper';
|
|
22
22
|
import { Icon } from '../components/Icon';
|
|
23
23
|
|
|
@@ -100,9 +100,13 @@ export function DocumentCaptureStep(): React.ReactElement {
|
|
|
100
100
|
// `perm` is derived from the ASYNC requestPermission result, not synchronously
|
|
101
101
|
// from `hasPermission` — otherwise the brief window while the OS prompt is open
|
|
102
102
|
// (hasPermission still false) would read as "denied" and fire onError early.
|
|
103
|
+
// 'priming' shows the "Allow camera access" screen BEFORE the OS prompt
|
|
104
|
+
// (Stripe-style); the prompt only fires (→ 'requesting') once the user taps
|
|
105
|
+
// "Grant access".
|
|
103
106
|
const { hasPermission, requestPermission } = useCameraPermission();
|
|
104
|
-
const [perm, setPerm] = useState<'
|
|
105
|
-
|
|
107
|
+
const [perm, setPerm] = useState<'priming' | 'requesting' | 'granted' | 'denied'>(
|
|
108
|
+
hasPermission ? 'granted' : 'priming',
|
|
109
|
+
);
|
|
106
110
|
const permReportedRef = useRef(false);
|
|
107
111
|
|
|
108
112
|
// ── Camera availability ─────────────────────────────────────────────────────
|
|
@@ -123,19 +127,25 @@ export function DocumentCaptureStep(): React.ReactElement {
|
|
|
123
127
|
// No camera hardware at all → "Camera not available" (regardless of what the
|
|
124
128
|
// permission API says — on a camera-less sim it may even report denied).
|
|
125
129
|
const cameraUnavailable = cameraGrace && !device;
|
|
130
|
+
const showPrimer = perm === 'priming' && !!device;
|
|
126
131
|
|
|
132
|
+
// Reflect an externally-granted permission.
|
|
127
133
|
useEffect(() => {
|
|
128
|
-
if (hasPermission)
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
+
if (hasPermission) setPerm('granted');
|
|
135
|
+
}, [hasPermission]);
|
|
136
|
+
|
|
137
|
+
// Fire the real OS prompt only after the user taps "Grant access" (or retry).
|
|
138
|
+
useEffect(() => {
|
|
139
|
+
if (perm !== 'requesting') return;
|
|
140
|
+
let cancelled = false;
|
|
134
141
|
void (async () => {
|
|
135
142
|
const granted = await requestPermission();
|
|
136
|
-
setPerm(granted ? 'granted' : 'denied');
|
|
143
|
+
if (!cancelled) setPerm(granted ? 'granted' : 'denied');
|
|
137
144
|
})();
|
|
138
|
-
|
|
145
|
+
return () => {
|
|
146
|
+
cancelled = true;
|
|
147
|
+
};
|
|
148
|
+
}, [perm, requestPermission]);
|
|
139
149
|
|
|
140
150
|
// A *genuine* permission denial requires a camera to exist but be blocked. On a
|
|
141
151
|
// camera-less sim the OS may report denied — that's "not available", not a
|
|
@@ -156,8 +166,7 @@ export function DocumentCaptureStep(): React.ReactElement {
|
|
|
156
166
|
}, [permissionDenied, config.onError]);
|
|
157
167
|
|
|
158
168
|
const retryPermission = useCallback(() => {
|
|
159
|
-
|
|
160
|
-
setPerm('checking');
|
|
169
|
+
setPerm('requesting');
|
|
161
170
|
}, []);
|
|
162
171
|
|
|
163
172
|
// ── Capture → compress → store for the current side ────────────────────────
|
|
@@ -303,6 +312,18 @@ export function DocumentCaptureStep(): React.ReactElement {
|
|
|
303
312
|
</>
|
|
304
313
|
);
|
|
305
314
|
}
|
|
315
|
+
if (showPrimer) {
|
|
316
|
+
// Primer before the OS prompt — camera not started yet.
|
|
317
|
+
return (
|
|
318
|
+
<>
|
|
319
|
+
{cropper}
|
|
320
|
+
<CameraPermissionPrimingView
|
|
321
|
+
message="When prompted, allow camera access to photograph your document."
|
|
322
|
+
onGrant={() => setPerm('requesting')}
|
|
323
|
+
/>
|
|
324
|
+
</>
|
|
325
|
+
);
|
|
326
|
+
}
|
|
306
327
|
if (permissionDenied) {
|
|
307
328
|
// A real camera exists but the OS blocked access.
|
|
308
329
|
return (
|
|
@@ -14,10 +14,12 @@ import { MyazaButton } from '../components/MyazaButton';
|
|
|
14
14
|
// IdInputScreen. Client-side format validation only; no OCR pre-fill. The step
|
|
15
15
|
// title/description live in the header.
|
|
16
16
|
|
|
17
|
-
function hintFor(def: { label: string; digits?: number } | null): string {
|
|
17
|
+
function hintFor(def: { label: string; inputLabel?: string; digits?: number } | null): string {
|
|
18
18
|
if (!def) return 'Enter your ID number';
|
|
19
|
-
|
|
20
|
-
|
|
19
|
+
// e.g. Tax ID is looked up by the person's NIN — ask for what they type.
|
|
20
|
+
const label = def.inputLabel ?? def.label;
|
|
21
|
+
if (def.digits != null) return `Enter ${def.digits}-digit ${label}`;
|
|
22
|
+
return `Enter your ${label}`;
|
|
21
23
|
}
|
|
22
24
|
|
|
23
25
|
export function IdInputStep(): React.ReactElement {
|
|
@@ -59,7 +61,7 @@ export function IdInputStep(): React.ReactElement {
|
|
|
59
61
|
<View>
|
|
60
62
|
{def ? (
|
|
61
63
|
<MyazaText variant="label" style={{ marginBottom: spacing.sm }}>
|
|
62
|
-
{def.label}
|
|
64
|
+
{def.inputLabel ?? def.label}
|
|
63
65
|
</MyazaText>
|
|
64
66
|
) : null}
|
|
65
67
|
<MyazaInput
|
|
@@ -29,7 +29,7 @@ import {
|
|
|
29
29
|
} from '../config/captureSettings';
|
|
30
30
|
import { Icon } from '../components/Icon';
|
|
31
31
|
import { useToast } from '../components/toast';
|
|
32
|
-
import { CameraPermissionView, CameraUnavailableView } from '../components/CameraPermissionView';
|
|
32
|
+
import { CameraPermissionView, CameraUnavailableView, CameraPermissionPrimingView } from '../components/CameraPermissionView';
|
|
33
33
|
import { LivenessAvatar } from './LivenessAvatar';
|
|
34
34
|
import { detectFaceOnFrame } from '../liveness/visionCameraFaceDetector';
|
|
35
35
|
import {
|
|
@@ -85,10 +85,14 @@ export function LivenessStep(): React.ReactElement {
|
|
|
85
85
|
});
|
|
86
86
|
const videoRecorder = useVideoRecorder(videoOutput, !!device);
|
|
87
87
|
|
|
88
|
-
|
|
89
|
-
|
|
88
|
+
// 'priming' shows the "Allow camera access" screen BEFORE the OS prompt
|
|
89
|
+
// (Stripe-style); the prompt only fires (→ 'requesting') once the user taps
|
|
90
|
+
// "Grant access". `perm` is driven by the async requestPermission result, not
|
|
91
|
+
// synchronously from `hasPermission` — otherwise the window while the OS prompt
|
|
92
|
+
// is open (hasPermission still false) would read as "denied" and fire onError.
|
|
93
|
+
const [perm, setPerm] = useState<'priming' | 'requesting' | 'granted' | 'denied'>(
|
|
94
|
+
hasPermission ? 'granted' : 'priming',
|
|
90
95
|
);
|
|
91
|
-
const askedRef = useRef(false);
|
|
92
96
|
const permReportedRef = useRef(false);
|
|
93
97
|
|
|
94
98
|
// Camera-availability grace (a simulator has no front camera).
|
|
@@ -98,20 +102,26 @@ export function LivenessStep(): React.ReactElement {
|
|
|
98
102
|
return () => clearTimeout(t);
|
|
99
103
|
}, []);
|
|
100
104
|
const cameraUnavailable = cameraGrace && !device;
|
|
105
|
+
const showPrimer = perm === 'priming' && !!device;
|
|
101
106
|
const permissionDenied = perm === 'denied' && !!device;
|
|
102
107
|
|
|
108
|
+
// Reflect an externally-granted permission.
|
|
103
109
|
useEffect(() => {
|
|
104
|
-
if (hasPermission)
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
+
if (hasPermission) setPerm('granted');
|
|
111
|
+
}, [hasPermission]);
|
|
112
|
+
|
|
113
|
+
// Fire the real OS prompt only after the user taps "Grant access" (or retry).
|
|
114
|
+
useEffect(() => {
|
|
115
|
+
if (perm !== 'requesting') return;
|
|
116
|
+
let cancelled = false;
|
|
110
117
|
void (async () => {
|
|
111
118
|
const granted = await requestPermission();
|
|
112
|
-
setPerm(granted ? 'granted' : 'denied');
|
|
119
|
+
if (!cancelled) setPerm(granted ? 'granted' : 'denied');
|
|
113
120
|
})();
|
|
114
|
-
|
|
121
|
+
return () => {
|
|
122
|
+
cancelled = true;
|
|
123
|
+
};
|
|
124
|
+
}, [perm, requestPermission]);
|
|
115
125
|
|
|
116
126
|
useEffect(() => {
|
|
117
127
|
if (permissionDenied && !permReportedRef.current) {
|
|
@@ -270,15 +280,11 @@ export function LivenessStep(): React.ReactElement {
|
|
|
270
280
|
if (cameraUnavailable) {
|
|
271
281
|
return <CameraUnavailableView />;
|
|
272
282
|
}
|
|
283
|
+
if (showPrimer) {
|
|
284
|
+
return <CameraPermissionPrimingView onGrant={() => setPerm('requesting')} />;
|
|
285
|
+
}
|
|
273
286
|
if (permissionDenied) {
|
|
274
|
-
return (
|
|
275
|
-
<CameraPermissionView
|
|
276
|
-
onRetry={() => {
|
|
277
|
-
askedRef.current = false;
|
|
278
|
-
setPerm('checking');
|
|
279
|
-
}}
|
|
280
|
-
/>
|
|
281
|
-
);
|
|
287
|
+
return <CameraPermissionView onRetry={() => setPerm('requesting')} />;
|
|
282
288
|
}
|
|
283
289
|
|
|
284
290
|
// ── Review (selfie captured) ─────────────────────────────────────────────────
|
|
@@ -14,7 +14,7 @@ export type DeviceType = 'mobile' | 'tablet' | 'desktop' | 'unknown';
|
|
|
14
14
|
* Single source of truth for the SDK version — also used by `services/api.ts`
|
|
15
15
|
* for the `X-SDK-Version` header. Keep in sync with `package.json`.
|
|
16
16
|
*/
|
|
17
|
-
export const SDK_VERSION = '2.0.
|
|
17
|
+
export const SDK_VERSION = '2.0.2';
|
|
18
18
|
|
|
19
19
|
export interface ReactNativeDeviceMetadata {
|
|
20
20
|
sdkType: 'react-native';
|
|
@@ -19,7 +19,8 @@ export type SdkEnvironment = 'development' | 'sandbox' | 'production';
|
|
|
19
19
|
|
|
20
20
|
/** Canonical base URLs for the non-development environments. */
|
|
21
21
|
const BASE_URLS: Record<Exclude<SdkEnvironment, 'development'>, string> = {
|
|
22
|
-
|
|
22
|
+
// Sandbox and production share the same host; the key prefix selects the env.
|
|
23
|
+
sandbox: 'https://identity.myaza.app',
|
|
23
24
|
production: 'https://identity.myaza.app',
|
|
24
25
|
};
|
|
25
26
|
|
|
@@ -34,6 +34,9 @@ function matchesPattern(value: string, pattern: RegExp, label: string, hint: str
|
|
|
34
34
|
const validators: Record<string, (value: string) => ValidationResult> = {
|
|
35
35
|
// Nigeria
|
|
36
36
|
bvn: (v) => digitsExact(v, 11, 'BVN'),
|
|
37
|
+
'bvn-premium': (v) => digitsExact(v, 11, 'BVN'),
|
|
38
|
+
// Tax ID lookups are keyed off the person's NIN — the typed number is a NIN.
|
|
39
|
+
'tax-id': (v) => digitsExact(v, 11, 'NIN'),
|
|
37
40
|
nin: (v) => digitsExact(v, 11, 'NIN'),
|
|
38
41
|
vnin: (v) => {
|
|
39
42
|
if (v.length !== 16) {
|
package/src/types/config.ts
CHANGED
|
@@ -6,7 +6,7 @@ import type { KYCSubmission, KYCError } from './verification';
|
|
|
6
6
|
|
|
7
7
|
export type SupportedCountry = 'NG' | 'GH' | 'KE' | 'ZA' | 'CI';
|
|
8
8
|
|
|
9
|
-
export type NigeriaIdType = 'bvn' | 'nin' | 'vnin' | 'passport' | 'drivers-license' | 'pvc';
|
|
9
|
+
export type NigeriaIdType = 'bvn' | 'bvn-premium' | 'nin' | 'vnin' | 'tax-id' | 'passport' | 'drivers-license' | 'pvc';
|
|
10
10
|
export type GhanaIdType = 'ghana-card' | 'voters' | 'drivers-license' | 'ssnit' | 'passport';
|
|
11
11
|
export type KenyaIdType = 'national-id' | 'passport';
|
|
12
12
|
export type SouthAfricaIdType = 'national-id';
|
|
@@ -31,6 +31,11 @@ export type IdTypeForCountry<C extends SupportedCountry> =
|
|
|
31
31
|
export interface IdTypeDefinition {
|
|
32
32
|
key: IdType;
|
|
33
33
|
label: string;
|
|
34
|
+
/**
|
|
35
|
+
* What the user actually types when it differs from the ID's name — e.g.
|
|
36
|
+
* Tax ID lookups are keyed off the person's NIN, so the input asks for a NIN.
|
|
37
|
+
*/
|
|
38
|
+
inputLabel?: string;
|
|
34
39
|
digits?: number;
|
|
35
40
|
pattern?: RegExp;
|
|
36
41
|
/** Whether this ID type requires photographing/uploading a physical document. */
|