@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 +1 -1
- 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
|
@@ -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://
|
|
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
|
@@ -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. */
|