smartcomply-web-sdk 1.0.66 → 1.0.69

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 (45) hide show
  1. package/dist/esm/flow/DocumentCapture.d.ts +10 -0
  2. package/dist/esm/flow/DocumentCapture.d.ts.map +1 -1
  3. package/dist/esm/flow/DocumentCapture.js +83 -5
  4. package/dist/esm/flow/DocumentCapture.js.map +1 -1
  5. package/dist/esm/flow/SmartComplyFlow.d.ts +19 -0
  6. package/dist/esm/flow/SmartComplyFlow.d.ts.map +1 -1
  7. package/dist/esm/flow/SmartComplyFlow.js +216 -32
  8. package/dist/esm/flow/SmartComplyFlow.js.map +1 -1
  9. package/dist/esm/modules/liveness/LivenessUI.d.ts.map +1 -1
  10. package/dist/esm/modules/liveness/LivenessUI.js +13 -3
  11. package/dist/esm/modules/liveness/LivenessUI.js.map +1 -1
  12. package/dist/esm/modules/liveness/liveness.js +1 -1
  13. package/dist/esm/modules/liveness/liveness.js.map +1 -1
  14. package/dist/esm/utils/ImageQuality.d.ts +12 -1
  15. package/dist/esm/utils/ImageQuality.d.ts.map +1 -1
  16. package/dist/esm/utils/ImageQuality.js +12 -1
  17. package/dist/esm/utils/ImageQuality.js.map +1 -1
  18. package/dist/esm/utils/constants.d.ts +9 -6
  19. package/dist/esm/utils/constants.d.ts.map +1 -1
  20. package/dist/esm/utils/constants.js +9 -6
  21. package/dist/esm/utils/constants.js.map +1 -1
  22. package/dist/flow/DocumentCapture.d.ts +10 -0
  23. package/dist/flow/DocumentCapture.d.ts.map +1 -1
  24. package/dist/flow/DocumentCapture.js +83 -5
  25. package/dist/flow/DocumentCapture.js.map +1 -1
  26. package/dist/flow/SmartComplyFlow.d.ts +19 -0
  27. package/dist/flow/SmartComplyFlow.d.ts.map +1 -1
  28. package/dist/flow/SmartComplyFlow.js +216 -32
  29. package/dist/flow/SmartComplyFlow.js.map +1 -1
  30. package/dist/modules/liveness/LivenessUI.d.ts.map +1 -1
  31. package/dist/modules/liveness/LivenessUI.js +13 -3
  32. package/dist/modules/liveness/LivenessUI.js.map +1 -1
  33. package/dist/modules/liveness/liveness.js +1 -1
  34. package/dist/modules/liveness/liveness.js.map +1 -1
  35. package/dist/smartcomply.browser.js +360 -43
  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/dist/utils/constants.d.ts +9 -6
  42. package/dist/utils/constants.d.ts.map +1 -1
  43. package/dist/utils/constants.js +9 -6
  44. package/dist/utils/constants.js.map +1 -1
  45. package/package.json +1 -1
@@ -6,8 +6,35 @@ const theme_1 = require("./theme");
6
6
  const DocumentCapture_1 = require("./DocumentCapture");
7
7
  const device_1 = require("../utils/device");
8
8
  const documentRules_1 = require("../utils/documentRules");
9
+ const constants_1 = require("../utils/constants");
9
10
  const icons_1 = require("./icons");
10
11
  const spinner_1 = require("./spinner");
