@myazahq/kyc-sdk-react-native 2.4.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.
- package/android/build.gradle +35 -3
- package/android/src/main/java/com/margelo/nitro/myazakyc/HybridMyazaFaceDetector.kt +75 -0
- package/ios/HybridMyazaFaceDetector.swift +8 -0
- package/nitrogen/generated/android/c++/JHybridMyazaFaceDetectorSpec.cpp +23 -0
- package/nitrogen/generated/android/c++/JHybridMyazaFaceDetectorSpec.hpp +2 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/myazakyc/HybridMyazaFaceDetectorSpec.kt +9 -0
- package/nitrogen/generated/ios/KycSdkReactNative-Swift-Cxx-Bridge.cpp +8 -0
- package/nitrogen/generated/ios/KycSdkReactNative-Swift-Cxx-Bridge.hpp +43 -0
- package/nitrogen/generated/ios/c++/HybridMyazaFaceDetectorSpecSwift.hpp +17 -0
- package/nitrogen/generated/ios/swift/Func_void_bool.swift +46 -0
- package/nitrogen/generated/ios/swift/HybridMyazaFaceDetectorSpec.swift +2 -0
- package/nitrogen/generated/ios/swift/HybridMyazaFaceDetectorSpec_cxx.swift +31 -0
- package/nitrogen/generated/shared/c++/HybridMyazaFaceDetectorSpec.cpp +2 -0
- package/nitrogen/generated/shared/c++/HybridMyazaFaceDetectorSpec.hpp +3 -0
- package/package.json +1 -1
- package/src/MyazaKYC.tsx +12 -0
- package/src/components/DashedBorder.tsx +55 -0
- package/src/components/DialCodePicker.tsx +14 -7
- package/src/components/Icon.tsx +9 -0
- package/src/components/MultiIdProgress.tsx +107 -0
- package/src/components/MyazaInput.tsx +23 -4
- package/src/components/MyazaSelect.tsx +2 -1
- package/src/components/OwnershipSlider.tsx +102 -0
- package/src/components/PhoneNumberInput.tsx +37 -4
- package/src/components/StaggerIn.tsx +63 -0
- package/src/components/StepView.tsx +26 -0
- package/src/components/glass/FloatingSheet.tsx +73 -21
- package/src/components/glass/GlassSheet.tsx +14 -2
- package/src/components/stepHeaderMeta.tsx +3 -1
- package/src/config/business.ts +6 -6
- package/src/config/businessDetailsValidity.ts +52 -0
- package/src/config/businessPrefill.ts +72 -0
- package/src/config/businessSteps.ts +14 -3
- package/src/config/keyPeople.ts +111 -7
- package/src/config/keyPeopleCorporate.ts +19 -0
- package/src/config/keyPeopleOwnerTypes.ts +12 -0
- package/src/config/keyPeopleOwners.ts +26 -0
- package/src/config/keyPeoplePrefill.ts +138 -0
- package/src/config/keyPeopleSectionDefs.ts +65 -0
- package/src/config/keyPeopleSections.ts +167 -0
- package/src/config/registrationHint.ts +1 -1
- package/src/config/stepOrder.ts +24 -4
- package/src/config/theme.ts +8 -2
- package/src/config/website.ts +36 -0
- package/src/config/workflowMerge.ts +5 -0
- package/src/emrtd/activeAuth.ts +100 -0
- package/src/emrtd/extras.ts +76 -0
- package/src/emrtd/files.ts +5 -0
- package/src/emrtd/index.ts +1 -0
- package/src/emrtd/read.ts +8 -1
- package/src/emrtd/session.ts +41 -0
- package/src/emrtd/stages.ts +11 -0
- package/src/lib/contact-recovery.ts +44 -0
- package/src/lib/multi-id.ts +210 -0
- package/src/lib/resubmit.ts +130 -0
- package/src/lib/screen-corners.ts +50 -0
- package/src/lib/step-log.ts +24 -3
- package/src/lib/use-multi-id-plan.ts +29 -0
- package/src/liveness/useModelReady.ts +65 -0
- package/src/liveness/visionCameraFaceDetector.ts +35 -0
- package/src/screens/ApplicantRoleStep.tsx +9 -5
- package/src/screens/BusinessCheckPanel.tsx +89 -0
- package/src/screens/BusinessDetailsFields.tsx +109 -0
- package/src/screens/BusinessDetailsStep.tsx +131 -145
- package/src/screens/BusinessDocumentSlot.tsx +2 -3
- package/src/screens/BusinessDocumentsStep.tsx +1 -1
- package/src/screens/BusinessKeyPeopleStep.tsx +103 -71
- package/src/screens/BusinessPickedCard.tsx +86 -0
- package/src/screens/BusinessPickedSection.tsx +51 -0
- package/src/screens/BusinessRegistryPickers.tsx +67 -0
- package/src/screens/BusinessSandboxToggle.tsx +114 -0
- package/src/screens/BusinessSearch.tsx +199 -0
- package/src/screens/BusinessSearchResults.tsx +115 -0
- package/src/screens/CompanyInfoFields.tsx +86 -51
- package/src/screens/ConsentStep.tsx +31 -0
- package/src/screens/ContactDestinationField.tsx +1 -0
- package/src/screens/ContactVerificationStep.tsx +34 -3
- package/src/screens/IdTypeStep.tsx +12 -5
- package/src/screens/KeepLinksSheet.tsx +88 -0
- package/src/screens/KeyPeopleAwaitCard.tsx +204 -0
- package/src/screens/KeyPeopleAwaitList.tsx +126 -0
- package/src/screens/KeyPeoplePending.tsx +154 -0
- package/src/screens/KeyPeopleSection.tsx +162 -0
- package/src/screens/KeyPeopleSectionsList.tsx +103 -0
- package/src/screens/KeyPeopleUboExemption.tsx +72 -0
- package/src/screens/KeyPersonCard.tsx +36 -4
- package/src/screens/KeyPersonForm.tsx +195 -36
- package/src/screens/KeyPersonKindToggle.tsx +68 -0
- package/src/screens/KeyPersonOwners.tsx +109 -0
- package/src/screens/KeyPersonRoleChips.tsx +68 -0
- package/src/screens/KeyPersonSheet.tsx +92 -108
- package/src/screens/LivenessStep.tsx +18 -0
- package/src/screens/NfcStep.tsx +14 -0
- package/src/screens/ProofOfAddressStep.tsx +6 -3
- package/src/screens/SubmittedStep.tsx +66 -10
- package/src/screens/nfc/NfcReadProgress.tsx +1 -0
- package/src/screens/useAwaitingPeople.ts +90 -0
- package/src/services/api-types.ts +158 -2
- package/src/services/api-verify-types.ts +53 -1
- package/src/services/api.ts +104 -0
- package/src/services/deviceMetadata.ts +1 -1
- package/src/services/errors.ts +10 -2
- package/src/services/workflowGate.ts +1 -0
- package/src/specs/MyazaFaceDetector.nitro.ts +29 -0
- package/src/store/businessCheck.ts +113 -0
- package/src/store/derive.ts +15 -0
- package/src/store/kycStore.ts +241 -14
- package/src/store/serverConfig.ts +2 -0
- package/src/store/session.ts +208 -0
- package/src/store/state.ts +155 -2
- package/src/store/submit.ts +63 -13
- package/src/store/submitApplicant.ts +5 -11
- package/src/types/business.ts +23 -2
- package/src/types/config.ts +22 -1
- package/src/types/verification.ts +1 -1
- package/src/types/workflow.ts +15 -0
- 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 {
|
|
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 {
|
|
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 —
|
|
23
|
-
//
|
|
24
|
-
//
|
|
25
|
-
//
|
|
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
|
|
32
|
-
//
|
|
33
|
-
//
|
|
34
|
-
//
|
|
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)}
|
|
97
|
+
? `Combined ownership would be ${fmtPct(combinedTotal)}%, over by ${fmtPct(combinedTotal - 100)}%.`
|
|
86
98
|
: null;
|
|
87
99
|
|
|
88
|
-
const canSave =
|
|
100
|
+
const canSave =
|
|
101
|
+
isKeyPersonRowValid(draft, emailRequiredFor ?? new Set()) && draft.name.trim().length >= 2;
|
|
89
102
|
|
|
90
103
|
return (
|
|
91
|
-
<
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
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
|
-
|
|
100
|
-
|
|
101
|
-
|
|
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
|
-
|
|
105
|
-
|
|
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
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
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
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
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
|
package/src/screens/NfcStep.tsx
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
24
|
-
|
|
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: '
|
|
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
|
-
:
|
|
139
|
-
// KYB:
|
|
140
|
-
// the
|
|
141
|
-
const
|
|
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
|
-
|
|
194
|
+
{settled
|
|
195
|
+
? settled.length > 0
|
|
196
|
+
? <KeyPeopleAwaitList rows={rowsFromServer(settled)} />
|
|
197
|
+
: null
|
|
198
|
+
: invitesExpected
|
|
199
|
+
? <KeyPeoplePending />
|
|
200
|
+
: null}
|
|
159
201
|
</View>
|
|
160
|
-
<MyazaButton
|
|
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
|
}
|
|
@@ -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
|
+
}
|