@myazahq/kyc-sdk-react-native 2.0.1 → 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
@@ -209,7 +209,7 @@ base URL) from the API key prefix, the single source of truth:
209
209
 
210
210
  | Prefix | Environment | Base URL |
211
211
  | ---------- | ----------- | --------------------------------- |
212
- | `pk_test_` | sandbox | `https://sandbox.identity.myaza.app` |
212
+ | `pk_test_` | sandbox | `https://identity.myaza.app` |
213
213
  | `pk_live_` | production | `https://identity.myaza.app` |
214
214
 
215
215
  An unrecognized or malformed key throws at setup (it never silently defaults).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@myazahq/kyc-sdk-react-native",
3
- "version": "2.0.1",
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.1';
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. */