@myazahq/kyc-sdk-react-native 2.3.0 → 2.5.0

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.
Files changed (129) hide show
  1. package/android/build.gradle +35 -3
  2. package/android/src/main/java/com/margelo/nitro/myazakyc/HybridMyazaFaceDetector.kt +75 -0
  3. package/ios/HybridMyazaFaceDetector.swift +8 -0
  4. package/nitrogen/generated/android/c++/JHybridMyazaFaceDetectorSpec.cpp +23 -0
  5. package/nitrogen/generated/android/c++/JHybridMyazaFaceDetectorSpec.hpp +2 -0
  6. package/nitrogen/generated/android/kotlin/com/margelo/nitro/myazakyc/HybridMyazaFaceDetectorSpec.kt +9 -0
  7. package/nitrogen/generated/ios/KycSdkReactNative-Swift-Cxx-Bridge.cpp +8 -0
  8. package/nitrogen/generated/ios/KycSdkReactNative-Swift-Cxx-Bridge.hpp +43 -0
  9. package/nitrogen/generated/ios/c++/HybridMyazaFaceDetectorSpecSwift.hpp +17 -0
  10. package/nitrogen/generated/ios/swift/Func_void_bool.swift +46 -0
  11. package/nitrogen/generated/ios/swift/HybridMyazaFaceDetectorSpec.swift +2 -0
  12. package/nitrogen/generated/ios/swift/HybridMyazaFaceDetectorSpec_cxx.swift +31 -0
  13. package/nitrogen/generated/shared/c++/HybridMyazaFaceDetectorSpec.cpp +2 -0
  14. package/nitrogen/generated/shared/c++/HybridMyazaFaceDetectorSpec.hpp +3 -0
  15. package/package.json +1 -2
  16. package/src/MyazaKYC.tsx +12 -0
  17. package/src/components/BrandBar.tsx +137 -0
  18. package/src/components/DashedBorder.tsx +55 -0
  19. package/src/components/DialCodePicker.tsx +25 -24
  20. package/src/components/DocumentReviewSide.tsx +34 -14
  21. package/src/components/Icon.tsx +14 -0
  22. package/src/components/KycSheet.tsx +39 -140
  23. package/src/components/MediaSourceSheet.tsx +7 -37
  24. package/src/components/MultiIdProgress.tsx +107 -0
  25. package/src/components/MyazaDateField.tsx +6 -20
  26. package/src/components/MyazaInput.tsx +23 -4
  27. package/src/components/MyazaSelect.tsx +17 -40
  28. package/src/components/OwnershipSlider.tsx +102 -0
  29. package/src/components/PhoneNumberInput.tsx +37 -4
  30. package/src/components/SandboxBanner.tsx +92 -0
  31. package/src/components/StaggerIn.tsx +63 -0
  32. package/src/components/StepHeader.tsx +15 -2
  33. package/src/components/StepView.tsx +26 -0
  34. package/src/components/glass/ChromeGlass.tsx +65 -0
  35. package/src/components/glass/FloatingSheet.tsx +242 -0
  36. package/src/components/glass/GlassSheet.tsx +50 -0
  37. package/src/components/glass/GlassSurface.tsx +31 -3
  38. package/src/components/stepHeaderMeta.tsx +3 -1
  39. package/src/components/viewfinder/ImmersiveBottomBar.tsx +28 -17
  40. package/src/components/viewfinder/ImmersiveControls.tsx +26 -14
  41. package/src/components/viewfinder/ViewfinderControls.tsx +29 -7
  42. package/src/config/business.ts +6 -6
  43. package/src/config/businessDetailsValidity.ts +52 -0
  44. package/src/config/businessPrefill.ts +72 -0
  45. package/src/config/businessSteps.ts +14 -3
  46. package/src/config/keyPeople.ts +111 -7
  47. package/src/config/keyPeopleCorporate.ts +19 -0
  48. package/src/config/keyPeopleOwnerTypes.ts +12 -0
  49. package/src/config/keyPeopleOwners.ts +26 -0
  50. package/src/config/keyPeoplePrefill.ts +138 -0
  51. package/src/config/keyPeopleSectionDefs.ts +65 -0
  52. package/src/config/keyPeopleSections.ts +167 -0
  53. package/src/config/registrationHint.ts +1 -1
  54. package/src/config/stepOrder.ts +24 -4
  55. package/src/config/theme.ts +8 -2
  56. package/src/config/website.ts +36 -0
  57. package/src/config/workflowMerge.ts +5 -0
  58. package/src/emrtd/activeAuth.ts +100 -0
  59. package/src/emrtd/extras.ts +76 -0
  60. package/src/emrtd/files.ts +5 -0
  61. package/src/emrtd/index.ts +1 -0
  62. package/src/emrtd/read.ts +8 -1
  63. package/src/emrtd/session.ts +41 -0
  64. package/src/emrtd/stages.ts +11 -0
  65. package/src/lib/contact-recovery.ts +44 -0
  66. package/src/lib/multi-id.ts +210 -0
  67. package/src/lib/resubmit.ts +130 -0
  68. package/src/lib/screen-corners.ts +50 -0
  69. package/src/lib/step-log.ts +24 -3
  70. package/src/lib/use-multi-id-plan.ts +29 -0
  71. package/src/liveness/useModelReady.ts +65 -0
  72. package/src/liveness/visionCameraFaceDetector.ts +35 -0
  73. package/src/screens/ApplicantRoleStep.tsx +9 -5
  74. package/src/screens/BusinessCheckPanel.tsx +89 -0
  75. package/src/screens/BusinessDetailsFields.tsx +109 -0
  76. package/src/screens/BusinessDetailsStep.tsx +131 -145
  77. package/src/screens/BusinessDocumentSlot.tsx +2 -3
  78. package/src/screens/BusinessDocumentsStep.tsx +1 -1
  79. package/src/screens/BusinessKeyPeopleStep.tsx +103 -71
  80. package/src/screens/BusinessPickedCard.tsx +86 -0
  81. package/src/screens/BusinessPickedSection.tsx +51 -0
  82. package/src/screens/BusinessRegistryPickers.tsx +67 -0
  83. package/src/screens/BusinessSandboxToggle.tsx +114 -0
  84. package/src/screens/BusinessSearch.tsx +199 -0
  85. package/src/screens/BusinessSearchResults.tsx +115 -0
  86. package/src/screens/CompanyInfoFields.tsx +86 -51
  87. package/src/screens/ConsentStep.tsx +31 -0
  88. package/src/screens/ContactDestinationField.tsx +1 -0
  89. package/src/screens/ContactVerificationStep.tsx +34 -3
  90. package/src/screens/IdTypeStep.tsx +12 -5
  91. package/src/screens/KeepLinksSheet.tsx +88 -0
  92. package/src/screens/KeyPeopleAwaitCard.tsx +204 -0
  93. package/src/screens/KeyPeopleAwaitList.tsx +126 -0
  94. package/src/screens/KeyPeoplePending.tsx +154 -0
  95. package/src/screens/KeyPeopleSection.tsx +162 -0
  96. package/src/screens/KeyPeopleSectionsList.tsx +103 -0
  97. package/src/screens/KeyPeopleUboExemption.tsx +72 -0
  98. package/src/screens/KeyPersonCard.tsx +36 -4
  99. package/src/screens/KeyPersonForm.tsx +195 -36
  100. package/src/screens/KeyPersonKindToggle.tsx +68 -0
  101. package/src/screens/KeyPersonOwners.tsx +109 -0
  102. package/src/screens/KeyPersonRoleChips.tsx +68 -0
  103. package/src/screens/KeyPersonSheet.tsx +92 -108
  104. package/src/screens/LivenessStep.tsx +18 -0
  105. package/src/screens/NfcStep.tsx +14 -0
  106. package/src/screens/ProofOfAddressStep.tsx +6 -3
  107. package/src/screens/SubmittedStep.tsx +66 -10
  108. package/src/screens/nfc/NfcReadProgress.tsx +1 -0
  109. package/src/screens/useAwaitingPeople.ts +90 -0
  110. package/src/services/api-types.ts +158 -2
  111. package/src/services/api-verify-types.ts +53 -1
  112. package/src/services/api.ts +104 -0
  113. package/src/services/deviceMetadata.ts +1 -1
  114. package/src/services/errors.ts +10 -2
  115. package/src/services/workflowGate.ts +1 -0
  116. package/src/specs/MyazaFaceDetector.nitro.ts +29 -0
  117. package/src/store/businessCheck.ts +113 -0
  118. package/src/store/derive.ts +15 -0
  119. package/src/store/kycStore.ts +241 -14
  120. package/src/store/serverConfig.ts +2 -0
  121. package/src/store/session.ts +208 -0
  122. package/src/store/state.ts +155 -2
  123. package/src/store/submit.ts +63 -13
  124. package/src/store/submitApplicant.ts +5 -11
  125. package/src/types/business.ts +23 -2
  126. package/src/types/config.ts +29 -3
  127. package/src/types/verification.ts +1 -1
  128. package/src/types/workflow.ts +15 -0
  129. package/src/screens/KeyPeopleInviteLinks.tsx +0 -239
