smartcomply-web-sdk 1.0.68 → 1.0.70

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 (41) hide show
  1. package/dist/esm/flow/DocumentCapture.d.ts +20 -0
  2. package/dist/esm/flow/DocumentCapture.d.ts.map +1 -1
  3. package/dist/esm/flow/DocumentCapture.js +173 -19
  4. package/dist/esm/flow/DocumentCapture.js.map +1 -1
  5. package/dist/esm/flow/SmartComplyFlow.d.ts +12 -0
  6. package/dist/esm/flow/SmartComplyFlow.d.ts.map +1 -1
  7. package/dist/esm/flow/SmartComplyFlow.js +165 -22
  8. package/dist/esm/flow/SmartComplyFlow.js.map +1 -1
  9. package/dist/esm/flow/icons.d.ts +1 -1
  10. package/dist/esm/flow/icons.d.ts.map +1 -1
  11. package/dist/esm/flow/icons.js +1 -0
  12. package/dist/esm/flow/icons.js.map +1 -1
  13. package/dist/esm/modules/liveness/LivenessUI.d.ts.map +1 -1
  14. package/dist/esm/modules/liveness/LivenessUI.js +13 -3
  15. package/dist/esm/modules/liveness/LivenessUI.js.map +1 -1
  16. package/dist/esm/utils/ImageQuality.d.ts +12 -1
  17. package/dist/esm/utils/ImageQuality.d.ts.map +1 -1
  18. package/dist/esm/utils/ImageQuality.js +12 -1
  19. package/dist/esm/utils/ImageQuality.js.map +1 -1
  20. package/dist/flow/DocumentCapture.d.ts +20 -0
  21. package/dist/flow/DocumentCapture.d.ts.map +1 -1
  22. package/dist/flow/DocumentCapture.js +173 -19
  23. package/dist/flow/DocumentCapture.js.map +1 -1
  24. package/dist/flow/SmartComplyFlow.d.ts +12 -0
  25. package/dist/flow/SmartComplyFlow.d.ts.map +1 -1
  26. package/dist/flow/SmartComplyFlow.js +165 -22
  27. package/dist/flow/SmartComplyFlow.js.map +1 -1
  28. package/dist/flow/icons.d.ts +1 -1
  29. package/dist/flow/icons.d.ts.map +1 -1
  30. package/dist/flow/icons.js +1 -0
  31. package/dist/flow/icons.js.map +1 -1
  32. package/dist/modules/liveness/LivenessUI.d.ts.map +1 -1
  33. package/dist/modules/liveness/LivenessUI.js +13 -3
  34. package/dist/modules/liveness/LivenessUI.js.map +1 -1
  35. package/dist/smartcomply.browser.js +393 -44
  36. package/dist/smartcomply.browser.js.map +4 -4
  37. package/dist/utils/ImageQuality.d.ts +12 -1
  38. package/dist/utils/ImageQuality.d.ts.map +1 -1
  39. package/dist/utils/ImageQuality.js +12 -1
  40. package/dist/utils/ImageQuality.js.map +1 -1
  41. package/package.json +1 -1
@@ -3,8 +3,35 @@ import { getTheme, TYPE, RADIUS } from "./theme";
3
3
  import { DocumentCapture } from "./DocumentCapture";
4
4
  import { collectDeviceMetadata } from "../utils/device";
5
5
  import { requiresBackSide } from "../utils/documentRules";
6
+ import { DEFAULT_CHALLENGE_ACTIONS } from "../utils/constants";
6
7
  import { icon } from "./icons";
7
8
  import { renderSpinner, renderInlineSpinner } from "./spinner";
