@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
package/src/emrtd/stages.ts
CHANGED
|
@@ -23,6 +23,8 @@ export type NfcReadStage =
|
|
|
23
23
|
| 'readingSecurity'
|
|
24
24
|
/** Reading DG2 — the chip's portrait. */
|
|
25
25
|
| 'readingPhoto'
|
|
26
|
+
/** Reading the optional detail groups (DG7/DG11/DG12) — small and quick. */
|
|
27
|
+
| 'readingDetails'
|
|
26
28
|
/** Everything that could be read has been. */
|
|
27
29
|
| 'done';
|
|
28
30
|
|
|
@@ -33,6 +35,7 @@ export const NFC_STAGE_ORDER: NfcReadStage[] = [
|
|
|
33
35
|
'readingData',
|
|
34
36
|
'readingSecurity',
|
|
35
37
|
'readingPhoto',
|
|
38
|
+
'readingDetails',
|
|
36
39
|
'done',
|
|
37
40
|
];
|
|
38
41
|
|
|
@@ -49,6 +52,8 @@ export function nfcStageLabel(stage: NfcReadStage): string {
|
|
|
49
52
|
return 'Reading security data';
|
|
50
53
|
case 'readingPhoto':
|
|
51
54
|
return 'Reading photo';
|
|
55
|
+
case 'readingDetails':
|
|
56
|
+
return 'Reading extra details';
|
|
52
57
|
case 'done':
|
|
53
58
|
return 'Chip read complete';
|
|
54
59
|
}
|
|
@@ -73,6 +78,8 @@ export function nfcStageDetail(stage: NfcReadStage): string {
|
|
|
73
78
|
return 'Downloading the chip’s digital signature. This is the largest part and takes the longest — keep holding.';
|
|
74
79
|
case 'readingPhoto':
|
|
75
80
|
return 'Copying the photo stored on the chip.';
|
|
81
|
+
case 'readingDetails':
|
|
82
|
+
return 'Copying the optional details stored on the chip. These are small and quick.';
|
|
76
83
|
case 'done':
|
|
77
84
|
return 'Everything was read successfully.';
|
|
78
85
|
}
|
|
@@ -98,6 +105,8 @@ export function nfcSheetMessage(stage: NfcReadStage): string {
|
|
|
98
105
|
return 'Verifying security data — keep holding…';
|
|
99
106
|
case 'readingPhoto':
|
|
100
107
|
return 'Reading your photo — keep holding…';
|
|
108
|
+
case 'readingDetails':
|
|
109
|
+
return 'Almost done — keep holding…';
|
|
101
110
|
case 'done':
|
|
102
111
|
return 'Chip read complete';
|
|
103
112
|
}
|
|
@@ -116,6 +125,8 @@ export function nfcStageProgress(stage: NfcReadStage): number {
|
|
|
116
125
|
return 0.6;
|
|
117
126
|
case 'readingPhoto':
|
|
118
127
|
return 0.85;
|
|
128
|
+
case 'readingDetails':
|
|
129
|
+
return 0.95;
|
|
119
130
|
case 'done':
|
|
120
131
|
return 1;
|
|
121
132
|
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
// Recovery from a submit refused over contact proofs. Mirrors the web SDK's
|
|
2
|
+
// steps/contact-recovery.ts — keep the two in lockstep.
|
|
3
|
+
//
|
|
4
|
+
// Contact proof tokens are SINGLE-USE and expire ~30 minutes after the OTP is
|
|
5
|
+
// checked, but they ride session progress and are restored on resume. A
|
|
6
|
+
// resumed attempt therefore carries a proof the server will (rightly)
|
|
7
|
+
// validate-and-drop at submit, while the contact step still shows "verified"
|
|
8
|
+
// — and a plain retry resubmits the same dead token forever.
|
|
9
|
+
//
|
|
10
|
+
// The 422 is recoverable in-flow: clear the stale proofs, walk the person
|
|
11
|
+
// back to the contact step (their data is untouched), and once re-verified
|
|
12
|
+
// the step routes straight back to 'submitted', which auto-submits with the
|
|
13
|
+
// fresh token.
|
|
14
|
+
import { KYCApiError } from '../services/api';
|
|
15
|
+
import type { KYCStep } from '../types/config';
|
|
16
|
+
|
|
17
|
+
export type ContactChannel = 'email' | 'phone';
|
|
18
|
+
|
|
19
|
+
/** The channels a 422 `contact_verification_required` names as missing. */
|
|
20
|
+
export function expiredContactChannels(err: unknown): ContactChannel[] {
|
|
21
|
+
if (!(err instanceof KYCApiError) || err.code !== 'contact_verification_required') return [];
|
|
22
|
+
const missing = Array.isArray(err.body?.missing) ? (err.body.missing as unknown[]) : [];
|
|
23
|
+
return missing.filter((c): c is ContactChannel => c === 'email' || c === 'phone');
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function contactStepFor(channel: ContactChannel): KYCStep {
|
|
27
|
+
return channel === 'email' ? 'email-verification' : 'phone-verification';
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Where the step routes once this channel is verified. Normally the ordinary
|
|
32
|
+
* forward step; in recovery it returns to 'submitted' (which auto-submits
|
|
33
|
+
* with the fresh proof) via any OTHER still-refused channel first — the
|
|
34
|
+
* person never re-walks steps they already completed.
|
|
35
|
+
*/
|
|
36
|
+
export function stepAfterContactVerified(opts: {
|
|
37
|
+
recovery: boolean;
|
|
38
|
+
expired: ContactChannel[];
|
|
39
|
+
channel: ContactChannel;
|
|
40
|
+
}): KYCStep | null {
|
|
41
|
+
if (!opts.recovery) return null;
|
|
42
|
+
const remaining = opts.expired.find((c) => c !== opts.channel);
|
|
43
|
+
return remaining ? contactStepFor(remaining) : 'submitted';
|
|
44
|
+
}
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
// Multi-ID flows on React Native. A PORT of the web SDK's lib/multi-id.ts,
|
|
2
|
+
// kept byte-for-byte in its logic: the server validates the pick sequence the
|
|
3
|
+
// client produced, so a client that computes options differently produces
|
|
4
|
+
// submissions the server rejects. If you change a rule here, change it in
|
|
5
|
+
// kyc-sdk-react/src/lib/multi-id.ts, kyc-core/src/lib/multi-id.ts and the
|
|
6
|
+
// dashboard's required-ids-model.ts in the same commit.
|
|
7
|
+
//
|
|
8
|
+
// Multi-ID flows: a workflow's `multiId` block asks for SEVERAL ID checks in
|
|
9
|
+
// one run — the applicant picks each slot's ID from what the admin allowed for
|
|
10
|
+
// THEIR country (a picked ID disappears from later slots), ONE selfie covers
|
|
11
|
+
// the whole run, and everything submits as ONE verification the server judges
|
|
12
|
+
// by the pass policy.
|
|
13
|
+
//
|
|
14
|
+
// Multi-region works: the country-select step comes first as usual, and the
|
|
15
|
+
// run then walks THAT country's slots (`countries[].multiIdSlots`). The pure
|
|
16
|
+
// helpers here MIRROR the server's lib/multi-id.ts exactly — the safe-options
|
|
17
|
+
// rule is what keeps an applicant from ever being offered a pick that would
|
|
18
|
+
// strand a later slot, and the server validates the same sequence.
|
|
19
|
+
|
|
20
|
+
export interface MultiIdConfig {
|
|
21
|
+
count: number;
|
|
22
|
+
minPassed: number;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface MultiIdCountry {
|
|
26
|
+
country: string;
|
|
27
|
+
idTypes?: string[];
|
|
28
|
+
/** This country's per-verification ID allowlists. */
|
|
29
|
+
multiIdSlots?: Array<{ idTypes?: string[] }>;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
interface MultiIdSourceConfig {
|
|
33
|
+
multiId?: MultiIdConfig;
|
|
34
|
+
subjectType?: string;
|
|
35
|
+
/** The EFFECTIVE country (post country-select) — whose slots the run walks. */
|
|
36
|
+
country?: string;
|
|
37
|
+
countries?: MultiIdCountry[];
|
|
38
|
+
idTypes?: string[];
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** The workflow's multiId POLICY — null for ordinary flows and every KYB flow. */
|
|
42
|
+
export function multiIdConfigFrom(config: MultiIdSourceConfig): MultiIdConfig | null {
|
|
43
|
+
if (config.subjectType === 'business') return null;
|
|
44
|
+
const block = config.multiId;
|
|
45
|
+
if (!block || typeof block.count !== 'number') return null;
|
|
46
|
+
const count = Math.trunc(block.count);
|
|
47
|
+
// REJECT an out-of-range count rather than clamping it. The server returns
|
|
48
|
+
// null here, so clamping made the client walk 3 checks for a config the
|
|
49
|
+
// server does not consider multi-ID at all — and clamping `minPassed`
|
|
50
|
+
// against the RAW count could ask for 9 of 3 to pass, which nothing can
|
|
51
|
+
// satisfy. Same rule, both sides, or the client builds submissions the
|
|
52
|
+
// server rejects.
|
|
53
|
+
if (!Number.isInteger(count) || count < 2 || count > 3) return null;
|
|
54
|
+
return {
|
|
55
|
+
count,
|
|
56
|
+
minPassed:
|
|
57
|
+
typeof block.minPassed === 'number' && Number.isInteger(block.minPassed)
|
|
58
|
+
? Math.min(Math.max(block.minPassed, 1), count)
|
|
59
|
+
: count,
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** The country entry the run walks: the EFFECTIVE country's, else the legacy
|
|
64
|
+
* single-country shape. */
|
|
65
|
+
export function multiIdCountryEntry(config: MultiIdSourceConfig): MultiIdCountry | null {
|
|
66
|
+
const country = config.country;
|
|
67
|
+
if (config.countries && config.countries.length > 0) {
|
|
68
|
+
return config.countries.find((c) => c.country === country) ?? null;
|
|
69
|
+
}
|
|
70
|
+
return country ? { country, idTypes: config.idTypes } : null;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* The ID types the picked country offers: its pinned list, else everything the
|
|
75
|
+
* server granted there.
|
|
76
|
+
*/
|
|
77
|
+
export function multiIdOfferedTypes(
|
|
78
|
+
config: MultiIdSourceConfig,
|
|
79
|
+
serverIdTypes: Array<{ country: string; idType: string }>,
|
|
80
|
+
): string[] {
|
|
81
|
+
const entry = multiIdCountryEntry(config);
|
|
82
|
+
if (!entry) return [];
|
|
83
|
+
if (entry.idTypes && entry.idTypes.length > 0) return entry.idTypes;
|
|
84
|
+
return serverIdTypes.filter((row) => row.country === entry.country).map((row) => row.idType);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Per-slot option lists (a pinned slot keeps its list; others offer everything). */
|
|
88
|
+
export function multiIdSlotOptions(
|
|
89
|
+
count: number,
|
|
90
|
+
slots: Array<{ idTypes?: string[] }> | undefined,
|
|
91
|
+
offered: string[],
|
|
92
|
+
): string[][] {
|
|
93
|
+
const offeredSet = new Set(offered);
|
|
94
|
+
return Array.from({ length: count }, (_, i) => {
|
|
95
|
+
const pinned = slots?.[i]?.idTypes;
|
|
96
|
+
const base = pinned && pinned.length > 0 ? pinned : offered;
|
|
97
|
+
return base.filter((t) => offeredSet.has(t));
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** The first reachable dead-end across pick orders, or null. */
|
|
102
|
+
export function multiIdFirstDeadEnd(
|
|
103
|
+
slotOptions: string[][],
|
|
104
|
+
): { picks: string[]; slotIndex: number } | null {
|
|
105
|
+
const walk = (index: number, picked: string[]): { picks: string[]; slotIndex: number } | null => {
|
|
106
|
+
if (index >= slotOptions.length) return null;
|
|
107
|
+
const available = slotOptions[index]!.filter((t) => !picked.includes(t));
|
|
108
|
+
if (available.length === 0) return { picks: picked, slotIndex: index };
|
|
109
|
+
for (const pick of available) {
|
|
110
|
+
const deadEnd = walk(index + 1, [...picked, pick]);
|
|
111
|
+
if (deadEnd) return deadEnd;
|
|
112
|
+
}
|
|
113
|
+
return null;
|
|
114
|
+
};
|
|
115
|
+
return walk(0, []);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** The picks a slot may SAFELY offer: unused AND non-stranding. */
|
|
119
|
+
export function multiIdSafeOptions(
|
|
120
|
+
slotOptions: string[][],
|
|
121
|
+
slotIndex: number,
|
|
122
|
+
picked: string[],
|
|
123
|
+
): string[] {
|
|
124
|
+
const remaining = slotOptions.slice(slotIndex + 1);
|
|
125
|
+
return (slotOptions[slotIndex] ?? [])
|
|
126
|
+
.filter((t) => !picked.includes(t))
|
|
127
|
+
.filter(
|
|
128
|
+
(t) =>
|
|
129
|
+
multiIdFirstDeadEnd(
|
|
130
|
+
remaining.map((opts) => opts.filter((o) => o !== t && !picked.includes(o))),
|
|
131
|
+
) === null,
|
|
132
|
+
);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export interface MultiIdPlan {
|
|
136
|
+
count: number;
|
|
137
|
+
minPassed: number;
|
|
138
|
+
/** Which slot is being walked (clamped; equals count once every slot committed). */
|
|
139
|
+
index: number;
|
|
140
|
+
/** The current slot is the final one. */
|
|
141
|
+
last: boolean;
|
|
142
|
+
/** ID types committed so far, in order. */
|
|
143
|
+
picked: string[];
|
|
144
|
+
/** What the CURRENT slot's picker may offer. */
|
|
145
|
+
safeOptions: string[];
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** The active plan, or null for ordinary flows. */
|
|
149
|
+
export function multiIdPlan(
|
|
150
|
+
config: MultiIdSourceConfig,
|
|
151
|
+
state: { multiIdSlotIndex: number; multiIdSlots: Array<{ idType: string }> },
|
|
152
|
+
serverIdTypes: Array<{ country: string; idType: string }>,
|
|
153
|
+
): MultiIdPlan | null {
|
|
154
|
+
const cfg = multiIdConfigFrom(config);
|
|
155
|
+
if (!cfg) return null;
|
|
156
|
+
const offered = multiIdOfferedTypes(config, serverIdTypes);
|
|
157
|
+
const options = multiIdSlotOptions(cfg.count, multiIdCountryEntry(config)?.multiIdSlots, offered);
|
|
158
|
+
const picked = state.multiIdSlots.map((s) => s.idType);
|
|
159
|
+
const index = Math.min(Math.max(state.multiIdSlotIndex, 0), cfg.count);
|
|
160
|
+
return {
|
|
161
|
+
count: cfg.count,
|
|
162
|
+
minPassed: cfg.minPassed,
|
|
163
|
+
index,
|
|
164
|
+
last: index >= cfg.count - 1,
|
|
165
|
+
picked,
|
|
166
|
+
safeOptions: index < cfg.count ? multiIdSafeOptions(options, index, picked) : [],
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** Which evidence step one slot's ID opens on. */
|
|
171
|
+
export function multiIdEvidenceStep(
|
|
172
|
+
def: { requiresDocumentCapture: boolean } | null | undefined,
|
|
173
|
+
): 'id-input' | 'document-capture' {
|
|
174
|
+
return def && def.requiresDocumentCapture === false ? 'id-input' : 'document-capture';
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* The committed slots as the WIRE sees them — an explicit whitelist, because
|
|
179
|
+
* the slots also carry local preview images for the back journey and those must
|
|
180
|
+
* never reach the submission or the session-progress blob. Whitelist, not
|
|
181
|
+
* spread: a field added to the slot later cannot leak by default.
|
|
182
|
+
*/
|
|
183
|
+
export function multiIdWireSlots(
|
|
184
|
+
slots: Array<{
|
|
185
|
+
idType: string;
|
|
186
|
+
idNumber?: string;
|
|
187
|
+
documentFront?: string;
|
|
188
|
+
documentBack?: string;
|
|
189
|
+
documentFrontVideo?: string;
|
|
190
|
+
documentBackVideo?: string;
|
|
191
|
+
}>,
|
|
192
|
+
): Array<{
|
|
193
|
+
idType: string;
|
|
194
|
+
idNumber?: string;
|
|
195
|
+
documentFront?: string;
|
|
196
|
+
documentBack?: string;
|
|
197
|
+
documentFrontVideo?: string;
|
|
198
|
+
documentBackVideo?: string;
|
|
199
|
+
}> {
|
|
200
|
+
// The chip is attached by the caller after this whitelist (it is a payload,
|
|
201
|
+
// not a mediaId), so it is absent here and not stripped.
|
|
202
|
+
return slots.map((s) => ({
|
|
203
|
+
idType: s.idType,
|
|
204
|
+
...(s.idNumber ? { idNumber: s.idNumber } : {}),
|
|
205
|
+
...(s.documentFront ? { documentFront: s.documentFront } : {}),
|
|
206
|
+
...(s.documentBack ? { documentBack: s.documentBack } : {}),
|
|
207
|
+
...(s.documentFrontVideo ? { documentFrontVideo: s.documentFrontVideo } : {}),
|
|
208
|
+
...(s.documentBackVideo ? { documentBackVideo: s.documentBackVideo } : {}),
|
|
209
|
+
}));
|
|
210
|
+
}
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
// Narrowing a flow to the steps a reviewer asked the applicant to redo.
|
|
2
|
+
//
|
|
3
|
+
// A reviewer looked at a finished verification and sent it back — usually
|
|
4
|
+
// because one thing was unreadable, not because everything was wrong. Making
|
|
5
|
+
// somebody retake a passport photo is reasonable; making them redo consent, the
|
|
6
|
+
// ID picker, liveness and a questionnaire to fix that photo is how you lose them.
|
|
7
|
+
//
|
|
8
|
+
// The instruction rides the session's config snapshot (`config.resubmit`), which
|
|
9
|
+
// is how every other per-session flow instruction travels. An SDK that predates
|
|
10
|
+
// this simply does not read the key and runs the whole flow, which is the safe
|
|
11
|
+
// degradation: asking for too much is recoverable, silently skipping a step the
|
|
12
|
+
// reviewer wanted is not.
|
|
13
|
+
import type { KYCStep } from '../types/config';
|
|
14
|
+
|
|
15
|
+
export interface ResubmitConfig {
|
|
16
|
+
/** Steps to redo, in flow order. Never empty — the server omits the key instead. */
|
|
17
|
+
steps: string[];
|
|
18
|
+
/** Reviewer's note to the applicant. */
|
|
19
|
+
message?: string | null;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Steps that are always kept, whatever the reviewer ticked.
|
|
24
|
+
*
|
|
25
|
+
* `consent` because a flow with no first screen is disorienting, and it is where
|
|
26
|
+
* the redo is explained. `submitted` because a flow has to end somewhere.
|
|
27
|
+
* Neither is something the applicant is being asked to redo — they are the
|
|
28
|
+
* frame around what is.
|
|
29
|
+
*/
|
|
30
|
+
const ALWAYS: KYCStep[] = ['consent', 'submitted'];
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* One ID's evidence — a FAMILY, not alternatives a reviewer picks between.
|
|
34
|
+
*
|
|
35
|
+
* Which member a flow contains depends on the ID type: a number-only ID has
|
|
36
|
+
* `id-input`, a document ID has `document-capture` (and maybe `nfc`). At the
|
|
37
|
+
* moment the order is built nobody has chosen one yet, so the flow is shaped by
|
|
38
|
+
* a default. A plan naming `id-input` against an order still shaped for
|
|
39
|
+
* `document-capture` therefore matched NOTHING, fell through to the safety net
|
|
40
|
+
* below, and silently ran the entire flow — which is exactly the bug a reviewer
|
|
41
|
+
* sees as "I asked for the ID and it made them do everything again".
|
|
42
|
+
*
|
|
43
|
+
* Asking for one member asks for whichever this flow turns out to have.
|
|
44
|
+
*/
|
|
45
|
+
const EVIDENCE: KYCStep[] = ['id-input', 'document-capture', 'nfc'];
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Steps a narrowed flow keeps regardless, because without them it cannot
|
|
49
|
+
* produce a submission at all.
|
|
50
|
+
*
|
|
51
|
+
* A resubmission is a NEW verification on a FRESH session: nothing is carried
|
|
52
|
+
* forward from the one being redone, so the applicant must still say which ID
|
|
53
|
+
* this is and supply it. `POST /verify` requires an `idType`, and a number-only
|
|
54
|
+
* ID requires the number with it.
|
|
55
|
+
*
|
|
56
|
+
* So narrowing removes the things arranged AROUND the identity — liveness,
|
|
57
|
+
* proof of address, the questionnaire, contact checks — and never the identity
|
|
58
|
+
* itself. The alternative is a two-screen flow that collects a photo and then
|
|
59
|
+
* fails to submit, which is worse for the applicant than being asked for one
|
|
60
|
+
* extra screen.
|
|
61
|
+
*/
|
|
62
|
+
const INDIVIDUAL_REQUIRED: KYCStep[] = ['id-type', ...EVIDENCE];
|
|
63
|
+
const BUSINESS_REQUIRED: KYCStep[] = ['business-details'];
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Narrow a full step order to the redo, preserving flow order.
|
|
67
|
+
*
|
|
68
|
+
* Order comes from `order`, never from the reviewer's list: they ticked
|
|
69
|
+
* checkboxes, and walking somebody through liveness before document capture
|
|
70
|
+
* because that is the order the boxes were ticked in would be nonsense.
|
|
71
|
+
*
|
|
72
|
+
* Returns the ORIGINAL order untouched when the instruction is absent, empty, or
|
|
73
|
+
* matches nothing we know. That last case matters: a server that learns a new
|
|
74
|
+
* step name before this SDK does must not produce a two-screen flow that
|
|
75
|
+
* collects nothing.
|
|
76
|
+
*/
|
|
77
|
+
export function applyResubmitSteps(
|
|
78
|
+
order: KYCStep[],
|
|
79
|
+
resubmit: ResubmitConfig | undefined | null,
|
|
80
|
+
): KYCStep[] {
|
|
81
|
+
const asked = resubmit?.steps;
|
|
82
|
+
if (!asked?.length) return order;
|
|
83
|
+
|
|
84
|
+
const wantsEvidence = asked.some((step) => EVIDENCE.includes(step as KYCStep));
|
|
85
|
+
|
|
86
|
+
// Does the instruction name anything this flow actually has? If not, it came
|
|
87
|
+
// from a server that knows a step name this SDK does not — and narrowing on it
|
|
88
|
+
// would quietly drop whatever was really asked for. Checked BEFORE the
|
|
89
|
+
// required steps are added, or those alone would make every unknown plan look
|
|
90
|
+
// recognised and turn the safety net below into dead code.
|
|
91
|
+
const recognised = wantsEvidence || asked.some((step) => order.includes(step as KYCStep));
|
|
92
|
+
if (!recognised) return order;
|
|
93
|
+
|
|
94
|
+
const wanted = new Set<string>(asked);
|
|
95
|
+
if (wantsEvidence) for (const step of EVIDENCE) wanted.add(step);
|
|
96
|
+
for (const step of order.includes('business-details') ? BUSINESS_REQUIRED : INDIVIDUAL_REQUIRED) {
|
|
97
|
+
wanted.add(step);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const narrowed = order.filter((step) => wanted.has(step) || ALWAYS.includes(step));
|
|
101
|
+
|
|
102
|
+
// Nothing but the frame survived, so the instruction named steps this flow
|
|
103
|
+
// does not contain. Run everything rather than nothing.
|
|
104
|
+
const collects = narrowed.some((step) => !ALWAYS.includes(step));
|
|
105
|
+
return collects ? narrowed : order;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** Whether this mount is a targeted redo, for the wording on screen. */
|
|
109
|
+
export function isResubmission(resubmit: ResubmitConfig | undefined | null): boolean {
|
|
110
|
+
return Boolean(resubmit?.steps?.length);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* The reviewer's note to the applicant, when this mount is a targeted redo.
|
|
115
|
+
*
|
|
116
|
+
* The dashboard's send-back dialog asks a reviewer to explain the problem — its
|
|
117
|
+
* placeholder is literally "Your document photo was too dark to read, please
|
|
118
|
+
* retake it in good light." The server stamps that note onto the session and
|
|
119
|
+
* every SDK parses it into config, and until now NONE of them displayed it. So
|
|
120
|
+
* a send-back reached the applicant as a flow that had silently lost most of
|
|
121
|
+
* its steps, with nothing saying why they were back or what to do differently.
|
|
122
|
+
*
|
|
123
|
+
* Returns null when there is nothing to show, so a caller can render this
|
|
124
|
+
* unconditionally.
|
|
125
|
+
*/
|
|
126
|
+
export function resubmitNote(resubmit: ResubmitConfig | undefined | null): string | null {
|
|
127
|
+
if (!isResubmission(resubmit)) return null;
|
|
128
|
+
const note = resubmit?.message?.trim();
|
|
129
|
+
return note && note.length > 0 ? note : null;
|
|
130
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { Dimensions, Platform } from 'react-native';
|
|
2
|
+
import { initialWindowMetrics } from 'react-native-safe-area-context';
|
|
3
|
+
|
|
4
|
+
// Apple's grammar for nested corners is CONCENTRIC: an inset card's radius is
|
|
5
|
+
// the DISPLAY's corner radius minus the gap between card and screen edge —
|
|
6
|
+
// that is why the system share sheet's curve runs parallel to the phone's.
|
|
7
|
+
// No public API exposes the display radius, so iOS resolves it from the
|
|
8
|
+
// model's logical screen size (the per-device values Apple ships, from
|
|
9
|
+
// UIScreen's private _displayCornerRadius, in points). Unknown hardware falls
|
|
10
|
+
// back on whether the display is rounded at all: home-indicator devices are,
|
|
11
|
+
// square-corner devices are not.
|
|
12
|
+
|
|
13
|
+
/** `short x long` logical points → display corner radius in points. */
|
|
14
|
+
const IOS_RADII: Record<string, number> = {
|
|
15
|
+
// X / XS / 11 Pro are 39 on the same canvas as the 12–13 mini's 44; the
|
|
16
|
+
// minis are the ones still in pockets, so the shared key takes their value.
|
|
17
|
+
'375x812': 44,
|
|
18
|
+
'414x896': 41.5, // XR / 11 / XS Max / 11 Pro Max
|
|
19
|
+
'390x844': 47.33, // 12 / 12 Pro / 13 / 13 Pro / 14
|
|
20
|
+
'428x926': 53.33, // 12–13 Pro Max / 14 Plus
|
|
21
|
+
'393x852': 55, // 14 Pro / 15 / 15 Pro / 16 / 16e
|
|
22
|
+
'430x932': 55, // 14 Pro Max / 15 Plus / 15 Pro Max / 16 Plus
|
|
23
|
+
'402x874': 62, // 16 Pro
|
|
24
|
+
'440x956': 62, // 16 Pro Max
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* The display's own corner radius, in points/dp. 0 means a square-cornered
|
|
29
|
+
* screen (or one we cannot judge), and the caller picks its flat-world look.
|
|
30
|
+
*/
|
|
31
|
+
export function displayCornerRadius(): number {
|
|
32
|
+
const { width, height } = Dimensions.get('screen');
|
|
33
|
+
const key = `${Math.round(Math.min(width, height))}x${Math.round(Math.max(width, height))}`;
|
|
34
|
+
const bottomInset = initialWindowMetrics?.insets?.bottom ?? 0;
|
|
35
|
+
|
|
36
|
+
if (Platform.OS === 'ios') {
|
|
37
|
+
const known = IOS_RADII[key];
|
|
38
|
+
if (known != null) return known;
|
|
39
|
+
// Unlisted model: a home indicator means a rounded display; 47 is the
|
|
40
|
+
// middle of the modern range.
|
|
41
|
+
return bottomInset > 0 ? 47 : 0;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Android has no cheap signal (the RoundedCorner API needs native code).
|
|
45
|
+
// A small bottom inset is gesture navigation — overwhelmingly the modern,
|
|
46
|
+
// rounded-display phones — while ~48dp is the opaque 3-button bar, which
|
|
47
|
+
// says nothing. Modest on purpose: an under-estimate still looks composed,
|
|
48
|
+
// an over-estimate looks like a mistake.
|
|
49
|
+
return bottomInset > 0 && bottomInset <= 32 ? 32 : 0;
|
|
50
|
+
}
|
package/src/lib/step-log.ts
CHANGED
|
@@ -11,6 +11,20 @@
|
|
|
11
11
|
export interface StepLogEntry {
|
|
12
12
|
step: string;
|
|
13
13
|
at: string;
|
|
14
|
+
/**
|
|
15
|
+
* Which multi-ID check the applicant was on (1-based). Absent on ordinary
|
|
16
|
+
* runs and on the first check.
|
|
17
|
+
*
|
|
18
|
+
* The SERVER cannot tell a slot advance from a back-press: a multi-ID run
|
|
19
|
+
* legitimately returns to the ID picker for its next ID, and by step name
|
|
20
|
+
* that is identical to pressing Back. Only the client knows a slot was
|
|
21
|
+
* committed, so only the client can say.
|
|
22
|
+
*/
|
|
23
|
+
slot?: number;
|
|
24
|
+
/** The ID type selected AT THAT MOMENT — a slot's final pick is not what the
|
|
25
|
+
* applicant was doing earlier in it (pick one ID, go back, settle on
|
|
26
|
+
* another, and the earlier step would be named for an ID not yet chosen). */
|
|
27
|
+
idType?: string;
|
|
14
28
|
}
|
|
15
29
|
|
|
16
30
|
export interface StepLog {
|
|
@@ -29,10 +43,17 @@ export function resetStepLog(): void {
|
|
|
29
43
|
|
|
30
44
|
/** Records a step visit. Consecutive duplicates are collapsed; back-and-forth
|
|
31
45
|
* navigation is kept — repeat visits are honest journey data. */
|
|
32
|
-
export function recordStep(step: string): void {
|
|
46
|
+
export function recordStep(step: string, slot?: number, idType?: string): void {
|
|
33
47
|
if (entries.length >= MAX_ENTRIES) return;
|
|
34
|
-
|
|
35
|
-
|
|
48
|
+
const last = entries[entries.length - 1];
|
|
49
|
+
// A revisit on a NEW check, or on a DIFFERENT ID, is a different visit.
|
|
50
|
+
if (last?.step === step && last.slot === slot && last.idType === idType) return;
|
|
51
|
+
entries.push({
|
|
52
|
+
step,
|
|
53
|
+
at: new Date().toISOString(),
|
|
54
|
+
...(slot ? { slot } : {}),
|
|
55
|
+
...(idType ? { idType } : {}),
|
|
56
|
+
});
|
|
36
57
|
}
|
|
37
58
|
|
|
38
59
|
/** Snapshot attached to the verify submission. Null when nothing was recorded
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { useMemo } from 'react';
|
|
2
|
+
|
|
3
|
+
import { multiIdPlan, type MultiIdPlan } from './multi-id';
|
|
4
|
+
import { useEffectiveCountry, useKyc, useKycConfig } from '../components/runtime';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The active multi-ID plan for the current state, or null on an ordinary run.
|
|
8
|
+
*
|
|
9
|
+
* One hook so the picker and the position strip cannot disagree about which
|
|
10
|
+
* check the applicant is on or what it may offer — the server validates the
|
|
11
|
+
* pick sequence, so two answers here means submissions it rejects.
|
|
12
|
+
*/
|
|
13
|
+
export function useMultiIdPlan(): MultiIdPlan | null {
|
|
14
|
+
const config = useKycConfig();
|
|
15
|
+
const country = useEffectiveCountry();
|
|
16
|
+
const multiIdSlotIndex = useKyc((s) => s.multiIdSlotIndex);
|
|
17
|
+
const multiIdSlots = useKyc((s) => s.multiIdSlots);
|
|
18
|
+
const serverConfig = useKyc((s) => s.serverConfig);
|
|
19
|
+
|
|
20
|
+
return useMemo(
|
|
21
|
+
() =>
|
|
22
|
+
multiIdPlan(
|
|
23
|
+
{ ...config, country },
|
|
24
|
+
{ multiIdSlotIndex, multiIdSlots },
|
|
25
|
+
serverConfig.status === 'ready' ? serverConfig.idTypes : [],
|
|
26
|
+
),
|
|
27
|
+
[config, country, multiIdSlotIndex, multiIdSlots, serverConfig],
|
|
28
|
+
);
|
|
29
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { useEffect, useRef, useState } from 'react';
|
|
2
|
+
|
|
3
|
+
import { isFaceModelReady, primeFaceModel } from './visionCameraFaceDetector';
|
|
4
|
+
|
|
5
|
+
// ─── Face-model readiness gate ────────────────────────────────────────────────
|
|
6
|
+
//
|
|
7
|
+
// Android fetches ML Kit's face model through Play Services rather than bundling
|
|
8
|
+
// it, which keeps ~8 MB per device out of the APK but leaves a window where
|
|
9
|
+
// detection cannot run: first launch before the download lands, or a device with
|
|
10
|
+
// no Google Play Services at all.
|
|
11
|
+
//
|
|
12
|
+
// This has to be answered BEFORE the camera opens. `detectFace` reports through
|
|
13
|
+
// `FaceResult`, where a missing model and an empty frame are both
|
|
14
|
+
// `faceCount: 0` — so gating on the per-frame result would strand the user on
|
|
15
|
+
// "position your face" indefinitely, with the SDK unable to say why. That is the
|
|
16
|
+
// single failure mode this whole gate exists to prevent.
|
|
17
|
+
//
|
|
18
|
+
// iOS is always ready (Apple Vision is a system framework), so this resolves on
|
|
19
|
+
// the first tick there and costs nothing.
|
|
20
|
+
|
|
21
|
+
export type ModelReadyState = 'ready' | 'preparing' | 'unavailable';
|
|
22
|
+
|
|
23
|
+
/** How long to wait for the model before calling it unavailable. */
|
|
24
|
+
const MODEL_WAIT_MS = 20_000;
|
|
25
|
+
/** Gap between readiness polls while the download is in flight. */
|
|
26
|
+
const POLL_INTERVAL_MS = 500;
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Tracks whether on-device face detection can run.
|
|
30
|
+
*
|
|
31
|
+
* Returns `'preparing'` while Play Services fetches the model, `'ready'` once it
|
|
32
|
+
* can run, and `'unavailable'` when it could not be obtained within
|
|
33
|
+
* {@link MODEL_WAIT_MS} — no Play Services, no network, or a declined install.
|
|
34
|
+
*
|
|
35
|
+
* The flow primes the download at open (see `MyazaKYC.tsx`), so by the time the
|
|
36
|
+
* user reaches liveness this is normally already `'ready'` and no waiting screen
|
|
37
|
+
* is ever shown.
|
|
38
|
+
*/
|
|
39
|
+
export function useFaceModelReady(): ModelReadyState {
|
|
40
|
+
const [state, setState] = useState<ModelReadyState>(() =>
|
|
41
|
+
isFaceModelReady() ? 'ready' : 'preparing',
|
|
42
|
+
);
|
|
43
|
+
const startedAt = useRef(Date.now());
|
|
44
|
+
|
|
45
|
+
useEffect(() => {
|
|
46
|
+
if (state !== 'preparing') return;
|
|
47
|
+
|
|
48
|
+
// Re-prime rather than assume the open-time call ran: the step can be
|
|
49
|
+
// reached directly in a resumed flow, and prepareModel() is a no-op once
|
|
50
|
+
// the model is present.
|
|
51
|
+
primeFaceModel();
|
|
52
|
+
|
|
53
|
+
const id = setInterval(() => {
|
|
54
|
+
if (isFaceModelReady()) {
|
|
55
|
+
setState('ready');
|
|
56
|
+
} else if (Date.now() - startedAt.current > MODEL_WAIT_MS) {
|
|
57
|
+
setState('unavailable');
|
|
58
|
+
}
|
|
59
|
+
}, POLL_INTERVAL_MS);
|
|
60
|
+
|
|
61
|
+
return () => clearInterval(id);
|
|
62
|
+
}, [state]);
|
|
63
|
+
|
|
64
|
+
return state;
|
|
65
|
+
}
|
|
@@ -45,6 +45,41 @@ const boxedDetector: BoxedHybridObject<MyazaFaceDetector> | null = detector
|
|
|
45
45
|
? NitroModules.box(detector)
|
|
46
46
|
: null;
|
|
47
47
|
|
|
48
|
+
/**
|
|
49
|
+
* Start fetching the face model, so it is warm by the time liveness runs.
|
|
50
|
+
*
|
|
51
|
+
* Android fetches ML Kit's models through Play Services rather than bundling
|
|
52
|
+
* them, which keeps ~18.5 MB per device out of the APK but leaves a window on
|
|
53
|
+
* first launch where detection cannot work. Priming at flow start puts that
|
|
54
|
+
* download behind the consent and ID-type screens instead of in front of the
|
|
55
|
+
* camera.
|
|
56
|
+
*
|
|
57
|
+
* Best-effort and non-blocking, exactly like the web SDK's `primeFaceMesh()`:
|
|
58
|
+
* a failure here is not fatal, because {@link isFaceModelReady} is what the
|
|
59
|
+
* liveness step actually gates on. iOS resolves immediately (Apple Vision is a
|
|
60
|
+
* system framework).
|
|
61
|
+
*/
|
|
62
|
+
export function primeFaceModel(): void {
|
|
63
|
+
detector?.prepareModel().catch(() => {
|
|
64
|
+
/* best-effort: isFaceModelReady() is the real gate */
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Whether face detection can run right now.
|
|
70
|
+
*
|
|
71
|
+
* Checked BEFORE the camera opens. `detectFace` cannot answer this — a missing
|
|
72
|
+
* model and an empty frame are both `faceCount: 0`, so gating on the per-frame
|
|
73
|
+
* result would strand the user on "position your face" with no explanation.
|
|
74
|
+
*
|
|
75
|
+
* `true` when the native module is absent entirely (Expo Go), because that path
|
|
76
|
+
* already degrades to the existing camera-unavailable screen and should not be
|
|
77
|
+
* reported as a model problem.
|
|
78
|
+
*/
|
|
79
|
+
export function isFaceModelReady(): boolean {
|
|
80
|
+
return detector?.isModelReady() ?? true;
|
|
81
|
+
}
|
|
82
|
+
|
|
48
83
|
/**
|
|
49
84
|
* Maps the native {@link FaceResult} to a {@link LivenessFaceData}, or `null`
|
|
50
85
|
* when no face is present (`faceCount === 0`). A worklet — runs on the camera
|