@@ -1,53 +1,65 @@
1
1
  import React, { useEffect, useState } from 'react';
2
- import {
3
- Keyboard,
4
- Modal,
5
- Platform,
6
- Pressable,
7
- ScrollView,
8
- useWindowDimensions,
9
- View,
10
- } from 'react-native';
2
+ import { Keyboard, Platform, Pressable, ScrollView, useWindowDimensions, View } from 'react-native';
11
3
 
12
- import { radius, spacing } from '../config/theme';
4
+ import { spacing } from '../config/theme';
13
5
  import { useTheme } from '../components/runtime';
14
6
  import { MyazaText } from '../components/Typography';
15
7
  import { MyazaButton } from '../components/MyazaButton';
16
- import { Icon } from '../components/Icon';
8
+ import { FloatingSheet } from '../components/glass/FloatingSheet';
17
9
  import { KeyPersonForm } from './KeyPersonForm';
18
10
  import { isKeyPersonRowValid, type KeyPersonEntry } from '../config/keyPeople';
11
+ import type { KeyPeopleSection as SectionKey } from '../config/keyPeopleSections';
12
+ import type { KeyPersonRole } from '../types/business';
13
+
14
+ /** What the sheet calls itself, per the section that opened it. */
15
+ const ADD_TITLES: Record<SectionKey, string> = {
16
+ ubos: 'Add a beneficial owner',
17
+ shareholders: 'Add a shareholder',
18
+ representatives: 'Add a representative',
19
+ };
19
20
 
