@kernaq/verify-react-native 1.0.4 → 2.0.1

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,16 +1,24 @@
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
24
  import React, { useState, useCallback, useMemo } from 'react'
@@ -18,8 +26,6 @@ import {
18
26
  Modal,
19
27
  View,
20
28
  Text,
21
- TouchableOpacity,
22
- ScrollView,
23
29
  StyleSheet,
24
30
  SafeAreaView,
25
31
  Platform,
@@ -27,61 +33,81 @@ 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)
76
77
 
77
78
  const theme = useMemo(() => resolveTheme(themeProp), [themeProp])
78
- const locale: VerifyLocale = useMemo(
79
- () => ({ ...DEFAULT_LOCALE, ...localeProp }),
80
- [localeProp]
81
- )
79
+ const locale: VerifyLocale = useMemo(() => ({ ...DEFAULT_LOCALE, ...localeProp }), [localeProp])
82
80
  const { requestPermissions } = usePermissions()
83
81
  const s = styles(theme)
84
82
 
83
+ // Build the ordered flow from the steps prop
84
+ const flow = useMemo((): VerifyStep[] => {
85
+ const wanted = stepsProp ?? ['document', 'selfie', 'liveness'] as VerifyStepName[]
86
+ const out: VerifyStep[] = []
87
+ if (wanted.includes('document')) { out.push('doc-front'); out.push('doc-back') }
88
+ if (wanted.includes('selfie')) out.push('selfie')
89
+ if (wanted.includes('liveness')) out.push('liveness')
90
+ out.push('processing')
91
+ return out
92
+ }, [stepsProp])
93
+
94
+ const next = useCallback((current: VerifyStep) => {
95
+ const idx = flow.indexOf(current)
96
+ const nextStep = flow[idx + 1] ?? 'processing'
97
+ goto(nextStep)
98
+ // eslint-disable-next-line react-hooks/exhaustive-deps
99
+ }, [flow])
100
+
101
+ const goto = useCallback(async (s: VerifyStep) => {
102
+ if (s === 'doc-front' || s === 'doc-back' || s === 'selfie' || s === 'liveness') {
103
+ const granted = await requestPermissions()
104
+ if (!granted) { setError(locale.error_camera_denied); return }
105
+ }
106
+ setStep(s)
107
+ if (s === 'processing') submit()
108
+ // eslint-disable-next-line react-hooks/exhaustive-deps
109
+ }, [capture, locale])
110
+
85
111
  const reset = useCallback(() => {
86
112
  setStep('intro')
87
113
  setCapture({})
@@ -89,68 +115,38 @@ export function KernaqVerify({
89
115
  setError(null)
90
116
  }, [])
91
117
 
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
118
  const submit = useCallback(async () => {
108
- const { documentUri, selfieUri, videoUri } = capture
109
- if (!documentUri || !selfieUri) {
110
- setError('Missing captures — please restart.')
111
- setStep('result')
112
- return
119
+ const { docFrontUri, docBackUri, selfieUri, videoUri } = capture
120
+ if (!docFrontUri && !selfieUri) {
121
+ // At least one capture needed depending on steps
113
122
  }
114
123
 
115
124
  const base = (backendUrl ?? 'https://api.identity.kernaq.com/v1').replace(/\/$/, '')
116
125
  const form = new FormData()
117
126
 
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
-
127
+ if (docFrontUri) {
128
+ form.append('document', { uri: docFrontUri, name: 'doc_front.jpg', type: 'image/jpeg' } as any)
129
+ }
130
+ if (docBackUri) {
131
+ form.append('document_back', { uri: docBackUri, name: 'doc_back.jpg', type: 'image/jpeg' } as any)
132
+ }
133
+ if (selfieUri) {
134
+ form.append('selfie', { uri: selfieUri, name: 'selfie.jpg', type: 'image/jpeg' } as any)
135
+ }
130
136
  if (videoUri) {
131
- form.append('video', {
132
- uri: videoUri,
133
- name: 'liveness.mp4',
134
- type: 'video/mp4',
135
- } as unknown as Blob)
137
+ form.append('video', { uri: videoUri, name: 'liveness.mp4', type: 'video/mp4' } as any)
136
138
  }
137
139
 
138
- form.append('document_type', documentTypes[0] ?? 'national_id')
139
- form.append('country', country)
140
- form.append('reference', reference ?? `kq_${Date.now()}`)
140
+ form.append('document_type', documentType)
141
+ form.append('country', country)
142
+ form.append('reference', reference ?? `kq_${Date.now()}`)
141
143
  if (sandbox) form.append('sandbox', 'true')
142
144
 
143
- const headers: Record<string, string> = {
144
- 'Content-Type': 'multipart/form-data',
145
- }
145
+ const headers: Record<string, string> = { 'Content-Type': 'multipart/form-data' }
146
146
  if (apiKey) headers['X-API-Key'] = apiKey
147
147
 
148
148
  try {
149
- const res = await fetch(`${base}/verify`, {
150
- method: 'POST',
151
- headers,
152
- body: form,
153
- })
149
+ const res = await fetch(`${base}/verify`, { method: 'POST', headers, body: form })
154
150
  const body = (await res.json()) as Record<string, unknown>
155
151
  if (!res.ok) throw new Error((body['message'] as string) ?? `HTTP ${res.status}`)
156
152
 
@@ -169,117 +165,110 @@ export function KernaqVerify({
169
165
  } catch (err) {
170
166
  const msg = (err as Error).message
171
167
  setError(`${locale.error_network} ${msg}`)
168
+ setResult(null)
172
169
  setStep('result')
173
170
  onError?.({ code: 'network_error', message: msg })
174
171
  }
175
172
  // eslint-disable-next-line react-hooks/exhaustive-deps
176
173
  }, [capture])
177
174
 
178
- const handleCancel = () => {
179
- reset()
180
- onCancel?.()
181
- }
182
-
183
- const progressSteps: VerifyStep[] = ['document', 'selfie', 'liveness', 'processing']
184
- const currentIdx = STEP_INDEX[step]
185
-
186
175
  return (
187
176
  <Modal
188
177
  visible={visible}
189
178
  animationType="slide"
190
179
  presentationStyle="pageSheet"
191
- onRequestClose={handleCancel}
180
+ onRequestClose={() => {/* intentionally empty — user cannot dismiss */}}
192
181
  >
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>
182
+ <SafeAreaView style={[s.safe, { backgroundColor: theme.bg }]}>
203
183
 
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
- )}
219
-
220
- {/* Body */}
221
- <ScrollView
222
- style={s.scroll}
223
- contentContainerStyle={s.scrollContent}
224
- keyboardShouldPersistTaps="handled"
225
- bounces={false}
226
- >
184
+ {/* ── Content ── */}
185
+ <View style={s.body}>
227
186
  {step === 'intro' && (
228
187
  <IntroStep
229
188
  theme={theme}
230
189
  locale={locale}
231
- onStart={() => goto('document')}
190
+ onStart={() => {
191
+ const first = flow[0]
192
+ if (first) goto(first)
193
+ }}
194
+ />
195
+ )}
196
+
197
+ {step === 'doc-front' && (
198
+ <DocumentStep
199
+ theme={theme}
200
+ locale={locale}
201
+ side="front"
202
+ onCapture={(uri) => {
203
+ setCapture(c => ({ ...c, docFrontUri: uri }))
204
+ next('doc-front')
205
+ }}
232
206
  />
233
207
  )}
234
- {step === 'document' && (
208
+
209
+ {step === 'doc-back' && (
235
210
  <DocumentStep
236
211
  theme={theme}
237
212
  locale={locale}
213
+ side="back"
214
+ frontUri={capture.docFrontUri}
238
215
  onCapture={(uri) => {
239
- setCapture((c) => ({ ...c, documentUri: uri }))
240
- goto('selfie')
216
+ setCapture(c => ({ ...c, docBackUri: uri }))
217
+ next('doc-back')
218
+ }}
219
+ onRetakeFront={() => {
220
+ setCapture(c => ({ ...c, docFrontUri: undefined }))
221
+ goto('doc-front')
241
222
  }}
242
223
  />
243
224
  )}
225
+
244
226
  {step === 'selfie' && (
245
227
  <SelfieStep
246
228
  theme={theme}
247
229
  locale={locale}
248
230
  onCapture={(uri) => {
249
- setCapture((c) => ({ ...c, selfieUri: uri }))
250
- goto('liveness')
231
+ setCapture(c => ({ ...c, selfieUri: uri }))
232
+ next('selfie')
251
233
  }}
252
- onBack={() => setStep('document')}
253
234
  />
254
235
  )}
236
+
255
237
  {step === 'liveness' && (
256
238
  <LivenessStep
257
239
  theme={theme}
258
240
  locale={locale}
259
241
  duration={livenessDuration}
242
+ taskCount={livenessTaskCount}
260
243
  onCapture={(uri) => {
261
- setCapture((c) => ({ ...c, videoUri: uri }))
262
- goto('processing')
244
+ setCapture(c => ({ ...c, videoUri: uri }))
245
+ next('liveness')
263
246
  }}
264
- onBack={() => setStep('selfie')}
265
247
  />
266
248
  )}
249
+
267
250
  {step === 'processing' && (
268
251
  <ProcessingStep theme={theme} locale={locale} />
269
252
  )}
253
+
270
254
  {step === 'result' && (
271
255
  <ResultStep
272
256
  theme={theme}
273
257
  locale={locale}
274
258
  result={result}
275
259
  error={error}
276
- onRetry={() => {
277
- reset()
278
- setStep('intro')
279
- }}
260
+ onRetry={() => { reset(); goto('intro') }}
280
261
  />
281
262
  )}
282
- </ScrollView>
263
+ </View>
264
+
265
+ {/* ── Footer — Secured by Kernaq ── */}
266
+ {step !== 'processing' && (
267
+ <View style={s.footer}>
268
+ <Text style={s.footerText}>Secured by </Text>
269
+ <Text style={[s.footerText, { fontWeight: '600', color: theme.text }]}>Kernaq</Text>
270
+ </View>
271
+ )}
283
272
  </SafeAreaView>
284
273
  </Modal>
285
274
  )
@@ -287,43 +276,23 @@ export function KernaqVerify({
287
276
 
288
277
  const styles = (t: ReturnType<typeof resolveTheme>) =>
289
278
  StyleSheet.create({
290
- safeArea: {
279
+ safe: {
291
280
  flex: 1,
292
281
  },
293
- header: {
282
+ body: {
283
+ flex: 1,
284
+ },
285
+ footer: {
294
286
  flexDirection: 'row',
295
287
  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',
288
+ justifyContent: 'center',
289
+ paddingVertical: 10,
290
+ paddingBottom: Platform.OS === 'ios' ? 4 : 10,
291
+ borderTopWidth: StyleSheet.hairlineWidth,
292
+ borderTopColor: t.border,
307
293
  },
308
- closeBtn: {
309
- padding: 4,
310
- },
311
- closeText: {
312
- fontSize: 18,
294
+ footerText: {
295
+ fontSize: 11,
313
296
  color: t.subtext,
314
297
  },
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
298
  })
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
  }