12
+ // A dropped camera/network/upload error is not the user's fault and must not
13
+ // cost them a retry attempt — only a genuine verification outcome should.
14
+ // Classified by message rather than a typed error class since the errors
15
+ // originate from several independent layers (CameraManager, VideoRecorder,
16
+ // LivenessUploader) that don't share a common error type today.
17
+ const _INFRASTRUCTURE_ERROR_PATTERNS = [
18
+ "camera connection was lost",
19
+ "network error",
20
+ "failed after all retries",
21
+ "camera access is not supported",
22
+ "camera access failed",
23
+ "recording failed unexpectedly",
24
+ ];
25
+ function _isInfrastructureError(err) {
26
+ const msg = String(err?.message || "").toLowerCase();
27
+ return _INFRASTRUCTURE_ERROR_PATTERNS.some((p) => msg.includes(p));
28
+ }
29
+ // Keep this plain-language and reassuring for infrastructure hiccups — the
30
+ // user did nothing wrong, so the copy should say so rather than sound like
31
+ // a rejection.
32
+ function _userFacingLivenessError(err) {
33
+ if (_isInfrastructureError(err)) {
34
+ return "We lost connection during your scan — this wasn't anything you did. Please check your camera and internet connection, then try again.";
35
+ }
36
+ return err?.message || "We couldn't verify your face. Please try again.";
37
+ }
11
38
  const STEP_ORDER = [
12
39
  "loading",
13
40
  "welcome",
@@ -276,9 +303,14 @@ class SmartComplyFlow {
276
303
  this.isDestroyed = false;
277
304
  this.headerBackBtn = null;
278
305
  this._keydownHandler = null;
306
+ this._visibilityHandler = null;
307
+ this._hiddenAtMs = null;
308
+ this._popstateHandler = null;
309
+ this._historyGuardActive = false;
279
310
  // Retry safety — max 3 confirmation rejections or liveness failures per session
280
311
  this._confirmRetries = 0;
281
312
  this._livenessFailures = 0;
313
+ this._livenessAttemptId = 0;
282
314
  // IVS result stored for confirm_identity screen
283
315
  this._ivsName = "";
284
316
  this._ivsGender = "";
@@ -337,6 +369,26 @@ class SmartComplyFlow {
337
369
  document.removeEventListener("keydown", this._keydownHandler);
338
370
  this._keydownHandler = null;
339
371
  }
372
+ if (this._visibilityHandler) {
373
+ document.removeEventListener("visibilitychange", this._visibilityHandler);
374
+ this._visibilityHandler = null;
375
+ }
376
+ if (this._popstateHandler) {
377
+ window.removeEventListener("popstate", this._popstateHandler);
378
+ this._popstateHandler = null;
379
+ }
380
+ // Closing via the ✕/Escape/completion (not via the back gesture, which
381
+ // handles its own guard entry above) still leaves our one guard entry
382
+ // sitting on top of the host's history — back out of it so the user
383
+ // isn't left pressing back once "into" a widget that's already gone.
384
+ if (this._historyGuardActive) {
385
+ this._historyGuardActive = false;
386
+ try {
387
+ if (history.state?.scFlowGuard)
388
+ history.back();
389
+ }
390
+ catch { /* ignore — best effort only */ }
391
+ }
340
392
  this.docCapture?.destroy();
341
393
  // Release any pre-warmed camera stream
342
394
  if (this._prewarmedStream) {
@@ -467,6 +519,64 @@ class SmartComplyFlow {
467
519
  }
468
520
  };
469
521
  document.addEventListener("keydown", this._keydownHandler);
522
+ // A tab backgrounded mid-scan (a notification, switching apps to copy a
523
+ // code, a phone call) leaves the camera stream and detection loop in an
524
+ // undefined state on most mobile browsers — rAF throttles/pauses while
525
+ // hidden, and some browsers suspend the camera outright. Rather than let
526
+ // the scan silently continue against stale state and fail confusingly,
527
+ // restart the liveness step cleanly once the tab has been away long
528
+ // enough to plausibly have disrupted an active camera session. A brief
529
+ // glance elsewhere (quick app switch, screen lock tap) is not enough to
530
+ // warrant restarting — only a real return-from-away.
531
+ const REBACKGROUND_RESTART_MS = 3000;
532
+ this._visibilityHandler = () => {
533
+ if (this.isDestroyed)
534
+ return;
535
+ if (document.hidden) {
536
+ this._hiddenAtMs = Date.now();
537
+ return;
538
+ }
539
+ const hiddenAt = this._hiddenAtMs;
540
+ this._hiddenAtMs = null;
541
+ if (hiddenAt === null)
542
+ return;
543
+ const awayMs = Date.now() - hiddenAt;
544
+ if (awayMs >= REBACKGROUND_RESTART_MS && this.currentStep === "liveness") {
545
+ this.showStep("liveness");
546
+ }
547
+ };
548
+ document.addEventListener("visibilitychange", this._visibilityHandler);
549
+ // Intercept the browser/OS back gesture so it steps back within the flow
550
+ // instead of navigating the host page away and abandoning the session
551
+ // mid-verification (camera left open, timers orphaned). One guard entry
552
+ // is pushed — not one per step — to minimize interference with a host
553
+ // page's own router; popstate re-arms it each time so back can be
554
+ // pressed repeatedly. If the current step has no back target, back
555
+ // closes the widget cleanly instead (same as the header ✕).
556
+ this._historyGuardActive = true;
557
+ try {
558
+ history.pushState({ scFlowGuard: true }, "");
559
+ }
560
+ catch {
561
+ this._historyGuardActive = false; // host disallows pushState — degrade silently
562
+ }
563
+ this._popstateHandler = () => {
564
+ if (this.isDestroyed || !this._historyGuardActive)
565
+ return;
566
+ const backAction = this._getBackAction(this.currentStep);
567
+ if (backAction) {
568
+ backAction();
569
+ }
570
+ else {
571
+ this.close();
572
+ return; // don't re-arm — we're closing, let the real back navigation proceed
573
+ }
574
+ try {
575
+ history.pushState({ scFlowGuard: true }, "");
576
+ }
577
+ catch { /* ignore — nothing more we can do */ }
578
+ };
579
+ window.addEventListener("popstate", this._popstateHandler);
470
580
  }
471
581
  async initialize() {
472
582
  this.showStep("loading");
@@ -658,7 +768,7 @@ class SmartComplyFlow {
658
768
  container.appendChild(topRow);
659
769
  // Title + subtitle (compact)
660
770
  const titleWrap = document.createElement("div");
661
- titleWrap.style.cssText = "display:flex;flex-direction:column;gap:5px;";
771
+ titleWrap.style.cssText = "display:flex;flex-direction:column;align-items:center;gap:5px;width:100%;";
662
772
  const title = document.createElement("h2");
663
773
  title.style.cssText = `
664
774
  color:${this.theme.text};font-size:${theme_1.TYPE.heading}px;font-weight:700;
@@ -673,7 +783,7 @@ class SmartComplyFlow {
673
783
  const desc = document.createElement("p");
674
784
  desc.style.cssText = `
675
785
  color:${this.theme.textSecondary};font-size:13px;line-height:1.5;
676
- margin:0;max-width:300px;
786
+ margin:0 auto;max-width:300px;text-align:center;
677
787
  `;
678
788
  desc.textContent = isDocFlow
679
789
  ? "You'll need your document and camera access. This takes about 2 minutes."
@@ -735,14 +845,16 @@ class SmartComplyFlow {
735
845
  btn.style.cssText += "margin-top:2px;";
736
846
  btn.addEventListener("click", () => this.showStep("country"));
737
847
  container.appendChild(btn);
738
- // Trust badge — "Powered by Adhere" already lives in the persistent
739
- // modal footer below; repeating it here just cost vertical space.
848
+ // Plain-language line on what the camera/photo is for and what happens
849
+ // to it — shown before the "Get Started" tap that leads to the first
850
+ // camera permission prompt, so the browser's own prompt isn't the only
851
+ // context the user gets for why a camera is being requested.
740
852
  const trust = document.createElement("div");
741
853
  trust.style.cssText = `
742
- display:flex;align-items:center;gap:6px;
743
- color:${this.theme.textMuted};font-size:10.5px;
854
+ display:flex;align-items:flex-start;gap:6px;text-align:left;
855
+ color:${this.theme.textMuted};font-size:10.5px;line-height:1.5;
744
856
  `;
745
- trust.textContent = "🔒 End-to-end encrypted";
857
+ 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>`;
746
858
  container.appendChild(trust);
747
859
  }
748
860
  // ── Country Select ──────────────────────────────────────────────
@@ -1293,6 +1405,14 @@ class SmartComplyFlow {
1293
1405
  this._renderRetryLimitReached(container);
1294
1406
  return;
1295
1407
  }
1408
+ // OCR reported "done" but extracted nothing usable — do not let the user
1409
+ // click through to liveness with no verified identity data. This is the
1410
+ // ocr_status==="done"-with-empty-fields sibling of the poll timeout case
1411
+ // (which _renderOcrTimeout already catches in renderOcrGate).
1412
+ if (!this._ocrName && !this._ocrDocNumber && !this._ocrExpiry) {
1413
+ this._renderOcrTimeout(container);
1414
+ return;
1415
+ }
1296
1416
  // W13: Pre-warm camera while user reads — but only if the document photo
1297
1417
  // itself came from the camera. Pre-warming here when the user uploaded
1298
1418
  // from gallery means requesting camera access before they've touched
@@ -1432,7 +1552,7 @@ class SmartComplyFlow {
1432
1552
  // end. 45 x 2s = 90s comfortably covers that worst case while the loop
1433
1553
  // still exits immediately once ocrDone is true — the common case
1434
1554
  // (OCR done in a few seconds) is exactly as fast as before.
1435
- const MAX_POLLS = 45; // 45 × 2s = 90s timeout
1555
+ const MAX_POLLS = 45; // 45 x 2s = 90s timeout
1436
1556
  let polls = 0;
1437
1557
  const poll = async () => {
1438
1558
  if (this.isDestroyed)
@@ -1486,13 +1606,13 @@ class SmartComplyFlow {
1486
1606
  this._ocrPollTimer = setTimeout(poll, 2000);
1487
1607
  }
1488
1608
  else if (!this.isDestroyed) {
1489
- // Genuinely exhausted the whole window — proceed anyway with whatever we have
1490
- statusEl.textContent = "Taking longer than usual…";
1491
- subEl.textContent = "We'll continue — you can confirm your details on the next screen.";
1492
- this._ocrPollTimer = setTimeout(() => {
1493
- if (!this.isDestroyed)
1494
- this.showStep("document_confirm");
1495
- }, 2000);
1609
+ // Genuinely exhausted the whole window with nothing usable read —
1610
+ // stop here instead of letting an unverified identity through to
1611
+ // liveness. document_confirm never blocks on missing fields (by
1612
+ // design, so a partial-but-real OCR read still lets the user
1613
+ // continue), so the gate has to live here, before that screen.
1614
+ container.innerHTML = "";
1615
+ this._renderOcrTimeout(container);
1496
1616
  }
1497
1617
  }
1498
1618
  catch {
@@ -1561,6 +1681,35 @@ class SmartComplyFlow {
1561
1681
  });
1562
1682
  container.appendChild(retryBtn);
1563
1683
  }
1684
+ /**
1685
+ * OCR ran to completion but never produced a single usable field within
1686
+ * the poll window — do not let the flow reach liveness with no verified
1687
+ * identity data. Distinct from _renderOcrQualityFail (a bad photo) since
1688
+ * this covers a genuinely stuck/slow OCR pass instead of a rejected image.
1689
+ */
1690
+ _renderOcrTimeout(container) {
1691
+ container.style.cssText += "display:flex;flex-direction:column;align-items:center;text-align:center;gap:14px;padding:20px 0;";
1692
+ const icon = document.createElement("div");
1693
+ icon.style.cssText = `width:72px;height:72px;border-radius:50%;background:${this.theme.errorBg};border:3px solid ${this.theme.error};display:flex;align-items:center;justify-content:center;font-size:28px;`;
1694
+ icon.textContent = "⏱️";
1695
+ container.appendChild(icon);
1696
+ const t = document.createElement("h2");
1697
+ t.style.cssText = `color:${this.theme.text};font-size:${theme_1.TYPE.heading}px;font-weight:700;margin:0;`;
1698
+ t.textContent = "Couldn't Read Your Document";
1699
+ container.appendChild(t);
1700
+ const d = document.createElement("p");
1701
+ d.style.cssText = `color:${this.theme.textSecondary};font-size:13px;margin:0;max-width:280px;line-height:1.6;`;
1702
+ d.textContent = "We weren't able to read any details from your document. Please retake the photo in good lighting, with all four corners visible.";
1703
+ container.appendChild(d);
1704
+ const retryBtn = this.createPrimaryButton("Retake photo");
1705
+ retryBtn.addEventListener("click", () => {
1706
+ this.livenessEntryId = null;
1707
+ this.documentBlob = null;
1708
+ this.documentBackBlob = null;
1709
+ this.showStep("document_capture");
1710
+ });
1711
+ container.appendChild(retryBtn);
1712
+ }
1564
1713
  // ── W11: You're all done (end-user final screen) ─────────────────
1565
1714
  renderDone(container) {
1566
1715
  container.style.cssText += "display:flex;flex-direction:column;align-items:center;text-align:center;gap:16px;padding:24px 0;";
@@ -1819,6 +1968,11 @@ class SmartComplyFlow {
1819
1968
  this._renderRetryLimitReached(container);
1820
1969
  return;
1821
1970
  }
1971
+ // Each render gets its own id so a stale attempt (e.g. abandoned after
1972
+ // the tab was backgrounded and we restarted the scan on return) can't
1973
+ // resolve/reject into the current one — see the visibilitychange
1974
+ // handler in mount(), which re-renders this step on return-to-foreground.
1975
+ const attemptId = ++this._livenessAttemptId;
1822
1976
  const title = this.createStepTitle("Face verification");
1823
1977
  container.appendChild(title);
1824
1978
  const desc = document.createElement("div");
@@ -1867,8 +2021,12 @@ class SmartComplyFlow {
1867
2021
  error: this.theme.error,
1868
2022
  warning: this.theme.warning,
1869
2023
  },
1870
- }, ["BLINK"], this.livenessEntryId || undefined)
2024
+ }, [...constants_1.DEFAULT_CHALLENGE_ACTIONS], this.livenessEntryId || undefined)
1871
2025
  .then((result) => {
2026
+ // A stale attempt (superseded by a restart after the tab was
2027
+ // backgrounded) resolving late must not act on the current screen.
2028
+ if (attemptId !== this._livenessAttemptId)
2029
+ return;
1872
2030
  // W11: After submit, end user is done — show "You're all done" screen.
1873
2031
  // Backend processing continues in background; client receives webhook.
1874
2032
  // Track liveness failures for retry limit (W14).
@@ -1881,8 +2039,18 @@ class SmartComplyFlow {
1881
2039
  this.showStep("done");
1882
2040
  })
1883
2041
  .catch((err) => {
1884
- this._livenessFailures++;
1885
- this.showErrorStep(err.message || "Liveness verification failed. Please try again.");
2042
+ if (attemptId !== this._livenessAttemptId)
2043
+ return;
2044
+ // Only count a genuine verification failure against the retry limit —
2045
+ // a dropped camera, network blip, or upload failure isn't the user's
2046
+ // fault and shouldn't cost them one of their limited attempts. These
2047
+ // are infrastructure-layer errors (thrown/rejected); a real face-
2048
+ // match/quality failure comes back as a resolved result with
2049
+ // status "failed" instead (handled in .then() above).
2050
+ if (!_isInfrastructureError(err)) {
2051
+ this._livenessFailures++;
2052
+ }
2053
+ this.showErrorStep(_userFacingLivenessError(err));
1886
2054
  });
1887
2055
  }