20
21
  // ---------------------------------------------------------------------------
21
22
  // The add/edit key-person sheet. The list step stays a clean stack of summary
22
- // cards; the FORM lives here — slide-up sheet, grab handle, the five fields,
23
- // and a pinned primary action. Editing adds a visually separated destructive
24
- // "Remove this person" beneath the save (never beside it — HIG destructive
25
- // separation).
23
+ // cards; the FORM lives here — the same liquid-glass FloatingSheet every other
24
+ // picker in the SDK rides (handle, glass surface, swipe-down), so the two
25
+ // sheet kinds feel like one system. Editing adds a visually separated
26
+ // destructive "Remove this person" beneath the save (never beside it — HIG
27
+ // destructive separation).
26
28
  //
27
29
  // Keyboard handling mirrors DialCodePicker: the sheet is lifted above the keys
28
30
  // and sized against the space that remains, so the focused field is never
29
31
  // underneath the keyboard.
30
32
  //
31
- // Save is enabled once the draft is valid. A combined-ownership overshoot
32
- // WARNS here but never blocks saving — the fix may live on a different
33
- // person's %, and trapping the user inside this sheet would force them to
34
- // discard their work to go adjust it.
33
+ // Save is enabled once the draft is valid (including a REQUIRED email when the
34
+ // workflow demands one for the role). A combined-ownership overshoot WARNS
35
+ // here but never blocks saving — the fix may live on a different person's %,
36
+ // and trapping the user inside this sheet would force them to discard their
37
+ // work to go adjust it.
35
38
  // ---------------------------------------------------------------------------
36
39
 
