@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 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 detection. Mirrors the [web](https://www.npmjs.com/package/@myazahq/kyc-sdk-react)
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
- ## Install
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
- Peer dependencies (both setups): `react-native-vision-camera`,
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-core react-native-svg
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` (the SDK's plugin adds the iOS camera usage
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 # or: npx expo run:android
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-worklets-core react-native-svg \
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
- - Enable VisionCamera frame processors (it needs `react-native-worklets-core`,
88
- installed above) — see the
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 (podspec + Android Gradle project + the
92
- `detectFace` frame-processor plugin) is autolinked via React Native / Expo
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 Screen() {
135
+ export default function VerifyScreen() {
105
136
  return (
106
137
  <MyazaKYC
107
- apiKey="pk_test_xxx" // prefix selects the env: pk_test_ → sandbox
138
+ apiKey="pk_live_xxx" // prefix selects the env: pk_test_ → sandbox
108
139
  country="NG"
109
- idTypes={['bvn', 'nin', 'passport']}
140
+ idTypes={['passport', 'drivers-license', 'bvn', 'nin', 'pvc']}
141
+ userData={{ firstName: 'Jane', lastName: 'Doe' }}
142
+ enableSelfie
110
143
  enableDocumentCapture
111
144
  enableLiveness
112
- appearance={{ primaryColor: '#5645F5', companyName: 'Myaza', theme: 'light' }}
113
- onSubmit={(s) => console.log('submitted', s.verificationId)}
114
- onError={(e) => console.warn(e.code, e.message)}
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
- Or drive the flow from your own trigger with the hook:
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
- const { open, close, isOpen, currentStep } = useMyazaKYC({ apiKey, country: 'NG' });
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
- ## Environment
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
- The base URL is auto-detected from the API key prefix — there is **no**
131
- `environment` prop:
253
+ ## Appearance & theming
132
254
 
133
- | Prefix | Environment | Base URL |
134
- | ---------- | ----------- | --------------------------------- |
135
- | `pk_dev_` | development | `http://localhost:3001` (override with `devUrl`) |
136
- | `pk_test_` | sandbox | `https://sandbox.identity.myaza.app` |
137
- | `pk_live_` | production | `https://identity.myaza.app` |
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-processor plugin: the camera frame never crosses the JS bridge. The selfie
151
- is **auto-captured** once challenges pass (anti-spoofing), and a short liveness
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
- ## Callbacks
412
+ ## Documentation
155
413
 
156
- `onSubmit(submission)` fires the moment the server accepts the request
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
- UNLICENSED — © Myaza.
418
+ MIT © Flitstack Technologies Inc.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@myazahq/kyc-sdk-react-native",
3
- "version": "2.0.0",
3
+ "version": "2.0.2",
4
4
  "description": "Myaza KYC SDK for React Native (Expo) — ID verification, liveness detection, and document capture",
5
5
  "repository": {
6
6
  "type": "git",
@@ -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://sandbox.identity.myaza.app');
23
- expect(resolveBaseUrl('pk_test_abc', 'http://example.test')).toBe('https://sandbox.identity.myaza.app');
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://sandbox.identity.myaza.app')).toBe(
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: `${colors.error}1A`,
45
+ backgroundColor: `${accent}1A`,
43
46
  alignItems: 'center',
44
47
  justifyContent: 'center',
45
48
  }}
46
49
  >
47
- <Icon name={icon} size={38} color={colors.error} />
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 =
@@ -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<'checking' | 'granted' | 'denied'>(hasPermission ? 'granted' : 'checking');
105
- const askedRef = useRef(false);
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
- setPerm('granted');
130
- return;
131
- }
132
- if (askedRef.current) return;
133
- askedRef.current = true;
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
- }, [hasPermission, requestPermission]);
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
- askedRef.current = false;
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
- if (def.digits != null) return `Enter ${def.digits}-digit ${def.label}`;
20
- return `Enter your ${def.label}`;
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
- const [perm, setPerm] = useState<'checking' | 'granted' | 'denied'>(
89
- hasPermission ? 'granted' : 'checking',
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
- setPerm('granted');
106
- return;
107
- }
108
- if (askedRef.current) return;
109
- askedRef.current = true;
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
- }, [hasPermission, requestPermission]);
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.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
- sandbox: 'https://sandbox.identity.myaza.app',
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) {
@@ -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. */