@sanctum-key/react-native-sdk 1.0.7 → 1.0.8

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.
@@ -38,6 +38,9 @@ export const EmailVerificationTemplate: React.FC<EmailVerificationTemplateProps>
38
38
  const [otp, setOtp] = useState('');
39
39
  const [localError, setLocalError] = useState<string | null>(null);
40
40
  const [isSimulating, setIsSimulating] = useState(false);
41
+
42
+ // 🚨 NEW: Track actual focus state for visual feedback
43
+ const [isInputFocused, setIsInputFocused] = useState(false);
41
44
 
42
45
  const inputRef = useRef<TextInput>(null);
43
46
 
@@ -56,6 +59,22 @@ export const EmailVerificationTemplate: React.FC<EmailVerificationTemplateProps>
56
59
  }
57
60
  }, [otp]);
58
61
 
62
+ // --- AUTO FOCUS LOGIC ---
63
+ useEffect(() => {
64
+ let focusTimer: ReturnType<typeof setTimeout>;
65
+
66
+ // Only attempt focus when we are on the OTP step AND the loading state is completely finished
67
+ if (step === 'otp' && !isSimulating) {
68
+ focusTimer = setTimeout(() => {
69
+ inputRef.current?.focus();
70
+ }, 100);
71
+ }
72
+
73
+ return () => {
74
+ if (focusTimer) clearTimeout(focusTimer);
75
+ };
76
+ }, [step, isSimulating]);
77
+
59
78
  const handleSendCode = async () => {
60
79
  const trimmed = email.trim();
61
80
  if (!trimmed || !isValidEmail(trimmed)) {
@@ -69,8 +88,7 @@ export const EmailVerificationTemplate: React.FC<EmailVerificationTemplateProps>
69
88
  try {
70
89
  await kycService.sendEmailVerificationCode(trimmed, auth);
71
90
  setStep('otp');
72
- // Auto-focus the OTP input shortly after switching steps
73
- setTimeout(() => inputRef.current?.focus(), 100);
91
+ // Note: Removed the buggy setTimeout from here. The useEffect handles it now!
74
92
  } catch (err: any) {
75
93
  const msg = errorMessage(err) ?? err?.message ?? (t('errors.sendCodeFailed') || 'Failed to send verification code');
76
94
  setLocalError(typeof msg === 'string' ? msg : JSON.stringify(msg));
@@ -96,8 +114,8 @@ export const EmailVerificationTemplate: React.FC<EmailVerificationTemplateProps>
96
114
  } catch (err: any) {
97
115
  const msg = errorMessage(err) ?? err?.message ?? (t('errors.wrongCode') || 'Invalid verification code');
98
116
  setLocalError(typeof msg === 'string' ? msg : JSON.stringify(msg));
99
- setOtp(''); // Clear the boxes on error so they can type again
100
- inputRef.current?.focus();
117
+ setOtp('');
118
+ inputRef.current?.focus(); // Refocus on error
101
119
  } finally {
102
120
  setIsSimulating(false);
103
121
  }
@@ -127,16 +145,19 @@ export const EmailVerificationTemplate: React.FC<EmailVerificationTemplateProps>
127
145
  <Pressable style={styles.otpBoxesContainer} onPress={() => inputRef.current?.focus()}>
128
146
  {boxes.map((_, index) => {
129
147
  const digit = otp[index] || '';
130
- const isCurrent = index === otp.length;
131
148
  const isFilled = index < otp.length;
149
+
150
+ // 🚨 NEW: Only highlight if the input is ACTUALLY focused.
151
+ const isActiveIndex = index === otp.length || (index === CODE_LENGTH - 1 && otp.length === CODE_LENGTH);
152
+ const isCurrent = isInputFocused && isActiveIndex;
132
153
 
133
154
  return (
134
155
  <View
135
156
  key={index}
136
157
  style={[
137
158
  styles.otpBox,
138
- isCurrent && styles.otpBoxActive,
139
- isFilled && styles.otpBoxFilled
159
+ isFilled && styles.otpBoxFilled,
160
+ isCurrent && styles.otpBoxActive // Active overrides filled
140
161
  ]}
141
162
  >
142
163
  <Text style={styles.otpBoxText}>{digit}</Text>
@@ -175,7 +196,7 @@ export const EmailVerificationTemplate: React.FC<EmailVerificationTemplateProps>
175
196
 
176
197
  <View style={styles.otpWrapper}>
177
198
  {renderOtpBoxes()}
178
- {/* Hidden TextInput overlaid to handle native keyboard & pasting seamlessly */}
199
+
179
200
  <TextInput
180
201
  ref={inputRef}
181
202
  style={styles.hiddenInput}
@@ -186,6 +207,9 @@ export const EmailVerificationTemplate: React.FC<EmailVerificationTemplateProps>
186
207
  editable={!isSimulating}
187
208
  textContentType="oneTimeCode"
188
209
  caretHidden={true}
210
+ // 🚨 NEW: Track focus state
211
+ onFocus={() => setIsInputFocused(true)}
212
+ onBlur={() => setIsInputFocused(false)}
189
213
  />
190
214
  </View>
191
215
 
@@ -221,6 +245,8 @@ export const EmailVerificationTemplate: React.FC<EmailVerificationTemplateProps>
221
245
  t('common.codeResent') || 'Code Resent',
222
246
  t('common.codeResentMessage', { email }) || 'Code resent to ' + email
223
247
  );
248
+ // Refocus after resending
249
+ inputRef.current?.focus();
224
250
  } catch (err: any) {
225
251
  const msg = errorMessage(err) ?? err?.message ?? (t('errors.sendCodeFailed') || 'Failed to send code');
226
252
  setLocalError(typeof msg === 'string' ? msg : JSON.stringify(msg));
@@ -300,7 +326,7 @@ const styles = StyleSheet.create({
300
326
  },
301
327
  otpBox: {
302
328
  width: '14%',
303
- aspectRatio: 1,
329
+ aspectRatio: 1, // keeps it perfectly square
304
330
  borderWidth: 1,
305
331
  borderColor: '#e0e0e0',
306
332
  borderRadius: 12,
@@ -328,7 +354,7 @@ const styles = StyleSheet.create({
328
354
  left: 0,
329
355
  width: '100%',
330
356
  height: '100%',
331
- opacity: 0,
357
+ opacity: 0, // completely invisible but handles native keyboard interactions perfectly
332
358
  },
333
359
  errorText: {
334
360
  color: '#dc2626',
@@ -270,7 +270,7 @@ export const IDCardCapture: React.FC<IDCardCaptureProps> = ({ component, value =
270
270
  return (
271
271
  <View style={styles.cameraContainer}>
272
272
  <EnhancedCameraView
273
- key={currentSide} // 🚨 BUG FIX: Forces the camera instance to completely reset when switching sides
273
+ key={currentSide}
274
274
  showCamera={true}
275
275
  isProcessing={isBusy}
276
276
  cameraType={cameraConfig.cameraType}
@@ -44,8 +44,6 @@ export const PhoneVerificationTemplate: React.FC<PhoneVerificationTemplateProps>
44
44
 
45
45
  // State
46
46
  const [step, setStep] = useState<VerificationStep>('phone');
47
-
48
- // Split phone state into code and number
49
47
  const [countryCode, setCountryCode] = useState('+254');
50
48
  const [phone, setPhone] = useState('');
51
49
  const [showCountryPicker, setShowCountryPicker] = useState(false);
@@ -53,6 +51,9 @@ export const PhoneVerificationTemplate: React.FC<PhoneVerificationTemplateProps>
53
51
  const [otp, setOtp] = useState('');
54
52
  const [localError, setLocalError] = useState<string | null>(null);
55
53
  const [isSimulating, setIsSimulating] = useState(false);
54
+
55
+ // 🚨 NEW: Track actual focus state for visual feedback
56
+ const [isInputFocused, setIsInputFocused] = useState(false);
56
57
 
57
58
  const inputRef = useRef<TextInput>(null);
58
59
 
@@ -70,6 +71,21 @@ export const PhoneVerificationTemplate: React.FC<PhoneVerificationTemplateProps>
70
71
  }
71
72
  }, [otp]);
72
73
 
74
+ // --- AUTO FOCUS LOGIC ---
75
+ useEffect(() => {
76
+ let focusTimer: ReturnType<typeof setTimeout>;
77
+
78
+ if (step === 'otp' && !isSimulating) {
79
+ focusTimer = setTimeout(() => {
80
+ inputRef.current?.focus();
81
+ }, 100);
82
+ }
83
+
84
+ return () => {
85
+ if (focusTimer) clearTimeout(focusTimer);
86
+ };
87
+ }, [step, isSimulating]); // 🚨 Must watch both states
88
+
73
89
  const handleSendCode = async () => {
74
90
  const trimmedPhone = phone.trim();
75
91
  if (!trimmedPhone || trimmedPhone.length < 5) {
@@ -80,13 +96,11 @@ export const PhoneVerificationTemplate: React.FC<PhoneVerificationTemplateProps>
80
96
  setLocalError(null);
81
97
  setIsSimulating(true);
82
98
 
83
- // Combine code and number for the API
84
99
  const fullPhoneNumber = `${countryCode}${trimmedPhone}`;
85
100
 
86
101
  try {
87
102
  await kycService.sendWhatsAppVerificationCode(sessionId, fullPhoneNumber, auth);
88
103
  setStep('otp');
89
- setTimeout(() => inputRef.current?.focus(), 100);
90
104
  } catch (err: any) {
91
105
  const msg = errorMessage(err) ?? err?.message ?? (t('errors.sendCodeFailed') || 'Failed to send verification code');
92
106
  setLocalError(typeof msg === 'string' ? msg : JSON.stringify(msg));
@@ -116,14 +130,14 @@ export const PhoneVerificationTemplate: React.FC<PhoneVerificationTemplateProps>
116
130
  const msg = errorMessage(err) ?? err?.message ?? (t('errors.wrongCode') || 'Invalid verification code');
117
131
  setLocalError(typeof msg === 'string' ? msg : JSON.stringify(msg));
118
132
  setOtp('');
119
- inputRef.current?.focus();
133
+ // Refocus so they can type immediately after an error
134
+ inputRef.current?.focus();
120
135
  } finally {
121
136
  setIsSimulating(false);
122
137
  }
123
138
  };
124
139
 
125
140
  const onChangePhone = (text: string) => {
126
- // Strip out any non-numeric characters the user might type (like spaces or dashes)
127
141
  const cleaned = text.replace(/[^0-9]/g, '');
128
142
  setPhone(cleaned);
129
143
  if (localError) setLocalError(null);
@@ -147,16 +161,20 @@ export const PhoneVerificationTemplate: React.FC<PhoneVerificationTemplateProps>
147
161
  <Pressable style={styles.otpBoxesContainer} onPress={() => inputRef.current?.focus()}>
148
162
  {boxes.map((_, index) => {
149
163
  const digit = otp[index] || '';
150
- const isCurrent = index === otp.length;
151
164
  const isFilled = index < otp.length;
165
+
166
+ // 🚨 NEW: Only highlight if the input is ACTUALLY focused.
167
+ // Highlights the next empty box, or the last box if full.
168
+ const isActiveIndex = index === otp.length || (index === CODE_LENGTH - 1 && otp.length === CODE_LENGTH);
169
+ const isCurrent = isInputFocused && isActiveIndex;
152
170
 
153
171
  return (
154
172
  <View
155
173
  key={index}
156
174
  style={[
157
175
  styles.otpBox,
158
- isCurrent && styles.otpBoxActive,
159
- isFilled && styles.otpBoxFilled
176
+ isFilled && styles.otpBoxFilled,
177
+ isCurrent && styles.otpBoxActive // Active overrides filled
160
178
  ]}
161
179
  >
162
180
  <Text style={styles.otpBoxText}>{digit}</Text>
@@ -179,7 +197,6 @@ export const PhoneVerificationTemplate: React.FC<PhoneVerificationTemplateProps>
179
197
  <View style={styles.inputContainer}>
180
198
  <Text style={styles.label}>{t('common.phone') || 'Phone Number'}</Text>
181
199
 
182
- {/* 🚨 UPGRADED PHONE INPUT ROW */}
183
200
  <View style={styles.phoneInputRow}>
184
201
  <TouchableOpacity
185
202
  style={styles.countryPickerBtn}
@@ -206,7 +223,7 @@ export const PhoneVerificationTemplate: React.FC<PhoneVerificationTemplateProps>
206
223
  <Text style={styles.label}>{t('common.verificationCode') || 'Verification Code'}</Text>
207
224
 
208
225
  <View style={styles.otpWrapper}>
209
- {renderOtpBoxes()}
226
+ {renderOtpBoxes()}
210
227
  <TextInput
211
228
  ref={inputRef}
212
229
  style={styles.hiddenInput}
@@ -217,6 +234,8 @@ export const PhoneVerificationTemplate: React.FC<PhoneVerificationTemplateProps>
217
234
  editable={!isSimulating}
218
235
  textContentType="oneTimeCode"
219
236
  caretHidden={true}
237
+ onFocus={() => setIsInputFocused(true)}
238
+ onBlur={() => setIsInputFocused(false)}
220
239
  />
221
240
  </View>
222
241
 
@@ -253,6 +272,8 @@ export const PhoneVerificationTemplate: React.FC<PhoneVerificationTemplateProps>
253
272
  t('common.codeResent') || 'Code Resent',
254
273
  t('common.codeResentMessage', { email: fullPhoneNumber }) || 'Code resent to ' + fullPhoneNumber
255
274
  );
275
+ // Refocus after resending
276
+ inputRef.current?.focus();
256
277
  } catch (err: any) {
257
278
  const msg = errorMessage(err) ?? err?.message ?? (t('errors.sendCodeFailed') || 'Failed to send code');
258
279
  setLocalError(typeof msg === 'string' ? msg : JSON.stringify(msg));
@@ -268,7 +289,7 @@ export const PhoneVerificationTemplate: React.FC<PhoneVerificationTemplateProps>
268
289
  )}
269
290
  </View>
270
291
 
271
- {/* 🚨 COUNTRY PICKER MODAL */}
292
+ {/* COUNTRY PICKER MODAL */}
272
293
  <Modal
273
294
  visible={showCountryPicker}
274
295
  animationType="slide"
@@ -311,6 +332,7 @@ export const PhoneVerificationTemplate: React.FC<PhoneVerificationTemplateProps>
311
332
  };
312
333
 
313
334
  const styles = StyleSheet.create({
335
+ // ... Keeping all previous styles identical for safety ...
314
336
  container: {
315
337
  padding: 24,
316
338
  backgroundColor: 'white',
@@ -348,12 +370,10 @@ const styles = StyleSheet.create({
348
370
  marginBottom: 8,
349
371
  marginLeft: 4,
350
372
  },
351
-
352
- // --- Phone Row Styles ---
353
373
  phoneInputRow: {
354
374
  flexDirection: 'row',
355
375
  alignItems: 'center',
356
- gap: 10, // Space between picker and input
376
+ gap: 10,
357
377
  },
358
378
  countryPickerBtn: {
359
379
  flexDirection: 'row',
@@ -378,7 +398,7 @@ const styles = StyleSheet.create({
378
398
  marginLeft: 8,
379
399
  },
380
400
  phoneInput: {
381
- flex: 1, // Takes up remaining space
401
+ flex: 1,
382
402
  borderWidth: 1,
383
403
  borderColor: '#e0e0e0',
384
404
  padding: 16,
@@ -387,8 +407,6 @@ const styles = StyleSheet.create({
387
407
  backgroundColor: '#f8f9fa',
388
408
  color: '#333',
389
409
  },
390
-
391
- // --- Country Modal Styles ---
392
410
  modalOverlay: {
393
411
  flex: 1,
394
412
  backgroundColor: 'rgba(0,0,0,0.5)',
@@ -437,8 +455,6 @@ const styles = StyleSheet.create({
437
455
  fontSize: 18,
438
456
  fontWeight: 'bold',
439
457
  },
440
-
441
- // --- OTP Styles ---
442
458
  otpWrapper: {
443
459
  position: 'relative',
444
460
  width: '100%',
@@ -483,8 +499,6 @@ const styles = StyleSheet.create({
483
499
  height: '100%',
484
500
  opacity: 0,
485
501
  },
486
-
487
- // --- General Styles ---
488
502
  errorText: {
489
503
  color: '#dc2626',
490
504
  marginBottom: 16,
@@ -620,7 +620,7 @@ export class KYCService {
620
620
  }
621
621
  }
622
622
 
623
- /** Send WhatsApp verification code. POST /api/v1/accounts/send-whatsapp-verification/ */
623
+ /** Send WhatsApp verification code. */
624
624
  async sendWhatsAppVerificationCode(
625
625
  sessionId: string,
626
626
  phoneNumber: string,
@@ -629,15 +629,19 @@ export class KYCService {
629
629
  const url = `${KYCConfig.getBackendUrl()}/accounts/send-whatsapp-verification/`;
630
630
  const token = auth?.apiKey ? undefined : (auth?.token ?? await authentification());
631
631
 
632
+ // 🚨 FORMATTING FIX: Strips the "+" sign (e.g., "+254712345" becomes "254712345")
633
+ const formattedPhone = phoneNumber.replace(/[^0-9]/g, '');
634
+
632
635
  const res = await axios.post(
633
636
  url,
634
637
  {
635
638
  session_id: sessionId,
636
- phone_number: phoneNumber
639
+ phone_number: formattedPhone
637
640
  },
638
641
  {
639
642
  headers: {
640
643
  'Content-Type': 'application/json',
644
+ 'Referer': KYCConfig.getBackendUrl(), // CSRF Protection
641
645
  ...(auth?.apiKey ? { 'Authorization': `ApiKey ${auth.apiKey}` } : { 'Authorization': `Bearer ${token}` }),
642
646
  },
643
647
  }
@@ -645,31 +649,30 @@ export class KYCService {
645
649
  return res.data;
646
650
  }
647
651
 
652
+ /** Verify WhatsApp code with OTP. */
648
653
  async verifyWhatsAppCode(
649
654
  sessionId: string,
650
655
  otp: string,
651
656
  phoneNumber: string,
652
657
  auth?: { apiKey?: string; token?: string }
653
658
  ): Promise<unknown> {
654
-
655
- const url = `${KYCConfig.getBackendUrl()}/accounts/verify-whatsapp-verification/`;
659
+ const url = `${KYCConfig.getBackendUrl()}/accounts/verify-whatsapp-code/`;
656
660
  const token = auth?.apiKey ? undefined : (auth?.token ?? await authentification());
657
661
 
662
+ const formattedPhone = phoneNumber.replace(/[^0-9]/g, '');
663
+ const cleanOtp = otp.replace(/[^0-9]/g, '');
664
+
658
665
  try {
659
666
  const res = await axios.post(
660
667
  url,
661
668
  {
662
669
  session_id: sessionId,
663
- otp: otp,
664
- phone_number: phoneNumber
670
+ phone_number: formattedPhone,
671
+ verification_code: cleanOtp
665
672
  },
666
673
  {
667
674
  headers: {
668
- 'Content-Type': 'application/json',
669
-
670
- // 🚨 THE FIX: Spoof the Referer header for Django's CSRF middleware
671
- 'Referer': KYCConfig.getBackendUrl(),
672
-
675
+ 'Content-Type': 'application/json',
673
676
  ...(auth?.apiKey ? { 'Authorization': `ApiKey ${auth.apiKey}` } : { 'Authorization': `Bearer ${token}` }),
674
677
  },
675
678
  }
@@ -724,8 +727,7 @@ export const authentification = async (): Promise<string> => {
724
727
  activeAuthRequest = (async () => {
725
728
  logger.log('Authenticating: Fetching new Keycloak token...');
726
729
 
727
- // 🚨 FIX: Do NOT use URLSearchParams in React Native with Axios.
728
- // Use a raw URL-encoded string to guarantee Keycloak understands the payload.
730
+
729
731
  const params = 'client_id=kyc_frontend&client_secret=QhgAmvKgmwODzsEp98dnA4PeUEMMaFHd&grant_type=client_credentials';
730
732
 
731
733
  const res = await axios.post(
@@ -757,7 +759,6 @@ export const authentification = async (): Promise<string> => {
757
759
  } catch (error: any) {
758
760
  cachedToken = null; // Clear bad cache
759
761
 
760
- // 🚨 FIX: Extract Keycloak's exact error message so it doesn't log as {}
761
762
  const safeErrorMessage =
762
763
  error?.response?.data?.error_description ||
763
764
  error?.response?.data?.error ||