@kernaq/verify-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.
@@ -1,25 +1,31 @@
1
1
  /**
2
- * <KernaqVerify /> — drop-in identity verification modal for React Native.
2
+ * <KernaqVerify /> — drop-in identity verification modal for React Native (v2).
3
3
  *
4
- * Usage:
5
- * import { KernaqVerify } from '@kernaq/verify-react-native'
4
+ * Matches @kernaq/verify web v2.0.0:
5
+ * - No header / logo
6
+ * - No progress bar
7
+ * - No cancel/close button — user must complete the flow
8
+ * - Front + back document capture
9
+ * - Auto-capture selfie with guidance pill
10
+ * - Randomised liveness tasks with in-viewport overlay
11
+ * - Configurable steps prop
12
+ * - "Secured by Kernaq" footer only
6
13
  *
14
+ * Usage:
7
15
  * <KernaqVerify
8
- * visible={showVerify}
9
- * apiKey="k_test_..."
16
+ * visible={show}
17
+ * documentType="national_id"
10
18
  * country="KEN"
11
19
  * reference={userId}
12
- * onComplete={(result) => console.log(result.verdict)}
13
- * onCancel={() => setShowVerify(false)}
20
+ * steps={['document', 'selfie', 'liveness']}
21
+ * onComplete={(r) => console.log(r.verdict)}
14
22
  * />
15
23
  */