1888
2056
  // ── Error ───────────────────────────────────────────────────────
@@ -2075,9 +2243,14 @@ class SmartComplyFlow {
2075
2243
  const powered = document.createElement("span");
2076
2244
  powered.style.cssText = `font-size:11px;color:${this.theme.textMuted};`;
2077
2245
  powered.textContent = "Powered by ";
2078
- const brand = document.createElement("span");
2079
- brand.style.cssText = `font-size:11px;font-weight:700;color:${this.theme.primary};`;
2246
+ const brand = document.createElement("a");
2247
+ brand.href = "https://adhere.smartcomply.com/";
2248
+ brand.target = "_blank";
2249
+ brand.rel = "noopener noreferrer";
2250
+ brand.style.cssText = `font-size:11px;font-weight:700;color:${this.theme.primary};text-decoration:none;`;
2080
2251
  brand.textContent = "Adhere";
2252
+ brand.addEventListener("mouseenter", () => { brand.style.textDecoration = "underline"; });
2253
+ brand.addEventListener("mouseleave", () => { brand.style.textDecoration = "none"; });
2081
2254
  footer.appendChild(powered);
2082
2255
  footer.appendChild(brand);
2083
2256
  return footer;
@@ -2114,18 +2287,19 @@ class SmartComplyFlow {
2114
2287
  });
