@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
@@ -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
@@ -50,11 +50,13 @@ export function ApplicantRoleStep(): React.ReactElement {
50
50
  const { colors } = useTheme();
51
51
  const stored = useKyc((s) => s.businessApplication);
52
52
 
53
- // The valid entered people, keeping their ORIGINAL index — the payload flag
54
- // is index-based, so the list and the submission can never disagree.
53
+ // The valid entered PEOPLE, keeping their ORIGINAL index — the payload flag
54
+ // is index-based, so the list and the submission can never disagree. A
55
+ // corporate shareholder is excluded: "which of these is you?" is a question
56
+ // about humans, and a company can never be the person filling in the form.
55
57
  const people = stored.keyPeople
56
58
  .map((row, index) => ({ row, index }))
57
- .filter(({ row }) => isKeyPersonRowValid(row));
59
+ .filter(({ row }) => !row.isCorporate && isKeyPersonRowValid(row));
58
60
  const hasPeople = people.length > 0;
59
61
 
60
62
  const propName = [config.userData?.firstName, config.userData?.lastName]
@@ -63,13 +65,15 @@ export function ApplicantRoleStep(): React.ReactElement {
63
65
 
64
66
  // Tri-state: a person's index, 'other', or nothing chosen yet. First arrival
65
67
  // pre-selects the applicant's own entry when the userData name matches
66
- // (they still confirm) — a stored choice always wins.
68
+ // (they still confirm) — and when the name matches NOBODY listed, "I'm not
69
+ // one of these people" is what that fact means, so it is pre-selected
70
+ // instead (mirrors the web SDK). A stored choice always wins.
67
71
  const [selection, setSelection] = useState<number | 'other' | null>(() => {
68
72
  if (stored.applicantKeyPersonIndex !== null) return stored.applicantKeyPersonIndex;
69
73
  if (stored.applicantRole) return 'other';
70
74
  if (propName) {
71
75
  const match = people.find(({ row }) => namesLooselyMatch(propName, row.name));
72
- if (match) return match.index;
76
+ return match ? match.index : people.length > 0 ? 'other' : null;
73
77
  }
74
78
  return null;
75
79
  });
@@ -0,0 +1,89 @@
1
+ import React from 'react';
2
+ import { View } from 'react-native';
3
+
4
+ import { radius, spacing } from '../config/theme';
5
+ import { useTheme } from '../components/runtime';
6
+ import { MyazaText } from '../components/Typography';
7
+ import { MyazaAlert } from '../components/MyazaAlert';
8
+ import type { BusinessCheckState } from '../store/state';
9
+
10
+ // ---------------------------------------------------------------------------
11
+ // What the register said, when it was NOT a plain yes.
12
+ //
13
+ // The check is a paid, deliberate step, so it is shown rather than run
14
+ // invisibly: the applicant sees a company that is not on the register caught
15
+ // HERE instead of after documents and a selfie. Nothing is shown for a company
16
+ // that WAS found — the register's answer is already in the form the applicant
17
+ // is looking at, editable, and its officers are the next step. Mirrors the web
18
+ // SDK's BusinessCheckPanel.
19
+ // ---------------------------------------------------------------------------
20
+
21
+ export function BusinessCheckPanel({
22
+ check,
23
+ }: {
24
+ check: BusinessCheckState;
25
+ }): React.ReactElement | null {
26
+ // `skipped` shows nothing on purpose. The organisation could not be charged,
27
+ // which is not the applicant's problem and not something they can act on;
28
+ // the check simply happens at submission instead. `checking` shows nothing
29
+ // either — the loader lives inside the Continue button they just pressed.
30
+ if (
31
+ check.status === 'idle' ||
32
+ check.status === 'skipped' ||
33
+ check.status === 'found' ||
34
+ check.status === 'checking'
35
+ ) {
36
+ return null;
37
+ }
38
+
39
+ if (check.status === 'not_found') {
40
+ return (
41
+ <MyazaAlert
42
+ variant="warning"
43
+ title="We could not find this business on the register"
44
+ message="Check the registration number and try again."
45
+ />
46
+ );
47
+ }
48
+
49
+ if (check.status === 'limit_reached') {
50
+ // Not a failure: the submission still runs its own check. What it says is
51
+ // "stop re-picking and look at the number", which is the only thing left
52
+ // that helps.
53
+ return (
54
+ <NeutralPanel
55
+ title="We have stopped looking this up for now"
56
+ body="This application has searched the register several times. Check the registration number is right; your details will still be verified when you submit."
57
+ />
58
+ );
59
+ }
60
+
61
+ // unavailable — deliberately not "we could not find it": an outage is not
62
+ // evidence that a business is unregistered.
63
+ return (
64
+ <NeutralPanel
65
+ title="The register is temporarily unavailable"
66
+ body="You can continue, and we will check it shortly."
67
+ />
68
+ );
69
+ }
70
+
71
+ function NeutralPanel({ title, body }: { title: string; body: string }): React.ReactElement {
72
+ const { colors } = useTheme();
73
+ return (
74
+ <View
75
+ style={{
76
+ borderWidth: 1,
77
+ borderColor: colors.border,
78
+ borderRadius: radius.sm,
79
+ backgroundColor: colors.backgroundSecondary,
80
+ padding: spacing.md,
81
+ }}
82
+ >
83
+ <MyazaText variant="label">{title}</MyazaText>
84
+ <MyazaText variant="bodySmall" color={colors.textSecondary} style={{ marginTop: 2 }}>
85
+ {body}
86
+ </MyazaText>
87
+ </View>
88
+ );
89
+ }
@@ -0,0 +1,109 @@
1
+ import React from 'react';
2
+ import { View } from 'react-native';
3
+
4
+ import { MyazaInput } from '../components/MyazaInput';
5
+ import { spacing } from '../config/theme';
6
+ import { CompanyInfoFields } from './CompanyInfoFields';
7
+ import { isValidContactEmail } from '../config/contact';
8
+ import type { BusinessState } from '../store/state';
9
+ import type { BusinessProductDef } from '../config/business';
10
+ import type { RegistrationHint } from '../config/registrationHint';
11
+ import type { CompanyInfoField, CompanyInfoMode } from '../types/business';
12
+
13
+ // ---------------------------------------------------------------------------
14
+ // The DETAILS screen of the business step: confirming what the register said.
15
+ //
16
+ // Registration number and name are editable here too — this is also where
17
+ // manual entry lands, for the companies no search index carries. Split from
18
+ // BusinessDetailsStep (200-line rule); field order mirrors the web SDK.
19
+ // ---------------------------------------------------------------------------
20
+
21
+ export function BusinessDetailsFields({
22
+ business,
23
+ productDef,
24
+ regHint,
25
+ numberValid,
26
+ formatOk,
27
+ requireName,
28
+ country,
29
+ modes,
30
+ showCompanyInfo,
31
+ showContactEmail,
32
+ onChange,
33
+ }: {
34
+ business: BusinessState;
35
+ productDef: BusinessProductDef;
36
+ regHint: RegistrationHint;
37
+ numberValid: boolean;
38
+ formatOk: boolean;
39
+ requireName: boolean;
40
+ country: string;
41
+ modes: Record<CompanyInfoField, CompanyInfoMode>;
42
+ showCompanyInfo: boolean;
43
+ showContactEmail: boolean;
44
+ onChange: <K extends keyof BusinessState>(key: K, value: BusinessState[K]) => void;
45
+ }): React.ReactElement {
46
+ const regNumber = business.registrationNumber;
47
+ const contactEmail = business.contactEmail;
48
+ const contactEmailInvalid =
49
+ contactEmail.trim() !== '' && !isValidContactEmail(contactEmail.trim());
50
+ return (
51
+ <View>
52
+ <MyazaInput
53
+ label={productDef.inputLabel}
54
+ value={regNumber}
55
+ onChangeText={(text) => onChange('registrationNumber', text)}
56
+ placeholder={regHint.placeholder}
57
+ autoCapitalize="characters"
58
+ autoCorrect={false}
59
+ // Live like the web SDK: a wrong CAC prefix is corrected on the spot,
60
+ // not discovered on Continue. The registry tip fills the same slot
61
+ // until there is an error to show.
62
+ error={
63
+ regNumber !== '' && !numberValid
64
+ ? (!formatOk && regHint.formatError) ||
65
+ `Enter a valid ${productDef.inputLabel.toLowerCase()}.`
66
+ : null
67
+ }
68
+ helper={regHint.tip ?? undefined}
69
+ />
70
+
71
+ <View style={{ height: spacing.md }} />
72
+ <MyazaInput
73
+ label={`Registered business name${requireName ? '' : ' (optional)'}`}
74
+ value={business.registrationName}
75
+ onChangeText={(text) => onChange('registrationName', text)}
76
+ placeholder="Enter the registered business name"
77
+ autoCorrect={false}
78
+ />
79
+
80
+ {showCompanyInfo ? (
81
+ <>
82
+ <View style={{ height: spacing.md }} />
83
+ <CompanyInfoFields
84
+ values={business}
85
+ modes={modes}
86
+ country={country}
87
+ onChange={(field, value) => onChange(field, value)}
88
+ />
89
+ </>
90
+ ) : null}
91
+
92
+ {showContactEmail ? (
93
+ <>
94
+ <View style={{ height: spacing.md }} />
95
+ <MyazaInput
96
+ label="Contact email for owner verification (optional)"
97
+ value={contactEmail}
98
+ onChangeText={(text) => onChange('contactEmail', text)}
99
+ placeholder="admin@company.com"
100
+ keyboardType="email-address"
101
+ autoCapitalize="none"
102
+ helper="We'll email this address a link for your directors and owners to verify their identity."
103
+ error={contactEmailInvalid ? 'Enter a valid email address.' : null}
104
+ />
105
+ </>
106
+ ) : null}
107
+ </View>
108
+ );
109
+ }