16
- import React, { useState, useCallback, useMemo } from 'react'
24
+ import React, { useState, useCallback, useMemo, useRef } from 'react'
17
25
  import {
18
26
  Modal,
19
27
  View,
20
28
  Text,
21
- TouchableOpacity,
22
- ScrollView,
23
29
  StyleSheet,
24
30
  SafeAreaView,
25
31
  Platform,
@@ -27,130 +33,147 @@ import {
27
33
  import { DEFAULT_LOCALE } from './locale'
28
34
  import { resolveTheme } from './theme'
29
35
  import { usePermissions } from './hooks/usePermissions'
30
- import { IntroStep } from './steps/IntroStep'
31
- import { DocumentStep } from './steps/DocumentStep'
32
- import { SelfieStep } from './steps/SelfieStep'
33
- import { LivenessStep } from './steps/LivenessStep'
36
+ import { IntroStep } from './steps/IntroStep'
37
+ import { DocumentStep } from './steps/DocumentStep'
38
+ import { SelfieStep } from './steps/SelfieStep'
39
+ import { LivenessStep } from './steps/LivenessStep'
34
40
  import { ProcessingStep } from './steps/ProcessingStep'
35
- import { ResultStep } from './steps/ResultStep'
36
- import type { VerifyConfig, VerifyStep, VerifyResult, VerifyLocale } from './types'
41
+ import { ResultStep } from './steps/ResultStep'
42
+ import type {
43
+ VerifyConfig, VerifyStep, VerifyResult, VerifyLocale, VerifyStepName,
44
+ } from './types'
37
45
 
38
46
  interface Props extends VerifyConfig {
39
47
  visible: boolean
40
48
  }
41
49
 
42
50
  type CaptureState = {
43
- documentUri?: string
44
- selfieUri?: string
45
- videoUri?: string
46
- }
47
-
48
- const STEP_INDEX: Record<VerifyStep, number> = {
49
- intro: -1,
50
- document: 0,
51
- selfie: 1,
52
- liveness: 2,
53
- processing: 3,
54
- result: 3,
51
+ docFrontUri?: string
52
+ docBackUri?: string
53
+ selfieUri?: string
54
+ videoUri?: string
55
55
  }
56
56
 
57
57
  export function KernaqVerify({
58
58
  visible,
59
59
  apiKey,
60
60
  backendUrl,
61
- country = 'KEN',
61
+ country = 'KEN',
62
62
  reference,
63
- sandbox = false,
63
+ sandbox = false,
64
64
  livenessDuration = 3000,
65
- documentTypes = ['national_id', 'passport'],
65
+ livenessTaskCount = 2,
66
+ documentType = 'national_id',
67
+ steps: stepsProp,
66
68
  theme: themeProp,
67
69
  locale: localeProp,
68
- onCancel,
69
70
  onComplete,
70
71
  onError,
71
72
  }: Props) {
72
- const [step, setStep] = useState<VerifyStep>('intro')
73
+ const [step, setStep] = useState<VerifyStep>('intro')
73
74
  const [capture, setCapture] = useState<CaptureState>({})
74
- const [result, setResult] = useState<VerifyResult | null>(null)
75
- const [error, setError] = useState<string | null>(null)
75
+ const [result, setResult] = useState<VerifyResult | null>(null)
76
+ const [error, setError] = useState<string | null>(null)
77
+
78
+ // Mirror capture in a ref so submit() always reads the latest value even
79
+ // when called from inside a stale closure (e.g. goto → submit chain).
80
+ const captureRef = useRef<CaptureState>({})
81
+ const setCaptureSync = (updater: (prev: CaptureState) => CaptureState) => {
82
+ setCapture((prev: CaptureState) => {
83
+ const next = updater(prev)
84
+ captureRef.current = next
85
+ return next
86
+ })
87
+ }
76
88
 
77
89
  const theme = useMemo(() => resolveTheme(themeProp), [themeProp])
78
- const locale: VerifyLocale = useMemo(
79
- () => ({ ...DEFAULT_LOCALE, ...localeProp }),
80
- [localeProp]
81
- )
90
+ const locale: VerifyLocale = useMemo(() => ({ ...DEFAULT_LOCALE, ...localeProp }), [localeProp])
82
91
  const { requestPermissions } = usePermissions()
83
92
  const s = styles(theme)
84
93
 
94
+ // Build the ordered flow from the steps prop
95
+ const flow = useMemo((): VerifyStep[] => {
96
+ const wanted = stepsProp ?? ['document', 'selfie', 'liveness'] as VerifyStepName[]
97
+ const out: VerifyStep[] = []
98
+ if (wanted.includes('document')) { out.push('doc-front'); out.push('doc-back') }
99
+ if (wanted.includes('selfie')) out.push('selfie')
100
+ if (wanted.includes('liveness')) out.push('liveness')
101
+ out.push('processing')
102
+ return out
103
+ }, [stepsProp])
104
+
105
+ const next = useCallback((current: VerifyStep) => {
106
+ const idx = flow.indexOf(current)
107
+ const nextStep = flow[idx + 1] ?? 'processing'
108
+ goto(nextStep)
109
+ // eslint-disable-next-line react-hooks/exhaustive-deps
110
+ }, [flow])
111
+
112
+ const goto = useCallback(async (s: VerifyStep) => {
113
+ if (s === 'doc-front' || s === 'doc-back' || s === 'selfie' || s === 'liveness') {
114
+ const granted = await requestPermissions()
115
+ if (!granted) { setError(locale.error_camera_denied); return }
116
+ }
117
+ setStep(s)
118
+ if (s === 'processing') submit()
119
+ // submit is stable (no deps that change); locale/requestPermissions are stable refs
120
+ // eslint-disable-next-line react-hooks/exhaustive-deps
121
+ }, [locale])
122
+
85
123
  const reset = useCallback(() => {
86
124
  setStep('intro')
125
+ captureRef.current = {}
87
126
  setCapture({})
88
127
  setResult(null)
89
128
  setError(null)
90
129
  }, [])
91
130
 
92
- const goto = useCallback(async (next: VerifyStep) => {
93
- if (next === 'document' || next === 'selfie' || next === 'liveness') {
94
- const granted = await requestPermissions()
95
- if (!granted) {
96
- setError(locale.error_camera_denied)
97
- return
98
- }
99
- }
100
- setStep(next)
101
- if (next === 'processing') {
102
- submit()
103
- }
104
- // eslint-disable-next-line react-hooks/exhaustive-deps
105
- }, [capture, locale])
106
-
107
131
  const submit = useCallback(async () => {
108
- const { documentUri, selfieUri, videoUri } = capture
109
- if (!documentUri || !selfieUri) {
110
- setError('Missing captures — please restart.')
132
+ const { docFrontUri, docBackUri, selfieUri, videoUri } = captureRef.current
133
+ const steps = stepsProp ?? ['document', 'selfie', 'liveness'] as VerifyStepName[]
134
+ if (steps.includes('document') && !docFrontUri) {
135
+ setError('Document capture is missing — please restart.')
136
+ setStep('result')
137
+ onError?.({ code: 'capture_missing', message: 'Document capture missing' })
138
+ return
139
+ }
140
+ if (steps.includes('selfie') && !selfieUri) {
141
+ setError('Selfie capture is missing — please restart.')
111
142
  setStep('result')
143
+ onError?.({ code: 'capture_missing', message: 'Selfie capture missing' })
112
144
  return
113
145
  }
114
146
 
115
147
  const base = (backendUrl ?? 'https://api.identity.kernaq.com/v1').replace(/\/$/, '')
148
+ const endpoint = sandbox ? '/verify/sandbox' : '/verify'
116
149
  const form = new FormData()
117
150
 
118
- form.append('document', {
119
- uri: documentUri,
120
- name: 'document.jpg',
121
- type: 'image/jpeg',
122
- } as unknown as Blob)
123
-
124
- form.append('selfie', {
125
- uri: selfieUri,
126
- name: 'selfie.jpg',
127
- type: 'image/jpeg',
128
- } as unknown as Blob)
129
-
151
+ if (docFrontUri) {
152
+ form.append('doc_front', { uri: docFrontUri, name: 'doc_front.jpg', type: 'image/jpeg' } as any)
153
+ }
154
+ if (docBackUri) {
155
+ form.append('doc_back', { uri: docBackUri, name: 'doc_back.jpg', type: 'image/jpeg' } as any)
156
+ }
157
+ if (selfieUri) {
158
+ form.append('selfie', { uri: selfieUri, name: 'selfie.jpg', type: 'image/jpeg' } as any)
159
+ }
130
160
  if (videoUri) {
131
- form.append('video', {
132
- uri: videoUri,
133
- name: 'liveness.mp4',
134
- type: 'video/mp4',
135
- } as unknown as Blob)
161
+ form.append('video', { uri: videoUri, name: 'liveness.mp4', type: 'video/mp4' } as any)
136
162
  }
137
163
 
138
- form.append('document_type', documentTypes[0] ?? 'national_id')
139
- form.append('country', country)
140
- form.append('reference', reference ?? `kq_${Date.now()}`)
141
- if (sandbox) form.append('sandbox', 'true')
164
+ form.append('document_type', documentType)
165
+ form.append('country', country)
166
+ form.append('reference', reference ?? `kq_${Date.now()}`)
167
+ // sandbox is handled via URL routing above — no form field needed
142
168
 
143
- const headers: Record<string, string> = {
144
- 'Content-Type': 'multipart/form-data',
145
- }
169
+ // Do NOT set Content-Type manually — RN fetch will set multipart/form-data with
170
+ // the correct boundary automatically. Setting it manually omits the boundary and
171
+ // causes the server to reject the request.
172
+ const headers: Record<string, string> = {}
146
173
  if (apiKey) headers['X-API-Key'] = apiKey
147
174
 
148
175
  try {
149
- const res = await fetch(`${base}/verify`, {
150
- method: 'POST',
151
- headers,
152
- body: form,
153
- })
176
+ const res = await fetch(`${base}${endpoint}`, { method: 'POST', headers, body: form })
154
177
  const body = (await res.json()) as Record<string, unknown>
155
178
  if (!res.ok) throw new Error((body['message'] as string) ?? `HTTP ${res.status}`)
156
179
 
@@ -169,117 +192,110 @@ export function KernaqVerify({
169
192
  } catch (err) {
170
193
  const msg = (err as Error).message
171
194
  setError(`${locale.error_network} ${msg}`)
195
+ setResult(null)
172
196
  setStep('result')
173
197
  onError?.({ code: 'network_error', message: msg })
174
198
  }
175
199
  // eslint-disable-next-line react-hooks/exhaustive-deps
176
- }, [capture])
177
-
178
- const handleCancel = () => {
179
- reset()
180
- onCancel?.()
181
- }
182
-
183
- const progressSteps: VerifyStep[] = ['document', 'selfie', 'liveness', 'processing']
184
- const currentIdx = STEP_INDEX[step]
200
+ }, []) // no deps — always reads from captureRef.current, never from stale closure
185
201
 
186
202
  return (
187
203
  <Modal
188
204
  visible={visible}
189
205
  animationType="slide"
190
206
  presentationStyle="pageSheet"
191
- onRequestClose={handleCancel}
207
+ onRequestClose={() => {/* intentionally empty — user cannot dismiss */}}
192
208
  >
193
- <SafeAreaView style={[s.safeArea, { backgroundColor: theme.bg }]}>
194
- {/* Header */}
195
- <View style={s.header}>
196
- <Text style={s.logo}>Kernaq</Text>
197
- {step !== 'processing' && (
198
- <TouchableOpacity onPress={handleCancel} style={s.closeBtn} hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }}>
199
- <Text style={s.closeText}>✕</Text>
200
- </TouchableOpacity>
201
- )}
202
- </View>
203
-
204
- {/* Progress bar */}
205
- {currentIdx >= 0 && (
206
- <View style={s.progress}>
207
- {progressSteps.map((_, i) => (
208
- <View
209
- key={i}
210
- style={[
211
- s.progressDot,
212
- i < currentIdx && { backgroundColor: theme.accent, opacity: 0.4 },
213
- i === currentIdx && { backgroundColor: theme.accent },
214
- ]}
215
- />
216
- ))}
217
- </View>
218
- )}
209
+ <SafeAreaView style={[s.safe, { backgroundColor: theme.bg }]}>
219
210
 
220
- {/* Body */}
221
- <ScrollView
222
- style={s.scroll}
223
- contentContainerStyle={s.scrollContent}
224
- keyboardShouldPersistTaps="handled"
225
- bounces={false}
226
- >
211
+ {/* ── Content ── */}
212
+ <View style={s.body}>
227
213
  {step === 'intro' && (
228
214
  <IntroStep
229
215
  theme={theme}
230
216
  locale={locale}
231
- onStart={() => goto('document')}
217
+ onStart={() => {
218
+ const first = flow[0]
219
+ if (first) goto(first)
220
+ }}
221
+ />
222
+ )}
223
+
224
+ {step === 'doc-front' && (
225
+ <DocumentStep
226
+ theme={theme}
227
+ locale={locale}
228
+ side="front"
229
+ onCapture={(uri) => {
230
+ setCaptureSync(c => ({ ...c, docFrontUri: uri }))
231
+ next('doc-front')
232
+ }}
232
233
  />
233
234
  )}
234
- {step === 'document' && (
235
+
236
+ {step === 'doc-back' && (
235
237
  <DocumentStep
236
238
  theme={theme}
237
239
  locale={locale}
240
+ side="back"
241
+ frontUri={capture.docFrontUri}
238
242
  onCapture={(uri) => {
239
- setCapture((c) => ({ ...c, documentUri: uri }))
240
- goto('selfie')
243
+ setCaptureSync(c => ({ ...c, docBackUri: uri }))
244
+ next('doc-back')
245
+ }}
246
+ onRetakeFront={() => {
247
+ setCaptureSync(c => ({ ...c, docFrontUri: undefined }))
248
+ goto('doc-front')
241
249
  }}
242
250
  />
243
251
  )}
252
+
244
253
  {step === 'selfie' && (
245
254
  <SelfieStep
246
255
  theme={theme}
247
256
  locale={locale}
248
257
  onCapture={(uri) => {
249
- setCapture((c) => ({ ...c, selfieUri: uri }))
250
- goto('liveness')
258
+ setCaptureSync(c => ({ ...c, selfieUri: uri }))
259
+ next('selfie')
251
260
  }}
252
- onBack={() => setStep('document')}
253
261
  />
254
262
  )}
263
+
255
264
  {step === 'liveness' && (
256
265
  <LivenessStep
257
266
  theme={theme}
258
267
  locale={locale}
259
268
  duration={livenessDuration}
269
+ taskCount={livenessTaskCount}
260
270
  onCapture={(uri) => {
261
- setCapture((c) => ({ ...c, videoUri: uri }))
262
- goto('processing')
271
+ setCaptureSync(c => ({ ...c, videoUri: uri }))
272
+ next('liveness')
263
273
  }}
264
- onBack={() => setStep('selfie')}
265
274
  />
266
275
  )}
276
+
267
277
  {step === 'processing' && (
268
278
  <ProcessingStep theme={theme} locale={locale} />
269
279
  )}
280
+
270
281
  {step === 'result' && (
271
282
  <ResultStep
272
283
  theme={theme}
273
284
  locale={locale}
274
285
  result={result}
275
286
  error={error}
276
- onRetry={() => {
277
- reset()
278
- setStep('intro')
279
- }}
287
+ onRetry={() => { reset(); goto('intro') }}
280
288
  />
281
289
  )}
282
- </ScrollView>
290
+ </View>
291
+
292
+ {/* ── Footer — Secured by Kernaq ── */}
293
+ {step !== 'processing' && (
294
+ <View style={s.footer}>
295
+ <Text style={s.footerText}>Secured by </Text>
296
+ <Text style={[s.footerText, { fontWeight: '600', color: theme.text }]}>Kernaq</Text>
297
+ </View>
298
+ )}
283
299
  </SafeAreaView>
284
300
  </Modal>
285
301
  )
@@ -287,43 +303,23 @@ export function KernaqVerify({
287
303
 
288
304
  const styles = (t: ReturnType<typeof resolveTheme>) =>
289
305
  StyleSheet.create({
290
- safeArea: {
306
+ safe: {
291
307
  flex: 1,
292
308
  },
293
- header: {
309
+ body: {
310
+ flex: 1,
311
+ },
312
+ footer: {
294
313
  flexDirection: 'row',
295
314
  alignItems: 'center',
296
- justifyContent: 'space-between',
297
- paddingHorizontal: 20,
298
- paddingTop: Platform.OS === 'android' ? 16 : 8,
299
- paddingBottom: 8,
300
- },
301
- logo: {
302
- fontSize: 14,
303
- fontWeight: '700',
304
- color: t.accent,
305
- letterSpacing: 1.2,
306
- textTransform: 'uppercase',
307
- },
308
- closeBtn: {
309
- padding: 4,
315
+ justifyContent: 'center',
316
+ paddingVertical: 10,
317
+ paddingBottom: Platform.OS === 'ios' ? 4 : 10,
318
+ borderTopWidth: StyleSheet.hairlineWidth,
319
+ borderTopColor: t.border,
310
320
  },
311
- closeText: {
312
- fontSize: 18,
321
+ footerText: {
322
+ fontSize: 11,
313
323
  color: t.subtext,
314
324
  },
315
- progress: {
316
- flexDirection: 'row',
317
- gap: 6,
318
- paddingHorizontal: 20,
319
- paddingBottom: 8,
320
- },
321
- progressDot: {
322
- flex: 1,
323
- height: 3,
324
- borderRadius: 999,
325
- backgroundColor: t.border,
326
- },
327
- scroll: { flex: 1 },
328
- scrollContent: { flexGrow: 1 },
329
325
  })
package/src/index.ts CHANGED
@@ -1,18 +1,19 @@
1
1
  /**
2
- * @kernaq/verify-react-native
2
+ * @kernaq/verify-react-native v2
3
3
  *
4
4
  * Drop-in KYC verification UI for React Native.
5
- * Works with bare React Native and Expo (requires expo prebuild).
5
+ * Matches @kernaq/verify web v2.0.0 — same flow, same API surface.
6
6
  *
7
7
  * Usage:
8
8
  * import { KernaqVerify } from '@kernaq/verify-react-native'
9
9
  *
10
10
  * <KernaqVerify
11
11
  * visible={showVerify}
12
- * apiKey="k_test_..."
12
+ * documentType="national_id"
13
13
  * country="KEN"
14
+ * reference={userId}
15
+ * steps={['document', 'selfie', 'liveness']}
14
16
  * onComplete={(result) => console.log(result.verdict)}
15
- * onCancel={() => setShowVerify(false)}
16
17
  * />
17
18
  *
18
19
  * Peer dependencies:
@@ -21,12 +22,16 @@
21
22
  */
22
23
 
23
24
  export { KernaqVerify } from './KernaqVerify'
25
+ export { analyseImageUri, analyseSelfieFrame } from './quality'
26
+ export type { QualityReport, QualityConfig, QualityFailure } from './quality'
24
27
  export type {
25
28
  VerifyConfig,
26
29
  VerifyResult,
27
30
  VerifyStep,
31
+ VerifyStepName,
28
32
  VerifyVerdict,
29
33
  VerifyTheme,
30
34
  VerifyLocale,
31
35
  DocumentType,
36
+ LivenessTask,
32
37
  } from './types'
package/src/locale.ts CHANGED
@@ -1,33 +1,36 @@
1
1
  import type { VerifyLocale } from './types'
2
2
 
3
3
  export const DEFAULT_LOCALE: VerifyLocale = {
4
- intro_title: 'Verify your identity',
5
- intro_body: "This takes about 60 seconds. You'll need your ID document and a clear view of your face.",
6
- intro_cta: 'Get started',
7
- doc_title: 'Scan your document',
8
- doc_instruction: 'Hold your ID flat inside the frame. Make sure it\'s well-lit and all four corners are visible.',
9
- doc_capture_btn: 'Capture',
10
- doc_retake_btn: 'Retake',
11
- doc_next_btn: 'Looks good',
12
- selfie_title: 'Take a selfie',
13
- selfie_instruction: 'Look straight at the camera. Keep your face inside the oval.',
14
- selfie_capture_btn: 'Take selfie',
15
- selfie_retake_btn: 'Retake',
16
- selfie_next_btn: 'Looks good',
17
- liveness_title: 'Liveness check',
18
- liveness_instruction: 'Press start, then slowly turn your head left and right.',
19
- liveness_start_btn: 'Start recording',
20
- liveness_recording: 'Recording…',
21
- processing_title: 'Verifying your identity',
22
- processing_subtitle: 'This usually takes a few seconds.',
23
- result_pass_title: 'Verification passed',
24
- result_pass_body: 'Your identity has been verified successfully.',
25
- result_fail_title: 'Verification failed',
26
- result_fail_body: 'We were unable to verify your identity. Please try again.',
27
- result_review_title: 'Under review',
28
- result_review_body: 'Your submission is being reviewed. This usually takes a few minutes.',
29
- result_retry_btn: 'Try again',
30
- error_camera_denied: 'Camera access denied. Please allow camera permissions in settings.',
31
- error_quality: 'Image quality too low. Try better lighting or hold the camera steady.',
32
- error_network: 'Network error. Please check your connection and try again.',
4
+ intro_title: 'Verify your identity',
5
+ intro_body: 'This takes about 90 seconds. You\'ll need your ID document and a clear view of your face.',
6
+ intro_cta: 'Get started',
7
+
8
+ doc_front_title: 'Document — front',
9
+ doc_front_instruction: 'Hold the front of your document flat inside the frame. Ensure all four corners are visible and there is no glare.',
10
+ doc_back_title: 'Document — back',
11
+ doc_back_instruction: 'Flip your document over and hold the back inside the frame.',
12
+ doc_capture_btn: 'Capture',
13
+ doc_retake_btn: 'Retake',
14
+
15
+ selfie_title: 'Selfie',
16
+ selfie_instruction: 'Centre your face inside the oval. Look directly at the camera.',
17
+
18
+ liveness_title: 'Liveness check',
19
+ liveness_ready: 'Follow the instructions as they appear.',
20
+ liveness_task_prefix: 'Please',
21
+ liveness_complete: 'Hold still…',
22
+
23
+ processing_title: 'Verifying…',
24
+
25
+ result_pass_title: 'Identity verified',
26
+ result_pass_body: 'Your verification was successful.',
27
+ result_fail_title: 'Verification failed',
28
+ result_fail_body: 'We could not verify your identity. Please try again with better lighting.',
29
+ result_review_title: 'Under review',
30
+ result_review_body: 'Your submission is being manually reviewed. This usually takes a few minutes.',
31
+
32
+ error_camera_denied: 'Camera access denied. Allow camera permissions and try again.',
33
+ error_blur: 'Image is too blurry — hold steady and ensure good lighting.',
34
+ error_quality: 'Image quality too low. Try better lighting or a steadier hand.',
35
+ error_network: 'Network error. Check your connection and try again.',
33
36
  }