2115
2288
  card.addEventListener("click", onClick);
2116
2289
  const textWrap = document.createElement("div");
2290
+ textWrap.style.cssText = "flex:1 1 auto;min-width:0;";
2117
2291
  const titleEl = document.createElement("div");
2118
- titleEl.style.cssText = `color:${this.theme.text};font-size:14px;font-weight:600;`;
2292
+ titleEl.style.cssText = `color:${this.theme.text};font-size:14px;font-weight:600;line-height:1.35;`;
2119
2293
  titleEl.textContent = title;
2120
2294
  textWrap.appendChild(titleEl);
2121
2295
  if (subtitle) {
2122
2296
  const subEl = document.createElement("div");
2123
- subEl.style.cssText = `color:${this.theme.textMuted};font-size:12px;margin-top:2px;`;
2297
+ subEl.style.cssText = `color:${this.theme.textMuted};font-size:12px;line-height:1.4;margin-top:2px;`;
2124
2298
  subEl.textContent = subtitle;
2125
2299
  textWrap.appendChild(subEl);
2126
2300
  }
2127
2301
  const arrow = document.createElement("span");
2128
- arrow.style.cssText = `color:${this.theme.textMuted};font-size:18px;flex-shrink:0;`;
2302
+ arrow.style.cssText = `color:${this.theme.textMuted};font-size:18px;flex-shrink:0;margin-left:12px;line-height:1;`;
2129
2303
  arrow.textContent = "→";