37
40
  export function KeyPersonSheet({
38
41
  mode,
42
+ section,
43
+ corporateKyb = false,
39
44
  initial,
40
45
  uboThreshold,
41
46
  otherPctTotal,
47
+ emailRequiredFor,
42
48
  onSave,
43
49
  onRemove,
44
50
  onClose,
45
51
  }: {
46
52
  mode: 'add' | 'edit';
53
+ /** The section whose add-tile or card opened the sheet. */
54
+ section: SectionKey;
55
+ /** The workflow sends corporate shareholders their own KYB application. */
56
+ corporateKyb?: boolean;
47
57
  initial: KeyPersonEntry;
48
58
  uboThreshold?: number;
49
59
  /** Sum of every OTHER person's ownership % — for the combined warning. */
50
60
  otherPctTotal: number;
61
+ /** Roles whose email is mandatory (they are sent a verification link). */
62
+ emailRequiredFor?: ReadonlySet<KeyPersonRole>;
51
63
  onSave: (entry: KeyPersonEntry) => void;
52
64
  /** Edit mode only — removes the person and closes. */
53
65
  onRemove?: () => void;
@@ -82,103 +94,75 @@ export function KeyPersonSheet({
82
94
  const fmtPct = (n: number): string => (Number.isInteger(n) ? String(n) : n.toFixed(1));
83
95
  const combinedPctError =
84
96
  combinedTotal > 100
85
- ? `Combined ownership would be ${fmtPct(combinedTotal)}% — over by ${fmtPct(combinedTotal - 100)}%.`
97
+ ? `Combined ownership would be ${fmtPct(combinedTotal)}%, over by ${fmtPct(combinedTotal - 100)}%.`
86
98
  : null;
87
99
 
88
- const canSave = isKeyPersonRowValid(draft) && draft.name.trim().length >= 2;
100
+ const canSave =
101
+ isKeyPersonRowValid(draft, emailRequiredFor ?? new Set()) && draft.name.trim().length >= 2;
89
102
 
90
103
  return (
91
- <Modal visible transparent animationType="slide" onRequestClose={onClose}>
92
- {/* Same wrapper as MyazaSelect's option sheet: a flex-end backdrop with
93
- the sheet inside it, so the top corners round identically. Tapping
94
- the backdrop dismisses; a tap inside the sheet does not. */}
95
- <Pressable
96
- onPress={onClose}
97
- accessibilityLabel="Close"
104
+ <FloatingSheet
105
+ visible
106
+ onClose={onClose}
107
+ maxHeight={maxHeight}
108
+ // Ride above a raised keyboard — this sheet is a form.
109
+ bottomOffset={keyboard}
110
+ closeLabel="Close person editor"
111
+ >
112
+ <MyazaText
113
+ variant="body"
98
114
  style={{
99
- flex: 1,
100
- backgroundColor: 'rgba(0,0,0,0.45)',
101
- justifyContent: 'flex-end',
115
+ fontWeight: '700',
116
+ paddingHorizontal: spacing.md,
117
+ // Tight under the handle, the way the system sheet titles sit.
118
+ paddingTop: spacing.xs,
119
+ paddingBottom: spacing.sm,
102
120
  }}
103
121
  >
104
- <Pressable
105
- onPress={() => {}}
106
- style={{
107
- maxHeight,
108
- marginBottom: keyboard,
109
- backgroundColor: colors.background,
110
- borderTopLeftRadius: radius.lg,
111
- borderTopRightRadius: radius.lg,
112
- overflow: 'hidden',
113
- }}
114
- >
115
- <View
116
- style={{
117
- alignSelf: 'center',
118
- width: 36,
119
- height: 4,
120
- borderRadius: radius.full,
121
- backgroundColor: colors.border,
122
- marginTop: spacing.sm,
123
- }}
124
- />
125
- <View
126
- style={{
127
- flexDirection: 'row',
128
- alignItems: 'center',
129
- paddingHorizontal: spacing.md,
130
- paddingTop: spacing.sm + 4,
131
- paddingBottom: spacing.sm,
132
- }}
133
- >
134
- <MyazaText variant="body" style={{ flex: 1, fontWeight: '700' }}>
135
- {mode === 'add' ? 'Add a person' : 'Edit person'}
136
- </MyazaText>
137
- <Pressable onPress={onClose} hitSlop={12} accessibilityRole="button" accessibilityLabel="Close">
138
- <Icon name="x" size={20} color={colors.textSecondary} />
139
- </Pressable>
140
- </View>
122
+ {mode === 'add' ? ADD_TITLES[section] : draft.isCorporate ? 'Edit company' : 'Edit person'}
123
+ </MyazaText>
141
124
 
142
- <ScrollView
143
- bounces={false}
144
- keyboardShouldPersistTaps="handled"
145
- contentContainerStyle={{ paddingHorizontal: spacing.md, paddingBottom: spacing.sm }}
146
- >
147
- <KeyPersonForm
148
- entry={draft}
149
- onChange={(patch) => setDraft((d) => ({ ...d, ...patch }))}
150
- uboThreshold={uboThreshold}
151
- combinedPctError={combinedPctError}
152
- />
153
- </ScrollView>
125
+ <ScrollView
126
+ bounces={false}
127
+ keyboardShouldPersistTaps="handled"
128
+ contentContainerStyle={{ paddingHorizontal: spacing.md, paddingBottom: spacing.sm }}
129
+ >
130
+ <KeyPersonForm
131
+ entry={draft}
132
+ section={section}
133
+ corporateKyb={corporateKyb}
134
+ onChange={(patch) => setDraft((d) => ({ ...d, ...patch }))}
135
+ uboThreshold={uboThreshold}
136
+ combinedPctError={combinedPctError}
137
+ emailRequiredFor={emailRequiredFor}
138
+ />
139
+ </ScrollView>
154
140
 
155
- <View style={{ paddingHorizontal: spacing.md, paddingTop: spacing.sm, paddingBottom: spacing.lg }}>
156
- <MyazaButton
157
- label={mode === 'add' ? 'Add person' : 'Save changes'}
158
- onPress={canSave ? () => onSave(draft) : undefined}
159
- disabled={!canSave}
160
- />
161
- {mode === 'edit' && onRemove ? (
162
- <Pressable
163
- onPress={onRemove}
164
- accessibilityRole="button"
165
- accessibilityLabel="Remove this person"
166
- style={({ pressed }) => ({
167
- height: 44,
168
- alignItems: 'center',
169
- justifyContent: 'center',
170
- marginTop: spacing.xs,
171
- opacity: pressed ? 0.7 : 1,
172
- })}
173
- >
174
- <MyazaText variant="bodySmall" color={colors.error} style={{ fontWeight: '600' }}>
175
- Remove this person
176
- </MyazaText>
177
- </Pressable>
178
- ) : null}
179
- </View>
180
- </Pressable>
181
- </Pressable>
182
- </Modal>
141
+ <View style={{ paddingHorizontal: spacing.md, paddingTop: spacing.sm }}>
142
+ <MyazaButton
143
+ label={mode === 'add' ? (draft.isCorporate ? 'Add company' : 'Add person') : 'Save changes'}
144
+ onPress={canSave ? () => onSave(draft) : undefined}
145
+ disabled={!canSave}
146
+ />
147
+ {mode === 'edit' && onRemove ? (
148
+ <Pressable
149
+ onPress={onRemove}
150
+ accessibilityRole="button"
151
+ accessibilityLabel="Remove this person"
152
+ style={({ pressed }) => ({
153
+ height: 44,
154
+ alignItems: 'center',
155
+ justifyContent: 'center',
156
+ marginTop: spacing.xs,
157
+ opacity: pressed ? 0.7 : 1,
158
+ })}
159
+ >
160
+ <MyazaText variant="bodyMedium" color={colors.error} style={{ fontWeight: '600' }}>
161
+ {draft.isCorporate ? 'Remove this company' : 'Remove this person'}
162
+ </MyazaText>
163
+ </Pressable>
164
+ ) : null}
165
+ </View>
166
+ </FloatingSheet>
183
167
  );
184
168
  }
@@ -31,6 +31,7 @@ import {
31
31
  import { Icon } from '../components/Icon';
32
32
  import { useToast } from '../components/toast';
33
33
  import { CameraPermissionView, CameraUnavailableView, CameraPermissionPrimingView } from '../components/CameraPermissionView';
34
+ import { useFaceModelReady } from '../liveness/useModelReady';
34
35
  import { ReadyPrimer } from '../components/ReadyPrimer';
35
36
  import { READY_LIVENESS } from '../components/readyPrimerContent';
36
37
  import { LivenessAvatar } from './LivenessAvatar';
@@ -113,6 +114,11 @@ export function LivenessStep(): React.ReactElement {
113
114
  const [perm, setPerm] = useState<'priming' | 'requesting' | 'granted' | 'denied'>(
114
115
  hasPermission ? 'granted' : 'priming',
115
116
  );
117
+
118
+ // Android fetches the face model through Play Services rather than bundling
119
+ // it, so it can be absent on a fresh install. Normally already 'ready' here —
120
+ // the flow primes the download at open, behind consent and ID-type selection.
121
+ const modelState = useFaceModelReady();
116
122
  const permReportedRef = useRef(false);
117
123
 
118
124
  // Camera-availability grace (a simulator has no front camera).
@@ -351,6 +357,18 @@ export function LivenessStep(): React.ReactElement {
351
357
  if (cameraUnavailable) {
352
358
  return <CameraUnavailableView />;
353
359
  }
360
+ // The face model is fetched on demand on Android (see useModelReady), so it
361
+ // can be absent on a fresh install or a device without Play Services. Checked
362
+ // here, before the camera opens, because detectFace reports a missing model
363
+ // and an empty frame identically — gating on frames would leave the user on
364
+ // "position your face" forever with nothing to explain it.
365
+ if (modelState === 'unavailable') {
366
+ return (
367
+ <CameraUnavailableView
368
+ message="Face verification couldn't start on this device. Check your connection and try again, or use a different device."
369
+ />
370
+ );
371
+ }
354
372
  // "Here's what happens next", BEFORE the OS prompt — and shown even when
355
373
  // permission is already granted, because it is about what the step will ask
356
374
  // of the user, not about access. Opening the camera unannounced is what makes
@@ -8,6 +8,7 @@ import { MyazaButton } from '../components/MyazaButton';
8
8
  import { MyazaPulseLoader } from '../components/MyazaPulseLoader';
9
9
  import { Icon } from '../components/Icon';
10
10
  import { cancelChipRead, nfcUnavailableReason, readPassportChip, type EmrtdReadResult } from '../emrtd';
11
+ import { fromBase64 } from '../emrtd/bytes';
11
12
  import { useNfcAvailability } from './nfc/useNfcAvailability';
12
13
  import { readErrorMessage } from './nfc/readErrorMessage';
13
14
  import { successHaptic } from '../services/haptics';
@@ -136,6 +137,18 @@ export function NfcStep(): React.ReactElement {
136
137
  stageRef.current = 'waiting';
137
138
  readingRef.current = true;
138
139
  try {
140
+ // The anti-clone challenge, asked for BEFORE the read so the whole thing
141
+ // happens in one session on the document. Best-effort by design: the
142
+ // server may not answer, and a chip read without a challenge is exactly
143
+ // the read this SDK did before Active Authentication existed. It must
144
+ // never be the reason a passport cannot be scanned.
145
+ const aaChallenge = await store
146
+ .getState()
147
+ .api.nfcChallenge()
148
+ .then((res) => ({ id: res.challengeId, bytes: fromBase64(res.challenge) }))
149
+ .catch(() => undefined);
150
+ if (!liveRef.current || seq !== readSeqRef.current) return;
151
+
139
152
  const result = await readPassportChip(
140
153
  {
141
154
  documentNumber: mrz.documentNumber,
@@ -143,6 +156,7 @@ export function NfcStep(): React.ReactElement {
143
156
  dateOfExpiry: toMrzDate(mrz.dateOfExpiry),
144
157
  },
145
158
  {
159
+ ...(aaChallenge ? { aaChallenge } : {}),
146
160
  onStage: (s) => {
147
161
  if (!liveRef.current || seq !== readSeqRef.current) return;
148
162
  stageRef.current = s;
@@ -8,6 +8,7 @@ import { MyazaButton } from '../components/MyazaButton';
8
8
  import { Icon } from '../components/Icon';
9
9
  import { MediaSourceSheet } from '../components/MediaSourceSheet';
10
10
  import { MyazaSelect } from '../components/MyazaSelect';
11
+ import { DashedBorder } from '../components/DashedBorder';
11
12
  import { withRetry } from '../services/retry';
12
13
  import { compressDocumentImage } from '../services/mediaCompress';
13
14
  import { poaDocumentTypes, poaMaxAgeDays, poaTypeLabel } from '../config/proofOfAddress';
@@ -201,11 +202,13 @@ export function ProofOfAddressStep(): React.ReactElement {
201
202
  paddingVertical: spacing.xl,
202
203
  paddingHorizontal: spacing.lg,
203
204
  borderRadius: radius.md,
204
- borderWidth: 1.5,
205
- borderStyle: 'dashed',
206
- borderColor: colors.border,
207
205
  }}
208
206
  >
207
+ <DashedBorder
208
+ color={colors.border}
209
+ radius={radius.md}
210
+ strokeWidth={1.5}
211
+ />
209
212
  {busy ? (
210
213
  <ActivityIndicator size="small" color={colors.primary} />
211
214
  ) : (
@@ -3,6 +3,7 @@ import { View } from 'react-native';
3
3
 
4
4
  import { radius, spacing } from '../config/theme';
5
5
  import { mapToKycError, safeReportError } from '../services/errors';
6
+ import { contactStepFor, expiredContactChannels } from '../lib/contact-recovery';
6
7
  import type { KYCError, KYCSubmission } from '../types/verification';
7
8
  import { useKyc, useKycConfig, useKycStore, useTheme } from '../components/runtime';
8
9
  import { MyazaText } from '../components/Typography';
@@ -11,7 +12,10 @@ import { MyazaAlert } from '../components/MyazaAlert';
11
12
  import { MyazaPulseLoader } from '../components/MyazaPulseLoader';
12
13
  import { Icon } from '../components/Icon';
13
14
  import { fillTokens } from '../utils/tokens';
14
- import { KeyPeopleInviteLinks } from './KeyPeopleInviteLinks';
15
+ import { KeepLinksSheet } from './KeepLinksSheet';
16
+ import { KeyPeopleAwaitList, rowsFromServer } from './KeyPeopleAwaitList';
17
+ import { KeyPeoplePending } from './KeyPeoplePending';
18
+ import { useAwaitingPeople } from './useAwaitingPeople';
15
19
 
16
20
  // Terminal screen — 1:1 with the Flutter SDK's SubmittedScreen. Calls
17
21
  // submitAsync on mount; renders submitting / success / error views with the same
@@ -20,8 +24,13 @@ import { KeyPeopleInviteLinks } from './KeyPeopleInviteLinks';
20
24
  type Phase = 'submitting' | 'success' | 'error';
21
25
 
22
26
  const DEFAULT_SUCCESS_TITLE = 'Verification Submitted!';
23
- const DEFAULT_SUCCESS_DESCRIPTION =
24
- "Your identity verification has been submitted for review. You'll be notified of the result.";
27
+ // The default description depends on WHAT was submitted. A KYB applicant told
28
+ // "your identity verification has been submitted" is being told about the wrong
29
+ // thing: they submitted a company. Mirrors the web SDK's successDescription.
30
+ const defaultSuccessDescription = (isBusiness: boolean): string =>
31
+ isBusiness
32
+ ? "Your business verification has been submitted for review. You'll be notified of the result."
33
+ : "Your identity verification has been submitted for review. You'll be notified of the result.";
25
34
 
26
35
  const ERROR_TITLES: Record<string, string> = {
27
36
  insufficient_credits: 'Credits Exhausted',
@@ -38,7 +47,13 @@ export function SubmittedStep({ onClose }: { onClose: () => void }): React.React
38
47
  const existingResult = useKyc((s) => s.submissionResult);
39
48
 
40
49
  const [phase, setPhase] = useState<Phase>('submitting');
50
+ // The people list comes from the SERVER once registry discovery settles —
51
+ // the submit-time invites are a first draft the register can contradict
52
+ // (it adds people the applicant never listed, including ones they removed).
53
+ const sessionId = useKyc((s) => s.sessionId);
54
+ const settled = useAwaitingPeople(store.getState().api, sessionId, phase === 'success');
41
55
  const [error, setError] = useState<KYCError | null>(null);
56
+ const [keepLinksOpen, setKeepLinksOpen] = useState(false);
42
57
  const [retry, setRetry] = useState<{ attempt: number; total: number } | null>(null);
43
58
  const reportedRef = useRef(false);
44
59
  const kickedRef = useRef(false);
@@ -51,13 +66,22 @@ export function SubmittedStep({ onClose }: { onClose: () => void }): React.React
51
66
  const result = await store.getState().submitAsync((attempt, total) => setRetry({ attempt, total }));
52
67
  const submission: KYCSubmission = {
53
68
  verificationId: result.verificationId,
54
- status: 'pending',
69
+ status: 'processing',
55
70
  metadata: config.metadata ?? {},
56
71
  submittedAt: new Date().toISOString(),
57
72
  };
58
73
  config.onSubmit?.(submission);
59
74
  setPhase('success');
60
75
  } catch (err) {
76
+ // A refusal over stale contact proofs is recoverable in-flow: clear the
77
+ // dead tokens and walk back to the contact step, which routes straight
78
+ // back here once re-verified (see lib/contact-recovery.ts).
79
+ const expired = expiredContactChannels(err);
80
+ if (expired.length > 0) {
81
+ store.getState().clearContactProofs(expired);
82
+ store.getState().goToStep(contactStepFor(expired[0]!));
83
+ return;
84
+ }
61
85
  const kycError = mapToKycError(err, 'verify');
62
86
  setError(kycError);
63
87
  if (!reportedRef.current) {
@@ -135,10 +159,22 @@ export function SubmittedStep({ onClose }: { onClose: () => void }): React.React
135
159
  const title = config.success?.title ? fillTokens(config.success.title, config.userData) : DEFAULT_SUCCESS_TITLE;
136
160
  const description = config.success?.description
137
161
  ? fillTokens(config.success.description, config.userData)
138
- : DEFAULT_SUCCESS_DESCRIPTION;
139
- // KYB: per-person verification links for full-KYC key people — rendered so
140
- // the applicant can send each one immediately.
141
- const invites = store.getState().keyPeopleInvites; // set before phase flips to success
162
+ : defaultSuccessDescription(config.subjectType === 'business');
163
+ // KYB: whether a people list is COMING (the submit minted invites). The list
164
+ // itself renders from the server's reconciled view, never from this draft.
165
+ const invitesExpected = store.getState().keyPeopleInvites.length > 0;
166
+
167
+ // People still owing a check when the applicant leaves. The settled list is
168
+ // authoritative once it arrives; before that, minted invites are the signal.
169
+ const outstanding = settled
170
+ ? settled.some((r) => r.status === 'pending' || r.status === 'failed')
171
+ : invitesExpected;
172
+ const sessionUrl = store.getState().sessionUrl;
173
+ // Tapping Done with links still live: offer the web page those links live
174
+ // on, because this screen dies with the app and the links die with it.
175
+ // Workflow opt-out: `keyPeopleLinkRecovery: false` (on by default).
176
+ const offerRecovery =
177
+ outstanding && !!sessionUrl && config.keyPeopleLinkRecovery !== false;
142
178
 
143
179
  return (
144
180
  <View style={{ flex: 1, justifyContent: 'space-between' }}>
@@ -155,9 +191,29 @@ export function SubmittedStep({ onClose }: { onClose: () => void }): React.React
155
191
  <MyazaText variant="bodyMedium" style={{ textAlign: 'center' }}>
156
192
  {description}
157
193
  </MyazaText>
158
- <KeyPeopleInviteLinks invites={invites} />
194
+ {settled
195
+ ? settled.length > 0
196
+ ? <KeyPeopleAwaitList rows={rowsFromServer(settled)} />
197
+ : null
198
+ : invitesExpected
199
+ ? <KeyPeoplePending />
200
+ : null}
159
201
  </View>
160
- <MyazaButton label="Done" onPress={onClose} />
202
+ <MyazaButton
203
+ label="Done"
204
+ onPress={() => (offerRecovery ? setKeepLinksOpen(true) : onClose())}
205
+ />
206
+ {offerRecovery ? (
207
+ <KeepLinksSheet
208
+ open={keepLinksOpen}
209
+ url={sessionUrl!}
210
+ onClose={() => setKeepLinksOpen(false)}
211
+ onDone={() => {
212
+ setKeepLinksOpen(false);
213
+ onClose();
214
+ }}
215
+ />
216
+ ) : null}
161
217
  </View>
162
218
  );
163
219
  }
@@ -30,6 +30,7 @@ const STEPS: NfcReadStage[] = [
30
30
  'readingData',
31
31
  'readingSecurity',
32
32
  'readingPhoto',
33
+ 'readingDetails',
33
34
  ];
34
35
 
35
36
  export function NfcReadProgress({ stage }: { stage: NfcReadStage }): React.ReactElement {
@@ -0,0 +1,90 @@
1
+ import { useEffect, useState } from 'react';
2
+ import { AppState } from 'react-native';
3
+ import type { KYCApi } from '../services/api';
4
+ import type { AwaitingPersonPayload } from '../services/api-types';
5
+
6
+ // The SERVER's list of who a submitted KYB application is waiting on — mirrors
7
+ // the web SDK's use-awaiting-people 1:1.
8
+ //
9
+ // MEMBERSHIP settles once: registry discovery runs after submission and can
10
+ // add people the applicant never listed, so nothing renders until the server
11
+ // says it has finished (`keyPeopleSettled`). A list shown earlier is one
12
+ // director short and would be contradicted moments later.
13
+ //
14
+ // STATUS stays live: the people named go and verify after this screen is first
15
+ // shown, so once settled the list re-reads slowly while anybody still owes a
16
+ // check — and re-reads on foreground, which is exactly when a stale badge
17
+ // would be noticed.
18
+
19
+ /** How often to re-ask while the server says it is still reconciling. */
20
+ const RETRY_MS = 1500;
21
+ /** How long to wait before showing whatever there is (discovery's final write
22
+ * is best-effort; a spinner forever is worse than a list that might be short). */
23
+ const GIVE_UP_MS = 15_000;
24
+ /** Status-refresh cadence once settled, while anybody owes a check. */
25
+ const POLL_MS = 20_000;
26
+
27
+ function anyoneStillOwes(people: AwaitingPersonPayload[]): boolean {
28
+ return people.some(
29
+ (p) => p.status === 'pending' || p.status === 'submitted' || p.status === 'failed',
30
+ );
31
+ }
32
+
33
+ export function useAwaitingPeople(
34
+ api: KYCApi,
35
+ sessionId: string | null,
36
+ enabled: boolean,
37
+ ): AwaitingPersonPayload[] | null {
38
+ const [people, setPeople] = useState<AwaitingPersonPayload[] | null>(null);
39
+
40
+ useEffect(() => {
41
+ if (!sessionId || !enabled) return undefined;
42
+ let cancelled = false;
43
+ let timer: ReturnType<typeof setTimeout> | null = null;
44
+ let committed = false;
45
+ const startedAt = Date.now();
46
+
47
+ const read = async (): Promise<void> => {
48
+ if (cancelled) return;
49
+ const summary = await api.sessionSummary(sessionId).catch(() => null);
50
+ if (cancelled) return;
51
+ const expired = Date.now() - startedAt > GIVE_UP_MS;
52
+
53
+ if (summary && (summary.keyPeopleSettled !== false || expired)) {
54
+ committed = true;
55
+ setPeople(summary.keyPeople);
56
+ if (anyoneStillOwes(summary.keyPeople)) {
57
+ timer = setTimeout(() => void read(), POLL_MS);
58
+ }
59
+ return;
60
+ }
61
+ if (committed) {
62
+ // One failed refresh must not end the updates: the list on screen is
63
+ // still true, only a little older.
64
+ timer = setTimeout(() => void read(), POLL_MS);
65
+ return;
66
+ }
67
+ if (expired) {
68
+ // Nothing readable at all — leave the list absent rather than empty,
69
+ // which would claim there is nobody to verify.
70
+ return;
71
+ }
72
+ timer = setTimeout(() => void read(), RETRY_MS);
73
+ };
74
+
75
+ const sub = AppState.addEventListener('change', (state) => {
76
+ if (state !== 'active') return;
77
+ if (timer) clearTimeout(timer);
78
+ void read();
79
+ });
80
+
81
+ void read();
82
+ return () => {
83
+ cancelled = true;
84
+ if (timer) clearTimeout(timer);
85
+ sub.remove();
86
+ };
87
+ }, [api, sessionId, enabled]);
88
+
89
+ return people;
90
+ }