9
+ // A dropped camera/network/upload error is not the user's fault and must not
10
+ // cost them a retry attempt — only a genuine verification outcome should.
11
+ // Classified by message rather than a typed error class since the errors
12
+ // originate from several independent layers (CameraManager, VideoRecorder,
13
+ // LivenessUploader) that don't share a common error type today.
14
+ const _INFRASTRUCTURE_ERROR_PATTERNS = [
15
+ "camera connection was lost",
16
+ "network error",
17
+ "failed after all retries",
18
+ "camera access is not supported",
19
+ "camera access failed",
20
+ "recording failed unexpectedly",
21
+ ];
22
+ function _isInfrastructureError(err) {
23
+ const msg = String(err?.message || "").toLowerCase();
24
+ return _INFRASTRUCTURE_ERROR_PATTERNS.some((p) => msg.includes(p));
25
+ }
26
+ // Keep this plain-language and reassuring for infrastructure hiccups — the
27
+ // user did nothing wrong, so the copy should say so rather than sound like
28
+ // a rejection.
29
+ function _userFacingLivenessError(err) {
30
+ if (_isInfrastructureError(err)) {
31
+ return "We lost connection during your scan — this wasn't anything you did. Please check your camera and internet connection, then try again.";
32
+ }
33
+ return err?.message || "We couldn't verify your face. Please try again.";
34
+ }
8
35
  const STEP_ORDER = [
9
36
  "loading",
10
37
  "welcome",
@@ -49,15 +76,17 @@ function resolveIdTypeInfo(typeName, isDocumentFlow) {
49
76
  return { icon: "🏦", desc: "Enter your 11-digit Bank Verification Number (BVN)", requiresBack: false };
50
77
  }
51
78
  // NIN — always 11 digits (covers nin_with_face, NIN slip, etc.). Not every
52
- // physical NIN card in circulation has meaningful back-side content, so
53
- // the back photo is offered but optional rather than required — the
54
- // plain data-verification flow (just typing the 11-digit number, no
55
- // document) never captures a photo at all, so requiresBack stays false there.
79
+ // NIN document in circulation has a back side — a NIN slip typically
80
+ // doesn't, while a NIN card does — so the back photo is offered for
81
+ // either, but optional rather than required: add it if your document
82
+ // has one, skip it if it doesn't. The plain data-verification flow (just
83
+ // typing the 11-digit number, no document) never captures a photo at
84
+ // all, so requiresBack stays false there.
56
85
  if (u.includes("NIN") || u.includes("NATIONAL IDENTITY NUMBER")) {
57
86
  return {
58
87
  icon: "🆔",
59
88
  desc: isDocumentFlow
60
- ? "Capture or upload your NIN card (back optional)"
89
+ ? "Capture or upload your NIN (slip or card) — add the back if your document has one"
61
90
  : "Enter your 11-digit National Identification Number (NIN)",
62
91
  requiresBack: isDocumentFlow ? "optional" : false,
63
92
  };
@@ -273,9 +302,14 @@ export class SmartComplyFlow {
273
302
  this.isDestroyed = false;
274
303
  this.headerBackBtn = null;
275
304
  this._keydownHandler = null;
305
+ this._visibilityHandler = null;
306
+ this._hiddenAtMs = null;
307
+ this._popstateHandler = null;
308
+ this._historyGuardActive = false;
276
309
  // Retry safety — max 3 confirmation rejections or liveness failures per session
277
310
  this._confirmRetries = 0;
278
311
  this._livenessFailures = 0;
312
+ this._livenessAttemptId = 0;
279
313
  // IVS result stored for confirm_identity screen
280
314
  this._ivsName = "";
281
315
  this._ivsGender = "";
@@ -334,6 +368,26 @@ export class SmartComplyFlow {
334
368
  document.removeEventListener("keydown", this._keydownHandler);
335
369
  this._keydownHandler = null;
336
370
  }
371
+ if (this._visibilityHandler) {
372
+ document.removeEventListener("visibilitychange", this._visibilityHandler);
373
+ this._visibilityHandler = null;
374
+ }
375
+ if (this._popstateHandler) {
376
+ window.removeEventListener("popstate", this._popstateHandler);
377
+ this._popstateHandler = null;
378
+ }
379
+ // Closing via the ✕/Escape/completion (not via the back gesture, which
380
+ // handles its own guard entry above) still leaves our one guard entry
381
+ // sitting on top of the host's history — back out of it so the user
382
+ // isn't left pressing back once "into" a widget that's already gone.
383
+ if (this._historyGuardActive) {
384
+ this._historyGuardActive = false;
385
+ try {
386
+ if (history.state?.scFlowGuard)
387
+ history.back();
388
+ }
389
+ catch { /* ignore — best effort only */ }
390
+ }
337
391
  this.docCapture?.destroy();
338
392
  // Release any pre-warmed camera stream
339
393
  if (this._prewarmedStream) {
@@ -464,6 +518,64 @@ export class SmartComplyFlow {
464
518
  }
465
519
  };
466
520
  document.addEventListener("keydown", this._keydownHandler);
521
+ // A tab backgrounded mid-scan (a notification, switching apps to copy a
522
+ // code, a phone call) leaves the camera stream and detection loop in an
523
+ // undefined state on most mobile browsers — rAF throttles/pauses while
524
+ // hidden, and some browsers suspend the camera outright. Rather than let
525
+ // the scan silently continue against stale state and fail confusingly,
526
+ // restart the liveness step cleanly once the tab has been away long
527
+ // enough to plausibly have disrupted an active camera session. A brief
528
+ // glance elsewhere (quick app switch, screen lock tap) is not enough to
529
+ // warrant restarting — only a real return-from-away.
530
+ const REBACKGROUND_RESTART_MS = 3000;
531
+ this._visibilityHandler = () => {
532
+ if (this.isDestroyed)
533
+ return;
534
+ if (document.hidden) {
535
+ this._hiddenAtMs = Date.now();
536
+ return;
537
+ }
538
+ const hiddenAt = this._hiddenAtMs;
539
+ this._hiddenAtMs = null;
540
+ if (hiddenAt === null)
541
+ return;
542
+ const awayMs = Date.now() - hiddenAt;
543
+ if (awayMs >= REBACKGROUND_RESTART_MS && this.currentStep === "liveness") {
544
+ this.showStep("liveness");
545
+ }
546
+ };
547
+ document.addEventListener("visibilitychange", this._visibilityHandler);
548
+ // Intercept the browser/OS back gesture so it steps back within the flow
549
+ // instead of navigating the host page away and abandoning the session
550
+ // mid-verification (camera left open, timers orphaned). One guard entry
551
+ // is pushed — not one per step — to minimize interference with a host
552
+ // page's own router; popstate re-arms it each time so back can be
553
+ // pressed repeatedly. If the current step has no back target, back
554
+ // closes the widget cleanly instead (same as the header ✕).
555
+ this._historyGuardActive = true;
556
+ try {
557
+ history.pushState({ scFlowGuard: true }, "");
558
+ }
559
+ catch {
560
+ this._historyGuardActive = false; // host disallows pushState — degrade silently
561
+ }
562
+ this._popstateHandler = () => {
563
+ if (this.isDestroyed || !this._historyGuardActive)
564
+ return;
565
+ const backAction = this._getBackAction(this.currentStep);
566
+ if (backAction) {
567
+ backAction();
568
+ }
569
+ else {
570
+ this.close();
571
+ return; // don't re-arm — we're closing, let the real back navigation proceed
572
+ }
573
+ try {
574
+ history.pushState({ scFlowGuard: true }, "");
575
+ }
576
+ catch { /* ignore — nothing more we can do */ }
577
+ };
578
+ window.addEventListener("popstate", this._popstateHandler);
467
579
  }
468
580
  async initialize() {
469
581
  this.showStep("loading");
@@ -732,14 +844,16 @@ export class SmartComplyFlow {
732
844
  btn.style.cssText += "margin-top:2px;";
733
845
  btn.addEventListener("click", () => this.showStep("country"));
734
846
  container.appendChild(btn);
735
- // Trust badge — "Powered by Adhere" already lives in the persistent
736
- // modal footer below; repeating it here just cost vertical space.
847
+ // Plain-language line on what the camera/photo is for and what happens
848
+ // to it — shown before the "Get Started" tap that leads to the first
849
+ // camera permission prompt, so the browser's own prompt isn't the only
850
+ // context the user gets for why a camera is being requested.
737
851
  const trust = document.createElement("div");
738
852
  trust.style.cssText = `
739
- display:flex;align-items:center;gap:6px;
740
- color:${this.theme.textMuted};font-size:10.5px;
853
+ display:flex;align-items:flex-start;gap:6px;text-align:left;
854
+ color:${this.theme.textMuted};font-size:10.5px;line-height:1.5;
741
855
  `;
742
- trust.textContent = "🔒 End-to-end encrypted";
856
+ trust.innerHTML = `<span style="flex-shrink:0;">🔒</span><span>Your photo and camera scan are used only to verify it's really you, and are kept encrypted.</span>`;
743
857
  container.appendChild(trust);
744
858
  }
745
859
  // ── Country Select ──────────────────────────────────────────────
@@ -1853,6 +1967,11 @@ export class SmartComplyFlow {
1853
1967
  this._renderRetryLimitReached(container);
1854
1968
  return;
1855
1969
  }
1970
+ // Each render gets its own id so a stale attempt (e.g. abandoned after
1971
+ // the tab was backgrounded and we restarted the scan on return) can't
1972
+ // resolve/reject into the current one — see the visibilitychange
1973
+ // handler in mount(), which re-renders this step on return-to-foreground.
1974
+ const attemptId = ++this._livenessAttemptId;
1856
1975
  const title = this.createStepTitle("Face verification");
1857
1976
  container.appendChild(title);
1858
1977
  const desc = document.createElement("div");
@@ -1901,8 +2020,12 @@ export class SmartComplyFlow {
1901
2020
  error: this.theme.error,
1902
2021
  warning: this.theme.warning,
1903
2022
  },
1904
- }, ["BLINK"], this.livenessEntryId || undefined)
2023
+ }, [...DEFAULT_CHALLENGE_ACTIONS], this.livenessEntryId || undefined)
1905
2024
  .then((result) => {
2025
+ // A stale attempt (superseded by a restart after the tab was
2026
+ // backgrounded) resolving late must not act on the current screen.
2027
+ if (attemptId !== this._livenessAttemptId)
2028
+ return;
1906
2029
  // W11: After submit, end user is done — show "You're all done" screen.
1907
2030
  // Backend processing continues in background; client receives webhook.
1908
2031
  // Track liveness failures for retry limit (W14).
@@ -1915,8 +2038,18 @@ export class SmartComplyFlow {
1915
2038
  this.showStep("done");
1916
2039
  })
1917
2040
  .catch((err) => {
1918
- this._livenessFailures++;
1919
- this.showErrorStep(err.message || "Liveness verification failed. Please try again.");
2041
+ if (attemptId !== this._livenessAttemptId)
2042
+ return;
2043
+ // Only count a genuine verification failure against the retry limit —
2044
+ // a dropped camera, network blip, or upload failure isn't the user's
2045
+ // fault and shouldn't cost them one of their limited attempts. These
2046
+ // are infrastructure-layer errors (thrown/rejected); a real face-
2047
+ // match/quality failure comes back as a resolved result with
2048
+ // status "failed" instead (handled in .then() above).
2049
+ if (!_isInfrastructureError(err)) {
2050
+ this._livenessFailures++;
2051
+ }
2052
+ this.showErrorStep(_userFacingLivenessError(err));
1920
2053
  });
1921
2054
  }