2130
2304
  card.appendChild(textWrap);
2131
2305
  card.appendChild(arrow);
@@ -2198,6 +2372,24 @@ class SmartComplyFlow {
2198
2372
  .flatMap((ch) => ch.fields || []);
2199
2373
  return allFields.some((f) => isUploadFieldType(f.type));
2200
2374
  }
2375
+ /**
2376
+ * The "go back one step" target for a given step, if that step is
2377
+ * navigable — shared by the header back button and the browser
2378
+ * back-button (popstate) handler in mount() so both agree on the same
2379
+ * step order.
2380
+ */
2381
+ _getBackAction(step) {
2382
+ const countries = Object.keys(this.sdkConfig?.channels || {});
2383
+ const backTargets = {
2384
+ id_type: () => this.showStep(countries.length > 1 ? "country" : "welcome"),
2385
+ country: () => this.showStep("welcome"),
2386
+ id_input: () => this.showStep("id_type"),
2387
+ confirm_identity: () => this.showStep("id_input"),
2388
+ document_capture: () => { this.docCapture?.destroy(); this.showStep("id_type"); },
2389
+ document_confirm: () => { this.docCapture?.destroy(); this.showStep("document_capture"); },
2390
+ };
2391
+ return backTargets[step];
2392
+ }
2201
2393
  updateProgress() {
2202
2394
  const isDocFlow = this.isDocumentFlow();
2203
2395
  const countries = Object.keys(this.sdkConfig?.channels || {});
@@ -2249,15 +2441,7 @@ class SmartComplyFlow {
2249
2441
  }
2250
2442
  // Back button — show for navigable steps only
2251
2443
  if (this.headerBackBtn) {
2252
- const backTargets = {
2253
- id_type: () => this.showStep(countries.length > 1 ? "country" : "welcome"),
2254
- country: () => this.showStep("welcome"),
2255
- id_input: () => this.showStep("id_type"),
2256
- confirm_identity: () => this.showStep("id_input"),
2257
- document_capture: () => { this.docCapture?.destroy(); this.showStep("id_type"); },
2258
- document_confirm: () => { this.docCapture?.destroy(); this.showStep("document_capture"); },
2259
- };
2260
- const backAction = backTargets[this.currentStep];
2444
+ const backAction = this._getBackAction(this.currentStep);
2261
2445
  if (backAction) {
2262
2446
  this.headerBackBtn.style.display = "flex";
2263
2447
  const newBtn = this.headerBackBtn.cloneNode(true);