1922
2055
  // ── Error ───────────────────────────────────────────────────────
@@ -2238,6 +2371,24 @@ export class SmartComplyFlow {
2238
2371
  .flatMap((ch) => ch.fields || []);
2239
2372
  return allFields.some((f) => isUploadFieldType(f.type));
2240
2373
  }
2374
+ /**
2375
+ * The "go back one step" target for a given step, if that step is
2376
+ * navigable — shared by the header back button and the browser
2377
+ * back-button (popstate) handler in mount() so both agree on the same
2378
+ * step order.
2379
+ */
2380
+ _getBackAction(step) {
2381
+ const countries = Object.keys(this.sdkConfig?.channels || {});
2382
+ const backTargets = {
2383
+ id_type: () => this.showStep(countries.length > 1 ? "country" : "welcome"),
2384
+ country: () => this.showStep("welcome"),
2385
+ id_input: () => this.showStep("id_type"),
2386
+ confirm_identity: () => this.showStep("id_input"),
2387
+ document_capture: () => { this.docCapture?.destroy(); this.showStep("id_type"); },
2388
+ document_confirm: () => { this.docCapture?.destroy(); this.showStep("document_capture"); },
2389
+ };
2390
+ return backTargets[step];
2391
+ }
2241
2392
  updateProgress() {
2242
2393
  const isDocFlow = this.isDocumentFlow();
2243
2394
  const countries = Object.keys(this.sdkConfig?.channels || {});
@@ -2289,15 +2440,7 @@ export class SmartComplyFlow {
2289
2440
  }
2290
2441
  // Back button — show for navigable steps only
2291
2442
  if (this.headerBackBtn) {
2292
- const backTargets = {
2293
- id_type: () => this.showStep(countries.length > 1 ? "country" : "welcome"),
2294
- country: () => this.showStep("welcome"),
2295
- id_input: () => this.showStep("id_type"),
2296
- confirm_identity: () => this.showStep("id_input"),
2297
- document_capture: () => { this.docCapture?.destroy(); this.showStep("id_type"); },
2298
- document_confirm: () => { this.docCapture?.destroy(); this.showStep("document_capture"); },
2299
- };
2300
- const backAction = backTargets[this.currentStep];
2443
+ const backAction = this._getBackAction(this.currentStep);
2301
2444
  if (backAction) {
2302
2445
  this.headerBackBtn.style.display = "flex";
2303
2446
  const newBtn = this.headerBackBtn.cloneNode(true);